// udeServer: DeleteService 走事务 —— 同一事务内先删该服务下的 t_directory, // 再删 t_service。任一步失败全部回滚,不会残留孤儿目录数据。 package logic import ( "context" "fmt" "io" "net/http" "net/url" "path" "strings" "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 DeleteServiceLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewDeleteServiceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteServiceLogic { return &DeleteServiceLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *DeleteServiceLogic) DeleteService(req *types.ServiceIdReq) (*types.DeleteResp, error) { if err := checkId(req.Id); err != nil { return nil, err } // 先查出服务实体:删库前要拿 ip/port 去通知外挂程序卸载,删了就查不到了 svc, err := findService(l.ctx, l.svcCtx.ServiceModel, req.Id) if err != nil { return nil, err } // 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) if err := dirModel.DeleteByServiceId(l.ctx, req.Id); err != nil { return fmt.Errorf("delete t_directory of service %d: %w", req.Id, err) } if err := svcModel.Delete(l.ctx, req.Id); err != nil { return fmt.Errorf("delete t_service %d: %w", req.Id, err) } return nil }) if err != nil { l.Errorf("delete service tx failed: %v", err) return nil, err } 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, "; ") }