package com.mydreamjob.controller; import com.mydreamjob.auth.AuthService; import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.InvalidTokenException; import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.SmsVerificationService; import com.mydreamjob.auth.dto.RefreshTokenRequest; import com.mydreamjob.auth.dto.TokenPairResponse; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.Map; /** * 认证相关接口 * *

S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、 * 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。

*/ @Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @Validated public class AuthController { private final AuthService authService; private final SmsVerificationService smsVerificationService; private final CurrentUserAccessor currentUserAccessor; @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @PostMapping("/sms/send") public ResponseEntity>> sendSmsCode( @RequestParam @NotBlank(message = "phone is required") @Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone, @RequestParam(defaultValue = "login") String purpose, HttpServletRequest request) { try { smsVerificationService.sendCode(phone, purpose, getRequestId(request), request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok( Map.of("message", "验证码已发送(Mock)"), getRequestId(request))); } catch (ConstraintViolationException e) { return ResponseEntity.badRequest().body( ApiResponse.badRequest(e.getMessage(), getRequestId(request))); } catch (Exception e) { return ResponseEntity.badRequest().body( ApiResponse.error(400, e.getMessage(), getRequestId(request))); } } @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话") @PostMapping("/login") public ResponseEntity>> mockLogin( @Valid @RequestBody LoginRequest body, HttpServletRequest request) { String phone = body.getPhone(); String code = body.getCode(); String purpose = body.getPurpose() == null || body.getPurpose().isBlank() ? "login" : body.getPurpose(); try { Map result = authService.mockLogin(phone, code, purpose, getRequestId(request), request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); } catch (BusinessException e) { // 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。 // 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。 if (e.getErrorCodeInt() == ErrorCode.ACCOUNT_DELETED.getCode()) { throw e; } return ResponseEntity.badRequest().body( ApiResponse.error(400, e.getMessage(), getRequestId(request))); } catch (Exception e) { return ResponseEntity.badRequest().body( ApiResponse.error(400, e.getMessage(), getRequestId(request))); } } @Operation(summary = "刷新 Token", description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效") @PostMapping("/refresh") public ResponseEntity> refresh( @Valid @RequestBody RefreshTokenRequest body, HttpServletRequest request) { try { AuthSessionService.SessionTokens tokens = authService.refresh( body.getRefreshToken(), getRequestId(request), request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok( new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(), AuthService.EXPIRES_IN_SECONDS), getRequestId(request))); } catch (InvalidTokenException e) { // 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。 return unauthorized(request); } } @Operation(summary = "登出", description = "撤销当前登录会话;需要 Access Token,不接受任何请求体") @PostMapping("/logout") public ResponseEntity>> logout(HttpServletRequest request) { // 身份与会话一律来自通过校验的 Access Token,不接受客户端传入 userId/sessionId。 Long userId = currentUserAccessor.requireUserId(); String sessionId = currentUserAccessor.requireSessionId(); authService.logout(userId, sessionId, getRequestId(request), request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok( Map.of("message", "logged out"), getRequestId(request))); } @Operation(summary = "当前用户信息", description = "需要 JWT 认证") @GetMapping("/me") public ResponseEntity>> me(HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request))); } private ResponseEntity> unauthorized(HttpServletRequest request) { return ResponseEntity.status(401).body( ApiResponse.unauthorized("invalid or expired token", getRequestId(request))); } private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } }