fix(frontend): cap envelope mismatch

This commit is contained in:
ryan
2026-06-21 11:20:36 +08:00
parent 6e6bce6a03
commit a4f6c2ae34
3 changed files with 104 additions and 21 deletions
+68
View File
@@ -0,0 +1,68 @@
/**
* OpenFlare 统一 API 响应信封解析。
* 与后端 internal/common/response.Response 及 axios api-client 约定一致。
*/
export interface ApiEnvelope<T = unknown> {
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<unknown> {
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<T>(
res: Response,
fallbackMessage: string,
): Promise<ApiEnvelope<T>> {
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<T>;
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<T>(
res: Response,
fallbackMessage: string,
): Promise<T> {
const envelope = await readApiEnvelope<T>(res, fallbackMessage);
if (envelope.data == null) {
throw new ApiEnvelopeError(fallbackMessage, res.status);
}
return envelope.data;
}
+10 -5
View File
@@ -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<string> {
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<ChallengeResponse>(
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<string> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: challenge.token, solutions, scope }),
});
const redeemData: RedeemResponse = await redeemRes.json();
const redeemData = await readApiData<RedeemResponse>(
redeemRes,
'人机验证失败',
);
if (!redeemData.success || !redeemData.token) {
throw new Error(redeemData.error || '人机验证失败');
}
+26 -16
View File
@@ -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)