2 Commit-ok e6dc3ddf48 ... ebec9898bc

Szerző SHA1 Üzenet Dátum
  lipan ebec9898bc Merge remote-tracking branch 'origin/main' 1 hete
  lipan 70e0873c6e feat:已实现挂载、卸载功能 + 数据库字段规范 + 拉取到挂载服务的目录及完善前端校验 1 hete

+ 105 - 35
internal/logic/createservicelogic.go

@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"path"
 	"strings"
 	"time"
 
@@ -45,13 +46,15 @@ func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.
 
 		// 1. 插服务
 		svcData := &model.TService{
-			Name:    nullString(req.Name),
-			Ip:      nullString(req.Ip),
-			Port:    nullString(req.Port),
-			Iden:    nullString(req.Iden),
-			KeyIden: nullString(req.KeyIden),
-			KeyAlgo: nullString(req.KeyAlgo),
-			Status:  nullString(req.Status),
+			Name:        nullString(req.Name),
+			Ip:          nullString(req.Ip),
+			Port:        req.Port, // 端口 int 类型,int64 直传(0=未配置)
+			Iden:        nullString(req.Iden),
+			KeyIden:     nullString(req.KeyIden),
+			KeyAlgo:     nullString(req.KeyAlgo),
+			Status:      nullString(req.Status),
+			MountStatus: nullString("unmounted"), // 挂载状态初始值:未挂载(表列 NOT NULL,必须显式传值,否则 NULL 会报错)
+			Brush:       req.Brush,               // 一键刷库配置原样落库(0=否默认,1=创建后自动下发刷库)
 		}
 		svcRes, err := svcModel.Insert(l.ctx, svcData)
 		if err != nil {
@@ -89,57 +92,124 @@ func (l *CreateServiceLogic) CreateService(req *types.CreateServiceReq) (*types.
 	if err != nil {
 		return nil, err
 	}
-
 	// 3. 落库成功后,通知外挂程序(req.Ip:req.Port)执行 fuse 挂载。
-	//    外挂程序未启动/网络不通时挂载会失败:服务数据已落库不回滚,仅把错误抛给前端提示。
+	//    挂载失败不阻塞服务创建:响应体带 mountWarning,前端弹黄色警告而非红色错误。
+	resp := toServiceResp(created)
+	// 4. 一键刷库(brush=1):挂载【之前】先下发。
+	//    刷库语义 = 外挂把保护目录存量明文加密进 <目录>-cipher 并清空原明文,
+	//    目录变空后才能通过外挂的空目录挂载校验(先刷后挂的编排顺序)。
+	//    刷库失败软处理:聚合进警告后继续走挂载(空目录场景挂载不受影响)。
+	var warnings []string
+	if req.Brush == 1 {
+		if err := l.notifyFuseBrush(created, dirList); err != nil {
+			l.Errorf("service created(id=%d), but brush dispatch failed: %v", svcId, err)
+			warnings = append(warnings, fmt.Sprintf("一键刷库: %v", err))
+		}
+	}
+
+	// 5. 挂载结果落库 + 响应回显:
+	//    成功 → mount_status=mounted;失败 → mount_status=failed(同时带 mountWarning 黄条)。
+	//    状态落库失败只记日志,不影响主流程(服务创建本身已成功)。
+	//    注:「没配置保护目录」目前也走 failed 分支,语义细分留给后续迭代。
 	if err := l.notifyFuseMount(created, dirList); err != nil {
 		l.Errorf("service created(id=%d), but fuse mount notify failed: %v", svcId, err)
-		return toServiceResp(created), fmt.Errorf("服务已创建,但通知外挂程序挂载失败: %v", 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)
+		}
+	} 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 len(warnings) > 0 {
+		// strings.Join 把刷库/挂载的失败信息拼成一条,分号分隔,前端一条黄条展示
+		resp.MountWarning = strings.Join(warnings, "; ")
+	}
+
+	return resp, nil
+}
 
-	return toServiceResp(created), nil
+// notifyFuseBrush 下发一键刷库到外挂程序(svc.Ip:svc.Port)。
+// 刷库语义:外挂读取保护目录存量明文 → 按服务密钥加密写入 <保护目录>-cipher
+// → 清空保护目录原明文(数据已转移到密文目录),为挂载的空目录校验扫清障碍。
+// 调用时机:创建流程中、挂载之前(目录非空时挂载会被空目录校验拒绝)。
+// TODO: 外挂程序侧的刷库接口(建议 POST /api/brush,请求体 {mount,ikey} 与挂载同源)
+// 开发完成后,在此填入真实下发实现(HTTP 客户端复用包级 fuseMountClient)。
+// 当前返回固定 error,由调用方软处理成黄色警告,绝不阻塞服务创建。
+func (l *CreateServiceLogic) notifyFuseBrush(svc *model.TService, dirs []string) error {
+	if len(dirs) == 0 {
+		return nil // 没配保护目录,无库可刷
+	}
+	if svc.Ip.String == "" || svc.Port == 0 { // port 已是 int64,0 视为未配置
+		return fmt.Errorf("外挂服务 IP/端口为空,无法下发刷库")
+	}
+	// TODO: 逐目录调用外挂程序刷库接口(与 notifyFuseMount 同款循环模式)
+	return fmt.Errorf("外挂程序刷库接口尚未实装,本轮未执行刷库(存量数据场景挂载会被空目录校验拒绝)")
 }
 
 // fuseMountClient 复用的 HTTP 客户端,10s 超时防止外挂程序无响应拖死创建接口
 var fuseMountClient = &http.Client{Timeout: 10 * time.Second}
 
 // notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口,
-// 由外挂程序对保护目录执行 fuse 挂载。请求体为 JSON:
+// 由外挂程序对保护目录执行 fuse 挂载。一个目录一次调用,请求体:
 //
 //	POST http://{ip}:{port}/api/fuseMount
