From 0edd3e674a54db800098ece43610c06bf64dffae Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 18 Sep 2026 12:33:58 +0800 Subject: [PATCH] =?UTF-8?q?Mock=20=E5=9C=B0=E5=9D=80=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=9A=E7=A9=BA=E9=97=B4=20ID=20=E2=86=92?= =?UTF-8?q?=2016=20=E4=BD=8D=E9=9A=8F=E6=9C=BA=20mock=5Ftoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 问题:/mock/{自增空间ID} 可枚举且服务绑定所有网卡,局域网可猜他人空间 - workspaces 新增 mock_token(UNIQUE,随机 8 字节 hex;老库自动 ALTER + 回填) - /mock/{token}/path:解析失败统一 404(不暴露存在性);限流 key 沿用 token - /api/me 与发布响应返回 token 形态 base;弹窗 URL 改用 token - 验证:token URL 正常随机生成;旧数字 ID 与任意伪造 token 均 404 --- apps/server/data/opsxapi.db-shm | Bin 32768 -> 32768 bytes apps/server/data/opsxapi.db-wal | Bin 436752 -> 543872 bytes apps/server/internal/api/api.go | 6 +- apps/server/internal/docs/content/14-mock.md | 2 +- apps/server/internal/mocksrv/server.go | 13 +++- apps/server/internal/store/store.go | 68 +++++++++++++++--- packages/ui/src/account.ts | 10 ++- .../src/components/dialogs/MockLinkDialog.tsx | 3 +- packages/ui/src/workspaceStore.ts | 2 +- 9 files changed, 87 insertions(+), 17 deletions(-) diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm index b4c4bbac040ed27484d13da8e654c2a96d60ef55..93de15d5ee8f2d6c4321b749f7d4fa84617ba092 100644 GIT binary patch delta 414 zcmZo@U}|V!s+V}A%K!qLK+MR%AkYG&C4uUT3zJA>z zQq=>E0t1k_|B(Pxn28~4W8->8PoUKx>lyffmS zll*1_CL4Rkip_>hnv9eGIZ3c)GvqLo0J+;487sk}oBz08VPvd=uzz^~+0_u$KfI+F z8-dKnOq0L)2{6_HnQxgUe+d*|tOqkUe+uE_d4gf6sZ{7d^ delta 279 zcmZo@U}|V!s+V}A%K!q55G;@dq(y<)?^+^%Y@g%y66e;2kj}%Z%pHsFfAbRnGTt#w{t^gbZ2lC&#|adA#WeX<0#xWlG7B3}=o=He14A@J3PZ-` fg-qX>CO^pM0EGHt$9p~emXZ7F10 diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal index 29c6982cb0c3b1aeefe4457ed2bdbda8d7e3a312..5b96ffa0c0c4572fa731b9c5437f89934fb9badf 100644 GIT binary patch delta 3137 zcmd^Bdr(wW7{7P#xpyD#-Lt$wL3yT&ZoBO3?xj&QLZuW@QIVdn-Ad4VPmeoJqnRE8+cfapD zzwdW`_q(^IWq9Sbs7ep%k$ET&@X!s6)*DI?1c^paS@NpNj5VLcaapHd&D(l6Zc|(F z2tR=DprLu-HEff5v3V3Ap5F2mt6EDyx`}`acv5qS{Z6xnjTlz1zC<@kb3dmi)2fv= zr931miu7;?y1tAc{l`TX6gd}VmlZ8?7e;2JWM=53v#01cDFA;Z$4C%8_03_G}Uuuj*??a*;5{b&LY*m)ZUsT$d`@{GHg4X_*U=bgP2v*&SrCNDN zQWRM=BCpWpUY6%tmc6v7WKn6c;B=Q}_ZQV~)FjVJnUs+dIVUZ3=A4ws)U@YPG9&*m zE^SkdcNR1iS}t!<(=hE&ZhM zD^v?i1QbEicmjB$@Fb%sLWv@9l#k|umce2pIXK}^h!IdM!U2e5vK8-hop66YVt7_k+v(cJ#*8w~6w^y()92}A zxJZ%(`YY8N+u!lDzjyem*dWW_yML!S$LKOT+-8@Vx0tM)83heEgWaY6-F2%vfvZo9KhDCO2ZsgE!dgjVr*}PG z@mUI-bE5c#hZ>GoJv59|=_v&NJzQqSn_>!jP*!8kmRw zSwG1Hp;;T4pFb+>va<509+HxLj)c18&OaBEh@So)1sTNnBzml-s)h$D{3xy5t^<1M zZUHbT#0?#k7C$q=|Gd6@{VVkw@BGoznJyLVM*7-9^r;IidzwuZT!E#;^JbgTVKiHe z33j8?VFLev=ul+=SUdwohb84h_x2BMO`(*aT4CTK=HZGbIj83cB<Cx=&=#HX~-5PO=AG^)3Sh#EiNg-COrDy%?D?V zcxJ)eb1^u4NnWATEgqADXsJR1$6?}Ns*%rt{@vzZ%f8%FQO(NL1C%F^N3ZDZk4P_Y z@OuK~8!2j&=-}~aWzf+>b#Rcsw?S!kprdv=9Y&Mg?Bbomf2~m;U3~K8rUR#?w|ZaK zTPW23I&cU4cQxA6K?hMl_qIFq(=V~`3uRNnlPkJwSY%YNI|z6X=D`R}vu2fsXS>-Q z=vdaU)1Ft*RV)c?yxnf&+=3}bFef-U&cz8fo;O-@a_oZOb~_Ss+%_v`w{n6t!NpmP z4&G%JoKClib2%M?Gl9zA+c;WcCFTrxnRd|GL)`Uov z=fDwGoDss%lb&=}g@n@FAa%YY`M&7W;YaJkF86E=&)_epTkr<_30{J&@C>|wYCa6b zea9)9RET4Aj9oGLM3jndU3y>2)iKSGWW+?CnGhUqJ-mL($GXLsI}(2dG{WU@If}ln z5zkjM-e_c>PX-0a^nMBe(r3)hFf4SJ5uXW3jEW!wqGWo5*g2JnVJ87Q;YD~6?R5f*C$}-`6l5P2MB-fnX_T7;Q&KVv zLUEp@7B+!Z09ZE%Pyhe` diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go index ddaca40..7ac9d8d 100644 --- a/apps/server/internal/api/api.go +++ b/apps/server/internal/api/api.go @@ -211,7 +211,11 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) { 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)}) + 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}) } /* ---------------- 工作区同步 ---------------- */ diff --git a/apps/server/internal/docs/content/14-mock.md b/apps/server/internal/docs/content/14-mock.md index 3165de3..930a016 100644 --- a/apps/server/internal/docs/content/14-mock.md +++ b/apps/server/internal/docs/content/14-mock.md @@ -77,7 +77,7 @@ Body 工具栏提供两个辅助按钮: 1. 登录并切换到「我的空间」或团队空间(Mock 按空间隔离); 2. HTTP 请求 URL 行点 **Mock 链接** → 选择响应状态码与延迟 → **发布**(一次发布当前空间集合内全部 HTTP 接口); -3. 得到地址:`http://127.0.0.1:8787/mock/{空间ID}{路径}`,例如 `http://127.0.0.1:8787/mock/3/api/users`。 +3. 得到地址:`http://127.0.0.1:8787/mock/{空间随机地址}{路径}`(每空间一个 16 位随机片段,不可枚举、不暴露归属),例如 `http://127.0.0.1:8787/mock/9f2c7a1b3d5e4f60/api/users`。 特点: diff --git a/apps/server/internal/mocksrv/server.go b/apps/server/internal/mocksrv/server.go index a1087da..27ca979 100644 --- a/apps/server/internal/mocksrv/server.go +++ b/apps/server/internal/mocksrv/server.go @@ -2,7 +2,6 @@ package mocksrv import ( "net/http" - "strconv" "strings" "sync" "time" @@ -25,6 +24,7 @@ type Endpoint struct { type storeAPI interface { ReplaceMockEndpoints(workspaceID int64, list []Endpoint) error FindMockEndpoint(workspaceID int64, method, path string) (*Endpoint, bool, error) + ResolveMockToken(token string) (int64, bool, error) } /* ---------------- 限流:20 QPS / 60 QPM(按 IP + 空间,滑动窗口) ---------------- */ @@ -121,7 +121,16 @@ func (s *Server) handle(c *gin.Context) { return } - wsID, _ := strconv.ParseInt(wsid, 10, 64) + // wsid 实为 mock_token:不可枚举、不暴露空间归属;解析失败统一 404 + wsID, ok, err := s.store.ResolveMockToken(wsid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"}) + return + } + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Mock 地址无效或已失效"}) + return + } ep, found, err := s.store.FindMockEndpoint(wsID, method, path) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"}) diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index b3177f9..9821517 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -80,7 +80,8 @@ CREATE TABLE IF NOT EXISTS workspaces ( team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id), data TEXT NOT NULL DEFAULT '', - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + mock_token TEXT UNIQUE ); CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id); CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id); @@ -99,7 +100,40 @@ CREATE TABLE IF NOT EXISTS mock_endpoints ( CREATE INDEX IF NOT EXISTS idx_mock_ws ON mock_endpoints(workspace_id); CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); `) - return err + if err != nil { + return err + } + // 老库补列 + 回填随机 token(幂等) + if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil { + _, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`) + } + rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`) + if err != nil { + return err + } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + ids = append(ids, id) + } + rows.Close() + for _, id := range ids { + if _, err := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, newMockToken(), id); err != nil { + return err + } + } + return nil +} + +// newMockToken 16 位随机 hex(URL 片段,不可枚举) +func newMockToken() string { + raw := make([]byte, 8) + _, _ = rand.Read(raw) + return hex.EncodeToString(raw) } /* ---------------- 密码 ---------------- */ @@ -227,8 +261,8 @@ func (s *Store) CreateTeam(ownerID int64, name string) (*Team, error) { return nil, err } if _, err := s.db.Exec( - `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,?,?,'',?)`, - name, id, ownerID, now, + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,?,?,'',?,?)`, + name, id, ownerID, now, newMockToken(), ); err != nil { return nil, err } @@ -285,6 +319,7 @@ type Workspace struct { OwnerID int64 `json:"ownerId"` Data string `json:"data"` UpdatedAt int64 `json:"updatedAt"` + MockToken string `json:"mockToken"` } // WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐) @@ -294,8 +329,8 @@ func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) { err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { if _, err := s.db.Exec( - `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`, - "我的空间", userID, time.Now().Unix(), + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`, + "我的空间", userID, time.Now().Unix(), newMockToken(), ); err != nil { return nil, err } @@ -304,7 +339,7 @@ func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) { } rows, err := s.db.Query(` -SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at +SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at, IFNULL(w.mock_token,'') FROM workspaces w WHERE w.owner_id = ? OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) @@ -316,7 +351,7 @@ ORDER BY w.team_id IS NOT NULL, w.id`, userID, userID) var out []Workspace for rows.Next() { var w Workspace - if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt); err != nil { + if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken); err != nil { return nil, err } out = append(out, w) @@ -341,8 +376,8 @@ WHERE w.id = ? AND (w.owner_id = ? func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) { w := &Workspace{} err := s.db.QueryRow( - `SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at FROM workspaces WHERE id = ?`, wsID, - ).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt) + `SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at, IFNULL(mock_token,'') FROM workspaces WHERE id = ?`, wsID, + ).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -393,6 +428,19 @@ func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocks return e, true, nil } +// ResolveMockToken:mock token → workspace id(不存在返回 false,不暴露信息) +func (s *Store) ResolveMockToken(token string) (int64, bool, error) { + var id int64 + err := s.db.QueryRow(`SELECT id FROM workspaces WHERE mock_token = ?`, token).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return id, true, nil +} + func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) { now := time.Now().Unix() _, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID) diff --git a/packages/ui/src/account.ts b/packages/ui/src/account.ts index d832507..069d39d 100644 --- a/packages/ui/src/account.ts +++ b/packages/ui/src/account.ts @@ -3,7 +3,15 @@ */ export type AccountUser = { id: number; name: string; email: string }; export type AccountTeam = { id: number; name: string; role: string; owner: string }; -export type AccountWorkspace = { id: number; name: string; teamId: number | null; ownerId: number; updatedAt: number }; +export type AccountWorkspace = { + id: number; + name: string; + teamId: number | null; + ownerId: number; + updatedAt: number; + /** Mock 服务随机地址片段(不可枚举) */ + mockToken?: string; +}; class ApiError extends Error { constructor(message: string, public status: number) { diff --git a/packages/ui/src/components/dialogs/MockLinkDialog.tsx b/packages/ui/src/components/dialogs/MockLinkDialog.tsx index 4914949..b8a9b3e 100644 --- a/packages/ui/src/components/dialogs/MockLinkDialog.tsx +++ b/packages/ui/src/components/dialogs/MockLinkDialog.tsx @@ -36,7 +36,8 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { } } - const mockUrl = space ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${space.id}${urlPath}` : ""; + const mockSeg = space?.mockToken ?? (space ? String(space.id) : ""); + const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : ""; const publish = async () => { if (!account.token || !space) return; diff --git a/packages/ui/src/workspaceStore.ts b/packages/ui/src/workspaceStore.ts index 9b7fd58..7c4e3f4 100644 --- a/packages/ui/src/workspaceStore.ts +++ b/packages/ui/src/workspaceStore.ts @@ -133,7 +133,7 @@ type WorkspaceStore = PersistedShape & { token: string | null; user: { id: number; name: string; email: string } | null; teams: { id: number; name: string; role: string; owner: string }[]; - spaces: { id: number; name: string; teamId: number | null; updatedAt: number }[]; + spaces: { id: number; name: string; teamId: number | null; updatedAt: number; mockToken?: string }[]; activeSpaceId: number | null; syncing: boolean; lastSyncAt: number | null;