Files
opsxapi/apps/server/internal/api/api.go
T
logan d9ccccd280 本地 Mock 服务 /mock + 限流 20QPS/60QPM + 服务条款
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型)
- mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个)
- 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404
- 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示
- 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规)
- UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试);
  发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE
- docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms
- 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
2026-09-18 12:29:49 +08:00

269 lines
7.8 KiB
Go

// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。
package api
import (
"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.POST("/teams/:id/members", a.auth(a.handleAddMember))
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))
}
/* ---------------- 中间件 ---------------- */
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"`
}
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))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
}
/* ---------------- 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"`
}
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 err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + strconv.FormatInt(ws.ID, 10)})
}
/* ---------------- 工作区同步 ---------------- */
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})
}