Files
2026-03-22 15:24:40 +08:00

443 lines
17 KiB
Java
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自然写互动课堂教学管理云平台软件 V1.0
*
* 用户认证控制器
* 负责用户登录、登出、Token刷新等认证相关接口
* 采用 JWT Token + Refresh Token 双令牌机制
*/
package com.writech.cloud.controller;
import com.writech.cloud.WritechCloudApplication.ApiResponse;
import com.writech.cloud.WritechCloudApplication.BusinessException;
import com.writech.cloud.model.User;
import com.writech.cloud.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.time.LocalDateTime;
/**
* 认证控制器 - /api/v1/auth
*
* 实现教师/学生/管理员/家长多角色用户的统一认证
* 支持手机号+密码、手机号+验证码、微信/钉钉第三方登录
*/
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@Autowired
private UserService userService;
/** JWT密钥 */
@Value("${writech.jwt.secret:writech-cloud-platform-jwt-secret-key-2026}")
private String jwtSecret;
/** Access Token 有效期(秒),默认2小时 */
@Value("${writech.jwt.access-token-expire:7200}")
private long accessTokenExpire;
/** Refresh Token 有效期(秒),默认7天 */
@Value("${writech.jwt.refresh-token-expire:604800}")
private long refreshTokenExpire;
/**
* 用户登录接口
* POST /api/v1/auth/login
*
* 验证用户身份,签发 JWT Access Token 和 Refresh Token
* Access Token 有效期2小时,Refresh Token 有效期7天
*
* @param request 登录请求(包含手机号、密码/验证码、登录方式)
* @return 包含双令牌和用户基本信息的响应
*/
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
// 校验登录参数
if (request.getLoginType() == null) {
throw new BusinessException(400, "登录方式不能为空");
}
User user = null;
// 根据不同登录方式验证身份
switch (request.getLoginType()) {
case "password":
// 手机号 + 密码登录
user = userService.verifyByPassword(request.getPhone(), request.getPassword());
break;
case "sms":
// 手机号 + 短信验证码登录
user = userService.verifyBySmsCode(request.getPhone(), request.getSmsCode());
break;
case "wechat":
// 微信授权登录
user = userService.verifyByWechat(request.getWechatCode());
break;
case "dingtalk":
// 钉钉授权登录
user = userService.verifyByDingtalk(request.getDingtalkCode());
break;
default:
throw new BusinessException(400, "不支持的登录方式: " + request.getLoginType());
}
if (user == null) {
throw new BusinessException(401, "登录失败,用户名或密码错误");
}
// 检查用户状态
if (user.getStatus() != 1) {
throw new BusinessException(403, "账户已被禁用,请联系管理员");
}
// 生成双令牌
String accessToken = generateAccessToken(user);
String refreshToken = generateRefreshToken(user);
// 更新用户最后登录时间和登录IP
userService.updateLoginInfo(user.getId(), LocalDateTime.now(), request.getClientIp());
// 构建登录响应
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(accessTokenExpire);
response.setUserId(user.getId());
response.setUserName(user.getName());
response.setRole(user.getRole());
response.setSchoolId(user.getSchoolId());
response.setSchoolName(user.getSchoolName());
return ApiResponse.success(response);
}
/**
* Token 刷新接口
* POST /api/v1/auth/refresh
*
* 使用 Refresh Token 换取新的 Access Token
* 避免用户频繁重新登录,提升使用体验
*
* @param request 刷新请求(包含 Refresh Token
* @return 新的 Access Token
*/
@PostMapping("/refresh")
public ApiResponse<TokenRefreshResponse> refreshToken(@Valid @RequestBody TokenRefreshRequest request) {
try {
// 解析并验证 Refresh Token
Claims claims = parseToken(request.getRefreshToken());
String userId = claims.getSubject();
String tokenType = claims.get("type", String.class);
// 确保是 Refresh Token 类型
if (!"refresh".equals(tokenType)) {
throw new BusinessException(401, "无效的刷新令牌");
}
// 查询用户信息(确保用户仍然有效)
User user = userService.findById(userId);
if (user == null || user.getStatus() != 1) {
throw new BusinessException(401, "用户不存在或已被禁用");
}
// 生成新的 Access Token
String newAccessToken = generateAccessToken(user);
TokenRefreshResponse response = new TokenRefreshResponse();
response.setAccessToken(newAccessToken);
response.setExpiresIn(accessTokenExpire);
return ApiResponse.success(response);
} catch (Exception e) {
throw new BusinessException(401, "令牌刷新失败: " + e.getMessage());
}
}
/**
* 用户登出接口
* POST /api/v1/auth/logout
*
* 将当前 Token 加入黑名单,使其立即失效
* 同时清除 Redis 中的会话缓存
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authorization) {
String token = extractToken(authorization);
if (token != null) {
// 将Token加入Redis黑名单,使其立即失效
userService.invalidateToken(token);
}
return ApiResponse.success();
}
/**
* 发送短信验证码
* POST /api/v1/auth/sms-code
*
* 向指定手机号发送登录验证码,验证码5分钟内有效
* 同一手机号60秒内只能发送一次
*/
@PostMapping("/sms-code")
public ApiResponse<Void> sendSmsCode(@RequestBody SmsCodeRequest request) {
if (request.getPhone() == null || request.getPhone().length() != 11) {
throw new BusinessException(400, "请输入正确的手机号");
}
userService.sendSmsVerificationCode(request.getPhone());
return ApiResponse.success();
}
/**
* 获取当前登录用户信息
* GET /api/v1/auth/profile
*
* 根据 Token 中的用户ID查询完整的用户信息
* 包括角色、学校、班级等关联信息
*/
@GetMapping("/profile")
public ApiResponse<UserProfileResponse> getProfile(@RequestHeader("Authorization") String authorization) {
String token = extractToken(authorization);
Claims claims = parseToken(token);
String userId = claims.getSubject();
User user = userService.findById(userId);
if (user == null) {
throw new BusinessException(404, "用户不存在");
}
UserProfileResponse profile = new UserProfileResponse();
profile.setUserId(user.getId());
profile.setName(user.getName());
profile.setPhone(maskPhone(user.getPhone()));
profile.setRole(user.getRole());
profile.setSchoolId(user.getSchoolId());
profile.setSchoolName(user.getSchoolName());
profile.setAvatar(user.getAvatar());
profile.setLastLoginTime(user.getLastLoginTime());
return ApiResponse.success(profile);
}
/**
* 修改密码
* PUT /api/v1/auth/password
*/
@PutMapping("/password")
public ApiResponse<Void> changePassword(@RequestHeader("Authorization") String authorization,
@Valid @RequestBody ChangePasswordRequest request) {
String token = extractToken(authorization);
Claims claims = parseToken(token);
String userId = claims.getSubject();
// 验证旧密码
boolean verified = userService.verifyPassword(userId, request.getOldPassword());
if (!verified) {
throw new BusinessException(400, "原密码错误");
}
// 更新密码
userService.updatePassword(userId, request.getNewPassword());
// 使所有现有Token失效,强制重新登录
userService.invalidateAllTokens(userId);
return ApiResponse.success();
}
// ==================== 内部方法 ====================
/**
* 生成 Access Token
* 有效期2小时,包含用户ID、角色、学校信息
*/
private String generateAccessToken(User user) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTokenExpire * 1000);
return Jwts.builder()
.setSubject(user.getId())
.claim("role", user.getRole())
.claim("schoolId", user.getSchoolId())
.claim("type", "access")
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 生成 Refresh Token
* 有效期7天,仅包含用户ID和令牌类型
*/
private String generateRefreshToken(User user) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshTokenExpire * 1000);
return Jwts.builder()
.setSubject(user.getId())
.claim("type", "refresh")
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/** 解析 JWT Token */
private Claims parseToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody();
}
/** 从 Authorization 头中提取 Token */
private String extractToken(String authorization) {
if (authorization != null && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
return null;
}
/** 手机号脱敏处理(中间4位替换为****) */
private String maskPhone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
// ==================== 请求/响应 DTO ====================
/** 登录请求 */
public static class LoginRequest {
@NotBlank(message = "登录方式不能为空")
private String loginType; // password/sms/wechat/dingtalk
private String phone;
private String password;
private String smsCode;
private String wechatCode;
private String dingtalkCode;
private String clientIp;
public String getLoginType() { return loginType; }
public void setLoginType(String loginType) { this.loginType = loginType; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getSmsCode() { return smsCode; }
public void setSmsCode(String smsCode) { this.smsCode = smsCode; }
public String getWechatCode() { return wechatCode; }
public void setWechatCode(String wechatCode) { this.wechatCode = wechatCode; }
public String getDingtalkCode() { return dingtalkCode; }
public void setDingtalkCode(String dingtalkCode) { this.dingtalkCode = dingtalkCode; }
public String getClientIp() { return clientIp; }
public void setClientIp(String clientIp) { this.clientIp = clientIp; }
}
/** 登录响应 */
public static class LoginResponse {
private String accessToken;
private String refreshToken;
private long expiresIn;
private String userId;
private String userName;
private String role;
private String schoolId;
private String schoolName;
public String getAccessToken() { return accessToken; }
public void setAccessToken(String t) { this.accessToken = t; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String t) { this.refreshToken = t; }
public long getExpiresIn() { return expiresIn; }
public void setExpiresIn(long e) { this.expiresIn = e; }
public String getUserId() { return userId; }
public void setUserId(String id) { this.userId = id; }
public String getUserName() { return userName; }
public void setUserName(String n) { this.userName = n; }
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public String getSchoolId() { return schoolId; }
public void setSchoolId(String id) { this.schoolId = id; }
public String getSchoolName() { return schoolName; }
public void setSchoolName(String n) { this.schoolName = n; }
}
/** Token刷新请求 */
public static class TokenRefreshRequest {
@NotBlank(message = "刷新令牌不能为空")
private String refreshToken;
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String t) { this.refreshToken = t; }
}
/** Token刷新响应 */
public static class TokenRefreshResponse {
private String accessToken;
private long expiresIn;
public String getAccessToken() { return accessToken; }
public void setAccessToken(String t) { this.accessToken = t; }
public long getExpiresIn() { return expiresIn; }
public void setExpiresIn(long e) { this.expiresIn = e; }
}
/** 短信验证码请求 */
public static class SmsCodeRequest {
private String phone;
public String getPhone() { return phone; }
public void setPhone(String p) { this.phone = p; }
}
/** 用户信息响应 */
public static class UserProfileResponse {
private String userId;
private String name;
private String phone;
private String role;
private String schoolId;
private String schoolName;
private String avatar;
private LocalDateTime lastLoginTime;
public String getUserId() { return userId; }
public void setUserId(String id) { this.userId = id; }
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public String getPhone() { return phone; }
public void setPhone(String p) { this.phone = p; }
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public String getSchoolId() { return schoolId; }
public void setSchoolId(String id) { this.schoolId = id; }
public String getSchoolName() { return schoolName; }
public void setSchoolName(String n) { this.schoolName = n; }
public String getAvatar() { return avatar; }
public void setAvatar(String a) { this.avatar = a; }
public LocalDateTime getLastLoginTime() { return lastLoginTime; }
public void setLastLoginTime(LocalDateTime t) { this.lastLoginTime = t; }
}
/** 修改密码请求 */
public static class ChangePasswordRequest {
@NotBlank(message = "原密码不能为空")
private String oldPassword;
@NotBlank(message = "新密码不能为空")
private String newPassword;
public String getOldPassword() { return oldPassword; }
public void setOldPassword(String p) { this.oldPassword = p; }
public String getNewPassword() { return newPassword; }
public void setNewPassword(String p) { this.newPassword = p; }
}
}