From a4f6c2ae347bc46550bdd43d9686d40a1929647f Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 21 Jun 2026 11:20:36 +0800 Subject: [PATCH] fix(frontend): cap envelope mismatch --- frontend/lib/api-envelope.ts | 68 +++++++++++++++++++++++++++++++ frontend/lib/cap-solver.ts | 15 ++++--- internal/apps/cap/routers_test.go | 42 +++++++++++-------- 3 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 frontend/lib/api-envelope.ts 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 6db78767..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() @@ -37,6 +52,15 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { c.JSON(http.StatusOK, response.OK("login success")) }) + // Ensure CAPTCHA is disabled initially for step 2 + if err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "false").Error; err != nil { + t.Fatalf("failed to disable cap_login_enabled in DB: %v", err) + } + if err := repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil { + t.Fatalf("InvalidateSystemConfigCache() error = %v", err) + } + InvalidateRuntimeSettings() + // 1. Test challenge generation w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/cap/challenge", nil) @@ -46,14 +70,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) } - var envelope struct { - ErrorMsg string `json:"error_msg"` - Data pkgcap.ChallengeResponse `json:"data"` - } - if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil { - t.Fatalf("failed to unmarshal challenge response: %v", err) - } - challengeResp := envelope.Data + challengeResp := decodeAPIResponse[pkgcap.ChallengeResponse](t, w.Body.Bytes()) if challengeResp.Token == "" { t.Fatalf("expected token in challenge response") @@ -103,14 +120,7 @@ func TestCapEndpointsAndMiddleware(t *testing.T) { t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String()) } - var redeemEnvelope struct { - ErrorMsg string `json:"error_msg"` - Data RedeemResponse `json:"data"` - } - if err := json.Unmarshal(w.Body.Bytes(), &redeemEnvelope); err != nil { - t.Fatalf("failed to unmarshal redeem response: %v", err) - } - redeemResp := redeemEnvelope.Data + redeemResp := decodeAPIResponse[RedeemResponse](t, w.Body.Bytes()) if !redeemResp.Success || redeemResp.Token == "" { t.Fatalf("redeem failed or returned empty token: %+v", redeemResp)