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 字段都没有)。 + * 两个后果: + *

+ */ + .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 稍后重试。 + */ + 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