-//	{ "iden": "...", "keyIden": "...", "keyAlgo": "...", "directories": ["/data/fuse", ...] }
+//	{ "backing": "<保护目录>-cipher", "mount": "<保护目录>", "ikey": "<密钥内容>" }
+//
+// ikey 取自 t_key.content(按服务配置的密钥标识 keyIden 查询)。
 func (l *CreateServiceLogic) notifyFuseMount(svc *model.TService, dirs []string) error {
-	ip, port := svc.Ip.String, svc.Port.String
-	if ip == "" || port == "" {
-		// 没配外挂服务地址:只落库,跳过挂载通知
-		l.Infof("service %d has empty ip/port, skip fuse mount notify", svc.Id)
-		return nil
+	ip, port := svc.Ip.String, svc.Port // port 已是 int64
+	if ip == "" || port == 0 {
+		return fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
 	}
 	if len(dirs) == 0 {
-		return nil // 没有保护目录,无需通知挂载
+		return fmt.Errorf("没有配置保护目录,无需通知挂载")
 	}
 
-	url := fmt.Sprintf("http://%s:%s/api/fuseMount", ip, port)
-	payload := map[string]interface{}{
-		"iden":        svc.Iden.String,
-		"keyIden":     svc.KeyIden.String,
-		"keyAlgo":     svc.KeyAlgo.String,
-		"directories": dirs,
+	// 密钥内容:按服务配置的密钥标识(keyIden)查 t_key
+	keyIden := svc.KeyIden.String
+	if keyIden == "" {
+		return fmt.Errorf("服务未配置密钥标识(keyIden),无法下发挂载")
 	}
-	body, err := json.Marshal(payload)
+	key, err := l.svcCtx.KeyModel.FindOneByIden(l.ctx, keyIden)
 	if err != nil {
-		return fmt.Errorf("序列化挂载请求失败: %w", err)
+		return fmt.Errorf("查询密钥(%s)失败: %w", keyIden, err)
 	}
-
-	resp, err := fuseMountClient.Post(url, "application/json", bytes.NewReader(body))
-	if err != nil {
-		return fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", url, err)
+	ikey := key.Content.String
+	if ikey == "" {
+		return fmt.Errorf("密钥(%s)内容为空,无法下发挂载", keyIden)
 	}
-	defer resp.Body.Close()
 
-	if resp.StatusCode >= http.StatusBadRequest {
-		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
-		return fmt.Errorf("外挂程序返回 HTTP %d: %s", resp.StatusCode, string(snippet))
+	baseURL := fmt.Sprintf("http://%s:%d/api/fuseMount", ip, port) // %d:port 是 int64,数字占位符
+	for _, mount := range dirs {
+		dirLinuxPath := path.Clean(mount)
+		cipherDir := dirLinuxPath + "-cipher"
+		payload := map[string]interface{}{
+			"backing": cipherDir,    // 密文存储目录
+			"mount":   dirLinuxPath, // 挂载点
+			"ikey":    ikey,
+		}
+		body, err := json.Marshal(payload)
+		if err != nil {
+			return fmt.Errorf("序列化挂载请求失败: %w", err)
+		}
+
+		resp, err := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+		if err != nil {
+			return fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", baseURL, err)
+		}
+		if resp.StatusCode >= http.StatusBadRequest {
+			snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+			resp.Body.Close()
+			return fmt.Errorf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", mount, resp.StatusCode, string(snippet))
+		}
+		// 2. 成功处理分支:必须将剩余的 body 数据读入黑洞 (io.Discard),以保证底层 TCP 连接池可以复用 Keep-Alive
+		_, _ = io.Copy(io.Discard, resp.Body)
+		resp.Body.Close()
+		l.Infof("fuse mount ok: %s -> %s", mount, mount+"-cipher")
 	}
 	return nil
 }

+ 71 - 3
internal/logic/deleteservicelogic.go

@@ -6,7 +6,13 @@ package logic
 import (
 	"context"
 	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
 
+	"udeServer/internal/model"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
 
@@ -32,11 +38,17 @@ func (l *DeleteServiceLogic) DeleteService(req *types.ServiceIdReq) (*types.Dele
 	if err := checkId(req.Id); err != nil {
 		return nil, err
 	}
-	if _, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id); err != nil {
+	// 先查出服务实体:删库前要拿 ip/port 去通知外挂程序卸载,删了就查不到了
+	svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
 		return nil, err
 	}
 
-	err := l.svcCtx.DB.Transact(func(session sqlx.Session) error {
+	// P1 联动卸载:先卸载后删库。卸载失败软处理(不阻塞删除,外挂机器可能已下线),
+	// 聚合成 unmountWarning 由前端弹黄色警告。
+	unmountWarning := l.notifyFuseUnmount(svc)
+
+	err = l.svcCtx.DB.Transact(func(session sqlx.Session) error {
 		dirModel := l.svcCtx.DirectoryModel.WithSession(session)
 		svcModel := l.svcCtx.ServiceModel.WithSession(session)
 
@@ -53,5 +65,61 @@ func (l *DeleteServiceLogic) DeleteService(req *types.ServiceIdReq) (*types.Dele
 		return nil, err
 	}
 
-	return &types.DeleteResp{Ok: true}, nil
+	resp := &types.DeleteResp{Ok: true}
+	if unmountWarning != "" {
+		resp.UnmountWarning = unmountWarning
+	}
+	return resp, nil
+}
+
+// notifyFuseUnmount 删库前回调外挂程序(svc.Ip:svc.Port)的 GET /api/fuseUnMount 接口,
+// 逐目录卸载该服务全部 FUSE 挂载。
+// 软处理:单个目录失败只记录并聚合,绝不阻塞删除流程(外挂机器可能已关机,
+// 服务记录必须还能删)。返回空串 = 全部卸载成功或无需卸载。
+func (l *DeleteServiceLogic) notifyFuseUnmount(svc *model.TService) string {
+	ip, port := svc.Ip.String, svc.Port // port 已是 int64
+	if ip == "" || port == 0 {
+		return "外挂服务 IP/端口为空,无法联动卸载,目录可能仍处于挂载状态"
+	}
+	dirs, err := l.svcCtx.DirectoryModel.FindDirsByServiceId(l.ctx, svc.Id)
+	if err != nil {
+		return fmt.Sprintf("查询保护目录失败,无法联动卸载: %v", err)
+	}
+	if len(dirs) == 0 {
+		return "" // 没配保护目录,无需卸载
+	}
+	baseURL := fmt.Sprintf("http://%s:%d/api/fuseUnMount", ip, port) // %d:port 是 int64
+	var msgs []string
+	for _, mount := range dirs {
+		// path.Clean(非 filepath!):该路径发给远程 Linux 外挂程序,必须保持 / 分隔符
+		cleanMount := path.Clean(mount)
+		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()
+		l.Infof("fuse unmount forward: GET %s", reqURL.String()) // 打出转发 URL 便于排查
+		resp, rerr := fuseMountClient.Get(reqURL.String())       // 复用挂载逻辑里的 HTTP 客户端(同包变量)
+		if rerr != nil {
+			msgs = append(msgs, fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, rerr))
+			continue
+		}
+		if resp.StatusCode >= http.StatusBadRequest {
+			snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) // 带出外挂错误内容(前 256 字节)
+			resp.Body.Close()
+			msgs = append(msgs, fmt.Sprintf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, resp.StatusCode, string(snippet)))
+			continue
+		}
+		_, _ = io.Copy(io.Discard, resp.Body) // 读入"黑洞"保证 TCP 连接复用 Keep-Alive
+		resp.Body.Close()
+		l.Infof("fuse unmount ok: %s", cleanMount)
+	}
+	if len(msgs) == 0 {
+		return ""
+	}
+	return "服务已删除,但卸载挂载失败: " + strings.Join(msgs, "; ")
 }

+ 11 - 9
internal/logic/helpers.go

@@ -108,14 +108,16 @@ func toKeyResp(data *model.TKey) *types.KeyResp {
 
 func toServiceResp(data *model.TService) *types.ServiceResp {
 	return &types.ServiceResp{
-		Id:       data.Id,
-		Name:     stringValue(data.Name),
-		Ip:       stringValue(data.Ip),
-		Port:     stringValue(data.Port),
-		Iden:     stringValue(data.Iden),
-		KeyIden:  stringValue(data.KeyIden),
-		KeyAlgo:  stringValue(data.KeyAlgo),
-		Status:   stringValue(data.Status),
-		CreateAt: timeValue(data.CreateAt),
+		Id:          data.Id,
+		Name:        stringValue(data.Name),
+		Ip:          stringValue(data.Ip),
+		Port:        data.Port,
+		Iden:        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),
 	}
 }

+ 53 - 123
internal/logic/listdirchildrenlogic.go

@@ -13,13 +13,10 @@ import (
 	"context"
 	"encoding/json"
 	"io"
-	"net"
 	"net/http"
 	"net/url"
-	"os"
 	"path"
 	"strings"
-	"time"
 
 	"udeServer/internal/apierr"
 	"udeServer/internal/svc"
@@ -67,6 +64,10 @@ type remoteEntry struct {
 }
 
 func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*types.DirChildrenResp, error) {
+	ip, port := req.Ip, req.Port
+	if ip == "" || port == "" {
+		return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
+	}
 	// 1. 归一化: 空 → 根目录; 必须以 / 开头; Clean 防 ../ 穿越
 	p := strings.TrimSpace(req.Path)
 	if p == "" {
@@ -75,129 +76,58 @@ func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*
 	if !strings.HasPrefix(p, "/") {
 		return nil, apierr.BadRequest("path 必须是绝对路径(以 / 开头)")
 	}
-	// 目标机是 Linux,统一用 path.Clean,避免依赖本机 OS 分隔符
+	// path.Clean 而非 filepath.Clean: 该路径发给远程 Linux 外挂程序,
+	// 必须保持 / 分隔符;filepath 在 Windows 上跑 udeServer 时会把 / 转成 \
 	dir := path.Clean(p)
-
-	if req.Ip == "" {
-		return nil, apierr.BadRequest("ip 不能为空")
-	}
-	if req.Port == "" {
-		return nil, apierr.BadRequest("port 不能为空")
-	}
-
-	// 2. 调用远程 /list 接口(只读一层,天然懒加载)
-	remote, err := l.fetchRemoteList(req.Ip, req.Port, dir)
-	if err != nil {
-		return nil, err
-	}
-
-	// 3. 转换成前端节点结构(响应结构与原来保持一致)
-	nodes := make([]types.LocalDirNode, 0, len(remote.Entries))
-	for _, e := range remote.Entries {
-		if !e.Dir {
-			continue // 只要目录;文件/符号链接(dir:false)一律跳过
-		}
-		name := e.Name
-		if dir == "/" && dirBlacklist[name] {
-			continue // 根层黑名单系统目录
-		}
-		full := path.Join(dir, name)
-		nodes = append(nodes, types.LocalDirNode{
-			Name:  name,
-			Path:  full,
-			IsDir: true,
-			// 远程 /list 不返回"是否有子目录",保守给 true;
-			// 前端真正展开时会再发一次 /list,行为上仍是懒加载
-			HasChildren: true,
-		})
-	}
-	return &types.DirChildrenResp{Nodes: nodes}, nil
+	return l.notifyListDirChildren(ip, port, dir)
 }
 
-// --- 调用远程 /list ------------------------------------------------------
-func (l *ListDirChildrenLogic) fetchRemoteList(ip, port, dir string) (*remoteListResp, error) {
-	u := &url.URL{
-		Scheme: "http",
-		Host:   net.JoinHostPort(ip, port),
-		Path:   "/list",
-	}
-	q := url.Values{}
-	q.Set("path", dir)
-	u.RawQuery = q.Encode()
-
-	ctx, cancel := context.WithTimeout(l.ctx, 10*time.Second)
-	defer cancel()
-
-	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+// notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口,
+// 由外挂程序对保护目录执行 fuse 挂载。一个目录一次调用,请求体:
+//
+//	POST http://{ip}:{port}/api/fuseMount
+//	{ "backing": "<保护目录>-cipher", "mount": "<保护目录>", "ikey": "<密钥内容>" }
+//
+// ikey 取自 t_key.content(按服务配置的密钥标识 keyIden 查询)。
+func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs string) (*types.DirChildrenResp, error) {
+	if ip == "" || port == "" {
+		return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
+	}
+	if len(dirs) == 0 {
+		return nil, fmt.Errorf("没有配置保护目录,无需通知挂载")
+	}
+	baseURL := fmt.Sprintf("http://%s:%s/path/lists", ip, port)
+	// 规范化路径(path.Clean 保持 / 分隔符,与 Linux 外挂程序对齐)
+	cleanPath := path.Clean(dirs)
+	// 构造带参数的 GET 请求 URL
+	reqURL, err := url.Parse(baseURL)
 	if err != nil {
-		return nil, apierr.BadRequest("构造远程请求失败: %v", err)
-	}
-
-	httpResp, err := http.DefaultClient.Do(httpReq)
+		return nil, fmt.Errorf("解析 URL 失败: %w", err)
+	}
+	// 核心修改:往 URL 中添加 ?path=xxx
+	q := reqURL.Query()
+	q.Set("path", cleanPath)
+	reqURL.RawQuery = q.Encode() // 自动处理 URL 编码,生成如 ?path=%2Fdata%2Ftest
+	// 发起 GET 请求
+	finalURL := reqURL.String()
+	l.Infof("URL,方便排查参数/编码问题list dirs forward: GET %s", finalURL) // 打出转发 URL,方便排查参数/编码问题
+	resp, err := fuseMountClient.Get(finalURL)
 	if err != nil {
-		l.Errorf("call remote /list %s failed: %v", u.String(), err)
-		return nil, apierr.BadRequest("调用远程接口失败: %v", err)
-	}
-	defer httpResp.Body.Close()
-
-	// 保护性限制:一个目录条目正常远小于 8MB
-	body, readErr := io.ReadAll(io.LimitReader(httpResp.Body, 8<<20))
-	if readErr != nil {
-		return nil, apierr.BadRequest("读取远程响应失败: %v", readErr)
-	}
-
-	switch httpResp.StatusCode {
-	case http.StatusOK:
-		// ok,继续解析
-	case http.StatusBadRequest:
-		// 远程 400 自带中文说明,原样透传给前端
-		return nil, apierr.BadRequest("%s", extractRemoteMessage(body))
-	case http.StatusMethodNotAllowed:
-		return nil, apierr.BadRequest("远程服务拒绝了 GET 请求")
-	default:
-		return nil, apierr.BadRequest("远程接口异常(%d): %s", httpResp.StatusCode, extractRemoteMessage(body))
-	}
-
-	var out remoteListResp
-	if err := json.Unmarshal(body, &out); err != nil {
-		return nil, apierr.BadRequest("解析远程响应失败: %v", err)
-	}
-	return &out, nil
-}
-
-// 从远程错误响应中提取中文说明(兼容 message/msg/error 字段)
-func extractRemoteMessage(body []byte) string {
-	if len(body) == 0 {
-		return "远程接口返回空响应"
-	}
-	var wrap struct {
-		Message string `json:"message"`
-		Msg     string `json:"msg"`
-		Error   string `json:"error"`
-	}
-	if json.Unmarshal(body, &wrap) == nil {
-		switch {
-		case wrap.Message != "":
-			return wrap.Message
-		case wrap.Msg != "":
-			return wrap.Msg
-		case wrap.Error != "":
-			return wrap.Error
-		}
-	}
-	return string(body)
-}
-
-// hasSubDir 判断目录下是否还有子目录(只读一层、找到一个即返回,开销可控)
-func hasSubDir(dir string) bool {
-	subs, err := os.ReadDir(dir)
-	if err != nil {
-		return false
-	}
-	for _, s := range subs {
-		if s.IsDir() {
-			return true
-		}
-	}
-	return false
+		return nil, fmt.Errorf("请求 %s 失败: %w", baseURL, err)
+	}
+	defer resp.Body.Close() // 确保无论如何都能释放连接
+	// 错误处理
+	// 5. 检查 HTTP 状态码
+	if resp.StatusCode != http.StatusOK {
+		// 把外挂程序返回的错误内容带出来(前 256 字节),便于定位 400 的具体原因
+		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+		return nil, fmt.Errorf("获取目录失败, HTTP 状态码: %d, 外挂程序返回: %s", resp.StatusCode, string(snippet))
+	}
+	// 6. 解析 JSON 响应
+	var result *types.DirChildrenResp
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		return nil, fmt.Errorf("解析JSON响应失败: %w", err)
+	}
+	l.Infof("fuse 请求成功, path: %s", cleanPath)
+	return result, err
 }

+ 265 - 13
internal/logic/updateservicelogic.go

@@ -4,13 +4,23 @@
 package logic
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
 
+	"udeServer/internal/apierr"
 	"udeServer/internal/model"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type UpdateServiceLogic struct {
@@ -31,22 +41,87 @@ func (l *UpdateServiceLogic) UpdateService(req *types.UpdateServiceReq) (*types.
 	if err := checkId(req.Id); err != nil {
 		return nil, err
 	}
-	if _, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id); err != nil {
+	// 查旧实体:换密钥校验需要旧的 mount_status/key_iden;
+	// 目录联动需要旧目录清单(算差集,决定哪些目录要去外挂程序卸载)
+	svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id)
+	if err != nil {
+		return nil, err
+	}
+
+	// B 方案硬校验:挂载中的服务禁止更换密钥。
+	// 原因:FUSE 实际挂载仍用【旧密钥】解密,库里 keyIden 换成新的会形成"记新挂旧"的
+	// 脏状态,后续任何按库里 keyIden 进行的重挂/刷库都会用错密钥 → 旧密文读成乱码。
+	// 正规路径:卸载 → 用旧密钥解密存量 → 新密钥重加密(刷库) → 新密钥重新挂载。
+	if svc.MountStatus.String == "mounted" && req.KeyIden != svc.KeyIden.String {
+		return nil, apierr.BadRequest("服务正在挂载中,禁止更换密钥;请先卸载后再操作")
+	}
+
+	// 旧目录清单:联动卸载的依据(哪些目录被移除了,才需要去外挂程序卸载)
+	oldDirs, err := l.svcCtx.DirectoryModel.FindDirsByServiceId(l.ctx, req.Id)
+	if err != nil {
+		l.Errorf("query old directories of service %d failed: %v", req.Id, err)
 		return nil, err
 	}
 
-	data := &model.TService{
-		Id:      req.Id,
-		Name:    nullString(req.Name),
-		Ip:      nullString(req.Ip),
-		Port:    nullString(req.Port),
-		Iden:    nullString(req.Iden),
-		KeyIden: nullString(req.KeyIden),
-		KeyAlgo: nullString(req.KeyAlgo),
-		Status:  nullString(req.Status),
+	// 新目录清单规范化:trim 去空白 + path.Clean(发给 Linux 外挂必须保持 / 分隔符) + 去重
+	newDirs := make([]string, 0, len(req.Directories))
+	for _, dir := range req.Directories {
+		dir = strings.TrimSpace(dir)
+		if dir == "" {
+			continue
+		}
+		newDirs = append(newDirs, path.Clean(dir))
 	}
-	if err := l.svcCtx.ServiceModel.Update(l.ctx, data); err != nil {
-		l.Errorf("update service %d failed: %v", req.Id, err)
+	newDirs = dedupStrings(newDirs)
+
+	// 挂载联动(事务前软处理):只有带 directories 字段的请求(编辑弹窗)才触发;
+	// changeStatus 状态开关等不传 directories 的调用方完全不受影响,状态保持原值。
+	// 联动失败绝不阻塞保存,失败信息聚合成 mountWarning 由前端弹黄色警告。
+	mountWarning := ""
+	mountStatus := svc.MountStatus.String // 无联动动作时,Update 原样写回旧值
+	if req.Directories != nil {
+		mountWarning, mountStatus = l.applyMountChanges(svc, oldDirs, newDirs, req)
+	}
+
+	// 事务:更新服务 + 目录全删全插(与新增服务同款后端收口模式,任一步失败整体回滚)
+	err = l.svcCtx.DB.Transact(func(session sqlx.Session) error {
+		svcModel := l.svcCtx.ServiceModel.WithSession(session)
+		dirModel := l.svcCtx.DirectoryModel.WithSession(session)
+
+		data := &model.TService{
+			Id:          req.Id,
+			Name:        nullString(req.Name),
+			Ip:          nullString(req.Ip),
+			Port:        req.Port, // 端口 int 类型,int64 直传(0=未配置)
+			Iden:        nullString(req.Iden),
+			KeyIden:     nullString(req.KeyIden),
+			KeyAlgo:     nullString(req.KeyAlgo),
+			Status:      nullString(req.Status),
+			MountStatus: nullString(mountStatus), // 联动算出的新状态(没联动就是旧值)
+			Brush:       req.Brush,               // 一键刷库配置:编辑只落库改配置,不追溯触发刷库(刷库编排仅在创建流程)
+		}
+		if err := svcModel.Update(l.ctx, data); err != nil {
+			return fmt.Errorf("update t_service %d: %w", req.Id, err)
+		}
+
+		// 目录三态:nil = 不动目录(状态开关等场景);空数组 = 清空;非空 = 全量重写
+		if req.Directories != nil {
+			if err := dirModel.DeleteByServiceId(l.ctx, req.Id); err != nil {
+				return fmt.Errorf("delete old directories of service %d: %w", req.Id, err)
+			}
+			for _, dir := range newDirs {
+				if _, err := dirModel.Insert(l.ctx, &model.TDirectory{
+					ServiceId: nullInt64(req.Id),
+					Directory: nullString(dir),
+				}); err != nil {
+					return fmt.Errorf("insert t_directory[%s]: %w", dir, err)
+				}
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		l.Errorf("update service tx failed: %v", err)
 		return nil, err
 	}
 
@@ -55,5 +130,182 @@ func (l *UpdateServiceLogic) UpdateService(req *types.UpdateServiceReq) (*types.
 		return nil, err
 	}
 
-	return toServiceResp(updated), nil
+	resp := toServiceResp(updated)
+	if mountWarning != "" {
+		resp.MountWarning = mountWarning // 联动有失败时前端弹黄色警告
+	}
+	return resp, nil
+}
+
+// ======== 编辑联动挂载的辅助方法 ========
+
+// applyMountChanges 编辑保存时的挂载联动:对比新旧目录,该卸的卸、该挂的挂。
+// 联动在【事务之前】执行且全程软处理:任何失败只聚合成警告文案,绝不阻塞保存。
+// 幂等复用:新目录全量下发挂载,外挂的幂等决策表会自动跳过"已挂且同密钥"的目录,
+// 顺带把 failed 服务漏挂的目录也补挂上(自愈)。
+// 返回:warning(空串 = 联动全部成功), newStatus(联动后应写入库的 mount_status)。
+func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, newDirs []string, req *types.UpdateServiceReq) (string, string) {
+	// IP/端口变了 = 外挂换机器了:旧机器上的挂载全部要卸,新机器全量重挂
+	ipChanged := req.Ip != svc.Ip.String || req.Port != svc.Port // port 已是 int64,直接数值比较
+
+	// 该卸载的清单:换 IP → 全卸;没换 IP → 只卸"被移除"的目录(差集 old-new)
+	var toUnmount []string
+	if ipChanged {
+		toUnmount = oldDirs
+	} else {
+		toUnmount = diffStrings(oldDirs, newDirs)
+	}
+
+	var msgs []string
+	// 1. 先卸载:发往【旧】IP(挂载记录登记在旧机器的外挂程序进程里)
+	if len(toUnmount) > 0 {
+		oldIp, oldPort := svc.Ip.String, svc.Port // port 已是 int64
+		if oldIp == "" || oldPort == 0 {
+			msgs = append(msgs, "原外挂 IP/端口为空,无法联动卸载,旧目录可能仍处于挂载状态")
+		} else {
+			for _, dir := range toUnmount {
+				if w := l.fuseUnmountOne(oldIp, oldPort, dir); w != "" {
+					msgs = append(msgs, w)
+				}
+			}
+		}
+	}
+
+	// 2. 再挂载:发往【新】IP,新目录全量下发(幂等表自动跳过已挂的)
+	if len(newDirs) > 0 {
+		if req.Ip == "" || req.Port == 0 {
+			msgs = append(msgs, "新外挂 IP/端口为空,无法联动挂载")
+		} else {
+			ikey, err := l.lookupIkey(req.KeyIden)
+			if err != nil {
+				msgs = append(msgs, fmt.Sprintf("联动挂载失败: %v", err))
+			} else {
+				for _, dir := range newDirs {
+					if w := l.fuseMountOne(req.Ip, req.Port, dir, ikey); w != "" {
+						msgs = append(msgs, w)
+					}
+				}
+			}
+		}
+	}
+
+	// 3. mount_status 重算:
+	//    无动作(没卸也没挂,比如目录原样重存) → 保持原值;
+	//    有动作全成功 → 还有新目录 = mounted,清空了目录 = unmounted;
+	//    有动作任一失败 → failed(同时返回聚合警告,前端弹黄条)。
+	hasAction := len(toUnmount) > 0 || len(newDirs) > 0
+	if !hasAction {
+		return "", svc.MountStatus.String
+	}
+	if len(msgs) > 0 {
+		return "编辑已保存,但挂载联动存在失败: " + strings.Join(msgs, "; "), "failed"
+	}
+	if len(newDirs) > 0 {
+		return "", "mounted"
+	}
+	return "", "unmounted"
+}
+
+// fuseMountOne 通知外挂程序挂载单个目录。成功返回空串,失败返回警告文案。
+// 请求体与新增服务完全同款:{backing: 目录+"-cipher", mount: 目录, ikey: 密钥内容}
+func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey string) string {
+	// 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 {
+		return fmt.Sprintf("序列化挂载请求失败: %v", err)
+	}
+	baseURL := fmt.Sprintf("http://%s:%d/api/fuseMount", ip, port) // %d:port 是 int64
+	resp, err := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+	if err != nil {
+		return fmt.Sprintf("挂载目录 %s 失败(外挂程序未启动?): %v", dirLinuxPath, err)
+	}
+	if resp.StatusCode >= http.StatusBadRequest {
+		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) // 带出外挂错误内容(前 256 字节)
+		resp.Body.Close()
+		return fmt.Sprintf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", dirLinuxPath, resp.StatusCode, string(snippet))
+	}
+	_, _ = io.Copy(io.Discard, resp.Body) // 读入"黑洞"保证 TCP 连接复用 Keep-Alive
+	resp.Body.Close()
+	l.Infof("fuse mount ok: %s -> %s", dirLinuxPath, dirLinuxPath+"-cipher")
+	return ""
+}
+
+// fuseUnmountOne 通知外挂程序卸载单个挂载点。成功返回空串,失败返回警告文案。
+// 卸载接口幂等:挂载点不在外挂注册表中也返回成功,所以重存/多卸都零成本。
+func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount string) string {
+	cleanMount := path.Clean(mount)                                  // 同上,发给 Linux 必须保持 / 分隔符
+	baseURL := fmt.Sprintf("http://%s:%d/api/fuseUnMount", ip, port) // %d:port 是 int64
+	reqURL, uerr := url.Parse(baseURL)
+	if uerr != nil {
+		return fmt.Sprintf("解析 URL 失败: %v", uerr)
+	}
+	q := reqURL.Query()
+	q.Set("mount", cleanMount) // q.Encode() 自动做 URL 编码(/ → %2F),外挂侧 Query().Get 自动解码还原
+	reqURL.RawQuery = q.Encode()
+	resp, err := fuseMountClient.Get(reqURL.String()) // 复用挂载逻辑的 HTTP 客户端(同包包级变量)
+	if err != nil {
+		return fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, err)
+	}
+	if resp.StatusCode >= http.StatusBadRequest {
+		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+		resp.Body.Close()
+		return fmt.Sprintf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, resp.StatusCode, string(snippet))
+	}
+	_, _ = io.Copy(io.Discard, resp.Body)
+	resp.Body.Close()
+	l.Infof("fuse unmount ok: %s", cleanMount)
+	return ""
+}
+
+// lookupIkey 按密钥标识(keyIden)查密钥内容,下发挂载时作为 ikey 使用。
+// 独立成方法供编辑联动复用,不动 createservicelogic.go 的原有实现。
+func (l *UpdateServiceLogic) lookupIkey(keyIden string) (string, error) {
+	if keyIden == "" {
+		return "", fmt.Errorf("服务未配置密钥标识(keyIden),无法下发挂载")
+	}
+	key, err := l.svcCtx.KeyModel.FindOneByIden(l.ctx, keyIden)
+	if err != nil {
+		return "", fmt.Errorf("查询密钥(%s)失败: %w", keyIden, err)
+	}
+	ikey := key.Content.String
+	if ikey == "" {
+		return "", fmt.Errorf("密钥(%s)内容为空,无法下发挂载", keyIden)
+	}
+	return ikey, nil
+}
+
+// diffStrings 集合差集:返回"在 oldDirs 中但不在 newDirs 中"的元素(被移除的目录)。
+func diffStrings(oldDirs, newDirs []string) []string {
+	// map[string]bool 当集合用:value 只存 true/false,判断"存在性"是 O(1)
+	newSet := make(map[string]bool, len(newDirs))
+	for _, v := range newDirs {
+		newSet[v] = true
+	}
+	out := make([]string, 0, len(oldDirs))
+	for _, v := range oldDirs {
+		if !newSet[v] {
+			out = append(out, v)
+		}
+	}
+	return out
+}
+
+// dedupStrings 保序去重:同一路径提交两次只落库/下发一次。
+func dedupStrings(in []string) []string {
+	seen := make(map[string]bool, len(in))
+	out := make([]string, 0, len(in))
+	for _, v := range in {
+		if seen[v] {
+			continue // 已见过,跳过
+		}
+		seen[v] = true
+		out = append(out, v)
+	}
+	return out
 }

+ 19 - 0
internal/model/tdirectorymodel.go

@@ -15,6 +15,7 @@ type (
 	TDirectoryModel interface {
 		tDirectoryModel
 		FindPage(ctx context.Context, page, pageSize int64) ([]TDirectory, int64, error)
+		FindDirsByServiceId(ctx context.Context, serviceId int64) ([]string, error)
 		DeleteByServiceId(ctx context.Context, serviceId int64) error
 		WithSession(session sqlx.Session) TDirectoryModel
 	}
@@ -51,6 +52,24 @@ func (m *customTDirectoryModel) FindPage(ctx context.Context, page, pageSize int
 	return resp, total, nil
 }
 
+// FindDirsByServiceId 查询某个服务下全部保护目录路径(剔除空值),
+// 删除服务前的联动卸载用它拿到要卸载的挂载点清单。
+func (m *customTDirectoryModel) FindDirsByServiceId(ctx context.Context, serviceId int64) ([]string, error) {
+	// 查全列扫描进 struct 切片(与 FindPage 同款成熟模式),再抽取 directory 字符串
+	query := fmt.Sprintf("select %s from %s where `service_id` = ?", tDirectoryRows, m.table)
+	var resp []TDirectory
+	if err := m.conn.QueryRowsCtx(ctx, &resp, query, serviceId); err != nil {
+		return nil, err
+	}
+	dirs := make([]string, 0, len(resp))
+	for _, d := range resp {
+		if d.Directory.Valid && d.Directory.String != "" {
+			dirs = append(dirs, d.Directory.String)
+		}
+	}
+	return dirs, nil
+}
+
 // DeleteByServiceId 删除某个服务下的全部保护目录,配合服务删除做级联清理。
 func (m *customTDirectoryModel) DeleteByServiceId(ctx context.Context, serviceId int64) error {
 	query := fmt.Sprintf("delete from %s where `service_id` = ?", m.table)

+ 16 - 0
internal/model/tkeymodel.go

@@ -16,6 +16,7 @@ type (
 	TKeyModel interface {
 		tKeyModel
 		FindPage(ctx context.Context, page, pageSize int64, name, status string) ([]TKey, int64, error)
+		FindOneByIden(ctx context.Context, iden string) (*TKey, error)
 		UpdateStatus(ctx context.Context, id int64, status string) error
 		withSession(session sqlx.Session) TKeyModel
 	}
@@ -86,6 +87,21 @@ func (m *customTKeyModel) FindPage(ctx context.Context, page, pageSize int64, na
 	return resp, total, nil
 }
 
+// FindOneByIden looks up a key row by its `iden` (密钥标识) column.
+func (m *customTKeyModel) FindOneByIden(ctx context.Context, iden string) (*TKey, error) {
+	query := fmt.Sprintf("select %s from %s where `iden` = ? limit 1", tKeyRows, m.table)
+	var resp TKey
+	err := m.conn.QueryRowCtx(ctx, &resp, query, iden)
+	switch err {
+	case nil:
+		return &resp, nil
+	case sqlx.ErrNotFound:
+		return nil, ErrNotFound
+	default:
+		return nil, err
+	}
+}
+
 // UpdateStatus enables (status "1") or disables (status "2") a key
 // without touching any other column.
 func (m *customTKeyModel) UpdateStatus(ctx context.Context, id int64, status string) error {

+ 9 - 0
internal/model/tservicemodel.go

@@ -15,6 +15,7 @@ type (
 	TServiceModel interface {
 		tServiceModel
 		FindPage(ctx context.Context, page, pageSize int64) ([]TService, int64, error)
+		UpdateMountStatus(ctx context.Context, id int64, status string) error
 		WithSession(session sqlx.Session) TServiceModel
 	}
 
@@ -50,6 +51,14 @@ func (m *customTServiceModel) FindPage(ctx context.Context, page, pageSize int64
 	return resp, total, nil
 }
 
+// UpdateMountStatus 只更新挂载状态(挂载链路专用,不做全字段 Update,避免并发覆盖其他字段)。
+// status 取值: mounted=已挂载 unmounted=未挂载 failed=挂载失败
+func (m *customTServiceModel) UpdateMountStatus(ctx context.Context, id int64, status string) error {
+	query := fmt.Sprintf("update %s set `mount_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))
 }

+ 14 - 12
internal/model/tservicemodel_gen.go

@@ -37,15 +37,17 @@ type (
 	}
 
 	TService struct {
-		Id       int64          `db:"id"`
-		Name     sql.NullString `db:"name"`      // 服务名称
-		Ip       sql.NullString `db:"ip"`        // 外挂服务ip
-		Port     sql.NullString `db:"port"`      // 外挂服务端口
-		Iden     sql.NullString `db:"iden"`      // 唯一标识
-		KeyIden  sql.NullString `db:"key_iden"`  // 密钥标识
-		KeyAlgo  sql.NullString `db:"key_algo"`  // 密钥算法
-		Status   sql.NullString `db:"status"`    // 服务状态
-		CreateAt time.Time      `db:"create_at"` // 创建时间
+		Id          int64          `db:"id"`
+		Name        sql.NullString `db:"name"`         // 服务名称
+		Ip          sql.NullString `db:"ip"`           // 外挂服务ip
+		Port        int64          `db:"port"`         // 外挂服务端口(DB 列已改 int,int64 直传;NOT NULL DEFAULT 0,0=未配置)
+		Iden        sql.NullString `db:"iden"`         // 唯一标识
+		KeyIden     sql.NullString `db:"key_iden"`     // 密钥标识
+		KeyAlgo     sql.NullString `db:"key_algo"`     // 密钥算法
+		Status      sql.NullString `db:"status"`       // 服务状态
+		MountStatus sql.NullString `db:"mount_status"` // 挂载状态:mounted=已挂载,unmounted=未挂载,failed=挂载失败
+		Brush       int64          `db:"brush"`        // 一键刷库:0=否(默认),1=创建后自动下发刷库
+		CreateAt    time.Time      `db:"create_at"`    // 创建时间
 	}
 )
 
@@ -77,14 +79,14 @@ func (m *defaultTServiceModel) FindOne(ctx context.Context, id int64) (*TService
 }
 
 func (m *defaultTServiceModel) Insert(ctx context.Context, data *TService) (sql.Result, error) {
-	query := fmt.Sprintf("insert into %s (%s) values (?, ?, ?, ?, ?, ?, ?, ?)", m.table, tServiceRowsExpectAutoSet)
-	ret, err := m.conn.ExecCtx(ctx, query, data.Id, data.Name, data.Ip, data.Port, data.Iden, data.KeyIden, data.KeyAlgo, data.Status)
+	query := fmt.Sprintf("insert into %s (%s) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", m.table, tServiceRowsExpectAutoSet)
+	ret, err := m.conn.ExecCtx(ctx, query, data.Id, data.Name, data.Ip, data.Port, data.Iden, data.KeyIden, data.KeyAlgo, data.Status, data.MountStatus, data.Brush)
 	return ret, err
 }
 
 func (m *defaultTServiceModel) Update(ctx context.Context, data *TService) error {
 	query := fmt.Sprintf("update %s set %s where `id` = ?", m.table, tServiceRowsWithPlaceHolder)
-	_, err := m.conn.ExecCtx(ctx, query, data.Name, data.Ip, data.Port, data.Iden, data.KeyIden, data.KeyAlgo, data.Status, data.Id)
+	_, err := m.conn.ExecCtx(ctx, query, data.Name, data.Ip, data.Port, data.Iden, data.KeyIden, data.KeyAlgo, data.Status, data.MountStatus, data.Brush, data.Id)
 	return err
 }
 

+ 49 - 10
internal/types/types.go

@@ -20,14 +20,16 @@ type CreateKeyReq struct {
 }
 
 type CreateServiceReq struct {
-	Id          int64    `json:"id,optional"`
-	Name        string   `json:"name,optional"`
-	Ip          string   `json:"ip,optional"`
-	Port        string   `json:"port,optional"`
-	Iden        string   `json:"iden,optional"`
-	KeyIden     string   `json:"keyIden,optional"`
-	KeyAlgo     string   `json:"keyAlgo,optional"`
-	Status      string   `json:"status,optional"`
+	Id      int64  `json:"id,optional"`
+	Name    string `json:"name,optional"`
+	Ip      string `json:"ip,optional"`
+	Port    int64  `json:"port,optional"`
+	Iden    string `json:"iden,optional"`
+	KeyIden string `json:"keyIden,optional"`
+	KeyAlgo string `json:"keyAlgo,optional"`
+	Status  string `json:"status,optional"`
+	// 一键刷库:0=否(默认),1=创建后自动下发刷库(存量明文加密,外挂侧能力就绪后生效)
+	Brush       int64    `json:"brush,optional"`
 	Directories []string `json:"directories,optional"`
 }
 
@@ -39,6 +41,10 @@ type DbPingResponse struct {
 
 type DeleteResp struct {
 	Ok bool `json:"ok"`
+
+	// 联动卸载失败等非致命警告(删除本身已成功),前端弹黄色警告;
+	// 目录/密钥删除等其他共用本结构体的接口不填此字段。
+	UnmountWarning string `json:"unmountWarning,optional"`
 }
 
 type DirChildrenResp struct {
@@ -101,6 +107,21 @@ type ListServiceReq struct {
 	PageSize int64 `form:"pageSize,optional"`
 }
 
+type Request struct {
+	Name string `path:"name,options=you|me"`
+}
+
+type Response struct {
+	Message string `json:"message"`
+}
+
+// 本地目录懒加载(udeServer 所在 Linux 主机文件系统,只列目录不列文件)
+type ListDirChildrenReq struct {
+	Ip   string `form:"ip"` // 目标 Linux 主机 IP
+	Port string `form:"port"`
+	Path string `form:"path"` // 空或 "/" 表示根目录
+}
+
 type LocalDirNode struct {
 	Name        string `json:"name"`        // 目录名,如 "usr"
 	Path        string `json:"path"`        // 完整绝对路径,如 "/usr"
@@ -187,12 +208,22 @@ type ServiceResp struct {
 	Id       int64  `json:"id"`
 	Name     string `json:"name"`
 	Ip       string `json:"ip"`
-	Port     string `json:"port"`
+	Port     int64  `json:"port"`
 	Iden     string `json:"iden"`
 	KeyIden  string `json:"keyIden"`
 	KeyAlgo  string `json:"keyAlgo"`
 	Status   string `json:"status"`
 	CreateAt string `json:"createAt"`
+
+	// 挂载状态: mounted=已挂载 unmounted=未挂载 failed=挂载失败
+	MountStatus string `json:"mountStatus,optional"`
+
+	// 挂载通知失败等非致命警告(如「服务已创建,但通知外挂程序挂载失败」),
+	// 服务创建本身成功;前端收到后弹黄色警告,不弹红色错误。
+	MountWarning string `json:"mountWarning,optional"`
+
+	// 一键刷库配置:0=否(默认),1=是
+	Brush int64 `json:"brush"`
 }
 
 type SystemLog struct {
@@ -298,9 +329,17 @@ type UpdateServiceReq struct {
 	Id      int64  `path:"id"`
 	Name    string `json:"name,optional"`
 	Ip      string `json:"ip,optional"`
-	Port    string `json:"port,optional"`
+	Port    int64  `json:"port,optional"`
 	Iden    string `json:"iden,optional"`
 	KeyIden string `json:"keyIden,optional"`
 	KeyAlgo string `json:"keyAlgo,optional"`
 	Status  string `json:"status,optional"`
+
+	// 一键刷库:0=否(默认),1=是。编辑只落库改配置,不追溯触发刷库(刷库编排仅在创建流程)。
+	Brush int64 `json:"brush,optional"`
+
+	// 编辑时全量提交保护目录列表(与新增同款后端收口模式):
+	// nil = 未提交,不动目录;空数组 = 清空目录;非空 = 全量重写。
+	// 落库与挂载联动(卸载被删目录/挂载新目录)由后端在同一接口完成。
+	Directories []string `json:"directories,optional"`
 }

+ 13 - 10
udeServer.api

@@ -85,14 +85,14 @@ type KeyListResp {
 }
 
 type CreateServiceReq {
-	Id          int64    `json:"id,optional"`
-	Name        string   `json:"name,optional"`
-	Ip          string   `json:"ip,optional"`
-	Port        string   `json:"port,optional"`
-	Iden        string   `json:"iden,optional"`
-	KeyIden     string   `json:"keyIden,optional"`
-	KeyAlgo     string   `json:"keyAlgo,optional"`
-	Status      string   `json:"status,optional"`
+	Id      int64    `json:"id,optional"`
+	Name    string   `json:"name,optional"`
+	Ip      string   `json:"ip,optional"`
+	Port    int64    `json:"port,optional"`
+	Iden    string   `json:"iden,optional"`
+	KeyIden string   `json:"keyIden,optional"`
+	KeyAlgo string   `json:"keyAlgo,optional"`
+	Status  string   `json:"status,optional"`
 	Directories []string `json:"directories,optional"`
 }
 
@@ -100,7 +100,7 @@ type UpdateServiceReq {
 	Id      int64  `path:"id"`
 	Name    string `json:"name,optional"`
 	Ip      string `json:"ip,optional"`
-	Port    string `json:"port,optional"`
+	Port    int64  `json:"port,optional"`
 	Iden    string `json:"iden,optional"`
 	KeyIden string `json:"keyIden,optional"`
 	KeyAlgo string `json:"keyAlgo,optional"`
@@ -115,12 +115,15 @@ type ServiceResp {
 	Id       int64  `json:"id"`
 	Name     string `json:"name"`
 	Ip       string `json:"ip"`
-	Port     string `json:"port"`
+	Port     int64  `json:"port"`
 	Iden     string `json:"iden"`
 	KeyIden  string `json:"keyIden"`
 	KeyAlgo  string `json:"keyAlgo"`
 	Status   string `json:"status"`
 	CreateAt string `json:"createAt"`
+
+	// 挂载通知失败等非致命警告,前端黄色提示
+	MountWarning string `json:"mountWarning,optional"`
 }
 
 type ListServiceReq {