This commit is contained in:
wangli 2026-01-22 00:01:43 +08:00
parent 6494acaa16
commit 6604fda564
35 changed files with 1917 additions and 621 deletions

View File

@ -17,4 +17,16 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.msgpack</groupId>
<artifactId>jackson-dataformat-msgpack</artifactId>
<version>0.9.6</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,33 @@
package top.biwin.xinayu.common.dto.request;
import lombok.Data;
/**
* 登录请求 DTO
* 用于接收客户端的登录请求参数
*
* @author wangli
* @since 2026-01-21
*/
@Data
public class LoginRequest {
/**
* 用户名
*/
private String username;
/**
* 邮箱
*/
private String email;
/**
* 密码
*/
private String password;
/**
* 邮箱验证码用于邮箱验证码登录
*/
private String verificationCode;
}

View File

@ -1,4 +1,4 @@
package top.biwin.xinayu.server.dto;
package top.biwin.xinayu.common.dto.request;
import lombok.Data;

View File

@ -0,0 +1,18 @@
package top.biwin.xinayu.common.dto.request;
import lombok.Data;
/**
* 发送验证码请求 DTO
* 用于接收客户端的发送邮箱验证码请求参数
*
* @author wangli
* @since 2026-01-21
*/
@Data
public class SendCodeRequest {
/**
* 邮箱地址
*/
private String email;
}

View File

@ -0,0 +1,67 @@
package top.biwin.xinayu.common.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应 DTO
* 返回给客户端的登录成功响应
*
* @author wangli
* @since 2026-01-21
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* 访问令牌短期有效用于访问受保护资源
*/
private String accessToken;
/**
* 刷新令牌长期有效用于获取新的访问令牌
*/
private String refreshToken;
/**
* 访问令牌过期时间
*/
private Long expiresIn;
/**
* 令牌类型固定为 "Bearer"
*/
private String tokenType = "Bearer";
@JsonProperty("is_admin")
private Boolean isAdmin;
@JsonProperty("user_id")
private Long userId;
private String username;
/**
* 用户邮箱
*/
private String email;
/**
* 账号是否激活
*/
@JsonProperty("is_active")
private Boolean isActive;
/**
* 是否登陆成功
*/
private Boolean success;
/**
* 错误信息
*/
private String message;
}

View File

@ -1,4 +1,4 @@
package top.biwin.xinayu.server.dto;
package top.biwin.xinayu.common.dto.response;
import lombok.AllArgsConstructor;
import lombok.Data;

View File

@ -0,0 +1,32 @@
package top.biwin.xinayu.common.dto.response;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 发送验证码响应 DTO
* 返回给客户端的验证码发送结果
*
* @author wangli
* @since 2026-01-21
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SendCodeResponse {
/**
* 是否发送成功
*/
private Boolean success;
/**
* 响应消息
*/
private String message;
/**
* 验证码过期时间
*/
private Integer expiresIn;
}

View File

@ -12,6 +12,11 @@
<artifactId>xianyu-core</artifactId>
<dependencies>
<dependency>
<groupId>top.biwin</groupId>
<artifactId>xianyu-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
@ -33,4 +38,4 @@
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
</project>

View File

@ -0,0 +1,55 @@
package top.biwin.xianyu.core.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* 邮箱验证码实体类
* 用于存储邮箱验证码信息生成验证过期管理
*
* @author wangli
* @since 2026-01-21
*/
@Data
@Entity
@Table(name = "email_verification_code")
public class EmailVerificationCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 邮箱地址
*/
@Column(nullable = false, length = 255)
private String email;
/**
* 验证码6位数字
*/
@Column(nullable = false, length = 6)
private String code;
/**
* 过期时间
*/
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
/**
* 创建时间
*/
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
/**
* 是否已使用
*/
@Column(name = "is_used")
private Boolean isUsed = false;
}

View File

