diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/ApiResponseStatusAdvice.java b/backend/src/main/java/com/danyang/match/platform/shared/config/ApiResponseStatusAdvice.java
new file mode 100644
index 0000000..616314e
--- /dev/null
+++ b/backend/src/main/java/com/danyang/match/platform/shared/config/ApiResponseStatusAdvice.java
@@ -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 状态码。
+ *
+ *
为什么需要它:仓库里有约 100 处
+ * {@code return ApiResponse.error(...)} / {@code forbidden()} / {@code notFound()},
+ * 它们不抛异常、也没有 {@code @ResponseStatus},HTTP 状态恒为 200。
+ * 逐个改造成抛异常要动上百个方法,回归面巨大;而在响应写出前统一补状态,
+ * 只需这一处,且不触碰任何 Controller 代码。
+ *
+ *
为什么只补 2xx:异常处理器({@code GlobalExceptionHandler})已经通过
+ * {@code ResponseEntity} 设好了 401/403/409/500 等状态。若这里无差别覆盖,
+ * 就会把已经正确的状态改错。因此只处理「业务失败但状态还是 2xx」这种错配。
+ *
+ *
兼容性:只改 HTTP 状态行,body 的
+ * {@code success / code / message / data} 一字不动。
+ * 依赖 body 判断成败的调用方行为不变;
+ * 依赖状态码的调用方(含小程序 api.js 里早已写好的 401/403/429 分支)
+ * 从此才能走到正确分支。
+ */
+@Slf4j
+@RestControllerAdvice
+public class ApiResponseStatusAdvice implements ResponseBodyAdvice {
+
+ private static final Map 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;
+ }
+
+ /**
+ * 读取响应当前已设置的状态码。
+ *
+ * 无法读取时保守返回 200:宁可继续走映射逻辑,也不要因为读不到
+ * 而误判为"已设定"从而漏掉本该纠正的状态。
+ */
+ private static int resolveCurrentStatus(ServerHttpResponse response) {
+ if (response instanceof ServletServerHttpResponse servletResponse) {
+ return servletResponse.getServletResponse().getStatus();
+ }
+ return HttpStatus.OK.value();
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/CorsConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/CorsConfig.java
index 35141b8..134463a 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/config/CorsConfig.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/config/CorsConfig.java
@@ -13,7 +13,23 @@ import java.util.List;
@Configuration
public class CorsConfig {
- @Value("${cors.allowed-origins:*}")
+ /**
+ * 允许的跨域来源。默认空 = 不放行任何跨域。
+ *
+ *
此前默认值是 {@code *}:配合下面的 {@code allowCredentials(true)},
+ * 等于允许任意站点发起携带凭证的跨域请求(CSRF 直通),
+ * 而注释却写着"默认只允许 localhost"——注释与代码不符,
+ * 这类不一致比没有注释更危险,因为它会让人误以为已经安全。
+ *
+ *
本项目现有的两个客户端都不需要跨域:管理端走 Vite 代理(同源),
+ * 小程序是原生客户端(不受同源策略约束)。因此默认拒绝是安全且无害的,
+ * 真要对接第三方域名时,由部署环境显式列出具体来源。
+ *
+ *
配置时切勿填 {@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 origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
index 5d5779e..b145e6e 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
@@ -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。
+ *
+ * 此前未配置入口点,Spring Security 默认走
+ * {@code Http403ForbiddenEntryPoint}:没带 token 和 token 失效
+ * 都返回 403,body 还是框架默认的空响应(连 code 字段都没有)。
+ * 两个后果:
+ *
+ * - 调用方无法区分「没认证」和「认证了但没权限」——
+ * 这是第三方集成最基础的分流依据;
+ * - 小程序 api.js 只在 401 时触发 onSessionExpired,
+ * token 过期拿到 403 就不会跳登录,用户会卡住。
+ *
+ */
+ .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());
+ }
+ }
}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java b/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java
index f2a504b..3148507 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java
@@ -1,51 +1,82 @@
package com.danyang.match.platform.shared.enums;
import lombok.Getter;
+import org.springframework.http.HttpStatus;
/**
- * 统一 API 响应码
+ * 统一 API 响应码。
+ *
+ * httpStatus 是本枚举的契约字段,不是装饰。
+ * 每个业务码必须自带 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 状态码。
+ *
+ *
为什么必须显式携带:Controller 中存在大量 {@code return ApiResponse.error(...)}
+ * 的直接返回(约 100 处),它们不抛异常、不带 {@code @ResponseStatus},
+ * HTTP 状态恒为 200。客户端只能靠 body 里的 {@code success} 判断成败,
+ * 于是「参数填错」「被封禁」「活动已满」这些性质完全不同的失败,
+ * 在 HTTP 层全都长得一样。
+ *
+ *
后果有两层:一是网关 / 监控 / APM 无法按状态码统计错误率,
+ * 「4xx 客户端错」和「5xx 服务端故障」混为 200;二是调用方
+ * (含小程序 api.js,它已经写好 401/403/429 分支)无法用标准 HTTP 语义分流,
+ * 只能去比对字符串业务码——这对内部调用尚可,对第三方集成是硬伤。
+ *
+ *
取值规则:
+ *
+ * - 400 参数/校验问题(调用方改请求就能通过)
+ * - 403 身份或状态不允许(改请求也没用)
+ * - 409 与当前资源状态冲突(重复操作、名额已满)
+ * - 429 速率或冷却限制(等一会儿重试)
+ *
+ * 区分它们的实际意义是告诉调用方「接下来该做什么」:
+ * 400 改参数、403 别重试、409 先查状态、429 稍后重试。
+ */
+ private final HttpStatus httpStatus;
+
+ ApiResponseCode(String code, String description, HttpStatus httpStatus) {
this.code = code;
this.description = description;
+ this.httpStatus = httpStatus;
}
}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java
index 29762bb..fb15449 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java
@@ -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。
+ *
+ * 此前此处硬编码 {@code @ResponseStatus(BAD_REQUEST)},导致「账号被封禁」
+ * 「活动名额已满」「需要双向匹配」这些性质不同的失败统统返回 400。
+ * 调用方无法从状态码判断该改参数、该放弃、还是该稍后重试。
+ */
@ExceptionHandler(BusinessException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
- public ApiResponse handleBusinessException(BusinessException e) {
+ public ResponseEntity> 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 handleUnauthorized(UnauthorizedException e) {
+ public ResponseEntity> 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 handlePermissionDenied(PermissionDeniedException e) {
+ public ResponseEntity> 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 handleResourceNotFound(ResourceNotFoundException e) {
+ public ResponseEntity> 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 handleConflict(ConflictException e) {
+ public ResponseEntity> 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。
*
- * 此前未映射 {@link IllegalArgumentException},这类"客户端传错值"的问题
- * 会被兜底成 500:调用方无法区分是自己的参数错还是服务端故障,
- * 真实 PostgreSQL 上还会伴随数据库异常语义外泄(H2 测试库则不报错 → 零覆盖)。
+ *
此前既未映射 {@link IllegalArgumentException}(→ 兜底 500),
+ * 补映射后又漏了状态码(→ 200)。两者都让调用方分不清是谁的错。
+ * 现在状态直接取自 {@link ApiResponseCode#BAD_REQUEST}。
*/
@ExceptionHandler(IllegalArgumentException.class)
- public ApiResponse handleIllegalArgument(IllegalArgumentException e) {
- return ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage());
+ public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) {
+ return status(ApiResponseCode.BAD_REQUEST)
+ .body(ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
- @ResponseStatus(HttpStatus.FORBIDDEN)
- public ApiResponse handleAccessDenied(AccessDeniedException e) {
+ public ResponseEntity> 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 handleValidation(MethodArgumentNotValidException e) {
+ public ResponseEntity> handleValidation(MethodArgumentNotValidException e) {
List 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 handleMessageNotReadable(HttpMessageNotReadableException e) {
+ public ResponseEntity> 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 handleMissingParam(MissingServletRequestParameterException e) {
+ public ResponseEntity> 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 handleTypeMismatch(MethodArgumentTypeMismatchException e) {
+ public ResponseEntity> 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 handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
+ public ResponseEntity> 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 handleNoResource(NoResourceFoundException e) {
+ public ResponseEntity> 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 handleUnknownException(Exception e) {
+ public ResponseEntity> 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());
}
}
diff --git a/backend/src/test/java/com/danyang/match/platform/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/danyang/match/platform/auth/controller/AuthControllerTest.java
index 0ff08c5..46d042d 100644
--- a/backend/src/test/java/com/danyang/match/platform/auth/controller/AuthControllerTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/auth/controller/AuthControllerTest.java
@@ -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());
}
}
diff --git a/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java
index e36c7e5..8528bc0 100644
--- a/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java
@@ -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"));
diff --git a/backend/src/test/java/com/danyang/match/platform/shared/enums/ApiResponseCodeHttpStatusTest.java b/backend/src/test/java/com/danyang/match/platform/shared/enums/ApiResponseCodeHttpStatusTest.java
new file mode 100644
index 0000000..d56f5c0
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/shared/enums/ApiResponseCodeHttpStatusTest.java
@@ -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 状态码映射的契约守卫。
+ *
+ * 为什么需要它:此前 Controller 里约 100 处 {@code return ApiResponse.error(...)}
+ * 返回 HTTP 200,客户端只能靠 body 的 {@code success} 判断成败。
+ * 现在状态由 {@link ApiResponseCode#getHttpStatus()} 统一提供,
+ * 但"映射写错"或"后来者给新码配了个 400"都不会有编译错误。
+ * 本测试把语义钉死,防止这套契约悄悄退化。
+ */
+class ApiResponseCodeHttpStatusTest {
+
+ /**
+ * 核心不变量:任何表示失败的业务码,HTTP 状态必须是 4xx/5xx,绝不能是 2xx。
+ *
+ *
若这条挂了,说明某个失败码会被当成成功响应发出去 —— 网关、监控、
+ * 客户端的重试逻辑会全部误判。
+ */
+ @Test
+ @DisplayName("所有失败业务码的 HTTP 状态都不得是 2xx")
+ void testFailureCodesNeverMapTo2xx() {
+ List 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 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 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 codes, HttpStatus expected) {
+ List wrong = codes.stream()
+ .filter(c -> c.getHttpStatus() != expected)
+ .map(c -> c.getCode() + "->" + c.getHttpStatus())
+ .collect(Collectors.toList());
+
+ assertTrue(wrong.isEmpty(),
+ "以下业务码应映射为 " + expected + ",实际不符:" + wrong);
+ }
+}
diff --git a/docs/HTTP语义检修报告-2026-09-18.md b/docs/HTTP语义检修报告-2026-09-18.md
new file mode 100644
index 0000000..d9963cb
--- /dev/null
+++ b/docs/HTTP语义检修报告-2026-09-18.md
@@ -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 过期不再让用户卡死。
diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js
index 632e0cf..ccb3fc0 100644
--- a/miniapp/utils/api.js
+++ b/miniapp/utils/api.js
@@ -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 });
}
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index e78f445..403a955 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -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:
diff --git a/tests/e2e/http_semantics_test.py b/tests/e2e/http_semantics_test.py
new file mode 100644
index 0000000..c3c676d
--- /dev/null
+++ b/tests/e2e/http_semantics_test.py
@@ -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}"
+ )
diff --git a/tests/e2e/multi_type_activity_test.py b/tests/e2e/multi_type_activity_test.py
index d4a16cf..06c03c3 100644
--- a/tests/e2e/multi_type_activity_test.py
+++ b/tests/e2e/multi_type_activity_test.py
@@ -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