// 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 换算的 apiSecret,外挂侧与环境变量 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(l.ctx, l.svcCtx, 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 }