@ -1,4 +1,4 @@
package top.biwin.xinayu.server.repository;
package top.biwin.xianyu.core.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@ -15,7 +15,7 @@ import java.util.Optional;
*/
@Repository
public interface AdminUserRepository extends JpaRepository<AdminUser, Long> {
/**
* 根据用户名查询用户
*

View File

@ -0,0 +1,46 @@
package top.biwin.xianyu.core.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import top.biwin.xianyu.core.entity.EmailVerificationCode;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 邮箱验证码数据访问层
* 提供验证码的 CRUD 操作和查询功能
*
* @author wangli
* @since 2026-01-21
*/
@Repository
public interface EmailVerificationCodeRepository extends JpaRepository<EmailVerificationCode, Long> {
/**
* 根据邮箱和验证码查询用于验证
* 查询未使用且未过期的验证码
*
* @param email 邮箱地址
* @param code 验证码
* @param now 当前时间
* @return Optional<EmailVerificationCode>
*/
Optional<EmailVerificationCode> findByEmailAndCodeAndIsUsedFalseAndExpiresAtAfter(
String email, String code, LocalDateTime now);
/**
* 根据邮箱查询最新的验证码用于防止频繁发送
*
* @param email 邮箱地址
* @return Optional<EmailVerificationCode>
*/
Optional<EmailVerificationCode> findFirstByEmailOrderByCreatedAtDesc(String email);
/**
* 删除过期的验证码定时清理任务使用
*
* @param now 当前时间
*/
void deleteByExpiresAtBefore(LocalDateTime now);
}

View File

@ -0,0 +1,12 @@
package top.biwin.xianyu.core.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import top.biwin.xianyu.core.entity.SystemSetting;
import java.util.Optional;
@Repository
public interface SystemSettingRepository extends JpaRepository<SystemSetting, String> {
Optional<SystemSetting> findByKey(String key);
}

View File

@ -12,6 +12,11 @@
<artifactId>xianyu-server</artifactId>
<dependencies>
<dependency>
<groupId>top.biwin</groupId>
<artifactId>xianyu-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>top.biwin</groupId>
<artifactId>xianyu-core</artifactId>
@ -48,6 +53,16 @@
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<!-- Spring Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>
</project>
</project>

View File

@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Xianyu Freedom 应用启动类
*
* <p>
* 配置说明
* - @EntityScan: 扫描 xianyu-core 模块中的实体类AdminUser
* - @EnableJpaRepositories: 启用 JPA Repository 支持
@ -17,9 +17,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
*/
@SpringBootApplication
@EntityScan(basePackages = "top.biwin.xianyu.core.entity")
@EnableJpaRepositories(basePackages = "top.biwin.xinayu.server.repository")
@EnableJpaRepositories(basePackages = "top.biwin.xianyu.core.repository")
public class XianyuFreedomApplication {
public static void main(String[] args) {
SpringApplication.run(XianyuFreedomApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(XianyuFreedomApplication.class, args);
}
}

View File

@ -0,0 +1,33 @@
package top.biwin.xinayu.server.config;
import cn.hutool.core.util.StrUtil;
/**
* 极验验证码配置
* 对应 Python: utils/geetest/geetest_config.py
*/
public class GeetestConfig {
// 极验分配的captcha_id从环境变量读取有默认值
public static final String CAPTCHA_ID = StrUtil.blankToDefault(System.getenv("GEETEST_CAPTCHA_ID"), "0ab567879ad202caee10fa9e30329806");
// 极验分配的私钥从环境变量读取有默认值
public static final String PRIVATE_KEY = StrUtil.blankToDefault(System.getenv("GEETEST_PRIVATE_KEY"), "e0517af788cb831d72f8886f9ba41ca3");
// 用户标识可选
public static final String USER_ID = StrUtil.blankToDefault(System.getenv("GEETEST_USER_ID"), "xianyu_system");
// 客户端类型web, h5, native, unknown
public static final String CLIENT_TYPE = "web";
// API地址
public static final String API_URL = "http://api.geetest.com";
public static final String REGISTER_URL = "/register.php";
public static final String VALIDATE_URL = "/validate.php";
// 请求超时时间毫秒- Python是5秒
public static final int TIMEOUT = 5000;
// SDK版本
public static final String VERSION = "java-springboot:1.0.0";
}

View File

@ -20,13 +20,13 @@ import top.biwin.xinayu.server.security.JwtAuthenticationFilter;
/**
* Spring Security 配置类
* 配置认证和授权相关的核心组件
*
* <p>
* 配置内容
* 1. SecurityFilterChain - 定义安全过滤器链
* 2. PasswordEncoder - BCrypt 密码编码器强度 12
* 3. AuthenticationManager - 认证管理器
* 4. DaoAuthenticationProvider - DAO 认证提供者
*
* <p>
* 安全策略
* - 无状态会话管理JWT 场景
* - 禁用 CSRFREST API 场景
@ -39,99 +39,101 @@ import top.biwin.xinayu.server.security.JwtAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private UserDetailsService userDetailsService;
/**
* 配置安全过滤器链
* <p>
* 配置项
* 1. 禁用 CSRFJWT 无状态认证不需要
* 2. 配置授权规则
* - /auth/** 接口允许匿名访问登录刷新令牌
* - 其他所有接口需要认证
* 3. 配置无状态会话管理
* 4. 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前
*
* @param http HttpSecurity 对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRFJWT 场景下不需要
.csrf(AbstractHttpConfigurer::disable)
/**
* 配置安全过滤器链
*
* 配置项
* 1. 禁用 CSRFJWT 无状态认证不需要
* 2. 配置授权规则
* - /auth/** 接口允许匿名访问登录刷新令牌
* - 其他所有接口需要认证
* 3. 配置无状态会话管理
* 4. 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前
*
* @param http HttpSecurity 对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRFJWT 场景下不需要
.csrf(AbstractHttpConfigurer::disable)
// 配置授权规则
.authorizeHttpRequests(auth -> auth
// 白名单允许 /auth/** 接口匿名访问
.requestMatchers("/auth/**").permitAll()
// 其他所有接口都需要认证
.anyRequest().authenticated()
)
// 配置无状态会话管理JWT 无状态认证
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// 配置授权规则
.authorizeHttpRequests(auth -> auth
// 白名单允许 /auth/** 接口匿名访问
.requestMatchers("/auth/**",
"/geetest/**",
"system-settings/public")
.permitAll()
return http.build();
}
// 其他所有接口都需要认证
.anyRequest().authenticated()
)
/**
* 密码编码器 Bean
* 使用 BCrypt 算法强度为 122^12 = 4096 次哈希
*
* BCrypt 优势
* - 自动加盐
* - 计算密集防暴力破解
* - 业界标准
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// 配置无状态会话管理JWT 无状态认证
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
/**
* 认证管理器 Bean
* Spring Security 用于处理认证请求
*
* @param authenticationConfiguration 认证配置
* @return AuthenticationManager
* @throws Exception 配置异常
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
/**
* DAO 认证提供者 Bean
* 连接 UserDetailsService PasswordEncoder
*
* 工作流程
* 1. UserDetailsService 加载用户信息
* 2. 使用 PasswordEncoder 验证密码
*
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
return http.build();
}
/**
* 密码编码器 Bean
* 使用 BCrypt 算法强度为 122^12 = 4096 次哈希
* <p>
* BCrypt 优势
* - 自动加盐
* - 计算密集防暴力破解
* - 业界标准
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* 认证管理器 Bean
* Spring Security 用于处理认证请求
*
* @param authenticationConfiguration 认证配置
* @return AuthenticationManager
* @throws Exception 配置异常
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* DAO 认证提供者 Bean
* 连接 UserDetailsService PasswordEncoder
* <p>
* 工作流程
* 1. UserDetailsService 加载用户信息
* 2. 使用 PasswordEncoder 验证密码
*
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}

View File

@ -1,25 +1,34 @@
package top.biwin.xinayu.server.controller;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xinayu.server.dto.LoginRequest;
import top.biwin.xinayu.server.dto.LoginResponse;
import top.biwin.xinayu.server.dto.RefreshRequest;
import top.biwin.xinayu.server.dto.RefreshResponse;
import org.springframework.web.bind.annotation.*;
import top.biwin.xinayu.common.dto.request.LoginRequest;
import top.biwin.xinayu.common.dto.request.RefreshRequest;
import top.biwin.xinayu.common.dto.request.SendCodeRequest;
import top.biwin.xinayu.common.dto.response.LoginResponse;
import top.biwin.xinayu.common.dto.response.RefreshResponse;
import top.biwin.xinayu.common.dto.response.SendCodeResponse;
import top.biwin.xinayu.server.security.JwtUtil;
import top.biwin.xinayu.server.service.AuthService;
import top.biwin.xinayu.server.service.EmailVerificationService;
import top.biwin.xianyu.core.entity.AdminUser;
import top.biwin.xianyu.core.repository.AdminUserRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 认证控制器
* 提供登录刷新令牌等认证相关的 REST API
*
*
* 接口列表
* - POST /auth/login - 用户登录
* - POST /auth/refresh - 刷新访问令牌
*
*
* 注意这些接口在 SecurityConfig 中已配置为白名单无需认证即可访问
*
* @author wangli
@ -32,16 +41,25 @@ public class AuthController {
@Autowired
private AuthService authService;
@Autowired
private EmailVerificationService emailVerificationService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private AdminUserRepository adminUserRepository;
/**
* 用户登录接口
*
*
* 请求示例
* POST /auth/login
* {
* "username": "admin",
* "password": "password123"
* }
*
*
* 响应示例
* {
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
@ -49,7 +67,7 @@ public class AuthController {
* "expiresIn": 900,
* "tokenType": "Bearer"
* }
*
*
* 错误响应
* - 401 Unauthorized: 用户名或密码错误
* - 500 Internal Server Error: 服务器内部错误
@ -59,26 +77,26 @@ public class AuthController {
*/
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
LoginResponse response = authService.login(request.getUsername(), request.getPassword());
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
/**
* 刷新访问令牌接口
*
*
* 请求示例
* POST /auth/refresh
* {
* "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
* }
*
*
* 响应示例
* {
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
* "expiresIn": 900,
* "tokenType": "Bearer"
* }
*
*
* 错误响应
* - 401 Unauthorized: 刷新令牌无效或已过期
* - 500 Internal Server Error: 服务器内部错误
@ -91,4 +109,129 @@ public class AuthController {
RefreshResponse response = authService.refresh(request.getRefreshToken());
return ResponseEntity.ok(response);
}
/**
* 发送邮箱验证码接口
*
* 请求示例
* POST /auth/send-code
* {
* "email": "user@example.com"
* }
*
* 响应示例
* {
* "success": true,
* "message": "验证码已发送到您的邮箱",
* "expiresIn": 300
* }
*
* 错误响应
* - 400 Bad Request: 邮箱格式错误
* - 429 Too Many Requests: 发送过于频繁
* - 500 Internal Server Error: 邮件发送失败
*
* @param request 发送验证码请求包含 email
* @return ResponseEntity<SendCodeResponse> 发送结果响应
*/
@PostMapping("/send-code")
public ResponseEntity<SendCodeResponse> sendCode(@RequestBody SendCodeRequest request) {
emailVerificationService.generateAndSendCode(request.getEmail());
SendCodeResponse response = new SendCodeResponse(
true,
"验证码已发送到您的邮箱,请注意查收",
300
);
return ResponseEntity.ok(response);
}
/**
* 验证 Token 接口
* 检查 Access Token 是否有效并返回用户信息
*
* 请求示例
* GET /auth/verify
* Headers: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
*
* 响应示例Token 有效
* {
* "authenticated": true,
* "user_id": 1,
* "username": "admin",
* "email": "admin@example.com",
* "is_admin": true
* }
*
* 响应示例Token 无效
* {
* "authenticated": false
* }
*
* @param request HTTP 请求对象
* @return 验证结果和用户信息
*/
@GetMapping("/verify")
public ResponseEntity<Map<String, Object>> verify(HttpServletRequest request) {
Map<String, Object> response = new HashMap<>();
// 从请求头中提取 Token
String token = getTokenFromRequest(request);
if (token == null) {
response.put("authenticated", false);
response.put("message", "缺少 Authorization 头");
return ResponseEntity.ok(response);
}
try {
// 使用 JwtUtil 验证 Token 是否有效检查签名和过期时间
if (jwtUtil.validateToken(token)) {
// Token 有效提取用户名
String username = jwtUtil.getUsernameFromToken(token);
// 从数据库查询用户信息
Optional<AdminUser> userOpt = adminUserRepository.findByUsername(username);
if (userOpt.isPresent()) {
AdminUser user = userOpt.get();
// 返回用户信息
response.put("authenticated", true);
response.put("user_id", user.getId());
response.put("username", user.getUsername());
response.put("email", user.getEmail());
response.put("is_admin", true); // admin_user 表的用户都是管理员
response.put("is_active", user.getIsActive());
} else {
// Token 有效但用户不存在可能已被删除
response.put("authenticated", false);
response.put("message", "用户不存在");
}
} else {
// Token 无效或已过期
response.put("authenticated", false);
response.put("message", "Token 无效或已过期");
}
} catch (Exception e) {
// Token 解析失败
response.put("authenticated", false);
response.put("message", "Token 格式错误");
}
return ResponseEntity.ok(response);
}
/**
* 从请求头中提取 Token
*
* @param request HTTP 请求对象
* @return Token 字符串如果不存在则返回 null
*/
private String getTokenFromRequest(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StrUtil.isNotBlank(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}

View File

@ -0,0 +1,13 @@
package top.biwin.xinayu.server.controller;
import org.springframework.web.bind.annotation.RestController;
/**
* TODO
*
* @author wangli
* @since 2026-01-21 22:58
*/
@RestController
public class DashboardController {
}

View File

@ -0,0 +1,94 @@
package top.biwin.xinayu.server.controller;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import top.biwin.xinayu.server.service.GeetestService;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/geetest")
public class GeetestController{
@Autowired
private GeetestService geetestService;
/**
* 极验初始化接口
*/
@GetMapping("/register")
public Map<String, Object> register() {
// 必传参数
// digestmod: 加密算法"md5", "sha256", "hmac-sha256"
GeetestService.GeetestResult result = geetestService.register(GeetestService.DigestMod.MD5, null, null);
Map<String, Object> response = new HashMap<>();
if (result.getStatus() == 1 || (result.getData() != null && result.getData().contains("\"success\": 0"))) {
// status 1 means full success
// or if it fallback mode (status might be 0 in Lib but we treat as success HTTP response with offline data)
// Check GeetestLib: logic. It sets status=0 if logic fails?
// GeetestLib: "初始化接口失败,后续流程走宕机模式" sets status=0.
// But for the frontend, getting the offline parameters IS a successful API call.
response.put("success", true);
response.put("code", 200);
response.put("message", "获取成功");
response.put("data", result.toJsonObject());
} else {
response.put("success", false);
response.put("code", 500);
response.put("message", "获取验证参数失败: " + result.getMsg());
}
return response;
}
/**
* 极验二次验证接口
*/
@PostMapping("/validate")
public Map<String, Object> validate(@RequestBody ValidateRequest request) {
GeetestService.GeetestResult result;
// 这里的逻辑需要根据 register 返回的 new_captcha (gt_server_status) 来判断走 normal 还是 fail 模式
// 但是在 Python SDK 的使用中这个状态通常维护在 Session
// 简单实现如果不判断状态默认尝试走 successValidate (正常模式)
// 也可以让前端传回来或者像 Python demo 那样存 session
// Python reply_server.py 其实并没有展示完整的 validate 逻辑
// 这里我们按照 Standard Flow 实现
result = geetestService.successValidate(
request.getChallenge(),
request.getValidate(),
request.getSeccode(),
null,
null
);
Map<String, Object> response = new HashMap<>();
if (result.getStatus() == 1) {
response.put("success", true);
response.put("code", 200);
response.put("message", "验证通过");
} else {
response.put("success", false);
response.put("code", 400);
response.put("message", "验证失败: " + result.getMsg());
}
return response;
}
@Data
public static class ValidateRequest {
private String challenge;
private String validate;
private String seccode;
}
}

View File

@ -0,0 +1,51 @@
package top.biwin.xinayu.server.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xianyu.core.entity.SystemSetting;
import top.biwin.xianyu.core.repository.SystemSettingRepository;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 系统设置控制器
* 提供系统配置的查询接口
*
* @author wangli
* @since 2026-01-21 22:59
*/
@RestController
@RequestMapping("/system-settings")
public class SystemSettingController {
@Autowired
private SystemSettingRepository systemSettingRepository;
@GetMapping("/public")
public Map<String, String> getPublicSystemSettings() {
Set<String> publicKeys = Set.of(
"registration_enabled",
"show_default_login_info",
"login_captcha_enabled"
);
List<SystemSetting> allHelper = systemSettingRepository.findAll();
Map<String, String> result = new HashMap<>();
// 默认值 (Python logic)
result.put("registration_enabled", "true");
result.put("show_default_login_info", "true");
result.put("login_captcha_enabled", "true");
for (SystemSetting setting : allHelper) {
if (publicKeys.contains(setting.getKey())) {
result.put(setting.getKey(), setting.getValue());
}
}
return result;
}
}

View File

@ -1,28 +0,0 @@
package top.biwin.xinayu.server.dto;
import lombok.Data;
/**
* 登录请求 DTO
* 用于接收客户端的登录请求参数
*
* @author wangli
* @since 2026-01-21
*/
@Data
public class LoginRequest {
/**
* 用户名
*/
private String username;
/**
* 邮箱
*/
private String email;
/**
* 密码
*/
private String password;
}

View File

@ -1,37 +0,0 @@
package top.biwin.xinayu.server.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应 DTO
* 返回给客户端的登录成功响应
*
* @author wangli
* @since 2026-01-21
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* 访问令牌短期有效用于访问受保护资源
*/
private String accessToken;
/**
* 刷新令牌长期有效用于获取新的访问令牌
*/
private String refreshToken;
/**
* 访问令牌过期时间
*/
private Long expiresIn;
/**
* 令牌类型固定为 "Bearer"
*/
private String tokenType = "Bearer";
}

View File

@ -14,20 +14,20 @@ import java.util.Map;
/**
* 全局异常处理器
* 统一处理认证授权相关的异常返回友好的 JSON 错误响应
*
* <p>
* 处理的异常类型
* - BadCredentialsException: 认证失败用户名或密码错误
* - UsernameNotFoundException: 用户不存在
* - RuntimeException: 运行时异常
* - Exception: 其他未知异常
*
* <p>
* 响应格式
* {
* "timestamp": "2026-01-21T21:50:00",
* "status": 401,
* "error": "Unauthorized",
* "message": "用户名或密码错误",
* "path": "/auth/login"
* "timestamp": "2026-01-21T21:50:00",
* "status": 401,
* "error": "Unauthorized",
* "message": "用户名或密码错误",
* "path": "/auth/login"
* }
*
* @author wangli
@ -36,93 +36,93 @@ import java.util.Map;
@RestControllerAdvice
public class AuthenticationExceptionHandler {
/**
* 处理认证失败异常
*
* 触发场景
* - 用户名或密码错误
* - JWT 令牌无效或过期
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Map<String, Object>> handleBadCredentialsException(BadCredentialsException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* 处理认证失败异常
* <p>
* 触发场景
* - 用户名或密码错误
* - JWT 令牌无效或过期
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Map<String, Object>> handleBadCredentialsException(BadCredentialsException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
/**
* 处理用户不存在异常
*
* 触发场景
* - 根据用户名查询用户时用户不存在
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleUsernameNotFoundException(UsernameNotFoundException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* 处理运行时异常
*
* 触发场景
* - 用户被禁用
* - 业务逻辑异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.BAD_REQUEST.value());
errorResponse.put("error", "Bad Request");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* 处理用户不存在异常
* <p>
* 触发场景
* - 根据用户名查询用户时用户不存在
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleUsernameNotFoundException(UsernameNotFoundException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", ex.getMessage());
/**
* 处理未知异常
*
* 触发场景
* - 其他未被捕获的异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleException(Exception ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
errorResponse.put("error", "Internal Server Error");
errorResponse.put("message", "服务器内部错误: " + ex.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* 处理运行时异常
* <p>
* 触发场景
* - 用户被禁用
* - 业务逻辑异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.BAD_REQUEST.value());
errorResponse.put("error", "Bad Request");
errorResponse.put("message", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* 处理未知异常
* <p>
* 触发场景
* - 其他未被捕获的异常
*
* @param ex 异常对象
* @return ResponseEntity<Map<String, Object>> 错误响应
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleException(Exception ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
errorResponse.put("error", "Internal Server Error");
errorResponse.put("message", "服务器内部错误: " + ex.getMessage());
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
}

View File

@ -0,0 +1,66 @@
package top.biwin.xinayu.server.initialization;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import top.biwin.xianyu.core.entity.SystemSetting;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
@Component
public class DataInitializerLogger implements ApplicationRunner {
private static final Logger logger = LoggerFactory.getLogger(DataInitializerLogger.class);
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ResourceLoader resourceLoader;
@Override
public void run(ApplicationArguments args) throws Exception {
// 检查初始化标志
List<SystemSetting> systemSettings = jdbcTemplate.query("SELECT `key`, `value` FROM system_settings WHERE `key` = 'init_system'", new BeanPropertyRowMapper<>(SystemSetting.class));
SystemSetting systemSetting = systemSettings.isEmpty() ? null : systemSettings.get(0);
if (Objects.isNull(systemSetting)) {
// 如果没有初始化过则执行data.sql中的脚本
Resource resource = resourceLoader.getResource("classpath:META-INF/init.sql");
try (InputStream inputStream = resource.getInputStream()) {
String sqlScript = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
// Split the SQL script into individual statements and execute them
String[] statements = sqlScript.split(";");
for (String statement : statements) {
String trimmedStatement = statement.trim();
// Remove single-line comments starting with --
trimmedStatement = trimmedStatement.replaceAll("--.*", "").trim();
if (!trimmedStatement.isEmpty()) {
jdbcTemplate.execute(trimmedStatement);
}
}
}
// 在这里定义您的自定义日志内容
logger.info("数据库首次初始化完成,默认数据已通过 init.sql 文件成功插入。");
logger.info("管理员默认账号admin");
logger.info("管理员默认密码123456");
// 插入初始化标志防止下次启动时再次执行
jdbcTemplate.update("UPDATE system_settings SET value = ? WHERE `key` = ?", "true", "init_system");
} else {
logger.info("数据库已初始化,跳过默认数据插入。");
}
}
}

View File

@ -0,0 +1,10 @@
package top.biwin.xinayu.server.notice;
/**
* TODO
*
* @author wangli
* @since 2026-01-21 23:40
*/
public interface NoticeService {
}

View File

@ -0,0 +1,10 @@
package top.biwin.xinayu.server.notice.channel;
/**
* TODO
*
* @author wangli
* @since 2026-01-21 23:40
*/
public class EmailChannel {
}

View File

@ -18,13 +18,13 @@ import java.io.IOException;
/**
* JWT 认证过滤器
* 从请求头中提取 JWT 令牌验证并设置 Spring Security 上下文
*
* <p>
* 工作流程
* 1. 从请求头 "Authorization" 中提取 JWT格式Bearer <token>
* 2. 验证 JWT 的有效性
* 3. JWT 中提取用户名加载用户信息
* 4. 将认证信息设置到 Spring Security Context
*
* <p>
* 安全性说明
* - 使用 OncePerRequestFilter 确保每个请求只执行一次
* - 异常情况下不会阻断请求由后续的认证检查处理
@ -35,81 +35,81 @@ import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private UserDetailsService userDetailsService;
/**
* JWT 过滤逻辑
*
* 处理流程
* 1. 提取请求头中的 Authorization
* 2. 检查是否以 "Bearer " 开头
* 3. 提取 JWT 令牌
* 4. 验证令牌并提取用户名
* 5. 加载用户详情并设置到 Security Context
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头中获取 Authorization
final String authorizationHeader = request.getHeader("Authorization");
/**
* JWT 过滤逻辑
* <p>
* 处理流程
* 1. 提取请求头中的 Authorization
* 2. 检查是否以 "Bearer " 开头
* 3. 提取 JWT 令牌
* 4. 验证令牌并提取用户名
* 5. 加载用户详情并设置到 Security Context
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String username = null;
String jwt = null;
// 1. 从请求头中获取 Authorization
final String authorizationHeader = request.getHeader("Authorization");
// 2. 检查 Authorization 头是否存在且以 "Bearer " 开头
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
// 3. 提取 JWT 令牌去掉 "Bearer " 前缀
jwt = authorizationHeader.substring(7);
try {
// 4. JWT 中提取用户名
username = jwtUtil.getUsernameFromToken(jwt);
} catch (Exception e) {
// JWT 解析失败记录日志可选
logger.warn("JWT 解析失败: " + e.getMessage());
}
}
String username = null;
String jwt = null;
// 5. 如果提取到用户名且当前 Security Context 中没有认证信息
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 6. 加载用户详情
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 2. 检查 Authorization 头是否存在且以 "Bearer " 开头
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
// 3. 提取 JWT 令牌去掉 "Bearer " 前缀
jwt = authorizationHeader.substring(7);
// 7. 验证 JWT 是否有效
if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
// 8. 创建认证令牌
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 9. 设置请求详情
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 10. 将认证信息设置到 Security Context
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
// 11. 继续执行过滤器链
filterChain.doFilter(request, response);
try {
// 4. JWT 中提取用户名
username = jwtUtil.getUsernameFromToken(jwt);
} catch (Exception e) {
// JWT 解析失败记录日志可选
logger.warn("JWT 解析失败: " + e.getMessage());
}
}
// 5. 如果提取到用户名且当前 Security Context 中没有认证信息
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 6. 加载用户详情
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 7. 验证 JWT 是否有效
if (jwtUtil.validateToken(jwt, userDetails.getUsername())) {
// 8. 创建认证令牌
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 9. 设置请求详情
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 10. 将认证信息设置到 Security Context
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
// 11. 继续执行过滤器链
filterChain.doFilter(request, response);
}
}

View File

@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
@ -15,7 +16,7 @@ import java.util.Map;
/**
* JWT 工具类
* 负责 JWT 令牌的生成解析和验证
*
* <p>
* 安全性说明
* - 使用 HS256 算法签名
* - 密钥从环境变量读取避免硬编码
@ -28,150 +29,156 @@ import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private Long accessTokenExpiration;
@Value("${jwt.access-token-expiration}")
private Long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private Long refreshTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private Long refreshTokenExpiration;
/**
* 生成访问令牌Access Token
* 有效期15分钟
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateAccessToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "access");
return createToken(claims, username, accessTokenExpiration);
/**
* 生成访问令牌Access Token
* 有效期15分钟
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateAccessToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "access");
return createToken(claims, username, accessTokenExpiration);
}
/**
* 生成刷新令牌Refresh Token
* 有效期7天
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateRefreshToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "refresh");
return createToken(claims, username, refreshTokenExpiration);
}
/**
* 从令牌中提取用户名
*
* @param token JWT 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
return extractAllClaims(token).getSubject();
}
/**
* 验证令牌有效性
* 检查令牌是否过期以及用户名是否匹配
*
* @param token JWT 令牌
* @param username 用户名
* @return 是否有效
*/
public boolean validateToken(String token, String username) {
final String tokenUsername = getUsernameFromToken(token);
return (tokenUsername.equals(username) && !isTokenExpired(token));
}
/**
* 验证令牌是否有效不检查用户名
*
* @param token JWT 令牌
* @return 是否有效
*/
public boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 生成刷新令牌Refresh Token
* 有效期7天
*
* @param username 用户名
* @return JWT 令牌字符串
*/
public String generateRefreshToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("type", "refresh");
return createToken(claims, username, refreshTokenExpiration);
}
/**
* 检查令牌是否过期
*
* @param token JWT 令牌
* @return 是否过期
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/**
* 从令牌中提取用户名
*
* @param token JWT 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
return extractAllClaims(token).getSubject();
}
/**
* 从令牌中提取过期时间
*
* @param token JWT 令牌
* @return 过期时间
*/
private Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
/**
* 验证令牌有效性
* 检查令牌是否过期以及用户名是否匹配
*
* @param token JWT 令牌
* @param username 用户名
* @return 是否有效
*/
public boolean validateToken(String token, String username) {
final String tokenUsername = getUsernameFromToken(token);
return (tokenUsername.equals(username) && !isTokenExpired(token));
}
/**
* 从令牌中提取所有声明Claims
*
* @param token JWT 令牌
* @return Claims 对象
*/
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 验证令牌是否有效不检查用户名
*
* @param token JWT 令牌
* @return 是否有效
*/
public boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
/**
* 创建 JWT 令牌
*
* @param claims 自定义声明
* @param subject 主题用户名
* @param expiration 过期时间毫秒
* @return JWT 令牌字符串
*/
private String createToken(Map<String, Object> claims, String subject, Long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
/**
* 检查令牌是否过期
*
* @param token JWT 令牌
* @return 是否过期
*/
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
/**
* 从令牌中提取过期时间
*
* @param token JWT 令牌
* @return 过期时间
*/
private Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
/**
* 获取签名密钥
* 使用 HMAC-SHA 算法密钥长度至少 256
*
* @return SecretKey
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 从令牌中提取所有声明Claims
*
* @param token JWT 令牌
* @return Claims 对象
*/
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 获取访问令牌过期时间
*
* @return 过期时间
*/
public Long getAccessTokenExpirationInSeconds() {
return accessTokenExpiration / 1000;
}
/**
* 创建 JWT 令牌
*
* @param claims 自定义声明
* @param subject 主题用户名
* @param expiration 过期时间毫秒
* @return JWT 令牌字符串
*/
private String createToken(Map<String, Object> claims, String subject, Long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
/**
* 获取签名密钥
* 使用 HMAC-SHA 算法密钥长度至少 256
*
* @return SecretKey
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 获取访问令牌过期时间
*
* @return 过期时间
*/
public Long getAccessTokenExpirationInSeconds() {
return accessTokenExpiration / 1000;
}
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
String hash = encoder.encode("123456");
System.out.println(hash);
}
}

View File

@ -7,16 +7,16 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import top.biwin.xianyu.core.entity.AdminUser;
import top.biwin.xinayu.server.repository.AdminUserRepository;
import top.biwin.xianyu.core.repository.AdminUserRepository;
import java.util.ArrayList;
/**
* AdminUser 用户详情服务
* 实现 Spring Security UserDetailsService 接口
*
* <p>
* 这是主人实现自定义用户查询逻辑的核心位置
*
* <p>
* 主要职责
* 1. 根据用户名从数据库查询 AdminUser
* 2. 检查用户是否存在和是否激活
@ -28,46 +28,34 @@ import java.util.ArrayList;
@Service
public class AdminUserDetailsService implements UserDetailsService {
@Autowired
private AdminUserRepository adminUserRepository;
@Autowired
private AdminUserRepository adminUserRepository;
/**
* 根据用户名加载用户信息
*
* 主人的自定义逻辑实现位置 #1
*
* TODO: 主人请在这里实现用户查询逻辑具体步骤如下
* 1. 调用 adminUserRepository.findByUsername(username) 查询数据库
* 2. 如果用户不存在抛出 UsernameNotFoundException 异常格式"用户不存在: " + username
* 3. 检查用户的 isActive 字段如果为 false抛出异常格式"用户已被禁用: " + username
* 4. 使用 User.builder() 创建 UserDetails 对象
* - username: adminUser.getUsername()
* - password: adminUser.getPasswordHash()
* - authorities: new ArrayList<>()空权限列表主人可以后续扩展角色权限
* 5. 返回构建好的 UserDetails 对象
*
* 💡 扩展提示
* - 可以在这里添加登录失败次数限制需要在 AdminUser 实体中添加 failedLoginAttempts 字段
* - 可以添加 IP 白名单验证
* - 可以添加账号锁定逻辑
* - 如果后续需要角色权限可以在 AdminUser 中添加 roles 字段并在这里设置 authorities
*
* @param username 用户名
* @return UserDetails Spring Security 的用户详情对象
* @throws UsernameNotFoundException 用户不存在时抛出
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO: 主人请实现这里的逻辑
// 1. AdminUser adminUser = adminUserRepository.findByUsername(username)
// .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
// 2. if (!adminUser.getIsActive()) { throw new RuntimeException("用户已被禁用: " + username); }
// 3. return User.builder()
// .username(adminUser.getUsername())
// .password(adminUser.getPasswordHash())
// .authorities(new ArrayList<>())
// .build();
throw new UnsupportedOperationException("主人,请实现 loadUserByUsername 方法!");
}
/**
* 根据用户名加载用户信息
* <p>
* 主人的自定义逻辑实现位置 #1
* <p>
* 💡 扩展提示
* - 可以在这里添加登录失败次数限制需要在 AdminUser 实体中添加 failedLoginAttempts 字段
* - 可以添加 IP 白名单验证
* - 可以添加账号锁定逻辑
* - 如果后续需要角色权限可以在 AdminUser 中添加 roles 字段并在这里设置 authorities
*
* @param username 用户名
* @return UserDetails Spring Security 的用户详情对象
* @throws UsernameNotFoundException 用户不存在时抛出
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AdminUser adminUser = adminUserRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
if (!adminUser.getIsActive()) { throw new RuntimeException("用户已被禁用: " + username); }
return User.builder()
.username(adminUser.getUsername())
.password(adminUser.getPasswordHash())
.authorities(new ArrayList<>())
.build();
}
}

View File

@ -1,116 +1,249 @@
package top.biwin.xinayu.server.service;
import cn.hutool.log.Log;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import top.biwin.xinayu.server.dto.LoginResponse;
import top.biwin.xinayu.server.dto.RefreshResponse;
import org.springframework.util.StringUtils;
import top.biwin.xinayu.common.dto.request.LoginRequest;
import top.biwin.xinayu.common.dto.response.LoginResponse;
import top.biwin.xinayu.common.dto.response.RefreshResponse;
import top.biwin.xianyu.core.entity.AdminUser;
import top.biwin.xianyu.core.repository.AdminUserRepository;
import top.biwin.xinayu.server.security.JwtUtil;
import java.util.Optional;
/**
* 认证服务
* 提供登录刷新令牌等认证相关功能
*
* <p>
* 这是主人实现登录和刷新令牌逻辑的核心位置
* <p>
* 支持的登录方式
* 1. 用户名 + 密码
* 2. 邮箱 + 密码
* 3. 邮箱 + 验证码
*
* @author wangli
* @since 2026-01-21
*/
@Slf4j
@Service
public class AuthService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private JwtUtil jwtUtil;
/**
* 用户登录
*
* 主人的自定义逻辑实现位置 #2
*
* TODO: 主人请在这里实现登录逻辑具体步骤如下
* 1. 创建认证令牌
* UsernamePasswordAuthenticationToken authToken =
* new UsernamePasswordAuthenticationToken(username, password);
* 2. 使用 AuthenticationManager 进行认证
* Authentication authentication = authenticationManager.authenticate(authToken);
* 注意如果认证失败会自动抛出 BadCredentialsException 异常
* 3. 认证成功后生成 JWT 令牌
* String accessToken = jwtUtil.generateAccessToken(username);
* String refreshToken = jwtUtil.generateRefreshToken(username);
* 4. 获取过期时间
* Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
* 5. 构建并返回 LoginResponse 对象
* return new LoginResponse(accessToken, refreshToken, expiresIn, "Bearer");
*
* 💡 扩展提示
* - 可以在这里记录登录日志时间IP设备信息等
* - 可以重置登录失败次数
* - 可以实现多因素认证2FA逻辑
* - 可以返回用户的基本信息如昵称头像等
*
* @param username 用户名
* @param password 密码明文
* @return LoginResponse 登录响应包含 accessToken refreshToken
* @throws BadCredentialsException 认证失败时抛出
*/
public LoginResponse login(String username, String password) {
// TODO: 主人请实现这里的逻辑
// 1. UsernamePasswordAuthenticationToken authToken =
// new UsernamePasswordAuthenticationToken(username, password);
// 2. Authentication authentication = authenticationManager.authenticate(authToken);
// 3. String accessToken = jwtUtil.generateAccessToken(username);
// 4. String refreshToken = jwtUtil.generateRefreshToken(username);
// 5. Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
// 6. return new LoginResponse(accessToken, refreshToken, expiresIn, "Bearer");
throw new UnsupportedOperationException("主人,请实现 login 方法!");
@Autowired
private AdminUserRepository adminUserRepository;
@Autowired
private EmailVerificationService emailVerificationService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户登录支持多种方式
* <p>
* 登录方式自动判断
* - username + password -> 用户名密码登录
* - email + password -> 邮箱密码登录
* - email + verificationCode -> 邮箱验证码登录
*
* @param request 登录请求
* @return LoginResponse 登录响应包含 token 和用户信息
* @throws BadCredentialsException 认证失败时抛出
*/
public LoginResponse login(LoginRequest request) {
AdminUser user = null;
// TODO: 主人这里判断使用哪种登录方式
// 根据 request 中的字段判断登录类型然后调用对应的认证方法
if (StringUtils.hasText(request.getUsername()) && StringUtils.hasText(request.getPassword())) {
// 方式1用户名 + 密码登录
user = authenticateByUsername(request.getUsername(), request.getPassword());
log.info("✅ 用户名登录成功: {}", request.getUsername());
} else if (StringUtils.hasText(request.getEmail()) && StringUtils.hasText(request.getPassword())) {
// 方式2邮箱 + 密码登录
user = authenticateByEmail(request.getEmail(), request.getPassword());
log.info("✅ 邮箱密码登录成功: {}", request.getEmail());
} else if (StringUtils.hasText(request.getEmail()) && StringUtils.hasText(request.getVerificationCode())) {
// 方式3邮箱 + 验证码登录
user = authenticateByEmailCode(request.getEmail(), request.getVerificationCode());
log.info("✅ 邮箱验证码登录成功: {}", request.getEmail());
} else {
throw new BadCredentialsException("无效的登录参数组合");
}
/**
* 刷新访问令牌
*
* 主人的自定义逻辑实现位置 #3
*
* TODO: 主人请在这里实现刷新令牌逻辑具体步骤如下
* 1. 验证 refreshToken 的有效性
* if (!jwtUtil.validateToken(refreshToken)) {
* throw new BadCredentialsException("刷新令牌无效或已过期");
* }
* 2. refreshToken 中提取用户名
* String username = jwtUtil.getUsernameFromToken(refreshToken);
* 3. 生成新的 Access Token
* String newAccessToken = jwtUtil.generateAccessToken(username);
* 4. 获取过期时间
* Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
* 5. 构建并返回 RefreshResponse 对象
* return new RefreshResponse(newAccessToken, expiresIn, "Bearer");
*
* 💡 扩展提示
* - 可以实现 Refresh Token 轮换机制每次刷新都返回新的 refreshToken
* - 可以实现 Refresh Token 黑名单用户登出时将 refreshToken 加入黑名单
* - 可以限制单个 refreshToken 的使用次数
* - 可以检查用户是否仍然处于激活状态调用 AdminUserRepository 再次验证
*
* @param refreshToken 刷新令牌
* @return RefreshResponse 刷新响应包含新的 accessToken
* @throws BadCredentialsException 刷新令牌无效时抛出
*/
public RefreshResponse refresh(String refreshToken) {
// TODO: 主人请实现这里的逻辑
// 1. if (!jwtUtil.validateToken(refreshToken)) {
// throw new BadCredentialsException("刷新令牌无效或已过期");
// }
// 2. String username = jwtUtil.getUsernameFromToken(refreshToken);
// 3. String newAccessToken = jwtUtil.generateAccessToken(username);
// 4. Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
// 5. return new RefreshResponse(newAccessToken, expiresIn, "Bearer");
throw new UnsupportedOperationException("主人,请实现 refresh 方法!");
// TODO: 主人这里生成 JWT Token 并构建响应
// 1. 使用 user.getUsername() 生成 accessToken refreshToken
// 2. 调用 buildLoginResponse() 方法构建包含完整用户信息的响应
// 生成 JWT Token
String accessToken = jwtUtil.generateAccessToken(user.getUsername());
String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());
Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
// 构建响应包含完整用户信息
return buildLoginResponse(user, accessToken, refreshToken, expiresIn);
}
/**
* 用户名 + 密码认证
*
* @param username 用户名
* @param password 密码
* @return AdminUser 用户实体
*/
private AdminUser authenticateByUsername(String username, String password) {
// TODO: 主人这里使用 Spring Security AuthenticationManager 进行认证
// 步骤
// 1. 创建 UsernamePasswordAuthenticationToken
// 2. 调用 authenticationManager.authenticate(authToken)
// 3. 认证成功后从数据库查询用户信息并返回
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(authToken);
// 认证成功查询用户完整信息
return adminUserRepository.findByUsername(username)
.orElseThrow(() -> new BadCredentialsException("用户不存在"));
}
/**
* 邮箱 + 密码认证
*
* @param email 邮箱
* @param password 密码
* @return AdminUser 用户实体
*/
private AdminUser authenticateByEmail(String email, String password) {
// TODO: 主人这里验证邮箱和密码
// 步骤
// 1. 通过邮箱查询用户adminUserRepository.findByEmail(email)
// 2. 如果用户不存在抛出 BadCredentialsException
// 3. 验证密码passwordEncoder.matches(password, user.getPasswordHash())
// 4. 如果密码错误抛出 BadCredentialsException
// 5. 检查账号是否激活
// 6. 返回用户实体
AdminUser user = adminUserRepository.findByEmail(email)
.orElseThrow(() -> new BadCredentialsException("邮箱或密码错误"));
// 验证密码
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new BadCredentialsException("邮箱或密码错误");
}
// 检查账号状态
if (!user.getIsActive()) {
throw new BadCredentialsException("账号已被禁用");
}
return user;
}
/**
* 邮箱 + 验证码认证
*
* @param email 邮箱
* @param code 验证码
* @return AdminUser 用户实体
*/
private AdminUser authenticateByEmailCode(String email, String code) {
// TODO: 主人这里验证邮箱验证码
// 步骤
// 1. 调用 emailVerificationService.verifyCode(email, code) 验证验证码
// 2. 如果验证失败抛出 BadCredentialsException
// 3. 通过邮箱查询用户
// 4. 检查账号是否激活
// 5. 返回用户实体
// 验证验证码
if (!emailVerificationService.verifyCode(email, code)) {
throw new BadCredentialsException("验证码无效或已过期");
}
// 查询用户
AdminUser user = adminUserRepository.findByEmail(email)
.orElseThrow(() -> new BadCredentialsException("该邮箱未注册"));
// 检查账号状态
if (!user.getIsActive()) {
throw new BadCredentialsException("账号已被禁用");
}
return user;
}
/**
* 构建登录响应包含完整用户信息
*
* @param user 用户实体
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @param expiresIn 过期时间
* @return LoginResponse
*/
private LoginResponse buildLoginResponse(AdminUser user, String accessToken,
String refreshToken, Long expiresIn) {
// TODO: 主人这里构建包含完整用户信息的响应
// user 实体的信息映射到 LoginResponse 的各个字段
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(expiresIn);
response.setTokenType("Bearer");
// 设置用户信息
response.setUserId(user.getId());
response.setUsername(user.getUsername());
response.setEmail(user.getEmail());
response.setIsActive(user.getIsActive());
response.setIsAdmin(true); // 所有 admin_user 表的用户都是管理员
response.setSuccess(true);
return response;
}
/**
* 刷新访问令牌
* <p>
* 主人的自定义逻辑实现位置 #3
* <p>
* 💡 扩展提示
* - 可以实现 Refresh Token 轮换机制每次刷新都返回新的 refreshToken
* - 可以实现 Refresh Token 黑名单用户登出时将 refreshToken 加入黑名单
* - 可以限制单个 refreshToken 的使用次数
* - 可以检查用户是否仍然处于激活状态调用 AdminUserRepository 再次验证
*
* @param refreshToken 刷新令牌
* @return RefreshResponse 刷新响应包含新的 accessToken
* @throws BadCredentialsException 刷新令牌无效时抛出
*/
public RefreshResponse refresh(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new BadCredentialsException("刷新令牌无效或已过期");
}
String username = jwtUtil.getUsernameFromToken(refreshToken);
String newAccessToken = jwtUtil.generateAccessToken(username);
Long expiresIn = jwtUtil.getAccessTokenExpirationInSeconds();
return new RefreshResponse(newAccessToken, expiresIn, "Bearer");
}
}

