// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。 package api import ( "net" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "opsxapi/server/internal/mocksrv" "opsxapi/server/internal/store" ) type API struct { store *store.Store } func New(path string) (*API, error) { s, err := store.Open(path) if err != nil { return nil, err } return &API{store: s}, nil } // Store 暴露底层存储(供 Mock 服务等复用同一 SQLite)。 func (a *API) Store() *store.Store { return a.store } func (a *API) Register(r *gin.Engine) { g := r.Group("/api") g.POST("/auth/register", a.handleRegister) g.POST("/auth/login", a.handleLogin) g.POST("/auth/logout", a.auth(a.handleLogout)) g.GET("/me", a.auth(a.handleMe)) g.POST("/teams", a.auth(a.handleCreateTeam)) g.PATCH("/teams/:id", a.auth(a.handleRenameTeam)) g.DELETE("/teams/:id", a.auth(a.handleDeleteTeam)) g.POST("/teams/:id/members", a.auth(a.handleAddMember)) g.GET("/teams/:id/members", a.auth(a.handleListMembers)) g.PUT("/teams/:id/members/:uid", a.auth(a.handleUpdateSpecialty)) g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace)) g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace)) g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock)) g.PUT("/mock/local", a.handlePublishLocalMock) g.DELETE("/mock/local", a.handleRevokeLocalMock) g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock)) } /* ---------------- 中间件 ---------------- */ func (a *API) auth(fn func(c *gin.Context, user *store.User)) gin.HandlerFunc { return func(c *gin.Context) { h := c.GetHeader("Authorization") token := strings.TrimSpace(strings.TrimPrefix(h, "Bearer")) if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } user, err := a.store.UserByToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"}) return } c.Set("token", token) fn(c, user) } } func userID(c *gin.Context) int64 { return c.GetInt64("uid") } /* ---------------- 鉴权 ---------------- */ func (a *API) handleRegister(c *gin.Context) { var req struct { Name string `json:"name"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请填写有效邮箱与至少 6 位密码"}) return } name := strings.TrimSpace(req.Name) if name == "" { name = strings.SplitN(req.Email, "@", 2)[0] } if _, err := a.store.UserByEmail(strings.ToLower(req.Email)); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"}) return } user, err := a.store.CreateUser(name, strings.ToLower(req.Email), req.Password) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()}) return } token, err := a.store.IssueToken(user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"}) return } c.JSON(http.StatusCreated, gin.H{"token": token, "user": user}) } func (a *API) handleLogin(c *gin.Context) { var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱与密码"}) return } user, err := a.store.CheckPassword(strings.ToLower(req.Email), req.Password) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码不正确"}) return } token, err := a.store.IssueToken(user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"}) return } c.JSON(http.StatusOK, gin.H{"token": token, "user": user}) } func (a *API) handleLogout(c *gin.Context, _ *store.User) { if token, _ := c.Get("token"); token != nil { _ = a.store.DeleteToken(token.(string)) } c.JSON(http.StatusOK, gin.H{"ok": true}) } /* ---------------- 账号概览 ---------------- */ func (a *API) handleMe(c *gin.Context, user *store.User) { teams, err := a.store.TeamsOfUser(user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } spaces, err := a.store.WorkspacesOfUser(user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // data 不随列表下发 for i := range spaces { spaces[i].Data = "" } c.JSON(http.StatusOK, gin.H{"user": user, "teams": teams, "workspaces": spaces, "serverTime": time.Now().Unix()}) } /* ---------------- 团队 ---------------- */ func (a *API) handleCreateTeam(c *gin.Context, user *store.User) { var req struct { Name string `json:"name" binding:"required,min=1,max=40"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请填写团队名称"}) return } team, err := a.store.CreateTeam(user.ID, strings.TrimSpace(req.Name)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()}) return } c.JSON(http.StatusCreated, team) } func (a *API) handleAddMember(c *gin.Context, user *store.User) { teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) return } owner, err := a.store.IsTeamOwner(teamID, user.ID) if err != nil || !owner { c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可添加成员"}) return } var req struct { Email string `json:"email" binding:"required,email"` Specialty string `json:"specialty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"}) return } member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email), trimSpecialty(req.Specialty)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "member": member}) } // handleRenameTeam 重命名团队(仅创建者) func (a *API) handleRenameTeam(c *gin.Context, user *store.User) { teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) return } owner, err := a.store.IsTeamOwner(teamID, user.ID) if err != nil || !owner { c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可重命名团队"}) return } var req struct { Name string `json:"name" binding:"required,min=1,max=40"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "团队名称需 1-40 个字符"}) return } if err := a.store.RenameTeam(teamID, strings.TrimSpace(req.Name)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "重命名失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleDeleteTeam 删除团队(仅创建者):成员、共享空间与 Mock 数据一并删除 func (a *API) handleDeleteTeam(c *gin.Context, user *store.User) { teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) return } owner, err := a.store.IsTeamOwner(teamID, user.ID) if err != nil || !owner { c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可删除团队"}) return } if err := a.store.DeleteTeam(teamID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // handleListMembers 团队成员列表(创建者与成员均可查看) func (a *API) handleListMembers(c *gin.Context, user *store.User) { teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) return } member, err := a.store.IsTeamMember(teamID, user.ID) if err != nil || !member { c.JSON(http.StatusForbidden, gin.H{"error": "仅团队成员可查看成员列表"}) return } members, err := a.store.TeamMembers(teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"members": members}) } // handleUpdateSpecialty 更新成员擅长:创建者可改任何人,成员只能改自己 func (a *API) handleUpdateSpecialty(c *gin.Context, user *store.User) { teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) return } uid, err := strconv.ParseInt(c.Param("uid"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的成员"}) return } owner, err := a.store.IsTeamOwner(teamID, user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if !owner && uid != user.ID { c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可修改他人擅长,成员可修改自己的"}) return } var req struct { Specialty string `json:"specialty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) return } if err := a.store.UpdateMemberSpecialty(teamID, uid, trimSpecialty(req.Specialty)); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "该成员不在团队中"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // trimSpecialty 擅长标签去空白、压长度 func trimSpecialty(s string) string { s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") r := []rune(s) if len(r) > 60 { return string(r[:60]) } return s } /* ---------------- Mock 发布 ---------------- */ func (a *API) handlePublishMock(c *gin.Context, user *store.User) { ws := a.workspaceOf(c, user) if ws == nil { return } var req struct { Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` TTLSeconds int64 `json:"ttlSeconds"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"}) return } if len(req.Endpoints) > 200 { c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"}) return } if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 { c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"}) return } if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()}) return } var expiresAt int64 if req.TTLSeconds > 0 { expiresAt = time.Now().Unix() + req.TTLSeconds } if err := a.store.SetMockExpiry(ws.ID, expiresAt); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()}) return } base := ws.MockToken if base == "" { base = strconv.FormatInt(ws.ID, 10) } c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base, "expiresAt": expiresAt}) } func (a *API) handleRevokeMock(c *gin.Context, user *store.User) { ws := a.workspaceOf(c, user) if ws == nil { return } if _, err := a.store.RevokeMock(ws.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } /* ---------------- 工作区同步 ---------------- */ func (a *API) workspaceOf(c *gin.Context, user *store.User) *store.Workspace { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的空间"}) return nil } ok, err := a.store.CanAccessWorkspace(id, user.ID) if err != nil || !ok { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该空间"}) return nil } ws, err := a.store.WorkspaceByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "空间不存在"}) return nil } return ws } func (a *API) handleGetWorkspace(c *gin.Context, user *store.User) { ws := a.workspaceOf(c, user) if ws == nil { return } c.JSON(http.StatusOK, ws) } func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) { ws := a.workspaceOf(c, user) if ws == nil { return } var req struct { Data string `json:"data" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少数据体"}) return } if len(req.Data) > 20*1024*1024 { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "工作区数据超过 20MB 上限"}) return } now, err := a.store.SaveWorkspace(ws.ID, req.Data) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"updatedAt": now}) } /* ---------------- 本机联调 Mock(不绑账号) ---------------- */ func (a *API) handlePublishLocalMock(c *gin.Context) { if !isLoopbackRequest(c) { c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"}) return } var req struct { Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` TTLSeconds int64 `json:"ttlSeconds"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"}) return } if len(req.Endpoints) > 200 { c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"}) return } if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 { c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"}) return } wsID, token, err := a.store.EnsureLocalMockWorkspace() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()}) return } if err := a.store.ReplaceMockEndpoints(wsID, req.Endpoints); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()}) return } var expiresAt int64 if req.TTLSeconds > 0 { expiresAt = time.Now().Unix() + req.TTLSeconds } if err := a.store.SetMockExpiry(wsID, expiresAt); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + token, "token": token, "expiresAt": expiresAt}) } func (a *API) handleRevokeLocalMock(c *gin.Context) { if !isLoopbackRequest(c) { c.JSON(http.StatusForbidden, gin.H{"error": "本机联调作废仅允许从 127.0.0.1 / ::1 调用"}) return } wsID, _, err := a.store.EnsureLocalMockWorkspace() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()}) return } if _, err := a.store.RevokeMock(wsID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func isLoopbackRequest(c *gin.Context) bool { ip := net.ParseIP(c.ClientIP()) if ip != nil && ip.IsLoopback() { return true } host, _, err := net.SplitHostPort(c.Request.RemoteAddr) if err != nil { host = c.Request.RemoteAddr } ip = net.ParseIP(host) return ip != nil && ip.IsLoopback() }