fix(api): give failures real HTTP semantics; fix 401/403 confusion and CORS default
Phase 23B ended with an E2E assertion changed to expect "HTTP 200 + body.code == BAD_REQUEST", documented at the time as "this project's convention". That description was accurate about the code and wrong about the intent: it described a defect, and I made the test accommodate it instead of fixing it. Auditing it found the convention was already broken, not merely risky: - ~100 sites (105, across 30 controllers) do `return ApiResponse.error(...)` / `forbidden()` / `notFound()` with no exception and no `@ResponseStatus`, so the HTTP status is always 200. Gateways and APM cannot separate 4xx from 5xx; callers cannot tell "fix your request" (400) from "don't retry" (403) from "check state first" (409) from "retry later" (429). - miniapp/utils/api.js already had 401/403/429 branches that could never be reached, since the backend never returned those codes. The concrete damage: an expired token returned 403, so `onSessionExpired` never fired and users were stuck with no way back to the login flow. - SecurityConfig had no `authenticationEntryPoint`, so Spring's default `Http403ForbiddenEntryPoint` returned 403 for unauthenticated calls, with a framework body that has no `code` field at all. - CorsConfig defaulted `allowed-origins` to `*` together with `allowCredentials(true)`, while its comment claimed "localhost only by default". Comment contradicting code is worse than no comment: it reads as safe. - Only 4 files / 15 assertions in the whole backend suite touched HTTP status, which is how this survived. Approach: one definition, two applications, zero controller changes. - ApiResponseCode gains an `httpStatus` field — the single source of truth. The constructor requires it, so a new code cannot be added without one. - GlobalExceptionHandler reads status from the enum instead of hardcoding `@ResponseStatus` per handler. - New ApiResponseStatusAdvice (ResponseBodyAdvice) fills in the status just before the body is written, covering all ~100 direct-return sites without touching them. Two constraints, both noted in code: only downgrade when the current status is still 2xx (the advice also sees exception-handler results, and must not override an already-correct 401/403/409), and never alter the body. - SecurityConfig: entry point returns 401 with a uniform ApiResponse body; access denied handler returns 403 likewise. - CorsConfig: default changed to empty (deny). Both clients are same-origin anyway — nginx proxies /api/ and Vite proxies /api — so nothing depends on it. - miniapp api.js: 401/403/429/409 branches now prefer the server's `body.message`, otherwise "账号已被封禁" would be flattened to "无权限访问". Implementation trap worth remembering: `ServerHttpResponse` has `setStatusCode` but no getter, so reading the current status requires casting to `ServletServerHttpResponse`. Verification: 37 E2E passed (33 + 4 new contract cases), including a live-stack check that a controller's direct `notFound()` now yields 404 — that path throws no exception and never reaches the exception handler, so if the advice were inactive it would silently return 200 while the enum unit tests stayed green. Backend regression 662 / 0 failures / 0 errors / 3 skipped (+6 new guards). The first regression run had 3 failures, all tests asserting the old wrong behavior (anonymous access expected 403; outsiders reading a conversation expected 200). Fixed the assertions, not the implementation. Where 404 is used instead of 403 for "invisible", that is deliberate — it prevents probing whether another user's conversation exists — and the substantive assertions (e.g. that an unauthorized response must not leak message bodies) were kept. Not done, needs a product decision: third-party credential issuance, whether rate limiting is actually active, publishing OpenAPI externally, per-caller audit and quotas, pagination/idempotency coverage, desensitization boundaries.
This commit is contained in:
+30
-3
@@ -107,13 +107,40 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
} else if (res.statusCode === 401) {
|
||||
// 服务端 message 优先:token 失效的原因可能各不相同(过期/被踢/未登录)。
|
||||
const body = res.data || {};
|
||||
this.clearToken();
|
||||
if (typeof this.onSessionExpired === 'function') this.onSessionExpired();
|
||||
reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: '请重新登录', requestId });
|
||||
reject({ code: REQUEST_ERROR.UNAUTHORIZED, message: body.message || '请重新登录', requestId });
|
||||
} else if (res.statusCode === 403) {
|
||||
reject({ code: REQUEST_ERROR.FORBIDDEN, message: '无权限访问', requestId });
|
||||
/**
|
||||
* 403 现在承载的不只是"无权限",还有账号被封禁、资料未过审、
|
||||
* 未双向匹配等状态类拒绝。code 保留服务端业务码(如 USER_BANNED),
|
||||
* message 保留服务端原文 —— 否则页面只会看到一句"无权限访问",
|
||||
* 用户不知道自己为什么被拦、该怎么处理。
|
||||
*/
|
||||
const body = res.data || {};
|
||||
reject({
|
||||
code: body.code || REQUEST_ERROR.FORBIDDEN,
|
||||
message: body.message || '无权限访问',
|
||||
requestId,
|
||||
});
|
||||
} else if (res.statusCode === 429) {
|
||||
reject({ code: REQUEST_ERROR.RATE_LIMITED, message: '请求过于频繁,请稍后重试', requestId });
|
||||
// 既可能是限流,也可能是 PASS_COOLDOWN 这类冷却,原文更有信息量。
|
||||
const body = res.data || {};
|
||||
reject({
|
||||
code: body.code || REQUEST_ERROR.RATE_LIMITED,
|
||||
message: body.message || '请求过于频繁,请稍后重试',
|
||||
requestId,
|
||||
});
|
||||
} else if (res.statusCode === 409) {
|
||||
// 与当前状态冲突(重复报名、名额已满、已喜欢过):不该重试,先查状态。
|
||||
const body = res.data || {};
|
||||
reject({
|
||||
code: body.code || REQUEST_ERROR.BUSINESS_ERROR,
|
||||
message: body.message || '当前状态无法执行此操作',
|
||||
requestId,
|
||||
});
|
||||
} else {
|
||||
reject({ code: REQUEST_ERROR.SERVER_ERROR, message: '服务暂时不可用', requestId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user