View File

@ -0,0 +1,94 @@
package top.biwin.xinayu.server.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 邮件发送服务
* 负责发送各类邮件验证码通知等
*
* @author wangli
* @since 2026-01-21
*/
@Slf4j
@Service
public class EmailService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username:noreply@xianyu-freedom.com}")
private String fromEmail;
/**
* 异步发送验证码邮件
*
* @param toEmail 收件人邮箱
* @param code 验证码
*/
@Async
public void sendVerificationCode(String toEmail, String code) {
// TODO: 主人这里需要配置邮件发送逻辑
// 步骤
// 1. 创建 SimpleMailMessage 对象
// 2. 设置发件人message.setFrom(fromEmail)
// 3. 设置收件人message.setTo(toEmail)
// 4. 设置主题message.setSubject("【闲鱼自由】邮箱验证码")
// 5. 设置邮件内容message.setText("您的验证码是:" + code + "有效期5分钟。")
// 6. 调用 mailSender.send(message) 发送邮件
// 7. 记录日志log.info("验证码邮件已发送到: {}", toEmail)
// 8. 异常处理catch (Exception e) { log.error("邮件发送失败", e); }
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(toEmail);
message.setSubject("【闲鱼自由】邮箱验证码");
message.setText(
"尊敬的用户,您好!\n\n" +
"您的登录验证码是:" + code + "\n\n" +
"该验证码5分钟内有效请勿泄露给他人。\n\n" +
"如非本人操作,请忽略此邮件。\n\n" +
"——闲鱼自由团队"
);
mailSender.send(message);
log.info("✅ 验证码邮件已成功发送到: {}", toEmail);
} catch (Exception e) {
log.error("❌ 邮件发送失败,收件人: {}, 错误信息: {}", toEmail, e.getMessage(), e);
throw new RuntimeException("邮件发送失败,请稍后重试", e);
}
}
/**
* 发送通用邮件扩展功能
*
* @param toEmail 收件人
* @param subject 主题
* @param content 内容
*/
@Async
public void sendEmail(String toEmail, String subject, String content) {
// TODO: 主人可以在这里实现通用邮件发送逻辑哦
// 参考上面的 sendVerificationCode 方法实现即可
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(toEmail);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
log.info("✅ 邮件已发送到: {}, 主题: {}", toEmail, subject);
} catch (Exception e) {
log.error("❌ 邮件发送失败: {}", e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,162 @@
package top.biwin.xinayu.server.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.biwin.xianyu.core.entity.EmailVerificationCode;
import top.biwin.xianyu.core.repository.EmailVerificationCodeRepository;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 邮箱验证码管理服务
* 负责验证码的生成存储验证和过期管理
*
* @author wangli
* @since 2026-01-21
*/
@Slf4j
@Service
public class EmailVerificationService {
@Autowired
private EmailVerificationCodeRepository codeRepository;
@Autowired
private EmailService emailService;
/**
* 验证码长度6位数字
*/
@Value("${app.verification.code-length:6}")
private int codeLength;
/**
* 验证码过期时间分钟
*/
@Value("${app.verification.expiration-minutes:5}")
private int expirationMinutes;
/**
* 重发间隔
*/
@Value("${app.verification.resend-interval-seconds:60}")
private int resendIntervalSeconds;
private static final SecureRandom RANDOM = new SecureRandom();
/**
* 生成并发送验证码
*
* @param email 邮箱地址
* @throws RuntimeException 如果发送过于频繁
*/
@Transactional
public void generateAndSendCode(String email) {
// TODO: 主人这里需要实现防止频繁发送的逻辑
// 步骤
// 1. 查询该邮箱最新的验证码codeRepository.findFirstByEmailOrderByCreatedAtDesc(email)
// 2. 如果存在且创建时间距今小于 resendIntervalSeconds 则抛出异常
// 3. 生成6位随机数字验证码调用 generateCode() 方法
// 4. 计算过期时间LocalDateTime.now().plusMinutes(expirationMinutes)
// 5. 创建 EmailVerificationCode 实体并保存
// 6. 调用 emailService.sendVerificationCode(email, code) 发送邮件
// 防止频繁发送
Optional<EmailVerificationCode> latestCode = codeRepository.findFirstByEmailOrderByCreatedAtDesc(email);
if (latestCode.isPresent()) {
LocalDateTime lastSentTime = latestCode.get().getCreatedAt();
long secondsSinceLastSent = java.time.Duration.between(lastSentTime, LocalDateTime.now()).getSeconds();
if (secondsSinceLastSent < resendIntervalSeconds) {
long remainingSeconds = resendIntervalSeconds - secondsSinceLastSent;
throw new RuntimeException("验证码发送过于频繁,请 " + remainingSeconds + " 秒后再试");
}
}
// 生成验证码
String code = generateCode();
// 创建验证码记录
EmailVerificationCode verificationCode = new EmailVerificationCode();
verificationCode.setEmail(email);
verificationCode.setCode(code);
verificationCode.setExpiresAt(LocalDateTime.now().plusMinutes(expirationMinutes));
verificationCode.setIsUsed(false);
// 保存到数据库
codeRepository.save(verificationCode);
log.info("📧 已生成验证码,邮箱: {}, 过期时间: {}", email, verificationCode.getExpiresAt());
// 发送邮件
emailService.sendVerificationCode(email, code);
}
/**
* 验证验证码是否正确
*
* @param email 邮箱地址
* @param code 验证码
* @return 是否验证成功
*/
@Transactional
public boolean verifyCode(String email, String code) {
// TODO: 主人这里需要验证验证码是否有效
// 步骤
// 1. 查询未使用且未过期的验证码codeRepository.findByEmailAndCodeAndIsUsedFalseAndExpiresAtAfter(...)
// 2. 如果不存在返回 false
// 3. 如果存在标记为已使用verificationCode.setIsUsed(true)
// 4. 保存codeRepository.save(verificationCode)
// 5. 返回 true
LocalDateTime now = LocalDateTime.now();
Optional<EmailVerificationCode> verificationCodeOpt =
codeRepository.findByEmailAndCodeAndIsUsedFalseAndExpiresAtAfter(email, code, now);
if (verificationCodeOpt.isEmpty()) {
log.warn("⚠️ 验证码无效或已过期,邮箱: {}, 验证码: {}", email, code);
return false;
}
// 标记为已使用
EmailVerificationCode verificationCode = verificationCodeOpt.get();
verificationCode.setIsUsed(true);
codeRepository.save(verificationCode);
log.info("✅ 验证码验证成功,邮箱: {}", email);
return true;
}
/**
* 生成指定长度的数字验证码
*
* @return 验证码字符串
*/
private String generateCode() {
// TODO: 主人这里生成随机数字验证码
// 提示使用 StringBuilder RANDOM.nextInt(10) 生成 codeLength 位数字
StringBuilder code = new StringBuilder();
for (int i = 0; i < codeLength; i++) {
code.append(RANDOM.nextInt(10));
}
return code.toString();
}
/**
* 清理过期的验证码定时任务调用
*/
@Transactional
public void cleanupExpiredCodes() {
// TODO: 主人这里可以实现定时清理过期验证码的逻辑
// 调用codeRepository.deleteByExpiresAtBefore(LocalDateTime.now())
LocalDateTime now = LocalDateTime.now();
codeRepository.deleteByExpiresAtBefore(now);
log.info("🧹 已清理过期验证码");
}
}

View File

@ -0,0 +1,232 @@
package top.biwin.xinayu.server.service;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import top.biwin.xinayu.server.config.GeetestConfig;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* TODO
*
* @author wangli
* @since 2026-01-21 23:06
*/
@Slf4j
@Component
public class GeetestService {
private final String captchaId;
private final String privateKey;
public GeetestService() {
this.captchaId = GeetestConfig.CAPTCHA_ID;
this.privateKey = GeetestConfig.PRIVATE_KEY;
}
public enum DigestMod {
MD5("md5"),
SHA256("sha256"),
HMAC_SHA256("hmac-sha256");
private final String value;
DigestMod(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class GeetestResult {
private int status; // 1成功0失败
private String data; // 返回数据JSON字符串
private String msg; // 备注信息
public JSONObject toJsonObject() {
try {
if (data != null && !data.isEmpty()) {
return JSONUtil.parseObj(data);
}
} catch (Exception e) {
// ignore
}
return new JSONObject();
}
}
private String md5Encode(String value) {
return DigestUtil.md5Hex(value);
}
private String sha256Encode(String value) {
return DigestUtil.sha256Hex(value);
}
private String hmacSha256Encode(String value, String key) {
HMac hMac = new HMac(HmacAlgorithm.HmacSHA256, key.getBytes());
return hMac.digestHex(value);
}
private String encryptChallenge(String originChallenge, DigestMod digestMod) {
if (digestMod == DigestMod.MD5) {
return md5Encode(originChallenge + this.privateKey);
} else if (digestMod == DigestMod.SHA256) {
return sha256Encode(originChallenge + this.privateKey);
} else if (digestMod == DigestMod.HMAC_SHA256) {
return hmacSha256Encode(originChallenge, this.privateKey);
} else {
return md5Encode(originChallenge + this.privateKey);
}
}
private String requestRegister(Map<String, Object> params) {
params.put("gt", this.captchaId);
params.put("json_format", "1");
params.put("sdk", GeetestConfig.VERSION);
String url = GeetestConfig.API_URL + GeetestConfig.REGISTER_URL;
log.debug("极验register URL: {}", url);
try {
String result = HttpUtil.get(url, params, GeetestConfig.TIMEOUT);
log.debug("极验register响应: {}", result);
JSONObject json = JSONUtil.parseObj(result);
return json.getStr("challenge", "");
} catch (Exception e) {
log.error("极验register请求失败: {}", e.getMessage());
return "";
}
}
private GeetestResult buildRegisterResult(String originChallenge, DigestMod digestMod) {
// challenge为空或为0表示失败走宕机模式
if (originChallenge == null || originChallenge.isEmpty() || "0".equals(originChallenge)) {
// 本地生成随机challenge
String challenge = UUID.randomUUID().toString().replace("-", "");
JSONObject data = new JSONObject();
data.set("success", 0);
data.set("gt", this.captchaId);
data.set("challenge", challenge);
data.set("new_captcha", true);
return new GeetestResult(0, data.toString(), "初始化接口失败,后续流程走宕机模式");
} else {
// 正常模式加密challenge
String challenge = encryptChallenge(originChallenge, digestMod != null ? digestMod : DigestMod.MD5);
JSONObject data = new JSONObject();
data.set("success", 1);
data.set("gt", this.captchaId);
data.set("challenge", challenge);
data.set("new_captcha", true);
return new GeetestResult(1, data.toString(), "");
}
}
/**
* 验证码初始化
*/
public GeetestResult register(DigestMod digestMod, String userId, String clientType) {
if (digestMod == null) digestMod = DigestMod.MD5;
log.info("极验register开始: digest_mod={}", digestMod.getValue());
Map<String, Object> params = new HashMap<>();
params.put("digestmod", digestMod.getValue());
params.put("user_id", StrUtil.blankToDefault(userId, GeetestConfig.USER_ID));
params.put("client_type", StrUtil.blankToDefault(clientType, GeetestConfig.CLIENT_TYPE));
String originChallenge = requestRegister(params);
GeetestResult result = buildRegisterResult(originChallenge, digestMod);
log.info("极验register完成: status={}", result.getStatus());
return result;
}
/**
* 本地初始化宕机降级模式
*/
public GeetestResult localInit() {
log.info("极验本地初始化(宕机模式)");
return buildRegisterResult(null, null);
}
private String requestValidate(String challenge, String validate, String seccode, Map<String, Object> params) {
params.put("seccode", seccode);
params.put("json_format", "1");
params.put("challenge", challenge);
params.put("sdk", GeetestConfig.VERSION);
params.put("captchaid", this.captchaId);
String url = GeetestConfig.API_URL + GeetestConfig.VALIDATE_URL;
try {
String result = HttpUtil.post(url, params, GeetestConfig.TIMEOUT);
log.debug("极验validate响应: {}", result);
JSONObject json = JSONUtil.parseObj(result);
return json.getStr("seccode", "");
} catch (Exception e) {
log.error("极验validate请求失败: {}", e.getMessage());
return "";
}
}
private boolean checkParams(String challenge, String validate, String seccode) {
return StrUtil.isNotBlank(challenge) && StrUtil.isNotBlank(validate) && StrUtil.isNotBlank(seccode);
}
/**
* 正常模式下的二次验证
*/
public GeetestResult successValidate(String challenge, String validate, String seccode, String userId, String clientType) {
log.info("极验二次验证(正常模式): challenge={}...", challenge != null && challenge.length() > 16 ? challenge.substring(0, 16) : challenge);
if (!checkParams(challenge, validate, seccode)) {
return new GeetestResult(0, "", "正常模式本地校验参数challenge、validate、seccode不可为空");
}
Map<String, Object> params = new HashMap<>();
params.put("user_id", StrUtil.blankToDefault(userId, GeetestConfig.USER_ID));
params.put("client_type", StrUtil.blankToDefault(clientType, GeetestConfig.CLIENT_TYPE));
String responseSeccode = requestValidate(challenge, validate, seccode, params);
if (StrUtil.isBlank(responseSeccode)) {
return new GeetestResult(0, "", "请求极验validate接口失败");
} else if ("false".equals(responseSeccode)) {
return new GeetestResult(0, "", "极验二次验证不通过");
} else {
return new GeetestResult(1, "", "");
}
}
/**
* 宕机模式下的二次验证
*/
public GeetestResult failValidate(String challenge, String validate, String seccode) {
log.info("极验二次验证(宕机模式): challenge={}...", challenge != null && challenge.length() > 16 ? challenge.substring(0, 16) : challenge);
if (!checkParams(challenge, validate, seccode)) {
return new GeetestResult(0, "", "宕机模式本地校验参数challenge、validate、seccode不可为空");
} else {
return new GeetestResult(1, "", "");
}
}
}

View File

@ -1,5 +1,5 @@
-- 系统默认账号
INSERT OR IGNORE INTO users (username, email, password_hash) VALUES ('admin', 'admin@localhost', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92');
INSERT OR IGNORE INTO admin_user (username, email, password_hash) VALUES ('admin', 'admin@localhost', '$2a$12$Ozdr6p4aCMIrt8KvRalWseNfMhl7exyeolzZXvheRgY3lD5fZTyNm');
-- 系统默认设置
INSERT OR IGNORE INTO system_settings (key, value, description)
VALUES ('init_system', 'false', '是否初始化'),

View File

@ -1,6 +1,10 @@
app:
ddl-auto: update # valid values: none, validate, update, create, create-drop
# 应用验证码配置
verification:
code-length: 6 # 验证码长度6位数字
expiration-minutes: 5 # 验证码过期时间5分钟
resend-interval-seconds: 60 # 重发间隔60秒
server:
port: 8080
@ -33,6 +37,31 @@ spring:
max-file-size: 10MB
max-request-size: 10MB
# 邮件服务配置
mail:
# TODO: 主人需要配置邮件服务器信息!
# 示例Gmail SMTP 配置
# 1. host: smtp.gmail.com
# 2. port: 587
# 3. username: 你的Gmail邮箱
# 4. password: Gmail应用专用密码不是登录密码
# 5. 需要在 Gmail 账户设置中开启"两步验证"并生成"应用专用密码"
host: ${MAIL_HOST:smtp.gmail.com} # 邮件服务器地址
port: ${MAIL_PORT:587} # SMTP 端口
username: ${MAIL_USERNAME:} # 发件人邮箱(请在环境变量中配置)
password: ${MAIL_PASSWORD:} # 邮箱密码或应用专用密码(请在环境变量中配置)
properties:
mail:
smtp:
auth: true # 启用 SMTP 认证
starttls:
enable: true # 启用 TLS 加密
required: true # 强制使用 TLS
connectiontimeout: 5000 # 连接超时(毫秒)
timeout: 5000 # 读取超时(毫秒)
writetimeout: 5000 # 写入超时(毫秒)
logging:
level:
root: INFO
@ -49,12 +78,11 @@ jwt:
# JWT 密钥(生产环境请使用环境变量 JWT_SECRET
# 建议使用至少 256 位的随机字符串
secret: ${JWT_SECRET:xianyu-freedom-default-secret-key-please-change-in-production-environment-2026}
# Access Token 过期时间(毫秒)
# 15 分钟 = 900,000 毫秒
access-token-expiration: 900000
# Refresh Token 过期时间(毫秒)
# 7 天 = 604,800,000 毫秒
refresh-token-expiration: 604800000