桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
1 parent
6452026fd6
commit
2bb4ef7a95
299 files changed
+6059
-3322
No files matched your search
@@ -0,0 +1,589 @@
|
||||
// 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:8788"
|
||||
}
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user