From e077b12328b79256bf09336df2176fcd87836e27 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 20 Jun 2026 19:04:55 +0800 Subject: [PATCH] fix(frontend): cap envelope mismatch --- docs/changelog/index.md | 2 + docs/design/login-captcha.md | 19 +++++---- frontend/lib/api-envelope.ts | 68 +++++++++++++++++++++++++++++++ frontend/lib/cap-solver.ts | 15 ++++--- internal/apps/cap/routers_test.go | 25 ++++++++---- 5 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 frontend/lib/api-envelope.ts diff --git a/docs/changelog/index.md b/docs/changelog/index.md index bf78cf34..65b58705 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -18,6 +18,8 @@ sidebar: false ### 修复 +- 修复登录 Cap 人机验证:前端 `cap-solver` 与 Cap 路由测试对齐 `b3a55d4` 之后的统一 API 信封 `{ error_msg, data }`,避免 `challenge` 解构失败。 + - 修复 Agent CI/Docker 构建:将 `GeoLite2-Country.mmdb` 提交至仓库作为兜底,构建前优先尝试 `scripts/fetch-agent-geoip-mmdb.sh` 拉取最新库,远程失败时回退使用已提交文件。 ## [v2.3.4] - 2026-06-17 diff --git a/docs/design/login-captcha.md b/docs/design/login-captcha.md index 740d675f..1d690d05 100644 --- a/docs/design/login-captcha.md +++ b/docs/design/login-captcha.md @@ -74,16 +74,19 @@ sequenceDiagram #### 1. 获取难题 (GET/POST /api/cap/challenge) * **请求方式**:`POST` * **接口权限**:公开 -* **响应负载**: +* **响应负载**(统一 API 信封,`data` 为业务载荷): ```json { - "challenge": { - "c": 50, - "s": 32, - "d": 4 - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjIjo1MCwicyI6MzIsImQiOjQsImV4cCI6MTcxNzY2MDgwMCwiaWF0IjoxNzE3NjYwMjAwLCJuIjoiMGExYjJjM2Q0ZTVmNiJ9.signature", - "expires": 1717660800000 + "error_msg": "", + "data": { + "challenge": { + "c": 50, + "s": 32, + "d": 4 + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires": 1717660800000 + } } ``` diff --git a/frontend/lib/api-envelope.ts b/frontend/lib/api-envelope.ts new file mode 100644 index 00000000..ac663822 --- /dev/null +++ b/frontend/lib/api-envelope.ts @@ -0,0 +1,68 @@ +/** + * OpenFlare 统一 API 响应信封解析。 + * 与后端 internal/common/response.Response 及 axios api-client 约定一致。 + */ + +export interface ApiEnvelope { + error_msg: string; + data: T | null; +} + +export class ApiEnvelopeError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiEnvelopeError'; + this.status = status; + } +} + +function hasEnvelopeShape(value: unknown): value is ApiEnvelope { + if (!value || typeof value !== 'object') { + return false; + } + return 'error_msg' in value && 'data' in value; +} + +/** + * 解析 fetch 响应体中的 { error_msg, data } 信封。 + * - HTTP 非 2xx:优先使用 error_msg + * - HTTP 200 但 error_msg 非空:视为业务失败 + */ +export async function readApiEnvelope( + res: Response, + fallbackMessage: string, +): Promise> { + let body: unknown; + try { + body = await res.json(); + } catch { + throw new ApiEnvelopeError(fallbackMessage, res.status); + } + + if (!hasEnvelopeShape(body)) { + throw new ApiEnvelopeError(fallbackMessage, res.status); + } + + const envelope = body as ApiEnvelope; + if (!res.ok) { + throw new ApiEnvelopeError(envelope.error_msg || fallbackMessage, res.status); + } + if (envelope.error_msg) { + throw new ApiEnvelopeError(envelope.error_msg, res.status); + } + + return envelope; +} + +export async function readApiData( + res: Response, + fallbackMessage: string, +): Promise { + const envelope = await readApiEnvelope(res, fallbackMessage); + if (envelope.data == null) { + throw new ApiEnvelopeError(fallbackMessage, res.status); + } + return envelope.data; +} diff --git a/frontend/lib/cap-solver.ts b/frontend/lib/cap-solver.ts index d911ea88..93093bdc 100644 --- a/frontend/lib/cap-solver.ts +++ b/frontend/lib/cap-solver.ts @@ -7,6 +7,8 @@ // ——— Challenge / Redeem API types ——— +import {readApiData} from '@/lib/api-envelope'; + export interface ChallengeResponse { challenge: { c: number; s: number; d: number }; token: string; @@ -138,10 +140,10 @@ export async function getCapToken(scope = 'login'): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }), }); - if (!challengeRes.ok) { - throw new Error('获取人机验证难题失败'); - } - const challenge: ChallengeResponse = await challengeRes.json(); + const challenge = await readApiData( + challengeRes, + '获取人机验证难题失败', + ); const { c: count, s: size, d: difficulty } = challenge.challenge; console.groupCollapsed('[Cap] 人机验证 PoW 求解开始'); @@ -174,7 +176,10 @@ export async function getCapToken(scope = 'login'): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: challenge.token, solutions, scope }), }); - const redeemData: RedeemResponse = await redeemRes.json(); + const redeemData = await readApiData( + redeemRes, + '人机验证失败', + ); if (!redeemData.success || !redeemData.token) { throw new Error(redeemData.error || '人机验证失败'); } diff --git a/internal/apps/cap/routers_test.go b/internal/apps/cap/routers_test.go index 4f72d2ed..64cec8a5 100644 --- a/internal/apps/cap/routers_test.go +++ b/internal/apps/cap/routers_test.go @@ -20,6 +20,21 @@ import ( pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" ) +func decodeAPIResponse[T any](t *testing.T, body []byte) T { + t.Helper() + var envelope struct { + ErrorMsg string `json:"error_msg"` + Data T `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("failed to unmarshal API envelope: %v", err) + } + if envelope.ErrorMsg != "" { + t.Fatalf("unexpected API error_msg: %s", envelope.ErrorMsg) + } + return envelope.Data +} + func TestCapEndpointsAndMiddleware(t *testing.T) { sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() @@ -55,10 +70,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } - var challengeResp pkgcap.ChallengeResponse - if err := json.Unmarshal(w.Body.Bytes(), &challengeResp); err != nil { - t.Fatalf("failed to unmarshal challenge response: %v", err) - } + challengeResp := decodeAPIResponse[pkgcap.ChallengeResponse](t, w.Body.Bytes()) if challengeResp.Token == "" { t.Fatalf("expected token in challenge response") @@ -108,10 +120,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String()) } - var redeemResp RedeemResponse - if err := json.Unmarshal(w.Body.Bytes(), &redeemResp); err != nil { - t.Fatalf("failed to unmarshal redeem response: %v", err) - } + redeemResp := decodeAPIResponse[RedeemResponse](t, w.Body.Bytes()) if !redeemResp.Success || redeemResp.Token == "" { t.Fatalf("redeem failed or returned empty token: %+v", redeemResp)