init
This commit is contained in:
parent
6494acaa16
commit
6604fda564
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package top.biwin.xinayu.server.dto;
|
||||
package top.biwin.xinayu.common.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package top.biwin.xinayu.server.dto;
|
||||
package top.biwin.xinayu.common.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
|
||||
/**
|
||||
* 根据用户名查询用户
|
||||
*
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -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 场景)
|
||||
* - 禁用 CSRF(REST 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. 禁用 CSRF(JWT 无状态认证不需要)
|
||||
* 2. 配置授权规则:
|
||||
* - /auth/** 接口允许匿名访问(登录、刷新令牌)
|
||||
* - 其他所有接口需要认证
|
||||
* 3. 配置无状态会话管理
|
||||
* 4. 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前)
|
||||
*
|
||||
* @param http HttpSecurity 对象
|
||||
* @return SecurityFilterChain
|
||||
* @throws Exception 配置异常
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// 禁用 CSRF(JWT 场景下不需要)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
/**
|
||||
* 配置安全过滤器链
|
||||
*
|
||||
* 配置项:
|
||||
* 1. 禁用 CSRF(JWT 无状态认证不需要)
|
||||
* 2. 配置授权规则:
|
||||
* - /auth/** 接口允许匿名访问(登录、刷新令牌)
|
||||
* - 其他所有接口需要认证
|
||||
* 3. 配置无状态会话管理
|
||||
* 4. 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前)
|
||||
*
|
||||
* @param http HttpSecurity 对象
|
||||
* @return SecurityFilterChain
|
||||
* @throws Exception 配置异常
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// 禁用 CSRF(JWT 场景下不需要)
|
||||
.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 算法,强度为 12(2^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 算法,强度为 12(2^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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("数据库已初始化,跳过默认数据插入。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package top.biwin.xinayu.server.notice;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @author wangli
|
||||
* @since 2026-01-21 23:40
|
||||
*/
|
||||
public interface NoticeService {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package top.biwin.xinayu.server.notice.channel;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @author wangli
|
||||
* @since 2026-01-21 23:40
|
||||
*/
|
||||
public class EmailChannel {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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("🧹 已清理过期验证码");
|
||||
}
|
||||
}
|
||||
@ -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, "", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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', '是否初始化'),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user