package com.mydreamjob.deletion; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.security.CurrentUserAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.MediaType; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import java.util.Set; /** * 注销守卫拦截器。 * *

基于当前已认证用户的账号注销状态拦截业务请求: *

* 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。 * 未认证、未注销用户不受影响。

* *

身份解析复用唯一的 {@link CurrentUserAccessor}(R01-A):本拦截器注册在 * {@code /**},先于所有 Controller 执行,因此绝不能自己解析主体。此前它用 * {@code Character::isDigit} 判断后直接 {@code Long.valueOf(name)},导致 Long 溢出主体 * (如 {@code "99999999999999999999"})抛出 {@code NumberFormatException},在到达任何 * Controller 之前就逃逸为全站 HTTP 500。现在非法主体一律视为未认证并放行, * 由 Controller 统一返回 401。

*/ @Component @RequiredArgsConstructor public class DeletionGuardInterceptor implements HandlerInterceptor { private static final Set DELETION_PATHS = Set.of( "/profile/me/deletion", "/profile/me/deletion/cancel"); private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private final ObjectProvider deletionServiceProvider; private final CurrentUserAccessor currentUserAccessor; private final ObjectMapper objectMapper; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 未认证 / 匿名 / 非法或溢出主体 → 一律放行,交由 Controller 按未登录处理(401)。 Long userId = resolveUserIdOrNull(SecurityContextHolder.getContext().getAuthentication()); if (userId == null) { return true; } // 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截 AccountDeletionService deletionService = deletionServiceProvider.getIfAvailable(); if (deletionService == null) { return true; } String path = resolvePath(request); if (DELETION_PATHS.contains(path)) { return true; } DeletionStatus status = deletionService.effectiveStatus(userId); if (status == DeletionStatus.COMPLETED) { return reject(response, ErrorCode.ACCOUNT_DELETED, request); // 409/9303 } if (status == DeletionStatus.PENDING && WRITE_METHODS.contains(request.getMethod().toUpperCase())) { return reject(response, ErrorCode.ACCOUNT_DELETION_PENDING, request); // 409/9302 } return true; } /** * 解析用户 ID;任何非法主体返回 {@code null},绝不抛出。 * *

该安全依赖由构造器强制注入;装配缺失时应用必须失败启动。

*/ private Long resolveUserIdOrNull(Authentication authentication) { try { return currentUserAccessor.requireUserId(authentication); } catch (InsufficientAuthenticationException e) { return null; } } private boolean reject(HttpServletResponse response, ErrorCode errorCode, HttpServletRequest request) throws Exception { response.setStatus(409); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); String requestId = request.getHeader("X-Request-Id"); response.getWriter().write( objectMapper.writeValueAsString(ApiResponse.error(errorCode.getCode(), errorCode.getMessage(), requestId))); return false; } /** 解析去除 context-path 的请求路径,兼容真实服务器与 MockMvc。 */ private String resolvePath(HttpServletRequest request) { String uri = request.getRequestURI(); String ctx = request.getContextPath(); if (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) { return uri.substring(ctx.length()); } return uri; } }