Преглед на файлове

feat: 启用服务联动挂载(状态开关拨向"启用") + 停用服务联动卸载(状态开关拨向"停用")

lipan преди 1 седмица
родител
ревизия
a7f916494d

+ 2 - 0
go.mod

@@ -4,8 +4,10 @@ go 1.25.10
 
 require (
 	github.com/google/uuid v1.6.0
+	github.com/mattheath/base62 v0.0.0-20150408093626-b80cdc656a7a
 	github.com/minio/minio-go/v7 v7.0.93
 	github.com/robfig/cron/v3 v3.0.1
+	github.com/segmentio/ksuid v1.0.4
 	github.com/tjfoc/gmsm v1.4.1
 	github.com/zeromicro/go-zero v1.10.3
 	go.uber.org/zap v1.27.0

+ 4 - 0
go.sum

@@ -75,6 +75,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mattheath/base62 v0.0.0-20150408093626-b80cdc656a7a h1:rnrxZue85aKdMU4nJ50GgKA31lCaVbft+7Xl8OXj55U=
+github.com/mattheath/base62 v0.0.0-20150408093626-b80cdc656a7a/go.mod h1:hJJYoBMTZIONmUEpX3+9v2057zuRM0n3n77U4Ob4wE4=
 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -114,6 +116,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
+github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=

+ 12 - 0
internal/handler/routes.go

@@ -111,6 +111,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 				Path:    "/services/:id/brush",
 				Handler: BrushServiceHandler(serverCtx),
 			},
+			{
+				// 启用服务联动挂载(状态开关拨向"启用")
+				Method:  http.MethodPost,
+				Path:    "/services/:id/mount",
+				Handler: MountServiceHandler(serverCtx),
+			},
+			{
+				// 停用服务联动卸载(状态开关拨向"停用")
+				Method:  http.MethodPost,
+				Path:    "/services/:id/unmount",
+				Handler: UnmountServiceHandler(serverCtx),
+			},
 		},
 		rest.WithPrefix("/api/v1"),
 	)

+ 27 - 3
internal/logic/createservicelogic.go

@@ -45,12 +45,18 @@ func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.
 		svcModel := l.svcCtx.ServiceModel.WithSession(session)
 		dirModel := l.svcCtx.DirectoryModel.WithSession(session)
 
+		// 从接入标识获取到秘钥
+		apiSecret, err := l.svcCtx.ApplicationCredModel.FindByApiKey(l.ctx, req.Iden)
+		fmt.Println("从接入标识获取到秘钥:", apiSecret)
+		if err != nil {
+			return fmt.Errorf("接入应用标识不存在: %w", err)
+		}
 		// 1. 插服务
 		svcData := &model.TService{
 			Name:        nullString(req.Name),
 			Ip:          nullString(req.Ip),
 			Port:        req.Port, // 端口 int 类型,int64 直传(0=未配置)
-			Iden:        nullString(req.Iden),
+			Iden:        nullString(apiSecret),
 			KeyIden:     nullString(req.KeyIden),
 			KeyAlgo:     nullString(req.KeyAlgo),
 			Status:      nullString(req.Status),
@@ -109,21 +115,39 @@ func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.
 	}
 
 	// 5. 挂载结果落库 + 响应回显:
-	//    成功 → mount_status=mounted;失败 → mount_status=failed(同时带 mountWarning 黄条)。
+	//    新增选"停用" → 只登记不挂载:mount_status=unmounted、status=停用
+	//                   (与列表开关"停用=不挂载"语义对称,后续拨向启用再挂载)。
+	//    新增选"启用" → 创建后挂载,状态与真实挂载结果对齐:
+	//                   成功 → mount_status=mounted、status=启用;
+	//                   失败 → mount_status=failed(同时带 mountWarning 黄条)、status=停用。
 	//    状态落库失败只记日志,不影响主流程(服务创建本身已成功)。
 	//    注:「没配置保护目录」目前也走 failed 分支,语义细分留给后续迭代。
