轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

149 lines
7.3 KiB

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;
/**
* 认证相关接口
*
* <p>S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、
* 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。</p>
*/
@Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Validated
public class AuthController {
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final AuthService authService;
private final SmsVerificationService smsVerificationService;
private final CurrentUserAccessor currentUserAccessor;
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send")
public ResponseEntity<ApiResponse<Map<String, String>>> 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,
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "验证码已发送(Mock)"),
requestIdAccessor.get(request)));
} catch (ConstraintViolationException e) {
return ResponseEntity.badRequest().body(
ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
}
}
@Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话")
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<String, Object> result = authService.mockLogin(phone, code, purpose,
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(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(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
}
}
@Operation(summary = "刷新 Token",
description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效")
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenPairResponse>> refresh(
@Valid @RequestBody RefreshTokenRequest body,
HttpServletRequest request) {
try {
AuthSessionService.SessionTokens tokens = authService.refresh(
body.getRefreshToken(),
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(),
AuthService.EXPIRES_IN_SECONDS),
requestIdAccessor.get(request)));
} catch (InvalidTokenException e) {
// 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。
return unauthorized(request);
}
}
@Operation(summary = "登出",
description = "撤销当前登录会话;需要 Access Token,不接受任何请求体")
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Map<String, String>>> logout(HttpServletRequest request) {
// 身份与会话一律来自通过校验的 Access Token,不接受客户端传入 userId/sessionId。
Long userId = currentUserAccessor.requireUserId();
String sessionId = currentUserAccessor.requireSessionId();
authService.logout(userId, sessionId,
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "logged out"), requestIdAccessor.get(request)));
}
@Operation(summary = "当前用户信息", description = "需要 JWT 认证")
@GetMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> me(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request)));
}
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request)));
}
}