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);
}
}