-	if err := l.notifyFuseMount(created, dirList); err != nil {
+	if req.Status == "停用" {
+		resp.MountStatus = "unmounted"
+		resp.Status = "停用"
+		if uerr := l.svcCtx.ServiceModel.UpdateMountStatus(l.ctx, svcId, "unmounted"); uerr != nil {
+			l.Errorf("update mount_status=unmounted failed(id=%d): %v", svcId, uerr)
+		}
+	} else if err := l.notifyFuseMount(created, dirList); err != nil {
 		l.Errorf("service created(id=%d), but fuse mount notify failed: %v", svcId, err)
 		warnings = append(warnings, fmt.Sprintf("服务已创建,但通知外挂程序挂载失败: %v", err))
 		resp.MountStatus = "failed"
 		if uerr := l.svcCtx.ServiceModel.UpdateMountStatus(l.ctx, svcId, "failed"); uerr != nil {
 			l.Errorf("update mount_status=failed failed(id=%d): %v", svcId, uerr)
 		}
+		if uerr := l.svcCtx.ServiceModel.UpdateStatus(l.ctx, svcId, "停用"); uerr != nil {
+			l.Errorf("update status=停用 failed(id=%d): %v", svcId, uerr)
+		}
+		resp.Status = "停用"
 	} else {
 		resp.MountStatus = "mounted"
 		if uerr := l.svcCtx.ServiceModel.UpdateMountStatus(l.ctx, svcId, "mounted"); uerr != nil {
 			l.Errorf("update mount_status=mounted failed(id=%d): %v", svcId, uerr)
 		}
+		if uerr := l.svcCtx.ServiceModel.UpdateStatus(l.ctx, svcId, "启用"); uerr != nil {
+			l.Errorf("update status=启用 failed(id=%d): %v", svcId, uerr)
+		}
+		resp.Status = "启用"
 	}
 	if len(warnings) > 0 {
 		// strings.Join 把刷库/挂载的失败信息拼成一条,分号分隔,前端一条黄条展示

+ 25 - 0
internal/logic/helpers.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"strings"
 	"time"
 
 	"udeServer/internal/apierr"
@@ -121,3 +122,27 @@ func toServiceResp(data *model.TService) *types.ServiceResp {
 		CreateAt:    timeValue(data.CreateAt),
 	}
 }
+
+func toListServiceResp(data *model.TService) *types.ServiceResp {
+	return &types.ServiceResp{
+		Id:          data.Id,
+		Name:        stringValue(data.Name),
+		Ip:          stringValue(data.Ip),
+		Port:        data.Port,
+		Iden:        maskSensitive(stringValue(data.Iden)),
+		KeyIden:     stringValue(data.KeyIden),
+		KeyAlgo:     stringValue(data.KeyAlgo),
+		Status:      stringValue(data.Status),
+		MountStatus: stringValue(data.MountStatus),
+		Brush:       data.Brush,
+		CreateAt:    timeValue(data.CreateAt),
+	}
+}
+
+// maskSensitive 掩码展示:前 4 后 4 明文,中间打星;长度不大于 8 时原样返回。
+func maskSensitive(value string) string {
+	if len(value) <= 8 {
+		return value
+	}
+	return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
+}

+ 1 - 1
internal/logic/listserviceslogic.go

@@ -40,7 +40,7 @@ func (l *ListServicesLogic) ListServices(req *types.ListServiceReq) (*types.Serv
 		Total: total,
 	}
 	for i := range list {
-		resp.List = append(resp.List, *toServiceResp(&list[i]))
+		resp.List = append(resp.List, *toListServiceResp(&list[i]))
 	}
 
 	return resp, nil

+ 137 - 0
internal/logic/mountservicelogic.go

@@ -0,0 +1,137 @@
+// udeServer: 启用服务(状态开关拨向"启用") —— 逐保护目录调外挂程序 /api/fuseMount 挂载。
+// 成功后 mount_status=mounted、status=启用;任一目录挂载失败则 mount_status=failed,
+// 失败信息聚合进 mountWarning 由前端弹黄色警告(软处理,不阻塞状态变更)。
+
+package logic
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"path"
+	"strings"
+
+	"udeServer/internal/audit"
+	"udeServer/internal/svc"
+	"udeServer/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type MountServiceLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewMountServiceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MountServiceLogic {
+	return &MountServiceLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// MountService 请求体与手动补刷同款:路径参数 id(types.ServiceIdReq)。
+// 挂载是幂等的:外挂注册表里"已挂且同密钥"的目录直接跳过,重复点启用零成本。
+func (l *MountServiceLogic) MountService(req *types.ServiceIdReq) (*types.ServiceResp, error) {
+	if err := checkId(req.Id); err != nil {
+		return nil, err
+	}
+	svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
+		return nil, err
+	}
+	ip, port := svc.Ip.String, svc.Port // port 已是 int64
+	if ip == "" || port == 0 {
+		return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
+	}
+	// 密钥内容:按服务配置的密钥标识(keyIden)查 t_key,与创建流程 notifyFuseMount 同源
+	keyIden := svc.KeyIden.String
+	if keyIden == "" {
+		return nil, fmt.Errorf("服务未配置密钥标识(keyIden),无法下发挂载")
+	}
+	key, err := l.svcCtx.KeyModel.FindOneByIden(l.ctx, keyIden)
+	if err != nil {
+		return nil, fmt.Errorf("查询密钥(%s)失败: %w", keyIden, err)
+	}
+	ikey := key.Content.String
+	if ikey == "" {
+		return nil, fmt.Errorf("密钥(%s)内容为空,无法下发挂载", keyIden)
+	}
+	dirs, err := l.svcCtx.DirectoryModel.FindDirsByServiceId(l.ctx, req.Id)
+	if err != nil {
+		return nil, fmt.Errorf("查询保护目录失败: %w", err)
+	}
+
+	// 逐目录挂载:audit.DoSimple 记 type=2 审计日志,
+	// doFuseRequest 注入 X-TOKEN=iden(外挂侧与环境变量 TOKEN 恒定时间比对)
+	var msgs []string
+	for _, mount := range dirs {
+		// path.Clean(非 filepath!):发给远程 Linux 外挂必须保持 / 分隔符
+		dirLinuxPath := path.Clean(mount)
+		payload := map[string]interface{}{
+			"backing": dirLinuxPath + "-cipher", // 密文目录 = 保护目录 + "-cipher" 后缀
+			"mount":   dirLinuxPath,             // 挂载点 = 保护目录本身
+			"ikey":    ikey,
+		}
+		body, err := json.Marshal(payload)
+		if err != nil {
+			msgs = append(msgs, fmt.Sprintf("序列化挂载请求失败: %v", err))
+			continue
+		}
+		baseURL := fmt.Sprintf("http://%s:%d/api/fuseMount", ip, port) // %d:port 是 int64
+		if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseMount", baseURL, body,
+			func() (map[string]interface{}, int64, error) {
+				resp, rerr := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
+				if rerr != nil {
+					return nil, 0, fmt.Errorf("挂载目录 %s 失败(外挂程序未启动?): %w", dirLinuxPath, rerr)
+				}
+				raw, _ := io.ReadAll(resp.Body) // 读到 EOF 保证 TCP 连接复用 Keep-Alive
+				resp.Body.Close()
+				status := int64(resp.StatusCode)
+				if status >= http.StatusBadRequest {
+					return nil, status, fmt.Errorf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", dirLinuxPath, status, string(raw))
+				}
+				return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+			}); callErr != nil {
+			msgs = append(msgs, callErr.Error())
+			continue
+		}
+		l.Infof("fuse mount ok: %s -> %s", dirLinuxPath, dirLinuxPath+"-cipher")
+	}
+
+	// 挂载结果落库:全成功=mounted;任一失败=failed;没配保护目录=unmounted(无挂载可言)。
+	// 启用状态与挂载结果挂钩:挂载成功才启用;任一失败(整体 failed)则保持停用,
+	// 前端按响应里的 mountStatus/status 把开关弹回"停用"位。落库失败只记日志,不影响主流程。
+	mountStatus := "mounted"
+	if len(dirs) == 0 {
+		mountStatus = "unmounted"
+	}
+	if len(msgs) > 0 {
+		mountStatus = "failed"
+	}
+	finalStatus := "启用"
+	if mountStatus == "failed" {
+		finalStatus = "停用"
+	}
+	if uerr := l.svcCtx.ServiceModel.UpdateMountStatus(l.ctx, req.Id, mountStatus); uerr != nil {
+		l.Errorf("update mount_status=%s failed(id=%d): %v", mountStatus, req.Id, uerr)
+	}
+	if uerr := l.svcCtx.ServiceModel.UpdateStatus(l.ctx, req.Id, finalStatus); uerr != nil {
+		l.Errorf("update status=%s failed(id=%d): %v", finalStatus, req.Id, uerr)
+	}
+
+	updated, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
+		return nil, err
+	}
+	resp := toServiceResp(updated)
+	if len(msgs) > 0 {
+		resp.MountWarning = strings.Join(msgs, "; ")
+	}
+	return resp, nil
+}

