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:
WorkBuddy
2026-09-18 14:34:58 +08:00
parent 730dddde30
commit 7682abd85c
13 changed files with 767 additions and 99 deletions
@@ -0,0 +1,93 @@
package com.danyang.match.platform.shared.config;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 让「直接返回失败 ApiResponse」的 Controller 也带上正确的 HTTP 状态码。
*
* <p><b>为什么需要它:</b>仓库里有约 100 处
* {@code return ApiResponse.error(...)} / {@code forbidden()} / {@code notFound()},
* 它们不抛异常、也没有 {@code @ResponseStatus},HTTP 状态恒为 200。
* 逐个改造成抛异常要动上百个方法,回归面巨大;而在响应写出前统一补状态,
* 只需这一处,且不触碰任何 Controller 代码。
*
* <p><b>为什么只补 2xx:</b>异常处理器({@code GlobalExceptionHandler})已经通过
* {@code ResponseEntity} 设好了 401/403/409/500 等状态。若这里无差别覆盖,
* 就会把已经正确的状态改错。因此只处理「业务失败但状态还是 2xx」这种错配。
*
* <p><b>兼容性:</b>只改 HTTP 状态行,body 的
* {@code success / code / message / data} 一字不动。
* 依赖 body 判断成败的调用方行为不变;
* 依赖状态码的调用方(含小程序 api.js 里早已写好的 401/403/429 分支)
* 从此才能走到正确分支。
*/
@Slf4j
@RestControllerAdvice
public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> {
private static final Map<String, ApiResponseCode> BY_CODE =
Arrays.stream(ApiResponseCode.values())
.collect(Collectors.toMap(ApiResponseCode::getCode, Function.identity(), (a, b) -> a));
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return ApiResponse.class.isAssignableFrom(returnType.getParameterType());
}
@Override
public ApiResponse beforeBodyWrite(ApiResponse body,
MethodParameter returnType,
MediaType selectedContentType,
Class selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body == null || body.isSuccess()) {
return body;
}
// ServerHttpResponse 只有 setter 没有 getter,需回溯到 Servlet 层才能读到当前状态。
int currentStatus = resolveCurrentStatus(response);
if (!HttpStatus.valueOf(currentStatus).is2xxSuccessful()) {
// 已由异常处理器或 ResponseEntity 显式指定,尊重原值。
return body;
}
ApiResponseCode mapped = BY_CODE.get(body.getCode());
if (mapped == null) {
// 未知业务码:保持 200 安全降级,不臆造状态。
log.debug("No HTTP status mapping for response code={}, keep 200", body.getCode());
return body;
}
response.setStatusCode(mapped.getHttpStatus());
return body;
}
/**
* 读取响应当前已设置的状态码。
*
* <p>无法读取时保守返回 200:宁可继续走映射逻辑,也不要因为读不到
* 而误判为"已设定"从而漏掉本该纠正的状态。
*/
private static int resolveCurrentStatus(ServerHttpResponse response) {
if (response instanceof ServletServerHttpResponse servletResponse) {
return servletResponse.getServletResponse().getStatus();
}
return HttpStatus.OK.value();
}
}
@@ -13,7 +13,23 @@ import java.util.List;
@Configuration
public class CorsConfig {
@Value("${cors.allowed-origins:*}")
/**
* 允许的跨域来源。默认空 = 不放行任何跨域。
*
* <p>此前默认值是 {@code *}:配合下面的 {@code allowCredentials(true)},
* 等于允许任意站点发起携带凭证的跨域请求(CSRF 直通),
* 而注释却写着"默认只允许 localhost"——注释与代码不符,
* 这类不一致比没有注释更危险,因为它会让人误以为已经安全。
*
* <p>本项目现有的两个客户端都不需要跨域:管理端走 Vite 代理(同源),
* 小程序是原生客户端(不受同源策略约束)。因此默认拒绝是安全且无害的,
* 真要对接第三方域名时,由部署环境显式列出具体来源。
*
* <p>配置时切勿填 {@code *}:带凭证的请求搭配通配来源,
* 浏览器虽会拒绝,但服务端语义上等于对所有来源放行,
* 任何非浏览器调用方(脚本、服务端 SDK)都能直连。
*/
@Value("${cors.allowed-origins:}")
private String allowedOrigins;
@Bean
@@ -21,7 +37,7 @@ public class CorsConfig {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
// 从环境变量读取允许的来源,默认只允许localhost(开发环境)
// 未配置来源时 origins 为空 → 不放行任何跨域请求(默认拒绝)。
List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
@@ -1,8 +1,15 @@
package com.danyang.match.platform.shared.config;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.filter.JwtAuthFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authorization.AuthorizationEventPublisher;
import org.springframework.security.authorization.SpringAuthorizationEventPublisher;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -12,8 +19,9 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import com.danyang.match.platform.shared.filter.JwtAuthFilter;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@@ -33,10 +41,28 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthFilter jwtAuthFilter) throws Exception {
JwtAuthFilter jwtAuthFilter,
ObjectMapper objectMapper) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
/**
* 未认证必须回 401,且 body 必须是统一 ApiResponse。
*
* <p>此前未配置入口点,Spring Security 默认走
* {@code Http403ForbiddenEntryPoint}:没带 token 和 token 失效
* 都返回 403,body 还是框架默认的空响应(连 code 字段都没有)。
* 两个后果:
* <ul>
* <li>调用方无法区分「没认证」和「认证了但没权限」——
* 这是第三方集成最基础的分流依据;</li>
* <li>小程序 api.js 只在 401 时触发 onSessionExpired,
* token 过期拿到 403 就不会跳登录,用户会卡住。</li>
* </ul>
*/
.exceptionHandling(ex -> ex
.authenticationEntryPoint(unauthorizedEntryPoint(objectMapper))
.accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll()
@@ -58,4 +84,33 @@ public class SecurityConfig {
return http.build();
}
/** 未认证 / token 失效 → 401。 */
private static AuthenticationEntryPoint unauthorizedEntryPoint(ObjectMapper objectMapper) {
return (request, response, ex) ->
writeJson(objectMapper, response, HttpStatus.UNAUTHORIZED,
ApiResponse.error(ApiResponseCode.UNAUTHORIZED));
}
/** 已认证但权限不足 → 403。 */
private static AccessDeniedHandler forbiddenHandler(ObjectMapper objectMapper) {
return (request, response, ex) ->
writeJson(objectMapper, response, HttpStatus.FORBIDDEN,
ApiResponse.error(ApiResponseCode.FORBIDDEN));
}
private static void writeJson(ObjectMapper objectMapper,
HttpServletResponse response,
HttpStatus status,
ApiResponse<?> body) {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
try {
objectMapper.writeValue(response.getWriter(), body);
} catch (java.io.IOException e) {
// 走到这里连错误响应都写不出去,只能交给容器兜底。
response.setStatus(status.value());
}
}
}
@@ -1,51 +1,82 @@
package com.danyang.match.platform.shared.enums;
import lombok.Getter;
import org.springframework.http.HttpStatus;
/**
* 统一 API 响应码
* 统一 API 响应码。
*
* <p><b>httpStatus 是本枚举的契约字段,不是装饰。</b>
* 每个业务码必须自带 HTTP 语义,原因见 {@link #httpStatus} 的注释。
*/
@Getter
public enum ApiResponseCode implements BaseEnum {
OK("OK", "成功"),
BAD_REQUEST("BAD_REQUEST", "请求参数错误"),
UNAUTHORIZED("UNAUTHORIZED", "未认证"),
FORBIDDEN("FORBIDDEN", "无权限访问"),
NOT_FOUND("NOT_FOUND", "资源不存在"),
CONFLICT("CONFLICT", "状态冲突"),
VALIDATION_ERROR("VALIDATION_ERROR", "业务校验失败"),
RATE_LIMITED("RATE_LIMITED", "请求过于频繁,请稍后重试"),
SERVER_ERROR("SERVER_ERROR", "服务端内部错误"),
OK("OK", "成功", HttpStatus.OK),
BAD_REQUEST("BAD_REQUEST", "请求参数错误", HttpStatus.BAD_REQUEST),
UNAUTHORIZED("UNAUTHORIZED", "未认证", HttpStatus.UNAUTHORIZED),
FORBIDDEN("FORBIDDEN", "无权限访问", HttpStatus.FORBIDDEN),
NOT_FOUND("NOT_FOUND", "资源不存在", HttpStatus.NOT_FOUND),
CONFLICT("CONFLICT", "状态冲突", HttpStatus.CONFLICT),
VALIDATION_ERROR("VALIDATION_ERROR", "业务校验失败", HttpStatus.BAD_REQUEST),
RATE_LIMITED("RATE_LIMITED", "请求过于频繁,请稍后重试", HttpStatus.TOO_MANY_REQUESTS),
SERVER_ERROR("SERVER_ERROR", "服务端内部错误", HttpStatus.INTERNAL_SERVER_ERROR),
// 业务错误码
PROFILE_NOT_APPROVED("PROFILE_NOT_APPROVED", "当前资料尚未通过审核"),
USER_UNDER_18("USER_UNDER_18", "未满18周岁,不能使用本服务"),
USER_BANNED("USER_BANNED", "账号已被封禁"),
USER_SUSPENDED("USER_SUSPENDED", "账号已暂停"),
ACCOUNT_CLOSED("ACCOUNT_CLOSED", "账号已注销"),
REQUIRE_MUTUAL_MATCH("REQUIRE_MUTUAL_MATCH", "需要双向匹配后才能聊天"),
USER_BLOCKED("USER_BLOCKED", "对方已将你拉黑"),
MATCH_UNMATCHED("MATCH_UNMATCHED", "该匹配关系已解除"),
ALREADY_LIKED("ALREADY_LIKED", "已经表达过喜欢"),
ALREADY_PASSED("ALREADY_PASSED", "已表示不合适"),
ALREADY_BLOCKED("ALREADY_BLOCKED", "已经拉黑过"),
PASS_COOLDOWN("PASS_COOLDOWN", "暂不适合推荐,请稍后再试"),
ACTIVITY_FULL("ACTIVITY_FULL", "活动名额已满"),
ACTIVITY_ALREADY_REGISTERED("ACTIVITY_ALREADY_REGISTERED", "已经报名过该活动"),
ALREADY_CHECKED_IN("ALREADY_CHECKED_IN", "已经签到"),
DUPLICATE_SUBMISSION("DUPLICATE_SUBMISSION", "重复提交,请勿重复操作"),
REDUNDANT_OPERATION("REDUNDANT_OPERATION", "当前状态下无法执行此操作"),
SENSITIVE_ACCESS_DENIED("SENSITIVE_ACCESS_DENIED", "无权查看敏感信息"),
CROSS_ORGANIZATION_DENIED("CROSS_ORGANIZATION_DENIED", "无权访问其他机构数据"),
OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许"),
MATCHMAKER_CANNOT_LIKE("MATCHMAKER_CANNOT_LIKE", "红娘不能替用户操作喜欢"),
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认");
PROFILE_NOT_APPROVED("PROFILE_NOT_APPROVED", "当前资料尚未通过审核", HttpStatus.FORBIDDEN),
USER_UNDER_18("USER_UNDER_18", "未满18周岁,不能使用本服务", HttpStatus.FORBIDDEN),
USER_BANNED("USER_BANNED", "账号已被封禁", HttpStatus.FORBIDDEN),
USER_SUSPENDED("USER_SUSPENDED", "账号已暂停", HttpStatus.FORBIDDEN),
ACCOUNT_CLOSED("ACCOUNT_CLOSED", "账号已注销", HttpStatus.FORBIDDEN),
REQUIRE_MUTUAL_MATCH("REQUIRE_MUTUAL_MATCH", "需要双向匹配后才能聊天", HttpStatus.FORBIDDEN),
USER_BLOCKED("USER_BLOCKED", "对方已将你拉黑", HttpStatus.FORBIDDEN),
MATCH_UNMATCHED("MATCH_UNMATCHED", "该匹配关系已解除", HttpStatus.CONFLICT),
ALREADY_LIKED("ALREADY_LIKED", "已经表达过喜欢", HttpStatus.CONFLICT),
ALREADY_PASSED("ALREADY_PASSED", "已表示不合适", HttpStatus.CONFLICT),
ALREADY_BLOCKED("ALREADY_BLOCKED", "已经拉黑过", HttpStatus.CONFLICT),
PASS_COOLDOWN("PASS_COOLDOWN", "暂不适合推荐,请稍后再试", HttpStatus.TOO_MANY_REQUESTS),
ACTIVITY_FULL("ACTIVITY_FULL", "活动名额已满", HttpStatus.CONFLICT),
ACTIVITY_ALREADY_REGISTERED("ACTIVITY_ALREADY_REGISTERED", "已经报名过该活动", HttpStatus.CONFLICT),
ALREADY_CHECKED_IN("ALREADY_CHECKED_IN", "已经签到", HttpStatus.CONFLICT),
DUPLICATE_SUBMISSION("DUPLICATE_SUBMISSION", "重复提交,请勿重复操作", HttpStatus.CONFLICT),
REDUNDANT_OPERATION("REDUNDANT_OPERATION", "当前状态下无法执行此操作", HttpStatus.CONFLICT),
SENSITIVE_ACCESS_DENIED("SENSITIVE_ACCESS_DENIED", "无权查看敏感信息", HttpStatus.FORBIDDEN),
CROSS_ORGANIZATION_DENIED("CROSS_ORGANIZATION_DENIED", "无权访问其他机构数据", HttpStatus.FORBIDDEN),
OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许", HttpStatus.FORBIDDEN),
MATCHMAKER_CANNOT_LIKE("MATCHMAKER_CANNOT_LIKE", "红娘不能替用户操作喜欢", HttpStatus.FORBIDDEN),
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认", HttpStatus.CONFLICT);
private final String code;
private final String description;
ApiResponseCode(String code, String description) {
/**
* 该业务码对应的 HTTP 状态码。
*
* <p>为什么必须显式携带:Controller 中存在大量 {@code return ApiResponse.error(...)}
* 的直接返回(约 100 处),它们不抛异常、不带 {@code @ResponseStatus},
* HTTP 状态恒为 200。客户端只能靠 body 里的 {@code success} 判断成败,
* 于是「参数填错」「被封禁」「活动已满」这些性质完全不同的失败,
* 在 HTTP 层全都长得一样。
*
* <p>后果有两层:一是网关 / 监控 / APM 无法按状态码统计错误率,
* 「4xx 客户端错」和「5xx 服务端故障」混为 200;二是调用方
* (含小程序 api.js,它已经写好 401/403/429 分支)无法用标准 HTTP 语义分流,
* 只能去比对字符串业务码——这对内部调用尚可,对第三方集成是硬伤。
*
* <p>取值规则:
* <ul>
* <li>400 参数/校验问题(调用方改请求就能通过)</li>
* <li>403 身份或状态不允许(改请求也没用)</li>
* <li>409 与当前资源状态冲突(重复操作、名额已满)</li>
* <li>429 速率或冷却限制(等一会儿重试)</li>
* </ul>
* 区分它们的实际意义是告诉调用方「接下来该做什么」:
* 400 改参数、403 别重试、409 先查状态、429 稍后重试。
*/
private final HttpStatus httpStatus;
ApiResponseCode(String code, String description, HttpStatus httpStatus) {
this.code = code;
this.description = description;
this.httpStatus = httpStatus;
}
}
@@ -6,6 +6,7 @@ import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
@@ -13,7 +14,6 @@ import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@@ -75,110 +75,121 @@ public class GlobalExceptionHandler {
}
}
/**
* 业务异常:HTTP 状态由业务码自带的语义决定,不再一律 400。
*
* <p>此前此处硬编码 {@code @ResponseStatus(BAD_REQUEST)},导致「账号被封禁」
* 「活动名额已满」「需要双向匹配」这些性质不同的失败统统返回 400。
* 调用方无法从状态码判断该改参数、该放弃、还是该稍后重试。
*/
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusinessException(BusinessException e) {
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
log.warn("[{}] Business exception: code={}, message={}",
e.getCode(), e.getCode(), e.getMessage());
return ApiResponse.error(e.getCode(), e.getMessage());
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
@ExceptionHandler(UnauthorizedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> handleUnauthorized(UnauthorizedException e) {
public ResponseEntity<ApiResponse<Void>> handleUnauthorized(UnauthorizedException e) {
log.warn("Authentication failed: {}", e.getMessage());
return ApiResponse.error(e.getCode(), e.getMessage());
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
@ExceptionHandler(PermissionDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handlePermissionDenied(PermissionDeniedException e) {
public ResponseEntity<ApiResponse<Void>> handlePermissionDenied(PermissionDeniedException e) {
log.warn("[{}] Permission denied", e.getCode());
return ApiResponse.error(e.getCode());
return status(e.getCode()).body(ApiResponse.error(e.getCode()));
}
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleResourceNotFound(ResourceNotFoundException e) {
public ResponseEntity<ApiResponse<Void>> handleResourceNotFound(ResourceNotFoundException e) {
log.debug("[{}] Resource not found", e.getCode());
return ApiResponse.error(e.getCode());
return status(e.getCode()).body(ApiResponse.error(e.getCode()));
}
@ExceptionHandler(ConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiResponse<Void> handleConflict(ConflictException e) {
public ResponseEntity<ApiResponse<Void>> handleConflict(ConflictException e) {
log.warn("[{}] Conflict: {}", e.getCode(), e.getMessage());
return ApiResponse.error(e.getCode(), e.getMessage());
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
/**
* 参数非法(例:活动类型不在 {@code chk_activity_type} 白名单内)必须回 400。
* 参数非法(例:活动类型不在白名单内)必须回 400。
*
* <p>此前未映射 {@link IllegalArgumentException},这类"客户端传错值"的问题
* 会被兜底成 500:调用方无法区分是自己的参数错还是服务端故障,
* 真实 PostgreSQL 上还会伴随数据库异常语义外泄(H2 测试库则不报错 → 零覆盖)。
* <p>此前既未映射 {@link IllegalArgumentException}(→ 兜底 500),
* 补映射后又漏了状态码(→ 200)。两者都让调用方分不清是谁的错。
* 现在状态直接取自 {@link ApiResponseCode#BAD_REQUEST}。
*/
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Void> handleIllegalArgument(IllegalArgumentException e) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage());
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
return status(ApiResponseCode.BAD_REQUEST)
.body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
log.warn("Access denied: {}", e.getMessage());
return ApiResponse.error(ApiResponseCode.FORBIDDEN);
return status(ApiResponseCode.FORBIDDEN).body(ApiResponse.error(ApiResponseCode.FORBIDDEN));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) {
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
List<String> errors = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.toList());
log.warn("Validation failed: {}", errors);
return ApiResponse.error(ApiResponseCode.VALIDATION_ERROR, String.join("; ", errors));
return status(ApiResponseCode.VALIDATION_ERROR)
.body(ApiResponse.error(ApiResponseCode.VALIDATION_ERROR, String.join("; ", errors)));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException e) {
public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(HttpMessageNotReadableException e) {
log.warn("Message not readable: {}", e.getMessage());
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "请求体格式错误");
return status(ApiResponseCode.BAD_REQUEST)
.body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, "请求体格式错误"));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleMissingParam(MissingServletRequestParameterException e) {
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("Missing parameter: {}", e.getParameterName());
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少必要参数: " + e.getParameterName());
return status(ApiResponseCode.BAD_REQUEST)
.body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少必要参数: " + e.getParameterName()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
log.warn("Type mismatch for parameter: {}", e.getName());
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "参数类型错误: " + e.getName());
return status(ApiResponseCode.BAD_REQUEST)
.body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, "参数类型错误: " + e.getName()));
}
/**
* 方法不允许是 HTTP 协议层错误,用 405;业务码表里没有对应项,故显式指定。
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ApiResponse<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("Method not supported: {}", e.getMethod());
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "不支持的HTTP方法");
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, "不支持的HTTP方法"));
}
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNoResource(NoResourceFoundException e) {
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
log.debug("No resource found: {}", e.getMessage());
return ApiResponse.error(ApiResponseCode.NOT_FOUND);
return status(ApiResponseCode.NOT_FOUND).body(ApiResponse.error(ApiResponseCode.NOT_FOUND));
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleUnknownException(Exception e) {
public ResponseEntity<ApiResponse<Void>> handleUnknownException(Exception e) {
log.error("Unexpected error", e);
return ApiResponse.error(ApiResponseCode.SERVER_ERROR, "服务端内部错误,请联系管理员");
return status(ApiResponseCode.SERVER_ERROR)
.body(ApiResponse.error(ApiResponseCode.SERVER_ERROR, "服务端内部错误,请联系管理员"));
}
/**
* 从业务码取 HTTP 状态 —— 状态码的唯一来源,避免各处硬编码产生分歧。
*/
private static ResponseEntity.BodyBuilder status(ApiResponseCode code) {
return ResponseEntity.status(code.getHttpStatus());
}
}
@@ -48,7 +48,16 @@ class AuthControllerTest {
}
@Test
void testProtectedEndpointRejectsAnonymousRequest() throws Exception {
/*
* 未认证 = 401,不是 403。
*
* 此前断言 403 是迁就了实现:SecurityConfig 未配置
* authenticationEntryPoint,Spring Security 默认走
* Http403ForbiddenEntryPoint,未认证也回 403。
* 401(需要认证)与 403(认证了但权限不足)必须分开,
* 否则客户端无从判断该去登录还是就此放弃。
*/
mockMvc.perform(get("/api/v1/users/me").contextPath("/api/v1"))
.andExpect(status().isForbidden());
.andExpect(status().isUnauthorized());
}
}
@@ -368,9 +368,15 @@ class ChatPhase09IntegrationTest {
String body = mockMvc.perform(get("/api/v1/conversations/" + conv.getId() + "/messages")
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
// 契约:控制器层以 body 级错误码表达"不可见"(success=false + code=NOT_FOUND),
// 与 MatchController.authorizedMatch 的既有约定一致;HTTP 层仍是 200。
.andExpect(status().isOk())
/**
* 契约:控制器层以 NOT_FOUND 表达"不可见",与
* MatchController.authorizedMatch 的既有约定一致 ——
* 刻意用 404 而非 403,避免通过错误码探测他人会话是否存在。
*
* HTTP 层此前恒为 200(Controller 直接 return 不带状态),
* 现已随业务码带上真实语义:404 + body.code=NOT_FOUND。
*/
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"))
.andReturn().getResponse().getContentAsString();
@@ -400,14 +406,14 @@ class ChatPhase09IntegrationTest {
mockMvc.perform(get("/api/v1/conversations/" + conv.getId())
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
.andExpect(status().isOk())
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
mockMvc.perform(get("/api/v1/conversations/" + conv.getId() + "/unread")
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
.andExpect(status().isOk())
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
@@ -0,0 +1,126 @@
package com.danyang.match.platform.shared.enums;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 业务码 → HTTP 状态码映射的契约守卫。
*
* <p>为什么需要它:此前 Controller 里约 100 处 {@code return ApiResponse.error(...)}
* 返回 HTTP 200,客户端只能靠 body 的 {@code success} 判断成败。
* 现在状态由 {@link ApiResponseCode#getHttpStatus()} 统一提供,
* 但"映射写错"或"后来者给新码配了个 400"都不会有编译错误。
* 本测试把语义钉死,防止这套契约悄悄退化。
*/
class ApiResponseCodeHttpStatusTest {
/**
* 核心不变量:任何表示失败的业务码,HTTP 状态必须是 4xx/5xx,绝不能是 2xx。
*
* <p>若这条挂了,说明某个失败码会被当成成功响应发出去 —— 网关、监控、
* 客户端的重试逻辑会全部误判。
*/
@Test
@DisplayName("所有失败业务码的 HTTP 状态都不得是 2xx")
void testFailureCodesNeverMapTo2xx() {
List<ApiResponseCode> offenders = Arrays.stream(ApiResponseCode.values())
.filter(c -> c != ApiResponseCode.OK)
.filter(c -> c.getHttpStatus().is2xxSuccessful())
.collect(Collectors.toList());
assertTrue(offenders.isEmpty(),
"失败业务码不得映射为 2xx(会让客户端/网关误判为成功):" + offenders);
}
@Test
@DisplayName("身份或状态不允许类的失败必须是 403")
void testDenialCodesMapToForbidden() {
Set<ApiResponseCode> forbidden = Set.of(
ApiResponseCode.FORBIDDEN,
ApiResponseCode.PROFILE_NOT_APPROVED,
ApiResponseCode.USER_UNDER_18,
ApiResponseCode.USER_BANNED,
ApiResponseCode.USER_SUSPENDED,
ApiResponseCode.ACCOUNT_CLOSED,
ApiResponseCode.REQUIRE_MUTUAL_MATCH,
ApiResponseCode.USER_BLOCKED,
ApiResponseCode.SENSITIVE_ACCESS_DENIED,
ApiResponseCode.CROSS_ORGANIZATION_DENIED,
ApiResponseCode.OPERATION_NOT_ALLOWED,
ApiResponseCode.MATCHMAKER_CANNOT_LIKE);
assertAllMapTo(forbidden, HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("与当前状态冲突(重复操作/名额已满)必须是 409")
void testConflictCodesMapToConflict() {
Set<ApiResponseCode> conflicts = Set.of(
ApiResponseCode.CONFLICT,
ApiResponseCode.MATCH_UNMATCHED,
ApiResponseCode.ALREADY_LIKED,
ApiResponseCode.ALREADY_PASSED,
ApiResponseCode.ALREADY_BLOCKED,
ApiResponseCode.ACTIVITY_FULL,
ApiResponseCode.ACTIVITY_ALREADY_REGISTERED,
ApiResponseCode.ALREADY_CHECKED_IN,
ApiResponseCode.DUPLICATE_SUBMISSION,
ApiResponseCode.REDUNDANT_OPERATION,
ApiResponseCode.MATCH_SUCCESS_NEED_BOTH_CONFIRM);
assertAllMapTo(conflicts, HttpStatus.CONFLICT);
}
@Test
@DisplayName("限流与冷却必须是 429,好让调用方知道该稍后重试")
void testThrottlingCodesMapToTooManyRequests() {
assertAllMapTo(Set.of(ApiResponseCode.RATE_LIMITED, ApiResponseCode.PASS_COOLDOWN),
HttpStatus.TOO_MANY_REQUESTS);
}
@Test
@DisplayName("认证与参数类基础码映射正确")
void testBasicCodes() {
assertEquals(HttpStatus.OK, ApiResponseCode.OK.getHttpStatus());
assertEquals(HttpStatus.UNAUTHORIZED, ApiResponseCode.UNAUTHORIZED.getHttpStatus());
assertEquals(HttpStatus.NOT_FOUND, ApiResponseCode.NOT_FOUND.getHttpStatus());
assertEquals(HttpStatus.BAD_REQUEST, ApiResponseCode.BAD_REQUEST.getHttpStatus());
assertEquals(HttpStatus.BAD_REQUEST, ApiResponseCode.VALIDATION_ERROR.getHttpStatus());
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, ApiResponseCode.SERVER_ERROR.getHttpStatus());
}
/**
* 409 与 403 的区分必须保持:前者"改状态后可重试",后者"重试也没用"。
* 若两者混同,调用方就无法决定后续动作。
*/
@Test
@DisplayName("403 与 409 不得混同")
void testForbiddenAndConflictAreDistinct() {
long forbidden = Arrays.stream(ApiResponseCode.values())
.filter(c -> c.getHttpStatus() == HttpStatus.FORBIDDEN).count();
long conflict = Arrays.stream(ApiResponseCode.values())
.filter(c -> c.getHttpStatus() == HttpStatus.CONFLICT).count();
assertTrue(forbidden > 0 && conflict > 0,
"403 与 409 都应有业务码使用;混同会让调用方无法判断能否重试");
}
private static void assertAllMapTo(Set<ApiResponseCode> codes, HttpStatus expected) {
List<String> wrong = codes.stream()
.filter(c -> c.getHttpStatus() != expected)
.map(c -> c.getCode() + "->" + c.getHttpStatus())
.collect(Collectors.toList());
assertTrue(wrong.isEmpty(),
"以下业务码应映射为 " + expected + ",实际不符:" + wrong);
}
}
+197
View File
@@ -0,0 +1,197 @@
# HTTP 语义检修报告(面向第三方开放)
- 日期:2026-09-18
- 起因:Phase 23B 收尾时留下的问题——「HTTP 200 + body.code 这套约定,将来要开放第三方还守得住吗?」
- 结论:**守不住,且当时就已经是断的**。已检修;过程中暴露 3 处被测试固化的错误期望与 1 处 CORS 安全缺陷。
---
## 一、起因:一条"迁就实现"的断言
Phase 23B 里我把一条 E2E 断言改成了:
```python
# 本项目的约定:业务错误是 HTTP 200 + body.code
assert body.get("code") == "BAD_REQUEST"
```
那段注释准确描述了代码行为,但它描述的对象恰恰是一个缺陷:
`handleIllegalArgument` 是全局异常处理器里**唯一没有 `@ResponseStatus` 的 handler**,
于是它返回 HTTP 200。我让断言迁就了实现,而不是修实现。
顺着这条线查下去,发现问题远不止一处。
---
## 二、审计发现
### 1. 主体问题:约 100 处失败响应顶着 HTTP 200
`ApiResponse.error(...)` / `forbidden()` / `notFound()` 在 Controller 里被直接
`return`,不抛异常、不带 `@ResponseStatus` —— HTTP 状态恒为 200。
统计:**105 处,分布在 30 个 Controller**。
后果分两层:
- **内部**:网关、监控、APM 无法按状态码统计错误率,「4xx 客户端错」与「5xx 服务端故障」全混在 200 里。
- **外部**:第三方集成商不会去比对字符串业务码。他们看的是状态码——
尤其是「能不能重试」这件事:**400 改参数、403 别重试、409 先查状态、429 稍后重试**。
全部返回 200 等于把这四种处置方式全部抹平。
### 2. 前后端契约当时就是断的(不只是"将来的风险")
小程序 `utils/api.js` 早就写好了 401 / 403 / 429 三个分支:
```js
} else if (res.statusCode === 401) { this.clearToken(); ... } // 触发重新登录
else if (res.statusCode === 403) { ... }
else if (res.statusCode === 429) { ... }
```
后端从未返回过这些状态码,这三个分支**永远走不到**,全部退化成 `BUSINESS_ERROR`。
最实际的一处伤害:token 过期时后端回 403,小程序因此**不会触发 `onSessionExpired`**,
用户卡在无法重新登录的状态。
### 3. 未认证返回 403,且 body 不是统一结构
`SecurityConfig` 从未配置 `authenticationEntryPoint`,Spring Security 默认走
`Http403ForbiddenEntryPoint`:没带 token 和 token 失效都返回 **403**,
body 是框架默认响应,**连 `code` 字段都没有**(实测 `body.code` 为 None)。
401(需要认证)与 403(认证了但权限不足)混同,是第三方集成最基础的分流依据缺失。
### 4. CORS 默认放行任意来源,且注释在撒谎
```java
@Value("${cors.allowed-origins:*}") // 默认通配
config.setAllowCredentials(true); // 允许携带凭证
// 注释却写着:"默认只允许localhost(开发环境)"
```
`allowCredentials(true)` + 通配来源 = 任意站点可发起带凭证的跨域请求(CSRF 直通)。
而注释声称的限制并不存在。**注释与代码不符比没有注释更危险**,它会让人误以为已经安全。
### 5. 状态码这层契约几乎无人看守
后端全量测试中,只有 **4 个文件、15 处**断言触及 HTTP 状态码。
这也是这套错配能长期存在的原因。
---
## 三、检修内容
### 设计取向:一处定义、两处应用、业务代码零改动
改造 105 处 Controller 代价高、回归面大。改为:
| 改动 | 作用 |
|---|---|
| `ApiResponseCode` 新增 `httpStatus` 字段 | 业务码 → HTTP 状态的**单一真相源**,构造函数强制携带,新增码不填就编译不过 |
| `GlobalExceptionHandler` 改为从枚举读状态 | 移除各处硬编码的 `@ResponseStatus`,避免与枚举产生分歧 |
| 新增 `ApiResponseStatusAdvice`(`ResponseBodyAdvice`) | 在响应写出前统一补状态,覆盖 105 处 Controller 直返,**不动任何 Controller** |
| `SecurityConfig` 补 401/403 入口点 | 未认证回 401,且 body 统一为 `ApiResponse` |
| `CorsConfig` 默认值改为空 | 默认拒绝跨域;生产由部署环境显式列出来源 |
| 小程序 `api.js` 401/403/429/409 分支 | 优先使用服务端 `body.message`,避免业务原因被硬编码文案冲掉 |
**Advice 的两个关键约束**(都写进了代码注释):
1. **只补 2xx。** 异常处理器已经设好 401/403/409/500,无差别覆盖会把已正确的状态改错。
由于 `ServerHttpResponse` 只有 setter 没有 getter,需向下转型到
`ServletServerHttpResponse` 才能读到当前状态。
2. **只改状态行,body 一字不动。** 依赖 `success/code` 判断的调用方行为不变;
依赖状态码的调用方从此走到正确分支。
### 业务码映射规则
| 语义 | 状态码 | 含义(告诉调用方下一步做什么) |
|---|---|---|
| 参数/校验问题 | 400 | 改请求就能通过 |
| 身份或状态不允许 | 403 | 改请求也没用,别重试 |
| 与当前状态冲突 | 409 | 先查状态,别盲目重试 |
| 速率或冷却限制 | 429 | 等一会儿重试 |
典型:`USER_BANNED / USER_UNDER_18 / REQUIRE_MUTUAL_MATCH / SENSITIVE_ACCESS_DENIED / MATCHMAKER_CANNOT_LIKE` → 403;
`ALREADY_LIKED / ACTIVITY_FULL / DUPLICATE_SUBMISSION` → 409;
`RATE_LIMITED / PASS_COOLDOWN` → 429。
---
## 四、验证证据
### 1. 活栈实测(最关键:证明 Advice 真的生效)
| 场景 | 路径 | 结果 |
|---|---|---|
| Controller 直返 `notFound()` | `POST /matches/{不存在UUID}/success/request` | **404** |
| 非法活动类型(异常处理器) | `GET /activities/types/not-a-real-type` | **400** |
| 非 UUID 路径参数 | `GET /counseling/services/not-a-uuid` | **400** |
| 未认证 | `GET /notifications`(无 token) | **401** |
第一条是判定性的:那条路径不抛异常、不进异常处理器,
若 Advice 失效(包扫描漏了、`supports` 写错),它会**悄悄退回 200,而枚举层的单元测试依然全绿**。
### 2. E2E
`37 passed`(原 33 + 新增 4 条契约用例)。
新增 `tests/e2e/http_semantics_test.py` 把「失败必须携带真实 HTTP 语义」钉在活栈上。
### 3. 后端全量回归
```
Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
```
基线 656(Phase 23B 后),净 +6 为新增的
`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)。
3 个 skip 均有显式原因,无一为空壳。
首个回归轮次为 **662 / 3 failures**,三处失败均已判定为「测试断言了旧的错误行为」
(详见第五节),修正断言(而非改回实现)后归零。
---
## 五、被测试固化的错误期望(3 处)
回归从 656 → 662 时暴露 3 处失败,全部是**测试在断言旧的错误行为**:
| 测试 | 旧期望 | 新实际 | 判定 |
|---|---|---|---|
| `AuthControllerTest.testProtectedEndpointRejectsAnonymousRequest` | 403 | 401 | 新行为对 |
| `ChatPhase09IntegrationTest.outsiderCannotReadConversationDetailNorUnread` | 200 | 404 | 新行为对 |
| `ChatPhase09IntegrationTest.outsiderCannotReadMessages` | 200 | 404 | 新行为对 |
后两处的原注释直接写着「HTTP 层仍是 200」,正是本轮检修掉的约定。
改断言时**保留了各自的实质性安全断言**(如「越权响应不得泄露聊天正文」)。
「用 404 而非 403 表达不可见」是**刻意的安全设计**:若回 403,
攻击者可枚举 ID 区分「存在但无权」与「根本不存在」,从而探测他人关系是否存在。
该设计在 `MatchController#authorizedMatch` 注释中亦有说明,本轮予以保留。
---
## 六、遗留:真正开放第三方还需要什么(本轮未做)
以下均属范围扩张,**需产品决策**,本轮不擅自实施:
1. **第三方凭据机制**:目前只有微信登录与运营账号登录,第三方没有标准途径获取 token。
需定义 API Key / OAuth Client Credentials,以及与之配套的权限范围与配额。
2. **限流是否真实生效**:`RATE_LIMITED` 业务码与 429 映射已就位,
但限流本身是否启用、阈值多少,本轮未验证。
3. **OpenAPI 文档对外发布**:已有 `OpenApiConfig`,是否开放、开放哪些端点需决策。
4. **审计与可观测**:第三方调用需要独立的调用审计与配额计量。
5. **分页稳定性与幂等**:`Idempotency-Key` 已支持,但需确认第三方可见端点的覆盖范围。
6. **敏感字段脱敏边界**:现有 INV-011 等约束面向内部角色,第三方场景需重新界定。
---
## 七、结论
本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制:
- 状态码的**唯一来源**是 `ApiResponseCode.httpStatus`,新增业务码不声明就编译不过;
- 105 处 Controller 直返**零改动**即获得正确状态;
- 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录;
- CORS 默认从「放行任意来源」改为「默认拒绝」。
顺带修掉了一个当时就会伤害用户的缺陷:token 过期不再让用户卡死。
+30 -3
View File
@@ -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 });
}
+6 -1
View File
@@ -128,7 +128,12 @@ def admin_token(api: ApiClient, operator_password: str) -> str:
def operator_token(
api: ApiClient, operator_password: str
) -> Callable[[str], str]:
"""按用户名取运营 token(会话内缓存)。用法:`operator_token("counselor01")`。"""
"""按用户名取运营 token(会话内缓存)。
可用账号见 tests/e2e/sql/seed_e2e_operators.sql:
e2e_admin / e2e_platform / e2e_counselor / e2e_station / e2e_matchmaker / e2e_content,
外加迁移 seed 的 admin。用法:`operator_token("e2e_counselor")`。
"""
cache: Dict[str, str] = {}
def _get(username: str) -> str:
+88
View File
@@ -0,0 +1,88 @@
"""
HTTP 语义契约 E2E。
背景:此前 Controller 里约 100 处 `return ApiResponse.error(...)` 不抛异常、
不带 @ResponseStatus,HTTP 状态恒为 200 —— 客户端只能靠 body 的 success 判断
成败。对内部调用尚可(虽然小程序 api.js 早已写好 401/403/429 分支却永远走不到),
对第三方集成是硬伤:集成商不会去比对字符串业务码。
本文件把「失败必须携带真实 HTTP 语义」钉死在活栈上。
单元测试(ApiResponseCodeHttpStatusTest)只能证明枚举映射写对了,
证明不了 Controller 直返路径真的用上了它 —— 那要靠这里的真实 HTTP。
"""
import pytest
pytestmark = pytest.mark.e2e
# 一个确定不存在的 UUID:走的是 Controller 里 `return ApiResponse.notFound()` 的直返路径。
MISSING_UUID = "00000000-0000-0000-0000-000000000000"
def test_controller_direct_return_carries_http_status(api, user_token):
"""
关键用例:证明 ResponseBodyAdvice 真的覆盖了 Controller 直返。
这条路径不抛异常、不进 GlobalExceptionHandler,
若 Advice 失效(例如包扫描漏了、或 supports 判断写错),
它会悄悄退回 200 —— 而枚举层面的单元测试依然全绿。
"""
resp = api.call(
"POST", f"/matches/{MISSING_UUID}/success/request", token=user_token, json={}
)
assert resp.status_code == 404, (
f"Controller 直返 notFound() 应为 HTTP 404,实际 {resp.status_code}。"
f"若为 200,说明 ApiResponseStatusAdvice 未生效:{resp.text[:300]}"
)
def test_unauthenticated_is_401_not_403(api):
"""
未认证必须是 401,不能是 403。
Spring Security 未配 authenticationEntryPoint 时默认走
Http403ForbiddenEntryPoint,未认证也回 403 —— 调用方无法区分
「没带 token」和「带了但权限不够」,小程序也不会触发重新登录。
"""
resp = api.call("GET", "/notifications")
assert resp.status_code == 401, (
f"未认证应回 401,实际 {resp.status_code}:{resp.text[:300]}"
)
body = resp.json()
assert body.get("code") == "UNAUTHORIZED", f"body 业务码应为 UNAUTHORIZED:{body}"
def test_error_responses_share_uniform_body(api, user_token):
"""
无论 4xx 来自哪条路径(Controller 直返 / 异常处理器 / Security 入口点),
body 结构必须一致,否则第三方要写三套解析逻辑。
"""
cases = [
("GET", "/notifications", None), # 401,Security 入口点
("GET", "/activities/types/not-a-real-type", user_token), # 400,异常处理器
("POST", f"/matches/{MISSING_UUID}/success/request", user_token), # 404,Controller 直返
]
for method, path, token in cases:
resp = api.call(method, path, token=token, json={} if method == "POST" else None)
assert 400 <= resp.status_code < 500, (
f"{method} {path} 应回 4xx,实际 {resp.status_code}:{resp.text[:300]}"
)
body = resp.json()
assert body.get("success") is False, f"{path} 的 success 应为 false:{body}"
assert body.get("code"), f"{path} 缺少业务码 code:{body}"
assert body.get("message"), f"{path} 缺少提示 message:{body}"
def test_not_found_is_used_to_avoid_existence_probing(api, user_token):
"""
访问不属于自己的 Match 必须回 404 而非 403。
这是刻意的安全设计(见 MatchController#authorizedMatch 注释):
若回 403,攻击者就能枚举 ID 区分「存在但无权」与「根本不存在」,
从而探测他人关系是否存在。
"""
resp = api.call(
"POST", f"/matches/{MISSING_UUID}/success/confirm", token=user_token, json={}
)
assert resp.status_code == 404, (
f"非本人 Match 应回 404(避免存在性探测),实际 {resp.status_code}"
)
+10 -6
View File
@@ -32,14 +32,18 @@ def test_activities_can_be_filtered_by_type(api, user_token, activity_type):
)
def test_invalid_activity_type_returns_bad_request_not_500(api, user_token):
def test_invalid_activity_type_returns_400(api, user_token):
"""
非法类型属客户端参数错误,必须回 BAD_REQUEST。
非法类型属客户端参数错误,必须回 HTTP 400。
注意本项目的约定:业务错误是 **HTTP 200 + body.code**(只有框架层
鉴权失败才是真 HTTP 403)。因此这里断言的是 code,不是 HTTP 状态码。
这里曾断言「HTTP 200 + body.code == BAD_REQUEST」——那是迁就了当时的实现:
handleIllegalArgument 补映射时漏了 @ResponseStatus,于是返回 200。
约定已检修(业务码自带 HTTP 语义),断言随之回到正确的期望值。
"""
resp = api.call("GET", "/activities/types/not-a-real-type", token=user_token)
assert resp.status_code != 500, f"非法活动类型触发了 500:{resp.text[:200]}"
assert resp.status_code == 400, (
f"非法活动类型应回 HTTP 400,实际 {resp.status_code}:{resp.text[:200]}"
)
body = resp.json()
assert body.get("code") == "BAD_REQUEST", f"非法类型应回 BAD_REQUEST,实际 {body}"
assert body.get("code") == "BAD_REQUEST", f"body 业务码应为 BAD_REQUEST,实际 {body}"
assert body.get("success") is False