Files
opsxapi/server/internal/api/api.go
T
logan 68a933e6fb 端口规划落地:Mock 默认挪 8798,8788 让给 OpsxDB;/home 落地页别名
- 本机端口约定:8787=OpsxAPI / 8788=OpsxDB / 8789=OpsXShell;mock 独立监听默认改 0.0.0.0:8798(run.sh、main.go、api.go 三处同步,README 4 处引用更新)
- mountHomepage 增加 /home、/index.html → / 别名:静态站导航里的 /home 链接不再 404
- 三站资产扫描零断链(含 opsxshell 下载 dmg 已就位)
2026-10-16 03:11:50 +08:00

590 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。
package api
import (
"net"
"net/http"
"os"
"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.GET("/mock/local", a.handleLocalMockStatus)
g.GET("/docs/local", a.localDocs)
g.PUT("/docs/local", a.publishLocalDocs)
g.DELETE("/docs/local/:projectId", a.revokeLocalDocs)
g.PATCH("/mock/local", a.handleLocalMockToggle)
g.PUT("/mock/local/access", a.handleLocalMockAccess)
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
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5*1024*1024)
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
}
for _, ep := range req.Endpoints {
method := strings.ToUpper(ep.Method)
allowed := method == "GET" || method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE" || method == "HEAD" || method == "OPTIONS"
if !allowed || !strings.HasPrefix(ep.Path, "/") || strings.ContainsAny(ep.Path, "?#\r\n") || ep.Status < 200 || ep.Status > 599 || ep.DelayMs < 0 || ep.DelayMs > 30000 || strings.ContainsAny(ep.ContentType, "\r\n") {
c.JSON(400, gin.H{"error": "Mock 接口参数无效:检查方法、路径、状态码(200~599)和延迟(0~30000ms)"})
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 {
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
host = c.Request.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (a *API) handleLocalMockStatus(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
return
}
endpoints, token, expires, err := a.store.LocalMockSnapshot()
if err != nil {
c.JSON(500, gin.H{"error": "读取本机 Mock 失败"})
return
}
addr := os.Getenv("OPSXAPI_MOCK_ADDR")
if addr == "" {
addr = "0.0.0.0:8798" // 8788 让给 OpsxDB;见 cmd/opsxapi/main.go
}
bindHost, port, err := net.SplitHostPort(addr)
if err != nil {
c.JSON(500, gin.H{"error": "Mock 监听地址配置无效"})
return
}
addresses := []string{"http://127.0.0.1:" + port}
if list, err := net.InterfaceAddrs(); err == nil && (bindHost == "" || bindHost == "0.0.0.0" || bindHost == "::") {
for _, item := range list {
if ipnet, ok := item.(*net.IPNet); ok && ipnet.IP.To4() != nil && ipnet.IP.IsPrivate() {
addresses = append(addresses, "http://"+net.JoinHostPort(ipnet.IP.String(), port))
}
}
}
if ip := net.ParseIP(bindHost); ip != nil && ip.IsPrivate() {
addresses = append(addresses, "http://"+net.JoinHostPort(bindHost, port))
}
enabled, err := a.store.MockEnabled()
if err != nil {
c.JSON(500, gin.H{"error": "读取 Mock 服务状态失败"})
return
}
bearerRequired, bearerConfigured, err := a.store.MockAccess()
if err != nil {
c.JSON(500, gin.H{"error": "读取 Mock 访问控制失败"})
return
}
c.JSON(200, gin.H{"enabled": enabled, "bearerRequired": bearerRequired, "bearerConfigured": bearerConfigured, "endpoints": endpoints, "token": token, "expiresAt": expires, "active": len(endpoints) > 0 && (expires == 0 || time.Now().Unix() < expires), "allowPublic": os.Getenv("OPSXAPI_MOCK_ALLOW_PUBLIC") == "1", "listenAddr": addr, "addresses": addresses})
}
func (a *API) handleLocalMockAccess(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "仅允许本机配置 Mock 访问控制"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
Token string `json:"token"`
}
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定是否启用 Bearer 验证"})
return
}
if *req.Enabled {
if len(req.Token) < 16 || len(req.Token) > 256 || strings.TrimSpace(req.Token) != req.Token {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bearer 密钥须为 16~256 字符且不能有首尾空格"})
return
}
}
if err := a.store.SetMockAccess(*req.Enabled, req.Token); err != nil {
c.JSON(500, gin.H{"error": "保存 Mock 访问控制失败"})
return
}
c.JSON(200, gin.H{"ok": true})
}
func (a *API) handleLocalMockToggle(c *gin.Context) {
if !isLoopbackRequest(c) {
c.JSON(403, gin.H{"error": "仅允许本机管理 Mock"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || req.Enabled == nil {
c.JSON(400, gin.H{"error": "缺少 enabled"})
return
}
if err := a.store.SetMockEnabled(*req.Enabled); err != nil {
c.JSON(500, gin.H{"error": "更新 Mock 服务状态失败"})
return
}
c.JSON(200, gin.H{"enabled": *req.Enabled})
}