+ 121 - 0
internal/logic/unmountservicelogic.go

@@ -0,0 +1,121 @@
+// udeServer: 停用服务(状态开关拨向"停用") —— 逐保护目录调外挂程序 /api/fuseUnMount 卸载。
+// 成功后 mount_status=unmounted、status=停用;任一目录卸载失败则 mount_status=failed,
+// 失败信息聚合进 mountWarning 由前端弹黄色警告(软处理,不阻塞状态变更)。
+
+package logic
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+
+	"udeServer/internal/audit"
+	"udeServer/internal/svc"
+	"udeServer/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type UnmountServiceLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewUnmountServiceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UnmountServiceLogic {
+	return &UnmountServiceLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// UnmountService 请求体与手动补刷同款:路径参数 id(types.ServiceIdReq)。
+// 卸载接口幂等:挂载点不在外挂注册表中也返回成功,重复点停用零成本。
+func (l *UnmountServiceLogic) UnmountService(req *types.ServiceIdReq) (*types.ServiceResp, error) {
+	if err := checkId(req.Id); err != nil {
+		return nil, err
+	}
+	svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
+		return nil, err
+	}
+	dirs, err := l.svcCtx.DirectoryModel.FindDirsByServiceId(l.ctx, req.Id)
+	if err != nil {
+		return nil, fmt.Errorf("查询保护目录失败: %w", err)
+	}
+
+	// 只有挂载状态是 mounted 才真正调外挂卸载(与删除服务的前置判断同款):
+	// unmounted/failed 直接跳过,不去外挂机器白跑一趟。
+	var msgs []string
+	if svc.MountStatus.String == "mounted" {
+		ip, port := svc.Ip.String, svc.Port // port 已是 int64
+		if ip == "" || port == 0 {
+			msgs = append(msgs, "外挂服务 IP/端口为空,无法联动卸载,目录可能仍处于挂载状态")
+		} else {
+			for _, mount := range dirs {
+				// path.Clean(非 filepath!):发给远程 Linux 外挂必须保持 / 分隔符
+				cleanMount := path.Clean(mount)
+				baseURL := fmt.Sprintf("http://%s:%d/api/fuseUnMount", ip, port) // %d:port 是 int64
+				reqURL, uerr := url.Parse(baseURL)
+				if uerr != nil {
+					msgs = append(msgs, fmt.Sprintf("解析 URL 失败: %v", uerr))
+					continue
+				}
+				// q.Encode() 自动做 URL 编码(/ → %2F),外挂侧 Query().Get 自动解码还原
+				q := reqURL.Query()
+				q.Set("mount", cleanMount)
+				reqURL.RawQuery = q.Encode()
+				finalURL := reqURL.String()
+				// DoSimple 记 type=2 审计日志(GET 用 url 描述请求原文),
+				// doFuseRequest 注入 X-TOKEN=iden(外挂侧与环境变量 TOKEN 恒定时间比对)
+				if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseUnMount", finalURL,
+					audit.BuildRemoteGetRequest(http.MethodGet, finalURL),
+					func() (map[string]interface{}, int64, error) {
+						resp, rerr := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodGet, finalURL, nil, "")
+						if rerr != nil {
+							return nil, 0, fmt.Errorf("卸载目录 %s 失败(外挂程序未启动?): %w", cleanMount, rerr)
+						}
+						raw, _ := io.ReadAll(resp.Body) // 读到 EOF 保证 TCP 连接复用 Keep-Alive
+						resp.Body.Close()
+						status := int64(resp.StatusCode)
+						if status >= http.StatusBadRequest {
+							return nil, status, fmt.Errorf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, status, string(raw))
+						}
+						return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+					}); callErr != nil {
+					msgs = append(msgs, callErr.Error())
+					continue
+				}
+				l.Infof("fuse unmount ok: %s", cleanMount)
+			}
+		}
+	}
+
+	// 卸载结果落库:全成功(或本来就没挂载)=unmounted;任一失败=failed。
+	// 同时把服务启用状态置"停用"。落库失败只记日志,不影响主流程。
+	mountStatus := "unmounted"
+	if len(msgs) > 0 {
+		mountStatus = "failed"
+	}
+	if uerr := l.svcCtx.ServiceModel.UpdateMountStatus(l.ctx, req.Id, mountStatus); uerr != nil {
+		l.Errorf("update mount_status=%s failed(id=%d): %v", mountStatus, req.Id, uerr)
+	}
+	if uerr := l.svcCtx.ServiceModel.UpdateStatus(l.ctx, req.Id, "停用"); uerr != nil {
+		l.Errorf("update status=停用 failed(id=%d): %v", req.Id, uerr)
+	}
+
+	updated, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
+		return nil, err
+	}
+	resp := toServiceResp(updated)
+	if len(msgs) > 0 {
+		resp.MountWarning = strings.Join(msgs, "; ")
+	}
+	return resp, nil
+}

