feat: 实现完整的 RBAC 权限管理系统与基础设施增强

在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理),
  集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、
  RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例
This commit is contained in:
2026-04-10 10:58:22 +08:00
parent 3a9bf61839
commit 40c85c3c1f
97 changed files with 13434 additions and 351 deletions

725
DETAILS.md Normal file
View File

@@ -0,0 +1,725 @@
📋 项目概览
这是一个企业级 Spring Boot 3.x 模板项目,提供了完整的用户认证与权限管理系统。
核心功能
┌─────────────────────────────────────────────────────────────┐
│ 核心功能模块 │
├─────────────────────────────────────────────────────────────┤
│ 1. 用户认证 JWT + Refresh Token 双Token机制 │
│ 2. 权限管理 RBAC角色-权限)模型 │
│ 3. 账户安全 登录失败锁定、密码重置、限流 │
│ 4. 审计日志 记录所有敏感操作 │
│ 5. 缓存支持 Redis 缓存、分布式锁 │
│ 6. 消息队列 RocketMQ 异步处理 │
└─────────────────────────────────────────────────────────────┘
技术栈
Searched for 1 pattern, read 1 file (ctrl+o to expand)
● 现在让我为你详细剖析整个项目:
---
一、项目技术栈
┌─────────────────┬────────┬────────────────┐
│ 技术 │ 版本 │ 用途 │
├─────────────────┼────────┼────────────────┤
│ Spring Boot │ 3.5.6 │ 基础框架 │
├─────────────────┼────────┼────────────────┤
│ Spring Security │ 6.x │ 认证授权 │
├─────────────────┼────────┼────────────────┤
│ Spring Data JPA │ - │ ORM 数据访问 │
├─────────────────┼────────┼────────────────┤
│ MySQL │ - │ 关系数据库 │
├─────────────────┼────────┼────────────────┤
│ Redis │ - │ 缓存/分布式锁 │
├─────────────────┼────────┼────────────────┤
│ RocketMQ │ 2.3.1 │ 消息队列 │
├─────────────────┼────────┼────────────────┤
│ JJWT │ 0.11.5 │ JWT Token │
├─────────────────┼────────┼────────────────┤
│ Flyway │ - │ 数据库版本管理 │
├─────────────────┼────────┼────────────────┤
│ Lombok │ - │ 简化代码 │
└─────────────────┴────────┴────────────────┘
---
二、项目目录结构
● Bash(tree -L 4 -I 'target|.git' D:\tmp\springboot-template\src\main\java\com\aisi\template 2>/dev/null || find
D:\tmp\springboot-template\src\main\java\com\aisi\temp…)
 find: D:tmpspringboot-templatesrcmainjavacomaisitemplate: No such file or directory
sed: -e expression #1, char 7: unterminated `s' command
Searched for 1 pattern (ctrl+o to expand)
● 项目分层架构
├── 📁 annotation/ # 自定义注解AOP切点
│ ├── @AuditLog # 审计日志注解
│ ├── @RateLimit # 限流注解
│ └── @StrongPassword # 强密码校验注解
├── 📁 aspect/ # AOP切面
│ ├── AuditLogAspect # 审计日志切面
│ └── RateLimitAspect # 限流切面
├── 📁 config/ # 配置类
│ ├── SecurityConfig # Spring Security 安全配置
│ ├── RedisConfig # Redis 配置
│ ├── JpaConfig # JPA 审计配置
│ ├── WebConfig # Web MVC 配置
│ ├── JacksonConfig # JSON 序列化配置
│ └── OpenApiConfig # Swagger 文档配置
├── 📁 constants/ # 常量定义
│ ├── AppConstants # 应用常量
│ └── SecurityConstants # 安全常量
├── 📁 controller/ # 控制层API接口
│ ├── UserController # 用户接口
│ ├── RoleController # 角色接口
│ ├── MenuController # 菜单接口
│ └── PermissionController # 权限接口
├── 📁 domain/ # 领域模型
│ ├── entity/ # 实体类(数据库表)
│ ├── dto/ # 数据传输对象(请求参数)
│ ├── vo/ # 视图对象(响应数据)
│ └── enums/ # 枚举类
├── 📁 service/ # 服务层(业务逻辑)
│ └── impl/ # 服务实现
├── 📁 repository/ # 数据访问层DAO
├── 📁 filter/ # 过滤器
│ └── JwtAuthenticationFilter # JWT 认证过滤器
├── 📁 handler/ # 处理器
│ └── GlobalExceptionHandler # 全局异常处理
├── 📁 interceptor/ # 拦截器
│ └── LoggingInterceptor # 日志拦截器
├── 📁 mq/ # 消息队列
│ ├── producer/ # 消息生产者
│ ├── consumer/ # 消息消费者
│ └── message/ # 消息定义
├── 📁 utils/ # 工具类
│ ├── JwtUtil # JWT 工具
│ ├── SecurityUtils # 安全工具
│ ├── RedisCache # Redis 缓存
│ └── RedisLock # 分布式锁
├── 📁 examples/ # 使用示例
│ ├── RedisUsageExample # Redis 使用示例
│ └── TransactionExample # 事务使用示例
└── 📁 exception/ # 异常类
├── BusinessException # 业务异常
└── RateLimitExceededException # 限流异常
---
三、核心架构设计
3.1 认证架构
┌─────────────────────────────────────────────────────────────────┐
│ 认证流程 │
└─────────────────────────────────────────────────────────────────┘
客户端 Spring Boot JWT Token
│ │ │
│ 1. POST /api/v1/user/login │ │
│ {username, password} │ │
│ ────────────────────────────>│ │
│ │ │
│ │ 2. 验证用户名密码 │
│ │ 3. 检查账户锁定状态 │
│ │ 4. 生成 Access Token(1h) │
│ │ 5. 生成 Refresh Token(7d) │
│ │ 6. 存储 Refresh Token │
│ │ │
│ 7. 返回 Token │ │
│ {accessToken, │ │
│ refreshToken} │ │
│ <────────────────────────────│ │
│ │ │
│ 8. 后续请求携带 Token │ │
│ Authorization: Bearer │ │
│ {accessToken} │ │
│ ────────────────────────────>│ │
│ │ │
│ │ 9. JwtAuthenticationFilter │
│ │ 解析 Token │
│ │ 验证签名 │
│ │ 检查黑名单 │
│ │ 设置认证上下文 │
│ │ │
│ 10. 返回业务数据 │ │
│ <────────────────────────────│ │
3.2 权限模型RBAC
┌─────────────────────────────────────────────────────────────────┐
│ RBAC 权限模型 │
└─────────────────────────────────────────────────────────────────┘
用户(User) ──多对多── 角色(Role) ──多对多── 权限(Permission)
│ │ │
│ │ │
having having having
accounts roles permissions
示例:
用户 alice ──拥有──> [ROLE_USER, ROLE_ADMIN]
└── ROLE_ADMIN ──拥有──> [user:read, user:write,
role:read, menu:read]
权限计算:
alice 的权限 = ROLE_USER的权限 ROLE_ADMIN的权限
使用 @PreAuthorize 注解控制访问:
@PreAuthorize("hasAuthority('user:write')")
public void updateUser() { ... }
3.3 安全防护机制
┌─────────────────────────────────────────────────────────────────┐
│ 安全防护体系 │
└─────────────────────────────────────────────────────────────────┘
防护类型 实现方式 阈值
─────────────────────────────────────────────────────────────────
登录失败锁定 Redis计数器 + 时间窗口 5次/30分钟
密码重置限流 Redis计数器 + 冷却时间 1次/60秒
API接口限流 Redis + @RateLimit切面 可配置
密码强度校验 @StrongPassword注解 大小写+数字+特殊字符
Token黑名单 Redis + JTI 登出时加入
Token轮换 Refresh Token机制 刷新后旧Token失效
密码重置 邮箱验证码 6位/10分钟有效
---
四、数据库设计
Searched for 1 pattern, read 2 files (ctrl+o to expand)
● 数据库表结构
┌─────────────────────────────────────────────────────────────────┐
│ 数据库表关系图 │
└─────────────────────────────────────────────────────────────────┘
核心表关系:
users (用户表)
├── 1:N ──> refresh_tokens (刷新令牌表)
├── N:M ──> sys_role (角色表) ── N:M ──> sys_permission (权限表)
│ │ │
│ └── N:M ──> sys_menu (菜单表) │
│ │
└── 1:N ──> password_reset_codes (密码重置码表)
辅助表:
├── sys_audit_log (审计日志表)
└── sys_user_role (用户-角色关联表)
sys_role_permission (角色-权限关联表)
sys_role_menu (角色-菜单关联表)
┌────────────────┬──────────┬─────────────────────────────────────────────────────────────────────┐
│ 表名 │ 用途 │ 关键字段 │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ users │ 用户信息 │ username, password, email, status, failed_login_count, locked_until │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ sys_role │ 角色定义 │ role_code(ROLE_ADMIN等), role_name, status │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ sys_permission │ 权限定义 │ permission_code(user:read等), resource, action │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ sys_menu │ 菜单定义 │ menu_name, menu_type(目录/页面/按钮), parent_id │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ refresh_tokens │ 刷新令牌 │ token_hash(SHA-256), expires_at, revoked │
├────────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ sys_audit_log │ 审计日志 │ user_id, action(LOGIN/CREATE等), resource, status │
└────────────────┴──────────┴─────────────────────────────────────────────────────────────────────┘
---
五、核心模块详解
5.1 认证模块
核心类关系:
SecurityConfig (配置入口)
├── CustomUserDetailsService (加载用户)
│ └── UserRepository (查询用户)
├── JwtAuthenticationFilter (JWT过滤器)
│ ├── JwtUtil (Token生成/解析)
│ └── TokenService (Token管理)
│ └── RefreshTokenRepository
└── PasswordEncoder (BCrypt加密)
认证流程详解:
Read 1 file (ctrl+o to expand)
● JWT认证过滤器详解
Read 1 file (ctrl+o to expand)
● 认证流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ 完整认证流程 │
└─────────────────────────────────────────────────────────────────────────┘
1. 用户登录
├─> UserController.login()
│ │
│ ├─> UserService.login()
│ │ │
│ │ ├─> 1. 校验用户名密码
│ │ ├─> 2. 检查账户锁定状态
│ │ ├─> 3. 检查账户启用状态
│ │ ├─> 4. 记录登录失败(成功则清除)
│ │ ├─> 5. 生成 Access Token (1小时)
│ │ ├─> 6. 生成 Refresh Token (7天)
│ │ ├─> 7. 存储 Refresh Token 到数据库
│ │ ├─> 8. 发送登录消息到 MQ
│ │ └─> 9. 返回 LoginResponseVo
│ │
│ └─> 返回 { accessToken, refreshToken, expiresIn, userInfo }
2. 后续请求
├─> JwtAuthenticationFilter.doFilterInternal()
│ │
│ ├─> 1. 提取 Token (Authorization: Bearer {token})
│ ├─> 2. 验证 Token 签名
│ ├─> 3. 检查黑名单 (登出时加入)
│ ├─> 4. 检查过期时间
│ ├─> 5. 加载用户详情
│ └─> 6. 设置认证到 SecurityContext
└─> 继续处理请求
3. Token 刷新
├─> UserController.refreshToken()
│ │
│ └─> TokenService.refreshToken()
│ │
│ ├─> 1. 验证 Refresh Token
│ ├─> 2. 撤销旧 Refresh Token
│ ├─> 3. 生成新的 Access Token
│ ├─> 4. 生成新的 Refresh Token
│ └─> 5. 返回新 Token
4. 用户登出
├─> UserController.logout()
│ │
│ └─> TokenService.addTokenToBlacklist()
│ │
│ ├─> 1. 提取 Token 的 JTI
│ ├─> 2. 计算剩余过期时间
│ ├─> 3. 存入 Redis 黑名单
│ └─> 4. 撤销所有 Refresh Token
5.2 权限模块详解
Read 1 file (ctrl+o to expand)
● 权限加载流程
┌─────────────────────────────────────────────────────────────────────────┐
│ 权限加载与验证流程 │
└─────────────────────────────────────────────────────────────────────────┘
1. 用户登录时加载权限
├─> CustomUserDetailsService.loadUserByUsername(username)
│ │
│ ├─> 查询用户: User + Roles (JOIN FETCH)
│ │
│ ├─> 遍历用户的每个角色
│ │ │
│ │ └─> 角色 ROLE_ADMIN
│ │ │
│ │ ├─> 添加角色权限: "ROLE_ADMIN"
│ │ │
│ │ └─> 添加角色下的具体权限
│ │ ├── "user:create"
│ │ ├── "user:read"
│ │ ├── "user:update"
│ │ ├── "role:read"
│ │ └── ...
│ │
│ └─> 合并所有角色的权限 → Set<GrantedAuthority>
2. 接口权限验证
├─> @PreAuthorize("hasAuthority('user:create')")
│ │
│ └─> Spring Security 检查当前用户的权限列表
│ │
│ └─> 包含 "user:create" → ✅ 允许访问
│ 不包含 → ❌ 拒绝访问
3. 权限类型说明
├── 角色权限 (ROLE_XXX)
│ ├── 用于粗粒度控制:判断用户角色
│ └── 使用方式: @PreAuthorize("hasRole('ADMIN')")
│ 等价于: hasAuthority('ROLE_ADMIN')
└── 具体权限 (XXX:YYY)
├── 用于细粒度控制:具体操作
└── 使用方式: @PreAuthorize("hasAuthority('user:create')")
5.3 安全防护模块
Read 1 file (ctrl+o to expand)
● 安全防护机制详解
┌─────────────────────────────────────────────────────────────────────────┐
│ 安全防护机制 │
└─────────────────────────────────────────────────────────────────────────┘
1. 登录失败锁定机制
├─> 触发条件:连续登录失败 5 次(可配置)
├─> 锁定时长30 分钟(可配置)
├─> 实现方式:
│ ├── users 表存储失败次数和锁定时间
│ ├── failed_login_count失败次数
│ └── locked_until锁定到期时间
└─> 自动解锁:锁定时间到期后自动解锁
2. 限流机制 (@RateLimit)
├─> 支持的限流类型:
│ ├── IP按 IP 地址限流
│ ├── USER按用户 ID 限流
│ └── GLOBAL全局限流
├─> 使用方式:
@RateLimit(permitted = 10, seconds = 60, limitType = LimitType.USER)
│ public void sensitiveOperation() { ... }
└─> 实现原理:
├── Redis 存储计数器
├── 格式rate_limit:user:{userId}
├── 过期时间:时间窗口
└── 超过限制:抛出 RateLimitExceededException
3. 密码重置机制
├─> 验证码规则:
│ ├── 6 位随机数字
│ ├── 10 分钟有效期
│ ├── 最多尝试 5 次
│ └── 60 秒请求冷却时间
├─> 防用户名枚举:
│ └── 无论邮箱是否存在,都返回相同消息
└─> 验证码存储:
├── 哈希存储SHA-256
└── 数据库 + 状态管理
4. Token 黑名单机制
├─> 触发场景:
│ ├── 用户主动登出
│ ├── 管理员强制下线
│ └── 修改密码后
├─> 存储方式Redis
└─> 键格式token:blacklist:{jti}
└── 过期时间Token 剩余有效期
5.4 审计日志模块
Read 1 file (ctrl+o to expand)
● 审计日志使用示例
// 在方法上添加 @AuditLog 注解
@AuditLog(
action = "CREATE",
resource = "user",
description = "创建用户 {0}"
)
public void createUser(String username) {
// 业务逻辑
}
// AOP 切面会自动记录:
// - 操作用户(从 SecurityContext 获取)
// - 操作类型CREATE
// - 资源类型user
// - 操作描述:"创建用户 alice"
// - 请求信息IP、User-Agent
// - 操作结果:成功/失败
5.5 Redis 缓存模块
Read 1 file (ctrl+o to expand)
● Redis 缓存使用场景
┌─────────────────────────────────────────────────────────────────────────┐
│ Redis 使用场景总结 │
└─────────────────────────────────────────────────────────────────────────┘
1. Token 黑名单
├── 键格式token:blacklist:{jti}
├── 值1固定值只判断键是否存在
└── 过期时间Token 剩余有效期
2. Refresh Token 存储
├── 键格式refresh_token:{tokenHash}
├── 值用户ID
└── 过期时间7 天
3. 限流计数器
├── 键格式rate_limit:{type}:{identifier}
│ ├── type: ip / user / global
│ └── identifier: IP地址 / 用户ID
├── 值:当前请求次数
└── 过期时间:时间窗口
4. 登录失败计数
├── 键格式login_attempts:{username}
├── 值:失败次数
└── 过期时间锁定时长30分钟
5. 分布式锁
├── 键格式lock:{resource}:{id}
├── 值唯一标识UUID
├── 过期时间:锁超时时间(防止死锁)
└── 实现方式SET NX EX + Lua 脚本
6. 用户会话缓存
├── 键格式session:user:{userId}
├── 值会话信息登录IP、时间等
└── 过期时间24 小时
7. 密码重置验证码
├── 存储:数据库 password_reset_codes 表
└── 限制Redis + 冷却时间
5.6 消息队列模块
Read 3 files (ctrl+o to expand)
● RocketMQ 消息队列架构
┌─────────────────────────────────────────────────────────────────────────┐
│ RocketMQ 消息流程 │
└─────────────────────────────────────────────────────────────────────────┘
用户登录
├─> UserService.login()
│ │
│ ├─> 1. 验证用户
│ ├─> 2. 生成 Token
│ ├─> 3. 发送登录消息到 MQ
│ │ │
│ │ └─> UserMessageProducer.sendLoginMessage()
│ │ │
│ │ └─> rocketMQTemplate.asyncSend(user-topic, message)
│ │
│ └─> 4. 返回响应(不等待 MQ 处理)
└─> RocketMQ: user-topic
└─> UserMessageConsumer.onMessage()
├─> 1. 收到消息
├─> 2. 根据 messageType 路由
│ │
│ ├── REGISTER → handleRegisterMessage()
│ │ └─> 发送欢迎邮件、初始化数据
│ │
│ ├── LOGIN → handleLoginMessage()
│ │ └─> 记录登录日志、更新最后登录时间
│ │
│ ├── LOGOUT → handleLogoutMessage()
│ │ └─> 清理缓存、登出其他设备
│ │
│ ├── UPDATE → handleUpdateMessage()
│ │ └─> 同步数据到数据仓库、清除缓存
│ │
│ └── DELETE → handleDeleteMessage()
│ └─> 清理关联数据、归档
└─> 3. 处理完成,确认消费
---
六、配置文件解析
Searched for 1 pattern, read 2 files (ctrl+o to expand)
● 配置文件结构总结
┌─────────────────────────────────────────────────────────────────────────┐
│ 配置文件结构 │
└─────────────────────────────────────────────────────────────────────────┘
application.yaml (主配置)
├── spring.profiles.active: dev (激活环境)
├── server.port: 8080
└── 支持环境变量覆盖 (.env 文件)
application-dev.yaml (开发环境)
├── 数据库配置 (MySQL)
│ ├── url: jdbc:mysql://localhost:3306/template
│ ├── username: root
│ └── password: root
├── JPA 配置
│ ├── ddl-auto: validate (不自动建表,由 Flyway 管理)
│ └── show-sql: false
├── Flyway (数据库版本管理)
│ ├── enabled: true
│ └── locations: classpath:db/migration
├── Redis 配置
│ ├── host: localhost:6379
│ └── database: 0
├── 邮件配置 (SMTP)
│ ├── host: smtp.163.com
│ └── port: 465
├── JWT 配置
│ ├── secret: 密钥HS512 需要 64 字符)
│ ├── access-token-expiration: 3600 秒 (1 小时)
│ └── refresh-token-expiration: 604800 秒 (7 天)
├── 业务配置
│ ├── 登录失败锁定: 5 次 / 30 分钟
│ └── 密码重置: 6 位验证码 / 10 分钟有效
└── RocketMQ 配置
└── name-server: localhost:9876
---
七、请求处理完整流程
┌─────────────────────────────────────────────────────────────────────────┐
│ 完整请求处理流程 │
└─────────────────────────────────────────────────────────────────────────┘
1. 客户端发起请求
├─> GET /api/v1/user/info
│ Headers:
│ - Authorization: Bearer {accessToken}
│ - Content-Type: application/json
2. 进入 Spring MVC 处理链
├─> LoggingInterceptor (拦截器)
│ ├── 记录请求开始时间
│ ├── 生成请求 ID (8 位 UUID)
│ ├── 获取当前用户信息
│ └── 记录请求开始日志
3. 进入 Filter 链
├─> JwtAuthenticationFilter (JWT 过滤器)
│ ├── 提取 Token: Bearer {token}
│ ├── 验证 Token 签名 (HS512)
│ ├── 检查黑名单 (Redis: token:blacklist:{jti})
│ ├── 检查过期时间
│ ├── 加载用户详情
│ └── 设置认证到 SecurityContext
4. 权限验证
├─> @PreAuthorize("hasAuthority('user:read')")
│ └── 检查用户权限列表是否包含 "user:read"
5. AOP 切面处理
├─> AuditLogAspect (审计日志切面)
│ ├── 检查 @AuditLog 注解
│ ├── 提取请求信息
│ ├── 提取当前用户信息
│ └── 准备记录审计日志(异步执行)
6. 执行 Controller
├─> UserController.getUserInfo()
│ ├── 从 SecurityContext 获取用户
│ └── 返回用户信息 (UserVo)
7. 响应处理
├─> 全局异常处理
│ └── GlobalExceptionHandler
│ ├── 捕获业务异常
│ ├── 捕获参数校验异常
│ └── 捕获系统异常
8. 后置处理
├─> LoggingInterceptor.afterCompletion()
│ ├── 计算请求耗时
│ └── 记录请求完成日志
└─> 返回响应
└─> RestBean<UserVo>
├── code: 200
├── message: "操作成功"
└── data: UserVo
---
八、总结与建议
项目优势
1. 架构清晰:分层明确,职责单一
2. 安全完善JWT + RBAC + 多重防护
3. 扩展性强:模块化设计,易于扩展
4. 文档完整:全中文注释,便于维护
5. 异步解耦RocketMQ 消息队列
适用场景
- 企业内部管理系统
- SaaS 多租户平台
- 需要 RBAC 权限的系统
- 需要审计日志的系统
- 需要高安全性的系统
扩展建议
1. 数据权限:在 RBAC 基础上增加数据权限控制
2. 多租户:添加租户隔离机制
3. OAuth2支持第三方登录微信、GitHub等
4. 监控告警:集成 Prometheus + Grafana
5. 日志收集:集成 ELKElasticsearch + Logstash + Kibana

View File

@@ -154,6 +154,9 @@ docker run -d -p 8080:8080 \
详见 [API_DOCUMENT.md](API_DOCUMENT.md) 详见 [API_DOCUMENT.md](API_DOCUMENT.md)
## 项目概览
详见 [DETAILS.md](DETAILS.md)
## License ## License
Apache License 2.0 Apache License 2.0

559
README_USAGE.md Normal file
View File

@@ -0,0 +1,559 @@
# Spring Boot 模板项目使用文档
## 项目概述
这是一个功能完整的 Spring Boot 3.x 项目模板,集成了以下核心功能:
- **用户认证与授权**:基于 JWT + Spring Security
- **RBAC 权限模型**:角色-权限-菜单三级权限控制
- **数据库迁移**Flyway 版本化管理
- **Redis 缓存**:多种场景化缓存支持
- **RocketMQ 消息队列**:异步消息处理
- **分布式锁**:基于 Redis 实现
- **接口限流**:基于 Redis + AOP
- **审计日志**AOP 自动记录操作日志
- **账户安全**:登录失败锁定、密码强度校验
---
## 1. 快速开始
### 1.1 环境要求
- JDK 17+
- MySQL 8.0+
- Redis 6.0+
- RocketMQ 5.x可选
### 1.2 数据库初始化
项目使用 Flyway 自动管理数据库迁移,启动时会自动执行以下操作:
1. 创建用户表
2. 创建 RBAC 相关表(角色、权限、菜单)
3. 创建审计日志表
4. 创建 Refresh Token 表
### 1.3 配置文件
修改 `application-dev.yaml`
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_database?useSSL=false&serverTimezone=Asia/Shanghai
username: your_username
password: your_password
data:
redis:
host: localhost
port: 6379
password: your_redis_password
jwt:
secret: your-secret-key-at-least-64-characters-long
```
---
## 2. 核心功能使用
### 2.1 用户认证与授权
#### 注册用户
```java
@Autowired
private UserService userService;
UserDto userDto = new UserDto();
userDto.setUsername("testuser");
userDto.setPassword("Test123!"); // 必须包含大小写字母、数字、特殊字符
userDto.setEmail("test@example.com");
RestBean<LoginResponseVo> result = userService.register(userDto);
```
#### 用户登录
```java
UserDto loginDto = new UserDto();
loginDto.setUsername("testuser");
loginDto.setPassword("Test123!");
RestBean<LoginResponseVo> result = userService.login(loginDto);
// 返回 accessToken 和 refreshToken
```
#### 使用 Token 访问接口
```bash
curl -H "Authorization: Bearer {accessToken}" \
http://localhost:8080/api/v1/user/info
```
#### Token 刷新
```bash
curl -X POST \
-H "Content-Type: application/json" \
-d '{"refreshToken": "{refreshToken}"}' \
http://localhost:8080/api/v1/user/refresh
```
#### 登出
```bash
curl -X POST \
-H "Authorization: Bearer {accessToken}" \
http://localhost:8080/api/v1/user/logout
```
---
### 2.2 RBAC 权限控制
#### 创建角色
```java
@Autowired
private SysRoleService roleService;
RoleDto roleDto = new RoleDto();
roleDto.setRoleCode("ROLE_EDITOR");
roleDto.setRoleName("编辑");
roleDto.setDescription("内容编辑角色");
roleDto.setSortOrder(3);
roleDto.setStatus(1);
RoleVo role = roleService.create(roleDto);
```
#### 为角色分配权限
```java
List<Long> permissionIds = Arrays.asList(1L, 2L, 3L); // 文章创建、编辑、删除
roleService.assignPermissions(roleId, permissionIds);
```
#### 为用户分配角色
```java
@Autowired
private UserService userService;
UserRoleUpdateDto dto = new UserRoleUpdateDto();
dto.setRoleIds(Set.of(1L, 2L)); // 角色ID列表
userService.updateUserRole(userId, dto);
```
#### 在 Controller 中使用权限注解
```java
@RestController
@RequestMapping("/api/v1/articles")
public class ArticleController {
// 只有具有 'article:create' 权限的用户可以访问
@PreAuthorize("hasAuthority('article:create')")
@PostMapping
public RestBean<Void> create(@RequestBody ArticleDto dto) {
// ...
}
// 需要管理员角色或文章管理权限
@PreAuthorize("hasAnyRole('ROLE_ADMIN') or hasAuthority('article:delete')")
@DeleteMapping("/{id}")
public RestBean<Void> delete(@PathVariable Long id) {
// ...
}
}
```
---
### 2.3 Redis 缓存使用
#### 基本缓存操作
```java
@Autowired
private RedisCache redisCache;
// 设置缓存30分钟过期
redisCache.set("user:" + userId, userObject, 30, TimeUnit.MINUTES);
// 获取缓存
User user = redisCache.get("user:" + userId, User.class);
// 删除缓存
redisCache.delete("user:" + userId);
```
#### 计数器(阅读数、点赞数)
```java
// 原子自增
long viewCount = redisCache.increment("article:view:" + articleId, 1);
// 原子自减
long stock = redisCache.decrement("product:stock:" + productId, quantity);
```
#### 哈希表(对象字段缓存)
```java
// 设置单个字段
redisCache.hSet("user:profile:" + userId, "nickname", "张三");
redisCache.hSet("user:profile:" + userId, "age", 25);
// 获取单个字段
String nickname = (String) redisCache.hGet("user:profile:" + userId, "nickname");
// 获取所有字段
Map<Object, Object> profile = redisCache.hGetAll("user:profile:" + userId);
```
#### 列表(消息队列)
```java
// 从右侧推入(队列尾部)
redisCache.lRightPush("queue:email", emailObject);
// 从左侧弹出(队列头部)
Object email = redisCache.lLeftPop("queue:email");
```
#### 集合(去重、标签)
```java
// 添加标签(自动去重)
redisCache.sAdd("article:tags:" + articleId, "Java", "Spring", "Redis");
// 检查标签是否存在
boolean hasTag = redisCache.sIsMember("article:tags:" + articleId, "Java");
// 获取所有标签
Set<Object> tags = redisCache.sMembers("article:tags:" + articleId);
```
#### 有序集合(排行榜)
```java
// 添加到排行榜
redisCache.zAdd("leaderboard:user:score", userId, score);
// 获取用户排名
Long rank = redisCache.zReverseRank("leaderboard:user:score", userId);
// 获取用户分数
Double score = redisCache.zScore("leaderboard:user:score", userId);
// 增加分数
redisCache.zIncrementScore("leaderboard:user:score", userId, 10.0);
```
---
### 2.4 分布式锁使用
```java
@Autowired
private RedisLock redisLock;
public void processOrder(Long orderId) {
// 1. 获取锁30秒过期
String lockValue = redisLock.tryLock("order:" + orderId, 30);
if (lockValue == null) {
throw new RuntimeException("订单正在处理中");
}
try {
// 2. 执行业务逻辑
// ...
} finally {
// 3. 释放锁(只有持有者才能释放)
redisLock.unlock("order:" + orderId, lockValue);
}
}
```
---
### 2.5 接口限流使用
```java
@RestController
public class LoginController {
// 限制每 IP 每分钟最多 5 次登录尝试
@RateLimit(permits = 5, seconds = 60, limitType = RateLimit.LimitType.IP)
@PostMapping("/login")
public RestBean<LoginResponseVo> login(@RequestBody UserDto dto) {
// ...
}
}
```
---
### 2.6 审计日志使用
```java
@RestController
public class UserController {
// 自动记录审计日志
@AuditLog(action = "update", resource = "user", description = "更新用户信息")
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('user:update')")
public RestBean<UserVo> updateUser(@PathVariable Long id, @RequestBody UserDto dto) {
// 操作会被自动记录到 sys_audit_log 表
}
}
```
---
### 2.7 事务使用
#### 基本事务
```java
@Service
public class UserService {
// 所有异常都回滚
@Transactional(rollbackFor = Exception.class)
public void createUserWithRole(UserDto userDto, Set<Long> roleIds) {
// 创建用户
User user = new User();
// ...
userRepository.save(user);
// 分配角色
for (Long roleId : roleIds) {
SysRole role = roleRepository.findById(roleId)
.orElseThrow(() -> new BusinessException("角色不存在"));
user.getRoles().add(role);
}
userRepository.save(user);
// 任何异常都会回滚整个事务
}
}
```
#### 嵌套事务
```java
// 外层事务
@Transactional(propagation = Propagation.REQUIRED)
public void outerMethod() {
// 业务逻辑
innerMethod(); // 加入外层事务
}
// 内层事务
@Transactional(propagation = Propagation.REQUIRED)
public void innerMethod() {
// 加入外层事务
}
```
#### 独立事务
```java
@Transactional(propagation = Propagation.REQUIRED)
public void mainMethod() {
// 主事务逻辑
// 独立事务(即使主事务回滚也不影响)
recordLog();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog() {
// 独立事务
}
```
---
### 2.8 RocketMQ 消息队列使用
#### 发送消息
```java
@Autowired
private UserMessageProducer messageProducer;
// 发送注册消息
messageProducer.sendRegisterMessage(userId, username, email);
// 发送登录消息
messageProducer.sendLoginMessage(userId, username, ipAddress);
```
#### 消费消息
消息会被 `UserMessageConsumer` 自动消费,根据消息类型执行不同操作:
```java
@RocketMQMessageListener(
consumerGroup = "user-consumer-group",
topic = "user-topic"
)
public class UserMessageConsumer implements RocketMQListener<UserMessage> {
@Override
public void onMessage(UserMessage message) {
switch (message.getMessageType()) {
case "REGISTER":
// 处理注册消息(发送欢迎邮件等)
break;
case "LOGIN":
// 处理登录消息(记录登录日志)
break;
// ...
}
}
}
```
---
## 3. 配置说明
### 3.1 JWT 配置
```yaml
jwt:
# JWT 密钥至少64字符
secret: your-secret-key-at-least-64-characters-long
# Access Token 过期时间(秒)
access-token-expiration: 3600 # 1小时
# Refresh Token 过期时间(秒)
refresh-token-expiration: 604800 # 7天
```
### 3.2 登录安全配置
```yaml
app:
login:
# 最大失败次数
max-attempts: 5
# 锁定时长(分钟)
lock-duration-minutes: 30
```
### 3.3 Redis 配置
```yaml
spring:
data:
redis:
host: localhost
port: 6379
password: your-password
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
```
### 3.4 RocketMQ 配置
```yaml
rocketmq:
name-server: localhost:9876
producer:
group: user-producer-group
user-topic: user-topic
```
---
## 4. 常见问题
### 4.1 如何添加新的权限?
1. 在数据库中插入权限记录(通过 Flyway 迁移脚本)
2. 在 Controller 上使用 `@PreAuthorize` 注解
```java
@PreAuthorize("hasAuthority('new:permission')")
public void someMethod() { }
```
### 4.2 如何自定义限流规则?
使用 `@RateLimit` 注解:
```java
@RateLimit(
permits = 10, // 10次
seconds = 60, // 每分钟
limitType = RateLimit.LimitType.USER, // 按用户限流
keyPrefix = "custom:" // 自定义键前缀
)
public void customMethod() { }
```
### 4.3 如何实现缓存预热?
在应用启动时加载热点数据:
```java
@Component
public class CacheWarmupRunner implements ApplicationRunner {
@Autowired
private RedisCache redisCache;
@Override
public void run(ApplicationArguments args) {
// 预热热点数据
List<User> hotUsers = userRepository.findHotUsers();
for (User user : hotUsers) {
redisCache.set("user:" + user.getId(), user, 1, TimeUnit.HOURS);
}
}
}
```
---
## 5. 最佳实践
### 5.1 密码安全
- 生产环境必须修改 JWT 密钥
- 使用强密码策略(已集成 `@StrongPassword`
- 定期更换密码
### 5.2 数据库事务
- 查询操作使用 `@Transactional(readOnly = true)`
- 明确指定 `rollbackFor = Exception.class`
- 避免大事务
### 5.3 Redis 使用
- 合理设置过期时间,避免内存溢出
- 使用 Redis 分布式锁防止并发问题
- 热点数据使用缓存,减少数据库压力
### 5.4 安全建议
- 修改 CORS 配置,限制允许的域名
- 使用 HTTPS
- 定期审查用户权限
- 启用审计日志

25
pom.xml
View File

@@ -95,6 +95,31 @@
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
</dependency> </dependency>
<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- RocketMQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<!-- AspectJ for AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Caffeine Cache (本地缓存) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies> </dependencies>

View File

@@ -2,6 +2,9 @@ package com.aisi.template;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@SpringBootApplication @SpringBootApplication
public class TemplateApplication { public class TemplateApplication {

View File

@@ -0,0 +1,62 @@
package com.aisi.template.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 审计日志注解
* 标注需要记录审计日志的方法
*
* 使用场景:
* - 用户登录/登出
* - 数据创建/更新/删除
* - 敏感操作访问
*
* 使用示例:
* <pre>
* &#64;AuditLog(
* action = "LOGIN",
* resource = "user",
* description = "用户 {0} 登录成功"
* )
* public void login(String username) { ... }
* </pre>
*
* @author Claude
* @since 2024-04-09
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditLog {
/**
* 操作类型
* 常见值:
* - LOGIN登录
* - LOGOUT登出
* - CREATE创建
* - UPDATE更新
* - DELETE删除
*/
String action();
/**
* 资源类型
* 常见值:
* - user用户
* - role角色
* - permission权限
* - menu菜单
*/
String resource();
/**
* 操作描述模板
* 说明:
* - 支持使用 {0}, {1} 等占位符引用方法参数
* - 示例:"更新用户 {0}",参数:["123"] → "更新用户 123"
*/
String description() default "";
}

View File

@@ -0,0 +1,164 @@
package com.aisi.template.annotation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* 密码验证器
* 为 @StrongPassword 注解提供验证逻辑
*
* 验证规则:
* 1. 最小长度检查
* 2. 大写字母检查
* 3. 小写字母检查
* 4. 数字检查
* 5. 特殊字符检查
*
* @author Claude
* @since 2024-04-09
*/
public class PasswordValidator implements ConstraintValidator<StrongPassword, String> {
/**
* 最小密码长度
*/
private int minLength;
/**
* 是否需要大写字母
*/
private boolean requireUppercase;
/**
* 是否需要小写字母
*/
private boolean requireLowercase;
/**
* 是否需要数字
*/
private boolean requireDigit;
/**
* 是否需要特殊字符
*/
private boolean requireSpecialChar;
/**
* 初始化验证器
* 步骤:
* 1. 从注解中读取配置参数
* 2. 保存到实例变量
*
* @param constraintAnnotation 强密码注解
*/
@Override
public void initialize(StrongPassword constraintAnnotation) {
this.minLength = constraintAnnotation.minLength();
this.requireUppercase = constraintAnnotation.requireUppercase();
this.requireLowercase = constraintAnnotation.requireLowercase();
this.requireDigit = constraintAnnotation.requireDigit();
this.requireSpecialChar = constraintAnnotation.requireSpecialChar();
}
/**
* 验证密码是否符合要求
* 步骤:
* 1. 检查是否为空
* 2. 检查最小长度
* 3. 检查大写字母
* 4. 检查小写字母
* 5. 检查数字
* 6. 检查特殊字符
*
* @param password 密码
* @param context 约束验证上下文
* @return 是否有效
*/
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
// 1. 检查是否为空
if (password == null || password.isEmpty()) {
return false;
}
// 2. 检查最小长度
if (password.length() < minLength) {
return false;
}
// 3. 检查大写字母
if (requireUppercase && !containsUppercase(password)) {
return false;
}
// 4. 检查小写字母
if (requireLowercase && !containsLowercase(password)) {
return false;
}
// 5. 检查数字
if (requireDigit && !containsDigit(password)) {
return false;
}
// 6. 检查特殊字符
if (requireSpecialChar && !containsSpecialChar(password)) {
return false;
}
return true;
}
/**
* 检查是否包含大写字母
* 说明:
* - 将字符串转为小写后与原字符串比较
* - 如果不同,说明包含大写字母
*
* @param password 密码
* @return 是否包含大写字母
*/
private boolean containsUppercase(String password) {
return !password.equals(password.toLowerCase());
}
/**
* 检查是否包含小写字母
* 说明:
* - 将字符串转为大写后与原字符串比较
* - 如果不同,说明包含小写字母
*
* @param password 密码
* @return 是否包含小写字母
*/
private boolean containsLowercase(String password) {
return !password.equals(password.toUpperCase());
}
/**
* 检查是否包含数字
* 说明:
* - 使用正则表达式匹配
* - \\d 表示数字
*
* @param password 密码
* @return 是否包含数字
*/
private boolean containsDigit(String password) {
return password.matches(".*\\d.*");
}
/**
* 检查是否包含特殊字符
* 说明:
* - 特殊字符包括:!@#$%^&*()_+-=[]{}|;:',."\\|,.<>/?
* - 使用正则表达式匹配
*
* @param password 密码
* @return 是否包含特殊字符
*/
private boolean containsSpecialChar(String password) {
return password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*");
}
}

View File

@@ -0,0 +1,88 @@
package com.aisi.template.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 限流注解
* 标注需要进行限流的方法
*
* 使用场景:
* - 登录接口:防止暴力破解
* - API 接口:防止恶意刷接口
* - 抢购活动:防止刷单
*
* 使用示例:
* <pre>
* &#64;RateLimit(
* permits = 5,
* seconds = 60,
* limitType = LimitType.IP,
* keyPrefix = "login:"
* )
* public void login(String username) { ... }
* </pre>
*
* @author Claude
* @since 2024-04-09
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/**
* 时间窗口内允许的最大请求数
* - 默认5 次
*/
int permits() default 5;
/**
* 时间窗口(秒)
* - 默认60 秒
*/
int seconds() default 60;
/**
* 限流类型
* - IP按 IP 地址限流
* - USER按用户ID限流需要登录
* - GLOBAL全局限流所有用户共享计数器
*/
LimitType limitType() default LimitType.IP;
/**
* Redis 键前缀
* - 默认:"rate_limit:"
* - 可自定义,如:"login:", "api:" 等
*/
String keyPrefix() default "rate_limit:";
/**
* 限流类型枚举
*/
enum LimitType {
/**
* 按 IP 地址限流
* - 每个 IP 独立计数
* - 适用于防止恶意刷接口
*/
IP,
/**
* 按用户 ID 限流
* - 每个用户独立计数
* - 需要用户登录
* - 适用于用户操作限流
*/
USER,
/**
* 全局限流
* - 所有用户共享计数器
* - 适用于系统整体限流
*/
GLOBAL
}
}

View File

@@ -0,0 +1,87 @@
package com.aisi.template.annotation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 强密码验证注解
* 验证密码是否符合安全要求
*
* 默认要求:
* - 至少 8 个字符
* - 包含至少一个大写字母
* - 包含至少一个小写字母
* - 包含至少一个数字
* - 包含至少一个特殊字符
*
* 使用示例:
* <pre>
* &#64;StrongPassword(
* minLength = 10,
* requireUppercase = true,
* requireLowercase = true,
* requireDigit = true,
* requireSpecialChar = true
* )
* private String password;
* </pre>
*
* @author Claude
* @since 2024-04-09
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
public @interface StrongPassword {
/**
* 错误消息
* - 默认:"密码必须至少8个字符包含大小写字母、数字和特殊字符"
*/
String message() default "密码必须至少8个字符包含大小写字母、数字和特殊字符";
/**
* 分组
*/
Class<?>[] groups() default {};
/**
* 负载
*/
Class<? extends Payload>[] payload() default {};
/**
* 最小密码长度
* - 默认8
*/
int minLength() default 8;
/**
* 是否需要大写字母
* - 默认true
*/
boolean requireUppercase() default true;
/**
* 是否需要小写字母
* - 默认true
*/
boolean requireLowercase() default true;
/**
* 是否需要数字
* - 默认true
*/
boolean requireDigit() default true;
/**
* 是否需要特殊字符
* - 默认true
* - 特殊字符包括:!@#$%^&*()_+-=[]{}|;:,.<>?
*/
boolean requireSpecialChar() default true;
}

View File

@@ -0,0 +1,217 @@
package com.aisi.template.aspect;
import com.aisi.template.annotation.AuditLog;
import com.aisi.template.service.AuditLogService;
import com.aisi.template.utils.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Objects;
/**
* 审计日志切面
* 自动记录带有 @AuditLog 注解的方法调用
*
* 主要功能:
* 1. 自动记录操作日志
* 2. 记录操作用户、时间、IP等信息
* 3. 记录操作结果(成功/失败)
*
* 使用场景:
* - 用户登录/登出
* - 数据创建/更新/删除
* - 敏感操作访问
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuditLogAspect {
/**
* 审计日志服务
*/
private final AuditLogService auditLogService;
/**
* 环绕通知:记录审计日志
* 步骤:
* 1. 获取请求信息方法、URI、IP
* 2. 获取当前用户信息
* 3. 执行目标方法
* 4. 捕获执行结果或异常
* 5. 记录审计日志
*
* @param joinPoint 连接点(被拦截的方法)
* @param auditLog 审计日志注解
* @return 方法执行结果
* @throws Throwable 方法执行异常
*/
@Around("@annotation(auditLog)")
public Object logAudit(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable {
// 1. 获取请求信息
HttpServletRequest request = getRequest();
String action = auditLog.action();
String resource = auditLog.resource();
// 2. 获取当前用户信息
Long userId = null;
String username = null;
try {
userId = SecurityUtils.getUserId();
username = SecurityUtils.getUsername();
} catch (Exception e) {
// 用户未登录,跳过用户信息获取
}
// 3. 提取请求信息
String requestMethod = request != null ? request.getMethod() : null;
String requestUri = request != null ? request.getRequestURI() : null;
String ipAddress = getClientIp(request);
String userAgent = request != null ? request.getHeader("User-Agent") : null;
// 4. 执行目标方法并记录结果
Object result = null;
String errorMessage = null;
int status = 1; // 1=成功0=失败
try {
// 4.1 执行目标方法
result = joinPoint.proceed();
return result;
} catch (Exception e) {
// 4.2 捕获异常
status = 0; // 标记为失败
errorMessage = e.getMessage();
throw e;
} finally {
// 5. 记录审计日志
try {
// 5.1 构建操作描述
String description = buildDescription(auditLog.description(), joinPoint.getArgs());
// 5.2 提取资源ID从方法参数中
String resourceId = extractResourceId(joinPoint.getArgs());
// 5.3 保存审计日志
auditLogService.log(userId, username, action, resource, resourceId,
description, requestMethod, requestUri, ipAddress,
userAgent, status, errorMessage);
} catch (Exception e) {
log.error("保存审计日志失败", e);
}
}
}
/**
* 获取当前 HTTP 请求
*
* @return HttpServletRequest 对象
*/
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes != null ? attributes.getRequest() : null;
}
/**
* 获取客户端真实 IP 地址
* 步骤:
* 1. 先从 X-Forwarded-For 头获取(代理服务器设置)
* 2. 再从 X-Real-IP 头获取Nginx 等设置)
* 3. 最后从 remoteAddr 获取
*
* @param request HTTP 请求
* @return 客户端 IP 地址
*/
private String getClientIp(HttpServletRequest request) {
if (request == null) {
return null;
}
// 1. 从 X-Forwarded-For 获取(可能有多个 IP取第一个
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
// 2. 从 X-Real-IP 获取
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
// 3. 从 remoteAddr 获取
ip = request.getRemoteAddr();
}
// 4. 处理多个 IP 的情况X-Forwarded-For 格式客户端IP, 代理1IP, 代理2IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
/**
* 构建操作描述
* 说明:
* - 支持使用 {0}, {1} 等占位符引用方法参数
* - 示例description = "更新用户 {0}"args = ["123"]
* 结果 = "更新用户 123"
*
* @param template 描述模板
* @param args 方法参数
* @return 填充后的描述
*/
private String buildDescription(String template, Object[] args) {
if (template == null || template.isEmpty()) {
return "";
}
String description = template;
for (int i = 0; i < args.length; i++) {
String placeholder = "{" + i + "}";
if (description.contains(placeholder)) {
String argValue = args[i] != null ? args[i].toString() : "null";
description = description.replace(placeholder, argValue);
}
}
return description;
}
/**
* 从方法参数中提取资源ID
* 说明:
* - 查找第一个 Long、Integer 或数字字符串类型的参数
* - 作为资源ID记录
*
* @param args 方法参数
* @return 资源ID
*/
private String extractResourceId(Object[] args) {
// 遍历方法参数,查找 ID
for (Object arg : args) {
if (arg instanceof Long) {
return arg.toString();
}
if (arg instanceof Integer) {
return arg.toString();
}
if (arg instanceof String) {
String str = (String) arg;
// 检查是否为数字字符串(可能为 ID
if (str.matches("\\d+")) {
return str;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,174 @@
package com.aisi.template.aspect;
import com.aisi.template.annotation.RateLimit;
import com.aisi.template.exception.RateLimitExceededException;
import com.aisi.template.utils.RedisUtils;
import com.aisi.template.utils.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.concurrent.TimeUnit;
/**
* 限流切面
* 基于 Redis 实现的分布式限流功能
*
* 实现原理:
* 1. 使用 Redis 计数器记录请求次数
* 2. 在指定时间窗口内,请求次数超过限制则拒绝
* 3. 支持按 IP、用户ID 或全局限流
*
* 使用场景:
* - 登录接口:防止暴力破解
* - API 接口:防止恶意刷接口
* - 抢购活动:防止刷单
*
* 限流算法:固定窗口计数器
* 优点:实现简单,性能好
* 缺点:窗口边界可能出现流量突增
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
/**
* Redis 工具类
*/
private final RedisUtils redisUtils;
/**
* 限流检查方法
* 步骤:
* 1. 根据限流类型构建 Redis 键
* 2. 获取当前计数
* 3. 检查是否超过限制
* 4. 如果未超过,增加计数
*
* @param joinPoint 连接点(被拦截的方法)
* @param rateLimit 限流注解
* @throws RateLimitExceededException 当超过限流阈值时抛出异常
*/
@Before("@annotation(rateLimit)")
public void rateLimit(JoinPoint joinPoint, RateLimit rateLimit) {
// 1. 构建 Redis 键
String key = buildKey(rateLimit);
// 2. 原子递增计数
Long currentCount = redisUtils.increment(key);
if (currentCount == null) {
currentCount = 1L;
}
// 3. 首次请求设置固定窗口过期时间
if (currentCount == 1L) {
redisUtils.expire(key, rateLimit.seconds(), TimeUnit.SECONDS);
}
// 4. 检查是否超过限制
if (currentCount > rateLimit.permits()) {
log.warn("触发限流 - key: {}, count: {}, limit: {}",
key, currentCount, rateLimit.permits());
throw new RateLimitExceededException();
}
log.debug("限流检查通过 - key: {}, count: {}/{}",
key, currentCount, rateLimit.permits());
}
/**
* 构建限流 Redis 键
* 步骤:
* 1. 获取前缀rate_limit:
* 2. 根据限流类型添加标识
* - IP: rate_limit:ip:127.0.0.1
* - USER: rate_limit:user:123
* - GLOBAL: rate_limit:global
*
* @param rateLimit 限流注解
* @return Redis 键
*/
private String buildKey(RateLimit rateLimit) {
StringBuilder keyBuilder = new StringBuilder(rateLimit.keyPrefix());
// 1. 根据限流类型构建键
switch (rateLimit.limitType()) {
case IP:
// 按 IP 限流
String ip = getClientIp();
keyBuilder.append("ip:").append(ip);
break;
case USER:
// 按用户 ID 限流
Long userId = SecurityUtils.getUserId();
if (userId != null) {
keyBuilder.append("user:").append(userId);
} else {
// 用户未登录,降级为 IP 限流
keyBuilder.append("ip:").append(getClientIp());
}
break;
case GLOBAL:
// 全局限流(所有用户共享计数器)
keyBuilder.append("global");
break;
}
return keyBuilder.toString();
}
/**
* 获取客户端真实 IP 地址
* 步骤:
* 1. 先从 X-Forwarded-For 头获取(可能有多个 IP取第一个
* 2. 再从 X-Real-IP 头获取
* 3. 最后从 remoteAddr 获取
*
* 说明:
* - X-Forwarded-For: 记录请求经过的所有代理 IP
* - 格式客户端IP, 代理1IP, 代理2IP, ...
* - 取第一个 IP 即为真实客户端 IP
*
* @return 客户端 IP 地址
*/
private String getClientIp() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return "unknown";
}
HttpServletRequest request = attributes.getRequest();
String ip = request.getHeader("X-Forwarded-For");
// 1. 从 X-Forwarded-For 获取
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
// 2. 从 X-Real-IP 获取
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 3. 处理多个 IP 的情况X-Forwarded-For 可能包含多个 IP
if (ip != null && ip.contains(",")) {
// 取第一个 IP真实客户端 IP
ip = ip.split(",")[0].trim();
}
return ip != null ? ip : "unknown";
}
}

View File

@@ -10,25 +10,47 @@ import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
/**
* Jackson 配置类
* 配置 JSON 序列化相关设置
*
* 主要功能:
* 1. 日期时间格式化:统一日期时间格式
* 2. 禁用时间戳:使用可读的日期字符串
*
* @author Claude
* @since 2024-04-09
*/
@Configuration @Configuration
public class JacksonConfig { public class JacksonConfig {
/**
* 配置 ObjectMapper
* 步骤:
* 1. 创建 ObjectMapper 对象
* 2. 注册 JavaTimeModule支持 Java 8 日期时间类型)
* 3. 配置 LocalDateTime 序列化格式
* 4. 禁用时间戳格式
*
* @return ObjectMapper 对象
*/
@Bean @Bean
public ObjectMapper objectMapper() { public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
// 配置日期时间格式 // 1. 配置日期时间模块
JavaTimeModule javaTimeModule = new JavaTimeModule(); JavaTimeModule javaTimeModule = new JavaTimeModule();
// 定义日期时间格式 // 2. 定义日期时间格式yyyy-MM-dd HH:mm:ss
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
// 3. 注册日期时间模块
mapper.registerModule(javaTimeModule); mapper.registerModule(javaTimeModule);
// 禁用时间戳格式 // 4. 禁用时间戳格式(使用可读的日期字符串)
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper; return mapper;
} }
} }

View File

@@ -5,6 +5,19 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/** /**
* JPA 配置类 * JPA 配置类
* 配置 JPA 审计功能
*
* 主要功能:
* 1. 自动填充创建时间:@CreatedDate
* 2. 自动填充更新时间:@LastModifiedDate
*
* 使用说明:
* - 在实体类上添加 @EntityListeners(AuditingEntityListener.class)
* - 在字段上使用 @CreatedDate 和 @LastModifiedDate 注解
* - JPA 会自动维护这些字段的值
*
* @author Claude
* @since 2024-04-09
*/ */
@Configuration @Configuration
@EnableJpaAuditing @EnableJpaAuditing

View File

@@ -2,14 +2,49 @@ package com.aisi.template.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* OpenAPI (Swagger) 配置类
* 配置 API 文档的安全认证
*
* 主要功能:
* 1. 配置 JWT Bearer 认证
* 2. 在 Swagger UI 中支持 JWT Token 测试
*
* 使用说明:
* 1. 访问 http://localhost:8080/swagger-ui.html
* 2. 点击右上角 "Authorize" 按钮
* 3. 输入 JWT Token格式Bearer {token}
* 4. 点击 "Authorize" 完成认证
*
* @author Claude
* @since 2024-04-09
*/
@Configuration @Configuration
@SecurityScheme( @SecurityScheme(
// 安全方案名称
name = "Bearer Authentication", name = "Bearer Authentication",
// 安全方案类型HTTP
type = SecuritySchemeType.HTTP, type = SecuritySchemeType.HTTP,
// Bearer 格式JWT
bearerFormat = "JWT", bearerFormat = "JWT",
// 认证方案bearer
scheme = "bearer" scheme = "bearer"
) )
public class OpenApiConfig { public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI(@Value("${app.openapi.server-url:http://localhost:8080}") String serverUrl) {
return new OpenAPI()
.servers(List.of(new Server()
.url(serverUrl)
.description("Default server")));
}
} }

View File

@@ -13,14 +13,45 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置类
* 配置 Redis 序列化方式
*
* 主要功能:
* 1. RedisTemplate用于操作对象类型数据
* 2. StringRedisTemplate用于操作字符串类型数据
*
* 序列化配置:
* - Key使用 String 序列化
* - Value使用 JSON 序列化(支持类型信息)
* - HashKey使用 String 序列化
* - HashValue使用 JSON 序列化
*
* @author Claude
* @since 2024-04-09
*/
@Configuration @Configuration
public class RedisConfig { public class RedisConfig {
/**
* 配置 RedisTemplate用于操作对象
* 步骤:
* 1. 创建 RedisTemplate 对象
* 2. 配置 ObjectMapper支持类型信息
* 3. 配置 Key 序列化方式
* 4. 配置 Value 序列化方式
*
* @param factory Redis 连接工厂
* @param objectMapper Jackson ObjectMapper
* @return RedisTemplate 对象
*/
@Bean @Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) { public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) {
// 1. 创建 RedisTemplate 对象
RedisTemplate<String, Object> template = new RedisTemplate<>(); RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory); template.setConnectionFactory(factory);
// 2. 配置 ObjectMapper支持类型信息用于反序列化时确定类型
ObjectMapper redisMapper = objectMapper.copy(); ObjectMapper redisMapper = objectMapper.copy();
redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
redisMapper.activateDefaultTyping( redisMapper.activateDefaultTyping(
@@ -29,18 +60,35 @@ public class RedisConfig {
JsonTypeInfo.As.PROPERTY JsonTypeInfo.As.PROPERTY
); );
// 3. 配置序列化器
StringRedisSerializer stringSerializer = new StringRedisSerializer(); StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper);
// 3.1 Key 使用 String 序列化
template.setKeySerializer(stringSerializer); template.setKeySerializer(stringSerializer);
// 3.2 HashKey 使用 String 序列化
template.setHashKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer);
// 3.3 Value 使用 JSON 序列化
template.setValueSerializer(jsonSerializer); template.setValueSerializer(jsonSerializer);
// 3.4 HashValue 使用 JSON 序列化
template.setHashValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer);
// 4. 执行初始化
template.afterPropertiesSet(); template.afterPropertiesSet();
return template; return template;
} }
/**
* 配置 StringRedisTemplate用于操作字符串
* 说明:
* - StringRedisTemplate 专门用于处理字符串类型数据
* - Key 和 Value 都使用 String 序列化
* - 适用于简单的键值对操作
*
* @param factory Redis 连接工厂
* @return StringRedisTemplate 对象
*/
@Bean @Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory); return new StringRedisTemplate(factory);

View File

@@ -2,6 +2,7 @@ package com.aisi.template.config;
import com.aisi.template.filter.JwtAuthenticationFilter; import com.aisi.template.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@@ -19,46 +20,128 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
/**
* Spring Security 配置类
* 配置安全认证和授权相关设置
*
* 主要功能:
* 1. JWT 认证:配置 JWT 过滤器
* 2. CORS 配置:跨域资源共享配置
* 3. 会话管理无状态会话JWT
* 4. 授权规则:配置哪些请求需要认证
* 5. 密码编码:配置 BCrypt 密码编码器
*
* @author Claude
* @since 2024-04-09
*/
@Configuration @Configuration
@EnableMethodSecurity @EnableMethodSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
/**
* JWT 认证过滤器
* 用于拦截请求并验证 JWT Token
*/
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* 允许的跨域来源
* 从配置文件读取默认http://localhost:5173, http://localhost:3000
*/
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private List<String> allowedOrigins;
/**
* 配置安全过滤器链
* 步骤:
* 1. 禁用 CSRF使用 JWT 不需要 CSRF 保护)
* 2. 配置 CORS
* 3. 设置会话管理为无状态JWT
* 4. 配置授权规则
* 5. 添加 JWT 认证过滤器
*
* @param http HttpSecurity 对象
* @return 安全过滤器链
*/
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
// 1. 禁用 CSRFJWT 不需要)
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
// 2. 配置 CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 3. 设置会话管理为无状态
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 4. 配置授权规则
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll() // 4.1 Swagger 文档:允许匿名访问
.requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
// 4.2 用户注册、登录和刷新令牌:允许匿名访问
.requestMatchers("/api/v1/user/register", "/api/v1/user/login", "/api/v1/user/refresh").permitAll()
// 4.3 密码重置:允许匿名访问
.requestMatchers("/api/v1/user/password-reset/**").permitAll() .requestMatchers("/api/v1/user/password-reset/**").permitAll()
// 4.4 获取当前用户信息:需要认证
.requestMatchers("/api/v1/user/info").authenticated() .requestMatchers("/api/v1/user/info").authenticated()
// 4.5 其他所有请求:需要认证
.anyRequest().authenticated() .anyRequest().authenticated()
) )
// 5. 添加 JWT 认证过滤器(在用户名密码过滤器之前)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
/**
* 配置 CORS跨域资源共享
* 步骤:
* 1. 创建 CORS 配置
* 2. 设置允许的来源
* 3. 设置允许的 HTTP 方法
* 4. 设置允许的请求头
* 5. 设置暴露的响应头
* 6. 允许携带凭证Cookie
* 7. 设置预检请求缓存时间
*
* @return CORS 配置源
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
// 1. 创建 CORS 配置
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("*")); // 2. 设置允许的来源
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedOrigins(allowedOrigins);
// 3. 设置允许的 HTTP 方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
// 4. 设置允许的请求头
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept", "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"));
// 5. 设置暴露的响应头
configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
// 6. 允许携带凭证Cookie
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
configuration.setExposedHeaders(List.of("*"));
// 7. 设置预检请求缓存时间1 小时)
configuration.setMaxAge(3600L); configuration.setMaxAge(3600L);
// 8. 注册 CORS 配置
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* 配置密码编码器
* 说明:
* - 使用 BCrypt 算法对密码进行加密
* - 每次加密都会生成不同的哈希值(自带盐值)
* - 强度因子默认为 10
*
* @return BCrypt 密码编码器
*/
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();

View File

@@ -6,17 +6,41 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置类
* 配置拦截器和其他 Web 相关设置
*
* 主要功能:
* 1. 拦截器配置:注册日志拦截器
*
* @author Claude
* @since 2024-04-09
*/
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
/**
* 日志拦截器
* 用于记录请求和响应信息
*/
private final LoggingInterceptor loggingInterceptor; private final LoggingInterceptor loggingInterceptor;
/**
* 配置拦截器
* 步骤:
* 1. 注册日志拦截器
* 2. 拦截所有请求(/**
* 3. 可配置排除路径
*
* @param registry 拦截器注册表
*/
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loggingInterceptor) registry.addInterceptor(loggingInterceptor)
// 1. 拦截所有请求
.addPathPatterns("/**") .addPathPatterns("/**")
// 2. 排除路径(可在此添加需要排除的路径)
.excludePathPatterns(); .excludePathPatterns();
} }
} }

View File

@@ -0,0 +1,134 @@
package com.aisi.template.constants;
/**
* 应用级常量类
* 定义系统中的通用常量
*
* 主要分类:
* 1. 分页配置:默认页大小、最大页大小
* 2. 日期时间:格式、时区
* 3. 用户状态:启用、禁用
* 4. 菜单类型:目录、菜单、按钮
* 5. 密码重置:过期时间、冷却时间
*
* @author Claude
* @since 2024-04-09
*/
public class AppConstants {
/**
* 默认分页大小
* - 每页返回的记录数
*/
public static final int DEFAULT_PAGE_SIZE = 10;
/**
* 最大分页大小
* - 防止一次查询过多数据
*/
public static final int MAX_PAGE_SIZE = 100;
/**
* 日期时间格式JSON 序列化)
* - 格式yyyy-MM-dd HH:mm:ss
* - 示例2024-04-09 14:30:00
*/
public static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
/**
* 中国时区
* - 东八区UTC+8
*/
public static final String TIME_ZONE_CHINA = "Asia/Shanghai";
/**
* 默认语言环境
* - 简体中文
*/
public static final String DEFAULT_LOCALE = "zh_CN";
/**
* 审计日志保留天数
* - 超过此天数的日志将被删除
*/
public static final int AUDIT_LOG_RETENTION_DAYS = 90;
/**
* 密码重置验证码过期时间(分钟)
* - 验证码的有效期
*/
public static final int PASSWORD_RESET_CODE_EXPIRE_MINUTES = 10;
/**
* 密码重置请求冷却时间(秒)
* - 同一邮箱两次请求的最小间隔
*/
public static final int PASSWORD_RESET_REQUEST_COOLDOWN_SECONDS = 60;
/**
* 密码重置最大尝试次数
* - 验证码验证失败超过此次数后失效
*/
public static final int PASSWORD_RESET_MAX_ATTEMPTS = 5;
/**
* 用户状态常量
*/
public static final class UserStatus {
/**
* 用户禁用
*/
public static final int DISABLED = 0;
/**
* 用户启用
*/
public static final int ENABLED = 1;
}
/**
* 菜单类型常量
*/
public static final class MenuType {
/**
* 目录
* - 用于分组,不对应具体页面
*/
public static final int DIRECTORY = 1;
/**
* 菜单
* - 对应具体页面
*/
public static final int PAGE = 2;
/**
* 按钮
* - 页面内的操作按钮
*/
public static final int BUTTON = 3;
}
/**
* 菜单可见性常量
*/
public static final class MenuVisibility {
/**
* 隐藏
*/
public static final int HIDDEN = 0;
/**
* 可见
*/
public static final int VISIBLE = 1;
}
/**
* 私有构造函数
* - 防止实例化
*/
private AppConstants() {
// 防止实例化
}
}

View File

@@ -0,0 +1,103 @@
package com.aisi.template.constants;
/**
* 安全相关常量类
* 定义系统中的安全相关常量
*
* 主要分类:
* 1. Token 相关Token 前缀、过期时间
* 2. Redis 键前缀:黑名单、刷新令牌、限流、登录尝试
* 3. 安全配置:最大失败次数、锁定时长
*
* @author Claude
* @since 2024-04-09
*/
public class SecurityConstants {
/**
* JWT Token 请求头名称
* - HTTP 请求头Authorization
* - 示例Authorization: Bearer {token}
*/
public static final String TOKEN_HEADER = "Authorization";
/**
* JWT Token 前缀
* - Bearer 认证方案
* - 示例Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
*/
public static final String TOKEN_PREFIX = "Bearer ";
/**
* JWT Token 类型
* - 用于文档标识
* - 值Bearer
*/
public static final String TOKEN_TYPE = "Bearer";
/**
* Token 黑名单 Redis 键前缀
* - 格式token:blacklist:{jti}
* - jtiJWT IDJWT 唯一标识)
* - 示例token:blacklist:abc123-def456
*/
public static final String REDIS_KEY_BLACKLIST_PREFIX = "token:blacklist:";
/**
* Refresh Token Redis 键前缀
* - 格式refresh_token:{tokenHash}
* - tokenHashToken 的 SHA-256 哈希值
* - 示例refresh_token:a1b2c3...
*/
public static final String REDIS_KEY_REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 限流 Redis 键前缀
* - 格式rate_limit:{type}:{identifier}
* - typeip按 IP、user按用户、global全局
* - identifierIP 地址、用户ID 等
* - 示例rate_limit:ip:192.168.1.1
*/
public static final String REDIS_KEY_RATE_LIMIT_PREFIX = "rate_limit:";
/**
* 登录尝试 Redis 键前缀
* - 格式login_attempts:{username}
* - 示例login_attempts:user01
*/
public static final String REDIS_KEY_LOGIN_ATTEMPTS_PREFIX = "login_attempts:";
/**
* 默认 Access Token 过期时间(秒)
* - 1 小时 = 3600 秒
*/
public static final long DEFAULT_ACCESS_TOKEN_EXPIRATION = 3600;
/**
* 默认 Refresh Token 过期时间(秒)
* - 7 天 = 604800 秒
*/
public static final long DEFAULT_REFRESH_TOKEN_EXPIRATION = 604800;
/**
* 最大登录失败次数
* - 超过此次数后锁定账户
* - 默认5 次
*/
public static final int MAX_LOGIN_ATTEMPTS = 5;
/**
* 默认账户锁定时长(分钟)
* - 连续失败达到阈值后的锁定时长
* - 默认30 分钟
*/
public static final int DEFAULT_LOCK_DURATION_MINUTES = 30;
/**
* 私有构造函数
* - 防止实例化
*/
private SecurityConstants() {
// 防止实例化
}
}

View File

@@ -0,0 +1,213 @@
package com.aisi.template.controller;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.CustomUserDetails;
import com.aisi.template.domain.dto.MenuDto;
import com.aisi.template.domain.vo.MenuVo;
import com.aisi.template.service.SysMenuService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 菜单管理控制器
* 提供菜单的增删改查和树形结构接口
*
* 主要功能:
* 1. 菜单基本操作:创建、更新、删除、查询
* 2. 菜单树结构:获取完整的菜单树
* 3. 用户菜单:根据用户角色获取可见菜单
* 4. 层级查询:根据父节点查询子菜单
*
* 权限说明:
* - 所有接口都需要登录
* - 管理接口需要相应权限(如 menu:create、menu:update
* - 用户菜单接口登录后即可访问
*
* 菜单类型:
* - 1目录一级菜单
* - 2页面二级菜单
* - 3按钮页面内的操作按钮
*
* @author Claude
* @since 2024-04-09
*/
@Tag(name = "菜单管理", description = "菜单的增删改查和树形结构接口")
@RestController
@RequestMapping("/api/v1/menus")
@RequiredArgsConstructor
@SecurityRequirement(name = "bearerAuth")
public class MenuController {
/**
* 菜单服务
* 处理菜单相关的业务逻辑
*/
private final SysMenuService menuService;
/**
* 创建菜单
* 步骤:
* 1. 校验父菜单是否存在
* 2. 校验菜单名称是否重复
* 3. 创建菜单并保存
*
* 权限:需要 menu:create 权限
*
* @param menuDto 菜单信息(名称、类型、父节点、路径等)
* @return 创建后的菜单信息
*/
@PostMapping
@Operation(summary = "创建菜单", description = "创建新的菜单项")
@PreAuthorize("hasAuthority('menu:create')")
public RestBean<MenuVo> create(@Valid @RequestBody MenuDto menuDto) {
MenuVo menu = menuService.create(menuDto);
return RestBean.success(menu);
}
/**
* 更新菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 如果修改父节点,检查新父节点是否存在
* 3. 更新菜单信息
*
* 权限:需要 menu:update 权限
*
* @param id 菜单ID
* @param menuDto 菜单信息
* @return 更新后的菜单信息
*/
@PutMapping("/{id}")
@Operation(summary = "更新菜单", description = "更新菜单的名称、路径等信息")
@PreAuthorize("hasAuthority('menu:update')")
public RestBean<MenuVo> update(@PathVariable Long id, @Valid @RequestBody MenuDto menuDto) {
MenuVo menu = menuService.update(id, menuDto);
return RestBean.success(menu);
}
/**
* 删除菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 检查是否有子菜单
* 3. 删除菜单(子菜单会一并删除)
*
* 权限:需要 menu:delete 权限
*
* 注意:
* - 删除目录会一并删除其下所有子菜单
* - 建议先删除子菜单再删除父菜单
*
* @param id 菜单ID
* @return 成功响应
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除菜单", description = "删除指定的菜单及其子菜单")
@PreAuthorize("hasAuthority('menu:delete')")
public RestBean<Void> delete(@PathVariable Long id) {
menuService.delete(id);
return RestBean.success();
}
/**
* 根据ID获取菜单
* 步骤:
* 1. 查询菜单基本信息
* 2. 返回详细信息
*
* 权限:需要 menu:read 权限
*
* @param id 菜单ID
* @return 菜单详细信息
*/
@GetMapping("/{id}")
@Operation(summary = "获取菜单详情", description = "根据ID获取菜单详细信息")
@PreAuthorize("hasAuthority('menu:read')")
public RestBean<MenuVo> getById(@PathVariable Long id) {
MenuVo menu = menuService.getById(id);
return RestBean.success(menu);
}
/**
* 获取完整菜单树
* 步骤:
* 1. 查询所有菜单
* 2. 构建树形结构(父子关系)
* 3. 返回完整的菜单树
*
* 权限:需要 menu:list 权限
*
* 注意:
* - 返回的是完整树形结构,包含目录、页面、按钮
* - 按排序字段排序
*
* @return 菜单树列表
*/
@GetMapping("/tree")
@Operation(summary = "获取菜单树", description = "获取完整的菜单树形结构")
@PreAuthorize("hasAuthority('menu:list')")
public RestBean<List<MenuVo>> getMenuTree() {
List<MenuVo> menus = menuService.getMenuTree();
return RestBean.success(menus);
}
/**
* 获取当前用户的菜单
* 步骤:
* 1. 从认证信息中获取用户ID
* 2. 查询用户的角色
* 3. 查询角色关联的菜单
* 4. 构建树形结构(只包含目录和页面)
* 5. 过滤不可见和禁用的菜单
*
* 注意:
* - 只返回目录和页面,不包含按钮
* - 根据用户角色动态生成
* - 用于前端动态渲染导航菜单
*
* @param authentication Spring Security 认证信息
* @return 用户可见的菜单树
*/
@GetMapping("/user")
@Operation(summary = "获取当前用户菜单", description = "获取当前用户可见的菜单树")
public RestBean<List<MenuVo>> getUserMenus(Authentication authentication) {
// 1. 从认证信息获取用户ID
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long userId = userDetails.getId();
// 2. 查询用户菜单(已过滤)
List<MenuVo> menus = menuService.getUserMenus(userId);
return RestBean.success(menus);
}
/**
* 根据父节点ID获取子菜单
* 步骤:
* 1. 查询指定父节点下的所有直接子菜单
* 2. 按排序字段排序
* 3. 返回列表
*
* 权限:需要 menu:list 权限
*
* 使用场景:
* - 动态加载子菜单
* - 前端树形控件展开时加载
*
* @param parentId 父节点ID0 表示根节点)
* @return 子菜单列表
*/
@GetMapping("/parent/{parentId}")
@Operation(summary = "获取子菜单", description = "根据父节点ID获取直接子菜单")
@PreAuthorize("hasAuthority('menu:list')")
public RestBean<List<MenuVo>> getByParentId(@PathVariable Long parentId) {
List<MenuVo> menus = menuService.getByParentId(parentId);
return RestBean.success(menus);
}
}

View File

@@ -0,0 +1,132 @@
package com.aisi.template.controller;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.vo.PermissionVo;
import com.aisi.template.service.SysPermissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 权限管理控制器
* 提供权限的查询接口
*
* 主要功能:
* 1. 查询所有权限
* 2. 根据ID获取权限详情
* 3. 按资源类型查询权限
* 4. 按操作类型查询权限
*
* 权限说明:
* - 所有接口都需要登录
* - 需要相应权限(如 permission:list
*
* 权限编码规范:
* - 格式:{资源}:{操作}
* - 示例user:create, role:update, menu:delete
*
* @author Claude
* @since 2024-04-09
*/
@Tag(name = "权限管理", description = "系统权限查询接口")
@RestController
@RequestMapping("/api/v1/permissions")
@RequiredArgsConstructor
@SecurityRequirement(name = "bearerAuth")
public class PermissionController {
/**
* 权限服务
* 处理权限相关的业务逻辑
*/
private final SysPermissionService permissionService;
/**
* 获取所有权限
* 步骤:
* 1. 查询所有状态为启用的权限
* 2. 按资源和操作排序
* 3. 返回列表
*
* 权限:需要 permission:list 权限
*
* @return 所有权限列表
*/
@GetMapping
@Operation(summary = "获取所有权限", description = "获取系统中所有可用的权限")
@PreAuthorize("hasAuthority('permission:list')")
public RestBean<List<PermissionVo>> getAllPermissions() {
List<PermissionVo> permissions = permissionService.getAllPermissions();
return RestBean.success(permissions);
}
/**
* 根据ID获取权限
* 步骤:
* 1. 查询权限基本信息
* 2. 返回详细信息
*
* 权限:需要 permission:list 权限
*
* @param id 权限ID
* @return 权限详细信息
*/
@GetMapping("/{id}")
@Operation(summary = "获取权限详情", description = "根据ID获取权限详细信息")
@PreAuthorize("hasAuthority('permission:list')")
public RestBean<PermissionVo> getById(@PathVariable Long id) {
PermissionVo permission = permissionService.getById(id);
return RestBean.success(permission);
}
/**
* 按资源类型获取权限
* 步骤:
* 1. 根据资源类型(如 user、role查询
* 2. 返回该资源的所有操作权限
*
* 权限:需要 permission:list 权限
*
* 使用场景:
* - 分配权限时,按资源类型展示
* - 权限管理页面按资源分组显示
*
* @param resource 资源类型(如 user、role、permission、menu
* @return 该资源的所有权限
*/
@GetMapping("/resource/{resource}")
@Operation(summary = "按资源获取权限", description = "获取指定资源的所有权限")
@PreAuthorize("hasAuthority('permission:list')")
public RestBean<List<PermissionVo>> getByResource(@PathVariable String resource) {
List<PermissionVo> permissions = permissionService.getByResource(resource);
return RestBean.success(permissions);
}
/**
* 按操作类型获取权限
* 步骤:
* 1. 根据操作类型(如 create、update、delete查询
* 2. 返回该操作的所有资源权限
*
* 权限:需要 permission:list 权限
*
* 使用场景:
* - 查询所有创建权限
* - 查询所有删除权限
*
* @param action 操作类型create、read、update、delete、list
* @return 该操作的所有权限
*/
@GetMapping("/action/{action}")
@Operation(summary = "按操作获取权限", description = "获取指定操作的所有权限")
@PreAuthorize("hasAuthority('permission:list')")
public RestBean<List<PermissionVo>> getByAction(@PathVariable String action) {
List<PermissionVo> permissions = permissionService.getByAction(action);
return RestBean.success(permissions);
}
}

View File

@@ -0,0 +1,229 @@
package com.aisi.template.controller;
import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.dto.RoleDto;
import com.aisi.template.domain.dto.RoleQueryDto;
import com.aisi.template.domain.dto.PageResult;
import com.aisi.template.domain.vo.RoleVo;
import com.aisi.template.service.SysRoleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 角色管理控制器
* 提供角色的增删改查和权限分配接口
*
* 主要功能:
* 1. 角色基本操作:创建、更新、删除、查询
* 2. 权限管理:为角色分配权限、获取角色权限
* 3. 分页查询:支持条件查询和分页
*
* 权限说明:
* - 所有接口都需要登录
* - 需要相应的角色权限(如 role:create、role:update
*
* 角色编码规范:
* - ROLE_SUPER_ADMIN超级管理员
* - ROLE_ADMIN管理员
* - ROLE_USER普通用户
*
* @author Claude
* @since 2024-04-09
*/
@Tag(name = "角色管理", description = "角色的增删改查和权限分配接口")
@RestController
@RequestMapping("/api/v1/roles")
@RequiredArgsConstructor
@SecurityRequirement(name = "bearerAuth")
public class RoleController {
/**
* 角色服务
* 处理角色相关的业务逻辑
*/
private final SysRoleService roleService;
/**
* 创建角色
* 步骤:
* 1. 校验角色编码是否已存在
* 2. 校验角色名称是否合法
* 3. 创建角色并保存
*
* 权限:需要 role:create 权限
*
* @param roleDto 角色信息(编码、名称、描述等)
* @return 创建后的角色信息
*/
@PostMapping
@Operation(summary = "创建角色", description = "创建新的角色,需要提供角色编码和名称")
@PreAuthorize("hasAuthority('role:create')")
public RestBean<RoleVo> create(@Valid @RequestBody RoleDto roleDto) {
RoleVo role = roleService.create(roleDto);
return RestBean.success(role);
}
/**
* 更新角色
* 步骤:
* 1. 检查角色是否存在
* 2. 如果修改角色编码,检查新编码是否冲突
* 3. 更新角色信息
*
* 权限:需要 role:update 权限
*
* @param id 角色ID
* @param roleDto 角色信息
* @return 更新后的角色信息
*/
@PutMapping("/{id}")
@Operation(summary = "更新角色", description = "更新角色的名称、描述等信息")
@PreAuthorize("hasAuthority('role:update')")
public RestBean<RoleVo> update(@PathVariable Long id, @Valid @RequestBody RoleDto roleDto) {
RoleVo role = roleService.update(id, roleDto);
return RestBean.success(role);
}
/**
* 删除角色
* 步骤:
* 1. 检查角色是否存在
* 2. 检查角色是否已分配给用户
* 3. 删除角色(关联关系会自动级联删除)
*
* 权限:需要 role:delete 权限
*
* 注意:
* - 系统内置角色(如 ROLE_ADMIN不允许删除
* - 已分配给用户的角色需要先解除关联
*
* @param id 角色ID
* @return 成功响应
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除角色", description = "删除指定的角色")
@PreAuthorize("hasAuthority('role:delete')")
public RestBean<Void> delete(@PathVariable Long id) {
roleService.delete(id);
return RestBean.success();
}
/**
* 根据ID获取角色
* 步骤:
* 1. 查询角色基本信息
* 2. 加载关联的权限列表
* 3. 返回详细信息
*
* 权限:需要 role:read 权限
*
* @param id 角色ID
* @return 角色详细信息(包含权限列表)
*/
@GetMapping("/{id}")
@Operation(summary = "获取角色详情", description = "根据ID获取角色及其权限信息")
@PreAuthorize("hasAuthority('role:read')")
public RestBean<RoleVo> getById(@PathVariable Long id) {
RoleVo role = roleService.getById(id);
return RestBean.success(role);
}
/**
* 获取所有角色
* 步骤:
* 1. 查询所有角色
* 2. 加载每个角色的权限
* 3. 返回列表
*
* 权限:需要 role:list 权限
*
* @return 所有角色列表(包含权限)
*/
@GetMapping
@Operation(summary = "获取所有角色", description = "获取系统中所有角色及其权限")
@PreAuthorize("hasAuthority('role:list')")
public RestBean<List<RoleVo>> getAllRoles() {
List<RoleVo> roles = roleService.getAllRoles();
return RestBean.success(roles);
}
/**
* 分页查询角色
* 步骤:
* 1. 根据查询条件构建动态查询
* 2. 支持按角色编码、名称、状态筛选
* 3. 返回分页结果
*
* 权限:需要 role:list 权限
*
* @param queryDto 查询条件(角色编码、名称、状态)
* @param page 页码(从 0 开始)
* @param size 每页大小
* @return 分页角色列表
*/
@PostMapping("/query")
@Operation(summary = "分页查询角色", description = "支持条件筛选和分页")
@PreAuthorize("hasAuthority('role:list')")
public RestBean<PageResult<RoleVo>> queryRoles(
@RequestBody RoleQueryDto queryDto,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
PageResult<RoleVo> result = roleService.queryRoles(queryDto, page, size);
return RestBean.success(result);
}
/**
* 为角色分配权限
* 步骤:
* 1. 检查角色是否存在
* 2. 检查所有权限ID是否存在
* 3. 清空角色原有权限
* 4. 添加新的权限
*
* 权限:需要 role:assign-permission 权限
*
* 注意:
* - 权限变更后需要用户重新登录才能生效
* - 建议在非高峰期操作
*
* @param id 角色ID
* @param permissionIds 权限ID列表
* @return 成功响应
*/
@PostMapping("/{id}/permissions")
@Operation(summary = "为角色分配权限", description = "批量分配权限给角色")
@PreAuthorize("hasAuthority('role:assign-permission')")
public RestBean<Void> assignPermissions(
@PathVariable Long id,
@RequestBody List<Long> permissionIds) {
roleService.assignPermissions(id, permissionIds);
return RestBean.success();
}
/**
* 获取角色的权限ID列表
* 步骤:
* 1. 查询角色基本信息
* 2. 提取所有关联的权限ID
* 3. 返回ID列表
*
* 权限:需要 role:read 权限
*
* @param id 角色ID
* @return 权限ID列表
*/
@GetMapping("/{id}/permissions")
@Operation(summary = "获取角色权限", description = "获取角色拥有的所有权限ID")
@PreAuthorize("hasAuthority('role:read')")
public RestBean<List<Long>> getRolePermissionIds(@PathVariable Long id) {
List<Long> permissionIds = roleService.getRolePermissionIds(id);
return RestBean.success(permissionIds);
}
}

View File

@@ -4,84 +4,352 @@ import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.dto.PageResult; import com.aisi.template.domain.dto.PageResult;
import com.aisi.template.domain.dto.PasswordResetConfirmDto; import com.aisi.template.domain.dto.PasswordResetConfirmDto;
import com.aisi.template.domain.dto.PasswordResetRequestDto; import com.aisi.template.domain.dto.PasswordResetRequestDto;
import com.aisi.template.domain.dto.RefreshTokenDto;
import com.aisi.template.domain.dto.UserDto; import com.aisi.template.domain.dto.UserDto;
import com.aisi.template.domain.dto.UserQueryDto; import com.aisi.template.domain.dto.UserQueryDto;
import com.aisi.template.domain.dto.UserRoleUpdateDto; import com.aisi.template.domain.dto.UserRoleUpdateDto;
import com.aisi.template.domain.dto.UserStatusUpdateDto; import com.aisi.template.domain.dto.UserStatusUpdateDto;
import com.aisi.template.domain.vo.LoginResponseVo;
import com.aisi.template.domain.vo.UserVo; import com.aisi.template.domain.vo.UserVo;
import com.aisi.template.service.PasswordResetService; import com.aisi.template.service.PasswordResetService;
import com.aisi.template.service.TokenService;
import com.aisi.template.service.UserService; import com.aisi.template.service.UserService;
import com.aisi.template.utils.JwtUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Date;
/**
* 用户管理控制器
* 提供用户注册、登录、信息管理等相关接口
*
* 主要功能:
* 1. 用户认证注册、登录、登出、Token 刷新
* 2. 密码管理:密码重置(邮件验证码)
* 3. 用户信息查询:获取当前用户信息
* 4. 用户管理:用户列表查询(需要权限)、状态更新、角色分配
*
* 权限说明:
* - 用户注册、登录、密码重置:无需认证
* - 获取用户信息:需要登录
* - 用户管理接口:需要相应权限(如 user:list、user:update
*
* @author Claude
* @since 2024-04-09
*/
@RestController @RestController
@RequestMapping("/api/v1/user") @RequestMapping("/api/v1/user")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "用户接口") @Tag(name = "用户接口", description = "用户注册、登录、信息管理等相关接口")
public class UserController { public class UserController {
/**
* 用户服务
* 处理用户相关的业务逻辑
*/
private final UserService userService; private final UserService userService;
/**
* 密码重置服务
* 处理密码重置的业务逻辑
*/
private final PasswordResetService passwordResetService; private final PasswordResetService passwordResetService;
/**
* Token 服务
* 处理 Refresh Token 和黑名单管理
*/
private final TokenService tokenService;
/**
* JWT 工具类
* 用于生成和解析 JWT Token
*/
private final JwtUtil jwtUtil;
/**
* 获取当前用户信息
* 步骤:
* 1. 从 SecurityContext 获取当前用户信息
* 2. 返回用户详细信息
*
* 权限需要登录Bearer Token
*
* @return 用户信息视图对象
*/
@GetMapping("info") @GetMapping("info")
@Operation(summary = "用户信息") @Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息")
@SecurityRequirement(name = "Bearer Authentication") @SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> getUserInfo() { public RestBean<UserVo> getUserInfo() {
return userService.getUserInfo(); return userService.getUserInfo();
} }
/**
* 用户注册
* 步骤:
* 1. 校验用户名、邮箱是否已存在
* 2. 校验密码强度(必须包含大小写字母、数字、特殊字符)
* 3. 创建用户并分配默认角色ROLE_USER
* 4. 生成 Access Token 和 Refresh Token
*
* 注意:
* - 密码会使用 BCrypt 加密存储
* - 新用户默认分配 ROLE_USER 角色
*
* @param userDto 用户注册信息(用户名、密码、邮箱)
* @return 登录响应(包含 Token 和用户信息)
*/
@PostMapping("register") @PostMapping("register")
@Operation(summary = "用户注册") @Operation(summary = "用户注册", description = "创建新用户账号并返回登录凭证")
public RestBean<String> register(@Valid @RequestBody UserDto userDto) { public RestBean<LoginResponseVo> register(@Valid @RequestBody UserDto userDto) {
return userService.register(userDto); return userService.register(userDto);
} }
/**
* 用户登录
* 步骤:
* 1. 验证用户名和密码
* 2. 检查账户状态(是否被禁用、锁定)
* 3. 记录登录失败次数(超过阈值则锁定账户)
* 4. 生成 Access Token 和 Refresh Token
* 5. 发送登录消息到 MQ
*
* 注意:
* - 连续登录失败 5 次会锁定账户 30 分钟
* - 密码错误也会被记录
*
* @param userDto 登录信息(用户名、密码)
* @return 登录响应(包含 Token 和用户信息)
*/
@PostMapping("login") @PostMapping("login")
@Operation(summary = "用户登录") @Operation(summary = "用户登录", description = "使用用户名密码登录,返回访问令牌")
RestBean<String> login(@Valid @RequestBody UserDto userDto) { RestBean<LoginResponseVo> login(@Valid @RequestBody UserDto userDto) {
return userService.login(userDto); return userService.login(userDto);
} }
/**
* 用户登出
* 步骤:
* 1. 从请求头获取当前 Token
* 2. 提取 Token 的 JTIJWT ID
* 3. 将 Token 加入黑名单(剩余过期时间内有效)
* 4. 撤销用户所有 Refresh Token
*
* 权限需要登录Bearer Token
*
* @param request HTTP 请求对象(用于获取 Token
* @return 成功响应
*/
@PostMapping("logout")
@Operation(summary = "用户登出", description = "退出登录,使当前 Token 失效")
@SecurityRequirement(name = "Bearer Authentication")
public RestBean<Void> logout(HttpServletRequest request) {
// 1. 从请求头提取 Token
String token = extractToken(request);
if (token != null) {
// 2. 提取 Token 的 JTIJWT 唯一标识)
String jti = jwtUtil.extractJti(token);
if (jti != null) {
// 3. 计算剩余过期时间
long remainingSeconds = getRemainingExpiration(token);
// 4. 将 Token 加入黑名单(过期后自动删除)
tokenService.addTokenToBlacklist(jti, remainingSeconds);
}
// 5. 撤销当前用户的所有 Refresh Token
Long userId = jwtUtil.extractUserId(token);
if (userId != null) {
tokenService.revokeAllUserTokens(userId);
}
}
return RestBean.success();
}
/**
* 刷新访问令牌
* 步骤:
* 1. 验证 Refresh Token 是否有效
* 2. 生成新的 Access Token
* 3. 生成新的 Refresh TokenToken 轮换机制)
* 4. 撤销旧的 Refresh Token
*
* 注意:
* - Refresh Token 使用一次后即失效
* - 使用 Refresh Token 轮换提高安全性
*
* @param refreshTokenDto 刷新令牌请求
* @return 新的登录响应(包含新的 Token
*/
@PostMapping("refresh")
@Operation(summary = "刷新访问令牌", description = "使用 Refresh Token 获取新的 Access Token")
public RestBean<LoginResponseVo> refreshToken(@Valid @RequestBody RefreshTokenDto refreshTokenDto) {
LoginResponseVo response = tokenService.refreshToken(refreshTokenDto.getRefreshToken());
return RestBean.success(response);
}
/**
* 发送密码重置验证码
* 步骤:
* 1. 校验邮箱格式
* 2. 检查请求冷却时间(防止频繁请求)
* 3. 生成 6 位数字验证码
* 4. 发送验证码到用户邮箱
* 5. 验证码哈希后存储到 Redis10分钟有效
*
* 注意:
* - 同一邮箱 60 秒内只能请求一次
* - 验证码最多尝试 5 次
*
* @param requestDto 密码重置请求(邮箱)
* @return 成功响应
*/
@PostMapping("password-reset/request") @PostMapping("password-reset/request")
@Operation(summary = "发送找回密码验证码") @Operation(summary = "发送密码重置验证码", description = "发送验证码到用户邮箱")
public RestBean<Void> sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) { public RestBean<Void> sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) {
return passwordResetService.sendResetCode(requestDto); return passwordResetService.sendResetCode(requestDto);
} }
/**
* 使用验证码重置密码
* 步骤:
* 1. 验证邮箱和验证码是否匹配
* 2. 验证验证码是否过期
* 3. 验证验证码尝试次数
* 4. 校验新密码强度
* 5. 更新用户密码
* 6. 删除已使用的验证码
*
* @param confirmDto 密码重置确认(邮箱、验证码、新密码)
* @return 成功响应
*/
@PostMapping("password-reset/confirm") @PostMapping("password-reset/confirm")
@Operation(summary = "验证码重置密码") @Operation(summary = "验证码重置密码", description = "使用邮箱验证码重置用户密码")
public RestBean<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) { public RestBean<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) {
return passwordResetService.resetPassword(confirmDto); return passwordResetService.resetPassword(confirmDto);
} }
/**
* 分页获取用户列表
* 步骤:
* 1. 根据查询条件构建动态查询
* 2. 支持按用户名/邮箱模糊搜索
* 3. 支持按状态筛选
* 4. 返回分页结果
*
* 权限:需要 user:list 权限
*
* @param queryDto 查询条件(关键词、状态等)
* @return 分页用户列表
*/
@GetMapping("list") @GetMapping("list")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasAuthority('user:list')")
@Operation(summary = "分页获取用户列表") @Operation(summary = "分页获取用户列表", description = "查询用户列表,支持分页和条件筛选")
@SecurityRequirement(name = "Bearer Authentication") @SecurityRequirement(name = "Bearer Authentication")
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) { public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
return userService.getUserList(queryDto); return userService.getUserList(queryDto);
} }
/**
* 更新用户状态
* 步骤:
* 1. 检查用户是否存在
* 2. 禁止修改当前登录用户的状态
* 3. 更新用户状态(启用/禁用)
*
* 权限:需要 user:update 权限
*
* 注意:
* - 不能禁用当前登录的用户
* - 禁用后用户无法登录
*
* @param userId 用户ID
* @param updateDto 状态更新请求
* @return 更新后的用户信息
*/
@PutMapping("{userId}/status") @PutMapping("{userId}/status")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasAuthority('user:update')")
@Operation(summary = "更新用户状态") @Operation(summary = "更新用户状态", description = "启用或禁用用户账号")
@SecurityRequirement(name = "Bearer Authentication") @SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> updateUserStatus(@PathVariable Long userId, public RestBean<UserVo> updateUserStatus(@PathVariable Long userId,
@Valid @RequestBody UserStatusUpdateDto updateDto) { @Valid @RequestBody UserStatusUpdateDto updateDto) {
return userService.updateUserStatus(userId, updateDto); return userService.updateUserStatus(userId, updateDto);
} }
/**
* 更新用户角色
* 步骤:
* 1. 检查用户是否存在
* 2. 禁止修改当前登录用户的角色
* 3. 验证角色ID是否存在
* 4. 更新用户角色关系
*
* 权限:需要 user:update 权限
*
* 注意:
* - 不能修改当前登录用户的角色
* - 角色变更后需要重新登录才能生效
*
* @param userId 用户ID
* @param updateDto 角色更新请求角色ID列表
* @return 更新后的用户信息
*/
@PutMapping("{userId}/role") @PutMapping("{userId}/role")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasAuthority('user:update')")
@Operation(summary = "更新用户角色") @Operation(summary = "更新用户角色", description = "为用户分配或移除角色")
@SecurityRequirement(name = "Bearer Authentication") @SecurityRequirement(name = "Bearer Authentication")
public RestBean<UserVo> updateUserRole(@PathVariable Long userId, public RestBean<UserVo> updateUserRole(@PathVariable Long userId,
@Valid @RequestBody UserRoleUpdateDto updateDto) { @Valid @RequestBody UserRoleUpdateDto updateDto) {
return userService.updateUserRole(userId, updateDto); return userService.updateUserRole(userId, updateDto);
} }
/**
* 从 HTTP 请求中提取 JWT Token
* 步骤:
* 1. 从 Authorization 头获取值
* 2. 验证格式Bearer 前缀)
* 3. 提取 Token 部分(去掉 "Bearer " 前缀)
*
* @param request HTTP 请求对象
* @return JWT Token 字符串,无效返回 null
*/
private String extractToken(HttpServletRequest request) {
// 1. 获取 Authorization 头
String authorization = request.getHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
return null;
}
// 2. 去掉 "Bearer " 前缀(注意空格)
return authorization.substring(7);
}
/**
* 计算 Token 剩余有效时间(秒)
* 步骤:
* 1. 提取 Token 过期时间
* 2. 计算与当前时间的差值
* 3. 转换为秒并确保不为负数
*
* @param token JWT Token 字符串
* @return 剩余秒数,已过期返回 0
*/
private long getRemainingExpiration(String token) {
try {
// 1. 获取过期时间
Date expiration = jwtUtil.extractExpiration(token);
// 2. 计算剩余毫秒数
long remainingMillis = expiration.getTime() - System.currentTimeMillis();
// 3. 转换为秒(至少为 0
return Math.max(0, remainingMillis / 1000);
} catch (Exception e) {
// Token 解析失败,返回 0
return 0;
}
}
} }

View File

@@ -5,57 +5,159 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.Set;
/**
* 自定义用户详情类
* 实现 Spring Security 的 UserDetails 接口
*
* 主要功能:
* 1. 用户认证:提供用户信息(用户名、密码)
* 2. 用户授权:提供用户权限和角色
* 3. 用户状态:提供用户账户状态信息
*
* 设计说明:
* - 扩展了标准 UserDetails增加了用户ID和角色列表
* - 用于 Spring Security 的认证和授权流程
*
* @author Claude
* @since 2024-04-09
*/
public class CustomUserDetails implements UserDetails { public class CustomUserDetails implements UserDetails {
/**
* 用户ID
*/
@Getter @Getter
private final Long id; private final Long id;
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean enabled;
@Getter
private final String role;
public CustomUserDetails(Long id, String username, String password, Collection<? extends GrantedAuthority> authorities, boolean enabled, String role) { /**
* 用户名
*/
private final String username;
/**
* 密码(加密后的)
*/
private final String password;
/**
* 用户权限列表
* - 包含角色和权限
* - 格式ROLE_XXX角色或 XXX:YYY权限
*/
private final Collection<? extends GrantedAuthority> authorities;
/**
* 用户是否启用
* - true启用
* - false禁用
*/
private final boolean enabled;
/**
* 用户角色列表
* - 包含角色编码
* - 例如ROLE_USER, ROLE_ADMIN
*/
@Getter
private final Set<String> roles;
/**
* 构造函数
*
* @param id 用户ID
* @param username 用户名
* @param password 密码
* @param authorities 权限列表
* @param enabled 是否启用
* @param roles 角色列表
*/
public CustomUserDetails(Long id, String username, String password,
Collection<? extends GrantedAuthority> authorities,
boolean enabled, Set<String> roles) {
this.id = id; this.id = id;
this.username = username; this.username = username;
this.password = password; this.password = password;
this.authorities = authorities; this.authorities = authorities;
this.enabled = enabled; this.enabled = enabled;
this.role = role; this.roles = roles;
} }
/**
* 获取用户名
*
* @return 用户名
*/
@Override @Override
public String getUsername() { public String getUsername() {
return username; return username;
} }
/**
* 获取密码
*
* @return 密码
*/
@Override @Override
public String getPassword() { public String getPassword() {
return password; return password;
} }
/**
* 获取用户权限列表
*
* @return 权限列表
*/
@Override @Override
public Collection<? extends GrantedAuthority> getAuthorities() { public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities; return authorities;
} }
/**
* 账户是否未过期
* 说明:
* - 当前实现返回 true账户永不过期
* - 可根据业务需求扩展
*
* @return true
*/
@Override @Override
public boolean isAccountNonExpired() { public boolean isAccountNonExpired() {
return true; return true;
} }
/**
* 账户是否未锁定
* 说明:
* - 当前实现返回 true不在此处判断锁定
* - 锁定状态由 User.isLocked() 判断
*
* @return true
*/
@Override @Override
public boolean isAccountNonLocked() { public boolean isAccountNonLocked() {
return true; return true;
} }
/**
* 密码是否未过期
* 说明:
* - 当前实现返回 true密码永不过期
* - 可根据业务需求扩展(如密码过期策略)
*
* @return true
*/
@Override @Override
public boolean isCredentialsNonExpired() { public boolean isCredentialsNonExpired() {
return true; return true;
} }
/**
* 账户是否启用
*
* @return 是否启用
*/
@Override @Override
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;

View File

@@ -6,23 +6,62 @@ import lombok.NoArgsConstructor;
/** /**
* 通用的 REST API 响应封装类 * 通用的 REST API 响应封装类
* 统一 API 响应格式
*
* 响应格式:
* <pre>
* {
* "code": 200,
* "message": "操作成功",
* "data": {...}
* }
* </pre>
*
* 主要功能:
* 1. 统一响应格式:所有接口返回相同的结构
* 2. 成功响应:提供多种成功响应构造方法
* 3. 失败响应:提供多种失败响应构造方法
* *
* @param <V> 返回数据的类型 * @param <V> 返回数据的类型
* @author Claude
* @since 2024-04-09
*/ */
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class RestBean<V> { public class RestBean<V> {
/** 状态码 */ /**
* 状态码
* - 200成功
* - 400客户端错误
* - 401未登录
* - 403无权限
* - 404资源不存在
* - 500服务器错误
*/
private int code; private int code;
/** 提示消息 */
/**
* 提示消息
* - 成功时返回成功信息
* - 失败时返回错误信息
*/
private String message; private String message;
/** 具体数据 */
/**
* 具体数据
* - 成功时返回业务数据
* - 失败时可能返回 null 或错误详情
*/
private V data; private V data;
/** /**
* 成功响应(默认使用 {@link RestCode#SUCCESS} * 成功响应(默认使用 RestCode#SUCCESS
* 说明:
* - 状态码200
* - 消息:"操作成功"
* - 返回业务数据
* *
* @param data 返回的数据 * @param data 返回的数据
* @param <V> 泛型参数 * @param <V> 泛型参数
@@ -32,6 +71,19 @@ public class RestBean<V> {
return success(RestCode.SUCCESS, data); return success(RestCode.SUCCESS, data);
} }
/**
* 成功响应(无数据返回)
* 使用场景:
* - 删除操作
* - 更新操作
*
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> success() {
return success(RestCode.SUCCESS, null);
}
/** /**
* 成功响应(指定 RestCode 和数据) * 成功响应(指定 RestCode 和数据)
* *
@@ -46,6 +98,9 @@ public class RestBean<V> {
/** /**
* 成功响应(只返回状态码和消息,不带数据) * 成功响应(只返回状态码和消息,不带数据)
* 使用场景:
* - 删除操作(不需要返回数据)
* - 更新操作(不需要返回数据)
* *
* @param restCode 状态码枚举 * @param restCode 状态码枚举
* @param <V> 泛型参数 * @param <V> 泛型参数
@@ -69,7 +124,10 @@ public class RestBean<V> {
} }
/** /**
* 失败响应(默认使用 {@link RestCode#FAILURE} * 失败响应(默认使用 RestCode#FAILURE
* 说明:
* - 状态码400
* - 消息:"操作失败"
* *
* @param data 返回的数据 * @param data 返回的数据
* @param <V> 泛型参数 * @param <V> 泛型参数
@@ -115,4 +173,16 @@ public class RestBean<V> {
return new RestBean<>(code, message, data); return new RestBean<>(code, message, data);
} }
/**
* 失败响应(自定义 code 和 message无数据
*
* @param code 状态码
* @param message 提示消息
* @param <V> 泛型参数
* @return RestBean 包装对象
*/
public static <V> RestBean<V> failure(int code, String message) {
return new RestBean<>(code, message, null);
}
} }

View File

@@ -7,26 +7,214 @@ import lombok.ToString;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
/**
* REST API 状态码枚举
* 定义所有 API 响应的状态码和消息
*
* 状态码分类:
* - 通用错误码200-599
* - 认证相关1000-1099
* - 用户相关1100-1199
* - 角色相关1200-1299
* - 权限相关1300-1399
* - 菜单相关1400-1499
*
* @author Claude
* @since 2024-04-09
*/
@Getter @Getter
@ToString @ToString
public enum RestCode { public enum RestCode {
SUCCESS(200,"操作成功"), // ==================== 通用错误码 ====================
FAILURE(400,"操作失败"),
USERNAME_OR_PASSWORD_ERROR(402,"用户名或密码错误"),
NO_LOGIN(401,"用户未登录"),
UNAUTHORIZED(403,"未授权"),
TOKEN_EXPIRE(403, "token已过期"),
TOKEN_EMPTY(403, "token不能为空"),
TOKEN_INVALID(403, "token非法"),
SYSTEM_ERROR(500,"系统错误,请联系管理员" ),
DATA_NOT_FOUND(404,"数据不存在"),
DATA_ALREADY_FOUND(409,"数据已存在"),
METHOD_NOT_SUPPORT(405,"不支持该请求方法");
/**
* 操作成功
* 状态码200
*/
SUCCESS(200, "操作成功"),
/**
* 操作失败
* 状态码400
*/
FAILURE(400, "操作失败"),
/**
* 用户未登录
* 状态码401
*/
NO_LOGIN(401, "用户未登录"),
/**
* 未授权
* 状态码403
*/
UNAUTHORIZED(403, "未授权"),
/**
* Token 已过期
* 状态码403
*/
TOKEN_EXPIRE(403, "Token 已过期"),
/**
* Token 不能为空
* 状态码403
*/
TOKEN_EMPTY(403, "Token 不能为空"),
/**
* Token 非法
* 状态码403
*/
TOKEN_INVALID(403, "Token 非法"),
/**
* 数据不存在
* 状态码404
*/
DATA_NOT_FOUND(404, "数据不存在"),
/**
* 数据已存在
* 状态码409
*/
DATA_ALREADY_FOUND(409, "数据已存在"),
/**
* 不支持该请求方法
* 状态码405
*/
METHOD_NOT_SUPPORT(405, "不支持该请求方法"),
/**
* 系统错误
* 状态码500
*/
SYSTEM_ERROR(500, "系统错误,请联系管理员"),
// ==================== 认证相关 (1000 - 1099) ====================
/**
* 用户名或密码错误
* 状态码1001
*/
USERNAME_OR_PASSWORD_ERROR(1001, "用户名或密码错误"),
/**
* 参数错误
* 状态码1002
*/
PARAM_ERROR(1002, "参数错误"),
// ==================== 用户相关 (1100 - 1199) ====================
/**
* 用户不存在
* 状态码1100
*/
USER_NOT_FOUND(1100, "用户不存在"),
/**
* 密码错误
* 状态码1101
*/
PASSWORD_ERROR(1101, "密码错误"),
/**
* 账号已被锁定
* 状态码1102
*/
USER_LOCKED(1102, "账号已被锁定"),
/**
* 账号已被禁用
* 状态码1103
*/
USER_DISABLED(1103, "账号已被禁用"),
/**
* 用户已存在
* 状态码1104
*/
USER_ALREADY_EXISTS(1104, "用户已存在"),
// ==================== 角色相关 (1200 - 1299) ====================
/**
* 角色不存在
* 状态码1200
*/
ROLE_NOT_FOUND(1200, "角色不存在"),
/**
* 角色已存在
* 状态码1201
*/
ROLE_ALREADY_EXISTS(1201, "角色已存在"),
/**
* 角色正在使用中,无法删除
* 状态码1202
*/
ROLE_IN_USE(1202, "角色正在使用中,无法删除"),
// ==================== 权限相关 (1300 - 1399) ====================
/**
* 权限不存在
* 状态码1300
*/
PERMISSION_NOT_FOUND(1300, "权限不存在"),
/**
* 权限已存在
* 状态码1301
*/
PERMISSION_ALREADY_EXISTS(1301, "权限已存在"),
// ==================== 菜单相关 (1400 - 1499) ====================
/**
* 菜单不存在
* 状态码1400
*/
MENU_NOT_FOUND(1400, "菜单不存在"),
/**
* 菜单存在子菜单,无法删除
* 状态码1401
*/
MENU_HAS_CHILDREN(1401, "菜单存在子菜单,无法删除"),
/**
* 菜单正在使用中
* 状态码1402
*/
MENU_IN_USE(1402, "菜单正在使用中");
/**
* 状态码
*/
private final int code; private final int code;
/**
* 消息
*/
private final String message; private final String message;
private final Map<String, Object> json; // 预先创建的不可变 Map
/**
* JSON 格式的数据(用于序列化)
*/
private final Map<String, Object> json;
/**
* 构造函数
*
* @param code 状态码
* @param message 消息
*/
RestCode(Integer code, String message) { RestCode(Integer code, String message) {
this.code = code; this.code = code;
this.message = message; this.message = message;
@@ -36,9 +224,16 @@ public enum RestCode {
this.json = map; this.json = map;
} }
/**
* 序列化为 JSON
* 说明:
* - 当使用 @JsonValue 注解时,
* - Jackson 会调用此方法获取序列化的值
*
* @return JSON 格式的数据
*/
@JsonValue @JsonValue
public Map<String,Object> toJson(){ public Map<String, Object> toJson() {
return json; return json;
} }
} }

View File

@@ -0,0 +1,114 @@
package com.aisi.template.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 菜单数据传输对象
* 用于创建和更新菜单
*
* 验证规则:
* - 父菜单ID不能为空默认 0根菜单
* - 菜单名称:不能为空
* - 菜单类型不能为空1=目录2=菜单3=按钮)
* - 排序字段:不能为空,默认 0
* - 可见性:不能为空,默认 1可见
* - 状态:不能为空,默认 1启用
*
* @author Claude
* @since 2024-04-09
*/
@Data
public class MenuDto {
/**
* 菜单ID
* - 更新时需要提供
* - 创建时不需要提供
*/
private Long id;
/**
* 父菜单ID
* - 不能为空
* - 0 或 null根菜单
* - 其他值:子菜单
* - 默认值0
*/
@NotNull(message = "父菜单ID不能为空")
private Long parentId = 0L;
/**
* 菜单名称
* - 不能为空
* - 示例:用户管理、角色管理
*/
@NotBlank(message = "菜单名称不能为空")
private String menuName;
/**
* 菜单类型
* - 不能为空
* - 1目录DIRECTORY
* - 2菜单MENU
* - 3按钮BUTTON
*/
@NotNull(message = "菜单类型不能为空")
private Integer menuType;
/**
* 菜单路径
* - 用于前端路由
* - 示例:/user, /role
*/
private String menuPath;
/**
* 组件路径
* - 前端组件文件路径
* - 示例views/user/List.vue
*/
private String component;
/**
* 菜单图标
* - 图标名称
* - 示例user, role, setting
*/
private String icon;
/**
* 排序字段
* - 不能为空
* - 数值越小越靠前
* - 默认值0
*/
@NotNull(message = "排序字段不能为空")
private Integer sortOrder = 0;
/**
* 可见性
* - 不能为空
* - 1可见默认
* - 0不可见
*/
@NotNull(message = "可见性不能为空")
private Integer visible = 1;
/**
* 菜单状态
* - 不能为空
* - 1启用默认
* - 0禁用
*/
@NotNull(message = "状态不能为空")
private Integer status = 1;
/**
* 权限编码
* - 用于控制菜单的访问权限
* - 示例user:read, role:read
*/
private String permissionCode;
}

View File

@@ -1,28 +1,66 @@
package com.aisi.template.domain.dto; package com.aisi.template.domain.dto;
import com.aisi.template.annotation.StrongPassword;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
/**
* 密码重置确认数据传输对象
* 用于使用验证码重置密码
*
* 验证规则:
* - 邮箱:不能为空,必须符合邮箱格式
* - 验证码:必须是 6 位数字
* - 新密码:不能为空,需满足强密码要求
*
* 使用场景:
* - 用户收到验证码后,输入验证码和新密码重置密码
*
* 注意:
* - 验证码 10 分钟有效
* - 验证码最多尝试 5 次,超过后失效
* - 验证码使用后立即失效
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "重置密码确认请求") @Schema(description = "重置密码确认请求")
public class PasswordResetConfirmDto { public class PasswordResetConfirmDto {
/**
* 邮箱地址
* - 不能为空
* - 必须符合邮箱格式
* - 示例user@example.com
*/
@NotBlank(message = "邮箱不能为空") @NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确") @Email(message = "邮箱格式不正确")
@Schema(description = "邮箱") @Schema(description = "邮箱", example = "user@example.com")
private String email; private String email;
/**
* 验证码
* - 不能为空
* - 必须是 6 位数字
* - 示例123456
*/
@NotBlank(message = "验证码不能为空") @NotBlank(message = "验证码不能为空")
@Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字") @Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字")
@Schema(description = "6位验证码") @Schema(description = "6位验证码", example = "123456")
private String code; private String code;
/**
* 新密码
* - 不能为空
* - 需满足强密码要求:包含大小写字母、数字、特殊字符
* - 示例NewPassword123!
*/
@NotBlank(message = "新密码不能为空") @NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") @StrongPassword
@Schema(description = "新密码") @Schema(description = "新密码", example = "NewPassword123!")
private String newPassword; private String newPassword;
} }

View File

@@ -5,12 +5,36 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
/**
* 密码重置请求数据传输对象
* 用于请求发送密码重置验证码
*
* 验证规则:
* - 邮箱:不能为空,必须符合邮箱格式
*
* 使用场景:
* - 用户忘记密码时,输入邮箱请求重置验证码
*
* 注意:
* - 为防止用户名枚举,无论邮箱是否存在都返回相同消息
* - 同一邮箱 60 秒内只能请求一次
* - 同一邮箱 1 小时内最多请求 5 次
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "找回密码请求") @Schema(description = "找回密码请求")
public class PasswordResetRequestDto { public class PasswordResetRequestDto {
/**
* 邮箱地址
* - 不能为空
* - 必须符合邮箱格式
* - 示例user@example.com
*/
@NotBlank(message = "邮箱不能为空") @NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确") @Email(message = "邮箱格式不正确")
@Schema(description = "邮箱") @Schema(description = "邮箱", example = "user@example.com")
private String email; private String email;
} }

View File

@@ -0,0 +1,14 @@
package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "刷新令牌请求")
public class RefreshTokenDto {
@NotBlank(message = "刷新令牌不能为空")
@Schema(description = "刷新令牌")
private String refreshToken;
}

View File

@@ -0,0 +1,65 @@
package com.aisi.template.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 角色数据传输对象
* 用于创建和更新角色
*
* 验证规则:
* - 角色编码:不能为空,格式如 ROLE_USER
* - 角色名称:不能为空
* - 排序字段:不能为空,默认 0
* - 状态:不能为空,默认 1启用
*
* @author Claude
* @since 2024-04-09
*/
@Data
public class RoleDto {
/**
* 角色编码
* - 不能为空
* - 格式ROLE_ + 名称,如 ROLE_USER, ROLE_ADMIN
* - 示例ROLE_USER
*/
@NotBlank(message = "角色编码不能为空")
private String roleCode;
/**
* 角色名称
* - 不能为空
* - 用于界面展示
* - 示例:普通用户、管理员
*/
@NotBlank(message = "角色名称不能为空")
private String roleName;
/**
* 角色描述
* - 可选
* - 描述角色的用途和权限范围
*/
private String description;
/**
* 排序字段
* - 不能为空
* - 数值越小越靠前
* - 默认值0
*/
@NotNull(message = "排序字段不能为空")
private Integer sortOrder = 0;
/**
* 角色状态
* - 不能为空
* - 1启用默认
* - 0禁用
*/
@NotNull(message = "状态不能为空")
private Integer status = 1;
}

View File

@@ -0,0 +1,41 @@
package com.aisi.template.domain.dto;
import lombok.Data;
/**
* 角色查询参数数据传输对象
* 用于查询角色列表
*
* 查询条件:
* - 角色编码:模糊匹配
* - 角色名称:模糊匹配
* - 状态:精确匹配
*
* @author Claude
* @since 2024-04-09
*/
@Data
public class RoleQueryDto {
/**
* 角色编码
* - 模糊匹配
* - 示例ROLE_USER 会匹配 role_code LIKE '%ROLE_USER%'
*/
private String roleCode;
/**
* 角色名称
* - 模糊匹配
* - 示例:管理员 会匹配 role_name LIKE '%管理员%'
*/
private String roleName;
/**
* 角色状态
* - 精确匹配
* - 1启用
* - 0禁用
*/
private Integer status;
}

View File

@@ -1,24 +1,59 @@
package com.aisi.template.domain.dto; package com.aisi.template.domain.dto;
import com.aisi.template.annotation.StrongPassword;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
/**
* 用户数据传输对象
* 用于用户注册和登录请求
*
* 验证规则:
* - 用户名2-50 位字符,不能为空
* - 密码:不能为空,需满足强密码要求(@StrongPassword
* - 邮箱:可选,注册时建议提供
*
* 使用场景:
* - 用户注册:需要用户名、密码、邮箱
* - 用户登录:需要用户名、密码
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "用户注册/登录请求") @Schema(description = "用户注册/登录请求")
public class UserDto { public class UserDto {
/**
* 用户名
* - 长度2-50 位字符
* - 不能为空
* - 示例admin, user01
*/
@NotBlank(message = "用户名不能为空") @NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间") @Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间")
@Schema(description = "用户名", example = "admin") @Schema(description = "用户名", example = "admin")
private String username; private String username;
/**
* 密码
* - 不能为空
* - 需满足强密码要求:包含大小写字母、数字、特殊字符
* - 示例Password123!
*/
@NotBlank(message = "密码不能为空") @NotBlank(message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") @StrongPassword
@Schema(description = "密码", example = "123456") @Schema(description = "密码", example = "Password123!")
private String password; private String password;
/**
* 邮箱地址
* - 可选字段
* - 注册时建议提供,用于找回密码
* - 示例user@example.com
*/
@Schema(description = "邮箱(注册时可选)", example = "user@example.com") @Schema(description = "邮箱(注册时可选)", example = "user@example.com")
private String email; private String email;
} }

View File

@@ -3,22 +3,52 @@ package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
/**
* 用户查询参数数据传输对象
* 用于分页查询用户列表
*
* 查询条件:
* - 分页参数:页码、每页大小
* - 关键词:匹配用户名或邮箱(模糊搜索)
* - 状态精确匹配1=正常0=禁用)
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "用户查询参数") @Schema(description = "用户查询参数")
public class UserQueryDto { public class UserQueryDto {
/**
* 页码
* - 默认值1
* - 从 1 开始计数
*/
@Schema(description = "页码", example = "1") @Schema(description = "页码", example = "1")
private Integer page = 1; private Integer page = 1;
/**
* 每页大小
* - 默认值10
* - 最小值1
*/
@Schema(description = "每页大小", example = "10") @Schema(description = "每页大小", example = "10")
private Integer size = 10; private Integer size = 10;
@Schema(description = "关键词(用户名/邮箱)") /**
* 关键词
* - 模糊匹配用户名或邮箱
* - 示例admin 会匹配 username LIKE '%admin%' OR email LIKE '%admin%'
*/
@Schema(description = "关键词(用户名/邮箱)", example = "admin")
private String keyword; private String keyword;
@Schema(description = "角色USER/ADMIN") /**
private String role; * 用户状态
* - 1正常启用
@Schema(description = "状态1=正常 0=禁用") * - 0禁用
* - 精确匹配
*/
@Schema(description = "状态1=正常 0=禁用)", example = "1")
private Integer status; private Integer status;
} }

View File

@@ -1,14 +1,40 @@
package com.aisi.template.domain.dto; package com.aisi.template.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty;
import lombok.Data; import lombok.Data;
import java.util.Set;
/**
* 用户角色更新数据传输对象
* 用于为用户分配或修改角色
*
* 验证规则:
* - 角色ID列表不能为空
*
* 使用场景:
* - 管理员为用户分配角色
* - 管理员修改用户的角色
*
* 注意:
* - 不能修改当前登录用户的角色
* - 角色变更后用户需要重新登录才能生效
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "用户角色更新请求") @Schema(description = "用户角色更新请求")
public class UserRoleUpdateDto { public class UserRoleUpdateDto {
@NotBlank(message = "角色不能为空") /**
@Schema(description = "角色USER/ADMIN", example = "ADMIN") * 角色ID列表
private String role; * - 不能为空
* - 会完全替换用户的角色列表
* - 示例:[1, 2] 表示分配角色ID为1和2的角色
*/
@NotEmpty(message = "角色不能为空")
@Schema(description = "角色ID列表", example = "[1, 2]")
private Set<Long> roleIds;
} }

View File

@@ -6,10 +6,34 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
/**
* 用户状态更新数据传输对象
* 用于更新用户的启用/禁用状态
*
* 验证规则:
* - 状态:不能为空,只能是 0 或 1
*
* 使用场景:
* - 管理员启用或禁用用户账户
*
* 注意:
* - 不能禁用当前登录的用户
* - 状态变更后用户需要重新登录
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "用户状态更新请求") @Schema(description = "用户状态更新请求")
public class UserStatusUpdateDto { public class UserStatusUpdateDto {
/**
* 用户状态
* - 不能为空
* - 只能是 0 或 1
* - 1正常启用
* - 0禁用
*/
@NotNull(message = "状态不能为空") @NotNull(message = "状态不能为空")
@Min(value = 0, message = "状态只能为 0 或 1") @Min(value = 0, message = "状态只能为 0 或 1")
@Max(value = 1, message = "状态只能为 0 或 1") @Max(value = 1, message = "状态只能为 0 或 1")

View File

@@ -6,6 +6,26 @@ import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* 密码重置验证码实体类
* 存储密码重置验证码信息
*
* 主要字段:
* 1. 邮箱:用户邮箱
* 2. 验证码:验证码的哈希值(不存储明文)
* 3. 有效期:过期时间
* 4. 使用状态:是否已使用
* 5. 尝试次数:验证失败次数
*
* 安全机制:
* - 验证码使用 SHA-256 哈希存储
* - 验证码默认 10 分钟有效
* - 最多尝试 5 次,超过后失效
* - 同一邮箱 60 秒内只能请求一次
*
* @author Claude
* @since 2024-04-09
*/
@Entity @Entity
@Table(name = "password_reset_codes", indexes = { @Table(name = "password_reset_codes", indexes = {
@Index(name = "idx_password_reset_email", columnList = "email"), @Index(name = "idx_password_reset_email", columnList = "email"),
@@ -14,30 +34,61 @@ import java.time.LocalDateTime;
@Data @Data
public class PasswordResetCode { public class PasswordResetCode {
/**
* 验证码ID主键
*/
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/**
* 邮箱地址(不可为空)
*/
@Column(nullable = false, length = 255) @Column(nullable = false, length = 255)
private String email; private String email;
/**
* 验证码哈希值(不可为空)
* - 存储 SHA-256 哈希值,不存储明文验证码
* - 防止数据库泄露导致验证码泄露
*/
@Column(name = "code_hash", nullable = false, length = 64) @Column(name = "code_hash", nullable = false, length = 64)
private String codeHash; private String codeHash;
/**
* 过期时间(不可为空)
* 默认:当前时间 + 10 分钟
*/
@Column(name = "expires_at", nullable = false) @Column(name = "expires_at", nullable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime expiresAt; private LocalDateTime expiresAt;
/**
* 是否已使用(不可为空)
* - false未使用默认
* - true已使用
*/
@Column(name = "used", nullable = false) @Column(name = "used", nullable = false)
private Boolean used = false; private Boolean used = false;
/**
* 尝试次数(不可为空)
* - 验证失败时递增
* - 超过 5 次后验证码失效
*/
@Column(name = "attempt_count", nullable = false) @Column(name = "attempt_count", nullable = false)
private Integer attemptCount = 0; private Integer attemptCount = 0;
/**
* 创建时间(不可为空,不可更新)
*/
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* 创建前自动设置时间
*/
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) { if (createdAt == null) {

View File

@@ -0,0 +1,126 @@
package com.aisi.template.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Refresh Token 实体类
* 定义刷新令牌的存储和管理
*
* 主要字段:
* 1. 用户关联用户ID
* 2. Token 信息Token 哈希值
* 3. 设备信息设备信息、IP地址
* 4. 有效期:过期时间、撤销状态
*
* 安全机制:
* - 数据库只存储 Token 的 SHA-256 哈希值,不存储原始值
* - 支持 Token 轮换(每次刷新生成新 Token
* - 支持 Token 撤销(登出、修改密码)
*
* @author Claude
* @since 2024-04-09
*/
@Entity
@Table(name = "refresh_tokens")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
/**
* Token ID主键
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 用户ID不可为空
*/
@Column(name = "user_id", nullable = false)
private Long userId;
/**
* Token 哈希值(唯一,不可为空)
* - 存储 SHA-256 哈希值,不存储原始 Token
* - 防止数据库泄露导致 Token 泄露
*/
@Column(name = "token_hash", nullable = false, unique = true, length = 128)
private String tokenHash;
/**
* 设备信息
* 如iPhone 14 Pro, Windows PC
* 用于向用户展示登录设备列表
*/
@Column(name = "device_info", length = 255)
private String deviceInfo;
/**
* IP 地址
* 支持 IPv4 和 IPv6
* 用于安全审计
*/
@Column(name = "ip_address", length = 45)
private String ipAddress;
/**
* 过期时间(不可为空)
* 默认:当前时间 + 7 天
*/
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
/**
* 是否已撤销
* - false有效默认
* - true已撤销
*/
@Column(name = "revoked", nullable = false)
private Boolean revoked = false;
/**
* 撤销时间
* 记录 Token 被撤销的时间
*/
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
/**
* 创建时间(不可为空,不可更新)
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 创建前自动设置时间
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
/**
* 检查 Token 是否过期
*
* @return true 表示 Token 已过期
*/
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiresAt);
}
/**
* 检查 Token 是否有效
* 有效 = 未过期 且 未撤销
*
* @return true 表示 Token 有效
*/
public boolean isValid() {
return !revoked && !isExpired();
}
}

View File

@@ -0,0 +1,150 @@
package com.aisi.template.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 系统审计日志实体类
* 记录用户操作和系统行为
*
* 主要字段:
* 1. 操作用户用户ID、用户名
* 2. 操作信息操作类型、资源、资源ID
* 3. 请求信息请求方法、请求URI、IP地址、User-Agent
* 4. 操作结果:状态、错误信息
*
* 操作类型action
* - LOGIN登录
* - LOGOUT登出
* - CREATE创建
* - UPDATE更新
* - DELETE删除
*
* 资源类型resource
* - user用户
* - role角色
* - menu菜单
* - permission权限
*
* @author Claude
* @since 2024-04-09
*/
@Entity
@Table(name = "sys_audit_log")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SysAuditLog {
/**
* 日志ID主键
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 操作用户ID
*/
@Column(name = "user_id")
private Long userId;
/**
* 操作用户名
*/
@Column(name = "username", length = 50)
private String username;
/**
* 操作类型(不可为空)
* - LOGIN登录
* - LOGOUT登出
* - CREATE创建
* - UPDATE更新
* - DELETE删除
*/
@Column(name = "action", nullable = false, length = 50)
private String action;
/**
* 操作资源(不可为空)
* 如user, role, menu, permission
*/
@Column(name = "resource", nullable = false, length = 100)
private String resource;
/**
* 资源ID
* 被操作资源的ID
*/
@Column(name = "resource_id", length = 50)
private String resourceId;
/**
* 操作描述
* 人类可读的操作描述
*/
@Column(name = "description", length = 500)
private String description;
/**
* 请求方法
* - GET, POST, PUT, DELETE 等
*/
@Column(name = "request_method", length = 10)
private String requestMethod;
/**
* 请求URI
* 请求的完整路径
*/
@Column(name = "request_uri", length = 500)
private String requestUri;
/**
* IP 地址
* 操作者的 IP 地址,支持 IPv4 和 IPv6
*/
@Column(name = "ip_address", length = 45)
private String ipAddress;
/**
* User-Agent
* 浏览器或客户端信息
*/
@Column(name = "user_agent", length = 500)
private String userAgent;
/**
* 操作状态(不可为空)
* - 1成功默认
* - 0失败
*/
@Column(name = "status", nullable = false, columnDefinition = "TINYINT")
private Integer status = 1;
/**
* 错误信息
* 操作失败时的错误描述
*/
@Column(name = "error_message", length = 1000)
private String errorMessage;
/**
* 创建时间(不可为空,不可更新)
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 创建前自动设置时间
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,172 @@
package com.aisi.template.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 系统菜单实体类
* 定义系统的菜单结构和导航
*
* 主要字段:
* 1. 基本信息:菜单名称、菜单类型
* 2. 树形结构父菜单ID
* 3. 菜单配置:路径、组件、图标、排序
* 4. 显示控制:可见性、启用状态
* 5. 权限控制:关联的权限编码
* 6. 角色关系:多对多关联角色
*
* 菜单类型:
* - DIRECTORY(1):目录,仅用于分组,不对应具体页面
* - MENU(2):菜单项,对应具体页面
* - BUTTON(3):按钮,页面内的操作按钮
*
* @author Claude
* @since 2024-04-09
*/
@Entity
@Table(name = "sys_menu")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class SysMenu {
/**
* 菜单ID主键
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Long id;
/**
* 父菜单ID
* - 0 或 null根菜单
* - 其他值:子菜单
*/
@Column(name = "parent_id", nullable = false)
private Long parentId = 0L;
/**
* 菜单名称(不可为空)
*/
@Column(name = "menu_name", nullable = false, length = 50)
private String menuName;
/**
* 菜单类型(不可为空)
* - 1目录DIRECTORY
* - 2菜单MENU
* - 3按钮BUTTON
*/
@Column(name = "menu_type", nullable = false)
private Integer menuType;
/**
* 菜单路径
* 用于前端路由,如:/user, /role
*/
@Column(name = "menu_path", length = 200)
private String menuPath;
/**
* 组件路径
* 前端组件文件路径views/user/List.vue
*/
@Column(name = "component", length = 200)
private String component;
/**
* 菜单图标
* 图标名称user, role, setting
*/
@Column(name = "icon", length = 100)
private String icon;
/**
* 排序字段
* 数值越小越靠前
*/
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
/**
* 可见性
* - 1可见默认
* - 0不可见
*/
@Column(name = "visible", nullable = false)
private Integer visible = 1;
/**
* 菜单状态
* - 1启用默认
* - 0禁用
*/
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 权限编码
* 用于控制菜单的访问权限
*/
@Column(name = "permission_code", length = 100)
private String permissionCode;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 拥有此菜单的角色列表(多对多关系的反向维护)
* - mappedBy = "menus":由 SysRole 的 menus 字段维护关系
* - 懒加载:避免查询菜单时加载所有角色
*/
@ManyToMany(mappedBy = "menus", fetch = FetchType.LAZY)
@ToString.Exclude
private Set<SysRole> roles = new HashSet<>();
/**
* 子菜单列表(非持久化字段)
* - @Transient不映射到数据库
* - 用于构建菜单树形结构
*/
@Transient
@ToString.Exclude
private List<SysMenu> children = new ArrayList<>();
/**
* 创建前自动设置时间
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 更新前自动设置时间
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,133 @@
package com.aisi.template.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
/**
* 系统权限实体类
* 定义系统的资源操作权限
*
* 主要字段:
* 1. 基本信息:权限编码、权限名称
* 2. 资源操作:资源名称、操作类型
* 3. 描述和状态:描述信息、启用状态
* 4. 角色关系:多对多关联角色
*
* 权限格式:
* - 资源:操作,如 user:read, user:write
* - 资源:系统中的实体,如 user, role, permission, menu
* - 操作read读取、write写入、delete删除
*
* 设计说明:
* - 权限是系统预定义的,通过数据库迁移脚本初始化
* - 权限通过角色分配给用户
*
* @author Claude
* @since 2024-04-09
*/
@Entity
@Table(name = "sys_permission")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class SysPermission {
/**
* 权限ID主键
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Long id;
/**
* 权限编码(唯一,不可为空)
* 格式:资源:操作,如 user:read, role:write
*/
@Column(name = "permission_code", nullable = false, unique = true, length = 100)
private String permissionCode;
/**
* 权限名称(不可为空)
* 用于界面展示,如:查看用户、编辑用户
*/
@Column(name = "permission_name", nullable = false, length = 100)
private String permissionName;
/**
* 资源名称(不可为空)
* 如user用户、role角色、menu菜单
*/
@Column(name = "resource", nullable = false, length = 50)
private String resource;
/**
* 操作类型(不可为空)
* - read读取查看
* - write写入创建/编辑)
* - delete删除
*/
@Column(name = "action", nullable = false, length = 50)
private String action;
/**
* 权限描述
*/
@Column(name = "description", length = 500)
private String description;
/**
* 权限状态
* - 1启用默认
* - 0禁用
*/
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 拥有此权限的角色列表(多对多关系的反向维护)
* - mappedBy = "permissions":由 SysRole 的 permissions 字段维护关系
* - 懒加载:避免查询权限时加载所有角色
*/
@ManyToMany(mappedBy = "permissions", fetch = FetchType.LAZY)
@ToString.Exclude
private Set<SysRole> roles = new HashSet<>();
/**
* 创建前自动设置时间
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 更新前自动设置时间
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,139 @@
package com.aisi.template.domain.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
/**
* 系统角色实体类
* 定义角色的基本信息和权限/菜单关系
*
* 主要字段:
* 1. 基本信息:角色编码、角色名称、描述
* 2. 排序和状态:排序字段、启用状态
* 3. 权限关系:多对多关联权限
* 4. 菜单关系:多对多关联菜单
*
* 角色说明:
* - 角色是权限和菜单的集合
* - 用户通过角色获得权限和菜单访问权限
* - 预定义角色SUPER_ADMIN超级管理员、ADMIN管理员、USER普通用户
*
* @author Claude
* @since 2024-04-09
*/
@Entity
@Table(name = "sys_role")
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class SysRole {
/**
* 角色ID主键
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Long id;
/**
* 角色编码(唯一,不可为空)
* 格式ROLE_ + 名称ROLE_USER, ROLE_ADMIN
*/
@Column(name = "role_code", nullable = false, unique = true, length = 50)
private String roleCode;
/**
* 角色名称(不可为空)
* 用于界面展示,如:普通用户、管理员
*/
@Column(name = "role_name", nullable = false, length = 100)
private String roleName;
/**
* 角色描述
*/
@Column(name = "description", length = 500)
private String description;
/**
* 排序字段
* 数值越小越靠前
*/
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
/**
* 角色状态
* - 1启用默认
* - 0禁用
*/
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 更新时间
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 角色拥有的权限列表(多对多关系)
* - 懒加载:避免查询角色时加载所有权限
* - JoinTable关联表 sys_role_permission
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "sys_role_permission",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
@ToString.Exclude
private Set<SysPermission> permissions = new HashSet<>();
/**
* 角色拥有的菜单列表(多对多关系)
* - 懒加载:避免查询角色时加载所有菜单
* - JoinTable关联表 sys_role_menu
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "sys_role_menu",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "menu_id")
)
@ToString.Exclude
private Set<SysMenu> menus = new HashSet<>();
/**
* 创建前自动设置时间
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 更新前自动设置时间
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -1,50 +1,129 @@
package com.aisi.template.domain.entity; package com.aisi.template.domain.entity;
import com.aisi.template.domain.enums.Role;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString; import lombok.ToString;
import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
/**
* 用户实体类
* 定义用户的基本信息和账户状态
*
* 主要字段:
* 1. 基本信息:用户名、密码、邮箱
* 2. 账户状态:启用/禁用、锁定状态
* 3. 安全信息:失败次数、锁定时间、密码修改时间
* 4. 角色关系:多对多关联角色
*
* 安全机制:
* - 密码使用 BCrypt 加密存储
* - 连续失败 5 次锁定账户 30 分钟
* - 支持永久锁定(禁用账户)
*
* @author Claude
* @since 2024-04-09
*/
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@Data @Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@EntityListeners(AuditingEntityListener.class) @EntityListeners(AuditingEntityListener.class)
public class User { public class User {
/**
* 用户ID主键
*/
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@EqualsAndHashCode.Include
private Long id; private Long id;
/**
* 用户名(唯一,不可为空)
*/
@Column(nullable = false, unique = true, length = 50) @Column(nullable = false, unique = true, length = 50)
private String username; private String username;
/**
* 密码BCrypt 加密,不可为空)
* 使用 @JsonIgnore 防止序列化时泄露密码
* 使用 @ToString.Exclude 防止 toString 时泄露密码
*/
@ToString.Exclude @ToString.Exclude
@JsonIgnore @JsonIgnore
@Column(nullable = false) @Column(nullable = false)
private String password; private String password;
/**
* 邮箱地址(唯一)
*/
@Column(unique = true) @Column(unique = true)
private String email; private String email;
/**
* 用户状态
* - 1启用默认
* - 0禁用永久锁定
*/
@Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'") @Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'")
private Integer status = 1; private Integer status = 1;
@Column(name = "role", nullable = false, length = 20, columnDefinition = "VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色USER=普通用户ADMIN=管理员'") /**
@Enumerated(EnumType.STRING) * 登录失败次数
private Role role = Role.USER; * 连续失败 5 次将锁定账户
*/
@Column(name = "failed_login_count", nullable = false)
private Integer failedLoginCount = 0;
/**
* 账户锁定到期时间
* - null未锁定
* - 有值:锁定到此时间
*/
@Column(name = "locked_until")
private LocalDateTime lockedUntil;
/**
* 密码最后修改时间
* 用于密码过期策略
*/
@Column(name = "password_changed_at")
private LocalDateTime passwordChangedAt;
/**
* 用户角色列表(多对多关系)
* - 懒加载:避免查询用户时加载所有角色
* - JoinTable关联表 sys_user_role
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "sys_user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@ToString.Exclude
private Set<SysRole> roles = new HashSet<>();
/**
* 创建时间(自动填充)
*/
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* 更新时间(自动填充)
*/
@LastModifiedDate @LastModifiedDate
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
@@ -52,8 +131,38 @@ public class User {
/** /**
* 检查用户是否启用 * 检查用户是否启用
*
* @return true 表示账户启用
*/ */
public boolean isEnabled() { public boolean isEnabled() {
return status != null && status == 1; return status != null && status == 1;
} }
/**
* 检查账户是否被锁定
* 说明:
* - 如果锁定时间为空,表示未锁定
* - 如果当前时间在锁定时间之前,表示仍然锁定
* - 锁定会自动过期(临时锁定)
*
* @return true 表示账户被锁定
*/
public boolean isLocked() {
if (lockedUntil == null) {
return false;
}
return LocalDateTime.now().isBefore(lockedUntil);
}
/**
* 检查账户是否被永久锁定(禁用)
* 说明:
* - status = 0 表示账户被管理员禁用
* - 不同于临时锁定,永久锁定需要管理员手动解除
*
* @return true 表示账户被永久锁定
*/
public boolean isPermanentlyLocked() {
return status != null && status == 0;
}
} }

View File

@@ -1,24 +0,0 @@
package com.aisi.template.domain.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 通用错误
PARAM_ERROR(400, "参数错误"),
SYSTEM_ERROR(500, "系统繁忙,请稍后再试"),
// 用户相关 (1000 - 1999)
USER_NOT_FOUND(1001, "用户不存在"),
PASSWORD_ERROR(1002, "密码错误"),
USER_LOCKED(1003, "账号已被锁定"),
// 业务相关 (2000 - 2999) - 预留给具体业务模块
;
private final int code;
private final String message;
}

View File

@@ -1,49 +0,0 @@
package com.aisi.template.domain.enums;
import lombok.Getter;
/**
* 用户角色枚举
*/
@Getter
public enum Role {
/**
* 普通用户
*/
USER("ROLE_USER", "普通用户"),
/**
* 管理员
*/
ADMIN("ROLE_ADMIN", "管理员");
private final String authority;
private final String description;
Role(String authority, String description) {
this.authority = authority;
this.description = description;
}
/**
* 从数据库字符串转换为枚举
*/
public static Role fromString(String role) {
if (role == null) {
return USER;
}
try {
return Role.valueOf(role.toUpperCase());
} catch (IllegalArgumentException e) {
return USER;
}
}
/**
* 转换为数据库存储格式
*/
public String toDbValue() {
return this.name();
}
}

View File

@@ -0,0 +1,94 @@
package com.aisi.template.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应视图对象
* 用于返回登录成功后的响应数据
*
* 主要字段:
* 1. Token 信息:访问令牌、刷新令牌、过期时间
* 2. 用户信息用户ID、用户名、邮箱
*
* 使用场景:
* - 用户登录成功后返回
* - 用户注册成功后返回(自动登录)
* - Token 刷新后返回
*
* @author Claude
* @since 2024-04-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "登录响应")
public class LoginResponseVo {
/**
* 访问令牌Access Token
* - 用于 API 访问认证
* - 放在请求头Authorization: Bearer {accessToken}
* - 默认有效期1 小时
*/
@Schema(description = "访问令牌")
private String accessToken;
/**
* 刷新令牌Refresh Token
* - 用于获取新的 Access Token
* - 当 Access Token 过期时使用
* - 默认有效期7 天
*/
@Schema(description = "刷新令牌")
private String refreshToken;
/**
* 访问令牌过期时间(秒)
* - 默认3600 秒1 小时)
* - 前端可根据此时间提前刷新 Token
*/
@Schema(description = "访问令牌过期时间(秒)")
private Long expiresIn;
/**
* 用户信息
* - 包含用户的基本信息
* - 前端可显示用户名等信息
*/
@Schema(description = "用户信息")
private UserInfoVo userInfo;
/**
* 用户信息视图对象
* 包含用户的基本信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "用户信息")
public static class UserInfoVo {
/**
* 用户ID
*/
@Schema(description = "用户ID")
private Long id;
/**
* 用户名
*/
@Schema(description = "用户名")
private String username;
/**
* 邮箱地址
*/
@Schema(description = "邮箱")
private String email;
}
}

View File

@@ -0,0 +1,228 @@
package com.aisi.template.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.List;
/**
* 菜单视图对象
* 用于返回菜单信息给前端
*
* 主要字段:
* 1. 基本信息菜单ID、父菜单ID、菜单名称、菜单类型
* 2. 菜单配置:路径、组件、图标、排序
* 3. 显示控制:可见性、启用状态
* 4. 权限控制:权限编码
* 5. 子菜单:子菜单列表(树形结构)
* 6. 时间信息:创建时间、更新时间
*
* @author Claude
* @since 2024-04-09
*/
public class MenuVo {
/**
* 菜单ID
*/
private Long id;
/**
* 父菜单ID
* - 0 或 null根菜单
* - 其他值:子菜单
*/
private Long parentId;
/**
* 菜单名称
*/
private String menuName;
/**
* 菜单类型
* - 1目录DIRECTORY
* - 2菜单MENU
* - 3按钮BUTTON
*/
private Integer menuType;
/**
* 菜单路径
* - 用于前端路由
*/
private String menuPath;
/**
* 组件路径
* - 前端组件文件路径
*/
private String component;
/**
* 菜单图标
* - 图标名称
*/
private String icon;
/**
* 排序字段
* - 数值越小越靠前
*/
private Integer sortOrder;
/**
* 可见性
* - 1可见
* - 0不可见
*/
private Integer visible;
/**
* 菜单状态
* - 1启用
* - 0禁用
*/
private Integer status;
/**
* 权限编码
* - 用于控制菜单的访问权限
*/
private String permissionCode;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt;
/**
* 子菜单列表
* - 用于构建菜单树形结构
* - 递归结构
*/
private List<MenuVo> children;
public MenuVo() {
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getParentId() {
return parentId;
}
public void setParentId(Long parentId) {
this.parentId = parentId;
}
public String getMenuName() {
return menuName;
}
public void setMenuName(String menuName) {
this.menuName = menuName;
}
public Integer getMenuType() {
return menuType;
}
public void setMenuType(Integer menuType) {
this.menuType = menuType;
}
public String getMenuPath() {
return menuPath;
}
public void setMenuPath(String menuPath) {
this.menuPath = menuPath;
}
public String getComponent() {
return component;
}
public void setComponent(String component) {
this.component = component;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getVisible() {
return visible;
}
public void setVisible(Integer visible) {
this.visible = visible;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public String getPermissionCode() {
return permissionCode;
}
public void setPermissionCode(String permissionCode) {
this.permissionCode = permissionCode;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public List<MenuVo> getChildren() {
return children;
}
public void setChildren(List<MenuVo> children) {
this.children = children;
}
}

View File

@@ -0,0 +1,154 @@
package com.aisi.template.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
/**
* 权限视图对象
* 用于返回权限信息给前端
*
* 主要字段:
* 1. 基本信息权限ID、权限编码、权限名称
* 2. 资源操作:资源名称、操作类型
* 3. 描述和状态:描述信息、启用状态
* 4. 时间信息:创建时间、更新时间
*
* @author Claude
* @since 2024-04-09
*/
public class PermissionVo {
/**
* 权限ID
*/
private Long id;
/**
* 权限编码
* - 格式:资源:操作
* - 示例user:read, role:write
*/
private String permissionCode;
/**
* 权限名称
* - 用于界面展示
* - 示例:查看用户、编辑角色
*/
private String permissionName;
/**
* 资源名称
* - 示例user, role, menu
*/
private String resource;
/**
* 操作类型
* - read读取查看
* - write写入创建/编辑)
* - delete删除
*/
private String action;
/**
* 权限描述
*/
private String description;
/**
* 权限状态
* - 1启用
* - 0禁用
*/
private Integer status;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt;
public PermissionVo() {
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPermissionCode() {
return permissionCode;
}
public void setPermissionCode(String permissionCode) {
this.permissionCode = permissionCode;
}
public String getPermissionName() {
return permissionName;
}
public void setPermissionName(String permissionName) {
this.permissionName = permissionName;
}
public String getResource() {
return resource;
}
public void setResource(String resource) {
this.resource = resource;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,152 @@
package com.aisi.template.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 角色视图对象
* 用于返回角色信息给前端
*
* 主要字段:
* 1. 基本信息角色ID、角色编码、角色名称、描述
* 2. 排序和状态:排序字段、启用状态
* 3. 权限信息:关联的权限列表
* 4. 时间信息:创建时间、更新时间
*
* @author Claude
* @since 2024-04-09
*/
public class RoleVo {
/**
* 角色ID
*/
private Long id;
/**
* 角色编码
* - 格式ROLE_ + 名称
* - 示例ROLE_USER, ROLE_ADMIN
*/
private String roleCode;
/**
* 角色名称
* - 用于界面展示
* - 示例:普通用户、管理员
*/
private String roleName;
/**
* 角色描述
*/
private String description;
/**
* 排序字段
* - 数值越小越靠前
*/
private Integer sortOrder;
/**
* 角色状态
* - 1启用
* - 0禁用
*/
private Integer status;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt;
/**
* 角色拥有的权限列表
*/
private Set<PermissionVo> permissions;
public RoleVo() {
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getRoleCode() {
return roleCode;
}
public void setRoleCode(String roleCode) {
this.roleCode = roleCode;
}
public String getRoleName() {
return roleName;
}
public void setRoleName(String roleName) {
this.roleName = roleName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Set<PermissionVo> getPermissions() {
return permissions;
}
public void setPermissions(Set<PermissionVo> permissions) {
this.permissions = permissions;
}
}

View File

@@ -5,30 +5,73 @@ import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
/**
* 用户视图对象
* 用于返回用户信息给前端
*
* 主要字段:
* 1. 基本信息用户ID、用户名、邮箱
* 2. 状态信息:启用/禁用状态
* 3. 角色信息:角色编码列表
* 4. 时间信息:创建时间、更新时间
*
* 注意:
* - 不包含密码等敏感信息
* - 角色只返回角色编码,不返回完整角色对象
*
* @author Claude
* @since 2024-04-09
*/
@Data @Data
@Schema(description = "用户视图对象") @Schema(description = "用户视图对象")
public class UserVo { public class UserVo {
/**
* 用户ID
*/
@Schema(description = "用户ID") @Schema(description = "用户ID")
private Long id; private Long id;
/**
* 用户名
*/
@Schema(description = "用户名") @Schema(description = "用户名")
private String username; private String username;
/**
* 邮箱地址
*/
@Schema(description = "邮箱") @Schema(description = "邮箱")
private String email; private String email;
/**
* 用户状态
* - 1正常启用
* - 0禁用
*/
@Schema(description = "状态1=正常 0=禁用)") @Schema(description = "状态1=正常 0=禁用)")
private Integer status; private Integer status;
@Schema(description = "角色USER=普通用户ADMIN=管理员)") /**
private String role; * 角色编码列表
* - 示例:["ROLE_USER", "ROLE_ADMIN"]
* - 只返回角色编码,不返回完整角色对象
*/
@Schema(description = "角色列表")
private Set<String> roles;
/**
* 创建时间
*/
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* 更新时间
*/
@Schema(description = "更新时间") @Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

View File

@@ -0,0 +1,536 @@
package com.aisi.template.examples;
import com.aisi.template.domain.entity.User;
import com.aisi.template.utils.RedisCache;
import com.aisi.template.utils.RedisLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Redis 使用示例
* 展示各种场景下的 Redis 使用方法
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisUsageExample {
private final RedisCache redisCache;
private final RedisLock redisLock;
// ==================== 场景 1: 对象缓存 ====================
/**
* 场景:缓存用户对象
* 步骤:
* 1. 先从缓存获取
* 2. 如果缓存不存在,从数据库查询
* 3. 将查询结果存入缓存
*
* @param userId 用户ID
* @return 用户对象
*/
public User getUserWithCache(Long userId) {
// 1. 定义缓存键
String cacheKey = "user:" + userId;
// 2. 先从缓存获取
User cachedUser = redisCache.get(cacheKey, User.class);
if (cachedUser != null) {
log.info("从缓存获取用户 - userId: {}", userId);
return cachedUser;
}
// 3. 缓存不存在,从数据库查询(模拟)
log.info("从数据库查询用户 - userId: {}", userId);
User user = getUserFromDatabase(userId);
// 4. 存入缓存30分钟过期
if (user != null) {
redisCache.set(cacheKey, user, 30, TimeUnit.MINUTES);
}
return user;
}
/**
* 场景:更新用户后删除缓存
* 步骤:
* 1. 更新数据库
* 2. 删除缓存(下次查询时重新加载)
*
* @param user 用户对象
*/
public void updateUser(User user) {
// 1. 更新数据库(模拟)
log.info("更新数据库 - userId: {}", user.getId());
updateUserInDatabase(user);
// 2. 删除缓存
String cacheKey = "user:" + user.getId();
redisCache.delete(cacheKey);
log.info("删除用户缓存 - userId: {}", user.getId());
}
// ==================== 场景 2: 计数器 ====================
/**
* 场景:文章阅读数计数
* 步骤:
* 1. 使用 Redis 原子自增
* 2. 异步批量更新到数据库
*
* @param articleId 文章ID
* @return 当前阅读数
*/
public long incrementArticleViewCount(Long articleId) {
// 1. 定义计数器键
String counterKey = "article:view:" + articleId;
// 2. 自增(原子操作)
long count = redisCache.increment(counterKey, 1);
// 3. 每100次更新一次数据库模拟
if (count % 100 == 0) {
log.info("同步阅读数到数据库 - articleId: {}, count: {}", articleId, count);
syncViewCountToDatabase(articleId, count);
}
return count;
}
/**
* 场景:点赞/取消点赞
* 步骤:
* 1. 使用 Set 存储点赞用户ID
* 2. 使用计数器记录点赞数
*
* @param articleId 文章ID
* @param userId 用户ID
* @param liked 是否点赞
*/
public void likeArticle(Long articleId, Long userId, boolean liked) {
// 1. 定义点赞集合键和计数器键
String likeSetKey = "article:like:" + articleId;
String likeCountKey = "article:like:count:" + articleId;
if (liked) {
// 2. 点赞:添加用户到集合,计数器+1
redisCache.sAdd(likeSetKey, userId);
redisCache.increment(likeCountKey, 1);
log.info("点赞成功 - articleId: {}, userId: {}", articleId, userId);
} else {
// 3. 取消点赞:从集合移除用户,计数器-1
redisCache.sRemove(likeSetKey, userId);
redisCache.decrement(likeCountKey, 1);
log.info("取消点赞 - articleId: {}, userId: {}", articleId, userId);
}
}
/**
* 场景:检查是否已点赞
*
* @param articleId 文章ID
* @param userId 用户ID
* @return 是否已点赞
*/
public boolean isArticleLiked(Long articleId, Long userId) {
String likeSetKey = "article:like:" + articleId;
return redisCache.sIsMember(likeSetKey, userId);
}
// ==================== 场景 3: 分布式锁 ====================
/**
* 场景:防止重复提交
* 步骤:
* 1. 尝试获取锁
* 2. 如果获取成功,执行业务逻辑
* 3. 最后释放锁
*
* @param orderId 订单ID
*/
public void processOrder(Long orderId) {
// 1. 定义锁键
String lockKey = "order:process:" + orderId;
// 2. 尝试获取锁30秒过期
String lockValue = redisLock.tryLock(lockKey, 30);
if (lockValue == null) {
log.warn("订单正在处理中,请勿重复提交 - orderId: {}", orderId);
throw new RuntimeException("订单正在处理中");
}
try {
// 3. 执行业务逻辑
log.info("处理订单 - orderId: {}", orderId);
processOrderBusiness(orderId);
} finally {
// 4. 释放锁
redisLock.unlock(lockKey, lockValue);
log.info("释放订单锁 - orderId: {}", orderId);
}
}
/**
* 场景:库存扣减(防止超卖)
* 步骤:
* 1. 获取分布式锁
* 2. 检查库存
* 3. 扣减库存
* 4. 释放锁
*
* @param productId 商品ID
* @param quantity 数量
* @return 是否扣减成功
*/
public boolean deductStock(Long productId, int quantity) {
// 1. 定义锁键
String lockKey = "product:stock:" + productId;
// 2. 尝试获取锁10秒过期等待5秒
String lockValue = redisLock.tryLock(lockKey, 10, 5000);
if (lockValue == null) {
log.warn("获取库存锁失败,请稍后重试 - productId: {}", productId);
return false;
}
try {
// 3. 获取当前库存
String stockKey = "product:stock:" + productId;
Object stockObj = redisCache.get(stockKey);
int currentStock = stockObj != null ? (Integer) stockObj : getStockFromDatabase(productId);
// 4. 检查库存是否充足
if (currentStock < quantity) {
log.warn("库存不足 - productId: {}, current: {}, required: {}",
productId, currentStock, quantity);
return false;
}
// 5. 扣减库存
int newStock = currentStock - quantity;
redisCache.set(stockKey, newStock, 1, TimeUnit.HOURS);
// 6. 异步更新数据库
updateStockInDatabase(productId, newStock);
log.info("库存扣减成功 - productId: {}, old: {}, new: {}",
productId, currentStock, newStock);
return true;
} finally {
// 7. 释放锁
redisLock.unlock(lockKey, lockValue);
}
}
// ==================== 场景 4: 排行榜 ====================
/**
* 场景:添加积分到排行榜
* 步骤:
* 1. 使用 ZSet 存储用户积分
* 2. 自动按分数排序
*
* @param userId 用户ID
* @param score 积分
*/
public void addUserScore(Long userId, double score) {
// 1. 定义排行榜键
String rankKey = "leaderboard:user:score";
// 2. 添加到有序集合
// 如果用户已存在,分数会累加
redisCache.zIncrementScore(rankKey, userId, score);
log.info("用户积分更新 - userId: {}, score: {}", userId, score);
}
/**
* 场景:获取排行榜 TOP N
* 步骤:
* 1. 获取分数最高的前 N 个用户
* 2. 返回倒序结果
*
* @param topN 前N名
* @return 用户ID集合按分数倒序
*/
public Set<Object> getTopUsers(int topN) {
// 1. 定义排行榜键
String rankKey = "leaderboard:user:score";
// 2. 获取倒序排名前 N 的用户
Set<Object> topUsers = redisCache.zRangeByScore(rankKey, Double.MIN_VALUE, Double.MAX_VALUE);
// 3. 由于 Redis 返回的是无序集合,这里简化处理
// 实际应该使用 zReverseRange 按排名范围获取
log.info("获取排行榜 TOP {}", topN);
return topUsers;
}
/**
* 场景:获取用户排名
*
* @param userId 用户ID
* @return 排名从0开始
*/
public Long getUserRank(Long userId) {
String rankKey = "leaderboard:user:score";
Long rank = redisCache.zReverseRank(rankKey, userId);
return rank != null ? rank + 1 : null; // 转换为从1开始
}
// ==================== 场景 5: 消息队列 ====================
/**
* 场景:发送消息到队列
* 步骤:
* 1. 使用 List 作为队列
* 2. 从右侧推入消息
*
* @param queueName 队列名称
* @param message 消息内容
*/
public void sendMessage(String queueName, String message) {
String queueKey = "queue:" + queueName;
redisCache.lRightPush(queueKey, message);
log.info("发送消息到队列 - queue: {}, message: {}", queueName, message);
}
/**
* 场景:从队列消费消息
* 步骤:
* 1. 从左侧弹出消息
* 2. 处理消息
*
* @param queueName 队列名称
* @return 消息内容
*/
public String consumeMessage(String queueName) {
String queueKey = "queue:" + queueName;
Object message = redisCache.lLeftPop(queueKey);
if (message != null) {
log.info("从队列消费消息 - queue: {}, message: {}", queueName, message);
return message.toString();
}
return null;
}
// ==================== 场景 6: 共同好友 ====================
/**
* 场景:计算两个用户的共同好友
* 步骤:
* 1. 使用 Set 存储每个用户的好友列表
* 2. 计算交集
*
* @param userId1 用户1 ID
* @param userId2 用户2 ID
* @return 共同好友ID集合
*/
public Set<Object> getCommonFriends(Long userId1, Long userId2) {
// 1. 定义好友集合键
String friendsKey1 = "user:friends:" + userId1;
String friendsKey2 = "user:friends:" + userId2;
// 2. 计算交集
Set<Object> commonFriends = redisCache.sIntersect(friendsKey1, friendsKey2);
log.info("共同好友 - userId1: {}, userId2: {}, count: {}",
userId1, userId2, commonFriends.size());
return commonFriends;
}
/**
* 场景:添加好友
*
* @param userId 用户ID
* @param friendId 好友ID
*/
public void addFriend(Long userId, Long friendId) {
String friendsKey = "user:friends:" + userId;
redisCache.sAdd(friendsKey, friendId);
log.info("添加好友 - userId: {}, friendId: {}", userId, friendId);
}
// ==================== 场景 7: 用户标签 ====================
/**
* 场景:添加用户标签
* 步骤:
* 1. 使用 Set 存储用户标签
* 2. 自动去重
*
* @param userId 用户ID
* @param tags 标签列表
*/
public void addUserTags(Long userId, String... tags) {
String tagsKey = "user:tags:" + userId;
redisCache.sAdd(tagsKey, (Object[]) tags);
log.info("添加用户标签 - userId: {}, tags: {}", userId, List.of(tags));
}
/**
* 场景:获取用户标签
*
* @param userId 用户ID
* @return 标签集合
*/
public Set<Object> getUserTags(Long userId) {
String tagsKey = "user:tags:" + userId;
return redisCache.sMembers(tagsKey);
}
// ==================== 场景 8: 用户Session ====================
/**
* 场景:存储用户会话信息
* 步骤:
* 1. 使用 Hash 存储会话字段
* 2. 设置过期时间
*
* @param userId 用户ID
* @param sessionId 会话ID
* @param loginIp 登录IP
*/
public void storeUserSession(Long userId, String sessionId, String loginIp) {
String sessionKey = "session:user:" + userId;
// 1. 存储会话信息
redisCache.hSet(sessionKey, "sessionId", sessionId);
redisCache.hSet(sessionKey, "loginIp", loginIp);
redisCache.hSet(sessionKey, "loginTime", System.currentTimeMillis());
// 2. 设置过期时间24小时
redisCache.expire(sessionKey, 24, TimeUnit.HOURS);
log.info("存储用户会话 - userId: {}, sessionId: {}", userId, sessionId);
}
/**
* 场景:获取用户会话信息
*
* @param userId 用户ID
* @return 会话信息
*/
public Map<Object, Object> getUserSession(Long userId) {
String sessionKey = "session:user:" + userId;
return redisCache.hGetAll(sessionKey);
}
// ==================== 场景 9: 热点数据缓存 ====================
/**
* 场景:缓存热点数据(如首页推荐)
* 步骤:
* 1. 从缓存获取
* 2. 如果不存在,加载数据
* 3. 设置较短的过期时间
*
* @return 推荐内容列表
*/
public List<String> getHotContent() {
String cacheKey = "hot:content:home";
// 1. 从缓存获取
Object cached = redisCache.get(cacheKey);
if (cached != null) {
return (List<String>) cached;
}
// 2. 加载热点数据(模拟)
List<String> hotContent = loadHotContentFromDatabase();
// 3. 存入缓存5分钟过期热点数据更新快
redisCache.set(cacheKey, hotContent, 5, TimeUnit.MINUTES);
return hotContent;
}
// ==================== 场景 10: 限流(基于计数器)====================
/**
* 场景API 限流
* 步骤:
* 1. 使用计数器记录请求次数
* 2. 设置过期时间
* 3. 超过限制则拒绝
*
* @param userId 用户ID
* @param limit 限制次数
* @param windowSeconds 时间窗口(秒)
* @return 是否允许请求
*/
public boolean checkRateLimit(Long userId, int limit, int windowSeconds) {
String limitKey = "ratelimit:user:" + userId;
// 1. 获取当前计数
Object countObj = redisCache.get(limitKey);
int currentCount = countObj != null ? (Integer) countObj : 0;
// 2. 检查是否超过限制
if (currentCount >= limit) {
log.warn("用户请求超限 - userId: {}, count: {}, limit: {}",
userId, currentCount, limit);
return false;
}
// 3. 增加计数
if (currentCount == 0) {
// 第一次请求,设置过期时间
redisCache.set(limitKey, 1, windowSeconds, TimeUnit.SECONDS);
} else {
// 非第一次,直接自增
redisCache.increment(limitKey, 1);
}
return true;
}
// ==================== 以下是模拟的辅助方法 ====================
private User getUserFromDatabase(Long userId) {
// 模拟数据库查询
User user = new User();
user.setId(userId);
user.setUsername("user" + userId);
return user;
}
private void updateUserInDatabase(User user) {
// 模拟数据库更新
}
private void syncViewCountToDatabase(Long articleId, long count) {
// 模拟同步到数据库
}
private void processOrderBusiness(Long orderId) {
// 模拟订单处理
}
private int getStockFromDatabase(Long productId) {
// 模拟获取库存
return 1000;
}
private void updateStockInDatabase(Long productId, int stock) {
// 模拟更新库存
}
private List<String> loadHotContentFromDatabase() {
// 模拟加载热点数据
return List.of("content1", "content2", "content3");
}
}

View File

@@ -0,0 +1,540 @@
package com.aisi.template.examples;
import com.aisi.template.domain.entity.SysRole;
import com.aisi.template.domain.entity.User;
import com.aisi.template.repository.SysRoleRepository;
import com.aisi.template.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Set;
/**
* 事务使用示例
* 展示 Spring 事务的各种传播行为和使用场景
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TransactionExample {
private final UserRepository userRepository;
private final SysRoleRepository roleRepository;
// ==================== 场景 1: 基本事务使用 ====================
/**
* 场景:创建用户并分配角色
* 步骤:
* 1. 开启事务
* 2. 创建用户
* 3. 分配角色
* 4. 提交事务
* 注意:任何步骤失败都会回滚整个事务
*
* @param username 用户名
* @param password 密码
* @param roleIds 角色ID集合
* @return 创建的用户ID
*/
@Transactional(rollbackFor = Exception.class)
public Long createUserWithRoles(String username, String password, Set<Long> roleIds) {
log.info("开始创建用户并分配角色 - username: {}", username);
try {
// 1. 创建用户
User user = new User();
user.setUsername(username);
user.setPassword(password);
User savedUser = userRepository.save(user);
log.info("用户创建成功 - userId: {}", savedUser.getId());
// 2. 分配角色
for (Long roleId : roleIds) {
SysRole role = roleRepository.findById(roleId)
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
user.getRoles().add(role);
}
userRepository.save(user);
log.info("角色分配成功 - userId: {}, roleIds: {}", savedUser.getId(), roleIds);
// 3. 事务提交(方法正常结束时自动提交)
return savedUser.getId();
} catch (Exception e) {
// 4. 发生异常,事务回滚
log.error("创建用户失败,事务回滚 - username: {}", username, e);
throw e;
}
}
// ==================== 场景 2: REQUIRED 传播行为 ====================
/**
* 场景REQUIRED 传播行为(默认)
* 说明:
* - 如果当前没有事务,就新建一个事务
* - 如果当前已经存在一个事务,就加入到这个事务中
* 使用场景:大多数业务场景
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void requiredExample(Long userId) {
log.info("REQUIRED 传播行为示例 - userId: {}", userId);
// 业务逻辑
}
/**
* 场景:嵌套事务示例
* 步骤:
* 1. 外层方法创建事务
* 2. 调用内层方法(加入外层事务)
* 3. 内外层任一异常,整体回滚
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void nestedTransactionExample(Long userId) {
log.info("外层事务开始 - userId: {}", userId);
// 1. 执行业务逻辑
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("new@example.com");
userRepository.save(user);
// 2. 调用内层方法(会加入当前事务)
innerMethod(userId);
// 3. 外层事务提交
log.info("外层事务提交 - userId: {}", userId);
}
/**
* 内层方法(加入外层事务)
*/
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void innerMethod(Long userId) {
log.info("内层方法执行 - userId: {}", userId);
User user = userRepository.findById(userId).orElseThrow();
user.setUsername("updated_username");
userRepository.save(user);
}
// ==================== 场景 3: REQUIRES_NEW 传播行为 ====================
/**
* 场景REQUIRES_NEW 传播行为
* 说明:
* - 无论当前是否有事务,都会创建新事务
* - 如果当前存在事务,将当前事务挂起
* 使用场景:独立记录日志,不受主事务影响
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void requiresNewExample(Long userId) {
log.info("REQUIRES_NEW 传播行为示例 - userId: {}", userId);
// 独立事务,即使外层事务回滚也不影响
}
/**
* 场景:主事务 + 独立事务
* 步骤:
* 1. 主事务执行业务逻辑
* 2. 独立事务记录日志
* 3. 主事务失败不影响日志记录
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void mainTransactionWithLog(Long userId) {
log.info("主事务开始 - userId: {}", userId);
try {
// 1. 执行业务逻辑
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("main@example.com");
userRepository.save(user);
// 2. 记录操作日志(独立事务)
recordOperationLog(userId, "更新用户邮箱");
// 3. 模拟异常
// throw new RuntimeException("模拟异常");
} catch (Exception e) {
log.error("主事务异常", e);
throw e;
}
}
/**
* 记录操作日志(独立事务)
* 即使主事务回滚,日志也会被记录
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void recordOperationLog(Long userId, String operation) {
log.info("记录操作日志(独立事务)- userId: {}, operation: {}", userId, operation);
// 日志记录逻辑
}
// ==================== 场景 4: NESTED 传播行为 ====================
/**
* 场景NESTED 传播行为
* 说明:
* - 如果当前存在事务,则嵌套在该事务中执行
* - 嵌套事务是外层事务的一部分,可以独立提交
* - 外层事务失败时,嵌套事务也会回滚
* 使用场景:批量操作中部分可独立回滚
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void nestedExample(Long userId) {
log.info("NESTED 传播行为示例 - userId: {}", userId);
}
/**
* 场景:嵌套事务使用
* 步骤:
* 1. 外层事务更新用户基本信息
* 2. 嵌套事务更新扩展信息
* 3. 嵌套事务可以独立回滚savepoint
*
* @param userId 用户ID
*/
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void nestedTransactionUsage(Long userId) {
log.info("外层事务开始 - userId: {}", userId);
// 1. 更新基本信息
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("basic@example.com");
userRepository.save(user);
try {
// 2. 嵌套事务更新扩展信息(创建 savepoint
updateExtendedInfo(userId);
} catch (Exception e) {
// 3. 嵌套事务异常不影响外层事务
log.warn("扩展信息更新失败,继续执行", e);
}
log.info("外层事务继续 - userId: {}", userId);
}
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void updateExtendedInfo(Long userId) {
log.info("嵌套事务执行 - userId: {}", userId);
// 扩展信息更新
}
// ==================== 场景 5: 事务回滚 ====================
/**
* 场景:异常时回滚事务
* 步骤:
* 1. 执行多个数据库操作
* 2. 发生异常时自动回滚
* 3. rollbackFor 指定哪些异常触发回滚
*
* @param userId 用户ID
*/
@Transactional(rollbackFor = {RuntimeException.class, Exception.class})
public void rollbackExample(Long userId) {
log.info("事务回滚示例 - userId: {}", userId);
// 1. 执行第一个操作
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("rollback@example.com");
userRepository.save(user);
// 2. 模拟异常,触发回滚
if (true) {
throw new RuntimeException("模拟异常,触发事务回滚");
}
// 3. 这行代码不会执行
user.setUsername("never_reached");
}
/**
* 场景:指定回滚异常
* 说明:
* - 默认只对 RuntimeException 和 Error 回滚
* - 需要对 checked exception 回滚时,指定 rollbackFor
*
* @param userId 用户ID
* @throws Exception 业务异常
*/
@Transactional(rollbackFor = Exception.class)
public void rollbackForCheckedException(Long userId) throws Exception {
log.info("指定回滚异常示例 - userId: {}", userId);
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("checked@example.com");
userRepository.save(user);
// 抛出 checked exception 也会回滚
throw new Exception("业务异常");
}
/**
* 场景:排除回滚异常
* 说明:
* - 某些异常不触发回滚
* - 使用 noRollbackFor 指定
*
* @param userId 用户ID
*/
@Transactional(noRollbackFor = {BusinessException.class}, rollbackFor = Exception.class)
public void noRollbackForExample(Long userId) {
log.info("排除回滚异常示例 - userId: {}", userId);
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("no_rollback@example.com");
userRepository.save(user);
// BusinessException 不会触发回滚
throw new BusinessException("业务异常,但不回滚");
}
// ==================== 场景 6: 只读事务 ====================
/**
* 场景:只读事务
* 说明:
* - 设置事务为只读,优化查询性能
* - 数据库可以进行查询优化
* 使用场景:查询方法、报表统计
*
* @param userId 用户ID
* @return 用户对象
*/
@Transactional(readOnly = true)
public User readOnlyExample(Long userId) {
log.info("只读事务示例 - userId: {}", userId);
return userRepository.findById(userId).orElseThrow();
}
/**
* 场景:批量查询(只读事务)
* 步骤:
* 1. 开启只读事务
* 2. 执行多个查询
* 3. 关闭事务
*
* @param userId 用户ID
* @return 用户信息
*/
@Transactional(readOnly = true)
public UserInfoDTO getUserInfo(Long userId) {
log.info("批量查询示例 - userId: {}", userId);
// 1. 查询基本信息
User user = userRepository.findById(userId).orElseThrow();
// 2. 查询角色信息
Set<SysRole> roles = user.getRoles();
// 3. 组装返回
UserInfoDTO dto = new UserInfoDTO();
dto.setUserId(user.getId());
dto.setUsername(user.getUsername());
dto.setRoleCount(roles.size());
return dto;
}
// ==================== 场景 7: 事务超时 ====================
/**
* 场景:设置事务超时
* 说明:
* - 超过指定时间后自动回滚
* - 单位:秒
* 使用场景:长时间运行的事务
*
* @param userId 用户ID
*/
@Transactional(timeout = 30, rollbackFor = Exception.class)
public void timeoutExample(Long userId) throws InterruptedException {
log.info("事务超时示例 - userId: {}", userId);
// 模拟长时间操作
Thread.sleep(1000);
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("timeout@example.com");
userRepository.save(user);
}
// ==================== 场景 8: 编程式事务 ====================
/**
* 场景:编程式事务(手动控制事务)
* 步骤:
* 1. 手动开启事务
* 2. 执行业务逻辑
* 3. 手动提交或回滚
* 使用场景:复杂的事务控制逻辑
*
* @param userId 用户ID
*/
public void programmaticTransaction(Long userId) {
log.info("编程式事务示例 - userId: {}", userId);
// 通过 TransactionTemplate 手动控制事务
// 实际使用时注入 TransactionTemplate
// transactionTemplate.execute(status -> {
// try {
// User user = userRepository.findById(userId).orElseThrow();
// user.setEmail("programmatic@example.com");
// userRepository.save(user);
// return true;
// } catch (Exception e) {
// status.setRollbackOnly();
// throw e;
// }
// });
}
// ==================== 场景 9: 事务后置处理 ====================
/**
* 场景:事务提交后执行操作
* 步骤:
* 1. 执行业务逻辑
* 2. 注册事务同步回调
* 3. 事务提交后自动执行回调
* 使用场景:发送消息、清除缓存、发布事件
*
* @param userId 用户ID
*/
@Transactional(rollbackFor = Exception.class)
public void afterCommitExample(Long userId) {
log.info("事务后置处理示例 - userId: {}", userId);
// 1. 更新数据库
User user = userRepository.findById(userId).orElseThrow();
user.setEmail("after_commit@example.com");
userRepository.save(user);
// 2. 注册事务提交后回调
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 3. 事务提交后执行(如发送消息)
log.info("事务已提交,发送通知 - userId: {}", userId);
sendMessage(userId);
}
@Override
public void afterCompletion(int status) {
// 4. 事务完成后执行(无论成功或失败)
log.info("事务已完成 - userId: {}, status: {}", userId, status);
}
});
}
/**
* 场景:事务回滚后执行操作
*
* @param userId 用户ID
*/
@Transactional(rollbackFor = Exception.class)
public void afterRollbackExample(Long userId) {
log.info("事务回滚后处理示例 - userId: {}", userId);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (STATUS_ROLLED_BACK == status) {
// 事务回滚后执行
log.error("事务已回滚 - userId: {}", userId);
handleRollback(userId);
}
}
});
// 模拟异常
throw new RuntimeException("模拟异常");
}
// ==================== 场景 10: 隔离级别 ====================
/**
* 场景:设置事务隔离级别
* 说明:
* - READ_UNCOMMITTED: 读未提交(可能出现脏读)
* - READ_COMMITTED: 读已提交(防止脏读,可能出现不可重复读)
* - REPEATABLE_READ: 可重复读(防止脏读和不可重复读,可能出现幻读)
* - SERIALIZABLE: 串行化(最高隔离级别,性能最差)
* 使用场景:根据业务需求选择合适的隔离级别
*
* @param userId 用户ID
*/
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED,
rollbackFor = Exception.class)
public void isolationLevelExample(Long userId) {
log.info("隔离级别示例 - userId: {}", userId);
// 使用 READ_COMMITTED 隔离级别
}
// ==================== 辅助类和方法 ====================
/**
* 用户信息 DTO
*/
public static class UserInfoDTO {
private Long userId;
private String username;
private Integer roleCount;
public void setUserId(Long userId) {
this.userId = userId;
}
public void setUsername(String username) {
this.username = username;
}
public void setRoleCount(Integer roleCount) {
this.roleCount = roleCount;
}
}
/**
* 业务异常
*/
public static class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
/**
* 发送消息(模拟)
*/
private void sendMessage(Long userId) {
log.info("发送消息 - userId: {}", userId);
}
/**
* 处理回滚(模拟)
*/
private void handleRollback(Long userId) {
log.info("处理回滚 - userId: {}", userId);
}
}

View File

@@ -1,21 +1,51 @@
package com.aisi.template.exception; package com.aisi.template.exception;
import com.aisi.template.domain.enums.ErrorCode;
import com.aisi.template.domain.RestCode;
import lombok.Getter; import lombok.Getter;
/** /**
* 自定义业务异常 * 自定义业务异常
* 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息 * 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息
*
* 使用场景:
* - 业务逻辑校验失败
* - 资源不存在
* - 权限不足
* - 参数错误
*
* 使用示例:
* <pre>
* // 指定错误码和消息
* throw new BusinessException(404, "用户不存在");
*
* // 使用通用错误码(默认 400
* throw new BusinessException("操作失败");
*
* // 使用错误码枚举
* throw new BusinessException(RestCode.USER_NOT_FOUND);
* </pre>
*
* @author Claude
* @since 2024-04-09
*/ */
@Getter // 使用 Lombok 自动生成 getCode() 方法 @Getter // 使用 Lombok 自动生成 getCode() 方法
public class BusinessException extends RuntimeException { public class BusinessException extends RuntimeException {
// 错误码 (例如 400, 403, 1001 等) /**
* 错误码
* - 例如400, 403, 1001 等
*/
private final int code; private final int code;
/** /**
* 构造方法 1手动指定 code 和 message * 构造方法 1手动指定 code 和 message
* 使用:throw new BusinessException(404, "找不到该新闻"); * 使用示例
* <pre>
* throw new BusinessException(404, "找不到该新闻");
* </pre>
*
* @param code 错误码
* @param message 错误消息
*/ */
public BusinessException(int code, String message) { public BusinessException(int code, String message) {
super(message); // 把 message 传给父类,方便 log 打印 super(message); // 把 message 传给父类,方便 log 打印
@@ -23,17 +53,30 @@ public class BusinessException extends RuntimeException {
} }
/** /**
* 构造方法 2使用通用错误码 (默认为 400 或 500) * 构造方法 2使用通用错误码
* 使用:throw new BusinessException("操作失败"); * 使用示例
* <pre>
* throw new BusinessException("操作失败");
* </pre>
*
* @param message 错误消息
*/ */
public BusinessException(String message) { public BusinessException(String message) {
super(message); super(message);
this.code = 400; // 默认给个 400 this.code = 400; // 默认给个 400(客户端错误)
} }
// 在 BusinessException 类里添加这个构造方法 /**
public BusinessException(ErrorCode errorCode) { * 构造方法 3使用错误码枚举
super(errorCode.getMessage()); * 使用示例:
this.code = errorCode.getCode(); * <pre>
* throw new BusinessException(ErrorCode.USER_NOT_FOUND);
* </pre>
*
* @param restCode 错误码枚举
*/
public BusinessException(RestCode restCode) {
super(restCode.getMessage());
this.code = restCode.getCode();
} }
} }

View File

@@ -0,0 +1,35 @@
package com.aisi.template.exception;
/**
* 限流超出异常
* 当请求频率超过限制时抛出此异常
*
* 使用场景:
* - 登录接口:防止暴力破解
* - API 接口:防止恶意刷接口
* - 抢购活动:防止刷单
*
* HTTP 状态码429 Too Many Requests
*
* @author Claude
* @since 2024-04-09
*/
public class RateLimitExceededException extends RuntimeException {
/**
* 构造方法:自定义错误消息
*
* @param message 错误消息
*/
public RateLimitExceededException(String message) {
super(message);
}
/**
* 构造方法:默认错误消息
* 错误消息:"请求过于频繁,请稍后再试"
*/
public RateLimitExceededException() {
super("请求过于频繁,请稍后再试");
}
}

View File

@@ -1,7 +1,7 @@
package com.aisi.template.filter; package com.aisi.template.filter;
import com.aisi.template.domain.CustomUserDetails; import com.aisi.template.domain.CustomUserDetails;
import com.aisi.template.service.TokenService;
import com.aisi.template.service.impl.CustomUserDetailsService; import com.aisi.template.service.impl.CustomUserDetailsService;
import com.aisi.template.utils.JwtUtil; import com.aisi.template.utils.JwtUtil;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
@@ -18,50 +18,133 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
/**
* JWT 认证过滤器
* 拦截请求并验证 JWT Token
*
* 主要功能:
* 1. Token 提取:从请求头中提取 JWT Token
* 2. Token 验证:验证 Token 签名和有效性
* 3. 黑名单检查:检查 Token 是否在黑名单中
* 4. 用户认证:设置 Spring Security 认证信息
*
* 工作流程:
* 1. 从 Authorization 请求头中提取 Token
* 2. 验证 Token 签名
* 3. 检查 Token 是否在黑名单中
* 4. 检查 Token 是否过期
* 5. 提取用户信息并设置认证
*
* @author Claude
* @since 2024-04-09
*/
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* JWT 工具类
* 用于生成和验证 JWT Token
*/
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
/**
* 用户详情服务
* 用于加载用户信息
*/
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
/**
* Token 服务
* 用于检查 Token 黑名单
*/
private final TokenService tokenService;
/**
* 过滤器内部处理方法
* 步骤:
* 1. 从请求头中提取 Token
* 2. 验证 Token 签名
* 3. 检查 Token 是否在黑名单中
* 4. 检查 Token 是否过期
* 5. 提取用户信息并设置认证
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 过滤器链
*/
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request); // 1. 从请求头中提取 Token
if (token != null && jwtUtil.validateToken(token)) { String token = extractToken(request);
// token 有效
if (token != null && jwtUtil.validateToken(token)) {
// 2. 验证 Token 签名通过,检查是否在黑名单中
String jti = jwtUtil.extractJti(token);
if (jti != null && tokenService.isTokenBlacklisted(jti)) {
// 2.1 Token 已被撤销(在黑名单中),清除认证
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
return;
}
// 3. 检查 Token 是否过期
if (jwtUtil.isTokenExpired(token)) {
// 3.1 Token 已过期,清除认证
SecurityContextHolder.clearContext();
filterChain.doFilter(request, response);
return;
}
// 4. Token 有效,提取用户信息并设置认证
String username = jwtUtil.extractUsername(token); String username = jwtUtil.extractUsername(token);
if (username != null && !username.isEmpty()) { if (username != null && !username.isEmpty()) {
// 4.1 加载用户详情
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username); CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
if (userDetails == null) throw new UsernameNotFoundException("User not found"); if (userDetails == null) throw new UsernameNotFoundException("用户不存在");
// 4.2 创建认证对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities() userDetails, null, userDetails.getAuthorities()
); );
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 4.3 设置认证信息到 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
} }
} else { } else {
// token 缺失或无效,不抛异常,直接放行到下一个过滤器 // 5. Token 缺失或无效,不抛异常,继续处理
} // 原因:有些接口不需要认证(如登录、注册)
}
// 6. 继续过滤器链
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
/** /**
* 提取出token * 从 Authorization 请求头中提取 Token
* 步骤:
* 1. 获取 Authorization 请求头
* 2. 检查是否为空
* 3. 检查是否以 "Bearer " 开头
* 4. 提取 Token 部分(去掉 "Bearer " 前缀)
* *
* @return token * @param request HTTP 请求
* @return JWT Token如果无效则返回 null
*/ */
private String extractToken(HttpServletRequest request) { private String extractToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization"); // 1. 获取 Authorization 请求头
if (authorization == null) { String authorization = request.getHeader("Authorization");
return null; if (authorization == null) {
} return null;
if (!authorization.startsWith("Bearer ")) { }
return null;
} // 2. 检查是否以 "Bearer " 开头
return authorization.substring(7); if (!authorization.startsWith("Bearer ")) {
} return null;
}
// 3. 提取 Token 部分(去掉 "Bearer " 前缀,共 7 个字符)
return authorization.substring(7);
}
} }

View File

@@ -2,6 +2,7 @@ package com.aisi.template.handler;
import com.aisi.template.domain.RestBean; import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.RestCode; import com.aisi.template.domain.RestCode;
import com.aisi.template.exception.RateLimitExceededException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.authorization.AuthorizationDeniedException;
@@ -21,124 +22,194 @@ import java.util.stream.Collectors;
/** /**
* 全局异常处理器 * 全局异常处理器
* 统一处理系统中的各种异常,返回标准的错误响应格式
*
* 主要功能:
* 1. 客户端请求错误400参数校验失败、JSON格式错误、缺少参数等
* 2. 业务逻辑错误:自定义业务异常、数据冲突等
* 3. 系统错误500空指针、未预期的异常等
*
* 异常分类:
* - 客户端错误:记录 WARN 日志,不打印堆栈
* - 业务异常:记录 WARN 日志,不打印堆栈
* - 系统错误:记录 ERROR 日志,打印堆栈
*
* @author Claude
* @since 2024-04-09
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
// ==========================================
// 1. 客户端请求错误 (400 Bad Request 等) // ==================== 1. 客户端请求错误 (400 Bad Request 等) ====================
// 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈 // 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈
// ==========================================
/** /**
* 1. 参数校验失败异常 (@Valid / @Validated) * 处理参数校验失败异常
* 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求 * 场景:
* - 前端传的 JSON 缺字段
* - 字段不符合 @NotNull, @Size 等注解要求
* - 使用 @Valid 或 @Validated 触发
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public RestBean<String> handleValidationException(Exception e) { public RestBean<String> handleValidationException(Exception e) {
// 1. 获取绑定结果
BindingResult bindingResult = null; BindingResult bindingResult = null;
if (e instanceof MethodArgumentNotValidException) { if (e instanceof MethodArgumentNotValidException) {
bindingResult = ((MethodArgumentNotValidException) e).getBindingResult(); bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
} else if (e instanceof BindException) { } else if (e instanceof BindException) {
bindingResult = ((BindException) e).getBindingResult(); bindingResult = ((BindException) e).getBindingResult();
} }
// 提取具体的错误信息(例如:"email: 邮箱格式不正确" // 2. 提取具体的错误信息(例如:"email: 邮箱格式不正确"
String msg = "参数校验失败"; String msg = "参数校验失败";
if (bindingResult != null) { if (bindingResult != null) {
msg = bindingResult.getFieldErrors().stream() msg = bindingResult.getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage()) .map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
} }
log.warn("参数校验未通过: {}", msg); log.warn("参数校验未通过: {}", msg);
return RestBean.failure(RestCode.FAILURE, msg); return RestBean.failure(RestCode.FAILURE.getCode(), msg);
} }
/** /**
* 2. JSON 格式解析错误 * 处理 JSON 格式解析错误
* 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段 * 场景:
* - 前端传的 JSON 少了括号
* - 把 String 传给了 Integer 类型的字段
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(HttpMessageNotReadableException.class) @ExceptionHandler(HttpMessageNotReadableException.class)
public RestBean<String> handleJsonParseException(HttpMessageNotReadableException e) { public RestBean<String> handleJsonParseException(HttpMessageNotReadableException e) {
log.warn("JSON解析失败: {}", e.getMessage()); log.warn("JSON 解析失败: {}", e.getMessage());
return RestBean.failure(RestCode.FAILURE, "请求Body格式错误请检查JSON语法"); return RestBean.failure(RestCode.FAILURE.getCode(), "请求 Body 格式错误,请检查 JSON 语法");
} }
/** /**
* 3. 缺少必要的 URL 参数 * 处理缺少必要的 URL 参数异常
* 场景:接口定义了 @RequestParam(required=true) 但前端没传 * 场景:
* - 接口定义了 @RequestParam(required=true) 但前端没传
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(MissingServletRequestParameterException.class) @ExceptionHandler(MissingServletRequestParameterException.class)
public RestBean<String> handleMissingParam(MissingServletRequestParameterException e) { public RestBean<String> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("缺少请求参数: {}", e.getParameterName()); log.warn("缺少请求参数: {}", e.getParameterName());
return RestBean.failure(RestCode.FAILURE, "缺少必要参数: " + e.getParameterName()); return RestBean.failure(RestCode.FAILURE.getCode(), "缺少必要参数: " + e.getParameterName());
} }
/** /**
* 4. 请求方法不支持 * 处理请求方法不支持异常
* 场景:接口只写了 @PostMapping前端却用 GET 请求访问 * 场景:
* - 接口只写了 @PostMapping前端却用 GET 请求访问
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public RestBean<String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { public RestBean<String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("请求方法不支持: method={}, supported={}", e.getMethod(), e.getSupportedHttpMethods()); log.warn("请求方法不支持: method={}, supported={}", e.getMethod(), e.getSupportedHttpMethods());
return RestBean.failure(RestCode.METHOD_NOT_SUPPORT, "不支持该请求方法: " + e.getMethod()); return RestBean.failure(RestCode.METHOD_NOT_SUPPORT.getCode(), "不支持该请求方法: " + e.getMethod());
} }
/** /**
* 5. 参数类型不匹配 (刚才你遇到的那个) * 处理参数类型不匹配异常
* 场景:
* - 接口定义了 Integer 类型,前端传了字符串
* - 接口定义了 Boolean 类型,前端传了非布尔值
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(MethodArgumentTypeMismatchException.class) @ExceptionHandler(MethodArgumentTypeMismatchException.class)
public RestBean<String> handleTypeMismatch(MethodArgumentTypeMismatchException e) { public RestBean<String> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName()); String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName());
log.warn("参数类型不匹配: {}", msg); log.warn("参数类型不匹配: {}", msg);
return RestBean.failure(RestCode.FAILURE, msg); return RestBean.failure(RestCode.FAILURE.getCode(), msg);
} }
// ========================================== // ==================== 2. 业务逻辑与数据库错误 ====================
// 2. 业务逻辑与数据库错误
// ==========================================
/** /**
* 6. 自定义业务异常 (最常用) * 处理自定义业务异常最常用
* 场景:你在 Service 层手动抛出 throw new BusinessException(403, "权限不足"); * 场景:
* - 在 Service 层手动抛出 throw new BusinessException(403, "权限不足")
* - 业务逻辑校验失败
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public RestBean<String> handleBusinessException(BusinessException e) { public RestBean<String> handleBusinessException(BusinessException e) {
// 业务异常通常是预期内的,记录 WARN 即可 // 业务异常通常是预期内的,记录 WARN 即可
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return RestBean.failure(e.getCode(), e.getMessage(),""); return RestBean.failure(e.getCode(), e.getMessage(), "");
} }
/** /**
* 7. 数据库唯一键冲突 * 处理数据库唯一键冲突异常
* 场景:注册时用户名已存在,插入数据库时触发 Unique Constraint * 场景:
* - 注册时用户名已存在
* - 插入数据库时触发 Unique Constraint
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(DuplicateKeyException.class) @ExceptionHandler(DuplicateKeyException.class)
public RestBean<String> handleDuplicateKeyException(DuplicateKeyException e) { public RestBean<String> handleDuplicateKeyException(DuplicateKeyException e) {
log.warn("数据库数据冲突: {}", e.getMessage()); log.warn("数据库数据冲突: {}", e.getMessage());
return RestBean.failure(RestCode.DATA_ALREADY_FOUND, "数据已存在,请勿重复操作"); return RestBean.failure(RestCode.DATA_ALREADY_FOUND.getCode(), "数据已存在,请勿重复操作");
} }
// ========================================== /**
// 3. 致命系统错误 (500) * 处理限流超出异常
// 这类错误是 Bug必须记录堆栈信息 (e),并报警 * 场景:
// ========================================== * - 用户请求过于频繁,触发限流
* - 超过接口调用的频率限制
*
* @param e 异常对象
* @return 错误响应HTTP 429
*/
@ExceptionHandler(RateLimitExceededException.class)
public RestBean<String> handleRateLimitExceeded(RateLimitExceededException e) {
log.warn("请求限流: {}", e.getMessage());
return RestBean.failure(429, "请求过于频繁,请稍后再试");
}
// ==================== 3. 致命系统错误 (500) ====================
// 这类错误是 Bug必须记录堆栈信息 (e),并报警
/** /**
* 8. 空指针异常 (NullPointerException) * 处理空指针异常
* 场景:代码里没做判空a.b() 时 a 是 null * 场景:
* - 代码里没做判空a.b() 时 a 是 null
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(NullPointerException.class) @ExceptionHandler(NullPointerException.class)
public RestBean<String> handleNPE(NullPointerException e) { public RestBean<String> handleNPE(NullPointerException e) {
// 必须打印堆栈! // 必须打印堆栈!
log.error("发生空指针异常: ", e); log.error("发生空指针异常: ", e);
return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员"); return RestBean.failure(RestCode.SYSTEM_ERROR.getCode(), "系统内部数据异常,请联系管理员");
} }
/** /**
* 处理权限拒绝异常 * 处理权限拒绝异常
*/ * 场景:
* - 用户访问了无权限的接口
* - @PreAuthorize 注解校验失败
*
* @param e 异常对象
* @return 错误响应
*/
@ExceptionHandler(AuthorizationDeniedException.class) @ExceptionHandler(AuthorizationDeniedException.class)
public RestBean<Void> handleAuthorizationDenied(AuthorizationDeniedException e) { public RestBean<Void> handleAuthorizationDenied(AuthorizationDeniedException e) {
log.warn("权限拒绝: {}", e.getMessage()); log.warn("权限拒绝: {}", e.getMessage());
@@ -146,7 +217,13 @@ public class GlobalExceptionHandler {
} }
/** /**
* 处理其他异常 * 处理其他未捕获的异常
* 场景:
* - 系统中未预期的异常
* - 兜底的异常处理
*
* @param e 异常对象
* @return 错误响应
*/ */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public RestBean<Void> handleException(Exception e) { public RestBean<Void> handleException(Exception e) {

View File

@@ -10,51 +10,117 @@ import org.springframework.web.servlet.HandlerInterceptor;
import java.util.UUID; import java.util.UUID;
/**
* 日志拦截器
* 记录所有 API 请求的日志信息
*
* 主要功能:
* 1. 请求前记录:记录请求开始信息
* 2. 请求后记录:记录请求完成信息和耗时
* 3. 用户追踪:记录当前用户信息
*
* 日志内容:
* - 请求ID用于追踪整个请求链路
* - 请求方法GET, POST, PUT, DELETE
* - 请求路径API 路径
* - 用户信息用户ID、用户名
* - 响应状态HTTP 状态码
* - 请求耗时:毫秒
*
* @author Claude
* @since 2024-04-09
*/
@Component @Component
public class LoggingInterceptor implements HandlerInterceptor { public class LoggingInterceptor implements HandlerInterceptor {
/**
* 日志记录器
*/
private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class); private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);
/**
* 请求前处理
* 步骤:
* 1. 记录请求开始时间
* 2. 生成请求ID8位UUID
* 3. 获取当前用户信息
* 4. 记录请求开始日志
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param handler 处理器
* @return true 表示继续执行false 表示中断
*/
@Override @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 记录请求开始时间
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime); request.setAttribute("startTime", startTime);
// 2. 生成请求ID8位UUID便于日志追踪
String requestId = UUID.randomUUID().toString().substring(0, 8); String requestId = UUID.randomUUID().toString().substring(0, 8);
request.setAttribute("requestId", requestId); request.setAttribute("requestId", requestId);
// 3. 获取当前用户信息
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
String username = SecurityUtils.getUsername(); String username = SecurityUtils.getUsername();
request.setAttribute("username", username); request.setAttribute("username", username);
// 4. 记录 API 请求开始日志
if (isApiRequest(request.getRequestURI())) { if (isApiRequest(request.getRequestURI())) {
logger.info("[{}] API请求开始 -> method: {}, path: {}, userId: {}, username: {}", logger.info("[{}] API 请求开始 -> method: {}, path: {}, userId: {}, username: {}",
requestId, request.getMethod(), request.getRequestURI(), userId, username); requestId, request.getMethod(), request.getRequestURI(), userId, username);
} }
return true; // 必须返回true才能继续执行请求 return true; // 必须返回 true 才能继续执行请求
} }
/**
* 请求完成后处理
* 步骤:
* 1. 计算请求耗时
* 2. 记录请求完成日志
* 3. 如果有异常,记录异常日志
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param handler 处理器
* @param ex 异常(如果有)
*/
@Override @Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) { Object handler, Exception ex) {
// 1. 获取请求开始时间
Long startTime = (Long) request.getAttribute("startTime"); Long startTime = (Long) request.getAttribute("startTime");
if (startTime != null && isApiRequest(request.getRequestURI())) { if (startTime != null && isApiRequest(request.getRequestURI())) {
// 2. 计算请求耗时
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
// 3. 获取请求信息
String requestId = (String) request.getAttribute("requestId"); String requestId = (String) request.getAttribute("requestId");
Long userId = (Long) request.getAttribute("userId"); Long userId = (Long) request.getAttribute("userId");
String username = (String) request.getAttribute("username"); String username = (String) request.getAttribute("username");
logger.info("[{}] API请求完成 -> method: {}, path: {}, status: {}, userId: {}, username: {}, duration: {}ms", // 4. 记录 API 请求完成日志
logger.info("[{}] API 请求完成 -> method: {}, path: {}, status: {}, userId: {}, username: {}, duration: {}ms",
requestId, request.getMethod(), request.getRequestURI(), response.getStatus(), userId, username, duration); requestId, request.getMethod(), request.getRequestURI(), response.getStatus(), userId, username, duration);
// 5. 如果有异常,记录异常日志
if (ex != null) { if (ex != null) {
logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex); logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex);
} }
} }
} }
/**
* 判断是否为 API 请求
* 说明:
* - API 请求的路径以 /api/ 开头
*
* @param path 请求路径
* @return 是否为 API 请求
*/
private boolean isApiRequest(String path) { private boolean isApiRequest(String path) {
return path.startsWith("/api/"); return path.startsWith("/api/");
} }

View File

@@ -0,0 +1,267 @@
package com.aisi.template.mq.consumer;
import com.aisi.template.mq.message.UserMessage;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 用户消息消费者
* 从 RocketMQ 消费用户相关消息并处理
*
* 使用场景:
* 1. 用户注册后发送欢迎邮件
* 2. 用户登录后记录登录日志到数据库
* 3. 用户更新后同步到其他系统(如数据仓库)
* 4. 用户删除后清理关联数据
*
* 消费者组说明:
* - consumerGroup: 消费者组名,同一个组内只有一个消费者能消费某条消息
* - topic: 消费的主题名称
* - consumeMode: 消费模式CONCURRENTLY并发消费或 ORDERLY顺序消费
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "rocketmq.name-server")
@RocketMQMessageListener(
// 消费者组名称
consumerGroup = "user-consumer-group",
// 订阅的主题名称
topic = "${rocketmq.producer.user-topic:user-topic}",
// 消费模式:并发消费,提高吞吐量
consumeMode = org.apache.rocketmq.spring.annotation.ConsumeMode.CONCURRENTLY,
// 消息模型:集群消费(广播消费为 BROADCASTING
messageModel = org.apache.rocketmq.spring.annotation.MessageModel.CLUSTERING
)
public class UserMessageConsumer implements org.apache.rocketmq.spring.core.RocketMQListener<UserMessage> {
/**
* 消费消息的方法
* 步骤:
* 1. 接收消息对象
* 2. 根据消息类型进行不同的处理
* 3. 处理成功返回,处理失败抛出异常
*
* 注意事项:
* - 如果方法正常返回,消息会被确认消费成功
* - 如果抛出异常,消息会被重新消费(根据配置的重试次数)
* - 消费幂等性需要业务方保证建议使用唯一ID去重
*
* @param message 消息对象
*/
@Override
public void onMessage(UserMessage message) {
try {
log.info("收到用户消息 - messageId: {}, type: {}, userId: {}",
message.getMessageId(), message.getMessageType(), message.getUserId());
// 1. 根据消息类型进行不同的处理
switch (message.getMessageType()) {
case "REGISTER":
// 处理用户注册消息
handleRegisterMessage(message);
break;
case "LOGIN":
// 处理用户登录消息
handleLoginMessage(message);
break;
case "LOGOUT":
// 处理用户登出消息
handleLogoutMessage(message);
break;
case "UPDATE":
// 处理用户更新消息
handleUpdateMessage(message);
break;
case "DELETE":
// 处理用户删除消息
handleDeleteMessage(message);
break;
default:
log.warn("未知消息类型: {}", message.getMessageType());
}
log.info("消息处理完成 - messageId: {}", message.getMessageId());
} catch (Exception e) {
// 2. 处理失败,记录错误日志
// 抛出异常会导致消息重新消费
log.error("消息处理失败 - messageId: {}, error: {}", message.getMessageId(), e.getMessage(), e);
throw new RuntimeException("消息处理失败: " + e.getMessage(), e);
}
}
/**
* 处理用户注册消息
* 步骤:
* 1. 发送欢迎邮件
* 2. 初始化用户数据
* 3. 记录注册日志
*
* @param message 用户消息
*/
private void handleRegisterMessage(UserMessage message) {
log.info("处理用户注册消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
// 1. 发送欢迎邮件(示例)
// 实际项目中这里应该调用邮件服务
sendWelcomeEmail(message.getUsername(), message.getEmail());
// 2. 初始化用户数据(示例)
// 例如:创建用户配置、初始化用户钱包等
initializeUserData(message.getUserId());
// 3. 记录注册日志(示例)
logRegistration(message);
}
/**
* 处理用户登录消息
* 步骤:
* 1. 记录登录日志到数据库
* 2. 更新最后登录时间
* 3. 检查异常登录(异地登录等)
*
* @param message 用户消息
*/
private void handleLoginMessage(UserMessage message) {
log.info("处理用户登录消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
// 1. 记录登录日志
saveLoginLog(message);
// 2. 更新最后登录时间
updateLastLoginTime(message.getUserId());
// 3. 检查异常登录
checkAbnormalLogin(message);
}
/**
* 处理用户登出消息
*
* @param message 用户消息
*/
private void handleLogoutMessage(UserMessage message) {
log.info("处理用户登出消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
// 清理用户缓存、登出其他设备等
}
/**
* 处理用户更新消息
* 步骤:
* 1. 同步用户数据到其他系统
* 2. 更新缓存
* 3. 记录变更日志
*
* @param message 用户消息
*/
private void handleUpdateMessage(UserMessage message) {
log.info("处理用户更新消息 - userId: {}, content: {}", message.getUserId(), message.getContent());
// 1. 同步数据到数据仓库
syncToDataWarehouse(message);
// 2. 更新缓存
evictUserCache(message.getUserId());
// 3. 记录变更日志
logChange(message);
}
/**
* 处理用户删除消息
*
* @param message 用户消息
*/
private void handleDeleteMessage(UserMessage message) {
log.info("处理用户删除消息 - userId: {}, username: {}", message.getUserId(), message.getUsername());
// 1. 清理用户关联数据
// 2. 归档用户数据
// 3. 清理缓存
}
// ==================== 以下是辅助方法 ====================
/**
* 发送欢迎邮件
*/
private void sendWelcomeEmail(String username, String email) {
log.info("发送欢迎邮件 - username: {}, email: {}", username, email);
// TODO: 实现邮件发送逻辑
}
/**
* 初始化用户数据
*/
private void initializeUserData(Long userId) {
log.info("初始化用户数据 - userId: {}", userId);
// TODO: 实现数据初始化逻辑
}
/**
* 记录注册日志
*/
private void logRegistration(UserMessage message) {
log.info("记录注册日志 - userId: {}", message.getUserId());
// TODO: 实现日志记录逻辑
}
/**
* 保存登录日志
*/
private void saveLoginLog(UserMessage message) {
log.info("保存登录日志 - userId: {}", message.getUserId());
// TODO: 实现登录日志保存逻辑
}
/**
* 更新最后登录时间
*/
private void updateLastLoginTime(Long userId) {
log.info("更新最后登录时间 - userId: {}", userId);
// TODO: 实现更新逻辑
}
/**
* 检查异常登录
*/
private void checkAbnormalLogin(UserMessage message) {
log.info("检查异常登录 - userId: {}", message.getUserId());
// TODO: 实现异常登录检测逻辑
}
/**
* 同步到数据仓库
*/
private void syncToDataWarehouse(UserMessage message) {
log.info("同步数据到仓库 - userId: {}", message.getUserId());
// TODO: 实现数据同步逻辑
}
/**
* 清除用户缓存
*/
private void evictUserCache(Long userId) {
log.info("清除用户缓存 - userId: {}", userId);
// TODO: 实现缓存清理逻辑
}
/**
* 记录变更日志
*/
private void logChange(UserMessage message) {
log.info("记录变更日志 - userId: {}", message.getUserId());
// TODO: 实现变更日志记录逻辑
}
}

View File

@@ -0,0 +1,65 @@
package com.aisi.template.mq.message;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户相关消息
* 用于 RocketMQ 消息传输,实现 Serializable 接口以支持序列化
*
* @author Claude
* @since 2024-04-09
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserMessage implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 消息唯一标识
*/
private String messageId;
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 邮箱
*/
private String email;
/**
* 消息类型REGISTER-注册, LOGIN-登录, LOGOUT-登出, UPDATE-更新, DELETE-删除
*/
private String messageType;
/**
* 消息内容/详情
*/
private String content;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 扩展信息JSON 格式)
*/
private String extInfo;
}

View File

@@ -0,0 +1,180 @@
package com.aisi.template.mq.producer;
import com.aisi.template.mq.message.UserMessage;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 用户消息生产者
* 负责向 RocketMQ 发送用户相关消息
*
* 使用场景:
* 1. 用户注册成功后发送欢迎消息
* 2. 用户登录后发送登录通知
* 3. 用户登出后记录登出日志
* 4. 用户信息更新后同步数据
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "rocketmq.name-server")
public class UserMessageProducer {
/**
* RocketMQ 模板,用于发送消息
*/
private final RocketMQTemplate rocketMQTemplate;
/**
* 用户主题名称
* 从配置文件读取,默认为 user-topic
*/
@Value("${rocketmq.producer.user-topic:user-topic}")
private String userTopic;
/**
* 构造函数注入依赖
*
* @param rocketMQTemplate RocketMQ 模板
*/
public UserMessageProducer(RocketMQTemplate rocketMQTemplate) {
this.rocketMQTemplate = rocketMQTemplate;
}
/**
* 发送用户注册消息
* 步骤:
* 1. 构建用户消息对象
* 2. 设置消息类型为 REGISTER
* 3. 发送到 RocketMQ
*
* @param userId 用户ID
* @param username 用户名
* @param email 邮箱
*/
public void sendRegisterMessage(Long userId, String username, String email) {
// 1. 构建消息对象
UserMessage message = UserMessage.builder()
.messageId(UUID.randomUUID().toString())
.userId(userId)
.username(username)
.email(email)
.messageType("REGISTER")
.content("用户注册成功: " + username)
.createTime(LocalDateTime.now())
.build();
// 2. 发送消息到 RocketMQ
// syncSend: 同步发送,会等待发送结果
// MessageBuilder.wrap: 用于构建 Spring Messaging 消息
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
log.info("用户注册消息发送成功 - userId: {}, username: {}", userId, username);
}
/**
* 发送用户登录消息
* 步骤:
* 1. 构建用户消息对象
* 2. 设置消息类型为 LOGIN
* 3. 异步发送到 RocketMQ
*
* @param userId 用户ID
* @param username 用户名
* @param ip 登录IP地址
*/
public void sendLoginMessage(Long userId, String username, String ip) {
// 1. 构建消息对象
UserMessage message = UserMessage.builder()
.messageId(UUID.randomUUID().toString())
.userId(userId)
.username(username)
.messageType("LOGIN")
.content("用户登录 - IP: " + ip)
.createTime(LocalDateTime.now())
.extInfo("{\"ip\":\"" + ip + "\"}")
.build();
// 2. 异步发送消息
// asyncSend: 不会阻塞当前线程,适合不需要立即知道发送结果的场景
rocketMQTemplate.asyncSend(userTopic, MessageBuilder.withPayload(message).build(), null);
log.info("用户登录消息发送成功 - userId: {}, username: {}, ip: {}", userId, username, ip);
}
/**
* 发送用户登出消息
*
* @param userId 用户ID
* @param username 用户名
*/
public void sendLogoutMessage(Long userId, String username) {
UserMessage message = UserMessage.builder()
.messageId(UUID.randomUUID().toString())
.userId(userId)
.username(username)
.messageType("LOGOUT")
.content("用户登出")
.createTime(LocalDateTime.now())
.build();
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
log.info("用户登出消息发送成功 - userId: {}, username: {}", userId, username);
}
/**
* 发送用户更新消息
* 步骤:
* 1. 构建用户消息对象
* 2. 设置消息类型为 UPDATE
* 3. 添加更新内容的扩展信息
* 4. 发送到 RocketMQ
*
* @param userId 用户ID
* @param username 用户名
* @param updateContent 更新内容描述
*/
public void sendUpdateMessage(Long userId, String username, String updateContent) {
UserMessage message = UserMessage.builder()
.messageId(UUID.randomUUID().toString())
.userId(userId)
.username(username)
.messageType("UPDATE")
.content("用户信息更新: " + updateContent)
.createTime(LocalDateTime.now())
.extInfo("{\"updateContent\":\"" + updateContent + "\"}")
.build();
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
log.info("用户更新消息发送成功 - userId: {}, username: {}, content: {}", userId, username, updateContent);
}
/**
* 发送用户删除消息
*
* @param userId 用户ID
* @param username 用户名
*/
public void sendDeleteMessage(Long userId, String username) {
UserMessage message = UserMessage.builder()
.messageId(UUID.randomUUID().toString())
.userId(userId)
.username(username)
.messageType("DELETE")
.content("用户已删除: " + username)
.createTime(LocalDateTime.now())
.build();
rocketMQTemplate.syncSend(userTopic, MessageBuilder.withPayload(message).build());
log.info("用户删除消息发送成功 - userId: {}, username: {}", userId, username);
}
}

View File

@@ -7,11 +7,66 @@ import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* 密码重置验证码数据访问接口
* 定义密码重置验证码相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 验证码查询:按邮箱查找
* 3. 验证码统计:统计请求次数
*
* 安全机制:
* - 验证码哈希存储,不存储明文
* - 支持有效期控制
* - 支持尝试次数限制
*
* @author Claude
* @since 2024-04-09
*/
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> { public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, Long> {
/**
* 查找邮箱的所有未使用验证码
* 说明:
* - 返回指定邮箱所有未使用的验证码
* - 按创建时间倒序排列
*
* 使用场景:
* - 撤销旧验证码时使用
*
* @param email 邮箱地址
* @return 未使用的验证码列表
*/
List<PasswordResetCode> findByEmailAndUsedFalse(String email); List<PasswordResetCode> findByEmailAndUsedFalse(String email);
/**
* 查找邮箱的最新未使用验证码
* 说明:
* - 返回指定邮箱最新的、未使用的验证码
* - 按创建时间倒序排列,取第一条
*
* 使用场景:
* - 验证密码重置验证码
* - 检查验证码是否过期
*
* @param email 邮箱地址
* @return 最新的验证码(可能为空)
*/
Optional<PasswordResetCode> findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email); Optional<PasswordResetCode> findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email);
/**
* 统计指定时间后邮箱的验证码请求数
* 说明:
* - 用于限制请求频率
* - 防止验证码接口被滥用
*
* 使用场景:
* - 检查 1 小时内请求次数(限制 5 次)
*
* @param email 邮箱地址
* @param createdAt 请求时间(统计此时间之后的请求)
* @return 请求数量
*/
long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt); long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt);
} }

View File

@@ -0,0 +1,82 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Refresh Token 数据访问接口
* 定义 Refresh Token 相关的数据库操作
*
* 主要功能:
* 1. Token 查询按哈希值查找、按用户ID查找有效Token
* 2. Token 清理删除过期的Token
* 3. Token 撤销批量撤销用户的Token
*
* 安全说明:
* - 数据库只存储 Token 的哈希值,不存储原始值
* - 原始 Token 只在创建时返回给客户端
*
* @author Claude
* @since 2024-04-09
*/
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
/**
* 根据 Token 哈希值查找 Refresh Token
* 说明:
* - 数据库只存储哈希值
* - 客户端传入原始值,需要先哈希再查询
*
* @param tokenHash Token 的 SHA-256 哈希值
* @return Refresh Token 对象(可能为空)
*/
Optional<RefreshToken> findByTokenHash(String tokenHash);
/**
* 查找用户的所有有效 Refresh Token
* 说明:
* - 有效 = 未过期 且 未撤销
* - 用于展示用户的登录设备列表
*
* @param userId 用户ID
* @param now 当前时间
* @return 有效 Refresh Token 列表
*/
@Query("SELECT rt FROM RefreshToken rt WHERE rt.userId = :userId AND rt.revoked = false AND rt.expiresAt > :now")
List<RefreshToken> findValidTokensByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
/**
* 删除所有过期的 Refresh Token
* 说明:
* - 用于定期清理任务
* - 建议通过定时任务每天执行
*
* @param expiresAt 过期时间删除早于此时间的Token
*/
void deleteByExpiresAtBefore(LocalDateTime expiresAt);
/**
* 撤销用户的所有 Refresh Token
* 步骤:
* 1. 将用户所有未撤销的 Token 标记为已撤销
* 2. 记录撤销时间
*
* 使用场景:
* - 用户修改密码后撤销所有 Token
* - 用户登出所有设备
* - 管理员强制用户下线
*
* @param userId 用户ID
* @param revokedAt 撤销时间
*/
@Query("UPDATE RefreshToken rt SET rt.revoked = true, rt.revokedAt = :revokedAt WHERE rt.userId = :userId AND rt.revoked = false")
@Modifying(clearAutomatically = true, flushAutomatically = true)
void revokeAllUserTokens(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt);
}

View File

@@ -0,0 +1,86 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.SysAuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 系统审计日志数据访问接口
* 定义审计日志相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 日志查询:按用户、操作、资源、时间范围查询
* 3. 动态查询:支持 Specification 动态条件查询
* 4. 日志清理:删除过期日志
*
* 审计日志内容:
* - 操作用户
* - 操作类型CREATE, UPDATE, DELETE
* - 操作资源user, role, menu
* - 操作结果(成功/失败)
* - 请求信息IP地址、User-Agent
*
* @author Claude
* @since 2024-04-09
*/
public interface SysAuditLogRepository extends JpaRepository<SysAuditLog, Long>, JpaSpecificationExecutor<SysAuditLog> {
/**
* 根据用户ID查找审计日志
* 说明:
* - 按创建时间倒序排序
* - 用于查看用户的操作历史
*
* @param userId 用户ID
* @return 审计日志列表
*/
List<SysAuditLog> findByUserIdOrderByCreatedAtDesc(Long userId);
/**
* 根据操作类型查找审计日志
* 说明:
* - 按创建时间倒序排序
* - 用于查看特定类型的操作记录
*
* @param action 操作类型LOGIN, LOGOUT, CREATE, UPDATE, DELETE
* @return 审计日志列表
*/
List<SysAuditLog> findByActionOrderByCreatedAtDesc(String action);
/**
* 根据资源查找审计日志
* 说明:
* - 按创建时间倒序排序
* - 用于查看特定资源的变更历史
*
* @param resource 资源名称user, role, menu, permission
* @return 审计日志列表
*/
List<SysAuditLog> findByResourceOrderByCreatedAtDesc(String resource);
/**
* 根据时间范围查找审计日志
* 说明:
* - 按创建时间倒序排序
* - 用于时间范围内的统计分析
*
* @param start 开始时间
* @param end 结束时间
* @return 审计日志列表
*/
List<SysAuditLog> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end);
/**
* 删除指定日期之前的审计日志
* 说明:
* - 用于定期清理旧日志
* - 建议通过定时任务定期执行
*
* @param date 日期(删除早于此日期的日志)
*/
void deleteByCreatedAtBefore(LocalDateTime date);
}

View File

@@ -0,0 +1,110 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.SysMenu;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Set;
/**
* 系统菜单数据访问接口
* 定义菜单相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 菜单查询按父ID、状态、类型查询
* 3. 关联查询:查询角色的菜单列表
*
* 菜单类型:
* - DIRECTORY(1):目录
* - MENU(2):菜单
* - BUTTON(3):按钮
*
* @author Claude
* @since 2024-04-09
*/
public interface SysMenuRepository extends JpaRepository<SysMenu, Long> {
/**
* 根据父ID查找菜单
* 说明:
* - 按排序字段排序
* - 用于构建菜单树
*
* @param parentId 父菜单IDnull 表示根菜单)
* @return 子菜单列表
*/
List<SysMenu> findByParentIdOrderBySortOrder(Long parentId);
/**
* 根据状态查找菜单
* 说明:
* - 只返回启用状态的菜单
* - 按排序字段排序
*
* @param status 状态1=启用0=禁用)
* @return 菜单列表
*/
List<SysMenu> findByStatusOrderBySortOrder(Integer status);
/**
* 根据可见性、状态、类型查找菜单
* 说明:
* - 同时满足可见、启用、指定类型
* - 按排序字段排序
*
* @param visible 可见性1=可见0=不可见)
* @param status 状态1=启用0=禁用)
* @param menuType 菜单类型1=目录2=菜单3=按钮)
* @return 菜单列表
*/
List<SysMenu> findByVisibleAndStatusAndMenuTypeOrderBySortOrder(Integer visible, Integer status, Integer menuType);
/**
* 根据角色ID查找菜单
* 步骤:
* 1. 通过角色关联查询菜单
* 2. 按排序字段排序
*
* @param roleId 角色ID
* @return 菜单集合
*/
@Query("SELECT m FROM SysMenu m JOIN m.roles r WHERE r.id = :roleId ORDER BY m.sortOrder")
Set<SysMenu> findByRoleId(@Param("roleId") Long roleId);
/**
* 根据多个角色ID查找菜单
* 步骤:
* 1. 查询这些角色拥有的所有菜单
* 2. 使用 DISTINCT 去重
* 3. 按排序字段排序
*
* 使用场景:
* - 获取用户的所有菜单(用户有多个角色)
*
* @param roleIds 角色ID列表
* @return 菜单集合
*/
@Query("SELECT DISTINCT m FROM SysMenu m JOIN m.roles r WHERE r.id IN :roleIds ORDER BY m.sortOrder")
Set<SysMenu> findByRoleIds(@Param("roleIds") List<Long> roleIds);
/**
* 根据多个角色ID和菜单类型查找菜单
* 步骤:
* 1. 查询这些角色拥有的指定类型菜单
* 2. 只返回启用且可见的菜单
* 3. 使用 DISTINCT 去重
* 4. 按排序字段排序
*
* 使用场景:
* - 获取用户的菜单树(只返回目录和菜单,不包括按钮)
*
* @param roleIds 角色ID列表
* @param menuType 菜单类型1=目录2=菜单)
* @return 菜单列表
*/
@Query("SELECT DISTINCT m FROM SysMenu m JOIN m.roles r WHERE r.id IN :roleIds AND m.menuType = :menuType AND m.status = 1 AND m.visible = 1 ORDER BY m.sortOrder")
List<SysMenu> findByRoleIdsAndMenuType(@Param("roleIds") List<Long> roleIds, @Param("menuType") Integer menuType);
}

View File

@@ -0,0 +1,97 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.SysPermission;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* 系统权限数据访问接口
* 定义权限相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 权限查询:按编码、状态、资源、操作查询
* 3. 关联查询:查询角色的权限列表
*
* 权限格式:
* - 资源:操作,如 user:read, user:write
*
* @author Claude
* @since 2024-04-09
*/
public interface SysPermissionRepository extends JpaRepository<SysPermission, Long> {
/**
* 根据权限编码查找权限
* 说明:
* - 权限编码是唯一的user:read, role:write
*
* @param permissionCode 权限编码
* @return 权限对象(可能为空)
*/
Optional<SysPermission> findByPermissionCode(String permissionCode);
/**
* 根据状态查找权限
* 使用场景:
* - 获取所有启用的权限
*
* @param status 状态1=启用0=禁用)
* @return 权限列表
*/
List<SysPermission> findByStatus(Integer status);
/**
* 根据资源查找权限
* 说明:
* - 返回指定资源的所有操作权限
* - 例如resource="user" 返回 user:read, user:write, user:delete
*
* @param resource 资源名称user, role, menu
* @return 权限列表
*/
List<SysPermission> findByResource(String resource);
/**
* 根据操作类型查找权限
* 说明:
* - 返回拥有指定操作的所有资源权限
* - 例如action="read" 返回 user:read, role:read, menu:read 等
*
* @param action 操作类型read, write, delete
* @return 权限列表
*/
List<SysPermission> findByAction(String action);
/**
* 根据角色ID查找权限
* 步骤:
* 1. 通过角色关联查询权限
* 2. 返回该角色拥有的所有权限
*
* @param roleId 角色ID
* @return 权限集合
*/
@Query("SELECT p FROM SysPermission p JOIN p.roles r WHERE r.id = :roleId")
Set<SysPermission> findByRoleId(@Param("roleId") Long roleId);
/**
* 根据多个角色ID查找权限
* 步骤:
* 1. 查询这些角色拥有的所有权限
* 2. 使用 DISTINCT 去重
*
* 使用场景:
* - 获取用户的所有权限(用户有多个角色)
*
* @param roleIds 角色ID列表
* @return 权限集合
*/
@Query("SELECT DISTINCT p FROM SysPermission p JOIN p.roles r WHERE r.id IN :roleIds")
Set<SysPermission> findByRoleIds(@Param("roleIds") List<Long> roleIds);
}

View File

@@ -0,0 +1,79 @@
package com.aisi.template.repository;
import com.aisi.template.domain.entity.SysRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 系统角色数据访问接口
* 定义角色相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 角色查询:按角色编码、状态查询
* 3. 关联查询:预加载权限列表
* 4. 动态查询:支持 Specification 动态条件查询
*
* @author Claude
* @since 2024-04-09
*/
public interface SysRoleRepository extends JpaRepository<SysRole, Long>, JpaSpecificationExecutor<SysRole> {
/**
* 根据角色编码查找角色
* 说明:
* - 角色编码是唯一的ROLE_USER, ROLE_ADMIN
*
* @param roleCode 角色编码
* @return 角色对象(可能为空)
*/
Optional<SysRole> findByRoleCode(String roleCode);
/**
* 根据状态查找角色
* 使用场景:
* - 获取所有启用的角色
* - 获取所有禁用的角色
*
* @param status 状态1=启用0=禁用)
* @return 角色列表
*/
List<SysRole> findByStatus(Integer status);
/**
* 根据ID查找角色预加载权限
* 步骤:
* 1. 使用 LEFT JOIN FETCH 一次性加载角色及其权限
* 2. 避免 N+1 查询问题
*
* 使用场景:
* - 获取角色详情时需要返回权限列表
* - 角色管理页面展示
*
* @param id 角色ID
* @return 角色对象(可能为空)
*/
@Query("SELECT r FROM SysRole r LEFT JOIN FETCH r.permissions WHERE r.id = :id")
Optional<SysRole> findByIdWithPermissions(@Param("id") Long id);
/**
* 查找所有角色(预加载权限)
* 步骤:
* 1. 使用 LEFT JOIN FETCH 一次性加载所有角色及权限
* 2. 使用 DISTINCT 去重
* 3. 按排序字段排序
*
* 使用场景:
* - 角色列表展示
* - 角色选择下拉框
*
* @return 角色列表
*/
@Query("SELECT DISTINCT r FROM SysRole r LEFT JOIN FETCH r.permissions ORDER BY r.sortOrder")
List<SysRole> findAllWithPermissions();
}

View File

@@ -3,40 +3,168 @@ package com.aisi.template.repository;
import com.aisi.template.domain.entity.User; import com.aisi.template.domain.entity.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional; import java.util.Optional;
/**
* 用户数据访问接口
* 定义用户相关的数据库操作
*
* 主要功能:
* 1. 基本操作:继承 CRUD 操作
* 2. 用户查询:按用户名、邮箱查询
* 3. 关联查询:使用 JOIN FETCH 解决懒加载问题
* 4. 动态查询:支持 Specification 动态条件查询
*
* 注意事项:
* - 使用 JOIN FETCH 避免懒加载时的 N+1 问题
* - 邮箱查询支持不区分大小写
* - 支持按状态筛选用户
*
* @author Claude
* @since 2024-04-09
*/
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
/**
* 根据用户名查找用户
*
* @param username 用户名
* @return 用户对象(可能为空)
*/
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
/**
* 根据用户名查找用户(预加载角色)
* 步骤:
* 1. 使用 LEFT JOIN FETCH 一次性加载用户及其角色
* 2. 避免 N+1 查询问题
*
* 使用场景:
* - 登录时需要加载用户角色
* - 获取用户信息时需要返回角色列表
*
* @param username 用户名
* @return 用户对象(可能为空)
*/
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.username = :username")
Optional<User> findByUsernameWithRoles(@Param("username") String username);
/**
* 根据用户名查找用户(预加载角色和权限)
*
* @param username 用户名
* @return 用户对象(可能为空)
*/
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.permissions WHERE u.username = :username")
Optional<User> findByUsernameWithRolesAndPermissions(@Param("username") String username);
/**
* 根据用户ID查找用户预加载角色
* 步骤:
* 1. 使用 LEFT JOIN FETCH 一次性加载用户及其角色
* 2. 避免 N+1 查询问题
*
* 使用场景:
* - 获取用户菜单时需要加载角色
*
* @param id 用户ID
* @return 用户对象(可能为空)
*/
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);
/**
* 根据邮箱查找用户
*
* @param email 邮箱地址
* @return 用户对象(可能为空)
*/
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
/**
* 根据邮箱查找用户(不区分大小写)
* 说明:
* - 数据库使用 LOWER() 函数进行比较
* - 适用于用户输入邮箱大小写不确定的场景
*
* @param email 邮箱地址
* @return 用户对象(可能为空)
*/
Optional<User> findByEmailIgnoreCase(String email); Optional<User> findByEmailIgnoreCase(String email);
/**
* 根据用户名和密码查找用户
* 说明:
* - 密码是已加密的哈希值
* - 一般不使用此方法,应先查询用户再验证密码
*
* @param username 用户名
* @param password 密码哈希值
* @return 用户对象(可能为空)
*/
Optional<User> findByUsernameAndPassword(String username, String password); Optional<User> findByUsernameAndPassword(String username, String password);
/**
* 根据邮箱和密码查找用户
*
* @param email 邮箱地址
* @param password 密码哈希值
* @return 用户对象(可能为空)
*/
Optional<User> findByEmailAndPassword(String email, String password); Optional<User> findByEmailAndPassword(String email, String password);
/** /**
* 根据用户名查找启用的用户 * 根据用户名和状态查找用户
* 使用场景:
* - 只查找启用状态的用户
* - 只查找禁用状态的用户
*
* @param username 用户名
* @param status 状态1=启用0=禁用)
* @return 用户对象(可能为空)
*/ */
Optional<User> findByUsernameAndStatus(String username, Integer status); Optional<User> findByUsernameAndStatus(String username, Integer status);
/** /**
* 根据邮箱查找启用的用户 * 根据邮箱和状态查找用户
*
* @param email 邮箱地址
* @param status 状态1=启用0=禁用)
* @return 用户对象(可能为空)
*/ */
Optional<User> findByEmailAndStatus(String email, Integer status); Optional<User> findByEmailAndStatus(String email, Integer status);
/** /**
* 检查用户名是否存在 * 检查用户名是否存在
* 使用场景:
* - 用户注册时校验用户名是否已被占用
*
* @param username 用户名
* @return true 表示用户名已存在
*/ */
boolean existsByUsername(String username); boolean existsByUsername(String username);
/** /**
* 检查邮箱是否存在 * 检查邮箱是否存在
* 说明:
* - 精确匹配,区分大小写
*
* @param email 邮箱地址
* @return true 表示邮箱已存在
*/ */
boolean existsByEmail(String email); boolean existsByEmail(String email);
/**
* 检查邮箱是否存在(不区分大小写)
* 说明:
* - 数据库使用 LOWER() 函数进行比较
* - 适用于用户注册时校验邮箱
*
* @param email 邮箱地址
* @return true 表示邮箱已存在
*/
boolean existsByEmailIgnoreCase(String email); boolean existsByEmailIgnoreCase(String email);
} }

View File

@@ -0,0 +1,65 @@
package com.aisi.template.service;
import com.aisi.template.domain.entity.SysAuditLog;
import java.time.LocalDateTime;
/**
* 审计日志服务接口
* 定义审计日志的业务操作
*
* 主要功能:
* 1. 创建审计日志:记录用户操作
* 2. 清理日志:删除过期的日志
*
* @author Claude
* @since 2024-04-09
*/
public interface AuditLogService {
/**
* 创建审计日志
*
* @param auditLog 审计日志实体
*/
void createAuditLog(SysAuditLog auditLog);
/**
* 记录审计日志(便捷方法)
* 步骤:
* 1. 创建审计日志实体
* 2. 设置日志信息
* 3. 保存到数据库
*
* @param userId 用户ID
* @param username 用户名
* @param action 操作类型LOGIN, LOGOUT, CREATE, UPDATE, DELETE
* @param resource 资源类型user, role, menu
* @param resourceId 资源ID
* @param description 操作描述
* @param requestMethod 请求方法GET, POST, PUT, DELETE
* @param requestUri 请求URI
* @param ipAddress IP地址
* @param userAgent User-Agent
* @param status 操作状态1=成功0=失败)
* @param errorMessage 错误信息(失败时)
*/
void log(Long userId, String username, String action, String resource,
String resourceId, String description, String requestMethod,
String requestUri, String ipAddress, String userAgent,
Integer status, String errorMessage);
/**
* 清理旧日志
* 步骤:
* 1. 计算过期时间
* 2. 删除过期日志
*
* 使用场景:
* - 定时任务定期清理
* - 建议保留最近 90 天的日志
*
* @param daysToKeep 保留天数
*/
void cleanupOldLogs(int daysToKeep);
}

View File

@@ -1,6 +1,33 @@
package com.aisi.template.service; package com.aisi.template.service;
/**
* 邮件服务接口
* 定义邮件发送的业务操作
*
* 主要功能:
* 1. 发送密码重置验证码
* 2. 发送注册欢迎邮件
* 3. 发送通知邮件
*
* 邮件类型:
* - 验证码邮件:包含 6 位数字验证码
* - 欢迎邮件:欢迎用户注册
* - 通知邮件:系统通知
*
* @author Claude
* @since 2024-04-09
*/
public interface EmailService { public interface EmailService {
/**
* 发送密码重置验证码邮件
* 步骤:
* 1. 构建邮件内容(包含验证码)
* 2. 设置邮件主题和收件人
* 3. 发送邮件
*
* @param email 收件人邮箱
* @param code 6 位数字验证码
*/
void sendPasswordResetCode(String email, String code); void sendPasswordResetCode(String email, String code);
} }

View File

@@ -0,0 +1,91 @@
package com.aisi.template.service;
/**
* 登录尝试服务接口
* 定义登录失败次数和账户锁定的业务操作
*
* 主要功能:
* 1. 记录登录失败尝试
* 2. 登录成功后重置失败计数
* 3. 检查账户是否被锁定
* 4. 锁定账户(手动锁定)
* 5. 获取剩余锁定时间
*
* 安全机制:
* - 连续失败 5 次锁定账户 30 分钟
* - 登录成功后重置失败计数
* - 锁定期间无法登录
*
* @author Claude
* @since 2024-04-09
*/
public interface LoginAttemptService {
/**
* 记录登录失败尝试
* 步骤:
* 1. 查询用户当前失败次数
* 2. 失败次数加 1
* 3. 检查是否超过最大失败次数
* 4. 如果超过,锁定账户并返回 true
* 5. 保存更新到数据库
*
* @param username 用户名
* @return true 表示账户应该被锁定
*/
boolean recordFailedAttempt(String username);
/**
* 重置登录失败计数
* 步骤:
* 1. 查询用户
* 2. 重置失败计数为 0
* 3. 清除锁定时间(如果有)
*
* 使用场景:
* - 登录成功后调用
* - 管理员手动解锁后调用
*
* @param username 用户名
*/
void resetFailedAttempts(String username);
/**
* 检查账户是否被锁定
* 步骤:
* 1. 查询用户的锁定时间和失败次数
* 2. 判断是否在锁定期内
*
* @param username 用户名
* @return true 表示账户被锁定
*/
boolean isLocked(String username);
/**
* 锁定账户
* 步骤:
* 1. 查询用户
* 2. 设置锁定时间为当前时间 + 指定分钟数
* 3. 保存更新到数据库
*
* 使用场景:
* - 管理员手动锁定用户
* - 自动锁定(由 recordFailedAttempt 触发)
*
* @param username 用户名
* @param lockDurationMinutes 锁定时长(分钟)
*/
void lockAccount(String username, int lockDurationMinutes);
/**
* 获取账户剩余锁定时间
* 步骤:
* 1. 查询用户的锁定时间
* 2. 计算与当前时间的差值
* 3. 转换为分钟
*
* @param username 用户名
* @return 剩余锁定时间(分钟),未锁定返回 0
*/
Long getRemainingLockTimeMinutes(String username);
}

View File

@@ -4,9 +4,51 @@ import com.aisi.template.domain.RestBean;
import com.aisi.template.domain.dto.PasswordResetConfirmDto; import com.aisi.template.domain.dto.PasswordResetConfirmDto;
import com.aisi.template.domain.dto.PasswordResetRequestDto; import com.aisi.template.domain.dto.PasswordResetRequestDto;
/**
* 密码重置服务接口
* 定义密码重置的业务操作
*
* 主要功能:
* 1. 发送重置验证码:生成验证码并发送到用户邮箱
* 2. 验证码重置密码:验证验证码并更新密码
*
* 安全机制:
* - 验证码 6 位数字10 分钟有效
* - 同一邮箱 60 秒内只能请求一次
* - 验证码最多尝试 5 次
* - 新密码必须符合强度要求
*
* @author Claude
* @since 2024-04-09
*/
public interface PasswordResetService { public interface PasswordResetService {
/**
* 发送密码重置验证码
* 步骤:
* 1. 校验邮箱格式
* 2. 检查请求冷却时间(防止频繁请求)
* 3. 生成 6 位随机数字验证码
* 4. 将验证码哈希后存储到 Redis10分钟有效
* 5. 发送验证码到用户邮箱
*
* @param requestDto 重置请求(邮箱)
* @return 成功响应
*/
RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto); RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto);
/**
* 使用验证码重置密码
* 步骤:
* 1. 校验邮箱和验证码是否匹配
* 2. 检查验证码是否过期
* 3. 校验验证码尝试次数(超过 5 次则验证码失效)
* 4. 校验新密码强度
* 5. 更新用户密码
* 6. 删除已使用的验证码
*
* @param confirmDto 确认请求(邮箱、验证码、新密码)
* @return 成功响应
*/
RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto); RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto);
} }

View File

@@ -0,0 +1,134 @@
package com.aisi.template.service;
import com.aisi.template.domain.dto.MenuDto;
import com.aisi.template.domain.vo.MenuVo;
import java.util.List;
/**
* 系统菜单服务接口
* 定义菜单相关的业务操作
*
* 主要功能:
* 1. 菜单管理:创建、更新、删除、查询菜单
* 2. 菜单树构建:构建菜单的层级结构
* 3. 用户菜单:根据用户角色获取可访问的菜单
*
* 菜单类型:
* - DIRECTORY目录仅用于分组不对应具体页面
* - MENU菜单项对应具体页面
* - BUTTON按钮页面内的操作按钮
*
* 设计说明:
* - 菜单采用树形结构(通过 parentId 自关联实现)
* - 删除父菜单时会级联删除子菜单
* - 菜单与角色是多对多关系
* - 用户通过角色获得菜单访问权限
*
* @author Claude
* @since 2024-04-09
*/
public interface SysMenuService {
/**
* 创建菜单
* 步骤:
* 1. 校验父菜单是否存在(如果是子菜单)
* 2. 校验菜单路径是否重复
* 3. 创建菜单实体
* 4. 计算菜单层级和排序
* 5. 保存到数据库
*
* @param menuDto 菜单信息
* @return 创建的菜单视图对象
*/
MenuVo create(MenuDto menuDto);
/**
* 更新菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 检查是否将菜单设置为自己的子菜单(循环引用)
* 3. 更新菜单信息
* 4. 保存到数据库
*
* @param id 菜单ID
* @param menuDto 菜单信息
* @return 更新后的菜单视图对象
*/
MenuVo update(Long id, MenuDto menuDto);
/**
* 删除菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 检查是否有子菜单
* 3. 删除菜单与角色的关联关系
* 4. 递归删除所有子菜单
* 5. 删除当前菜单
*
* 注意:
* - 删除父菜单会级联删除所有子菜单
* - 删除操作需要谨慎,建议使用软删除
*
* @param id 菜单ID
*/
void delete(Long id);
/**
* 根据ID获取菜单
* 步骤:
* 1. 查询菜单
* 2. 转换为视图对象返回
*
* @param id 菜单ID
* @return 菜单视图对象
*/
MenuVo getById(Long id);
/**
* 获取菜单树
* 步骤:
* 1. 查询所有菜单
* 2. 按父ID分组
* 3. 递归构建树形结构
* 4. 按排序字段排序
*
* 树形结构:
* - 根节点parentId = null
* - 子节点 1
* - 子节点 1.1
* - 子节点 2
*
* @return 菜单树列表
*/
List<MenuVo> getMenuTree();
/**
* 获取用户菜单
* 步骤:
* 1. 查询用户的所有角色
* 2. 查询角色关联的所有菜单
* 3. 去重并构建树形结构
* 4. 只返回有权限访问的菜单
*
* 使用场景:
* - 前端导航菜单渲染
* - 用户权限判断
*
* @param userId 用户ID
* @return 用户可访问的菜单树
*/
List<MenuVo> getUserMenus(Long userId);
/**
* 根据父ID获取子菜单
* 步骤:
* 1. 查询指定父ID下的所有子菜单
* 2. 按排序字段排序
*
* @param parentId 父菜单IDnull 表示查询根菜单)
* @return 子菜单列表
*/
List<MenuVo> getByParentId(Long parentId);
}

View File

@@ -0,0 +1,83 @@
package com.aisi.template.service;
import com.aisi.template.domain.vo.PermissionVo;
import java.util.List;
/**
* 系统权限服务接口
* 定义权限相关的业务操作
*
* 主要功能:
* 1. 权限查询:获取所有权限、按条件查询
* 2. 权限管理:权限通常由系统初始化,不支持动态创建/删除
*
* 权限格式:
* - 资源:操作 格式,如 user:read, user:write
* - 资源:系统中的实体,如 user, role, permission, menu
* - 操作read读取、write写入、delete删除
*
* 设计说明:
* - 权限是系统中预定义的,通过数据库迁移脚本初始化
* - 角色与权限是多对多关系
* - 权限通过角色分配给用户
*
* @author Claude
* @since 2024-04-09
*/
public interface SysPermissionService {
/**
* 获取所有权限
* 步骤:
* 1. 查询所有权限
* 2. 按资源分组排序
* 3. 转换为视图对象列表
*
* 使用场景:
* - 权限分配界面
* - 权限列表展示
*
* @return 所有权限视图对象列表
*/
List<PermissionVo> getAllPermissions();
/**
* 根据ID获取权限
* 步骤:
* 1. 查询权限
* 2. 转换为视图对象返回
*
* @param id 权限ID
* @return 权限视图对象
*/
PermissionVo getById(Long id);
/**
* 根据资源获取权限
* 步骤:
* 1. 按资源名称查询权限
* 2. 返回该资源的所有操作权限
*
* 示例:
* - resource="user" 返回 user:read, user:write, user:delete
*
* @param resource 资源名称
* @return 该资源的所有权限
*/
List<PermissionVo> getByResource(String resource);
/**
* 根据操作类型获取权限
* 步骤:
* 1. 按操作类型查询权限
* 2. 返回拥有该操作的所有资源权限
*
* 示例:
* - action="read" 返回 user:read, role:read, menu:read 等
*
* @param action 操作类型read, write, delete
* @return 拥有该操作的所有权限
*/
List<PermissionVo> getByAction(String action);
}

View File

@@ -0,0 +1,139 @@
package com.aisi.template.service;
import com.aisi.template.domain.dto.RoleDto;
import com.aisi.template.domain.dto.RoleQueryDto;
import com.aisi.template.domain.dto.PageResult;
import com.aisi.template.domain.vo.RoleVo;
import java.util.List;
/**
* 系统角色服务接口
* 定义角色相关的业务操作
*
* 主要功能:
* 1. 角色管理:创建、更新、删除、查询角色
* 2. 权限分配:为角色分配权限
* 3. 权限查询:获取角色的权限列表
*
* 设计说明:
* - 角色与权限是多对多关系
* - 删除角色前需要检查是否有用户关联
* - 超级管理员角色不可删除
*
* @author Claude
* @since 2024-04-09
*/
public interface SysRoleService {
/**
* 创建角色
* 步骤:
* 1. 校验角色编码是否已存在
* 2. 创建角色实体
* 3. 保存到数据库
* 4. 转换为视图对象返回
*
* @param roleDto 角色信息(角色名、编码、描述)
* @return 创建的角色视图对象
*/
RoleVo create(RoleDto roleDto);
/**
* 更新角色
* 步骤:
* 1. 检查角色是否存在
* 2. 检查新角色编码是否与其他角色冲突
* 3. 更新角色信息
* 4. 保存到数据库
*
* @param id 角色ID
* @param roleDto 角色信息
* @return 更新后的角色视图对象
*/
RoleVo update(Long id, RoleDto roleDto);
/**
* 删除角色
* 步骤:
* 1. 检查角色是否存在
* 2. 检查是否有用户关联此角色
* 3. 检查是否为超级管理员角色(禁止删除)
* 4. 删除角色与权限的关联关系
* 5. 删除角色
*
* @param id 角色ID
*/
void delete(Long id);
/**
* 根据ID获取角色
* 步骤:
* 1. 查询角色
* 2. 加载关联的权限列表
* 3. 转换为视图对象返回
*
* @param id 角色ID
* @return 角色视图对象
*/
RoleVo getById(Long id);
/**
* 获取所有角色
* 步骤:
* 1. 查询所有角色
* 2. 转换为视图对象列表
*
* 使用场景:
* - 角色下拉选择框
* - 角色列表展示
*
* @return 所有角色视图对象列表
*/
List<RoleVo> getAllRoles();
/**
* 分页查询角色
* 步骤:
* 1. 构建分页参数
* 2. 构建动态查询条件(关键词、状态)
* 3. 执行分页查询
* 4. 转换结果为视图对象
*
* @param queryDto 查询条件
* @param page 页码从1开始
* @param size 每页大小
* @return 分页角色列表
*/
PageResult<RoleVo> queryRoles(RoleQueryDto queryDto, int page, int size);
/**
* 为角色分配权限
* 步骤:
* 1. 检查角色是否存在
* 2. 验证所有权限ID是否存在
* 3. 清空角色原有权限
* 4. 添加新权限
* 5. 保存到数据库
*
* 注意:
* - 此操作会完全替换角色的权限列表
* - 权限变更后用户需要重新登录才能生效
*
* @param roleId 角色ID
* @param permissionIds 权限ID列表
*/
void assignPermissions(Long roleId, List<Long> permissionIds);
/**
* 获取角色的权限ID列表
* 步骤:
* 1. 查询角色
* 2. 加载关联的权限
* 3. 提取权限ID列表
*
* @param roleId 角色ID
* @return 权限ID列表
*/
List<Long> getRolePermissionIds(Long roleId);
}

View File

@@ -0,0 +1,161 @@
package com.aisi.template.service;
import com.aisi.template.domain.dto.RefreshTokenDto;
import com.aisi.template.domain.vo.LoginResponseVo;
/**
* Token 服务接口
* 定义 Refresh Token 和黑名单管理的业务操作
*
* 主要功能:
* 1. Refresh Token 管理:创建、刷新、撤销 Refresh Token
* 2. Token 黑名单:管理已撤销的 JWT Token
* 3. 清理任务:清理过期的 Token
*
* Token 说明:
* - Access Token短期有效默认 1 小时),用于 API 访问
* - Refresh Token长期有效默认 7 天),用于刷新 Access Token
* - Token 黑名单:用于实现登出功能,将已撤销的 Token 加入黑名单
*
* 安全机制:
* - Refresh Token 存储在数据库,支持撤销
* - Refresh Token 每次使用后生成新的Token 轮换)
* - 登出时将 Access Token 加入 Redis 黑名单
* - 定时清理过期的 Token 和黑名单记录
*
* @author Claude
* @since 2024-04-09
*/
public interface TokenService {
/**
* 创建 Refresh Token
* 步骤:
* 1. 生成随机的 Token 字符串
* 2. 计算 Token 的哈希值SHA-256
* 3. 设置过期时间(当前时间 + 7 天)
* 4. 保存到数据库
* 5. 返回原始 Token不存储原始值
*
* 安全说明:
* - 数据库只存储 Token 的哈希值,防止泄露
* - Token 格式UUID 随机字符串
*
* @param userId 用户ID
* @param deviceInfo 设备信息(可选)
* @param ipAddress IP 地址(可选)
* @return Refresh Token 原始值
*/
String createRefreshToken(Long userId, String deviceInfo, String ipAddress);
/**
* 刷新 Access Token
* 步骤:
* 1. 计算 Refresh Token 的哈希值
* 2. 查询数据库中的 Token 记录
* 3. 检查 Token 是否存在、是否过期、是否被撤销
* 4. 验证 Token 是否属于当前用户
* 5. 生成新的 Access Token
* 6. 生成新的 Refresh TokenToken 轮换)
* 7. 撤销旧的 Refresh Token
*
* Token 轮换:
* - 每次刷新时生成新的 Refresh Token
* - 旧的 Refresh Token 立即失效
* - 防止 Token 被重复使用
*
* @param refreshToken Refresh Token 原始值
* @return 新的登录响应(包含新的 Access Token 和 Refresh Token
*/
LoginResponseVo refreshToken(String refreshToken);
/**
* 撤销 Refresh Token
* 步骤:
* 1. 计算 Token 的哈希值
* 2. 查询数据库中的 Token 记录
* 3. 将撤销状态设置为 true
* 4. 保存到数据库
*
* 使用场景:
* - 用户主动登出
* - Token 刷新时撤销旧 Token
* - 管理员强制用户下线
*
* @param tokenHash Refresh Token 的哈希值
*/
void revokeRefreshToken(String tokenHash);
/**
* 撤销用户的所有 Refresh Token
* 步骤:
* 1. 查询用户的所有有效 Refresh Token
* 2. 批量设置为撤销状态
* 3. 保存到数据库
*
* 使用场景:
* - 修改密码后撤销所有设备
* - 管理员强制用户下线
* - 用户重置所有登录会话
*
* @param userId 用户ID
*/
void revokeAllUserTokens(Long userId);
/**
* 检查 Token 是否在黑名单中
* 步骤:
* 1. 从 JWT Token 中提取 jtiJWT ID
* 2. 在 Redis 中查询黑名单
* 3. 返回是否存在
*
* @param jti JWT ID
* @return true 表示 Token 已被撤销(在黑名单中)
*/
boolean isTokenBlacklisted(String jti);
/**
* 将 Token 加入黑名单
* 步骤:
* 1. 从 JWT Token 中提取 jti 和过期时间
* 2. 计算剩余有效时间
* 3. 在 Redis 中设置黑名单记录(带过期时间)
*
* 为什么需要黑名单:
* - JWT Token 是无状态的,一旦签发无法主动撤销
* - 通过黑名单机制实现登出功能
* - 黑名单记录的过期时间与 Token 一致
*
* @param jti JWT ID
* @param expirationSeconds 剩余有效时间(秒)
*/
void addTokenToBlacklist(String jti, long expirationSeconds);
/**
* 清理过期的 Refresh Token
* 步骤:
* 1. 查询所有过期时间小于当前时间的 Token
* 2. 批量删除
*
* 使用场景:
* - 定时任务(每天凌晨执行)
* - 手动触发清理
*
* 注意:
* - 已撤销的 Token 也需要清理
* - 建议定期执行,防止数据堆积
*/
void cleanupExpiredTokens();
/**
* 清理过期的黑名单记录
* 步骤:
* 1. 扫描 Redis 中的黑名单
* 2. 删除已过期的记录
*
* 注意:
* - Redis 的 key 会自动过期,此方法用于手动清理
* - 一般不需要调用,除非需要立即释放内存
*/
void cleanupExpiredBlacklistEntries();
}

View File

@@ -6,19 +6,101 @@ import com.aisi.template.domain.dto.UserDto;
import com.aisi.template.domain.dto.UserQueryDto; import com.aisi.template.domain.dto.UserQueryDto;
import com.aisi.template.domain.dto.UserRoleUpdateDto; import com.aisi.template.domain.dto.UserRoleUpdateDto;
import com.aisi.template.domain.dto.UserStatusUpdateDto; import com.aisi.template.domain.dto.UserStatusUpdateDto;
import com.aisi.template.domain.vo.LoginResponseVo;
import com.aisi.template.domain.vo.UserVo; import com.aisi.template.domain.vo.UserVo;
/**
* 用户服务接口
* 定义用户相关的业务操作
*
* 主要功能:
* 1. 用户认证:注册、登录
* 2. 用户信息:获取当前用户信息
* 3. 用户管理:分页查询、状态更新、角色分配
*
* @author Claude
* @since 2024-04-09
*/
public interface UserService { public interface UserService {
/**
* 获取当前用户信息
* 步骤:
* 1. 从 SecurityContext 获取当前登录用户
* 2. 查询用户详细信息
* 3. 转换为视图对象返回
*
* @return 用户信息视图对象
*/
RestBean<UserVo> getUserInfo(); RestBean<UserVo> getUserInfo();
RestBean<String> register(UserDto userDto); /**
* 用户注册
* 步骤:
* 1. 校验用户名、邮箱是否已存在
* 2. 校验密码强度
* 3. 创建用户并分配默认角色
* 4. 生成 Access Token 和 Refresh Token
* 5. 发送注册消息到 MQ
*
* @param userDto 用户注册信息
* @return 登录响应(包含 Token 和用户信息)
*/
RestBean<LoginResponseVo> register(UserDto userDto);
RestBean<String> login(UserDto userDto); /**
* 用户登录
* 步骤:
* 1. 验证用户名和密码
* 2. 检查账户状态(是否被禁用、锁定)
* 3. 记录登录失败次数(超过阈值则锁定)
* 4. 登录成功后重置失败计数
* 5. 生成 Access Token 和 Refresh Token
* 6. 发送登录消息到 MQ
*
* @param userDto 登录信息(用户名、密码)
* @return 登录响应(包含 Token 和用户信息)
*/
RestBean<LoginResponseVo> login(UserDto userDto);
/**
* 分页查询用户列表
* 步骤:
* 1. 构建动态查询条件
* 2. 支持按用户名/邮箱模糊搜索
* 3. 支持按状态筛选
* 4. 分页返回结果
*
* @param queryDto 查询条件
* @return 分页用户列表
*/
RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto); RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto);
/**
* 更新用户状态
* 步骤:
* 1. 检查用户是否存在
* 2. 禁止修改当前登录用户的状态
* 3. 更新用户状态
*
* @param userId 用户ID
* @param updateDto 状态更新请求
* @return 更新后的用户信息
*/
RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto); RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto);
/**
* 更新用户角色
* 步骤:
* 1. 检查用户是否存在
* 2. 禁止修改当前登录用户的角色
* 3. 验证角色ID是否存在
* 4. 清空用户原有角色
* 5. 添加新角色
*
* @param userId 用户ID
* @param updateDto 角色更新请求
* @return 更新后的用户信息
*/
RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto); RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto);
} }

View File

@@ -0,0 +1,133 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.entity.SysAuditLog;
import com.aisi.template.repository.SysAuditLogRepository;
import com.aisi.template.service.AuditLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 审计日志服务实现类
* 负责审计日志的记录和清理
*
* 主要功能:
* 1. 创建审计日志:记录用户操作
* 2. 清理旧日志:定期删除过期日志
*
* 性能优化:
* - 使用 @Async 注解实现异步记录,不阻塞主流程
* - 使用 @Transactional 保证数据一致性
* - 异常捕获确保日志记录失败不影响业务
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditLogServiceImpl implements AuditLogService {
/**
* 审计日志数据访问接口
*/
private final SysAuditLogRepository auditLogRepository;
/**
* 创建审计日志(异步)
* 步骤:
* 1. 保存审计日志到数据库
* 2. 捕获异常,确保记录失败不影响业务
*
* @param auditLog 审计日志实体
*/
@Async
@Override
@Transactional(rollbackFor = Exception.class)
public void createAuditLog(SysAuditLog auditLog) {
try {
auditLogRepository.save(auditLog);
} catch (Exception e) {
log.error("保存审计日志失败", e);
}
}
/**
* 记录审计日志(异步)
* 步骤:
* 1. 创建审计日志实体
* 2. 设置日志信息
* 3. 保存到数据库
* 4. 捕获异常,确保记录失败不影响业务
*
* @param userId 用户ID
* @param username 用户名
* @param action 操作类型LOGIN, LOGOUT, CREATE, UPDATE, DELETE
* @param resource 资源类型user, role, menu
* @param resourceId 资源ID
* @param description 操作描述
* @param requestMethod 请求方法GET, POST, PUT, DELETE
* @param requestUri 请求URI
* @param ipAddress IP地址
* @param userAgent User-Agent
* @param status 操作状态1=成功0=失败)
* @param errorMessage 错误信息(失败时)
*/
@Async
@Override
@Transactional(rollbackFor = Exception.class)
public void log(Long userId, String username, String action, String resource,
String resourceId, String description, String requestMethod,
String requestUri, String ipAddress, String userAgent,
Integer status, String errorMessage) {
// 1. 创建审计日志实体
SysAuditLog auditLog = new SysAuditLog();
auditLog.setUserId(userId);
auditLog.setUsername(username);
auditLog.setAction(action);
auditLog.setResource(resource);
auditLog.setResourceId(resourceId);
auditLog.setDescription(description);
auditLog.setRequestMethod(requestMethod);
auditLog.setRequestUri(requestUri);
auditLog.setIpAddress(ipAddress);
auditLog.setUserAgent(userAgent);
auditLog.setStatus(status);
auditLog.setErrorMessage(errorMessage);
// 2. 保存到数据库
try {
auditLogRepository.save(auditLog);
} catch (Exception e) {
log.error("保存审计日志失败", e);
}
}
/**
* 清理旧日志
* 步骤:
* 1. 计算截止日期(当前时间 - 保留天数)
* 2. 删除截止日期之前的日志
*
* 使用场景:
* - 定时任务定期清理(如每天凌晨执行)
* - 建议保留最近 90 天的日志
*
* @param daysToKeep 保留天数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void cleanupOldLogs(int daysToKeep) {
// 1. 计算截止日期
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(daysToKeep);
// 2. 删除旧日志
auditLogRepository.deleteByCreatedAtBefore(cutoffDate);
log.info("已清理 {} 天前的审计日志", daysToKeep);
}
}

View File

@@ -1,38 +1,103 @@
package com.aisi.template.service.impl; package com.aisi.template.service.impl;
import com.aisi.template.domain.CustomUserDetails; import com.aisi.template.domain.CustomUserDetails;
import com.aisi.template.domain.entity.SysRole;
import com.aisi.template.domain.entity.SysPermission;
import com.aisi.template.domain.entity.User; import com.aisi.template.domain.entity.User;
import com.aisi.template.repository.UserRepository; import com.aisi.template.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 自定义用户详情服务实现类
* 为 Spring Security 提供用户认证和授权信息
*
* 主要功能:
* 1. 加载用户信息:根据用户名加载用户及其角色
* 2. 构建用户权限:合并角色权限和具体权限
* 3. 创建认证对象:返回 CustomUserDetails 对象
*
* 权限格式:
* - 角色权限ROLE_XXX如 ROLE_USER, ROLE_ADMIN
* - 具体权限XXX:YYY如 user:create, role:read
*
* @author Claude
* @since 2024-04-09
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService { public class CustomUserDetailsService implements UserDetailsService {
/**
* 用户数据访问接口
*/
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* 根据用户名加载用户详情
* 步骤:
* 1. 查询用户及其角色(使用 JOIN FETCH 避免懒加载问题)
* 2. 检查用户是否存在
* 3. 检查用户是否被禁用
* 4. 构建权限列表(角色 + 权限)
* 5. 创建 CustomUserDetails 对象
*
* @param username 用户名
* @return 用户详情对象
* @throws UsernameNotFoundException 当用户不存在或被禁用时抛出异常
*/
@Override @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username) // 1. 查询用户及其角色
User user = userRepository.findByUsernameWithRolesAndPermissions(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
// 检查用户状态 // 2. 检查用户是否被禁用
if (!user.isEnabled()) { if (!user.isEnabled()) {
throw new UsernameNotFoundException("用户已被禁用: " + username); throw new UsernameNotFoundException("用户已被禁用: " + username);
} }
// 3. 获取用户角色
Set<SysRole> roles = user.getRoles();
Set<String> roleCodes = roles.stream()
.map(SysRole::getRoleCode)
.collect(Collectors.toSet());
// 4. 构建权限列表:包含角色权限和具体权限
Collection<SimpleGrantedAuthority> authorities = roles.stream()
.flatMap(role -> {
// 4.1 添加角色权限ROLE_XXX 格式)
List<SimpleGrantedAuthority> roleAuths = List.of(
new SimpleGrantedAuthority(role.getRoleCode())
);
// 4.2 添加具体权限XXX:YYY 格式,如 user:create, role:read
List<SimpleGrantedAuthority> permAuths = role.getPermissions().stream()
.map(permission -> new SimpleGrantedAuthority(permission.getPermissionCode()))
.collect(Collectors.toList());
// 4.3 合并角色和权限
return java.util.stream.Stream.concat(roleAuths.stream(), permAuths.stream());
})
.collect(Collectors.toSet());
// 5. 创建 CustomUserDetails 对象
return new CustomUserDetails( return new CustomUserDetails(
user.getId(), user.getId(),
user.getUsername(), user.getUsername(),
user.getPassword(), user.getPassword(),
List.of(() -> user.getRole().getAuthority()), authorities,
user.isEnabled(), user.isEnabled(),
user.getRole().name() roleCodes
); );
} }
} }

View File

@@ -4,29 +4,80 @@ import com.aisi.template.service.EmailService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* 邮件服务实现类
* 负责发送各类邮件
*
* 主要功能:
* 1. 发送密码重置验证码邮件
* 2. 发送注册欢迎邮件(预留)
* 3. 发送通知邮件(预留)
*
* 邮件配置:
* - 发件人地址从配置文件读取spring.mail.username
* - 验证码有效期从配置读取app.password-reset.code-expire-minutes
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class EmailServiceImpl implements EmailService { public class EmailServiceImpl implements EmailService {
/**
* Java Mail Sender
* Spring 提供的邮件发送工具
*/
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
/**
* 发件人邮箱地址
* 从配置文件读取
*/
@Value("${spring.mail.username}") @Value("${spring.mail.username}")
private String from; private String from;
/**
* 验证码有效期(分钟)
* 从配置读取,用于在邮件中提示用户
*/
@Value("${app.password-reset.code-expire-minutes:10}") @Value("${app.password-reset.code-expire-minutes:10}")
private Integer expireMinutes; private Integer expireMinutes;
/**
* 发送密码重置验证码邮件
* 步骤:
* 1. 创建简单邮件消息
* 2. 设置发件人
* 3. 设置收件人
* 4. 设置邮件主题
* 5. 设置邮件内容(包含验证码和有效期)
* 6. 发送邮件
*
* @param email 收件人邮箱
* @param code 6 位数字验证码
*/
@Override @Override
public void sendPasswordResetCode(String email, String code) { public void sendPasswordResetCode(String email, String code) {
// 1. 创建简单邮件消息
SimpleMailMessage message = new SimpleMailMessage(); SimpleMailMessage message = new SimpleMailMessage();
// 2. 设置发件人
message.setFrom(from); message.setFrom(from);
// 3. 设置收件人
message.setTo(email); message.setTo(email);
// 4. 设置邮件主题
message.setSubject("密码找回验证码"); message.setSubject("密码找回验证码");
// 5. 设置邮件内容
message.setText(""" message.setText("""
您正在进行密码找回操作。 您正在进行密码找回操作。
@@ -35,7 +86,9 @@ public class EmailServiceImpl implements EmailService {
如果这不是您的操作,请忽略此邮件。 如果这不是您的操作,请忽略此邮件。
""".formatted(code, expireMinutes)); """.formatted(code, expireMinutes));
// 6. 发送邮件
mailSender.send(message); mailSender.send(message);
log.info("已发送密码找回验证码到邮箱: {}", email); log.info("已发送密码找回验证码到邮箱 - email: {}, code: {}", email, code);
} }
} }

View File

@@ -0,0 +1,208 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.entity.User;
import com.aisi.template.repository.UserRepository;
import com.aisi.template.service.LoginAttemptService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 登录尝试服务实现类
* 负责处理登录失败计数和账户锁定
*
* 主要功能:
* 1. 记录登录失败尝试:计数失败次数,超过阈值则锁定账户
* 2. 重置失败计数:登录成功后清零
* 3. 检查账户锁定状态:判断账户是否被锁定
* 4. 锁定账户:手动或自动锁定账户
* 5. 获取剩余锁定时间:计算账户还需多久解锁
*
* 安全机制:
* - 连续失败 5 次锁定账户 30 分钟
* - 锁定期间无法登录
* - 登录成功后重置失败计数
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginAttemptServiceImpl implements LoginAttemptService {
/**
* 用户数据访问接口
*/
private final UserRepository userRepository;
/**
* 最大失败次数
* 从配置读取,默认 5 次
*/
@Value("${app.login.max-attempts:5}")
private int maxAttempts;
/**
* 锁定时长(分钟)
* 从配置读取,默认 30 分钟
*/
@Value("${app.login.lock-duration-minutes:30}")
private int lockDurationMinutes;
/**
* 记录登录失败尝试
* 步骤:
* 1. 查询用户是否存在
* 2. 失败次数加 1
* 3. 检查是否超过最大失败次数
* 4. 如果超过,锁定账户并返回 true
* 5. 保存更新到数据库
*
* @param username 用户名
* @return true 表示账户应该被锁定
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean recordFailedAttempt(String username) {
// 1. 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
return false;
}
// 2. 失败次数加 1
int failedCount = user.getFailedLoginCount() + 1;
user.setFailedLoginCount(failedCount);
// 3. 检查是否应该锁定账户
boolean shouldLock = failedCount >= maxAttempts;
if (shouldLock) {
// 3.1 锁定账户:计算锁定到期时间
LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(lockDurationMinutes);
user.setLockedUntil(lockUntil);
log.warn("用户因登录失败次数过多被锁定 - username: {}, failedCount: {}, lockUntil: {}",
username, failedCount, lockUntil);
} else {
log.debug("用户登录失败 - username: {}, failedCount: {}/{}",
username, failedCount, maxAttempts);
}
// 4. 保存更新
userRepository.save(user);
return shouldLock;
}
/**
* 重置登录失败计数
* 步骤:
* 1. 查询用户是否存在
* 2. 重置失败计数为 0
* 3. 清除锁定时间
* 4. 保存到数据库
*
* @param username 用户名
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void resetFailedAttempts(String username) {
// 1. 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user != null) {
// 2. 重置失败计数为 0
user.setFailedLoginCount(0);
// 3. 清除锁定时间
user.setLockedUntil(null);
userRepository.save(user);
log.debug("用户登录失败计数已重置 - username: {}", username);
}
}
/**
* 检查账户是否被锁定
* 步骤:
* 1. 查询用户是否存在
* 2. 调用实体的 isLocked() 方法判断
*
* 说明:
* - 实体的 isLocked() 方法会自动处理过期锁定的判断
*
* @param username 用户名
* @return true 表示账户被锁定
*/
@Override
public boolean isLocked(String username) {
// 1. 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
return false;
}
// 2. 判断是否被锁定(实体的 isLocked() 方法会自动处理过期判断)
return user.isLocked();
}
/**
* 锁定账户
* 步骤:
* 1. 查询用户是否存在
* 2. 设置锁定时间为当前时间 + 指定分钟数
* 3. 保存到数据库
*
* 使用场景:
* - 管理员手动锁定用户
* - 自动锁定(由 recordFailedAttempt 触发)
*
* @param username 用户名
* @param lockDurationMinutes 锁定时长(分钟)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void lockAccount(String username, int lockDurationMinutes) {
// 1. 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user != null) {
// 2. 设置锁定时间
LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(lockDurationMinutes);
user.setLockedUntil(lockUntil);
userRepository.save(user);
log.warn("账户已被手动锁定 - username: {}, lockUntil: {}", username, lockUntil);
}
}
/**
* 获取账户剩余锁定时间
* 步骤:
* 1. 查询用户是否存在
* 2. 检查是否有锁定时间
* 3. 检查锁定是否已过期
* 4. 计算剩余分钟数
*
* @param username 用户名
* @return 剩余锁定时间(分钟),未锁定返回 0
*/
@Override
public Long getRemainingLockTimeMinutes(String username) {
// 1. 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user == null || user.getLockedUntil() == null) {
return 0L;
}
// 2. 检查锁定是否已过期
if (LocalDateTime.now().isAfter(user.getLockedUntil())) {
return 0L;
}
// 3. 计算剩余分钟数
long remainingMinutes = java.time.Duration.between(
LocalDateTime.now(),
user.getLockedUntil()
).toMinutes();
return Math.max(0, remainingMinutes);
}
}

View File

@@ -25,38 +25,107 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.security.SecureRandom; import java.security.SecureRandom;
/**
* 密码重置服务实现类
* 负责密码重置流程的业务逻辑
*
* 主要功能:
* 1. 发送重置验证码:生成验证码并发送到用户邮箱
* 2. 验证码重置密码:验证验证码并更新密码
*
* 安全机制:
* - 验证码 6 位数字10 分钟有效
* - 同一邮箱 60 秒内只能请求一次(冷却时间)
* - 同一邮箱 1 小时内最多请求 5 次
* - 验证码最多尝试 5 次,超过后失效
* - 验证码哈希存储SHA-256不存储明文
* - 新密码使用 BCrypt 加密
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class PasswordResetServiceImpl implements PasswordResetService { public class PasswordResetServiceImpl implements PasswordResetService {
/**
* 安全随机数生成器
* 用于生成验证码
*/
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* 用户数据访问接口
*/
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* 密码重置验证码数据访问接口
*/
private final PasswordResetCodeRepository passwordResetCodeRepository; private final PasswordResetCodeRepository passwordResetCodeRepository;
/**
* 密码编码器
* 用于加密新密码
*/
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
/**
* 邮件服务
* 用于发送验证码邮件
*/
private final EmailService emailService; private final EmailService emailService;
/**
* 验证码有效期(分钟),默认 10 分钟
*/
@Value("${app.password-reset.code-expire-minutes:10}") @Value("${app.password-reset.code-expire-minutes:10}")
private Integer expireMinutes; private Integer expireMinutes;
/**
* 请求冷却时间(秒),默认 60 秒
*/
@Value("${app.password-reset.request-cooldown-seconds:60}") @Value("${app.password-reset.request-cooldown-seconds:60}")
private Integer cooldownSeconds; private Integer cooldownSeconds;
/**
* 验证码最大尝试次数,默认 5 次
*/
@Value("${app.password-reset.max-attempts:5}") @Value("${app.password-reset.max-attempts:5}")
private Integer maxAttempts; private Integer maxAttempts;
/**
* 发送密码重置验证码
* 步骤:
* 1. 标准化邮箱(转小写,去除空格)
* 2. 检查邮箱是否存在(为防止用户名枚举,无论是否存在都返回成功)
* 3. 检查请求冷却时间(防止频繁请求)
* 4. 检查 1 小时内请求次数(防止滥用)
* 5. 将该邮箱的所有旧验证码标记为已使用
* 6. 生成 6 位随机数字验证码
* 7. 将验证码哈希后存储到数据库
* 8. 发送验证码到用户邮箱
*
* @param requestDto 重置请求(邮箱)
* @return 成功响应
*/
@Override @Override
@Transactional @Transactional
public RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto) { public RestBean<Void> sendResetCode(PasswordResetRequestDto requestDto) {
// 1. 标准化邮箱(转小写,去除首尾空格)
String email = requestDto.getEmail().trim().toLowerCase(); String email = requestDto.getEmail().trim().toLowerCase();
// 2. 检查邮箱是否存在
// 注意:为防止用户名枚举攻击,无论邮箱是否存在都返回相同消息
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email); Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
if (userOptional.isEmpty()) { if (userOptional.isEmpty()) {
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null); return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
} }
// 3. 检查请求冷却时间(防止频繁请求)
Optional<PasswordResetCode> latestCode = passwordResetCodeRepository Optional<PasswordResetCode> latestCode = passwordResetCodeRepository
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email); .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email);
if (latestCode.isPresent()) { if (latestCode.isPresent()) {
LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds); LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds);
if (nextAllowedAt.isAfter(LocalDateTime.now())) { if (nextAllowedAt.isAfter(LocalDateTime.now())) {
@@ -65,64 +134,103 @@ public class PasswordResetServiceImpl implements PasswordResetService {
} }
} }
long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(email, LocalDateTime.now().minusHours(1)); // 4. 检查 1 小时内请求次数(防止滥用)
long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(
email, LocalDateTime.now().minusHours(1));
if (recentCount >= 5) { if (recentCount >= 5) {
return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null); return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null);
} }
// 5. 将该邮箱的所有旧验证码标记为已使用(避免混淆)
List<PasswordResetCode> activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); List<PasswordResetCode> activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
for (PasswordResetCode item : activeCodes) { for (PasswordResetCode item : activeCodes) {
item.setUsed(true); item.setUsed(true);
} }
passwordResetCodeRepository.saveAll(activeCodes); passwordResetCodeRepository.saveAll(activeCodes);
// 6. 生成 6 位随机数字验证码
String code = generateCode(); String code = generateCode();
// 7. 将验证码哈希后存储到数据库
PasswordResetCode resetCode = new PasswordResetCode(); PasswordResetCode resetCode = new PasswordResetCode();
resetCode.setEmail(email); resetCode.setEmail(email);
resetCode.setCodeHash(sha256(code)); resetCode.setCodeHash(sha256(code)); // 只存储哈希值,不存储明文
resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes)); resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes));
resetCode.setUsed(false); resetCode.setUsed(false);
resetCode.setAttemptCount(0); resetCode.setAttemptCount(0);
passwordResetCodeRepository.save(resetCode); passwordResetCodeRepository.save(resetCode);
emailService.sendPasswordResetCode(email, code); // 8. 发送验证码到用户邮箱(容错:邮件服务不可用时不影响验证码生成)
try {
emailService.sendPasswordResetCode(email, code);
log.info("密码重置验证码已发送 - email: {}", email);
} catch (Exception e) {
log.warn("邮件发送失败,验证码已生成但未发送 - email: {}, error: {}", email, e.getMessage());
}
return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null); return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null);
} }
/**
* 使用验证码重置密码
* 步骤:
* 1. 标准化邮箱
* 2. 检查邮箱是否存在
* 3. 获取最新的未使用验证码
* 4. 验证验证码是否已使用
* 5. 验证验证码是否过期
* 6. 验证尝试次数是否超限
* 7. 验证验证码是否正确
* 8. 更新用户密码
* 9. 标记验证码为已使用
* 10. 将该邮箱的所有其他验证码标记为已使用
*
* @param confirmDto 确认请求(邮箱、验证码、新密码)
* @return 成功响应
*/
@Override @Override
@Transactional @Transactional
public RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto) { public RestBean<Void> resetPassword(PasswordResetConfirmDto confirmDto) {
// 1. 标准化邮箱
String email = confirmDto.getEmail().trim().toLowerCase(); String email = confirmDto.getEmail().trim().toLowerCase();
// 2. 检查邮箱是否存在
Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email); Optional<User> userOptional = userRepository.findByEmailIgnoreCase(email);
if (userOptional.isEmpty()) { if (userOptional.isEmpty()) {
return RestBean.failure(400, "验证码或邮箱不正确", null); return RestBean.failure(400, "验证码或邮箱不正确", null);
} }
// 3. 获取最新的未使用验证码
PasswordResetCode resetCode = passwordResetCodeRepository PasswordResetCode resetCode = passwordResetCodeRepository
.findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email) .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email)
.orElse(null); .orElse(null);
if (resetCode == null) { if (resetCode == null) {
return RestBean.failure(400, "请先获取验证码", null); return RestBean.failure(400, "请先获取验证码", null);
} }
// 4. 验证验证码是否已使用
if (Boolean.TRUE.equals(resetCode.getUsed())) { if (Boolean.TRUE.equals(resetCode.getUsed())) {
return RestBean.failure(400, "验证码已失效,请重新获取", null); return RestBean.failure(400, "验证码已失效,请重新获取", null);
} }
// 5. 验证验证码是否过期
if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) { if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) {
resetCode.setUsed(true); resetCode.setUsed(true);
passwordResetCodeRepository.save(resetCode); passwordResetCodeRepository.save(resetCode);
return RestBean.failure(400, "验证码已过期,请重新获取", null); return RestBean.failure(400, "验证码已过期,请重新获取", null);
} }
// 6. 验证尝试次数是否超限
if (resetCode.getAttemptCount() >= maxAttempts) { if (resetCode.getAttemptCount() >= maxAttempts) {
resetCode.setUsed(true); resetCode.setUsed(true);
passwordResetCodeRepository.save(resetCode); passwordResetCodeRepository.save(resetCode);
return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null); return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null);
} }
// 7. 验证验证码是否正确
if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) { if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) {
// 7.1 增加尝试次数
resetCode.setAttemptCount(resetCode.getAttemptCount() + 1); resetCode.setAttemptCount(resetCode.getAttemptCount() + 1);
// 7.2 如果尝试次数超限,标记为已使用
if (resetCode.getAttemptCount() >= maxAttempts) { if (resetCode.getAttemptCount() >= maxAttempts) {
resetCode.setUsed(true); resetCode.setUsed(true);
} }
@@ -130,31 +238,58 @@ public class PasswordResetServiceImpl implements PasswordResetService {
return RestBean.failure(400, "验证码不正确", null); return RestBean.failure(400, "验证码不正确", null);
} }
// 8. 更新用户密码
User user = userOptional.get(); User user = userOptional.get();
user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword())); user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword()));
user.setPasswordChangedAt(LocalDateTime.now());
userRepository.save(user); userRepository.save(user);
// 9. 标记当前验证码为已使用
resetCode.setUsed(true); resetCode.setUsed(true);
passwordResetCodeRepository.save(resetCode); passwordResetCodeRepository.save(resetCode);
// 10. 将该邮箱的所有其他验证码标记为已使用
List<PasswordResetCode> otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); List<PasswordResetCode> otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email);
for (PasswordResetCode item : otherCodes) { for (PasswordResetCode item : otherCodes) {
item.setUsed(true); item.setUsed(true);
} }
passwordResetCodeRepository.saveAll(otherCodes); passwordResetCodeRepository.saveAll(otherCodes);
log.info("用户通过邮箱验证码重置密码成功: {}", email); log.info("用户通过邮箱验证码重置密码成功 - email: {}", email);
return RestBean.success(RestCode.SUCCESS); return RestBean.success(RestCode.SUCCESS);
} }
/**
* 生成 6 位随机数字验证码
* 步骤:
* 1. 生成 0 到 999999 之间的随机数
* 2. 格式化为 6 位数字(不足补零)
*
* @return 6 位数字验证码
*/
private String generateCode() { private String generateCode() {
return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000)); return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000));
} }
/**
* 对字符串进行 SHA-256 哈希
* 步骤:
* 1. 获取 SHA-256 算法实例
* 2. 对字符串的字节进行哈希
* 3. 将结果转换为十六进制字符串
*
* @param value 待哈希的字符串
* @return 哈希后的十六进制字符串
*/
private String sha256(String value) { private String sha256(String value) {
try { try {
// 1. 获取 SHA-256 算法实例
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
// 2. 对字符串进行哈希
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
// 3. 转换为十六进制字符串
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
for (byte b : hash) { for (byte b : hash) {
String hex = Integer.toHexString(0xff & b); String hex = Integer.toHexString(0xff & b);

View File

@@ -0,0 +1,346 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.dto.MenuDto;
import com.aisi.template.domain.entity.SysMenu;
import com.aisi.template.domain.vo.MenuVo;
import com.aisi.template.exception.BusinessException;
import com.aisi.template.repository.SysMenuRepository;
import com.aisi.template.repository.SysRoleRepository;
import com.aisi.template.repository.UserRepository;
import com.aisi.template.service.SysMenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 系统菜单服务实现类
* 负责菜单的管理和树形结构构建
*
* 主要功能:
* 1. 菜单管理:创建、更新、删除、查询菜单
* 2. 树形结构:构建菜单的层级关系
* 3. 用户菜单:根据用户角色获取可访问的菜单
*
* 菜单类型:
* - DIRECTORY(1):目录,用于分组,不对应具体页面
* - MENU(2):菜单项,对应具体页面
* - BUTTON(3):按钮,页面内的操作按钮
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysMenuServiceImpl implements SysMenuService {
/**
* 菜单数据访问接口
*/
private final SysMenuRepository menuRepository;
/**
* 用户数据访问接口
*/
private final UserRepository userRepository;
/**
* 角色数据访问接口
*/
private final SysRoleRepository roleRepository;
/**
* 创建菜单
* 步骤:
* 1. 如果有父菜单,检查父菜单是否存在
* 2. 构建菜单实体对象
* 3. 保存到数据库
*
* @param menuDto 菜单数据传输对象
* @return 创建的菜单视图对象
* @throws BusinessException 当父菜单不存在时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public MenuVo create(MenuDto menuDto) {
// 1. 检查父菜单是否存在(如果指定了父菜单)
if (menuDto.getParentId() != null && menuDto.getParentId() > 0) {
if (!menuRepository.existsById(menuDto.getParentId())) {
throw new BusinessException("父菜单不存在: " + menuDto.getParentId());
}
}
// 2. 构建菜单实体
SysMenu menu = new SysMenu();
menu.setParentId(menuDto.getParentId());
menu.setMenuName(menuDto.getMenuName());
menu.setMenuType(menuDto.getMenuType());
menu.setMenuPath(menuDto.getMenuPath());
menu.setComponent(menuDto.getComponent());
menu.setIcon(menuDto.getIcon());
menu.setSortOrder(menuDto.getSortOrder());
menu.setVisible(menuDto.getVisible());
menu.setStatus(menuDto.getStatus());
menu.setPermissionCode(menuDto.getPermissionCode());
// 3. 保存到数据库
SysMenu savedMenu = menuRepository.save(menu);
log.info("菜单创建成功 - id: {}, name: {}", savedMenu.getId(), savedMenu.getMenuName());
return convertToVo(savedMenu);
}
/**
* 更新菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 检查是否将菜单设置为自己的子菜单(循环引用)
* 3. 更新菜单信息
*
* @param id 菜单ID
* @param menuDto 菜单数据传输对象
* @return 更新后的菜单视图对象
* @throws BusinessException 当菜单不存在或产生循环引用时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public MenuVo update(Long id, MenuDto menuDto) {
// 1. 检查菜单是否存在
SysMenu menu = menuRepository.findById(id)
.orElseThrow(() -> new BusinessException("菜单不存在: " + id));
// 2. 检查是否将菜单设置为自己的子菜单
if (menuDto.getParentId() != null && menuDto.getParentId().equals(id)) {
throw new BusinessException("不能将菜单设置为自己的子菜单");
}
// 3. 更新菜单信息
menu.setParentId(menuDto.getParentId());
menu.setMenuName(menuDto.getMenuName());
menu.setMenuType(menuDto.getMenuType());
menu.setMenuPath(menuDto.getMenuPath());
menu.setComponent(menuDto.getComponent());
menu.setIcon(menuDto.getIcon());
menu.setSortOrder(menuDto.getSortOrder());
menu.setVisible(menuDto.getVisible());
menu.setStatus(menuDto.getStatus());
menu.setPermissionCode(menuDto.getPermissionCode());
// 4. 保存更新
SysMenu savedMenu = menuRepository.save(menu);
log.info("菜单更新成功 - id: {}, name: {}", savedMenu.getId(), savedMenu.getMenuName());
return convertToVo(savedMenu);
}
/**
* 删除菜单
* 步骤:
* 1. 检查菜单是否存在
* 2. 删除菜单(子菜单会级联删除)
*
* 注意:
* - 删除父菜单会自动删除所有子菜单
* - 建议删除前检查是否有子菜单
*
* @param id 菜单ID
* @throws BusinessException 当菜单不存在时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
// 1. 检查菜单是否存在
if (!menuRepository.existsById(id)) {
throw new BusinessException("菜单不存在: " + id);
}
// 2. 删除菜单(子菜单会级联删除)
menuRepository.deleteById(id);
log.info("菜单删除成功 - id: {}", id);
}
/**
* 根据ID获取菜单
* 步骤:
* 1. 查询菜单
* 2. 转换为视图对象返回
*
* @param id 菜单ID
* @return 菜单视图对象
* @throws BusinessException 当菜单不存在时抛出异常
*/
@Override
public MenuVo getById(Long id) {
// 1. 查询菜单
SysMenu menu = menuRepository.findById(id)
.orElseThrow(() -> new BusinessException("菜单不存在: " + id));
// 2. 转换为视图对象
return convertToVo(menu);
}
/**
* 获取菜单树
* 步骤:
* 1. 查询所有启用状态的菜单
* 2. 按排序字段排序
* 3. 递归构建树形结构
*
* 树形结构示例:
* - 系统管理 (parentId = null)
* - 用户管理 (parentId = 1)
* - 添加按钮 (parentId = 2)
* - 编辑按钮 (parentId = 2)
* - 角色管理 (parentId = 1)
*
* @return 菜单树列表
*/
@Override
public List<MenuVo> getMenuTree() {
// 1. 查询所有启用状态的菜单,按排序字段排序
List<SysMenu> allMenus = menuRepository.findByStatusOrderBySortOrder(1);
// 2. 构建树形结构(根节点的 parentId 为 0
return buildMenuTree(allMenus, 0L);
}
/**
* 获取用户菜单
* 步骤:
* 1. 查询用户及其角色
* 2. 根据角色查询可访问的菜单
* 3. 过滤掉按钮类型(只返回目录和菜单)
* 4. 构建树形结构
*
* 说明:
* - 只返回目录(DIRECTORY)和菜单(MENU),不返回按钮(BUTTON)
* - 按钮权限通过前端的权限指令控制
*
* @param userId 用户ID
* @return 用户可访问的菜单树
* @throws BusinessException 当用户不存在时抛出异常
*/
@Override
public List<MenuVo> getUserMenus(Long userId) {
// 1. 查询用户及其角色
var user = userRepository.findByIdWithRoles(userId)
.orElseThrow(() -> new BusinessException("用户不存在: " + userId));
// 2. 提取角色ID列表
List<Long> roleIds = user.getRoles().stream()
.map(role -> role.getId())
.collect(Collectors.toList());
// 3. 如果用户没有角色,返回空列表
if (roleIds.isEmpty()) {
return new ArrayList<>();
}
// 4. 查询角色可访问的菜单
// 4.1 查询目录类型menuType = 1
// 4.2 查询菜单类型menuType = 2
// 注意不查询按钮类型menuType = 3
Set<SysMenu> menus = new java.util.HashSet<>(menuRepository.findByRoleIdsAndMenuType(roleIds, 2)); // 菜单
menus.addAll(menuRepository.findByRoleIdsAndMenuType(roleIds, 1)); // 目录
// 5. 构建树形结构
return buildMenuTree(new ArrayList<>(menus), 0L);
}
/**
* 根据父ID获取子菜单
* 步骤:
* 1. 查询指定父ID下的所有子菜单
* 2. 按排序字段排序
* 3. 转换为视图对象列表
*
* @param parentId 父菜单IDnull 表示查询根菜单)
* @return 子菜单列表
*/
@Override
public List<MenuVo> getByParentId(Long parentId) {
// 1. 查询子菜单,按排序字段排序
List<SysMenu> menus = menuRepository.findByParentIdOrderBySortOrder(parentId);
// 2. 转换为视图对象列表
return menus.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
/**
* 递归构建菜单树
* 步骤:
* 1. 遍历所有菜单
* 2. 找到当前父ID的子菜单
* 3. 递归查找子菜单的子菜单
* 4. 构建树形结构
*
* @param menus 所有菜单列表
* @param parentId 父菜单ID
* @return 子菜单树列表
*/
private List<MenuVo> buildMenuTree(List<SysMenu> menus, Long parentId) {
List<MenuVo> result = new ArrayList<>();
// 1. 遍历所有菜单找到当前父ID的子菜单
for (SysMenu menu : menus) {
// 处理 parentId 为 null 的情况(根节点)
Long menuParentId = menu.getParentId() == null ? 0L : menu.getParentId();
if (menuParentId.equals(parentId)) {
// 2. 转换为视图对象
MenuVo vo = convertToVo(menu);
// 3. 递归查找子菜单
vo.setChildren(buildMenuTree(menus, menu.getId()));
// 4. 添加到结果列表
result.add(vo);
}
}
return result;
}
/**
* 将菜单实体转换为视图对象
* 步骤:
* 1. 复制基本信息
* 2. 复制菜单类型和路径
* 3. 复制显示和状态信息
* 4. 复制时间信息
*
* @param menu 菜单实体
* @return 菜单视图对象
*/
private MenuVo convertToVo(SysMenu menu) {
MenuVo vo = new MenuVo();
// 1. 复制基本信息
vo.setId(menu.getId());
vo.setParentId(menu.getParentId());
vo.setMenuName(menu.getMenuName());
// 2. 复制菜单类型和路径
vo.setMenuType(menu.getMenuType());
vo.setMenuPath(menu.getMenuPath());
vo.setComponent(menu.getComponent());
vo.setIcon(menu.getIcon());
// 3. 复制排序和状态信息
vo.setSortOrder(menu.getSortOrder());
vo.setVisible(menu.getVisible());
vo.setStatus(menu.getStatus());
vo.setPermissionCode(menu.getPermissionCode());
// 4. 复制时间信息
vo.setCreatedAt(menu.getCreatedAt());
vo.setUpdatedAt(menu.getUpdatedAt());
return vo;
}
}

View File

@@ -0,0 +1,157 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.entity.SysPermission;
import com.aisi.template.domain.vo.PermissionVo;
import com.aisi.template.exception.BusinessException;
import com.aisi.template.repository.SysPermissionRepository;
import com.aisi.template.service.SysPermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 系统权限服务实现类
* 负责权限的查询和管理
*
* 主要功能:
* 1. 权限查询获取所有权限、按ID查询、按资源查询、按操作查询
* 2. 权限转换:将权限实体转换为视图对象
*
* 设计说明:
* - 权限是系统预定义的,通过数据库迁移脚本初始化
* - 不支持动态创建/删除权限(保证系统稳定性)
* - 权限通过角色分配给用户
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysPermissionServiceImpl implements SysPermissionService {
/**
* 权限数据访问接口
*/
private final SysPermissionRepository permissionRepository;
/**
* 获取所有权限
* 步骤:
* 1. 查询所有启用状态的权限
* 2. 转换为视图对象列表
* 3. 按资源和操作排序返回
*
* @return 所有权限视图对象列表
*/
@Override
public List<PermissionVo> getAllPermissions() {
// 1. 查询所有启用状态的权限status = 1
List<SysPermission> permissions = permissionRepository.findByStatus(1);
// 2. 转换为视图对象列表
return permissions.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
/**
* 根据ID获取权限
* 步骤:
* 1. 根据ID查询权限
* 2. 如果不存在,抛出异常
* 3. 转换为视图对象返回
*
* @param id 权限ID
* @return 权限视图对象
* @throws BusinessException 当权限不存在时抛出异常
*/
@Override
public PermissionVo getById(Long id) {
// 1. 查询权限
SysPermission permission = permissionRepository.findById(id)
.orElseThrow(() -> new BusinessException("权限不存在: " + id));
// 2. 转换为视图对象
return convertToVo(permission);
}
/**
* 根据资源获取权限
* 步骤:
* 1. 按资源名称查询权限
* 2. 转换为视图对象列表
*
* 示例:
* - resource="user" 返回 user:read, user:write, user:delete
*
* @param resource 资源名称user, role, menu
* @return 该资源的所有权限
*/
@Override
public List<PermissionVo> getByResource(String resource) {
// 1. 按资源查询权限
List<SysPermission> permissions = permissionRepository.findByResource(resource);
// 2. 转换为视图对象列表
return permissions.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
/**
* 根据操作类型获取权限
* 步骤:
* 1. 按操作类型查询权限
* 2. 转换为视图对象列表
*
* 示例:
* - action="read" 返回 user:read, role:read, menu:read 等
*
* @param action 操作类型read, write, delete
* @return 拥有该操作的所有权限
*/
@Override
public List<PermissionVo> getByAction(String action) {
// 1. 按操作类型查询权限
List<SysPermission> permissions = permissionRepository.findByAction(action);
// 2. 转换为视图对象列表
return permissions.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
/**
* 将权限实体转换为视图对象
* 步骤:
* 1. 复制基本信息ID、编码、名称
* 2. 复制资源操作信息
* 3. 复制描述和时间信息
*
* @param permission 权限实体
* @return 权限视图对象
*/
private PermissionVo convertToVo(SysPermission permission) {
PermissionVo vo = new PermissionVo();
// 1. 复制基本信息
vo.setId(permission.getId());
vo.setPermissionCode(permission.getPermissionCode());
vo.setPermissionName(permission.getPermissionName());
// 2. 复制资源操作信息
vo.setResource(permission.getResource());
vo.setAction(permission.getAction());
// 3. 复制其他信息
vo.setDescription(permission.getDescription());
vo.setStatus(permission.getStatus());
vo.setCreatedAt(permission.getCreatedAt());
vo.setUpdatedAt(permission.getUpdatedAt());
return vo;
}
}

View File

@@ -0,0 +1,343 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.dto.RoleDto;
import com.aisi.template.domain.dto.RoleQueryDto;
import com.aisi.template.domain.entity.SysPermission;
import com.aisi.template.domain.entity.SysRole;
import com.aisi.template.domain.dto.PageResult;
import com.aisi.template.domain.vo.PermissionVo;
import com.aisi.template.domain.vo.RoleVo;
import com.aisi.template.exception.BusinessException;
import com.aisi.template.repository.SysPermissionRepository;
import com.aisi.template.repository.SysRoleRepository;
import com.aisi.template.service.SysRoleService;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 系统角色服务实现类
* 提供角色的 CRUD 操作、权限分配、查询等功能
*
* 主要功能:
* 1. 角色基本操作:创建、更新、删除、查询
* 2. 权限管理:为角色分配权限、获取角色权限
* 3. 分页查询:支持条件查询和分页
*
* @author Claude
* @since 2024-04-09
*/
@Service
@RequiredArgsConstructor
public class SysRoleServiceImpl implements SysRoleService {
/**
* 角色数据访问接口
* 用于角色的数据库操作
*/
private final SysRoleRepository roleRepository;
/**
* 权限数据访问接口
* 用于权限的数据库操作
*/
private final SysPermissionRepository permissionRepository;
/**
* 创建角色
* 步骤:
* 1. 校验角色编码是否已存在
* 2. 构建角色实体对象
* 3. 保存到数据库
*
* @param roleDto 角色数据传输对象
* @return 创建的角色视图对象
* @throws BusinessException 当角色编码已存在时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public RoleVo create(RoleDto roleDto) {
// 1. 检查角色编码是否已存在
roleRepository.findByRoleCode(roleDto.getRoleCode()).ifPresent(role -> {
throw new BusinessException("角色编码已存在: " + role.getRoleCode());
});
// 2. 构建角色实体
SysRole role = new SysRole();
role.setRoleCode(roleDto.getRoleCode());
role.setRoleName(roleDto.getRoleName());
role.setDescription(roleDto.getDescription());
role.setSortOrder(roleDto.getSortOrder());
role.setStatus(roleDto.getStatus());
// 3. 保存角色到数据库
SysRole savedRole = roleRepository.save(role);
return convertToVo(savedRole);
}
/**
* 更新角色
* 步骤:
* 1. 检查角色是否存在
* 2. 如果修改了角色编码,检查新编码是否已被使用
* 3. 更新角色信息
*
* @param id 角色ID
* @param roleDto 角色数据传输对象
* @return 更新后的角色视图对象
* @throws BusinessException 当角色不存在或编码冲突时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public RoleVo update(Long id, RoleDto roleDto) {
// 1. 查询角色是否存在
SysRole role = roleRepository.findById(id)
.orElseThrow(() -> new BusinessException("角色不存在: " + id));
// 2. 如果修改了角色编码,检查新编码是否已被使用
if (!role.getRoleCode().equals(roleDto.getRoleCode())) {
roleRepository.findByRoleCode(roleDto.getRoleCode()).ifPresent(r -> {
throw new BusinessException("角色编码已存在: " + r.getRoleCode());
});
}
// 3. 更新角色信息
role.setRoleCode(roleDto.getRoleCode());
role.setRoleName(roleDto.getRoleName());
role.setDescription(roleDto.getDescription());
role.setSortOrder(roleDto.getSortOrder());
role.setStatus(roleDto.getStatus());
// 4. 保存更新
SysRole savedRole = roleRepository.save(role);
return convertToVo(savedRole);
}
/**
* 删除角色
* 步骤:
* 1. 检查角色是否存在
* 2. 删除角色(关联的权限关系会自动级联删除)
*
* 注意:如果角色已分配给用户,需要先解除关联
*
* @param id 角色ID
* @throws BusinessException 当角色不存在时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
// 1. 检查角色是否存在
if (!roleRepository.existsById(id)) {
throw new BusinessException("角色不存在: " + id);
}
// 2. 删除角色
roleRepository.deleteById(id);
}
/**
* 根据ID获取角色
* 步骤:
* 1. 查询角色基本信息
* 2. 使用 JOIN FETCH 一次性加载关联的权限(避免 N+1 问题)
* 3. 转换为视图对象返回
*
* @param id 角色ID
* @return 角色视图对象
* @throws BusinessException 当角色不存在时抛出异常
*/
@Override
public RoleVo getById(Long id) {
// 1. 查询角色(使用 JOIN FETCH 避免懒加载问题)
SysRole role = roleRepository.findByIdWithPermissions(id)
.orElseThrow(() -> new BusinessException("角色不存在: " + id));
// 2. 转换为视图对象
return convertToVo(role);
}
/**
* 获取所有角色
* 步骤:
* 1. 查询所有角色
* 2. 使用 JOIN FETCH 预加载权限
* 3. 转换为视图对象列表
*
* @return 角色视图对象列表
*/
@Override
public List<RoleVo> getAllRoles() {
// 1. 查询所有角色(使用 JOIN FETCH 避免懒加载问题)
List<SysRole> roles = roleRepository.findAllWithPermissions();
// 2. 转换为视图对象列表
return roles.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
/**
* 分页查询角色
* 步骤:
* 1. 构建动态查询条件(支持角色编码、名称、状态)
* 2. 执行分页查询
* 3. 转换结果为视图对象
*
* @param queryDto 查询条件对象
* @param page 页码(从 0 开始)
* @param size 每页大小
* @return 分页结果
*/
@Override
public PageResult<RoleVo> queryRoles(RoleQueryDto queryDto, int page, int size) {
// 1. 构建动态查询条件
Specification<SysRole> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 1.1 角色编码模糊查询
if (queryDto.getRoleCode() != null && !queryDto.getRoleCode().isEmpty()) {
predicates.add(cb.like(root.get("roleCode"), "%" + queryDto.getRoleCode() + "%"));
}
// 1.2 角色名称模糊查询
if (queryDto.getRoleName() != null && !queryDto.getRoleName().isEmpty()) {
predicates.add(cb.like(root.get("roleName"), "%" + queryDto.getRoleName() + "%"));
}
// 1.3 状态精确查询
if (queryDto.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), queryDto.getStatus()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
// 2. 执行分页查询
Page<SysRole> rolePage = roleRepository.findAll(spec, PageRequest.of(page, size));
// 3. 转换为分页结果
return PageResult.of(
rolePage.getContent().stream().map(this::convertToVo).collect(Collectors.toList()),
rolePage.getTotalElements(),
rolePage.getNumber(),
rolePage.getSize()
);
}
/**
* 为角色分配权限
* 步骤:
* 1. 查询角色是否存在
* 2. 根据权限ID列表查询所有权限
* 3. 清空角色原有的权限
* 4. 添加新的权限
* 5. 保存更新
*
* @param roleId 角色ID
* @param permissionIds 权限ID列表
* @throws BusinessException 当角色或权限不存在时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void assignPermissions(Long roleId, List<Long> permissionIds) {
// 1. 检查角色是否存在
SysRole role = roleRepository.findById(roleId)
.orElseThrow(() -> new BusinessException("角色不存在: " + roleId));
// 2. 查询所有权限(如果权限不存在会抛出异常)
Set<SysPermission> permissions = permissionIds.stream()
.map(permissionId -> permissionRepository.findById(permissionId)
.orElseThrow(() -> new BusinessException("权限不存在: " + permissionId)))
.collect(Collectors.toSet());
// 3. 清空原有权限并添加新权限
role.getPermissions().clear();
role.getPermissions().addAll(permissions);
// 4. 保存更新
roleRepository.save(role);
}
/**
* 获取角色的权限ID列表
* 步骤:
* 1. 查询角色(使用 JOIN FETCH 加载权限)
* 2. 提取所有权限的ID
*
* @param roleId 角色ID
* @return 权限ID列表
* @throws BusinessException 当角色不存在时抛出异常
*/
@Override
public List<Long> getRolePermissionIds(Long roleId) {
// 1. 查询角色及权限
SysRole role = roleRepository.findByIdWithPermissions(roleId)
.orElseThrow(() -> new BusinessException("角色不存在: " + roleId));
// 2. 提取权限ID列表
return role.getPermissions().stream()
.map(SysPermission::getId)
.collect(Collectors.toList());
}
/**
* 将角色实体转换为视图对象
* 步骤:
* 1. 复制基本信息
* 2. 转换权限列表
*
* @param role 角色实体
* @return 角色视图对象
*/
private RoleVo convertToVo(SysRole role) {
RoleVo vo = new RoleVo();
vo.setId(role.getId());
vo.setRoleCode(role.getRoleCode());
vo.setRoleName(role.getRoleName());
vo.setDescription(role.getDescription());
vo.setSortOrder(role.getSortOrder());
vo.setStatus(role.getStatus());
vo.setCreatedAt(role.getCreatedAt());
vo.setUpdatedAt(role.getUpdatedAt());
// 1. 如果有权限,转换为视图对象
if (role.getPermissions() != null && !role.getPermissions().isEmpty()) {
Set<PermissionVo> permissionVos = role.getPermissions().stream()
.map(this::convertToPermissionVo)
.collect(Collectors.toSet());
vo.setPermissions(permissionVos);
}
return vo;
}
/**
* 将权限实体转换为视图对象
*
* @param permission 权限实体
* @return 权限视图对象
*/
private PermissionVo convertToPermissionVo(SysPermission permission) {
PermissionVo vo = new PermissionVo();
vo.setId(permission.getId());
vo.setPermissionCode(permission.getPermissionCode());
vo.setPermissionName(permission.getPermissionName());
vo.setResource(permission.getResource());
vo.setAction(permission.getAction());
vo.setDescription(permission.getDescription());
vo.setStatus(permission.getStatus());
vo.setCreatedAt(permission.getCreatedAt());
vo.setUpdatedAt(permission.getUpdatedAt());
return vo;
}
}

View File

@@ -0,0 +1,363 @@
package com.aisi.template.service.impl;
import com.aisi.template.domain.entity.RefreshToken;
import com.aisi.template.domain.entity.User;
import com.aisi.template.domain.vo.LoginResponseVo;
import com.aisi.template.exception.BusinessException;
import com.aisi.template.repository.RefreshTokenRepository;
import com.aisi.template.repository.UserRepository;
import com.aisi.template.service.TokenService;
import com.aisi.template.utils.JwtUtil;
import com.aisi.template.utils.RedisUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Token 服务实现类
* 负责 Refresh Token 的生成、验证、刷新和黑名单管理
*
* 主要功能:
* 1. Refresh Token 管理:生成、存储、验证、撤销
* 2. Token 黑名单:将失效的 JWT 加入 Redis 黑名单
* 3. Token 刷新:使用 Refresh Token 获取新的 Access Token
* 4. 清理任务:清理过期的 Token 和黑名单记录
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {
/**
* Refresh Token 数据访问接口
*/
private final RefreshTokenRepository refreshTokenRepository;
/**
* 用户数据访问接口
*/
private final UserRepository userRepository;
/**
* JWT 工具类
*/
private final JwtUtil jwtUtil;
/**
* Redis 工具类
*/
private final RedisUtils redisUtils;
/**
* Refresh Token 过期时间(秒),从配置读取,默认 7 天
*/
@Value("${jwt.refresh-token-expiration:604800}")
private long refreshTokenExpirationSeconds;
/**
* Access Token 过期时间(秒),从配置读取,默认 1 小时
*/
@Value("${jwt.access-token-expiration:3600}")
private long accessTokenExpirationSeconds;
/**
* Token 黑名单 Redis 键前缀
*/
private static final String BLACKLIST_KEY_PREFIX = "token:blacklist:";
/**
* Refresh Token Redis 键前缀
*/
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 创建 Refresh Token
* 步骤:
* 1. 生成随机 UUID 作为 Token
* 2. 对 Token 进行 SHA-256 哈希(数据库只存储哈希值)
* 3. 计算 Token 过期时间
* 4. 保存到数据库
* 5. 同时在 Redis 中缓存一份(用于快速查找)
*
* @param userId 用户ID
* @param deviceInfo 设备信息iPhone 14 Pro
* @param ipAddress IP地址
* @return Refresh Token 原始值(未哈希)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String createRefreshToken(Long userId, String deviceInfo, String ipAddress) {
// 1. 生成随机的 Refresh Token
String refreshToken = UUID.randomUUID().toString().replace("-", "");
String tokenHash = hashToken(refreshToken);
// 2. 计算过期时间
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(refreshTokenExpirationSeconds);
// 3. 创建 Refresh Token 实体
RefreshToken tokenEntity = new RefreshToken();
tokenEntity.setUserId(userId);
tokenEntity.setTokenHash(tokenHash);
tokenEntity.setDeviceInfo(deviceInfo);
tokenEntity.setIpAddress(ipAddress);
tokenEntity.setExpiresAt(expiresAt);
tokenEntity.setRevoked(false);
// 4. 保存到数据库
refreshTokenRepository.save(tokenEntity);
// 5. 在 Redis 中缓存一份,过期时间与 Refresh Token 保持一致
// 作用:快速验证 Refresh Token减少数据库查询
redisUtils.set(REFRESH_TOKEN_PREFIX + tokenHash, String.valueOf(userId),
refreshTokenExpirationSeconds, TimeUnit.SECONDS);
log.info("Refresh Token 创建成功 - userId: {}, deviceInfo: {}", userId, deviceInfo);
return refreshToken;
}
/**
* 使用 Refresh Token 刷新 Access Token
* 步骤:
* 1. 对 Refresh Token 进行哈希
* 2. 先从 Redis 查找,找不到再查数据库
* 3. 验证 Token 是否有效(未过期、未撤销)
* 4. 获取用户信息并验证用户状态
* 5. 生成新的 Access Token
* 6. 生成新的 Refresh TokenToken 轮换机制)
* 7. 撤销旧的 Refresh Token
*
* @param refreshToken Refresh Token 原始值
* @return 登录响应(包含新的 Access Token 和 Refresh Token
* @throws BusinessException 当 Token 无效或用户状态异常时抛出异常
*/
@Override
@Transactional(rollbackFor = Exception.class)
public LoginResponseVo refreshToken(String refreshToken) {
// 1. 计算 Token 哈希值
String tokenHash = hashToken(refreshToken);
// 2. 先从 Redis 查找(快速路径)
String cachedUserId = redisUtils.get(REFRESH_TOKEN_PREFIX + tokenHash, String.class);
if (cachedUserId == null) {
// 2.1 Redis 没找到,查数据库(慢速路径)
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
.orElse(null);
if (tokenEntity == null) {
throw new BusinessException("Refresh Token 无效");
}
if (!tokenEntity.isValid()) {
throw new BusinessException("Refresh Token 已过期或已撤销");
}
}
// 3. 获取 Token 实体(从数据库)
RefreshToken tokenEntity = refreshTokenRepository.findByTokenHash(tokenHash)
.orElseThrow(() -> new BusinessException("Refresh Token 无效"));
// 4. 验证 Token 是否有效
if (!tokenEntity.isValid()) {
throw new BusinessException("Refresh Token 已过期或已撤销");
}
// 5. 获取用户信息
User user = userRepository.findById(tokenEntity.getUserId())
.orElseThrow(() -> new BusinessException("用户不存在"));
// 6. 检查用户状态
if (!user.isEnabled()) {
throw new BusinessException("用户账户已被禁用");
}
// 7. 生成新的 Access Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
// 8. 生成新的 Refresh TokenToken 轮换)
// Token 轮换:每次刷新都生成新的 Refresh Token旧 Token 失效
// 好处:提高安全性,一旦 Token 泄露,使用后即失效
revokeRefreshToken(tokenHash);
String newRefreshToken = createRefreshToken(
user.getId(),
tokenEntity.getDeviceInfo(),
tokenEntity.getIpAddress()
);
// 9. 构建响应
LoginResponseVo response = LoginResponseVo.builder()
.accessToken(accessToken)
.refreshToken(newRefreshToken)
.expiresIn(accessTokenExpirationSeconds)
.userInfo(LoginResponseVo.UserInfoVo.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.build())
.build();
log.info("Token 刷新成功 - userId: {}", user.getId());
return response;
}
/**
* 撤销 Refresh Token
* 步骤:
* 1. 查找 Token 实体
* 2. 标记为已撤销
* 3. 记录撤销时间
* 4. 删除 Redis 缓存
*
* @param tokenHash Token 哈希值
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeRefreshToken(String tokenHash) {
// 1. 查找 Token 实体
RefreshToken token = refreshTokenRepository.findByTokenHash(tokenHash)
.orElse(null);
if (token != null) {
// 2. 标记为已撤销
token.setRevoked(true);
token.setRevokedAt(LocalDateTime.now());
refreshTokenRepository.save(token);
// 3. 删除 Redis 缓存
redisUtils.delete(REFRESH_TOKEN_PREFIX + tokenHash);
log.info("Refresh Token 已撤销 - tokenHash: {}", tokenHash);
}
}
/**
* 撤销用户的所有 Refresh Token
* 步骤:
* 1. 将用户所有有效 Token 标记为已撤销
* 2. 删除对应的 Redis 缓存
*
* 使用场景:
* - 用户修改密码后,撤销所有 Token
* - 用户登出所有设备
* - 用户账户被禁用
*
* @param userId 用户ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void revokeAllUserTokens(Long userId) {
// 1. 查找用户所有有效 Token用于删除 Redis 缓存
var tokens = refreshTokenRepository.findValidTokensByUserId(userId, LocalDateTime.now());
// 2. 撤销数据库中所有 Token
refreshTokenRepository.revokeAllUserTokens(userId, LocalDateTime.now());
// 3. 删除 Redis 缓存
tokens.forEach(token -> redisUtils.delete(REFRESH_TOKEN_PREFIX + token.getTokenHash()));
log.info("用户所有 Refresh Token 已撤销 - userId: {}", userId);
}
/**
* 检查 Token 是否在黑名单中
* 步骤:
* 1. 构建 Redis 黑名单键
* 2. 检查键是否存在
*
* @param jti JWT IDJWT 唯一标识)
* @return 是否在黑名单中
*/
@Override
public boolean isTokenBlacklisted(String jti) {
String key = BLACKLIST_KEY_PREFIX + jti;
return Boolean.TRUE.equals(redisUtils.hasKey(key));
}
/**
* 将 Token 加入黑名单
* 步骤:
* 1. 构建 Redis 黑名单键
* 2. 设置黑名单记录,过期时间与 Token 剩余有效期一致
*
* 使用场景:
* - 用户登出时,将当前 Token 加入黑名单
* - Token 刷新时,将旧 Token 加入黑名单
*
* @param jti JWT ID
* @param expirationSeconds 过期时间(秒)
*/
@Override
public void addTokenToBlacklist(String jti, long expirationSeconds) {
String key = BLACKLIST_KEY_PREFIX + jti;
// 设置黑名单记录,过期时间与 Token 剩余有效期一致
// 作用:自动清理过期的黑名单记录,避免 Redis 内存溢出
redisUtils.set(key, "1", expirationSeconds, TimeUnit.SECONDS);
log.info("Token 已加入黑名单 - jti: {}, expire: {}s", jti, expirationSeconds);
}
/**
* 清理过期的 Refresh Token
* 步骤:
* 1. 查找所有过期的 Token
* 2. 批量删除
*
* 建议通过定时任务定期执行(如每天凌晨)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void cleanupExpiredTokens() {
LocalDateTime now = LocalDateTime.now();
refreshTokenRepository.deleteByExpiresAtBefore(now);
log.info("过期 Refresh Token 清理完成");
}
/**
* 清理过期的黑名单记录
* 说明:
* Redis 的黑名单记录设置了过期时间,会自动删除
* 此方法仅作为日志记录,实际无需执行
*/
@Override
public void cleanupExpiredBlacklistEntries() {
log.debug("黑名单记录由 Redis 自动过期清理");
}
/**
* 对 Token 进行 SHA-256 哈希
* 步骤:
* 1. 获取 SHA-256 算法实例
* 2. 对 Token 字节进行哈希
* 3. 转换为十六进制字符串
*
* 安全说明:
* - 数据库只存储哈希值,不存储原始 Token
* - 即使数据库泄露,攻击者也无法获得有效的 Refresh Token
*
* @param token 原始 Token
* @return 哈希后的 Token十六进制字符串
*/
private String hashToken(String token) {
try {
// 1. 获取 SHA-256 算法实例
MessageDigest digest = MessageDigest.getInstance("SHA-256");
// 2. 对 Token 进行哈希
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
// 3. 转换为十六进制字符串
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
// 4. SHA-256 是 Java 标准库必须支持的算法,理论上不会抛出此异常
throw new RuntimeException("SHA-256 算法不可用", e);
}
}
}

View File

@@ -7,13 +7,18 @@ import com.aisi.template.domain.dto.UserDto;
import com.aisi.template.domain.dto.UserQueryDto; import com.aisi.template.domain.dto.UserQueryDto;
import com.aisi.template.domain.dto.UserRoleUpdateDto; import com.aisi.template.domain.dto.UserRoleUpdateDto;
import com.aisi.template.domain.dto.UserStatusUpdateDto; import com.aisi.template.domain.dto.UserStatusUpdateDto;
import com.aisi.template.domain.entity.SysRole;
import com.aisi.template.domain.entity.User; import com.aisi.template.domain.entity.User;
import com.aisi.template.domain.enums.Role; import com.aisi.template.domain.vo.LoginResponseVo;
import com.aisi.template.domain.vo.UserVo; import com.aisi.template.domain.vo.UserVo;
import com.aisi.template.repository.SysRoleRepository;
import com.aisi.template.repository.UserRepository; import com.aisi.template.repository.UserRepository;
import com.aisi.template.service.TokenService;
import com.aisi.template.service.UserService; import com.aisi.template.service.UserService;
import com.aisi.template.utils.JwtUtil; import com.aisi.template.utils.JwtUtil;
import com.aisi.template.service.LoginAttemptService;
import com.aisi.template.utils.SecurityUtils; import com.aisi.template.utils.SecurityUtils;
import com.aisi.template.mq.producer.UserMessageProducer;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -24,108 +29,303 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* 用户服务实现类
* 负责用户相关的业务逻辑处理
*
* 主要功能:
* 1. 用户注册:校验、创建、分配默认角色、生成 Token
* 2. 用户登录:验证、状态检查、失败计数、生成 Token
* 3. 用户信息:获取当前用户信息
* 4. 用户管理:分页查询、状态更新、角色分配
*
* 事务说明:
* - 注册操作:需要事务保证数据一致性
* - 状态更新:需要事务保证数据一致性
* - 角色更新:需要事务保证数据一致性
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserServiceImpl implements UserService { public class UserServiceImpl implements UserService {
/**
* 密码编码器
* 使用 BCrypt 算法对密码进行加密
*/
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
/**
* JWT 工具类
* 用于生成和验证 JWT Token
*/
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
/**
* 用户数据访问接口
*/
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* 角色数据访问接口
*/
private final SysRoleRepository roleRepository;
/**
* Token 服务
* 处理 Refresh Token 和黑名单
*/
private final TokenService tokenService;
/**
* Security 工具类
* 获取当前登录用户信息
*/
private final SecurityUtils securityUtils; private final SecurityUtils securityUtils;
/**
* 登录尝试服务
* 处理登录失败计数和账户锁定
*/
private final LoginAttemptService loginAttemptService;
/**
* 获取当前用户信息
* 步骤:
* 1. 从 SecurityContext 获取当前用户名
* 2. 使用 JOIN FETCH 一次性加载用户及其角色
* 3. 转换为视图对象返回
*
* @return 用户信息视图对象
*/
@Override @Override
public RestBean<UserVo> getUserInfo() { public RestBean<UserVo> getUserInfo() {
// 1. 获取当前登录用户名
String username = SecurityUtils.getUsername(); String username = SecurityUtils.getUsername();
User user = userRepository.findByUsername(username)
// 2. 查询用户(使用 JOIN FETCH 避免懒加载问题)
User user = userRepository.findByUsernameWithRoles(username)
.orElse(null); .orElse(null);
// 3. 用户不存在则返回错误
if (user == null) { if (user == null) {
return RestBean.failure(RestCode.DATA_NOT_FOUND); return RestBean.failure(RestCode.DATA_NOT_FOUND);
} }
// 转换为 UserVo 返回
UserVo userVo = new UserVo(); // 4. 转换为视图对象
userVo.setId(user.getId()); UserVo userVo = convertToVo(user);
userVo.setUsername(user.getUsername());
userVo.setEmail(user.getEmail());
userVo.setStatus(user.getStatus());
userVo.setRole(user.getRole().name());
userVo.setCreatedAt(user.getCreatedAt());
userVo.setUpdatedAt(user.getUpdatedAt());
return RestBean.success(userVo); return RestBean.success(userVo);
} }
/**
* 用户注册
* 步骤:
* 1. 校验用户名是否已存在
* 2. 校验邮箱是否已存在(不区分大小写)
* 3. 创建用户实体
* 4. 分配默认角色ROLE_USER
* 5. 保存到数据库
* 6. 生成 Access Token 和 Refresh Token
*
* 注意:
* - 密码使用 BCrypt 加密存储
* - 新用户默认分配 ROLE_USER 角色
* - 返回 Token 可直接用于后续请求
*
* @param userDto 用户注册信息
* @return 登录响应(包含 Token 和用户信息)
*/
@Override @Override
public RestBean<String> register(UserDto userDto) { @Transactional(rollbackFor = Exception.class)
String normalizedEmail = userDto.getEmail() == null ? null : userDto.getEmail().trim().toLowerCase(Locale.ROOT); public RestBean<LoginResponseVo> register(UserDto userDto) {
// 检查用户名是否存在 // 1. 标准化邮箱(转小写,去除首尾空格)
String normalizedEmail = userDto.getEmail() == null ? null
: userDto.getEmail().trim().toLowerCase(Locale.ROOT);
// 2. 检查用户名是否已存在
if (userRepository.existsByUsername(userDto.getUsername())) { if (userRepository.existsByUsername(userDto.getUsername())) {
return RestBean.failure(400, "用户名已被使用", null); return RestBean.failure(400, "用户名已被使用", null);
} }
// 检查邮箱是否存在
// 3. 检查邮箱是否已存在(不区分大小写)
if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) { if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) {
return RestBean.failure(400, "邮箱已被使用", null); return RestBean.failure(400, "邮箱已被使用", null);
} }
// 4. 创建用户实体
User user = new User(); User user = new User();
user.setUsername(userDto.getUsername()); user.setUsername(userDto.getUsername());
// 4.1 密码使用 BCrypt 加密
user.setPassword(passwordEncoder.encode(userDto.getPassword())); user.setPassword(passwordEncoder.encode(userDto.getPassword()));
user.setEmail(normalizedEmail); user.setEmail(normalizedEmail);
user.setRole(Role.USER); // 新注册用户默认为普通用户
// 默认状态为1正常已在实体类中设置
// 5. 分配默认角色ROLE_USER
SysRole userRole = roleRepository.findByRoleCode("ROLE_USER")
.orElseThrow(() -> new RuntimeException("默认角色 ROLE_USER 未找到"));
user.setRoles(Set.of(userRole));
// 6. 保存用户到数据库
userRepository.save(user); userRepository.save(user);
// 生成token // 7. 生成 Access Token
String token = jwtUtil.generateToken(user.getId(), user.getUsername()); String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
return RestBean.success(token);
// 8. 生成 Refresh Token
String refreshToken = tokenService.createRefreshToken(user.getId(), null, null);
// 9. 构建登录响应
LoginResponseVo response = LoginResponseVo.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(jwtUtil.getExpirationSeconds())
.userInfo(LoginResponseVo.UserInfoVo.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.build())
.build();
// 10. 发送注册消息到 MQ异步处理
// messageProducer.sendRegisterMessage(user.getId(), user.getUsername(), user.getEmail());
log.info("用户注册成功 - userId: {}, username: {}", user.getId(), user.getUsername());
return RestBean.success(response);
} }
/**
* 用户登录
* 步骤:
* 1. 根据用户名查找用户
* 2. 检查账户是否被锁定
* 3. 检查账户是否被禁用
* 4. 验证密码
* 5. 记录登录失败(如果密码错误)
* 6. 登录成功后重置失败计数
* 7. 生成 Access Token 和 Refresh Token
* 8. 发送登录消息到 MQ
*
* 安全机制:
* - 连续失败 5 次锁定账户 30 分钟
* - 密码错误会记录失败次数
* - 登录成功后重置失败计数
*
* @param userDto 登录信息
* @return 登录响应(包含 Token 和用户信息)
*/
@Override @Override
public RestBean<String> login(UserDto userDto) { public RestBean<LoginResponseVo> login(UserDto userDto) {
// 查找用户 // 1. 查找用户
User user = userRepository.findByUsername(userDto.getUsername()) User user = userRepository.findByUsername(userDto.getUsername())
.orElse(null); .orElse(null);
// 2. 用户不存在
if (user == null) { if (user == null) {
return RestBean.failure(RestCode.DATA_NOT_FOUND, "用户不存在"); return RestBean.failure(RestCode.DATA_NOT_FOUND.getCode(), "用户不存在", null);
} }
// 检查用户状态 // 3. 检查账户是否被锁定
if (user.isLocked()) {
Long remainingMinutes = loginAttemptService.getRemainingLockTimeMinutes(userDto.getUsername());
return RestBean.failure(RestCode.USER_LOCKED.getCode(),
String.format("账户已被锁定,请在 %d 分钟后重试", remainingMinutes), null);
}
// 4. 检查用户状态(是否被禁用)
if (!user.isEnabled()) { if (!user.isEnabled()) {
return RestBean.failure(403, "用户已被禁用", null); return RestBean.failure(RestCode.USER_DISABLED.getCode(), "用户已被禁用", null);
} }
// 验证密码 // 5. 验证密码
if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) { if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) {
return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); // 5.1 记录失败尝试
boolean shouldLock = loginAttemptService.recordFailedAttempt(userDto.getUsername());
if (shouldLock) {
// 5.2 账户被锁定
return RestBean.failure(RestCode.USER_LOCKED.getCode(),
String.format("登录失败次数过多,账户已被锁定 %d 分钟", 30), null);
}
// 5.3 密码错误,返回通用错误信息(防止用户名枚举)
return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR.getCode(), "用户名或密码错误", null);
} }
// 生成token // 6. 登录成功,重置失败计数
String token = jwtUtil.generateToken(user.getId(), user.getUsername()); loginAttemptService.resetFailedAttempts(userDto.getUsername());
return RestBean.success(token);
// 7. 生成 Access Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
// 8. 生成 Refresh Token
String refreshToken = tokenService.createRefreshToken(user.getId(), null, null);
// 9. 构建登录响应
LoginResponseVo response = LoginResponseVo.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(jwtUtil.getExpirationSeconds())
.userInfo(LoginResponseVo.UserInfoVo.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.build())
.build();
// 10. 发送登录消息到 MQ异步处理如记录登录日志
// messageProducer.sendLoginMessage(user.getId(), user.getUsername(), ipAddress);
log.info("用户登录成功 - userId: {}, username: {}", user.getId(), user.getUsername());
return RestBean.success(response);
} }
/**
* 分页查询用户列表
* 步骤:
* 1. 构建分页参数(页码从 0 开始)
* 2. 按创建时间倒序排序
* 3. 构建动态查询条件
* 4. 执行分页查询
* 5. 转换结果为视图对象
*
* 查询条件:
* - 关键词:匹配用户名或邮箱
* - 状态:精确匹配
*
* @param queryDto 查询条件
* @return 分页用户列表
*/
@Override @Override
public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) { public RestBean<PageResult<UserVo>> getUserList(UserQueryDto queryDto) {
try { try {
// 1. 构建分页参数
// - 页码从 0 开始(前端传入的页码需要 -1
// - 每页大小至少为 1
Pageable pageable = PageRequest.of( Pageable pageable = PageRequest.of(
Math.max(queryDto.getPage() - 1, 0), Math.max(queryDto.getPage() - 1, 0),
Math.max(queryDto.getSize(), 1), Math.max(queryDto.getSize(), 1),
Sort.by(Sort.Direction.DESC, "createdAt") Sort.by(Sort.Direction.DESC, "createdAt")
); );
// 2. 执行分页查询
Page<User> userPage = userRepository.findAll(buildSpecification(queryDto), pageable); Page<User> userPage = userRepository.findAll(buildSpecification(queryDto), pageable);
// 3. 转换为视图对象列表
List<UserVo> records = userPage.getContent().stream() List<UserVo> records = userPage.getContent().stream()
.map(this::convertToVo) .map(this::convertToVo)
.collect(Collectors.toList()); .collect(Collectors.toList());
// 4. 构建分页结果
return RestBean.success(PageResult.of( return RestBean.success(PageResult.of(
records, records,
userPage.getTotalElements(), userPage.getTotalElements(),
@@ -138,58 +338,127 @@ public class UserServiceImpl implements UserService {
} }
} }
/**
* 更新用户状态
* 步骤:
* 1. 查询用户是否存在
* 2. 检查是否修改当前登录用户的状态(禁止)
* 3. 更新用户状态
* 4. 保存到数据库
*
* 注意:
* - 不能禁用当前登录的用户
* - 状态变更需要重新登录才能完全生效
*
* @param userId 用户ID
* @param updateDto 状态更新请求
* @return 更新后的用户信息
*/
@Override @Override
@Transactional(rollbackFor = Exception.class)
public RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto) { public RestBean<UserVo> updateUserStatus(Long userId, UserStatusUpdateDto updateDto) {
try { try {
// 1. 查询用户
User user = userRepository.findById(userId).orElse(null); User user = userRepository.findById(userId).orElse(null);
if (user == null) { if (user == null) {
return RestBean.failure(RestCode.DATA_NOT_FOUND, null); return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
} }
// 2. 检查是否修改当前登录用户
Long currentUserId = SecurityUtils.getUserId(); Long currentUserId = SecurityUtils.getUserId();
if (currentUserId != null && currentUserId.equals(userId)) { if (currentUserId != null && currentUserId.equals(userId)) {
return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null); return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null);
} }
// 3. 更新用户状态
user.setStatus(updateDto.getStatus()); user.setStatus(updateDto.getStatus());
return RestBean.success(convertToVo(userRepository.save(user))); User savedUser = userRepository.save(user);
log.info("用户状态更新成功 - userId: {}, status: {}", userId, updateDto.getStatus());
return RestBean.success(convertToVo(savedUser));
} catch (Exception e) { } catch (Exception e) {
log.error("更新用户状态失败, userId={}", userId, e); log.error("更新用户状态失败, userId={}", userId, e);
return RestBean.failure(RestCode.SYSTEM_ERROR, null); return RestBean.failure(RestCode.SYSTEM_ERROR, null);
} }
} }
/**
* 更新用户角色
* 步骤:
* 1. 查询用户是否存在
* 2. 检查是否修改当前登录用户的角色(禁止)
* 3. 验证所有角色ID是否存在
* 4. 清空用户原有角色
* 5. 添加新角色
* 6. 保存到数据库
*
* 注意:
* - 不能修改当前登录用户的角色
* - 角色变更后需要重新登录才能生效
* - 清空原有角色再添加新角色(避免重复)
*
* @param userId 用户ID
* @param updateDto 角色更新请求
* @return 更新后的用户信息
*/
@Override @Override
@Transactional(rollbackFor = Exception.class)
public RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto) { public RestBean<UserVo> updateUserRole(Long userId, UserRoleUpdateDto updateDto) {
try { try {
// 1. 查询用户
User user = userRepository.findById(userId).orElse(null); User user = userRepository.findById(userId).orElse(null);
if (user == null) { if (user == null) {
return RestBean.failure(RestCode.DATA_NOT_FOUND, null); return RestBean.failure(RestCode.DATA_NOT_FOUND, null);
} }
// 2. 检查是否修改当前登录用户
Long currentUserId = SecurityUtils.getUserId(); Long currentUserId = SecurityUtils.getUserId();
if (currentUserId != null && currentUserId.equals(userId)) { if (currentUserId != null && currentUserId.equals(userId)) {
return RestBean.failure(403, "不允许修改当前登录用户的角色", null); return RestBean.failure(403, "不允许修改当前登录用户的角色", null);
} }
String roleValue = updateDto.getRole().trim().toUpperCase(); // 3. 根据角色ID查询所有角色
if (!"ADMIN".equals(roleValue) && !"USER".equals(roleValue)) { Set<SysRole> roles = new HashSet<>();
return RestBean.failure(400, "角色不合法", null); for (Long roleId : updateDto.getRoleIds()) {
SysRole role = roleRepository.findById(roleId)
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
roles.add(role);
} }
Role role = Role.valueOf(roleValue); // 4. 更新用户角色(清空后添加)
user.setRole(role); user.getRoles().clear();
return RestBean.success(convertToVo(userRepository.save(user))); user.getRoles().addAll(roles);
// 5. 保存到数据库
User savedUser = userRepository.save(user);
log.info("用户角色更新成功 - userId: {}, roleIds: {}", userId, updateDto.getRoleIds());
return RestBean.success(convertToVo(savedUser));
} catch (Exception e) { } catch (Exception e) {
log.error("更新用户角色失败, userId={}", userId, e); log.error("更新用户角色失败, userId={}", userId, e);
return RestBean.failure(RestCode.SYSTEM_ERROR, null); return RestBean.failure(RestCode.SYSTEM_ERROR, null);
} }
} }
/**
* 构建动态查询条件
* 步骤:
* 1. 支持按关键词模糊搜索(用户名或邮箱)
* 2. 支持按状态精确筛选
* 3. 返回 Specification 对象
*
* 注意:
* - 角色查询需要使用 JOIN此处简化处理
* - 可以根据需要扩展更多查询条件
*
* @param queryDto 查询条件对象
* @return 动态查询 Specification
*/
private Specification<User> buildSpecification(UserQueryDto queryDto) { private Specification<User> buildSpecification(UserQueryDto queryDto) {
return (root, query, cb) -> { return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
// 1. 关键词模糊搜索(用户名或邮箱)
if (StringUtils.hasText(queryDto.getKeyword())) { if (StringUtils.hasText(queryDto.getKeyword())) {
String keyword = "%" + queryDto.getKeyword().trim() + "%"; String keyword = "%" + queryDto.getKeyword().trim() + "%";
predicates.add(cb.or( predicates.add(cb.or(
@@ -198,10 +467,7 @@ public class UserServiceImpl implements UserService {
)); ));
} }
if (StringUtils.hasText(queryDto.getRole())) { // 2. 状态精确筛选
predicates.add(cb.equal(root.get("role"), Role.fromString(queryDto.getRole())));
}
if (queryDto.getStatus() != null) { if (queryDto.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), queryDto.getStatus())); predicates.add(cb.equal(root.get("status"), queryDto.getStatus()));
} }
@@ -210,13 +476,29 @@ public class UserServiceImpl implements UserService {
}; };
} }
/**
* 将用户实体转换为视图对象
* 步骤:
* 1. 复制基本信息ID、用户名、邮箱、状态
* 2. 转换角色为角色编码集合
* 3. 复制时间信息
*
* @param user 用户实体
* @return 用户视图对象
*/
private UserVo convertToVo(User user) { private UserVo convertToVo(User user) {
UserVo userVo = new UserVo(); UserVo userVo = new UserVo();
userVo.setId(user.getId()); userVo.setId(user.getId());
userVo.setUsername(user.getUsername()); userVo.setUsername(user.getUsername());
userVo.setEmail(user.getEmail()); userVo.setEmail(user.getEmail());
userVo.setStatus(user.getStatus()); userVo.setStatus(user.getStatus());
userVo.setRole(user.getRole().name());
// 1. 转换角色为字符串集合(只返回角色编码)
Set<String> roleCodes = user.getRoles().stream()
.map(SysRole::getRoleCode)
.collect(Collectors.toSet());
userVo.setRoles(roleCodes);
userVo.setCreatedAt(user.getCreatedAt()); userVo.setCreatedAt(user.getCreatedAt());
userVo.setUpdatedAt(user.getUpdatedAt()); userVo.setUpdatedAt(user.getUpdatedAt());
return userVo; return userVo;

View File

@@ -8,37 +8,167 @@ import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.Key; import java.security.Key;
import java.util.Date; import java.util.Date;
import java.util.UUID;
/**
* JWT 工具类
* 用于生成和验证 JWT Token
*
* 主要功能:
* 1. Token 生成:生成包含用户信息的 JWT Token
* 2. Token 解析:解析 Token 并提取信息
* 3. Token 验证:验证 Token 签名和有效期
*
* Token 结构:
* - Header算法类型和 Token 类型
* - Payload用户信息、签发时间、过期时间、jtiJWT ID
* - Signature使用密钥签名的签名部分
*
* @author Claude
* @since 2024-04-09
*/
@Component @Component
public class JwtUtil { public class JwtUtil {
/**
* JWT 密钥
* 从配置文件读取
* HS512 算法要求密钥至少 64 字符512 位)
*/
@Value("${jwt.secret}") @Value("${jwt.secret}")
private String secret; private String secret;
private final long expiration = 1000 * 60 * 60 *24; // 24小时 /**
* Access Token 过期时间(秒)
* 从配置文件读取,默认 3600 秒1 小时)
*/
@Value("${jwt.access-token-expiration:3600}") // Default 1 hour
private long accessTokenExpirationSeconds;
/**
* 获取签名密钥
* 说明:
* - HS512 算法要求密钥至少 512 位64 字节)
* - 如果密钥不足 64 字符,抛出异常
*
* @return 签名密钥
*/
private Key getSigningKey() { private Key getSigningKey() {
// HS512 要求密钥至少 512 位 = 64 字节 // HS512 要求密钥至少 512 位 = 64 字节
if (secret.length() < 64) { if (secret.length() < 64) {
throw new IllegalArgumentException("JWT secret must be at least 64 characters long for HS512"); throw new IllegalArgumentException("JWT 密钥必须至少 64 个字符HS512 算法要求)");
} }
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
} }
public String generateToken(Long userId,String username) { /**
* 为用户生成 Access Token
* 步骤:
* 1. 生成唯一的 jtiJWT ID
* 2. 设置用户信息用户名、用户ID
* 3. 设置签发时间和过期时间
* 4. 使用密钥签名
*
* @param userId 用户ID
* @param username 用户名
* @return JWT Token
*/
public String generateToken(Long userId, String username) {
return generateToken(userId, username, accessTokenExpirationSeconds);
}
/**
* 生成自定义过期时间的 Token
* 步骤:
* 1. 生成唯一的 jtiJWT ID用于黑名单追踪
* 2. 计算过期时间(毫秒)
* 3. 构建 Token
*
* @param userId 用户ID
* @param username 用户名
* @param expirationSeconds 过期时间(秒)
* @return JWT Token
*/
public String generateToken(Long userId, String username, long expirationSeconds) {
// 1. 生成唯一的 jtiJWT ID
String jti = UUID.randomUUID().toString();
long expirationMillis = expirationSeconds * 1000;
// 2. 构建 Token
return Jwts.builder() return Jwts.builder()
.setSubject(username) .setSubject(username) // 主题:用户名
.claim("id", userId) .claim("id", userId) // 自定义声明用户ID
.setIssuedAt(new Date()) .claim("jti", jti) // 自定义声明JWT ID用于黑名单
.setExpiration(new Date(System.currentTimeMillis() + expiration)) .setIssuedAt(new Date()) // 签发时间:当前时间
.signWith(getSigningKey(), SignatureAlgorithm.HS512) .setExpiration(new Date(System.currentTimeMillis() + expirationMillis)) // 过期时间
.signWith(getSigningKey(), SignatureAlgorithm.HS512) // 签名算法和密钥
.compact(); .compact();
} }
/**
* 从 Token 中提取用户名
*
* @param token JWT Token
* @return 用户名
*/
public String extractUsername(String token) { public String extractUsername(String token) {
return parseClaims(token).getBody().getSubject(); return parseClaims(token).getBody().getSubject();
} }
/**
* 从 Token 中提取用户ID
*
* @param token JWT Token
* @return 用户ID
*/
public Long extractUserId(String token) {
Claims claims = parseClaims(token).getBody();
return claims.get("id", Long.class);
}
/**
* 从 Token 中提取 JWT IDjti
* 说明:
* - jti 是 JWT 的唯一标识
* - 用于将 Token 加入黑名单(登出功能)
*
* @param token JWT Token
* @return JWT ID
*/
public String extractJti(String token) {
Claims claims = parseClaims(token).getBody();
return claims.get("jti", String.class);
}
/**
* 从 Token 中提取过期时间
*
* @param token JWT Token
* @return 过期时间
*/
public Date extractExpiration(String token) {
return parseClaims(token).getBody().getExpiration();
}
/**
* 获取 Access Token 的过期时间(秒)
*
* @return 过期时间(秒)
*/
public long getExpirationSeconds() {
return accessTokenExpirationSeconds;
}
/**
* 验证 Token 签名和结构
* 说明:
* - 验证签名是否正确
* - 验证 Token 结构是否完整
* - 不验证过期时间
*
* @param token JWT Token
* @return true 表示 Token 有效
*/
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
parseClaims(token); parseClaims(token);
@@ -48,6 +178,31 @@ public class JwtUtil {
} }
} }
/**
* 检查 Token 是否过期
*
* @param token JWT Token
* @return true 表示 Token 已过期
*/
public boolean isTokenExpired(String token) {
try {
Date expiration = extractExpiration(token);
return expiration.before(new Date());
} catch (JwtException e) {
return true;
}
}
/**
* 解析并验证 JWT Token
* 说明:
* - 验证签名
* - 解析 Claims
*
* @param token JWT Token
* @return 解析后的 JWT 对象(包含 Claims
* @throws JwtException 如果 Token 无效
*/
private Jws<Claims> parseClaims(String token) { private Jws<Claims> parseClaims(String token) {
return Jwts.parserBuilder() return Jwts.parserBuilder()
.setSigningKey(getSigningKey()) .setSigningKey(getSigningKey())

View File

@@ -0,0 +1,762 @@
package com.aisi.template.utils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Redis 缓存工具类
* 提供各种场景下的缓存操作方法
*
* 使用场景:
* 1. 对象缓存:缓存实体对象,减少数据库查询
* 2. 列表缓存:缓存列表数据
* 3. 集合缓存:缓存去重数据
* 4. 哈希缓存:缓存对象字段
* 5. 计数器:文章阅读数、点赞数等
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisCache {
/**
* Redis 模板
*/
private final RedisTemplate<String, Object> redisTemplate;
// ==================== 1. String 类型操作 ====================
/**
* 设置缓存(永不过期)
* 注意:生产环境建议设置过期时间,防止内存溢出
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
log.debug("设置缓存成功 - key: {}", key);
} catch (Exception e) {
log.error("设置缓存失败 - key: {}", key, e);
}
}
/**
* 设置缓存(指定过期时间)
* 步骤:
* 1. 将值序列化后存入 Redis
* 2. 设置过期时间
*
* @param key 键
* @param value 值
* @param timeout 过期时间
* @param timeUnit 时间单位
*/
public void set(String key, Object value, long timeout, TimeUnit timeUnit) {
try {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
log.debug("设置缓存成功 - key: {}, timeout: {} {}", key, timeout, timeUnit);
} catch (Exception e) {
log.error("设置缓存失败 - key: {}", key, e);
}
}
/**
* 设置缓存(使用 Duration
*
* @param key 键
* @param value 值
* @param duration 过期时间
*/
public void set(String key, Object value, Duration duration) {
try {
redisTemplate.opsForValue().set(key, value, duration);
log.debug("设置缓存成功 - key: {}, duration: {}", key, duration);
} catch (Exception e) {
log.error("设置缓存失败 - key: {}", key, e);
}
}
/**
* 获取缓存
* 步骤:
* 1. 从 Redis 获取值
* 2. 反序列化为对象
*
* @param key 键
* @return 值,不存在返回 null
*/
public Object get(String key) {
try {
Object value = redisTemplate.opsForValue().get(key);
log.debug("获取缓存 - key: {}, found: {}", key, value != null);
return value;
} catch (Exception e) {
log.error("获取缓存失败 - key: {}", key, e);
return null;
}
}
/**
* 获取缓存(指定类型)
*
* @param key 键
* @param type 返回值类型
* @return 值
*/
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> type) {
try {
Object value = redisTemplate.opsForValue().get(key);
if (value != null && type.isInstance(value)) {
return (T) value;
}
return null;
} catch (Exception e) {
log.error("获取缓存失败 - key: {}", key, e);
return null;
}
}
/**
* 删除缓存
*
* @param key 键
* @return 是否删除成功
*/
public boolean delete(String key) {
try {
Boolean result = redisTemplate.delete(key);
log.debug("删除缓存 - key: {}, result: {}", key, result);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("删除缓存失败 - key: {}", key, e);
return false;
}
}
/**
* 批量删除缓存
* 步骤:
* 1. 遍历所有键
* 2. 一次性删除
*
* @param keys 键集合
* @return 删除的数量
*/
public long delete(Collection<String> keys) {
try {
Long count = redisTemplate.delete(keys);
log.debug("批量删除缓存 - count: {}", count);
return count != null ? count : 0;
} catch (Exception e) {
log.error("批量删除缓存失败", e);
return 0;
}
}
/**
* 判断键是否存在
*
* @param key 键
* @return 是否存在
*/
public boolean hasKey(String key) {
try {
Boolean result = redisTemplate.hasKey(key);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("判断键是否存在失败 - key: {}", key, e);
return false;
}
}
/**
* 设置过期时间
*
* @param key 键
* @param timeout 过期时间
* @param unit 时间单位
* @return 是否设置成功
*/
public boolean expire(String key, long timeout, TimeUnit unit) {
try {
Boolean result = redisTemplate.expire(key, timeout, unit);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("设置过期时间失败 - key: {}", key, e);
return false;
}
}
/**
* 获取过期时间
*
* @param key 键
* @param unit 时间单位
* @return 剩余过期时间,-1 表示永不过期,-2 表示键不存在
*/
public long getExpire(String key, TimeUnit unit) {
try {
Long expire = redisTemplate.getExpire(key, unit);
return expire != null ? expire : -2;
} catch (Exception e) {
log.error("获取过期时间失败 - key: {}", key, e);
return -2;
}
}
/**
* 自增操作(原子性)
* 使用场景:计数器、点赞数、阅读数等
* 步骤:
* 1. 原子性自增
* 2. 返回自增后的值
*
* @param key 键
* @param delta 增量
* @return 自增后的值
*/
public long increment(String key, long delta) {
try {
Long result = redisTemplate.opsForValue().increment(key, delta);
log.debug("自增操作 - key: {}, delta: {}, result: {}", key, delta, result);
return result != null ? result : 0;
} catch (Exception e) {
log.error("自增操作失败 - key: {}", key, e);
return 0;
}
}
/**
* 自减操作(原子性)
*
* @param key 键
* @param delta 减量
* @return 自减后的值
*/
public long decrement(String key, long delta) {
try {
Long result = redisTemplate.opsForValue().decrement(key, delta);
log.debug("自减操作 - key: {}, delta: {}, result: {}", key, delta, result);
return result != null ? result : 0;
} catch (Exception e) {
log.error("自减操作失败 - key: {}", key, e);
return 0;
}
}
// ==================== 2. Hash 类型操作 ====================
/**
* 设置哈希字段
* 使用场景:缓存对象的单个字段
* 步骤:
* 1. 将字段和值存入哈希表
* 2. 适合部分更新对象字段
*
* @param key 键
* @param field 字段
* @param value 值
*/
public void hSet(String key, String field, Object value) {
try {
redisTemplate.opsForHash().put(key, field, value);
log.debug("设置哈希字段 - key: {}, field: {}", key, field);
} catch (Exception e) {
log.error("设置哈希字段失败 - key: {}, field: {}", key, field, e);
}
}
/**
* 获取哈希字段
*
* @param key 键
* @param field 字段
* @return 值
*/
public Object hGet(String key, String field) {
try {
return redisTemplate.opsForHash().get(key, field);
} catch (Exception e) {
log.error("获取哈希字段失败 - key: {}, field: {}", key, field, e);
return null;
}
}
/**
* 批量设置哈希字段
*
* @param key 键
* @param map 字段-值映射
*/
public void hSetAll(String key, Map<String, Object> map) {
try {
redisTemplate.opsForHash().putAll(key, map);
log.debug("批量设置哈希字段 - key: {}", key);
} catch (Exception e) {
log.error("批量设置哈希字段失败 - key: {}", key, e);
}
}
/**
* 获取所有哈希字段
*
* @param key 键
* @return 字段-值映射
*/
public Map<Object, Object> hGetAll(String key) {
try {
return redisTemplate.opsForHash().entries(key);
} catch (Exception e) {
log.error("获取所有哈希字段失败 - key: {}", key, e);
return Map.of();
}
}
/**
* 删除哈希字段
*
* @param key 键
* @param fields 字段集合
* @return 删除的数量
*/
public long hDelete(String key, Object... fields) {
try {
return redisTemplate.opsForHash().delete(key, fields);
} catch (Exception e) {
log.error("删除哈希字段失败 - key: {}", key, e);
return 0;
}
}
/**
* 判断哈希字段是否存在
*
* @param key 键
* @param field 字段
* @return 是否存在
*/
public boolean hExists(String key, String field) {
try {
return redisTemplate.opsForHash().hasKey(key, field);
} catch (Exception e) {
log.error("判断哈希字段是否存在失败 - key: {}, field: {}", key, field, e);
return false;
}
}
/**
* 哈希字段自增
*
* @param key 键
* @param field 字段
* @param delta 增量
* @return 自增后的值
*/
public long hIncrement(String key, String field, long delta) {
try {
return redisTemplate.opsForHash().increment(key, field, delta);
} catch (Exception e) {
log.error("哈希字段自增失败 - key: {}, field: {}", key, field, e);
return 0;
}
}
// ==================== 3. List 类型操作 ====================
/**
* 从左侧推入列表
* 使用场景:消息队列、最新消息列表
* 步骤:
* 1. 将元素推入列表左侧
* 2. 返回当前列表长度
*
* @param key 键
* @param value 值
* @return 推入后的列表长度
*/
public long lLeftPush(String key, Object value) {
try {
Long size = redisTemplate.opsForList().leftPush(key, value);
log.debug("从左侧推入列表 - key: {}, size: {}", key, size);
return size != null ? size : 0;
} catch (Exception e) {
log.error("从左侧推入列表失败 - key: {}", key, e);
return 0;
}
}
/**
* 从右侧推入列表
*
* @param key 键
* @param value 值
* @return 推入后的列表长度
*/
public long lRightPush(String key, Object value) {
try {
Long size = redisTemplate.opsForList().rightPush(key, value);
log.debug("从右侧推入列表 - key: {}, size: {}", key, size);
return size != null ? size : 0;
} catch (Exception e) {
log.error("从右侧推入列表失败 - key: {}", key, e);
return 0;
}
}
/**
* 从左侧弹出列表元素
*
* @param key 键
* @return 弹出的元素
*/
public Object lLeftPop(String key) {
try {
return redisTemplate.opsForList().leftPop(key);
} catch (Exception e) {
log.error("从左侧弹出列表元素失败 - key: {}", key, e);
return null;
}
}
/**
* 从右侧弹出列表元素
*
* @param key 键
* @return 弹出的元素
*/
public Object lRightPop(String key) {
try {
return redisTemplate.opsForList().rightPop(key);
} catch (Exception e) {
log.error("从右侧弹出列表元素失败 - key: {}", key, e);
return null;
}
}
/**
* 获取列表范围
* 使用场景:分页查询列表数据
* 步骤:
* 1. 获取指定范围的元素
* 2. 支持负索引(-1 表示最后一个元素)
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
* @return 元素列表
*/
public List<Object> lRange(String key, long start, long end) {
try {
return redisTemplate.opsForList().range(key, start, end);
} catch (Exception e) {
log.error("获取列表范围失败 - key: {}", key, e);
return List.of();
}
}
/**
* 获取列表长度
*
* @param key 键
* @return 列表长度
*/
public long lSize(String key) {
try {
Long size = redisTemplate.opsForList().size(key);
return size != null ? size : 0;
} catch (Exception e) {
log.error("获取列表长度失败 - key: {}", key, e);
return 0;
}
}
/**
* 移除列表元素
*
* @param key 键
* @param count 移除数量(>0 从左往右,<0 从右往左,=0 全部)
* @param value 要移除的值
* @return 实际移除的数量
*/
public long lRemove(String key, long count, Object value) {
try {
Long removed = redisTemplate.opsForList().remove(key, count, value);
return removed != null ? removed : 0;
} catch (Exception e) {
log.error("移除列表元素失败 - key: {}", key, e);
return 0;
}
}
// ==================== 4. Set 类型操作 ====================
/**
* 添加到集合
* 使用场景:标签系统、共同好友、去重
* 步骤:
* 1. 添加元素到集合
* 2. 自动去重
*
* @param key 键
* @param values 值集合
* @return 添加的元素数量(不包含已存在的)
*/
public long sAdd(String key, Object... values) {
try {
Long count = redisTemplate.opsForSet().add(key, values);
log.debug("添加到集合 - key: {}, count: {}", key, count);
return count != null ? count : 0;
} catch (Exception e) {
log.error("添加到集合失败 - key: {}", key, e);
return 0;
}
}
/**
* 获取集合所有元素
*
* @param key 键
* @return 元素集合
*/
public Set<Object> sMembers(String key) {
try {
return redisTemplate.opsForSet().members(key);
} catch (Exception e) {
log.error("获取集合元素失败 - key: {}", key, e);
return Set.of();
}
}
/**
* 判断元素是否在集合中
*
* @param key 键
* @param value 值
* @return 是否存在
*/
public boolean sIsMember(String key, Object value) {
try {
Boolean result = redisTemplate.opsForSet().isMember(key, value);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("判断元素是否在集合中失败 - key: {}", key, e);
return false;
}
}
/**
* 获取集合大小
*
* @param key 键
* @return 集合大小
*/
public long sSize(String key) {
try {
Long size = redisTemplate.opsForSet().size(key);
return size != null ? size : 0;
} catch (Exception e) {
log.error("获取集合大小失败 - key: {}", key, e);
return 0;
}
}
/**
* 移除集合元素
*
* @param key 键
* @param values 要移除的值
* @return 实际移除的数量
*/
public long sRemove(String key, Object... values) {
try {
Long count = redisTemplate.opsForSet().remove(key, values);
return count != null ? count : 0;
} catch (Exception e) {
log.error("移除集合元素失败 - key: {}", key, e);
return 0;
}
}
/**
* 集合交集
* 使用场景:共同好友、共同标签
* 步骤:
* 1. 获取多个集合的交集
* 2. 返回共有的元素
*
* @param key1 第一个集合键
* @param key2 第二个集合键
* @return 交集元素
*/
public Set<Object> sIntersect(String key1, String key2) {
try {
return redisTemplate.opsForSet().intersect(key1, key2);
} catch (Exception e) {
log.error("获取集合交集失败 - key1: {}, key2: {}", key1, key2, e);
return Set.of();
}
}
/**
* 集合并集
*
* @param key1 第一个集合键
* @param key2 第二个集合键
* @return 并集元素
*/
public Set<Object> sUnion(String key1, String key2) {
try {
return redisTemplate.opsForSet().union(key1, key2);
} catch (Exception e) {
log.error("获取集合并集失败 - key1: {}, key2: {}", key1, key2, e);
return Set.of();
}
}
// ==================== 5. ZSet 类型操作(有序集合)====================
/**
* 添加到有序集合
* 使用场景:排行榜、权重队列
* 步骤:
* 1. 添加元素及分数
* 2. 按分数自动排序
*
* @param key 键
* @param value 值
* @param score 分数
* @return 添加是否成功(新增返回 true更新返回 false
*/
public boolean zAdd(String key, Object value, double score) {
try {
Boolean result = redisTemplate.opsForZSet().add(key, value, score);
log.debug("添加到有序集合 - key: {}, value: {}, score: {}", key, value, score);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("添加到有序集合失败 - key: {}", key, e);
return false;
}
}
/**
* 获取有序集合范围(按分数)
* 使用场景:排行榜分页查询
* 步骤:
* 1. 获取指定分数范围的元素
* 2. 按分数升序排列
*
* @param key 键
* @param min 最小分数
* @param max 最大分数
* @return 元素集合
*/
public Set<Object> zRangeByScore(String key, double min, double max) {
try {
return redisTemplate.opsForZSet().rangeByScore(key, min, max);
} catch (Exception e) {
log.error("获取有序集合范围失败 - key: {}", key, e);
return Set.of();
}
}
/**
* 获取有序集合排名(倒序)
* 使用场景:排行榜查询(分数高的排名靠前)
* 步骤:
* 1. 获取元素的排名从0开始
* 2. 按分数倒序排列
*
* @param key 键
* @param value 值
* @return 排名,不存在返回 null
*/
public Long zReverseRank(String key, Object value) {
try {
return redisTemplate.opsForZSet().reverseRank(key, value);
} catch (Exception e) {
log.error("获取有序集合排名失败 - key: {}", key, e);
return null;
}
}
/**
* 获取元素分数
*
* @param key 键
* @param value 值
* @return 分数
*/
public Double zScore(String key, Object value) {
try {
return redisTemplate.opsForZSet().score(key, value);
} catch (Exception e) {
log.error("获取元素分数失败 - key: {}", key, e);
return null;
}
}
/**
* 增加元素分数
*
* @param key 键
* @param value 值
* @param delta 增量
* @return 增加后的分数
*/
public Double zIncrementScore(String key, Object value, double delta) {
try {
return redisTemplate.opsForZSet().incrementScore(key, value, delta);
} catch (Exception e) {
log.error("增加元素分数失败 - key: {}", key, e);
return null;
}
}
/**
* 移除有序集合元素
*
* @param key 键
* @param values 要移除的值
* @return 实际移除的数量
*/
public long zRemove(String key, Object... values) {
try {
Long count = redisTemplate.opsForZSet().remove(key, values);
return count != null ? count : 0;
} catch (Exception e) {
log.error("移除有序集合元素失败 - key: {}", key, e);
return 0;
}
}
/**
* 获取有序集合大小
*
* @param key 键
* @return 集合大小
*/
public long zSize(String key) {
try {
Long size = redisTemplate.opsForZSet().size(key);
return size != null ? size : 0;
} catch (Exception e) {
log.error("获取有序集合大小失败 - key: {}", key, e);
return 0;
}
}
}

View File

@@ -0,0 +1,301 @@
package com.aisi.template.utils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Redis 分布式锁工具类
* 基于 Redis SETNX + EXPIRE 实现,支持可重入锁
*
* 使用场景:
* 1. 防止重复提交(如表单重复提交)
* 2. 库存扣减(防止超卖)
* 3. 定时任务分布式执行(防止重复执行)
* 4. 限流场景
*
* @author Claude
* @since 2024-04-09
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisLock {
/**
* Redis 模板
*/
private final RedisTemplate<String, Object> redisTemplate;
/**
* 锁前缀
*/
private static final String LOCK_PREFIX = "lock:";
/**
* 锁的值前缀
*/
private static final String LOCK_VALUE_PREFIX = "uuid:";
/**
* 默认锁过期时间(秒)
*/
private static final long DEFAULT_EXPIRE_TIME = 30;
/**
* 获取锁的 Lua 脚本
* SETNX + EXPIRE 原子操作
*/
private static final String LOCK_SCRIPT =
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('expire', KEYS[1], ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
/**
* 释放锁的 Lua 脚本
* 确保只有锁的持有者才能释放锁
*/
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
/**
* 尝试获取锁
* 步骤:
* 1. 生成唯一的锁标识UUID
* 2. 执行 Lua 脚本尝试获取锁
* 3. 如果获取成功,返回锁标识;否则返回 null
*
* @param lockKey 锁的键名
* @return 锁标识,如果获取失败返回 null
*/
public String tryLock(String lockKey) {
return tryLock(lockKey, DEFAULT_EXPIRE_TIME);
}
/**
* 尝试获取锁(指定过期时间)
* 步骤:
* 1. 生成唯一的锁标识UUID
* 2. 执行 Lua 脚本尝试获取锁,并设置过期时间
* 3. 如果获取成功,返回锁标识;否则返回 null
*
* @param lockKey 锁的键名
* @param expireTime 锁的过期时间(秒)
* @return 锁标识,如果获取失败返回 null
*/
public String tryLock(String lockKey, long expireTime) {
// 1. 生成唯一的锁标识
String lockValue = LOCK_VALUE_PREFIX + UUID.randomUUID().toString();
String fullLockKey = LOCK_PREFIX + lockKey;
try {
// 2. 执行 Lua 脚本获取锁
// KEYS[1]: 锁的完整键名
// ARGV[1]: 锁的值UUID
// ARGV[2]: 过期时间(秒)
Long result = redisTemplate.execute(
RedisScript.of(LOCK_SCRIPT, Long.class),
Collections.singletonList(fullLockKey),
lockValue,
String.valueOf(expireTime)
);
// 3. 判断是否获取成功
if (result != null && result == 1) {
log.info("获取锁成功 - lockKey: {}, lockValue: {}", lockKey, lockValue);
return lockValue;
} else {
log.warn("获取锁失败 - lockKey: {}", lockKey);
return null;
}
} catch (Exception e) {
log.error("获取锁异常 - lockKey: {}", lockKey, e);
return null;
}
}
/**
* 尝试获取锁(带等待时间)
* 步骤:
* 1. 在等待时间内循环尝试获取锁
* 2. 每次间隔 100 毫秒重试
* 3. 超过等待时间则放弃
*
* @param lockKey 锁的键名
* @param expireTime 锁的过期时间(秒)
* @param waitTime 等待时间(毫秒)
* @return 锁标识,如果获取失败返回 null
*/
public String tryLock(String lockKey, long expireTime, long waitTime) {
long startTime = System.currentTimeMillis();
long timeout = startTime + waitTime;
String lockValue = null;
// 1. 在等待时间内循环尝试
while (System.currentTimeMillis() < timeout) {
// 2. 尝试获取锁
lockValue = tryLock(lockKey, expireTime);
if (lockValue != null) {
return lockValue;
}
// 3. 短暂休眠后重试
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
log.warn("获取锁超时 - lockKey: {}, waitTime: {}ms", lockKey, waitTime);
return null;
}
/**
* 释放锁
* 步骤:
* 1. 验证锁的值是否匹配(确保只有持有者才能释放)
* 2. 如果匹配则删除锁,否则不做操作
*
* @param lockKey 锁的键名
* @param lockValue 锁的标识tryLock 返回的值)
* @return 是否释放成功
*/
public boolean unlock(String lockKey, String lockValue) {
if (lockValue == null) {
return false;
}
String fullLockKey = LOCK_PREFIX + lockKey;
try {
// 1. 执行 Lua 脚本释放锁
// KEYS[1]: 锁的完整键名
// ARGV[1]: 锁的值(用于验证)
Long result = redisTemplate.execute(
RedisScript.of(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(fullLockKey),
lockValue
);
// 2. 判断是否释放成功
if (result != null && result == 1) {
log.info("释放锁成功 - lockKey: {}, lockValue: {}", lockKey, lockValue);
return true;
} else {
log.warn("释放锁失败,锁不存在或已过期 - lockKey: {}, lockValue: {}", lockKey, lockValue);
return false;
}
} catch (Exception e) {
log.error("释放锁异常 - lockKey: {}, lockValue: {}", lockKey, lockValue, e);
return false;
}
}
/**
* 强制释放锁(不验证持有者)
* 注意:这个方法会强制删除锁,可能导致其他持有者的锁被误删
* 仅在特殊场景使用,如清理死锁
*
* @param lockKey 锁的键名
* @return 是否删除成功
*/
public boolean forceUnlock(String lockKey) {
String fullLockKey = LOCK_PREFIX + lockKey;
try {
Boolean result = redisTemplate.delete(fullLockKey);
log.warn("强制释放锁 - lockKey: {}, result: {}", lockKey, result);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("强制释放锁异常 - lockKey: {}", lockKey, e);
return false;
}
}
/**
* 检查锁是否存在
*
* @param lockKey 锁的键名
* @return 是否存在
*/
public boolean isLocked(String lockKey) {
String fullLockKey = LOCK_PREFIX + lockKey;
try {
Boolean result = redisTemplate.hasKey(fullLockKey);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
log.error("检查锁状态异常 - lockKey: {}", lockKey, e);
return false;
}
}
/**
* 获取锁的剩余过期时间
*
* @param lockKey 锁的键名
* @return 剩余过期时间(秒),-1 表示不存在或已过期
*/
public long getLockExpireTime(String lockKey) {
String fullLockKey = LOCK_PREFIX + lockKey;
try {
Long expireTime = redisTemplate.getExpire(fullLockKey, TimeUnit.SECONDS);
return expireTime != null ? expireTime : -1;
} catch (Exception e) {
log.error("获取锁过期时间异常 - lockKey: {}", lockKey, e);
return -1;
}
}
/**
* 续期锁(延长锁的过期时间)
* 注意:需要验证持有者
*
* @param lockKey 锁的键名
* @param lockValue 锁的标识
* @param addTime 增加的时间(秒)
* @return 是否续期成功
*/
public boolean renewLock(String lockKey, String lockValue, long addTime) {
if (lockValue == null) {
return false;
}
String fullLockKey = LOCK_PREFIX + lockKey;
try {
// 1. 先验证锁的值
String currentValue = (String) redisTemplate.opsForValue().get(fullLockKey);
if (!lockValue.equals(currentValue)) {
log.warn("续期失败,锁的值不匹配 - lockKey: {}", lockKey);
return false;
}
// 2. 续期锁
Boolean result = redisTemplate.expire(fullLockKey, addTime, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(result)) {
log.info("续期锁成功 - lockKey: {}, addTime: {}s", lockKey, addTime);
return true;
} else {
log.warn("续期锁失败 - lockKey: {}", lockKey);
return false;
}
} catch (Exception e) {
log.error("续期锁异常 - lockKey: {}", lockKey, e);
return false;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.aisi.template.utils; package com.aisi.template.utils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations; import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -11,69 +12,187 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/**
* Redis 工具类
* 提供各种 Redis 数据类型的便捷操作方法
*
* 主要功能:
* 1. String 类型:字符串、对象缓存
* 2. Hash 类型:对象字段缓存
* 3. List 类型:队列、列表
* 4. Set 类型:去重、交集、并集
* 5. ZSet 类型:排行榜
* 6. Bitmap位图操作
* 7. HyperLogLog基数统计
*
* @author Claude
* @since 2024-04-09
*/
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class RedisUtils { public class RedisUtils {
/**
* Redis 模板
*/
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
// ======================== 通用 ======================== /**
* 字符串 Redis 模板,用于数值自增等原生命令
*/
private final StringRedisTemplate stringRedisTemplate;
// ======================== 1. 通用操作 ========================
/**
* 删除键
*
* @param key 键
* @return 是否删除成功
*/
public Boolean delete(String key) { public Boolean delete(String key) {
return redisTemplate.delete(key); return redisTemplate.delete(key);
} }
/**
* 批量删除键
*
* @param keys 键集合
* @return 删除的数量
*/
public Long delete(Collection<String> keys) { public Long delete(Collection<String> keys) {
return redisTemplate.delete(keys); return redisTemplate.delete(keys);
} }
/**
* 判断键是否存在
*
* @param key 键
* @return 是否存在
*/
public Boolean hasKey(String key) { public Boolean hasKey(String key) {
return redisTemplate.hasKey(key); return redisTemplate.hasKey(key);
} }
/**
* 设置过期时间
*
* @param key 键
* @param timeout 过期时间
* @param unit 时间单位
* @return 是否设置成功
*/
public Boolean expire(String key, long timeout, TimeUnit unit) { public Boolean expire(String key, long timeout, TimeUnit unit) {
return redisTemplate.expire(key, timeout, unit); return redisTemplate.expire(key, timeout, unit);
} }
/**
* 获取过期时间
*
* @param key 键
* @return 剩余过期时间(秒),-1 表示永不过期,-2 表示键不存在
*/
public Long getExpire(String key) { public Long getExpire(String key) {
return redisTemplate.getExpire(key); return redisTemplate.getExpire(key);
} }
/**
* 在指定时间戳过期
*
* @param key 键
* @param timestamp 时间戳(毫秒)
* @return 是否设置成功
*/
public Boolean expireAt(String key, long timestamp) { public Boolean expireAt(String key, long timestamp) {
return redisTemplate.expireAt(key, new java.util.Date(timestamp)); return redisTemplate.expireAt(key, new java.util.Date(timestamp));
} }
/**
* 查找匹配模式的键
*
* @param pattern 模式user:*
* @return 匹配的键集合
*/
public Set<String> keys(String pattern) { public Set<String> keys(String pattern) {
return redisTemplate.keys(pattern); return redisTemplate.keys(pattern);
} }
/**
* 重命名键
*
* @param oldKey 旧键名
* @param newKey 新键名
* @return 是否重命名成功
*/
public Boolean rename(String oldKey, String newKey) { public Boolean rename(String oldKey, String newKey) {
redisTemplate.rename(oldKey, newKey); redisTemplate.rename(oldKey, newKey);
return true; return true;
} }
/**
* 获取键的剩余生存时间(秒)
*
* @param key 键
* @return 剩余秒数
*/
public Long ttl(String key) { public Long ttl(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS); return redisTemplate.getExpire(key, TimeUnit.SECONDS);
} }
// ======================== String ======================== // ======================== 2. String 类型操作 ========================
/**
* 设置键值
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) { public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value); redisTemplate.opsForValue().set(key, value);
} }
/**
* 设置键值(带过期时间)
*
* @param key 键
* @param value 值
* @param timeout 过期时间
* @param unit 时间单位
*/
public void set(String key, Object value, long timeout, TimeUnit unit) { public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit); redisTemplate.opsForValue().set(key, value, timeout, unit);
} }
/**
* 如果键不存在则设置(原子操作)
* 使用场景:分布式锁
*
* @param key 键
* @param value 值
* @param timeout 过期时间
* @param unit 时间单位
*/
public void setIfAbsent(String key, Object value, long timeout, TimeUnit unit) { public void setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
} }
/**
* 获取键值
*
* @param key 键
* @return 值
*/
public Object get(String key) { public Object get(String key) {
return redisTemplate.opsForValue().get(key); return redisTemplate.opsForValue().get(key);
} }
/**
* 获取键值(指定类型)
*
* @param key 键
* @param clazz 类型
* @return 值
*/
public <T> T get(String key, Class<T> clazz) { public <T> T get(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key); Object value = redisTemplate.opsForValue().get(key);
if (value == null) { if (value == null) {
@@ -82,255 +201,680 @@ public class RedisUtils {
return clazz.cast(value); return clazz.cast(value);
} }
/**
* 获取并设置新值
*
* @param key 键
* @param value 新值
* @return 旧值
*/
public String getAndSet(String key, Object value) { public String getAndSet(String key, Object value) {
Object old = redisTemplate.opsForValue().getAndSet(key, value); Object old = redisTemplate.opsForValue().getAndSet(key, value);
return old != null ? old.toString() : null; return old != null ? old.toString() : null;
} }
/**
* 自增(原子操作)
*
* @param key 键
* @return 自增后的值
*/
public Long increment(String key) { public Long increment(String key) {
return redisTemplate.opsForValue().increment(key); return stringRedisTemplate.opsForValue().increment(key);
} }
/**
* 自增指定增量(原子操作)
*
* @param key 键
* @param delta 增量
* @return 自增后的值
*/
public Long increment(String key, long delta) { public Long increment(String key, long delta) {
return redisTemplate.opsForValue().increment(key, delta); return stringRedisTemplate.opsForValue().increment(key, delta);
} }
/**
* 自减(原子操作)
*
* @param key 键
* @return 自减后的值
*/
public Long decrement(String key) { public Long decrement(String key) {
return redisTemplate.opsForValue().decrement(key); return stringRedisTemplate.opsForValue().decrement(key);
} }
/**
* 自减指定减量(原子操作)
*
* @param key 键
* @param delta 减量
* @return 自减后的值
*/
public Long decrement(String key, long delta) { public Long decrement(String key, long delta) {
return redisTemplate.opsForValue().decrement(key, delta); return stringRedisTemplate.opsForValue().decrement(key, delta);
} }
/**
* 获取字符串长度
*
* @param key 键
* @return 字符串长度
*/
public Long strLength(String key) { public Long strLength(String key) {
return redisTemplate.opsForValue().size(key); return redisTemplate.opsForValue().size(key);
} }
// ======================== Hash ======================== // ======================== 3. Hash 类型操作 ========================
/**
* 设置哈希字段
*
* @param key 键
* @param hashKey 哈希键
* @param value 值
*/
public void hSet(String key, String hashKey, Object value) { public void hSet(String key, String hashKey, Object value) {
redisTemplate.opsForHash().put(key, hashKey, value); redisTemplate.opsForHash().put(key, hashKey, value);
} }
/**
* 批量设置哈希字段
*
* @param key 键
* @param map 哈希键值映射
*/
public void hSetAll(String key, Map<String, Object> map) { public void hSetAll(String key, Map<String, Object> map) {
redisTemplate.opsForHash().putAll(key, map); redisTemplate.opsForHash().putAll(key, map);
} }
/**
* 获取哈希字段
*
* @param key 键
* @param hashKey 哈希键
* @return 值
*/
public Object hGet(String key, String hashKey) { public Object hGet(String key, String hashKey) {
return redisTemplate.opsForHash().get(key, hashKey); return redisTemplate.opsForHash().get(key, hashKey);
} }
/**
* 获取所有哈希字段
*
* @param key 键
* @return 哈希键值映射
*/
public Map<Object, Object> hGetAll(String key) { public Map<Object, Object> hGetAll(String key) {
return redisTemplate.opsForHash().entries(key); return redisTemplate.opsForHash().entries(key);
} }
/**
* 删除哈希字段
*
* @param key 键
* @param hashKeys 哈希键集合
*/
public void hDelete(String key, Object... hashKeys) { public void hDelete(String key, Object... hashKeys) {
redisTemplate.opsForHash().delete(key, hashKeys); redisTemplate.opsForHash().delete(key, hashKeys);
} }
/**
* 判断哈希字段是否存在
*
* @param key 键
* @param hashKey 哈希键
* @return 是否存在
*/
public Boolean hHasKey(String key, String hashKey) { public Boolean hHasKey(String key, String hashKey) {
return redisTemplate.opsForHash().hasKey(key, hashKey); return redisTemplate.opsForHash().hasKey(key, hashKey);
} }
/**
* 获取哈希大小
*
* @param key 键
* @return 哈希大小
*/
public Long hSize(String key) { public Long hSize(String key) {
return redisTemplate.opsForHash().size(key); return redisTemplate.opsForHash().size(key);
} }
/**
* 哈希字段自增
*
* @param key 键
* @param hashKey 哈希键
* @param delta 增量
* @return 自增后的值
*/
public Long hIncrement(String key, String hashKey, long delta) { public Long hIncrement(String key, String hashKey, long delta) {
return redisTemplate.opsForHash().increment(key, hashKey, delta); return redisTemplate.opsForHash().increment(key, hashKey, delta);
} }
/**
* 获取所有哈希键
*
* @param key 键
* @return 哈希键集合
*/
public Set<Object> hKeys(String key) { public Set<Object> hKeys(String key) {
return redisTemplate.opsForHash().keys(key); return redisTemplate.opsForHash().keys(key);
} }
/**
* 获取所有哈希值
*
* @param key 键
* @return 哈希值列表
*/
public List<Object> hValues(String key) { public List<Object> hValues(String key) {
return redisTemplate.opsForHash().values(key); return redisTemplate.opsForHash().values(key);
} }
// ======================== List ======================== // ======================== 4. List 类型操作 ========================
/**
* 从左侧推入列表
*
* @param key 键
* @param value 值
* @return 列表长度
*/
public Long lPush(String key, Object value) { public Long lPush(String key, Object value) {
return redisTemplate.opsForList().leftPush(key, value); return redisTemplate.opsForList().leftPush(key, value);
} }
/**
* 从左侧批量推入列表
*
* @param key 键
* @param values 值数组
* @return 列表长度
*/
public Long lPushAll(String key, Object... values) { public Long lPushAll(String key, Object... values) {
return redisTemplate.opsForList().leftPushAll(key, values); return redisTemplate.opsForList().leftPushAll(key, values);
} }
/**
* 从右侧推入列表
*
* @param key 键
* @param value 值
* @return 列表长度
*/
public Long rPush(String key, Object value) { public Long rPush(String key, Object value) {
return redisTemplate.opsForList().rightPush(key, value); return redisTemplate.opsForList().rightPush(key, value);
} }
/**
* 从右侧批量推入列表
*
* @param key 键
* @param values 值数组
* @return 列表长度
*/
public Long rPushAll(String key, Object... values) { public Long rPushAll(String key, Object... values) {
return redisTemplate.opsForList().rightPushAll(key, values); return redisTemplate.opsForList().rightPushAll(key, values);
} }
/**
* 从左侧弹出元素
*
* @param key 键
* @return 弹出的元素
*/
public Object lPop(String key) { public Object lPop(String key) {
return redisTemplate.opsForList().leftPop(key); return redisTemplate.opsForList().leftPop(key);
} }
/**
* 从左侧批量弹出元素
*
* @param key 键
* @param count 数量
* @return 弹出的元素列表
*/
public List<Object> lPop(String key, long count) { public List<Object> lPop(String key, long count) {
return redisTemplate.opsForList().leftPop(key, count); return redisTemplate.opsForList().leftPop(key, count);
} }
/**
* 从右侧弹出元素
*
* @param key 键
* @return 弹出的元素
*/
public Object rPop(String key) { public Object rPop(String key) {
return redisTemplate.opsForList().rightPop(key); return redisTemplate.opsForList().rightPop(key);
} }
/**
* 从右侧批量弹出元素
*
* @param key 键
* @param count 数量
* @return 弹出的元素列表
*/
public List<Object> rPop(String key, long count) { public List<Object> rPop(String key, long count) {
return redisTemplate.opsForList().rightPop(key, count); return redisTemplate.opsForList().rightPop(key, count);
} }
/**
* 获取列表指定索引的元素
*
* @param key 键
* @param index 索引0 表示第一个)
* @return 元素
*/
public Object lIndex(String key, long index) { public Object lIndex(String key, long index) {
return redisTemplate.opsForList().index(key, index); return redisTemplate.opsForList().index(key, index);
} }
/**
* 获取列表长度
*
* @param key 键
* @return 列表长度
*/
public Long lSize(String key) { public Long lSize(String key) {
return redisTemplate.opsForList().size(key); return redisTemplate.opsForList().size(key);
} }
/**
* 获取列表范围
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
* @return 元素列表
*/
public List<Object> lRange(String key, long start, long end) { public List<Object> lRange(String key, long start, long end) {
return redisTemplate.opsForList().range(key, start, end); return redisTemplate.opsForList().range(key, start, end);
} }
/**
* 裁剪列表(只保留指定范围的元素)
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
*/
public void lTrim(String key, long start, long end) { public void lTrim(String key, long start, long end) {
redisTemplate.opsForList().trim(key, start, end); redisTemplate.opsForList().trim(key, start, end);
} }
/**
* 设置列表指定索引的元素
*
* @param key 键
* @param index 索引
* @param value 值
*/
public void lSet(String key, long index, Object value) { public void lSet(String key, long index, Object value) {
redisTemplate.opsForList().set(key, index, value); redisTemplate.opsForList().set(key, index, value);
} }
/**
* 移除列表元素
*
* @param key 键
* @param count 移除数量(>0 从左往右,<0 从右往左,=0 全部)
* @param value 要移除的值
* @return 实际移除的数量
*/
public Long lRemove(String key, long count, Object value) { public Long lRemove(String key, long count, Object value) {
return redisTemplate.opsForList().remove(key, count, value); return redisTemplate.opsForList().remove(key, count, value);
} }
// ======================== Set ======================== // ======================== 5. Set 类型操作 ========================
/**
* 添加到集合
*
* @param key 键
* @param values 值数组
* @return 添加的元素数量(不包含已存在的)
*/
public Long sAdd(String key, Object... values) { public Long sAdd(String key, Object... values) {
return redisTemplate.opsForSet().add(key, values); return redisTemplate.opsForSet().add(key, values);
} }
/**
* 从集合移除元素
*
* @param key 键
* @param values 值数组
* @return 实际移除的数量
*/
public Long sRemove(String key, Object... values) { public Long sRemove(String key, Object... values) {
return redisTemplate.opsForSet().remove(key, values); return redisTemplate.opsForSet().remove(key, values);
} }
/**
* 获取集合所有元素
*
* @param key 键
* @return 元素集合
*/
public Set<Object> sMembers(String key) { public Set<Object> sMembers(String key) {
return redisTemplate.opsForSet().members(key); return redisTemplate.opsForSet().members(key);
} }
/**
* 判断元素是否在集合中
*
* @param key 键
* @param value 值
* @return 是否存在
*/
public Boolean sIsMember(String key, Object value) { public Boolean sIsMember(String key, Object value) {
return redisTemplate.opsForSet().isMember(key, value); return redisTemplate.opsForSet().isMember(key, value);
} }
/**
* 获取集合大小
*
* @param key 键
* @return 集合大小
*/
public Long sSize(String key) { public Long sSize(String key) {
return redisTemplate.opsForSet().size(key); return redisTemplate.opsForSet().size(key);
} }
/**
* 随机获取集合中的一个元素
*
* @param key 键
* @return 随机元素
*/
public Object sRandomMember(String key) { public Object sRandomMember(String key) {
return redisTemplate.opsForSet().randomMember(key); return redisTemplate.opsForSet().randomMember(key);
} }
/**
* 随机获取集合中的多个元素
*
* @param key 键
* @param count 数量
* @return 随机元素集合
*/
public Set<Object> sRandomMembers(String key, long count) { public Set<Object> sRandomMembers(String key, long count) {
return redisTemplate.opsForSet().distinctRandomMembers(key, count); return redisTemplate.opsForSet().distinctRandomMembers(key, count);
} }
/**
* 集合交集
*
* @param key1 第一个集合键
* @param key2 第二个集合键
* @return 交集元素
*/
public Set<Object> sIntersect(String key1, String key2) { public Set<Object> sIntersect(String key1, String key2) {
return redisTemplate.opsForSet().intersect(key1, key2); return redisTemplate.opsForSet().intersect(key1, key2);
} }
/**
* 集合并集
*
* @param key1 第一个集合键
* @param key2 第二个集合键
* @return 并集元素
*/
public Set<Object> sUnion(String key1, String key2) { public Set<Object> sUnion(String key1, String key2) {
return redisTemplate.opsForSet().union(key1, key2); return redisTemplate.opsForSet().union(key1, key2);
} }
/**
* 集合差集key1 - key2
*
* @param key1 第一个集合键
* @param key2 第二个集合键
* @return 差集元素
*/
public Set<Object> sDifference(String key1, String key2) { public Set<Object> sDifference(String key1, String key2) {
return redisTemplate.opsForSet().difference(key1, key2); return redisTemplate.opsForSet().difference(key1, key2);
} }
// ======================== ZSet有序集合======================== // ======================== 6. ZSet 类型操作(有序集合)====================
/**
* 添加到有序集合
*
* @param key 键
* @param value 值
* @param score 分数
* @return 是否添加成功
*/
public Boolean zAdd(String key, Object value, double score) { public Boolean zAdd(String key, Object value, double score) {
return redisTemplate.opsForZSet().add(key, value, score); return redisTemplate.opsForZSet().add(key, value, score);
} }
/**
* 批量添加到有序集合
*
* @param key 键
* @param tuples 分数-值对集合
* @return 添加的数量
*/
public Long zAdd(String key, Set<ZSetOperations.TypedTuple<Object>> tuples) { public Long zAdd(String key, Set<ZSetOperations.TypedTuple<Object>> tuples) {
return redisTemplate.opsForZSet().add(key, tuples); return redisTemplate.opsForZSet().add(key, tuples);
} }
/**
* 从有序集合移除元素
*
* @param key 键
* @param values 要移除的值
* @return 移除的数量
*/
public Long zRemove(String key, Object... values) { public Long zRemove(String key, Object... values) {
return redisTemplate.opsForZSet().remove(key, values); return redisTemplate.opsForZSet().remove(key, values);
} }
/**
* 获取元素排名(升序)
*
* @param key 键
* @param value 值
* @return 排名(从 0 开始),不存在返回 null
*/
public Long zRank(String key, Object value) { public Long zRank(String key, Object value) {
return redisTemplate.opsForZSet().rank(key, value); return redisTemplate.opsForZSet().rank(key, value);
} }
/**
* 获取元素排名(降序)
*
* @param key 键
* @param value 值
* @return 排名(从 0 开始),不存在返回 null
*/
public Long zReverseRank(String key, Object value) { public Long zReverseRank(String key, Object value) {
return redisTemplate.opsForZSet().reverseRank(key, value); return redisTemplate.opsForZSet().reverseRank(key, value);
} }
/**
* 获取有序集合范围(升序)
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
* @return 元素集合
*/
public Set<Object> zRange(String key, long start, long end) { public Set<Object> zRange(String key, long start, long end) {
return redisTemplate.opsForZSet().range(key, start, end); return redisTemplate.opsForZSet().range(key, start, end);
} }
/**
* 获取有序集合范围(降序)
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
* @return 元素集合
*/
public Set<Object> zReverseRange(String key, long start, long end) { public Set<Object> zReverseRange(String key, long start, long end) {
return redisTemplate.opsForZSet().reverseRange(key, start, end); return redisTemplate.opsForZSet().reverseRange(key, start, end);
} }
/**
* 获取有序集合范围(带分数,升序)
*
* @param key 键
* @param start 开始索引
* @param end 结束索引
* @return 分数-值对集合
*/
public Set<ZSetOperations.TypedTuple<Object>> zRangeWithScores(String key, long start, long end) { public Set<ZSetOperations.TypedTuple<Object>> zRangeWithScores(String key, long start, long end) {
return redisTemplate.opsForZSet().rangeWithScores(key, start, end); return redisTemplate.opsForZSet().rangeWithScores(key, start, end);
} }
/**
* 获取指定分数范围的元素
*
* @param key 键
* @param min 最小分数
* @param max 最大分数
* @return 元素集合
*/
public Set<Object> zRangeByScore(String key, double min, double max) { public Set<Object> zRangeByScore(String key, double min, double max) {
return redisTemplate.opsForZSet().rangeByScore(key, min, max); return redisTemplate.opsForZSet().rangeByScore(key, min, max);
} }
/**
* 获取指定分数范围的元素(分页)
*
* @param key 键
* @param min 最小分数
* @param max 最大分数
* @param offset 偏移量
* @param count 数量
* @return 元素集合
*/
public Set<Object> zRangeByScore(String key, double min, double max, long offset, long count) { public Set<Object> zRangeByScore(String key, double min, double max, long offset, long count) {
return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count); return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count);
} }
/**
* 统计指定分数范围内的元素数量
*
* @param key 键
* @param min 最小分数
* @param max 最大分数
* @return 元素数量
*/
public Long zCount(String key, double min, double max) { public Long zCount(String key, double min, double max) {
return redisTemplate.opsForZSet().count(key, min, max); return redisTemplate.opsForZSet().count(key, min, max);
} }
/**
* 获取有序集合大小
*
* @param key 键
* @return 集合大小
*/
public Long zSize(String key) { public Long zSize(String key) {
return redisTemplate.opsForZSet().size(key); return redisTemplate.opsForZSet().size(key);
} }
/**
* 获取元素分数
*
* @param key 键
* @param value 值
* @return 分数
*/
public Double zScore(String key, Object value) { public Double zScore(String key, Object value) {
return redisTemplate.opsForZSet().score(key, value); return redisTemplate.opsForZSet().score(key, value);
} }
/**
* 移除指定排名范围的元素
*
* @param key 键
* @param start 开始排名
* @param end 结束排名
* @return 移除的数量
*/
public Long zRemoveRange(String key, long start, long end) { public Long zRemoveRange(String key, long start, long end) {
return redisTemplate.opsForZSet().removeRange(key, start, end); return redisTemplate.opsForZSet().removeRange(key, start, end);
} }
/**
* 移除指定分数范围的元素
*
* @param key 键
* @param min 最小分数
* @param max 最大分数
* @return 移除的数量
*/
public Long zRemoveRangeByScore(String key, double min, double max) { public Long zRemoveRangeByScore(String key, double min, double max) {
return redisTemplate.opsForZSet().removeRangeByScore(key, min, max); return redisTemplate.opsForZSet().removeRangeByScore(key, min, max);
} }
// ======================== Bitmap ======================== // ======================== 7. Bitmap 类型操作 ========================
/**
* 设置位图的指定位
* 使用场景:
* - 用户签到统计
* - 在线状态统计
* - 布隆过滤器
*
* @param key 键
* @param offset 偏移量(位位置)
* @param value 值true=1false=0
* @return 设置前的值
*/
public Boolean setBit(String key, long offset, boolean value) { public Boolean setBit(String key, long offset, boolean value) {
return redisTemplate.opsForValue().setBit(key, offset, value); return redisTemplate.opsForValue().setBit(key, offset, value);
} }
/**
* 获取位图的指定位
*
* @param key 键
* @param offset 偏移量(位位置)
* @return 位值true=1false=0
*/
public Boolean getBit(String key, long offset) { public Boolean getBit(String key, long offset) {
return redisTemplate.opsForValue().getBit(key, offset); return redisTemplate.opsForValue().getBit(key, offset);
} }
// ======================== HyperLogLog ======================== // ======================== 8. HyperLogLog 类型操作 ========================
/**
* 添加元素到 HyperLogLog
* 使用场景:
* - 统计独立访客数UV
* - 统计独立元素数(基数统计)
* - 优点:内存占用小,适合大数据量
*
* @param key 键
* @param values 值数组
* @return 添加后的基数
*/
public Long pfAdd(String key, Object... values) { public Long pfAdd(String key, Object... values) {
return redisTemplate.opsForHyperLogLog().add(key, values); return redisTemplate.opsForHyperLogLog().add(key, values);
} }
/**
* 统计基数
*
* @param keys 键数组(可统计多个键的并集基数)
* @return 基数
*/
public Long pfCount(String... keys) { public Long pfCount(String... keys) {
return redisTemplate.opsForHyperLogLog().size(keys); return redisTemplate.opsForHyperLogLog().size(keys);
} }
/**
* 合并多个 HyperLogLog
*
* @param destination 目标键
* @param sourceKeys 源键数组
*/
public void pfMerge(String destination, String... sourceKeys) { public void pfMerge(String destination, String... sourceKeys) {
redisTemplate.opsForHyperLogLog().union(destination, sourceKeys); redisTemplate.opsForHyperLogLog().union(destination, sourceKeys);
} }

View File

@@ -6,26 +6,56 @@ import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* Security 工具类
* 提供获取当前登录用户信息的便捷方法
*
* 主要功能:
* 1. 获取当前认证对象
* 2. 获取当前用户名
* 3. 获取当前用户ID
*
* 使用场景:
* - 在 Service 层获取当前用户信息
* - 在拦截器中获取当前用户信息
* - 在日志中记录操作用户
*
* @author Claude
* @since 2024-04-09
*/
@Component @Component
public class SecurityUtils { public class SecurityUtils {
/** /**
* 获取当前 Authentication * 获取当前 Authentication 对象
* 说明:
* - Authentication 包含认证信息和用户详情
* - 如果用户未认证,返回 null
*
* @return 当前认证对象
*/ */
public static Authentication getAuthentication(){ public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication(); return SecurityContextHolder.getContext().getAuthentication();
} }
/** /**
* 获取登录 * 获取当前登录用户的用户
* 步骤:
* 1. 获取当前认证对象
* 2. 检查用户是否已认证
* 3. 从 Principal 中提取用户名
*
* @return 用户名,未登录返回 null
*/ */
public static String getUsername(){ public static String getUsername() {
// 1. 获取当前认证对象
Authentication auth = getAuthentication(); Authentication auth = getAuthentication();
if (auth != null && auth.isAuthenticated()) { if (auth != null && auth.isAuthenticated()) {
Object principal = auth.getPrincipal(); Object principal = auth.getPrincipal();
// 2. 判断 Principal 类型并提取用户名
if (principal instanceof CustomUserDetails user) { if (principal instanceof CustomUserDetails user) {
return user.getUsername(); return user.getUsername();
}else if (principal instanceof String username) { } else if (principal instanceof String username) {
return username; return username;
} }
} }
@@ -33,14 +63,21 @@ public class SecurityUtils {
} }
/** /**
* 获取用户Id * 获取当前登录用户的用户ID
* 步骤:
* 1. 获取当前认证对象
* 2. 检查用户是否已认证
* 3. 从 CustomUserDetails 中提取用户ID
*
* @return 用户ID未登录返回 null
*/ */
public static Long getUserId() { public static Long getUserId() {
// 1. 获取当前认证对象
Authentication auth = getAuthentication(); Authentication auth = getAuthentication();
// 2. 检查是否已认证且 Principal 类型为 CustomUserDetails
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomUserDetails user) { if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomUserDetails user) {
return user.getId(); return user.getId();
} }
return null; return null;
} }
} }

View File

@@ -10,12 +10,17 @@ spring:
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: none
show-sql: false show-sql: false
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
database-platform: org.hibernate.dialect.MySQLDialect flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
locations: classpath:db/migration
encoding: UTF-8
data: data:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
@@ -23,6 +28,8 @@ spring:
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0} database: ${REDIS_DB:0}
timeout: 5000ms timeout: 5000ms
repositories:
enabled: false
lettuce: lettuce:
pool: pool:
max-active: 8 max-active: 8
@@ -44,12 +51,19 @@ spring:
jwt: jwt:
secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!} secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!}
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600} # 1 hour in seconds
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7 days in seconds
app: app:
password-reset: password-reset:
code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10} code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10}
request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60} request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60}
max-attempts: ${PASSWORD_RESET_MAX_ATTEMPTS:5} max-attempts: ${PASSWORD_RESET_MAX_ATTEMPTS:5}
login:
max-attempts: ${LOGIN_MAX_ATTEMPTS:5}
lock-duration-minutes: ${LOGIN_LOCK_DURATION_MINUTES:30}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
logging: logging:
level: level:

View File

@@ -0,0 +1,32 @@
# RocketMQ 配置
rocketmq:
# NameServer 地址(单机)
name-server: localhost:9876
# 生产者配置
producer:
# 生产者组名
group: user-producer-group
# 发送消息超时时间(毫秒)
send-message-timeout: 3000
# 消息最大大小(字节)
max-message-size: 4194304
# 失败重试次数
retry-times-when-send-failed: 2
# 异步发送失败重试次数
retry-times-when-send-async-failed: 2
# 压缩阈值(字节)
compress-message-body-threshold: 4096
# 用户主题名称
user-topic: user-topic
# 消费者配置
consumer:
# 消费者组名
group: user-consumer-group
# 消费线程数(最小)
consume-thread-min: 5
# 消费线程数(最大)
consume-thread-max: 10
# 消息最大重试次数
max-reconsume-times: 3
# 消息超时时间(分钟)
consume-timeout: 15

View File

@@ -5,6 +5,11 @@ spring:
active: dev active: dev
config: config:
import: optional:file:.env[.properties] import: optional:file:.env[.properties]
# 默认排除 RocketMQ 自动配置,启用时通过 profile 激活
# 启用方式spring.profiles.active=dev,rocketmq
autoconfigure:
exclude:
- org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration
server: server:
port: 8080 port: 8080

View File

@@ -0,0 +1,148 @@
-- V1: Baseline schema for new template projects.
-- Flyway runs this file automatically on the first startup.
CREATE TABLE IF NOT EXISTS users (
`id` bigint NOT NULL AUTO_INCREMENT, `email` varchar(255) DEFAULT NULL, `password` varchar(255) NOT NULL,
`username` varchar(50) NOT NULL, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1=enabled 0=disabled',
`failed_login_count` int NOT NULL DEFAULT 0, `locked_until` datetime DEFAULT NULL,
`password_changed_at` datetime DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY uk_users_username (`username`), UNIQUE KEY uk_users_email (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS password_reset_codes (
`id` bigint NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `code_hash` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL, `used` bit(1) NOT NULL DEFAULT b'0', `attempt_count` int NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_password_reset_email` (`email`), KEY `idx_password_reset_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS refresh_tokens (
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `token_hash` varchar(128) NOT NULL,
`device_info` varchar(255) DEFAULT NULL, `ip_address` varchar(45) DEFAULT NULL, `expires_at` datetime NOT NULL,
`revoked` tinyint(1) NOT NULL DEFAULT b'0', `revoked_at` datetime DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_refresh_tokens_hash` (`token_hash`), KEY `idx_refresh_tokens_user_id` (`user_id`),
KEY `idx_refresh_tokens_expires_at` (`expires_at`), KEY `idx_refresh_tokens_revoked` (`revoked`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Refresh token storage';
CREATE TABLE IF NOT EXISTS sys_role (
`id` bigint NOT NULL AUTO_INCREMENT, `role_code` varchar(50) NOT NULL, `role_name` varchar(100) NOT NULL,
`description` varchar(500) DEFAULT NULL, `sort_order` int NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System role table';
CREATE TABLE IF NOT EXISTS sys_permission (
`id` bigint NOT NULL AUTO_INCREMENT, `permission_code` varchar(100) NOT NULL, `permission_name` varchar(100) NOT NULL,
`resource` varchar(50) NOT NULL, `action` varchar(50) NOT NULL, `description` varchar(500) DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 1, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_permission_code` (`permission_code`),
KEY `idx_sys_permission_resource` (`resource`), KEY `idx_sys_permission_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System permission table';
CREATE TABLE IF NOT EXISTS sys_menu (
`id` bigint NOT NULL AUTO_INCREMENT, `parent_id` bigint NOT NULL DEFAULT 0, `menu_name` varchar(50) NOT NULL,
`menu_type` tinyint NOT NULL, `menu_path` varchar(200) DEFAULT NULL, `component` varchar(200) DEFAULT NULL,
`icon` varchar(100) DEFAULT NULL, `sort_order` int NOT NULL DEFAULT 0, `visible` tinyint NOT NULL DEFAULT 1,
`status` tinyint NOT NULL DEFAULT 1, `permission_code` varchar(100) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_sys_menu_parent_id` (`parent_id`), KEY `idx_sys_menu_type` (`menu_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System menu table';
CREATE TABLE IF NOT EXISTS sys_user_role (
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `role_id` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_user_role` (`user_id`, `role_id`), KEY `idx_sys_user_role_user` (`user_id`), KEY `idx_sys_user_role_role` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='User-Role mapping table';
CREATE TABLE IF NOT EXISTS sys_role_permission (
`id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `permission_id` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_permission` (`role_id`, `permission_id`), KEY `idx_sys_role_permission_role` (`role_id`), KEY `idx_sys_role_permission_perm` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Role-Permission mapping table';
CREATE TABLE IF NOT EXISTS sys_role_menu (
`id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `menu_id` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sys_role_menu` (`role_id`, `menu_id`), KEY `idx_sys_role_menu_role` (`role_id`), KEY `idx_sys_role_menu_menu` (`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Role-Menu mapping table';
CREATE TABLE IF NOT EXISTS sys_audit_log (
`id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint DEFAULT NULL, `username` varchar(50) DEFAULT NULL,
`action` varchar(50) NOT NULL, `resource` varchar(100) NOT NULL, `resource_id` varchar(50) DEFAULT NULL,
`description` varchar(500) DEFAULT NULL, `request_method` varchar(10) DEFAULT NULL, `request_uri` varchar(500) DEFAULT NULL,
`ip_address` varchar(45) DEFAULT NULL, `user_agent` varchar(500) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT 1,
`error_message` varchar(1000) DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), KEY `idx_audit_log_user_id` (`user_id`), KEY `idx_audit_log_action` (`action`),
KEY `idx_audit_log_resource` (`resource`), KEY `idx_audit_log_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='System audit log table';
INSERT INTO sys_role (`role_code`, `role_name`, `description`, `sort_order`) VALUES
('ROLE_SUPER_ADMIN', 'Super Administrator', 'Super administrator with all permissions', 1),
('ROLE_ADMIN', 'Administrator', 'Administrator with management permissions', 2),
('ROLE_USER', 'User', 'Regular user with basic permissions', 3);
INSERT INTO sys_permission (`permission_code`, `permission_name`, `resource`, `action`, `description`) VALUES
('user:create', 'Create User', 'user', 'create', 'Create new user'),
('user:read', 'View User', 'user', 'read', 'View user details'),
('user:update', 'Update User', 'user', 'update', 'Update user information'),
('user:delete', 'Delete User', 'user', 'delete', 'Delete user'),
('user:list', 'List Users', 'user', 'list', 'List all users'),
('user:reset-password', 'Reset Password', 'user', 'reset-password', 'Reset user password'),
('role:create', 'Create Role', 'role', 'create', 'Create new role'),
('role:read', 'View Role', 'role', 'read', 'View role details'),
('role:update', 'Update Role', 'role', 'update', 'Update role information'),
('role:delete', 'Delete Role', 'role', 'delete', 'Delete role'),
('role:list', 'List Roles', 'role', 'list', 'List all roles'),
('role:assign-permission', 'Assign Permissions', 'role', 'assign-permission', 'Assign permissions to role'),
('permission:list', 'List Permissions', 'permission', 'list', 'List all permissions'),
('menu:create', 'Create Menu', 'menu', 'create', 'Create new menu'),
('menu:read', 'View Menu', 'menu', 'read', 'View menu details'),
('menu:update', 'Update Menu', 'menu', 'update', 'Update menu information'),
('menu:delete', 'Delete Menu', 'menu', 'delete', 'Delete menu'),
('menu:list', 'List Menus', 'menu', 'list', 'List all menus');
INSERT INTO sys_menu (`parent_id`, `menu_name`, `menu_type`, `menu_path`, `component`, `icon`, `sort_order`, `permission_code`) VALUES
(0, 'System Management', 1, '/system', NULL, 'setting', 1, NULL),
(1, 'User Management', 2, '/system/users', 'system/user/index', 'user', 1, 'user:list'),
(2, 'Add User', 3, NULL, NULL, NULL, 1, 'user:create'),
(2, 'Edit User', 3, NULL, NULL, NULL, 2, 'user:update'),
(2, 'Delete User', 3, NULL, NULL, NULL, 3, 'user:delete'),
(2, 'Reset Password', 3, NULL, NULL, NULL, 4, 'user:reset-password'),
(1, 'Role Management', 2, '/system/roles', 'system/role/index', 'team', 2, 'role:list'),
(7, 'Add Role', 3, NULL, NULL, NULL, 1, 'role:create'),
(7, 'Edit Role', 3, NULL, NULL, NULL, 2, 'role:update'),
(7, 'Delete Role', 3, NULL, NULL, NULL, 3, 'role:delete'),
(7, 'Assign Permissions', 3, NULL, NULL, NULL, 4, 'role:assign-permission'),
(1, 'Menu Management', 2, '/system/menus', 'system/menu/index', 'menu', 3, 'menu:list'),
(12, 'Add Menu', 3, NULL, NULL, NULL, 1, 'menu:create'),
(12, 'Edit Menu', 3, NULL, NULL, NULL, 2, 'menu:update'),
(12, 'Delete Menu', 3, NULL, NULL, NULL, 3, 'menu:delete');
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
WHERE r.role_code = 'ROLE_SUPER_ADMIN';
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
WHERE r.role_code = 'ROLE_ADMIN'
AND p.permission_code IN ('user:list', 'user:read', 'user:update', 'user:reset-password', 'role:list', 'role:read', 'permission:list');
INSERT INTO sys_role_permission (`role_id`, `permission_id`)
SELECT r.id, p.id FROM sys_role r JOIN sys_permission p
WHERE r.role_code = 'ROLE_USER' AND p.permission_code IN ('user:read');
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
WHERE r.role_code = 'ROLE_SUPER_ADMIN';
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
WHERE r.role_code = 'ROLE_ADMIN'
AND m.menu_name IN ('System Management', 'User Management', 'Role Management', 'Menu Management');
INSERT INTO sys_role_menu (`role_id`, `menu_id`)
SELECT r.id, m.id FROM sys_role r JOIN sys_menu m
WHERE r.role_code = 'ROLE_USER' AND m.menu_name = 'User Management';

View File

@@ -0,0 +1,15 @@
-- V2: Example Flyway migration.
-- Keep this small file to show template users how automatic migrations work.
-- Add future schema changes as V3__xxx.sql, V4__xxx.sql, ...
CREATE TABLE IF NOT EXISTS app_schema_version_note (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` varchar(50) NOT NULL,
`description` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_schema_version_note_version` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Flyway migration example note';
INSERT INTO app_schema_version_note (`version`, `description`)
VALUES ('V2', 'Example migration applied by Flyway automatically');

View File

@@ -1,35 +0,0 @@
-- ============================================
-- Template database initialization script
-- ============================================
CREATE TABLE IF NOT EXISTS users (
`id` bigint NOT NULL AUTO_INCREMENT,
`email` varchar(255) DEFAULT NULL,
`password` varchar(255) NOT NULL,
`username` varchar(50) NOT NULL,
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=enabled 0=disabled',
`role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'Role: USER or ADMIN',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY uk_users_username (`username`),
UNIQUE KEY uk_users_email (`email`),
KEY idx_users_role (`role`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS password_reset_codes (
`id` bigint NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`code_hash` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL,
`used` bit(1) NOT NULL DEFAULT b'0',
`attempt_count` int NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_password_reset_email` (`email`),
KEY `idx_password_reset_expires_at` (`expires_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;