+ 15 - 0
internal/model/tapplicationcredentialsmodel.go

@@ -31,6 +31,7 @@ type (
 	TApplicationCredentialsModel interface {
 		Insert(ctx context.Context, data *TApplicationCredentials) (sql.Result, error)
 		FindOne(ctx context.Context, id int64) (*TApplicationCredentials, error)
+		FindByApiKey(ctx context.Context, apiKey string) (string, error)
 		ExistsByName(ctx context.Context, name string) (bool, error)
 		Update(ctx context.Context, data *TApplicationCredentials) error
 		Delete(ctx context.Context, id int64) error
@@ -84,6 +85,20 @@ func (m *defaultTApplicationCredentialsModel) FindOne(ctx context.Context, id in
 	}
 }
 
+func (m *defaultTApplicationCredentialsModel) FindByApiKey(ctx context.Context, apiKey string) (string, error) {
+	query := fmt.Sprintf("select `api_secret` from %s where `api_key` = ? limit 1", m.table)
+	var apiSecret string
+	err := m.conn.QueryRowCtx(ctx, &apiSecret, query, apiKey)
+	switch err {
+	case nil:
+		return apiSecret, nil
+	case sqlx.ErrNotFound:
+		return "", ErrNotFound
+	default:
+		return "", err
+	}
+}
+
 // ExistsByName reports whether a credential with the same name exists.
 func (m *defaultTApplicationCredentialsModel) ExistsByName(ctx context.Context, name string) (bool, error) {
 	query := fmt.Sprintf("select count(1) from %s where `name` = ?", m.table)

+ 9 - 0
internal/model/tservicemodel.go

@@ -16,6 +16,7 @@ type (
 		tServiceModel
 		FindPage(ctx context.Context, page, pageSize int64) ([]TService, int64, error)
 		UpdateMountStatus(ctx context.Context, id int64, status string) error
+		UpdateStatus(ctx context.Context, id int64, status string) error
 		WithSession(session sqlx.Session) TServiceModel
 	}
 
@@ -59,6 +60,14 @@ func (m *customTServiceModel) UpdateMountStatus(ctx context.Context, id int64, s
 	return err
 }
 
+// UpdateStatus 只更新服务启用状态(启用/停用)。挂载/卸载联动接口专用:
+// 不走全字段 Update,避免并发时把 brush(一键刷库)等配置覆盖成零值。
+func (m *customTServiceModel) UpdateStatus(ctx context.Context, id int64, status string) error {
+	query := fmt.Sprintf("update %s set `status` = ? where `id` = ?", m.table)
+	_, err := m.conn.ExecCtx(ctx, query, status, id)
+	return err
+}
+
 func (m *customTServiceModel) WithSession(session sqlx.Session) TServiceModel {
 	return NewTServiceModel(sqlx.NewSqlConnFromSession(session))
 }

+ 4 - 4
internal/types/types.go

@@ -150,10 +150,10 @@ type KeyResp struct {
 }
 
 type ListDirChildrenReq struct {
-	Ip   string `form:"ip"`   // 目标 Linux 主机 IP
-	Port string `form:"port"` // 目标主机 /api/v1 端口
-	Path string `form:"path"` // 空或 "/" 表示根目录
-	Iden string `form:"iden"` // 接入标识:转发外挂时作为 X-TOKEN 头的值
+	Ip   string `form:"ip"`            // 目标 Linux 主机 IP
+	Port string `form:"port"`          // 目标主机 /api/v1 端口
+	Path string `form:"path"`          // 空或 "/" 表示根目录
+	Iden string `form:"iden,optional"` // 接入标识:转发外挂时作为 X-TOKEN 头的值;外挂 /path/lists 已免鉴权,未选接入标识(空值)也放行
 }
 
 type ListDirectoryReq struct {

+ 9 - 1
udeServer.api

@@ -153,7 +153,7 @@ type ListDirChildrenReq {
 	Ip   string `form:"ip"` // 目标 Linux 主机 IP
 	Port string `form:"port"` // 目标主机 /api/v1 端口
 	Path string `form:"path"` // 空或 "/" 表示根目录
-	Iden string `form:"iden"` // 接入标识:转发外挂时作为 X-TOKEN 头的值
+	Iden string `form:"iden,optional"` // 接入标识:转发外挂时作为 X-TOKEN 头的值;外挂 /path/lists 已免鉴权,未选接入标识(空值)也放行
 }
 
 type LocalDirNode {
@@ -435,6 +435,14 @@ service udeServer-api {
 	@handler BrushServiceHandler
 	post /services/:id/brush (ServiceIdReq) returns (DeleteResp)
 
+	// 启用服务联动挂载:逐保护目录调外挂 /api/fuseMount,成功后 status=启用、mount_status=mounted
+	@handler MountServiceHandler
+	post /services/:id/mount (ServiceIdReq) returns (ServiceResp)
+
+	// 停用服务联动卸载:逐保护目录调外挂 /api/fuseUnMount,成功后 status=停用、mount_status=unmounted
+	@handler UnmountServiceHandler
+	post /services/:id/unmount (ServiceIdReq) returns (ServiceResp)
+
 	@handler ListDirChildrenHandler
 	get /dirs/children (ListDirChildrenReq) returns (DirChildrenResp)
 }