From 40c85c3c1f91d057e6e63f9d6d75e3c1a46e62fc Mon Sep 17 00:00:00 2001 From: shenjianZ Date: Fri, 10 Apr 2026 10:58:22 +0800 Subject: [PATCH] =?UTF-8?q?=20=20feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=20RBAC=20=E6=9D=83=E9=99=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F=E4=B8=8E=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在初始认证基础上,新增完整的 RBAC 权限模型(角色、权限、菜单三级管理), 集成审计日志、接口限流、登录失败锁定、Refresh Token 机制、Redis 分布式缓存与锁、 RocketMQ 消息队列,并引入 Flyway 数据库版本管理,同时补充项目文档与使用示例 --- DETAILS.md | 725 +++++++++++++++++ README.md | 3 + README_USAGE.md | 559 +++++++++++++ pom.xml | 25 + .../aisi/template/TemplateApplication.java | 3 + .../aisi/template/annotation/AuditLog.java | 62 ++ .../annotation/PasswordValidator.java | 164 ++++ .../aisi/template/annotation/RateLimit.java | 88 ++ .../template/annotation/StrongPassword.java | 87 ++ .../aisi/template/aspect/AuditLogAspect.java | 217 +++++ .../aisi/template/aspect/RateLimitAspect.java | 174 ++++ .../aisi/template/config/JacksonConfig.java | 30 +- .../com/aisi/template/config/JpaConfig.java | 13 + .../aisi/template/config/OpenApiConfig.java | 35 + .../com/aisi/template/config/RedisConfig.java | 48 ++ .../aisi/template/config/SecurityConfig.java | 97 ++- .../com/aisi/template/config/WebConfig.java | 26 +- .../aisi/template/constants/AppConstants.java | 134 +++ .../template/constants/SecurityConstants.java | 103 +++ .../template/controller/MenuController.java | 213 +++++ .../controller/PermissionController.java | 132 +++ .../template/controller/RoleController.java | 229 ++++++ .../template/controller/UserController.java | 296 ++++++- .../template/domain/CustomUserDetails.java | 118 ++- .../com/aisi/template/domain/RestBean.java | 80 +- .../com/aisi/template/domain/RestCode.java | 225 +++++- .../com/aisi/template/domain/dto/MenuDto.java | 114 +++ .../domain/dto/PasswordResetConfirmDto.java | 48 +- .../domain/dto/PasswordResetRequestDto.java | 26 +- .../template/domain/dto/RefreshTokenDto.java | 14 + .../com/aisi/template/domain/dto/RoleDto.java | 65 ++ .../template/domain/dto/RoleQueryDto.java | 41 + .../com/aisi/template/domain/dto/UserDto.java | 39 +- .../template/domain/dto/UserQueryDto.java | 40 +- .../domain/dto/UserRoleUpdateDto.java | 34 +- .../domain/dto/UserStatusUpdateDto.java | 24 + .../domain/entity/PasswordResetCode.java | 51 ++ .../template/domain/entity/RefreshToken.java | 126 +++ .../template/domain/entity/SysAuditLog.java | 150 ++++ .../aisi/template/domain/entity/SysMenu.java | 172 ++++ .../template/domain/entity/SysPermission.java | 133 +++ .../aisi/template/domain/entity/SysRole.java | 139 ++++ .../com/aisi/template/domain/entity/User.java | 117 ++- .../aisi/template/domain/enums/ErrorCode.java | 24 - .../com/aisi/template/domain/enums/Role.java | 49 -- .../template/domain/vo/LoginResponseVo.java | 94 +++ .../com/aisi/template/domain/vo/MenuVo.java | 228 ++++++ .../aisi/template/domain/vo/PermissionVo.java | 154 ++++ .../com/aisi/template/domain/vo/RoleVo.java | 152 ++++ .../com/aisi/template/domain/vo/UserVo.java | 47 +- .../template/examples/RedisUsageExample.java | 536 ++++++++++++ .../template/examples/TransactionExample.java | 540 +++++++++++++ .../template/exception/BusinessException.java | 67 +- .../exception/RateLimitExceededException.java | 35 + .../filter/JwtAuthenticationFilter.java | 125 ++- .../handler/GlobalExceptionHandler.java | 161 +++- .../interceptor/LoggingInterceptor.java | 72 +- .../mq/consumer/UserMessageConsumer.java | 267 ++++++ .../aisi/template/mq/message/UserMessage.java | 65 ++ .../mq/producer/UserMessageProducer.java | 180 +++++ .../PasswordResetCodeRepository.java | 55 ++ .../repository/RefreshTokenRepository.java | 82 ++ .../repository/SysAuditLogRepository.java | 86 ++ .../repository/SysMenuRepository.java | 110 +++ .../repository/SysPermissionRepository.java | 97 +++ .../repository/SysRoleRepository.java | 79 ++ .../template/repository/UserRepository.java | 132 ++- .../template/service/AuditLogService.java | 65 ++ .../aisi/template/service/EmailService.java | 27 + .../template/service/LoginAttemptService.java | 91 +++ .../service/PasswordResetService.java | 42 + .../aisi/template/service/SysMenuService.java | 134 +++ .../service/SysPermissionService.java | 83 ++ .../aisi/template/service/SysRoleService.java | 139 ++++ .../aisi/template/service/TokenService.java | 161 ++++ .../aisi/template/service/UserService.java | 86 +- .../service/impl/AuditLogServiceImpl.java | 133 +++ .../impl/CustomUserDetailsService.java | 73 +- .../service/impl/EmailServiceImpl.java | 55 +- .../service/impl/LoginAttemptServiceImpl.java | 208 +++++ .../impl/PasswordResetServiceImpl.java | 149 +++- .../service/impl/SysMenuServiceImpl.java | 346 ++++++++ .../impl/SysPermissionServiceImpl.java | 157 ++++ .../service/impl/SysRoleServiceImpl.java | 343 ++++++++ .../service/impl/TokenServiceImpl.java | 363 +++++++++ .../service/impl/UserServiceImpl.java | 366 ++++++++- .../java/com/aisi/template/utils/JwtUtil.java | 171 +++- .../com/aisi/template/utils/RedisCache.java | 762 ++++++++++++++++++ .../com/aisi/template/utils/RedisLock.java | 301 +++++++ .../com/aisi/template/utils/RedisUtils.java | 568 ++++++++++++- .../aisi/template/utils/SecurityUtils.java | 53 +- src/main/resources/application-dev.yaml | 18 +- src/main/resources/application-rocketmq.yaml | 32 + src/main/resources/application.yaml | 5 + .../db/migration/V1__baseline_schema.sql | 148 ++++ .../migration/V2__example_auto_migration.sql | 15 + src/main/resources/sql/init.sql | 35 - 97 files changed, 13434 insertions(+), 351 deletions(-) create mode 100644 DETAILS.md create mode 100644 README_USAGE.md create mode 100644 src/main/java/com/aisi/template/annotation/AuditLog.java create mode 100644 src/main/java/com/aisi/template/annotation/PasswordValidator.java create mode 100644 src/main/java/com/aisi/template/annotation/RateLimit.java create mode 100644 src/main/java/com/aisi/template/annotation/StrongPassword.java create mode 100644 src/main/java/com/aisi/template/aspect/AuditLogAspect.java create mode 100644 src/main/java/com/aisi/template/aspect/RateLimitAspect.java create mode 100644 src/main/java/com/aisi/template/constants/AppConstants.java create mode 100644 src/main/java/com/aisi/template/constants/SecurityConstants.java create mode 100644 src/main/java/com/aisi/template/controller/MenuController.java create mode 100644 src/main/java/com/aisi/template/controller/PermissionController.java create mode 100644 src/main/java/com/aisi/template/controller/RoleController.java create mode 100644 src/main/java/com/aisi/template/domain/dto/MenuDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/RefreshTokenDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/RoleDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java create mode 100644 src/main/java/com/aisi/template/domain/entity/RefreshToken.java create mode 100644 src/main/java/com/aisi/template/domain/entity/SysAuditLog.java create mode 100644 src/main/java/com/aisi/template/domain/entity/SysMenu.java create mode 100644 src/main/java/com/aisi/template/domain/entity/SysPermission.java create mode 100644 src/main/java/com/aisi/template/domain/entity/SysRole.java delete mode 100644 src/main/java/com/aisi/template/domain/enums/ErrorCode.java delete mode 100644 src/main/java/com/aisi/template/domain/enums/Role.java create mode 100644 src/main/java/com/aisi/template/domain/vo/LoginResponseVo.java create mode 100644 src/main/java/com/aisi/template/domain/vo/MenuVo.java create mode 100644 src/main/java/com/aisi/template/domain/vo/PermissionVo.java create mode 100644 src/main/java/com/aisi/template/domain/vo/RoleVo.java create mode 100644 src/main/java/com/aisi/template/examples/RedisUsageExample.java create mode 100644 src/main/java/com/aisi/template/examples/TransactionExample.java create mode 100644 src/main/java/com/aisi/template/exception/RateLimitExceededException.java create mode 100644 src/main/java/com/aisi/template/mq/consumer/UserMessageConsumer.java create mode 100644 src/main/java/com/aisi/template/mq/message/UserMessage.java create mode 100644 src/main/java/com/aisi/template/mq/producer/UserMessageProducer.java create mode 100644 src/main/java/com/aisi/template/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/aisi/template/repository/SysAuditLogRepository.java create mode 100644 src/main/java/com/aisi/template/repository/SysMenuRepository.java create mode 100644 src/main/java/com/aisi/template/repository/SysPermissionRepository.java create mode 100644 src/main/java/com/aisi/template/repository/SysRoleRepository.java create mode 100644 src/main/java/com/aisi/template/service/AuditLogService.java create mode 100644 src/main/java/com/aisi/template/service/LoginAttemptService.java create mode 100644 src/main/java/com/aisi/template/service/SysMenuService.java create mode 100644 src/main/java/com/aisi/template/service/SysPermissionService.java create mode 100644 src/main/java/com/aisi/template/service/SysRoleService.java create mode 100644 src/main/java/com/aisi/template/service/TokenService.java create mode 100644 src/main/java/com/aisi/template/service/impl/AuditLogServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/LoginAttemptServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/SysMenuServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/SysPermissionServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/SysRoleServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/TokenServiceImpl.java create mode 100644 src/main/java/com/aisi/template/utils/RedisCache.java create mode 100644 src/main/java/com/aisi/template/utils/RedisLock.java create mode 100644 src/main/resources/application-rocketmq.yaml create mode 100644 src/main/resources/db/migration/V1__baseline_schema.sql create mode 100644 src/main/resources/db/migration/V2__example_auto_migration.sql delete mode 100644 src/main/resources/sql/init.sql diff --git a/DETAILS.md b/DETAILS.md new file mode 100644 index 0000000..2926680 --- /dev/null +++ b/DETAILS.md @@ -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 + + 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 + ├── 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. 日志收集:集成 ELK(Elasticsearch + Logstash + Kibana) diff --git a/README.md b/README.md index 878c0f6..32cf6b2 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ docker run -d -p 8080:8080 \ 详见 [API_DOCUMENT.md](API_DOCUMENT.md) +## 项目概览 + +详见 [DETAILS.md](DETAILS.md) ## License Apache License 2.0 diff --git a/README_USAGE.md b/README_USAGE.md new file mode 100644 index 0000000..18a61b8 --- /dev/null +++ b/README_USAGE.md @@ -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 result = userService.register(userDto); +``` + +#### 用户登录 + +```java +UserDto loginDto = new UserDto(); +loginDto.setUsername("testuser"); +loginDto.setPassword("Test123!"); + +RestBean 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 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 create(@RequestBody ArticleDto dto) { + // ... + } + + // 需要管理员角色或文章管理权限 + @PreAuthorize("hasAnyRole('ROLE_ADMIN') or hasAuthority('article:delete')") + @DeleteMapping("/{id}") + public RestBean 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 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 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 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 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 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 { + + @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 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 +- 定期审查用户权限 +- 启用审计日志 diff --git a/pom.xml b/pom.xml index bbd9c97..69db3c3 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,31 @@ com.fasterxml.jackson.core jackson-databind + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + org.apache.rocketmq + rocketmq-spring-boot-starter + 2.3.1 + + + + org.springframework.boot + spring-boot-starter-aop + + + + com.github.ben-manes.caffeine + caffeine + diff --git a/src/main/java/com/aisi/template/TemplateApplication.java b/src/main/java/com/aisi/template/TemplateApplication.java index 6e5d9b1..3c4ad1d 100644 --- a/src/main/java/com/aisi/template/TemplateApplication.java +++ b/src/main/java/com/aisi/template/TemplateApplication.java @@ -2,6 +2,9 @@ package com.aisi.template; import org.springframework.boot.SpringApplication; 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 public class TemplateApplication { diff --git a/src/main/java/com/aisi/template/annotation/AuditLog.java b/src/main/java/com/aisi/template/annotation/AuditLog.java new file mode 100644 index 0000000..bc72cf8 --- /dev/null +++ b/src/main/java/com/aisi/template/annotation/AuditLog.java @@ -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; + +/** + * 审计日志注解 + * 标注需要记录审计日志的方法 + * + * 使用场景: + * - 用户登录/登出 + * - 数据创建/更新/删除 + * - 敏感操作访问 + * + * 使用示例: + *
+ * @AuditLog(
+ *     action = "LOGIN",
+ *     resource = "user",
+ *     description = "用户 {0} 登录成功"
+ * )
+ * public void login(String username) { ... }
+ * 
+ * + * @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 ""; +} diff --git a/src/main/java/com/aisi/template/annotation/PasswordValidator.java b/src/main/java/com/aisi/template/annotation/PasswordValidator.java new file mode 100644 index 0000000..3eb5c6c --- /dev/null +++ b/src/main/java/com/aisi/template/annotation/PasswordValidator.java @@ -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 { + + /** + * 最小密码长度 + */ + 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(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*"); + } +} diff --git a/src/main/java/com/aisi/template/annotation/RateLimit.java b/src/main/java/com/aisi/template/annotation/RateLimit.java new file mode 100644 index 0000000..94402aa --- /dev/null +++ b/src/main/java/com/aisi/template/annotation/RateLimit.java @@ -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 接口:防止恶意刷接口 + * - 抢购活动:防止刷单 + * + * 使用示例: + *
+ * @RateLimit(
+ *     permits = 5,
+ *     seconds = 60,
+ *     limitType = LimitType.IP,
+ *     keyPrefix = "login:"
+ * )
+ * public void login(String username) { ... }
+ * 
+ * + * @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 + } +} diff --git a/src/main/java/com/aisi/template/annotation/StrongPassword.java b/src/main/java/com/aisi/template/annotation/StrongPassword.java new file mode 100644 index 0000000..34aac26 --- /dev/null +++ b/src/main/java/com/aisi/template/annotation/StrongPassword.java @@ -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 个字符 + * - 包含至少一个大写字母 + * - 包含至少一个小写字母 + * - 包含至少一个数字 + * - 包含至少一个特殊字符 + * + * 使用示例: + *
+ * @StrongPassword(
+ *     minLength = 10,
+ *     requireUppercase = true,
+ *     requireLowercase = true,
+ *     requireDigit = true,
+ *     requireSpecialChar = true
+ * )
+ * private String password;
+ * 
+ * + * @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[] 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; +} diff --git a/src/main/java/com/aisi/template/aspect/AuditLogAspect.java b/src/main/java/com/aisi/template/aspect/AuditLogAspect.java new file mode 100644 index 0000000..e665620 --- /dev/null +++ b/src/main/java/com/aisi/template/aspect/AuditLogAspect.java @@ -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; + } +} diff --git a/src/main/java/com/aisi/template/aspect/RateLimitAspect.java b/src/main/java/com/aisi/template/aspect/RateLimitAspect.java new file mode 100644 index 0000000..19b4876 --- /dev/null +++ b/src/main/java/com/aisi/template/aspect/RateLimitAspect.java @@ -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"; + } +} diff --git a/src/main/java/com/aisi/template/config/JacksonConfig.java b/src/main/java/com/aisi/template/config/JacksonConfig.java index f8d2890..ba71c65 100644 --- a/src/main/java/com/aisi/template/config/JacksonConfig.java +++ b/src/main/java/com/aisi/template/config/JacksonConfig.java @@ -10,25 +10,47 @@ import org.springframework.context.annotation.Configuration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +/** + * Jackson 配置类 + * 配置 JSON 序列化相关设置 + * + * 主要功能: + * 1. 日期时间格式化:统一日期时间格式 + * 2. 禁用时间戳:使用可读的日期字符串 + * + * @author Claude + * @since 2024-04-09 + */ @Configuration public class JacksonConfig { + /** + * 配置 ObjectMapper + * 步骤: + * 1. 创建 ObjectMapper 对象 + * 2. 注册 JavaTimeModule(支持 Java 8 日期时间类型) + * 3. 配置 LocalDateTime 序列化格式 + * 4. 禁用时间戳格式 + * + * @return ObjectMapper 对象 + */ @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); - // 配置日期时间格式 + // 1. 配置日期时间模块 JavaTimeModule javaTimeModule = new JavaTimeModule(); - // 定义日期时间格式 + // 2. 定义日期时间格式:yyyy-MM-dd HH:mm:ss DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); + // 3. 注册日期时间模块 mapper.registerModule(javaTimeModule); - // 禁用时间戳格式 + // 4. 禁用时间戳格式(使用可读的日期字符串) mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisi/template/config/JpaConfig.java b/src/main/java/com/aisi/template/config/JpaConfig.java index 4f617cd..353fc71 100644 --- a/src/main/java/com/aisi/template/config/JpaConfig.java +++ b/src/main/java/com/aisi/template/config/JpaConfig.java @@ -5,6 +5,19 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; /** * JPA 配置类 + * 配置 JPA 审计功能 + * + * 主要功能: + * 1. 自动填充创建时间:@CreatedDate + * 2. 自动填充更新时间:@LastModifiedDate + * + * 使用说明: + * - 在实体类上添加 @EntityListeners(AuditingEntityListener.class) + * - 在字段上使用 @CreatedDate 和 @LastModifiedDate 注解 + * - JPA 会自动维护这些字段的值 + * + * @author Claude + * @since 2024-04-09 */ @Configuration @EnableJpaAuditing diff --git a/src/main/java/com/aisi/template/config/OpenApiConfig.java b/src/main/java/com/aisi/template/config/OpenApiConfig.java index 813576a..0ea02b6 100644 --- a/src/main/java/com/aisi/template/config/OpenApiConfig.java +++ b/src/main/java/com/aisi/template/config/OpenApiConfig.java @@ -2,14 +2,49 @@ package com.aisi.template.config; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 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 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 @SecurityScheme( + // 安全方案名称 name = "Bearer Authentication", + // 安全方案类型:HTTP type = SecuritySchemeType.HTTP, + // Bearer 格式:JWT bearerFormat = "JWT", + // 认证方案:bearer scheme = "bearer" ) 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"))); + } } diff --git a/src/main/java/com/aisi/template/config/RedisConfig.java b/src/main/java/com/aisi/template/config/RedisConfig.java index 9f7445c..4b06b94 100644 --- a/src/main/java/com/aisi/template/config/RedisConfig.java +++ b/src/main/java/com/aisi/template/config/RedisConfig.java @@ -13,14 +13,45 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 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 public class RedisConfig { + /** + * 配置 RedisTemplate(用于操作对象) + * 步骤: + * 1. 创建 RedisTemplate 对象 + * 2. 配置 ObjectMapper(支持类型信息) + * 3. 配置 Key 序列化方式 + * 4. 配置 Value 序列化方式 + * + * @param factory Redis 连接工厂 + * @param objectMapper Jackson ObjectMapper + * @return RedisTemplate 对象 + */ @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) { + // 1. 创建 RedisTemplate 对象 RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(factory); + // 2. 配置 ObjectMapper(支持类型信息,用于反序列化时确定类型) ObjectMapper redisMapper = objectMapper.copy(); redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); redisMapper.activateDefaultTyping( @@ -29,18 +60,35 @@ public class RedisConfig { JsonTypeInfo.As.PROPERTY ); + // 3. 配置序列化器 StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper); + // 3.1 Key 使用 String 序列化 template.setKeySerializer(stringSerializer); + // 3.2 HashKey 使用 String 序列化 template.setHashKeySerializer(stringSerializer); + // 3.3 Value 使用 JSON 序列化 template.setValueSerializer(jsonSerializer); + // 3.4 HashValue 使用 JSON 序列化 template.setHashValueSerializer(jsonSerializer); + + // 4. 执行初始化 template.afterPropertiesSet(); return template; } + /** + * 配置 StringRedisTemplate(用于操作字符串) + * 说明: + * - StringRedisTemplate 专门用于处理字符串类型数据 + * - Key 和 Value 都使用 String 序列化 + * - 适用于简单的键值对操作 + * + * @param factory Redis 连接工厂 + * @return StringRedisTemplate 对象 + */ @Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { return new StringRedisTemplate(factory); diff --git a/src/main/java/com/aisi/template/config/SecurityConfig.java b/src/main/java/com/aisi/template/config/SecurityConfig.java index 9c3d8e0..34e8d9b 100644 --- a/src/main/java/com/aisi/template/config/SecurityConfig.java +++ b/src/main/java/com/aisi/template/config/SecurityConfig.java @@ -2,6 +2,7 @@ package com.aisi.template.config; import com.aisi.template.filter.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; 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.List; +/** + * Spring Security 配置类 + * 配置安全认证和授权相关设置 + * + * 主要功能: + * 1. JWT 认证:配置 JWT 过滤器 + * 2. CORS 配置:跨域资源共享配置 + * 3. 会话管理:无状态会话(JWT) + * 4. 授权规则:配置哪些请求需要认证 + * 5. 密码编码:配置 BCrypt 密码编码器 + * + * @author Claude + * @since 2024-04-09 + */ @Configuration @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { - + /** + * JWT 认证过滤器 + * 用于拦截请求并验证 JWT Token + */ private final JwtAuthenticationFilter jwtAuthenticationFilter; + /** + * 允许的跨域来源 + * 从配置文件读取,默认:http://localhost:5173, http://localhost:3000 + */ + @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") + private List allowedOrigins; + + /** + * 配置安全过滤器链 + * 步骤: + * 1. 禁用 CSRF(使用 JWT 不需要 CSRF 保护) + * 2. 配置 CORS + * 3. 设置会话管理为无状态(JWT) + * 4. 配置授权规则 + * 5. 添加 JWT 认证过滤器 + * + * @param http HttpSecurity 对象 + * @return 安全过滤器链 + */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + // 1. 禁用 CSRF(JWT 不需要) .csrf(AbstractHttpConfigurer::disable) + // 2. 配置 CORS .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // 3. 设置会话管理为无状态 .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 4. 配置授权规则 .authorizeHttpRequests(auth -> auth - .requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll() - .requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll() + // 4.1 Swagger 文档:允许匿名访问 + .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() + // 4.4 获取当前用户信息:需要认证 .requestMatchers("/api/v1/user/info").authenticated() + // 4.5 其他所有请求:需要认证 .anyRequest().authenticated() ) + // 5. 添加 JWT 认证过滤器(在用户名密码过滤器之前) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + /** + * 配置 CORS(跨域资源共享) + * 步骤: + * 1. 创建 CORS 配置 + * 2. 设置允许的来源 + * 3. 设置允许的 HTTP 方法 + * 4. 设置允许的请求头 + * 5. 设置暴露的响应头 + * 6. 允许携带凭证(Cookie) + * 7. 设置预检请求缓存时间 + * + * @return CORS 配置源 + */ @Bean public CorsConfigurationSource corsConfigurationSource() { + // 1. 创建 CORS 配置 CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of("*")); - configuration.setAllowedHeaders(List.of("*")); + + // 2. 设置允许的来源 + 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.setExposedHeaders(List.of("*")); + + // 7. 设置预检请求缓存时间(1 小时) configuration.setMaxAge(3600L); + // 8. 注册 CORS 配置 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } + /** + * 配置密码编码器 + * 说明: + * - 使用 BCrypt 算法对密码进行加密 + * - 每次加密都会生成不同的哈希值(自带盐值) + * - 强度因子默认为 10 + * + * @return BCrypt 密码编码器 + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/aisi/template/config/WebConfig.java b/src/main/java/com/aisi/template/config/WebConfig.java index cea7ae7..471571b 100644 --- a/src/main/java/com/aisi/template/config/WebConfig.java +++ b/src/main/java/com/aisi/template/config/WebConfig.java @@ -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.WebMvcConfigurer; +/** + * Web MVC 配置类 + * 配置拦截器和其他 Web 相关设置 + * + * 主要功能: + * 1. 拦截器配置:注册日志拦截器 + * + * @author Claude + * @since 2024-04-09 + */ @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + /** + * 日志拦截器 + * 用于记录请求和响应信息 + */ private final LoggingInterceptor loggingInterceptor; - + /** + * 配置拦截器 + * 步骤: + * 1. 注册日志拦截器 + * 2. 拦截所有请求(/**) + * 3. 可配置排除路径 + * + * @param registry 拦截器注册表 + */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loggingInterceptor) + // 1. 拦截所有请求 .addPathPatterns("/**") + // 2. 排除路径(可在此添加需要排除的路径) .excludePathPatterns(); } } diff --git a/src/main/java/com/aisi/template/constants/AppConstants.java b/src/main/java/com/aisi/template/constants/AppConstants.java new file mode 100644 index 0000000..cf0b90a --- /dev/null +++ b/src/main/java/com/aisi/template/constants/AppConstants.java @@ -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() { + // 防止实例化 + } +} diff --git a/src/main/java/com/aisi/template/constants/SecurityConstants.java b/src/main/java/com/aisi/template/constants/SecurityConstants.java new file mode 100644 index 0000000..1a77f23 --- /dev/null +++ b/src/main/java/com/aisi/template/constants/SecurityConstants.java @@ -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} + * - jti:JWT ID(JWT 唯一标识) + * - 示例:token:blacklist:abc123-def456 + */ + public static final String REDIS_KEY_BLACKLIST_PREFIX = "token:blacklist:"; + + /** + * Refresh Token Redis 键前缀 + * - 格式:refresh_token:{tokenHash} + * - tokenHash:Token 的 SHA-256 哈希值 + * - 示例:refresh_token:a1b2c3... + */ + public static final String REDIS_KEY_REFRESH_TOKEN_PREFIX = "refresh_token:"; + + /** + * 限流 Redis 键前缀 + * - 格式:rate_limit:{type}:{identifier} + * - type:ip(按 IP)、user(按用户)、global(全局) + * - identifier:IP 地址、用户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() { + // 防止实例化 + } +} diff --git a/src/main/java/com/aisi/template/controller/MenuController.java b/src/main/java/com/aisi/template/controller/MenuController.java new file mode 100644 index 0000000..0fb3dcb --- /dev/null +++ b/src/main/java/com/aisi/template/controller/MenuController.java @@ -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 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 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 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 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> getMenuTree() { + List 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> getUserMenus(Authentication authentication) { + // 1. 从认证信息获取用户ID + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + Long userId = userDetails.getId(); + // 2. 查询用户菜单(已过滤) + List menus = menuService.getUserMenus(userId); + return RestBean.success(menus); + } + + /** + * 根据父节点ID获取子菜单 + * 步骤: + * 1. 查询指定父节点下的所有直接子菜单 + * 2. 按排序字段排序 + * 3. 返回列表 + * + * 权限:需要 menu:list 权限 + * + * 使用场景: + * - 动态加载子菜单 + * - 前端树形控件展开时加载 + * + * @param parentId 父节点ID(0 表示根节点) + * @return 子菜单列表 + */ + @GetMapping("/parent/{parentId}") + @Operation(summary = "获取子菜单", description = "根据父节点ID获取直接子菜单") + @PreAuthorize("hasAuthority('menu:list')") + public RestBean> getByParentId(@PathVariable Long parentId) { + List menus = menuService.getByParentId(parentId); + return RestBean.success(menus); + } +} diff --git a/src/main/java/com/aisi/template/controller/PermissionController.java b/src/main/java/com/aisi/template/controller/PermissionController.java new file mode 100644 index 0000000..351d015 --- /dev/null +++ b/src/main/java/com/aisi/template/controller/PermissionController.java @@ -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> getAllPermissions() { + List 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 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> getByResource(@PathVariable String resource) { + List 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> getByAction(@PathVariable String action) { + List permissions = permissionService.getByAction(action); + return RestBean.success(permissions); + } +} diff --git a/src/main/java/com/aisi/template/controller/RoleController.java b/src/main/java/com/aisi/template/controller/RoleController.java new file mode 100644 index 0000000..f5c66b8 --- /dev/null +++ b/src/main/java/com/aisi/template/controller/RoleController.java @@ -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 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 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 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 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> getAllRoles() { + List 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> queryRoles( + @RequestBody RoleQueryDto queryDto, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + PageResult 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 assignPermissions( + @PathVariable Long id, + @RequestBody List 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> getRolePermissionIds(@PathVariable Long id) { + List permissionIds = roleService.getRolePermissionIds(id); + return RestBean.success(permissionIds); + } +} diff --git a/src/main/java/com/aisi/template/controller/UserController.java b/src/main/java/com/aisi/template/controller/UserController.java index 541a4a8..ed09670 100644 --- a/src/main/java/com/aisi/template/controller/UserController.java +++ b/src/main/java/com/aisi/template/controller/UserController.java @@ -4,84 +4,352 @@ import com.aisi.template.domain.RestBean; import com.aisi.template.domain.dto.PageResult; import com.aisi.template.domain.dto.PasswordResetConfirmDto; 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.UserQueryDto; import com.aisi.template.domain.dto.UserRoleUpdateDto; 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.service.PasswordResetService; +import com.aisi.template.service.TokenService; 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; 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 @RequestMapping("/api/v1/user") @RequiredArgsConstructor -@Tag(name = "用户接口") +@Tag(name = "用户接口", description = "用户注册、登录、信息管理等相关接口") public class UserController { + /** + * 用户服务 + * 处理用户相关的业务逻辑 + */ private final UserService userService; + + /** + * 密码重置服务 + * 处理密码重置的业务逻辑 + */ 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") - @Operation(summary = "用户信息") + @Operation(summary = "获取当前用户信息", description = "获取已登录用户的详细信息") @SecurityRequirement(name = "Bearer Authentication") public RestBean getUserInfo() { return userService.getUserInfo(); } + /** + * 用户注册 + * 步骤: + * 1. 校验用户名、邮箱是否已存在 + * 2. 校验密码强度(必须包含大小写字母、数字、特殊字符) + * 3. 创建用户并分配默认角色(ROLE_USER) + * 4. 生成 Access Token 和 Refresh Token + * + * 注意: + * - 密码会使用 BCrypt 加密存储 + * - 新用户默认分配 ROLE_USER 角色 + * + * @param userDto 用户注册信息(用户名、密码、邮箱) + * @return 登录响应(包含 Token 和用户信息) + */ @PostMapping("register") - @Operation(summary = "用户注册") - public RestBean register(@Valid @RequestBody UserDto userDto) { + @Operation(summary = "用户注册", description = "创建新用户账号并返回登录凭证") + public RestBean register(@Valid @RequestBody UserDto userDto) { return userService.register(userDto); } + /** + * 用户登录 + * 步骤: + * 1. 验证用户名和密码 + * 2. 检查账户状态(是否被禁用、锁定) + * 3. 记录登录失败次数(超过阈值则锁定账户) + * 4. 生成 Access Token 和 Refresh Token + * 5. 发送登录消息到 MQ + * + * 注意: + * - 连续登录失败 5 次会锁定账户 30 分钟 + * - 密码错误也会被记录 + * + * @param userDto 登录信息(用户名、密码) + * @return 登录响应(包含 Token 和用户信息) + */ @PostMapping("login") - @Operation(summary = "用户登录") - RestBean login(@Valid @RequestBody UserDto userDto) { + @Operation(summary = "用户登录", description = "使用用户名密码登录,返回访问令牌") + RestBean login(@Valid @RequestBody UserDto userDto) { return userService.login(userDto); } + /** + * 用户登出 + * 步骤: + * 1. 从请求头获取当前 Token + * 2. 提取 Token 的 JTI(JWT 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 logout(HttpServletRequest request) { + // 1. 从请求头提取 Token + String token = extractToken(request); + if (token != null) { + // 2. 提取 Token 的 JTI(JWT 唯一标识) + 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 Token(Token 轮换机制) + * 4. 撤销旧的 Refresh Token + * + * 注意: + * - Refresh Token 使用一次后即失效 + * - 使用 Refresh Token 轮换提高安全性 + * + * @param refreshTokenDto 刷新令牌请求 + * @return 新的登录响应(包含新的 Token) + */ + @PostMapping("refresh") + @Operation(summary = "刷新访问令牌", description = "使用 Refresh Token 获取新的 Access Token") + public RestBean refreshToken(@Valid @RequestBody RefreshTokenDto refreshTokenDto) { + LoginResponseVo response = tokenService.refreshToken(refreshTokenDto.getRefreshToken()); + return RestBean.success(response); + } + + /** + * 发送密码重置验证码 + * 步骤: + * 1. 校验邮箱格式 + * 2. 检查请求冷却时间(防止频繁请求) + * 3. 生成 6 位数字验证码 + * 4. 发送验证码到用户邮箱 + * 5. 验证码哈希后存储到 Redis(10分钟有效) + * + * 注意: + * - 同一邮箱 60 秒内只能请求一次 + * - 验证码最多尝试 5 次 + * + * @param requestDto 密码重置请求(邮箱) + * @return 成功响应 + */ @PostMapping("password-reset/request") - @Operation(summary = "发送找回密码验证码") + @Operation(summary = "发送密码重置验证码", description = "发送验证码到用户邮箱") public RestBean sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) { return passwordResetService.sendResetCode(requestDto); } + /** + * 使用验证码重置密码 + * 步骤: + * 1. 验证邮箱和验证码是否匹配 + * 2. 验证验证码是否过期 + * 3. 验证验证码尝试次数 + * 4. 校验新密码强度 + * 5. 更新用户密码 + * 6. 删除已使用的验证码 + * + * @param confirmDto 密码重置确认(邮箱、验证码、新密码) + * @return 成功响应 + */ @PostMapping("password-reset/confirm") - @Operation(summary = "验证码重置密码") + @Operation(summary = "验证码重置密码", description = "使用邮箱验证码重置用户密码") public RestBean confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) { return passwordResetService.resetPassword(confirmDto); } + /** + * 分页获取用户列表 + * 步骤: + * 1. 根据查询条件构建动态查询 + * 2. 支持按用户名/邮箱模糊搜索 + * 3. 支持按状态筛选 + * 4. 返回分页结果 + * + * 权限:需要 user:list 权限 + * + * @param queryDto 查询条件(关键词、状态等) + * @return 分页用户列表 + */ @GetMapping("list") - @PreAuthorize("hasRole('ADMIN')") - @Operation(summary = "分页获取用户列表") + @PreAuthorize("hasAuthority('user:list')") + @Operation(summary = "分页获取用户列表", description = "查询用户列表,支持分页和条件筛选") @SecurityRequirement(name = "Bearer Authentication") public RestBean> getUserList(UserQueryDto queryDto) { return userService.getUserList(queryDto); } + /** + * 更新用户状态 + * 步骤: + * 1. 检查用户是否存在 + * 2. 禁止修改当前登录用户的状态 + * 3. 更新用户状态(启用/禁用) + * + * 权限:需要 user:update 权限 + * + * 注意: + * - 不能禁用当前登录的用户 + * - 禁用后用户无法登录 + * + * @param userId 用户ID + * @param updateDto 状态更新请求 + * @return 更新后的用户信息 + */ @PutMapping("{userId}/status") - @PreAuthorize("hasRole('ADMIN')") - @Operation(summary = "更新用户状态") + @PreAuthorize("hasAuthority('user:update')") + @Operation(summary = "更新用户状态", description = "启用或禁用用户账号") @SecurityRequirement(name = "Bearer Authentication") public RestBean updateUserStatus(@PathVariable Long userId, @Valid @RequestBody UserStatusUpdateDto updateDto) { return userService.updateUserStatus(userId, updateDto); } + /** + * 更新用户角色 + * 步骤: + * 1. 检查用户是否存在 + * 2. 禁止修改当前登录用户的角色 + * 3. 验证角色ID是否存在 + * 4. 更新用户角色关系 + * + * 权限:需要 user:update 权限 + * + * 注意: + * - 不能修改当前登录用户的角色 + * - 角色变更后需要重新登录才能生效 + * + * @param userId 用户ID + * @param updateDto 角色更新请求(角色ID列表) + * @return 更新后的用户信息 + */ @PutMapping("{userId}/role") - @PreAuthorize("hasRole('ADMIN')") - @Operation(summary = "更新用户角色") + @PreAuthorize("hasAuthority('user:update')") + @Operation(summary = "更新用户角色", description = "为用户分配或移除角色") @SecurityRequirement(name = "Bearer Authentication") public RestBean updateUserRole(@PathVariable Long userId, @Valid @RequestBody UserRoleUpdateDto 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; + } + } } diff --git a/src/main/java/com/aisi/template/domain/CustomUserDetails.java b/src/main/java/com/aisi/template/domain/CustomUserDetails.java index dfc3027..97cb29c 100644 --- a/src/main/java/com/aisi/template/domain/CustomUserDetails.java +++ b/src/main/java/com/aisi/template/domain/CustomUserDetails.java @@ -5,57 +5,159 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; 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 { + /** + * 用户ID + */ @Getter private final Long id; - private final String username; - private final String password; - private final Collection authorities; - private final boolean enabled; - @Getter - private final String role; - public CustomUserDetails(Long id, String username, String password, Collection authorities, boolean enabled, String role) { + /** + * 用户名 + */ + private final String username; + + /** + * 密码(加密后的) + */ + private final String password; + + /** + * 用户权限列表 + * - 包含角色和权限 + * - 格式:ROLE_XXX(角色)或 XXX:YYY(权限) + */ + private final Collection authorities; + + /** + * 用户是否启用 + * - true:启用 + * - false:禁用 + */ + private final boolean enabled; + + /** + * 用户角色列表 + * - 包含角色编码 + * - 例如:ROLE_USER, ROLE_ADMIN + */ + @Getter + private final Set roles; + + /** + * 构造函数 + * + * @param id 用户ID + * @param username 用户名 + * @param password 密码 + * @param authorities 权限列表 + * @param enabled 是否启用 + * @param roles 角色列表 + */ + public CustomUserDetails(Long id, String username, String password, + Collection authorities, + boolean enabled, Set roles) { this.id = id; this.username = username; this.password = password; this.authorities = authorities; this.enabled = enabled; - this.role = role; + this.roles = roles; } + /** + * 获取用户名 + * + * @return 用户名 + */ @Override public String getUsername() { return username; } + /** + * 获取密码 + * + * @return 密码 + */ @Override public String getPassword() { return password; } + /** + * 获取用户权限列表 + * + * @return 权限列表 + */ @Override public Collection getAuthorities() { return authorities; } + /** + * 账户是否未过期 + * 说明: + * - 当前实现返回 true(账户永不过期) + * - 可根据业务需求扩展 + * + * @return true + */ @Override public boolean isAccountNonExpired() { return true; } + /** + * 账户是否未锁定 + * 说明: + * - 当前实现返回 true(不在此处判断锁定) + * - 锁定状态由 User.isLocked() 判断 + * + * @return true + */ @Override public boolean isAccountNonLocked() { return true; } + /** + * 密码是否未过期 + * 说明: + * - 当前实现返回 true(密码永不过期) + * - 可根据业务需求扩展(如密码过期策略) + * + * @return true + */ @Override public boolean isCredentialsNonExpired() { return true; } + /** + * 账户是否启用 + * + * @return 是否启用 + */ @Override public boolean isEnabled() { return enabled; diff --git a/src/main/java/com/aisi/template/domain/RestBean.java b/src/main/java/com/aisi/template/domain/RestBean.java index 8e8f948..b4da125 100644 --- a/src/main/java/com/aisi/template/domain/RestBean.java +++ b/src/main/java/com/aisi/template/domain/RestBean.java @@ -6,23 +6,62 @@ import lombok.NoArgsConstructor; /** * 通用的 REST API 响应封装类 + * 统一 API 响应格式 + * + * 响应格式: + *
+ * {
+ *   "code": 200,
+ *   "message": "操作成功",
+ *   "data": {...}
+ * }
+ * 
+ * + * 主要功能: + * 1. 统一响应格式:所有接口返回相同的结构 + * 2. 成功响应:提供多种成功响应构造方法 + * 3. 失败响应:提供多种失败响应构造方法 * * @param 返回数据的类型 + * @author Claude + * @since 2024-04-09 */ @Data @AllArgsConstructor @NoArgsConstructor public class RestBean { - /** 状态码 */ + /** + * 状态码 + * - 200:成功 + * - 400:客户端错误 + * - 401:未登录 + * - 403:无权限 + * - 404:资源不存在 + * - 500:服务器错误 + */ private int code; - /** 提示消息 */ + + /** + * 提示消息 + * - 成功时返回成功信息 + * - 失败时返回错误信息 + */ private String message; - /** 具体数据 */ + + /** + * 具体数据 + * - 成功时返回业务数据 + * - 失败时可能返回 null 或错误详情 + */ private V data; /** - * 成功响应(默认使用 {@link RestCode#SUCCESS}) + * 成功响应(默认使用 RestCode#SUCCESS) + * 说明: + * - 状态码:200 + * - 消息:"操作成功" + * - 返回业务数据 * * @param data 返回的数据 * @param 泛型参数 @@ -32,6 +71,19 @@ public class RestBean { return success(RestCode.SUCCESS, data); } + /** + * 成功响应(无数据返回) + * 使用场景: + * - 删除操作 + * - 更新操作 + * + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean success() { + return success(RestCode.SUCCESS, null); + } + /** * 成功响应(指定 RestCode 和数据) * @@ -46,6 +98,9 @@ public class RestBean { /** * 成功响应(只返回状态码和消息,不带数据) + * 使用场景: + * - 删除操作(不需要返回数据) + * - 更新操作(不需要返回数据) * * @param restCode 状态码枚举 * @param 泛型参数 @@ -69,7 +124,10 @@ public class RestBean { } /** - * 失败响应(默认使用 {@link RestCode#FAILURE}) + * 失败响应(默认使用 RestCode#FAILURE) + * 说明: + * - 状态码:400 + * - 消息:"操作失败" * * @param data 返回的数据 * @param 泛型参数 @@ -115,4 +173,16 @@ public class RestBean { return new RestBean<>(code, message, data); } + /** + * 失败响应(自定义 code 和 message,无数据) + * + * @param code 状态码 + * @param message 提示消息 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean failure(int code, String message) { + return new RestBean<>(code, message, null); + } + } diff --git a/src/main/java/com/aisi/template/domain/RestCode.java b/src/main/java/com/aisi/template/domain/RestCode.java index dd6e663..10b2a9a 100644 --- a/src/main/java/com/aisi/template/domain/RestCode.java +++ b/src/main/java/com/aisi/template/domain/RestCode.java @@ -7,26 +7,214 @@ import lombok.ToString; import java.util.HashMap; 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 @ToString 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 String message; - private final Map json; // 预先创建的不可变 Map + + /** + * JSON 格式的数据(用于序列化) + */ + private final Map json; + + /** + * 构造函数 + * + * @param code 状态码 + * @param message 消息 + */ RestCode(Integer code, String message) { this.code = code; this.message = message; @@ -36,9 +224,16 @@ public enum RestCode { this.json = map; } + /** + * 序列化为 JSON + * 说明: + * - 当使用 @JsonValue 注解时, + * - Jackson 会调用此方法获取序列化的值 + * + * @return JSON 格式的数据 + */ @JsonValue - public Map toJson(){ + public Map toJson() { return json; } - } diff --git a/src/main/java/com/aisi/template/domain/dto/MenuDto.java b/src/main/java/com/aisi/template/domain/dto/MenuDto.java new file mode 100644 index 0000000..bd35288 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/MenuDto.java @@ -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; +} diff --git a/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java b/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java index d8354bb..38fbab6 100644 --- a/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java +++ b/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java @@ -1,28 +1,66 @@ package com.aisi.template.domain.dto; +import com.aisi.template.annotation.StrongPassword; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; import lombok.Data; +/** + * 密码重置确认数据传输对象 + * 用于使用验证码重置密码 + * + * 验证规则: + * - 邮箱:不能为空,必须符合邮箱格式 + * - 验证码:必须是 6 位数字 + * - 新密码:不能为空,需满足强密码要求 + * + * 使用场景: + * - 用户收到验证码后,输入验证码和新密码重置密码 + * + * 注意: + * - 验证码 10 分钟有效 + * - 验证码最多尝试 5 次,超过后失效 + * - 验证码使用后立即失效 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "重置密码确认请求") public class PasswordResetConfirmDto { + /** + * 邮箱地址 + * - 不能为空 + * - 必须符合邮箱格式 + * - 示例:user@example.com + */ @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") - @Schema(description = "邮箱") + @Schema(description = "邮箱", example = "user@example.com") private String email; + /** + * 验证码 + * - 不能为空 + * - 必须是 6 位数字 + * - 示例:123456 + */ @NotBlank(message = "验证码不能为空") @Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字") - @Schema(description = "6位验证码") + @Schema(description = "6位验证码", example = "123456") private String code; + /** + * 新密码 + * - 不能为空 + * - 需满足强密码要求:包含大小写字母、数字、特殊字符 + * - 示例:NewPassword123! + */ @NotBlank(message = "新密码不能为空") - @Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") - @Schema(description = "新密码") + @StrongPassword + @Schema(description = "新密码", example = "NewPassword123!") private String newPassword; } diff --git a/src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java b/src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java index 393d398..1bb48a9 100644 --- a/src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java +++ b/src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java @@ -5,12 +5,36 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.Data; +/** + * 密码重置请求数据传输对象 + * 用于请求发送密码重置验证码 + * + * 验证规则: + * - 邮箱:不能为空,必须符合邮箱格式 + * + * 使用场景: + * - 用户忘记密码时,输入邮箱请求重置验证码 + * + * 注意: + * - 为防止用户名枚举,无论邮箱是否存在都返回相同消息 + * - 同一邮箱 60 秒内只能请求一次 + * - 同一邮箱 1 小时内最多请求 5 次 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "找回密码请求") public class PasswordResetRequestDto { + /** + * 邮箱地址 + * - 不能为空 + * - 必须符合邮箱格式 + * - 示例:user@example.com + */ @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") - @Schema(description = "邮箱") + @Schema(description = "邮箱", example = "user@example.com") private String email; } diff --git a/src/main/java/com/aisi/template/domain/dto/RefreshTokenDto.java b/src/main/java/com/aisi/template/domain/dto/RefreshTokenDto.java new file mode 100644 index 0000000..46ed989 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/RefreshTokenDto.java @@ -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; +} diff --git a/src/main/java/com/aisi/template/domain/dto/RoleDto.java b/src/main/java/com/aisi/template/domain/dto/RoleDto.java new file mode 100644 index 0000000..122416f --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/RoleDto.java @@ -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; +} diff --git a/src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java b/src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java new file mode 100644 index 0000000..d6e0d2c --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/RoleQueryDto.java @@ -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; +} diff --git a/src/main/java/com/aisi/template/domain/dto/UserDto.java b/src/main/java/com/aisi/template/domain/dto/UserDto.java index bbf1f67..428747c 100644 --- a/src/main/java/com/aisi/template/domain/dto/UserDto.java +++ b/src/main/java/com/aisi/template/domain/dto/UserDto.java @@ -1,24 +1,59 @@ package com.aisi.template.domain.dto; +import com.aisi.template.annotation.StrongPassword; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data; +/** + * 用户数据传输对象 + * 用于用户注册和登录请求 + * + * 验证规则: + * - 用户名:2-50 位字符,不能为空 + * - 密码:不能为空,需满足强密码要求(@StrongPassword) + * - 邮箱:可选,注册时建议提供 + * + * 使用场景: + * - 用户注册:需要用户名、密码、邮箱 + * - 用户登录:需要用户名、密码 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "用户注册/登录请求") public class UserDto { + /** + * 用户名 + * - 长度:2-50 位字符 + * - 不能为空 + * - 示例:admin, user01 + */ @NotBlank(message = "用户名不能为空") @Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间") @Schema(description = "用户名", example = "admin") private String username; + /** + * 密码 + * - 不能为空 + * - 需满足强密码要求:包含大小写字母、数字、特殊字符 + * - 示例:Password123! + */ @NotBlank(message = "密码不能为空") - @Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") - @Schema(description = "密码", example = "123456") + @StrongPassword + @Schema(description = "密码", example = "Password123!") private String password; + /** + * 邮箱地址 + * - 可选字段 + * - 注册时建议提供,用于找回密码 + * - 示例:user@example.com + */ @Schema(description = "邮箱(注册时可选)", example = "user@example.com") private String email; } diff --git a/src/main/java/com/aisi/template/domain/dto/UserQueryDto.java b/src/main/java/com/aisi/template/domain/dto/UserQueryDto.java index d84d240..369b978 100644 --- a/src/main/java/com/aisi/template/domain/dto/UserQueryDto.java +++ b/src/main/java/com/aisi/template/domain/dto/UserQueryDto.java @@ -3,22 +3,52 @@ package com.aisi.template.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +/** + * 用户查询参数数据传输对象 + * 用于分页查询用户列表 + * + * 查询条件: + * - 分页参数:页码、每页大小 + * - 关键词:匹配用户名或邮箱(模糊搜索) + * - 状态:精确匹配(1=正常,0=禁用) + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "用户查询参数") public class UserQueryDto { + /** + * 页码 + * - 默认值:1 + * - 从 1 开始计数 + */ @Schema(description = "页码", example = "1") private Integer page = 1; + /** + * 每页大小 + * - 默认值:10 + * - 最小值:1 + */ @Schema(description = "每页大小", example = "10") private Integer size = 10; - @Schema(description = "关键词(用户名/邮箱)") + /** + * 关键词 + * - 模糊匹配用户名或邮箱 + * - 示例:admin 会匹配 username LIKE '%admin%' OR email LIKE '%admin%' + */ + @Schema(description = "关键词(用户名/邮箱)", example = "admin") private String keyword; - @Schema(description = "角色(USER/ADMIN)") - private String role; - - @Schema(description = "状态(1=正常 0=禁用)") + /** + * 用户状态 + * - 1:正常(启用) + * - 0:禁用 + * - 精确匹配 + */ + @Schema(description = "状态(1=正常 0=禁用)", example = "1") private Integer status; } diff --git a/src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.java b/src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.java index e1d5109..2033d22 100644 --- a/src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.java +++ b/src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.java @@ -1,14 +1,40 @@ package com.aisi.template.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; +import java.util.Set; + +/** + * 用户角色更新数据传输对象 + * 用于为用户分配或修改角色 + * + * 验证规则: + * - 角色ID列表:不能为空 + * + * 使用场景: + * - 管理员为用户分配角色 + * - 管理员修改用户的角色 + * + * 注意: + * - 不能修改当前登录用户的角色 + * - 角色变更后用户需要重新登录才能生效 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "用户角色更新请求") public class UserRoleUpdateDto { - @NotBlank(message = "角色不能为空") - @Schema(description = "角色(USER/ADMIN)", example = "ADMIN") - private String role; + /** + * 角色ID列表 + * - 不能为空 + * - 会完全替换用户的角色列表 + * - 示例:[1, 2] 表示分配角色ID为1和2的角色 + */ + @NotEmpty(message = "角色不能为空") + @Schema(description = "角色ID列表", example = "[1, 2]") + private Set roleIds; } diff --git a/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java b/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java index c20c837..bd77bb3 100644 --- a/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java +++ b/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java @@ -6,10 +6,34 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data; +/** + * 用户状态更新数据传输对象 + * 用于更新用户的启用/禁用状态 + * + * 验证规则: + * - 状态:不能为空,只能是 0 或 1 + * + * 使用场景: + * - 管理员启用或禁用用户账户 + * + * 注意: + * - 不能禁用当前登录的用户 + * - 状态变更后用户需要重新登录 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "用户状态更新请求") public class UserStatusUpdateDto { + /** + * 用户状态 + * - 不能为空 + * - 只能是 0 或 1 + * - 1:正常(启用) + * - 0:禁用 + */ @NotNull(message = "状态不能为空") @Min(value = 0, message = "状态只能为 0 或 1") @Max(value = 1, message = "状态只能为 0 或 1") diff --git a/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java b/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java index 13a50f0..d350d1e 100644 --- a/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java +++ b/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java @@ -6,6 +6,26 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 密码重置验证码实体类 + * 存储密码重置验证码信息 + * + * 主要字段: + * 1. 邮箱:用户邮箱 + * 2. 验证码:验证码的哈希值(不存储明文) + * 3. 有效期:过期时间 + * 4. 使用状态:是否已使用 + * 5. 尝试次数:验证失败次数 + * + * 安全机制: + * - 验证码使用 SHA-256 哈希存储 + * - 验证码默认 10 分钟有效 + * - 最多尝试 5 次,超过后失效 + * - 同一邮箱 60 秒内只能请求一次 + * + * @author Claude + * @since 2024-04-09 + */ @Entity @Table(name = "password_reset_codes", indexes = { @Index(name = "idx_password_reset_email", columnList = "email"), @@ -14,30 +34,61 @@ import java.time.LocalDateTime; @Data public class PasswordResetCode { + /** + * 验证码ID(主键) + */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * 邮箱地址(不可为空) + */ @Column(nullable = false, length = 255) private String email; + /** + * 验证码哈希值(不可为空) + * - 存储 SHA-256 哈希值,不存储明文验证码 + * - 防止数据库泄露导致验证码泄露 + */ @Column(name = "code_hash", nullable = false, length = 64) private String codeHash; + /** + * 过期时间(不可为空) + * 默认:当前时间 + 10 分钟 + */ @Column(name = "expires_at", nullable = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime expiresAt; + /** + * 是否已使用(不可为空) + * - false:未使用(默认) + * - true:已使用 + */ @Column(name = "used", nullable = false) private Boolean used = false; + /** + * 尝试次数(不可为空) + * - 验证失败时递增 + * - 超过 5 次后验证码失效 + */ @Column(name = "attempt_count", nullable = false) private Integer attemptCount = 0; + /** + * 创建时间(不可为空,不可更新) + */ @Column(name = "created_at", nullable = false, updatable = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime createdAt; + /** + * 创建前自动设置时间 + */ @PrePersist protected void onCreate() { if (createdAt == null) { diff --git a/src/main/java/com/aisi/template/domain/entity/RefreshToken.java b/src/main/java/com/aisi/template/domain/entity/RefreshToken.java new file mode 100644 index 0000000..4ceed8c --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/RefreshToken.java @@ -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(); + } +} diff --git a/src/main/java/com/aisi/template/domain/entity/SysAuditLog.java b/src/main/java/com/aisi/template/domain/entity/SysAuditLog.java new file mode 100644 index 0000000..0df232a --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/SysAuditLog.java @@ -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(); + } +} diff --git a/src/main/java/com/aisi/template/domain/entity/SysMenu.java b/src/main/java/com/aisi/template/domain/entity/SysMenu.java new file mode 100644 index 0000000..341bb24 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/SysMenu.java @@ -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 roles = new HashSet<>(); + + /** + * 子菜单列表(非持久化字段) + * - @Transient:不映射到数据库 + * - 用于构建菜单树形结构 + */ + @Transient + @ToString.Exclude + private List children = new ArrayList<>(); + + /** + * 创建前自动设置时间 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 更新前自动设置时间 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/aisi/template/domain/entity/SysPermission.java b/src/main/java/com/aisi/template/domain/entity/SysPermission.java new file mode 100644 index 0000000..b7b29d7 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/SysPermission.java @@ -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 roles = new HashSet<>(); + + /** + * 创建前自动设置时间 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 更新前自动设置时间 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/aisi/template/domain/entity/SysRole.java b/src/main/java/com/aisi/template/domain/entity/SysRole.java new file mode 100644 index 0000000..b1dea30 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/SysRole.java @@ -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 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 menus = new HashSet<>(); + + /** + * 创建前自动设置时间 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + /** + * 更新前自动设置时间 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/aisi/template/domain/entity/User.java b/src/main/java/com/aisi/template/domain/entity/User.java index fc0af4d..28de263 100644 --- a/src/main/java/com/aisi/template/domain/entity/User.java +++ b/src/main/java/com/aisi/template/domain/entity/User.java @@ -1,50 +1,129 @@ package com.aisi.template.domain.entity; -import com.aisi.template.domain.enums.Role; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.persistence.*; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.ToString; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; 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 @Table(name = "users") @Data +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @EntityListeners(AuditingEntityListener.class) public class User { + /** + * 用户ID(主键) + */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include private Long id; + /** + * 用户名(唯一,不可为空) + */ @Column(nullable = false, unique = true, length = 50) private String username; + /** + * 密码(BCrypt 加密,不可为空) + * 使用 @JsonIgnore 防止序列化时泄露密码 + * 使用 @ToString.Exclude 防止 toString 时泄露密码 + */ @ToString.Exclude @JsonIgnore @Column(nullable = false) private String password; + /** + * 邮箱地址(唯一) + */ @Column(unique = true) private String email; + /** + * 用户状态 + * - 1:启用(默认) + * - 0:禁用(永久锁定) + */ @Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'") 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 roles = new HashSet<>(); + + /** + * 创建时间(自动填充) + */ @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime createdAt; + /** + * 更新时间(自动填充) + */ @LastModifiedDate @Column(name = "updated_at", nullable = false) @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") @@ -52,8 +131,38 @@ public class User { /** * 检查用户是否启用 + * + * @return true 表示账户启用 */ public boolean isEnabled() { 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; + } } diff --git a/src/main/java/com/aisi/template/domain/enums/ErrorCode.java b/src/main/java/com/aisi/template/domain/enums/ErrorCode.java deleted file mode 100644 index ae1e723..0000000 --- a/src/main/java/com/aisi/template/domain/enums/ErrorCode.java +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/main/java/com/aisi/template/domain/enums/Role.java b/src/main/java/com/aisi/template/domain/enums/Role.java deleted file mode 100644 index 0d5c0b9..0000000 --- a/src/main/java/com/aisi/template/domain/enums/Role.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/com/aisi/template/domain/vo/LoginResponseVo.java b/src/main/java/com/aisi/template/domain/vo/LoginResponseVo.java new file mode 100644 index 0000000..965c547 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/vo/LoginResponseVo.java @@ -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; + } +} diff --git a/src/main/java/com/aisi/template/domain/vo/MenuVo.java b/src/main/java/com/aisi/template/domain/vo/MenuVo.java new file mode 100644 index 0000000..2afda37 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/vo/MenuVo.java @@ -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 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 getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } +} diff --git a/src/main/java/com/aisi/template/domain/vo/PermissionVo.java b/src/main/java/com/aisi/template/domain/vo/PermissionVo.java new file mode 100644 index 0000000..7d9b897 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/vo/PermissionVo.java @@ -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; + } +} diff --git a/src/main/java/com/aisi/template/domain/vo/RoleVo.java b/src/main/java/com/aisi/template/domain/vo/RoleVo.java new file mode 100644 index 0000000..b4c32bf --- /dev/null +++ b/src/main/java/com/aisi/template/domain/vo/RoleVo.java @@ -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 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 getPermissions() { + return permissions; + } + + public void setPermissions(Set permissions) { + this.permissions = permissions; + } +} diff --git a/src/main/java/com/aisi/template/domain/vo/UserVo.java b/src/main/java/com/aisi/template/domain/vo/UserVo.java index e9d5828..515bd7e 100644 --- a/src/main/java/com/aisi/template/domain/vo/UserVo.java +++ b/src/main/java/com/aisi/template/domain/vo/UserVo.java @@ -5,30 +5,73 @@ import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import java.time.LocalDateTime; +import java.util.Set; +/** + * 用户视图对象 + * 用于返回用户信息给前端 + * + * 主要字段: + * 1. 基本信息:用户ID、用户名、邮箱 + * 2. 状态信息:启用/禁用状态 + * 3. 角色信息:角色编码列表 + * 4. 时间信息:创建时间、更新时间 + * + * 注意: + * - 不包含密码等敏感信息 + * - 角色只返回角色编码,不返回完整角色对象 + * + * @author Claude + * @since 2024-04-09 + */ @Data @Schema(description = "用户视图对象") public class UserVo { + /** + * 用户ID + */ @Schema(description = "用户ID") private Long id; + /** + * 用户名 + */ @Schema(description = "用户名") private String username; + /** + * 邮箱地址 + */ @Schema(description = "邮箱") private String email; + /** + * 用户状态 + * - 1:正常(启用) + * - 0:禁用 + */ @Schema(description = "状态(1=正常 0=禁用)") private Integer status; - @Schema(description = "角色(USER=普通用户,ADMIN=管理员)") - private String role; + /** + * 角色编码列表 + * - 示例:["ROLE_USER", "ROLE_ADMIN"] + * - 只返回角色编码,不返回完整角色对象 + */ + @Schema(description = "角色列表") + private Set roles; + /** + * 创建时间 + */ @Schema(description = "创建时间") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime createdAt; + /** + * 更新时间 + */ @Schema(description = "更新时间") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private LocalDateTime updatedAt; diff --git a/src/main/java/com/aisi/template/examples/RedisUsageExample.java b/src/main/java/com/aisi/template/examples/RedisUsageExample.java new file mode 100644 index 0000000..f91d0c3 --- /dev/null +++ b/src/main/java/com/aisi/template/examples/RedisUsageExample.java @@ -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 getTopUsers(int topN) { + // 1. 定义排行榜键 + String rankKey = "leaderboard:user:score"; + + // 2. 获取倒序排名前 N 的用户 + Set 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 getCommonFriends(Long userId1, Long userId2) { + // 1. 定义好友集合键 + String friendsKey1 = "user:friends:" + userId1; + String friendsKey2 = "user:friends:" + userId2; + + // 2. 计算交集 + Set 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 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 getUserSession(Long userId) { + String sessionKey = "session:user:" + userId; + return redisCache.hGetAll(sessionKey); + } + + // ==================== 场景 9: 热点数据缓存 ==================== + + /** + * 场景:缓存热点数据(如首页推荐) + * 步骤: + * 1. 从缓存获取 + * 2. 如果不存在,加载数据 + * 3. 设置较短的过期时间 + * + * @return 推荐内容列表 + */ + public List getHotContent() { + String cacheKey = "hot:content:home"; + + // 1. 从缓存获取 + Object cached = redisCache.get(cacheKey); + if (cached != null) { + return (List) cached; + } + + // 2. 加载热点数据(模拟) + List 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 loadHotContentFromDatabase() { + // 模拟加载热点数据 + return List.of("content1", "content2", "content3"); + } +} diff --git a/src/main/java/com/aisi/template/examples/TransactionExample.java b/src/main/java/com/aisi/template/examples/TransactionExample.java new file mode 100644 index 0000000..9ccb12d --- /dev/null +++ b/src/main/java/com/aisi/template/examples/TransactionExample.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/aisi/template/exception/BusinessException.java b/src/main/java/com/aisi/template/exception/BusinessException.java index 46efefb..63e1c7d 100644 --- a/src/main/java/com/aisi/template/exception/BusinessException.java +++ b/src/main/java/com/aisi/template/exception/BusinessException.java @@ -1,21 +1,51 @@ package com.aisi.template.exception; -import com.aisi.template.domain.enums.ErrorCode; +import com.aisi.template.domain.RestCode; import lombok.Getter; /** * 自定义业务异常 * 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息 + * + * 使用场景: + * - 业务逻辑校验失败 + * - 资源不存在 + * - 权限不足 + * - 参数错误 + * + * 使用示例: + *
+ * // 指定错误码和消息
+ * throw new BusinessException(404, "用户不存在");
+ *
+ * // 使用通用错误码(默认 400)
+ * throw new BusinessException("操作失败");
+ *
+ * // 使用错误码枚举
+ * throw new BusinessException(RestCode.USER_NOT_FOUND);
+ * 
+ * + * @author Claude + * @since 2024-04-09 */ @Getter // 使用 Lombok 自动生成 getCode() 方法 public class BusinessException extends RuntimeException { - - // 错误码 (例如 400, 403, 1001 等) + + /** + * 错误码 + * - 例如:400, 403, 1001 等 + */ private final int code; /** * 构造方法 1:手动指定 code 和 message - * 使用:throw new BusinessException(404, "找不到该新闻"); + * 使用示例: + *
+     * throw new BusinessException(404, "找不到该新闻");
+     * 
+ * + * @param code 错误码 + * @param message 错误消息 */ public BusinessException(int code, String message) { super(message); // 把 message 传给父类,方便 log 打印 @@ -23,17 +53,30 @@ public class BusinessException extends RuntimeException { } /** - * 构造方法 2:使用通用错误码 (默认为 400 或 500) - * 使用:throw new BusinessException("操作失败"); + * 构造方法 2:使用通用错误码 + * 使用示例: + *
+     * throw new BusinessException("操作失败");
+     * 
+ * + * @param message 错误消息 */ public BusinessException(String message) { super(message); - this.code = 400; // 默认给个 400 + this.code = 400; // 默认给个 400(客户端错误) } - // 在 BusinessException 类里添加这个构造方法 - public BusinessException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.code = errorCode.getCode(); + /** + * 构造方法 3:使用错误码枚举 + * 使用示例: + *
+     * throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+     * 
+ * + * @param restCode 错误码枚举 + */ + public BusinessException(RestCode restCode) { + super(restCode.getMessage()); + this.code = restCode.getCode(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisi/template/exception/RateLimitExceededException.java b/src/main/java/com/aisi/template/exception/RateLimitExceededException.java new file mode 100644 index 0000000..ee7d444 --- /dev/null +++ b/src/main/java/com/aisi/template/exception/RateLimitExceededException.java @@ -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("请求过于频繁,请稍后再试"); + } +} diff --git a/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java b/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java index 72d10dc..22cf1fc 100644 --- a/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java @@ -1,7 +1,7 @@ package com.aisi.template.filter; - import com.aisi.template.domain.CustomUserDetails; +import com.aisi.template.service.TokenService; import com.aisi.template.service.impl.CustomUserDetailsService; import com.aisi.template.utils.JwtUtil; import jakarta.servlet.FilterChain; @@ -18,50 +18,133 @@ import org.springframework.web.filter.OncePerRequestFilter; 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 @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { + /** + * JWT 工具类 + * 用于生成和验证 JWT Token + */ private final JwtUtil jwtUtil; + /** + * 用户详情服务 + * 用于加载用户信息 + */ 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 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = extractToken(request); - if (token != null && jwtUtil.validateToken(token)) { - // token 有效 + // 1. 从请求头中提取 Token + String token = extractToken(request); + + 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); if (username != null && !username.isEmpty()) { + // 4.1 加载用户详情 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( userDetails, null, userDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // 4.3 设置认证信息到 SecurityContext SecurityContextHolder.getContext().setAuthentication(authentication); - } - } else { - // token 缺失或无效时,不抛异常,直接放行到下一个过滤器 - } + } else { + // 5. Token 缺失或无效,不抛出异常,继续处理 + // 原因:有些接口不需要认证(如登录、注册) + } + // 6. 继续过滤器链 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) { - String authorization = request.getHeader("Authorization"); - if (authorization == null) { - return null; - } - if (!authorization.startsWith("Bearer ")) { - return null; - } - return authorization.substring(7); - } + private String extractToken(HttpServletRequest request) { + // 1. 获取 Authorization 请求头 + String authorization = request.getHeader("Authorization"); + if (authorization == null) { + return null; + } + + // 2. 检查是否以 "Bearer " 开头 + if (!authorization.startsWith("Bearer ")) { + return null; + } + + // 3. 提取 Token 部分(去掉 "Bearer " 前缀,共 7 个字符) + return authorization.substring(7); + } } diff --git a/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java b/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java index 17c3284..43c89a4 100644 --- a/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package com.aisi.template.handler; import com.aisi.template.domain.RestBean; import com.aisi.template.domain.RestCode; +import com.aisi.template.exception.RateLimitExceededException; import lombok.extern.slf4j.Slf4j; 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 @RestControllerAdvice public class GlobalExceptionHandler { - // ========================================== - // 1. 客户端请求错误 (400 Bad Request 等) - // 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈 - // ========================================== + + // ==================== 1. 客户端请求错误 (400 Bad Request 等) ==================== + // 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈 /** - * 1. 参数校验失败异常 (@Valid / @Validated) - * 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求 + * 处理参数校验失败异常 + * 场景: + * - 前端传的 JSON 缺字段 + * - 字段不符合 @NotNull, @Size 等注解要求 + * - 使用 @Valid 或 @Validated 触发 + * + * @param e 异常对象 + * @return 错误响应 */ @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) public RestBean handleValidationException(Exception e) { + // 1. 获取绑定结果 BindingResult bindingResult = null; if (e instanceof MethodArgumentNotValidException) { bindingResult = ((MethodArgumentNotValidException) e).getBindingResult(); } else if (e instanceof BindException) { bindingResult = ((BindException) e).getBindingResult(); } - - // 提取具体的错误信息(例如:"email: 邮箱格式不正确") + + // 2. 提取具体的错误信息(例如:"email: 邮箱格式不正确") String msg = "参数校验失败"; if (bindingResult != null) { msg = bindingResult.getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining(", ")); } - + log.warn("参数校验未通过: {}", msg); - return RestBean.failure(RestCode.FAILURE, msg); + return RestBean.failure(RestCode.FAILURE.getCode(), msg); } /** - * 2. JSON 格式解析错误 - * 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段 + * 处理 JSON 格式解析错误 + * 场景: + * - 前端传的 JSON 少了括号 + * - 把 String 传给了 Integer 类型的字段 + * + * @param e 异常对象 + * @return 错误响应 */ @ExceptionHandler(HttpMessageNotReadableException.class) public RestBean handleJsonParseException(HttpMessageNotReadableException e) { - log.warn("JSON解析失败: {}", e.getMessage()); - return RestBean.failure(RestCode.FAILURE, "请求Body格式错误,请检查JSON语法"); + log.warn("JSON 解析失败: {}", e.getMessage()); + return RestBean.failure(RestCode.FAILURE.getCode(), "请求 Body 格式错误,请检查 JSON 语法"); } /** - * 3. 缺少必要的 URL 参数 - * 场景:接口定义了 @RequestParam(required=true) 但前端没传 + * 处理缺少必要的 URL 参数异常 + * 场景: + * - 接口定义了 @RequestParam(required=true) 但前端没传 + * + * @param e 异常对象 + * @return 错误响应 */ @ExceptionHandler(MissingServletRequestParameterException.class) public RestBean handleMissingParam(MissingServletRequestParameterException e) { 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) public RestBean handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { 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) public RestBean handleTypeMismatch(MethodArgumentTypeMismatchException e) { String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName()); 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) public RestBean handleBusinessException(BusinessException e) { // 业务异常通常是预期内的,记录 WARN 即可 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) public RestBean handleDuplicateKeyException(DuplicateKeyException e) { 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 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) public RestBean handleNPE(NullPointerException e) { // 必须打印堆栈! - log.error("发生空指针异常: ", e); - return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员"); + log.error("发生空指针异常: ", e); + return RestBean.failure(RestCode.SYSTEM_ERROR.getCode(), "系统内部数据异常,请联系管理员"); } /** * 处理权限拒绝异常 - */ + * 场景: + * - 用户访问了无权限的接口 + * - @PreAuthorize 注解校验失败 + * + * @param e 异常对象 + * @return 错误响应 + */ @ExceptionHandler(AuthorizationDeniedException.class) public RestBean handleAuthorizationDenied(AuthorizationDeniedException e) { log.warn("权限拒绝: {}", e.getMessage()); @@ -146,7 +217,13 @@ public class GlobalExceptionHandler { } /** - * 处理其他异常 + * 处理其他未捕获的异常 + * 场景: + * - 系统中未预期的异常 + * - 兜底的异常处理 + * + * @param e 异常对象 + * @return 错误响应 */ @ExceptionHandler(Exception.class) public RestBean handleException(Exception e) { diff --git a/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java b/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java index 2d35ba1..33532f4 100644 --- a/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java +++ b/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java @@ -10,51 +10,117 @@ import org.springframework.web.servlet.HandlerInterceptor; import java.util.UUID; +/** + * 日志拦截器 + * 记录所有 API 请求的日志信息 + * + * 主要功能: + * 1. 请求前记录:记录请求开始信息 + * 2. 请求后记录:记录请求完成信息和耗时 + * 3. 用户追踪:记录当前用户信息 + * + * 日志内容: + * - 请求ID:用于追踪整个请求链路 + * - 请求方法:GET, POST, PUT, DELETE + * - 请求路径:API 路径 + * - 用户信息:用户ID、用户名 + * - 响应状态:HTTP 状态码 + * - 请求耗时:毫秒 + * + * @author Claude + * @since 2024-04-09 + */ @Component public class LoggingInterceptor implements HandlerInterceptor { + /** + * 日志记录器 + */ private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class); + /** + * 请求前处理 + * 步骤: + * 1. 记录请求开始时间 + * 2. 生成请求ID(8位UUID) + * 3. 获取当前用户信息 + * 4. 记录请求开始日志 + * + * @param request HTTP 请求 + * @param response HTTP 响应 + * @param handler 处理器 + * @return true 表示继续执行,false 表示中断 + */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 1. 记录请求开始时间 long startTime = System.currentTimeMillis(); request.setAttribute("startTime", startTime); + // 2. 生成请求ID(8位UUID,便于日志追踪) String requestId = UUID.randomUUID().toString().substring(0, 8); request.setAttribute("requestId", requestId); + // 3. 获取当前用户信息 Long userId = SecurityUtils.getUserId(); request.setAttribute("userId", userId); String username = SecurityUtils.getUsername(); request.setAttribute("username", username); + // 4. 记录 API 请求开始日志 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); } - return true; // ✅ 必须返回true才能继续执行请求 + return true; // 必须返回 true 才能继续执行请求 } + /** + * 请求完成后处理 + * 步骤: + * 1. 计算请求耗时 + * 2. 记录请求完成日志 + * 3. 如果有异常,记录异常日志 + * + * @param request HTTP 请求 + * @param response HTTP 响应 + * @param handler 处理器 + * @param ex 异常(如果有) + */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 1. 获取请求开始时间 Long startTime = (Long) request.getAttribute("startTime"); if (startTime != null && isApiRequest(request.getRequestURI())) { + // 2. 计算请求耗时 long duration = System.currentTimeMillis() - startTime; + + // 3. 获取请求信息 String requestId = (String) request.getAttribute("requestId"); Long userId = (Long) request.getAttribute("userId"); 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); + // 5. 如果有异常,记录异常日志 if (ex != null) { logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex); } } } + /** + * 判断是否为 API 请求 + * 说明: + * - API 请求的路径以 /api/ 开头 + * + * @param path 请求路径 + * @return 是否为 API 请求 + */ private boolean isApiRequest(String path) { return path.startsWith("/api/"); } diff --git a/src/main/java/com/aisi/template/mq/consumer/UserMessageConsumer.java b/src/main/java/com/aisi/template/mq/consumer/UserMessageConsumer.java new file mode 100644 index 0000000..c3d9b27 --- /dev/null +++ b/src/main/java/com/aisi/template/mq/consumer/UserMessageConsumer.java @@ -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 { + + /** + * 消费消息的方法 + * 步骤: + * 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: 实现变更日志记录逻辑 + } +} diff --git a/src/main/java/com/aisi/template/mq/message/UserMessage.java b/src/main/java/com/aisi/template/mq/message/UserMessage.java new file mode 100644 index 0000000..8e3f6c9 --- /dev/null +++ b/src/main/java/com/aisi/template/mq/message/UserMessage.java @@ -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; +} diff --git a/src/main/java/com/aisi/template/mq/producer/UserMessageProducer.java b/src/main/java/com/aisi/template/mq/producer/UserMessageProducer.java new file mode 100644 index 0000000..a676903 --- /dev/null +++ b/src/main/java/com/aisi/template/mq/producer/UserMessageProducer.java @@ -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); + } +} diff --git a/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java b/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java index 2a1d681..5e89826 100644 --- a/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java +++ b/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java @@ -7,11 +7,66 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +/** + * 密码重置验证码数据访问接口 + * 定义密码重置验证码相关的数据库操作 + * + * 主要功能: + * 1. 基本操作:继承 CRUD 操作 + * 2. 验证码查询:按邮箱查找 + * 3. 验证码统计:统计请求次数 + * + * 安全机制: + * - 验证码哈希存储,不存储明文 + * - 支持有效期控制 + * - 支持尝试次数限制 + * + * @author Claude + * @since 2024-04-09 + */ public interface PasswordResetCodeRepository extends JpaRepository { + /** + * 查找邮箱的所有未使用验证码 + * 说明: + * - 返回指定邮箱所有未使用的验证码 + * - 按创建时间倒序排列 + * + * 使用场景: + * - 撤销旧验证码时使用 + * + * @param email 邮箱地址 + * @return 未使用的验证码列表 + */ List findByEmailAndUsedFalse(String email); + /** + * 查找邮箱的最新未使用验证码 + * 说明: + * - 返回指定邮箱最新的、未使用的验证码 + * - 按创建时间倒序排列,取第一条 + * + * 使用场景: + * - 验证密码重置验证码 + * - 检查验证码是否过期 + * + * @param email 邮箱地址 + * @return 最新的验证码(可能为空) + */ Optional findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email); + /** + * 统计指定时间后邮箱的验证码请求数 + * 说明: + * - 用于限制请求频率 + * - 防止验证码接口被滥用 + * + * 使用场景: + * - 检查 1 小时内请求次数(限制 5 次) + * + * @param email 邮箱地址 + * @param createdAt 请求时间(统计此时间之后的请求) + * @return 请求数量 + */ long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt); } diff --git a/src/main/java/com/aisi/template/repository/RefreshTokenRepository.java b/src/main/java/com/aisi/template/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..ecbcf52 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/RefreshTokenRepository.java @@ -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 { + + /** + * 根据 Token 哈希值查找 Refresh Token + * 说明: + * - 数据库只存储哈希值 + * - 客户端传入原始值,需要先哈希再查询 + * + * @param tokenHash Token 的 SHA-256 哈希值 + * @return Refresh Token 对象(可能为空) + */ + Optional 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 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); +} diff --git a/src/main/java/com/aisi/template/repository/SysAuditLogRepository.java b/src/main/java/com/aisi/template/repository/SysAuditLogRepository.java new file mode 100644 index 0000000..84cc099 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/SysAuditLogRepository.java @@ -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, JpaSpecificationExecutor { + + /** + * 根据用户ID查找审计日志 + * 说明: + * - 按创建时间倒序排序 + * - 用于查看用户的操作历史 + * + * @param userId 用户ID + * @return 审计日志列表 + */ + List findByUserIdOrderByCreatedAtDesc(Long userId); + + /** + * 根据操作类型查找审计日志 + * 说明: + * - 按创建时间倒序排序 + * - 用于查看特定类型的操作记录 + * + * @param action 操作类型(LOGIN, LOGOUT, CREATE, UPDATE, DELETE) + * @return 审计日志列表 + */ + List findByActionOrderByCreatedAtDesc(String action); + + /** + * 根据资源查找审计日志 + * 说明: + * - 按创建时间倒序排序 + * - 用于查看特定资源的变更历史 + * + * @param resource 资源名称(user, role, menu, permission) + * @return 审计日志列表 + */ + List findByResourceOrderByCreatedAtDesc(String resource); + + /** + * 根据时间范围查找审计日志 + * 说明: + * - 按创建时间倒序排序 + * - 用于时间范围内的统计分析 + * + * @param start 开始时间 + * @param end 结束时间 + * @return 审计日志列表 + */ + List findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end); + + /** + * 删除指定日期之前的审计日志 + * 说明: + * - 用于定期清理旧日志 + * - 建议通过定时任务定期执行 + * + * @param date 日期(删除早于此日期的日志) + */ + void deleteByCreatedAtBefore(LocalDateTime date); +} diff --git a/src/main/java/com/aisi/template/repository/SysMenuRepository.java b/src/main/java/com/aisi/template/repository/SysMenuRepository.java new file mode 100644 index 0000000..b06b2b1 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/SysMenuRepository.java @@ -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 { + + /** + * 根据父ID查找菜单 + * 说明: + * - 按排序字段排序 + * - 用于构建菜单树 + * + * @param parentId 父菜单ID(null 表示根菜单) + * @return 子菜单列表 + */ + List findByParentIdOrderBySortOrder(Long parentId); + + /** + * 根据状态查找菜单 + * 说明: + * - 只返回启用状态的菜单 + * - 按排序字段排序 + * + * @param status 状态(1=启用,0=禁用) + * @return 菜单列表 + */ + List findByStatusOrderBySortOrder(Integer status); + + /** + * 根据可见性、状态、类型查找菜单 + * 说明: + * - 同时满足可见、启用、指定类型 + * - 按排序字段排序 + * + * @param visible 可见性(1=可见,0=不可见) + * @param status 状态(1=启用,0=禁用) + * @param menuType 菜单类型(1=目录,2=菜单,3=按钮) + * @return 菜单列表 + */ + List 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 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 findByRoleIds(@Param("roleIds") List 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 findByRoleIdsAndMenuType(@Param("roleIds") List roleIds, @Param("menuType") Integer menuType); +} diff --git a/src/main/java/com/aisi/template/repository/SysPermissionRepository.java b/src/main/java/com/aisi/template/repository/SysPermissionRepository.java new file mode 100644 index 0000000..2d35c9a --- /dev/null +++ b/src/main/java/com/aisi/template/repository/SysPermissionRepository.java @@ -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 { + + /** + * 根据权限编码查找权限 + * 说明: + * - 权限编码是唯一的,如:user:read, role:write + * + * @param permissionCode 权限编码 + * @return 权限对象(可能为空) + */ + Optional findByPermissionCode(String permissionCode); + + /** + * 根据状态查找权限 + * 使用场景: + * - 获取所有启用的权限 + * + * @param status 状态(1=启用,0=禁用) + * @return 权限列表 + */ + List findByStatus(Integer status); + + /** + * 根据资源查找权限 + * 说明: + * - 返回指定资源的所有操作权限 + * - 例如:resource="user" 返回 user:read, user:write, user:delete + * + * @param resource 资源名称(如:user, role, menu) + * @return 权限列表 + */ + List findByResource(String resource); + + /** + * 根据操作类型查找权限 + * 说明: + * - 返回拥有指定操作的所有资源权限 + * - 例如:action="read" 返回 user:read, role:read, menu:read 等 + * + * @param action 操作类型(read, write, delete) + * @return 权限列表 + */ + List 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 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 findByRoleIds(@Param("roleIds") List roleIds); +} diff --git a/src/main/java/com/aisi/template/repository/SysRoleRepository.java b/src/main/java/com/aisi/template/repository/SysRoleRepository.java new file mode 100644 index 0000000..8785b42 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/SysRoleRepository.java @@ -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, JpaSpecificationExecutor { + + /** + * 根据角色编码查找角色 + * 说明: + * - 角色编码是唯一的,如:ROLE_USER, ROLE_ADMIN + * + * @param roleCode 角色编码 + * @return 角色对象(可能为空) + */ + Optional findByRoleCode(String roleCode); + + /** + * 根据状态查找角色 + * 使用场景: + * - 获取所有启用的角色 + * - 获取所有禁用的角色 + * + * @param status 状态(1=启用,0=禁用) + * @return 角色列表 + */ + List 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 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 findAllWithPermissions(); +} diff --git a/src/main/java/com/aisi/template/repository/UserRepository.java b/src/main/java/com/aisi/template/repository/UserRepository.java index 3bb5f91..fd7a25f 100644 --- a/src/main/java/com/aisi/template/repository/UserRepository.java +++ b/src/main/java/com/aisi/template/repository/UserRepository.java @@ -3,40 +3,168 @@ package com.aisi.template.repository; import com.aisi.template.domain.entity.User; 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.Optional; +/** + * 用户数据访问接口 + * 定义用户相关的数据库操作 + * + * 主要功能: + * 1. 基本操作:继承 CRUD 操作 + * 2. 用户查询:按用户名、邮箱查询 + * 3. 关联查询:使用 JOIN FETCH 解决懒加载问题 + * 4. 动态查询:支持 Specification 动态条件查询 + * + * 注意事项: + * - 使用 JOIN FETCH 避免懒加载时的 N+1 问题 + * - 邮箱查询支持不区分大小写 + * - 支持按状态筛选用户 + * + * @author Claude + * @since 2024-04-09 + */ public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { + /** + * 根据用户名查找用户 + * + * @param username 用户名 + * @return 用户对象(可能为空) + */ Optional 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 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 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 findByIdWithRoles(@Param("id") Long id); + + /** + * 根据邮箱查找用户 + * + * @param email 邮箱地址 + * @return 用户对象(可能为空) + */ Optional findByEmail(String email); + /** + * 根据邮箱查找用户(不区分大小写) + * 说明: + * - 数据库使用 LOWER() 函数进行比较 + * - 适用于用户输入邮箱大小写不确定的场景 + * + * @param email 邮箱地址 + * @return 用户对象(可能为空) + */ Optional findByEmailIgnoreCase(String email); + /** + * 根据用户名和密码查找用户 + * 说明: + * - 密码是已加密的哈希值 + * - 一般不使用此方法,应先查询用户再验证密码 + * + * @param username 用户名 + * @param password 密码哈希值 + * @return 用户对象(可能为空) + */ Optional findByUsernameAndPassword(String username, String password); + /** + * 根据邮箱和密码查找用户 + * + * @param email 邮箱地址 + * @param password 密码哈希值 + * @return 用户对象(可能为空) + */ Optional findByEmailAndPassword(String email, String password); /** - * 根据用户名查找启用的用户 + * 根据用户名和状态查找用户 + * 使用场景: + * - 只查找启用状态的用户 + * - 只查找禁用状态的用户 + * + * @param username 用户名 + * @param status 状态(1=启用,0=禁用) + * @return 用户对象(可能为空) */ Optional findByUsernameAndStatus(String username, Integer status); /** - * 根据邮箱查找启用的用户 + * 根据邮箱和状态查找用户 + * + * @param email 邮箱地址 + * @param status 状态(1=启用,0=禁用) + * @return 用户对象(可能为空) */ Optional findByEmailAndStatus(String email, Integer status); /** * 检查用户名是否存在 + * 使用场景: + * - 用户注册时校验用户名是否已被占用 + * + * @param username 用户名 + * @return true 表示用户名已存在 */ boolean existsByUsername(String username); /** * 检查邮箱是否存在 + * 说明: + * - 精确匹配,区分大小写 + * + * @param email 邮箱地址 + * @return true 表示邮箱已存在 */ boolean existsByEmail(String email); + /** + * 检查邮箱是否存在(不区分大小写) + * 说明: + * - 数据库使用 LOWER() 函数进行比较 + * - 适用于用户注册时校验邮箱 + * + * @param email 邮箱地址 + * @return true 表示邮箱已存在 + */ boolean existsByEmailIgnoreCase(String email); } diff --git a/src/main/java/com/aisi/template/service/AuditLogService.java b/src/main/java/com/aisi/template/service/AuditLogService.java new file mode 100644 index 0000000..c764730 --- /dev/null +++ b/src/main/java/com/aisi/template/service/AuditLogService.java @@ -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); +} diff --git a/src/main/java/com/aisi/template/service/EmailService.java b/src/main/java/com/aisi/template/service/EmailService.java index 50f0f8a..5491d4d 100644 --- a/src/main/java/com/aisi/template/service/EmailService.java +++ b/src/main/java/com/aisi/template/service/EmailService.java @@ -1,6 +1,33 @@ package com.aisi.template.service; +/** + * 邮件服务接口 + * 定义邮件发送的业务操作 + * + * 主要功能: + * 1. 发送密码重置验证码 + * 2. 发送注册欢迎邮件 + * 3. 发送通知邮件 + * + * 邮件类型: + * - 验证码邮件:包含 6 位数字验证码 + * - 欢迎邮件:欢迎用户注册 + * - 通知邮件:系统通知 + * + * @author Claude + * @since 2024-04-09 + */ public interface EmailService { + /** + * 发送密码重置验证码邮件 + * 步骤: + * 1. 构建邮件内容(包含验证码) + * 2. 设置邮件主题和收件人 + * 3. 发送邮件 + * + * @param email 收件人邮箱 + * @param code 6 位数字验证码 + */ void sendPasswordResetCode(String email, String code); } diff --git a/src/main/java/com/aisi/template/service/LoginAttemptService.java b/src/main/java/com/aisi/template/service/LoginAttemptService.java new file mode 100644 index 0000000..1049095 --- /dev/null +++ b/src/main/java/com/aisi/template/service/LoginAttemptService.java @@ -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); +} diff --git a/src/main/java/com/aisi/template/service/PasswordResetService.java b/src/main/java/com/aisi/template/service/PasswordResetService.java index 706ae6a..04781f8 100644 --- a/src/main/java/com/aisi/template/service/PasswordResetService.java +++ b/src/main/java/com/aisi/template/service/PasswordResetService.java @@ -4,9 +4,51 @@ import com.aisi.template.domain.RestBean; import com.aisi.template.domain.dto.PasswordResetConfirmDto; import com.aisi.template.domain.dto.PasswordResetRequestDto; +/** + * 密码重置服务接口 + * 定义密码重置的业务操作 + * + * 主要功能: + * 1. 发送重置验证码:生成验证码并发送到用户邮箱 + * 2. 验证码重置密码:验证验证码并更新密码 + * + * 安全机制: + * - 验证码 6 位数字,10 分钟有效 + * - 同一邮箱 60 秒内只能请求一次 + * - 验证码最多尝试 5 次 + * - 新密码必须符合强度要求 + * + * @author Claude + * @since 2024-04-09 + */ public interface PasswordResetService { + /** + * 发送密码重置验证码 + * 步骤: + * 1. 校验邮箱格式 + * 2. 检查请求冷却时间(防止频繁请求) + * 3. 生成 6 位随机数字验证码 + * 4. 将验证码哈希后存储到 Redis(10分钟有效) + * 5. 发送验证码到用户邮箱 + * + * @param requestDto 重置请求(邮箱) + * @return 成功响应 + */ RestBean sendResetCode(PasswordResetRequestDto requestDto); + /** + * 使用验证码重置密码 + * 步骤: + * 1. 校验邮箱和验证码是否匹配 + * 2. 检查验证码是否过期 + * 3. 校验验证码尝试次数(超过 5 次则验证码失效) + * 4. 校验新密码强度 + * 5. 更新用户密码 + * 6. 删除已使用的验证码 + * + * @param confirmDto 确认请求(邮箱、验证码、新密码) + * @return 成功响应 + */ RestBean resetPassword(PasswordResetConfirmDto confirmDto); } diff --git a/src/main/java/com/aisi/template/service/SysMenuService.java b/src/main/java/com/aisi/template/service/SysMenuService.java new file mode 100644 index 0000000..ee05ab4 --- /dev/null +++ b/src/main/java/com/aisi/template/service/SysMenuService.java @@ -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 getMenuTree(); + + /** + * 获取用户菜单 + * 步骤: + * 1. 查询用户的所有角色 + * 2. 查询角色关联的所有菜单 + * 3. 去重并构建树形结构 + * 4. 只返回有权限访问的菜单 + * + * 使用场景: + * - 前端导航菜单渲染 + * - 用户权限判断 + * + * @param userId 用户ID + * @return 用户可访问的菜单树 + */ + List getUserMenus(Long userId); + + /** + * 根据父ID获取子菜单 + * 步骤: + * 1. 查询指定父ID下的所有子菜单 + * 2. 按排序字段排序 + * + * @param parentId 父菜单ID(null 表示查询根菜单) + * @return 子菜单列表 + */ + List getByParentId(Long parentId); +} diff --git a/src/main/java/com/aisi/template/service/SysPermissionService.java b/src/main/java/com/aisi/template/service/SysPermissionService.java new file mode 100644 index 0000000..0b43393 --- /dev/null +++ b/src/main/java/com/aisi/template/service/SysPermissionService.java @@ -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 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 getByResource(String resource); + + /** + * 根据操作类型获取权限 + * 步骤: + * 1. 按操作类型查询权限 + * 2. 返回拥有该操作的所有资源权限 + * + * 示例: + * - action="read" 返回 user:read, role:read, menu:read 等 + * + * @param action 操作类型(read, write, delete) + * @return 拥有该操作的所有权限 + */ + List getByAction(String action); +} diff --git a/src/main/java/com/aisi/template/service/SysRoleService.java b/src/main/java/com/aisi/template/service/SysRoleService.java new file mode 100644 index 0000000..bd088b0 --- /dev/null +++ b/src/main/java/com/aisi/template/service/SysRoleService.java @@ -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 getAllRoles(); + + /** + * 分页查询角色 + * 步骤: + * 1. 构建分页参数 + * 2. 构建动态查询条件(关键词、状态) + * 3. 执行分页查询 + * 4. 转换结果为视图对象 + * + * @param queryDto 查询条件 + * @param page 页码(从1开始) + * @param size 每页大小 + * @return 分页角色列表 + */ + PageResult queryRoles(RoleQueryDto queryDto, int page, int size); + + /** + * 为角色分配权限 + * 步骤: + * 1. 检查角色是否存在 + * 2. 验证所有权限ID是否存在 + * 3. 清空角色原有权限 + * 4. 添加新权限 + * 5. 保存到数据库 + * + * 注意: + * - 此操作会完全替换角色的权限列表 + * - 权限变更后用户需要重新登录才能生效 + * + * @param roleId 角色ID + * @param permissionIds 权限ID列表 + */ + void assignPermissions(Long roleId, List permissionIds); + + /** + * 获取角色的权限ID列表 + * 步骤: + * 1. 查询角色 + * 2. 加载关联的权限 + * 3. 提取权限ID列表 + * + * @param roleId 角色ID + * @return 权限ID列表 + */ + List getRolePermissionIds(Long roleId); +} diff --git a/src/main/java/com/aisi/template/service/TokenService.java b/src/main/java/com/aisi/template/service/TokenService.java new file mode 100644 index 0000000..101a9da --- /dev/null +++ b/src/main/java/com/aisi/template/service/TokenService.java @@ -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 Token(Token 轮换) + * 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 中提取 jti(JWT 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(); +} diff --git a/src/main/java/com/aisi/template/service/UserService.java b/src/main/java/com/aisi/template/service/UserService.java index d01907e..f0b06cb 100644 --- a/src/main/java/com/aisi/template/service/UserService.java +++ b/src/main/java/com/aisi/template/service/UserService.java @@ -6,19 +6,101 @@ import com.aisi.template.domain.dto.UserDto; import com.aisi.template.domain.dto.UserQueryDto; import com.aisi.template.domain.dto.UserRoleUpdateDto; import com.aisi.template.domain.dto.UserStatusUpdateDto; +import com.aisi.template.domain.vo.LoginResponseVo; import com.aisi.template.domain.vo.UserVo; +/** + * 用户服务接口 + * 定义用户相关的业务操作 + * + * 主要功能: + * 1. 用户认证:注册、登录 + * 2. 用户信息:获取当前用户信息 + * 3. 用户管理:分页查询、状态更新、角色分配 + * + * @author Claude + * @since 2024-04-09 + */ public interface UserService { + /** + * 获取当前用户信息 + * 步骤: + * 1. 从 SecurityContext 获取当前登录用户 + * 2. 查询用户详细信息 + * 3. 转换为视图对象返回 + * + * @return 用户信息视图对象 + */ RestBean getUserInfo(); - RestBean register(UserDto userDto); + /** + * 用户注册 + * 步骤: + * 1. 校验用户名、邮箱是否已存在 + * 2. 校验密码强度 + * 3. 创建用户并分配默认角色 + * 4. 生成 Access Token 和 Refresh Token + * 5. 发送注册消息到 MQ + * + * @param userDto 用户注册信息 + * @return 登录响应(包含 Token 和用户信息) + */ + RestBean register(UserDto userDto); - RestBean login(UserDto userDto); + /** + * 用户登录 + * 步骤: + * 1. 验证用户名和密码 + * 2. 检查账户状态(是否被禁用、锁定) + * 3. 记录登录失败次数(超过阈值则锁定) + * 4. 登录成功后重置失败计数 + * 5. 生成 Access Token 和 Refresh Token + * 6. 发送登录消息到 MQ + * + * @param userDto 登录信息(用户名、密码) + * @return 登录响应(包含 Token 和用户信息) + */ + RestBean login(UserDto userDto); + /** + * 分页查询用户列表 + * 步骤: + * 1. 构建动态查询条件 + * 2. 支持按用户名/邮箱模糊搜索 + * 3. 支持按状态筛选 + * 4. 分页返回结果 + * + * @param queryDto 查询条件 + * @return 分页用户列表 + */ RestBean> getUserList(UserQueryDto queryDto); + /** + * 更新用户状态 + * 步骤: + * 1. 检查用户是否存在 + * 2. 禁止修改当前登录用户的状态 + * 3. 更新用户状态 + * + * @param userId 用户ID + * @param updateDto 状态更新请求 + * @return 更新后的用户信息 + */ RestBean updateUserStatus(Long userId, UserStatusUpdateDto updateDto); + /** + * 更新用户角色 + * 步骤: + * 1. 检查用户是否存在 + * 2. 禁止修改当前登录用户的角色 + * 3. 验证角色ID是否存在 + * 4. 清空用户原有角色 + * 5. 添加新角色 + * + * @param userId 用户ID + * @param updateDto 角色更新请求 + * @return 更新后的用户信息 + */ RestBean updateUserRole(Long userId, UserRoleUpdateDto updateDto); } diff --git a/src/main/java/com/aisi/template/service/impl/AuditLogServiceImpl.java b/src/main/java/com/aisi/template/service/impl/AuditLogServiceImpl.java new file mode 100644 index 0000000..a3b25ed --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/AuditLogServiceImpl.java @@ -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); + } +} diff --git a/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java b/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java index 5b541db..228c571 100644 --- a/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java +++ b/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java @@ -1,38 +1,103 @@ package com.aisi.template.service.impl; 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.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import java.util.Collection; 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 @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { + + /** + * 用户数据访问接口 + */ private final UserRepository userRepository; + /** + * 根据用户名加载用户详情 + * 步骤: + * 1. 查询用户及其角色(使用 JOIN FETCH 避免懒加载问题) + * 2. 检查用户是否存在 + * 3. 检查用户是否被禁用 + * 4. 构建权限列表(角色 + 权限) + * 5. 创建 CustomUserDetails 对象 + * + * @param username 用户名 + * @return 用户详情对象 + * @throws UsernameNotFoundException 当用户不存在或被禁用时抛出异常 + */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByUsername(username) + // 1. 查询用户及其角色 + User user = userRepository.findByUsernameWithRolesAndPermissions(username) .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); - // 检查用户状态 + // 2. 检查用户是否被禁用 if (!user.isEnabled()) { throw new UsernameNotFoundException("用户已被禁用: " + username); } + // 3. 获取用户角色 + Set roles = user.getRoles(); + Set roleCodes = roles.stream() + .map(SysRole::getRoleCode) + .collect(Collectors.toSet()); + + // 4. 构建权限列表:包含角色权限和具体权限 + Collection authorities = roles.stream() + .flatMap(role -> { + // 4.1 添加角色权限(ROLE_XXX 格式) + List roleAuths = List.of( + new SimpleGrantedAuthority(role.getRoleCode()) + ); + + // 4.2 添加具体权限(XXX:YYY 格式,如 user:create, role:read) + List 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( user.getId(), user.getUsername(), user.getPassword(), - List.of(() -> user.getRole().getAuthority()), + authorities, user.isEnabled(), - user.getRole().name() + roleCodes ); } } diff --git a/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java b/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java index 3d32dfb..4d1a23c 100644 --- a/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java +++ b/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java @@ -4,29 +4,80 @@ import com.aisi.template.service.EmailService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +/** + * 邮件服务实现类 + * 负责发送各类邮件 + * + * 主要功能: + * 1. 发送密码重置验证码邮件 + * 2. 发送注册欢迎邮件(预留) + * 3. 发送通知邮件(预留) + * + * 邮件配置: + * - 发件人地址:从配置文件读取(spring.mail.username) + * - 验证码有效期:从配置读取(app.password-reset.code-expire-minutes) + * + * @author Claude + * @since 2024-04-09 + */ @Slf4j @Service @RequiredArgsConstructor public class EmailServiceImpl implements EmailService { + /** + * Java Mail Sender + * Spring 提供的邮件发送工具 + */ private final JavaMailSender mailSender; + /** + * 发件人邮箱地址 + * 从配置文件读取 + */ @Value("${spring.mail.username}") private String from; + /** + * 验证码有效期(分钟) + * 从配置读取,用于在邮件中提示用户 + */ @Value("${app.password-reset.code-expire-minutes:10}") private Integer expireMinutes; + /** + * 发送密码重置验证码邮件 + * 步骤: + * 1. 创建简单邮件消息 + * 2. 设置发件人 + * 3. 设置收件人 + * 4. 设置邮件主题 + * 5. 设置邮件内容(包含验证码和有效期) + * 6. 发送邮件 + * + * @param email 收件人邮箱 + * @param code 6 位数字验证码 + */ @Override public void sendPasswordResetCode(String email, String code) { + // 1. 创建简单邮件消息 SimpleMailMessage message = new SimpleMailMessage(); + + // 2. 设置发件人 message.setFrom(from); + + // 3. 设置收件人 message.setTo(email); + + // 4. 设置邮件主题 message.setSubject("密码找回验证码"); + + // 5. 设置邮件内容 message.setText(""" 您正在进行密码找回操作。 @@ -35,7 +86,9 @@ public class EmailServiceImpl implements EmailService { 如果这不是您的操作,请忽略此邮件。 """.formatted(code, expireMinutes)); + + // 6. 发送邮件 mailSender.send(message); - log.info("已发送密码找回验证码到邮箱: {}", email); + log.info("已发送密码找回验证码到邮箱 - email: {}, code: {}", email, code); } } diff --git a/src/main/java/com/aisi/template/service/impl/LoginAttemptServiceImpl.java b/src/main/java/com/aisi/template/service/impl/LoginAttemptServiceImpl.java new file mode 100644 index 0000000..ea8e119 --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/LoginAttemptServiceImpl.java @@ -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); + } +} diff --git a/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java b/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java index de83323..b47d49a 100644 --- a/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java +++ b/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java @@ -25,38 +25,107 @@ import java.util.List; import java.util.Optional; import java.security.SecureRandom; +/** + * 密码重置服务实现类 + * 负责密码重置流程的业务逻辑 + * + * 主要功能: + * 1. 发送重置验证码:生成验证码并发送到用户邮箱 + * 2. 验证码重置密码:验证验证码并更新密码 + * + * 安全机制: + * - 验证码 6 位数字,10 分钟有效 + * - 同一邮箱 60 秒内只能请求一次(冷却时间) + * - 同一邮箱 1 小时内最多请求 5 次 + * - 验证码最多尝试 5 次,超过后失效 + * - 验证码哈希存储(SHA-256),不存储明文 + * - 新密码使用 BCrypt 加密 + * + * @author Claude + * @since 2024-04-09 + */ @Slf4j @Service @RequiredArgsConstructor public class PasswordResetServiceImpl implements PasswordResetService { + + /** + * 安全随机数生成器 + * 用于生成验证码 + */ private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + /** + * 用户数据访问接口 + */ private final UserRepository userRepository; + + /** + * 密码重置验证码数据访问接口 + */ private final PasswordResetCodeRepository passwordResetCodeRepository; + + /** + * 密码编码器 + * 用于加密新密码 + */ private final PasswordEncoder passwordEncoder; + + /** + * 邮件服务 + * 用于发送验证码邮件 + */ private final EmailService emailService; + /** + * 验证码有效期(分钟),默认 10 分钟 + */ @Value("${app.password-reset.code-expire-minutes:10}") private Integer expireMinutes; + /** + * 请求冷却时间(秒),默认 60 秒 + */ @Value("${app.password-reset.request-cooldown-seconds:60}") private Integer cooldownSeconds; + /** + * 验证码最大尝试次数,默认 5 次 + */ @Value("${app.password-reset.max-attempts:5}") private Integer maxAttempts; + /** + * 发送密码重置验证码 + * 步骤: + * 1. 标准化邮箱(转小写,去除空格) + * 2. 检查邮箱是否存在(为防止用户名枚举,无论是否存在都返回成功) + * 3. 检查请求冷却时间(防止频繁请求) + * 4. 检查 1 小时内请求次数(防止滥用) + * 5. 将该邮箱的所有旧验证码标记为已使用 + * 6. 生成 6 位随机数字验证码 + * 7. 将验证码哈希后存储到数据库 + * 8. 发送验证码到用户邮箱 + * + * @param requestDto 重置请求(邮箱) + * @return 成功响应 + */ @Override @Transactional public RestBean sendResetCode(PasswordResetRequestDto requestDto) { + // 1. 标准化邮箱(转小写,去除首尾空格) String email = requestDto.getEmail().trim().toLowerCase(); + // 2. 检查邮箱是否存在 + // 注意:为防止用户名枚举攻击,无论邮箱是否存在都返回相同消息 Optional userOptional = userRepository.findByEmailIgnoreCase(email); if (userOptional.isEmpty()) { return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null); } + // 3. 检查请求冷却时间(防止频繁请求) Optional latestCode = passwordResetCodeRepository - .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email); + .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email); if (latestCode.isPresent()) { LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds); 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) { return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null); } + // 5. 将该邮箱的所有旧验证码标记为已使用(避免混淆) List activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); for (PasswordResetCode item : activeCodes) { item.setUsed(true); } passwordResetCodeRepository.saveAll(activeCodes); + // 6. 生成 6 位随机数字验证码 String code = generateCode(); + + // 7. 将验证码哈希后存储到数据库 PasswordResetCode resetCode = new PasswordResetCode(); resetCode.setEmail(email); - resetCode.setCodeHash(sha256(code)); + resetCode.setCodeHash(sha256(code)); // 只存储哈希值,不存储明文 resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes)); resetCode.setUsed(false); resetCode.setAttemptCount(0); 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); } + /** + * 使用验证码重置密码 + * 步骤: + * 1. 标准化邮箱 + * 2. 检查邮箱是否存在 + * 3. 获取最新的未使用验证码 + * 4. 验证验证码是否已使用 + * 5. 验证验证码是否过期 + * 6. 验证尝试次数是否超限 + * 7. 验证验证码是否正确 + * 8. 更新用户密码 + * 9. 标记验证码为已使用 + * 10. 将该邮箱的所有其他验证码标记为已使用 + * + * @param confirmDto 确认请求(邮箱、验证码、新密码) + * @return 成功响应 + */ @Override @Transactional public RestBean resetPassword(PasswordResetConfirmDto confirmDto) { + // 1. 标准化邮箱 String email = confirmDto.getEmail().trim().toLowerCase(); + + // 2. 检查邮箱是否存在 Optional userOptional = userRepository.findByEmailIgnoreCase(email); if (userOptional.isEmpty()) { return RestBean.failure(400, "验证码或邮箱不正确", null); } + // 3. 获取最新的未使用验证码 PasswordResetCode resetCode = passwordResetCodeRepository - .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email) - .orElse(null); + .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email) + .orElse(null); if (resetCode == null) { return RestBean.failure(400, "请先获取验证码", null); } + // 4. 验证验证码是否已使用 if (Boolean.TRUE.equals(resetCode.getUsed())) { return RestBean.failure(400, "验证码已失效,请重新获取", null); } + // 5. 验证验证码是否过期 if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) { resetCode.setUsed(true); passwordResetCodeRepository.save(resetCode); return RestBean.failure(400, "验证码已过期,请重新获取", null); } + // 6. 验证尝试次数是否超限 if (resetCode.getAttemptCount() >= maxAttempts) { resetCode.setUsed(true); passwordResetCodeRepository.save(resetCode); return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null); } + // 7. 验证验证码是否正确 if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) { + // 7.1 增加尝试次数 resetCode.setAttemptCount(resetCode.getAttemptCount() + 1); + // 7.2 如果尝试次数超限,标记为已使用 if (resetCode.getAttemptCount() >= maxAttempts) { resetCode.setUsed(true); } @@ -130,31 +238,58 @@ public class PasswordResetServiceImpl implements PasswordResetService { return RestBean.failure(400, "验证码不正确", null); } + // 8. 更新用户密码 User user = userOptional.get(); user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword())); + user.setPasswordChangedAt(LocalDateTime.now()); userRepository.save(user); + // 9. 标记当前验证码为已使用 resetCode.setUsed(true); passwordResetCodeRepository.save(resetCode); + // 10. 将该邮箱的所有其他验证码标记为已使用 List otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); for (PasswordResetCode item : otherCodes) { item.setUsed(true); } passwordResetCodeRepository.saveAll(otherCodes); - log.info("用户通过邮箱验证码重置密码成功: {}", email); + log.info("用户通过邮箱验证码重置密码成功 - email: {}", email); return RestBean.success(RestCode.SUCCESS); } + /** + * 生成 6 位随机数字验证码 + * 步骤: + * 1. 生成 0 到 999999 之间的随机数 + * 2. 格式化为 6 位数字(不足补零) + * + * @return 6 位数字验证码 + */ private String generateCode() { 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) { try { + // 1. 获取 SHA-256 算法实例 MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + // 2. 对字符串进行哈希 byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + + // 3. 转换为十六进制字符串 StringBuilder builder = new StringBuilder(); for (byte b : hash) { String hex = Integer.toHexString(0xff & b); diff --git a/src/main/java/com/aisi/template/service/impl/SysMenuServiceImpl.java b/src/main/java/com/aisi/template/service/impl/SysMenuServiceImpl.java new file mode 100644 index 0000000..9d1cfaf --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/SysMenuServiceImpl.java @@ -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 getMenuTree() { + // 1. 查询所有启用状态的菜单,按排序字段排序 + List 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 getUserMenus(Long userId) { + // 1. 查询用户及其角色 + var user = userRepository.findByIdWithRoles(userId) + .orElseThrow(() -> new BusinessException("用户不存在: " + userId)); + + // 2. 提取角色ID列表 + List 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 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 父菜单ID(null 表示查询根菜单) + * @return 子菜单列表 + */ + @Override + public List getByParentId(Long parentId) { + // 1. 查询子菜单,按排序字段排序 + List 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 buildMenuTree(List menus, Long parentId) { + List 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; + } +} diff --git a/src/main/java/com/aisi/template/service/impl/SysPermissionServiceImpl.java b/src/main/java/com/aisi/template/service/impl/SysPermissionServiceImpl.java new file mode 100644 index 0000000..5ef4344 --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/SysPermissionServiceImpl.java @@ -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 getAllPermissions() { + // 1. 查询所有启用状态的权限(status = 1) + List 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 getByResource(String resource) { + // 1. 按资源查询权限 + List 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 getByAction(String action) { + // 1. 按操作类型查询权限 + List 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; + } +} diff --git a/src/main/java/com/aisi/template/service/impl/SysRoleServiceImpl.java b/src/main/java/com/aisi/template/service/impl/SysRoleServiceImpl.java new file mode 100644 index 0000000..a69d026 --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/SysRoleServiceImpl.java @@ -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 getAllRoles() { + // 1. 查询所有角色(使用 JOIN FETCH 避免懒加载问题) + List 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 queryRoles(RoleQueryDto queryDto, int page, int size) { + // 1. 构建动态查询条件 + Specification spec = (root, query, cb) -> { + List 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 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 permissionIds) { + // 1. 检查角色是否存在 + SysRole role = roleRepository.findById(roleId) + .orElseThrow(() -> new BusinessException("角色不存在: " + roleId)); + + // 2. 查询所有权限(如果权限不存在会抛出异常) + Set 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 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 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; + } +} diff --git a/src/main/java/com/aisi/template/service/impl/TokenServiceImpl.java b/src/main/java/com/aisi/template/service/impl/TokenServiceImpl.java new file mode 100644 index 0000000..f62a2c5 --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/TokenServiceImpl.java @@ -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 Token(Token 轮换机制) + * 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 Token(Token 轮换) + // 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 ID(JWT 唯一标识) + * @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); + } + } +} diff --git a/src/main/java/com/aisi/template/service/impl/UserServiceImpl.java b/src/main/java/com/aisi/template/service/impl/UserServiceImpl.java index 65e6fec..12dbcbe 100644 --- a/src/main/java/com/aisi/template/service/impl/UserServiceImpl.java +++ b/src/main/java/com/aisi/template/service/impl/UserServiceImpl.java @@ -7,13 +7,18 @@ import com.aisi.template.domain.dto.UserDto; import com.aisi.template.domain.dto.UserQueryDto; import com.aisi.template.domain.dto.UserRoleUpdateDto; 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.enums.Role; +import com.aisi.template.domain.vo.LoginResponseVo; import com.aisi.template.domain.vo.UserVo; +import com.aisi.template.repository.SysRoleRepository; import com.aisi.template.repository.UserRepository; +import com.aisi.template.service.TokenService; import com.aisi.template.service.UserService; import com.aisi.template.utils.JwtUtil; +import com.aisi.template.service.LoginAttemptService; import com.aisi.template.utils.SecurityUtils; +import com.aisi.template.mq.producer.UserMessageProducer; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; 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.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.stream.Collectors; +/** + * 用户服务实现类 + * 负责用户相关的业务逻辑处理 + * + * 主要功能: + * 1. 用户注册:校验、创建、分配默认角色、生成 Token + * 2. 用户登录:验证、状态检查、失败计数、生成 Token + * 3. 用户信息:获取当前用户信息 + * 4. 用户管理:分页查询、状态更新、角色分配 + * + * 事务说明: + * - 注册操作:需要事务保证数据一致性 + * - 状态更新:需要事务保证数据一致性 + * - 角色更新:需要事务保证数据一致性 + * + * @author Claude + * @since 2024-04-09 + */ @Slf4j @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { + /** + * 密码编码器 + * 使用 BCrypt 算法对密码进行加密 + */ private final PasswordEncoder passwordEncoder; + + /** + * JWT 工具类 + * 用于生成和验证 JWT Token + */ private final JwtUtil jwtUtil; + + /** + * 用户数据访问接口 + */ private final UserRepository userRepository; + + /** + * 角色数据访问接口 + */ + private final SysRoleRepository roleRepository; + + /** + * Token 服务 + * 处理 Refresh Token 和黑名单 + */ + private final TokenService tokenService; + + /** + * Security 工具类 + * 获取当前登录用户信息 + */ private final SecurityUtils securityUtils; + /** + * 登录尝试服务 + * 处理登录失败计数和账户锁定 + */ + private final LoginAttemptService loginAttemptService; + + /** + * 获取当前用户信息 + * 步骤: + * 1. 从 SecurityContext 获取当前用户名 + * 2. 使用 JOIN FETCH 一次性加载用户及其角色 + * 3. 转换为视图对象返回 + * + * @return 用户信息视图对象 + */ @Override public RestBean getUserInfo() { + // 1. 获取当前登录用户名 String username = SecurityUtils.getUsername(); - User user = userRepository.findByUsername(username) + + // 2. 查询用户(使用 JOIN FETCH 避免懒加载问题) + User user = userRepository.findByUsernameWithRoles(username) .orElse(null); + + // 3. 用户不存在则返回错误 if (user == null) { return RestBean.failure(RestCode.DATA_NOT_FOUND); } - // 转换为 UserVo 返回 - UserVo userVo = new UserVo(); - userVo.setId(user.getId()); - 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()); + + // 4. 转换为视图对象 + UserVo userVo = convertToVo(user); 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 - public RestBean register(UserDto userDto) { - String normalizedEmail = userDto.getEmail() == null ? null : userDto.getEmail().trim().toLowerCase(Locale.ROOT); - // 检查用户名是否存在 + @Transactional(rollbackFor = Exception.class) + public RestBean register(UserDto userDto) { + // 1. 标准化邮箱(转小写,去除首尾空格) + String normalizedEmail = userDto.getEmail() == null ? null + : userDto.getEmail().trim().toLowerCase(Locale.ROOT); + + // 2. 检查用户名是否已存在 if (userRepository.existsByUsername(userDto.getUsername())) { return RestBean.failure(400, "用户名已被使用", null); } - // 检查邮箱是否存在 + + // 3. 检查邮箱是否已存在(不区分大小写) if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) { return RestBean.failure(400, "邮箱已被使用", null); } + // 4. 创建用户实体 User user = new User(); user.setUsername(userDto.getUsername()); + // 4.1 密码使用 BCrypt 加密 user.setPassword(passwordEncoder.encode(userDto.getPassword())); 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); - // 生成token - String token = jwtUtil.generateToken(user.getId(), user.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.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 - public RestBean login(UserDto userDto) { - // 查找用户 + public RestBean login(UserDto userDto) { + // 1. 查找用户 User user = userRepository.findByUsername(userDto.getUsername()) .orElse(null); + // 2. 用户不存在 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()) { - return RestBean.failure(403, "用户已被禁用", null); + return RestBean.failure(RestCode.USER_DISABLED.getCode(), "用户已被禁用", null); } - // 验证密码 + // 5. 验证密码 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 - String token = jwtUtil.generateToken(user.getId(), user.getUsername()); - return RestBean.success(token); + // 6. 登录成功,重置失败计数 + loginAttemptService.resetFailedAttempts(userDto.getUsername()); + + // 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 public RestBean> getUserList(UserQueryDto queryDto) { try { + // 1. 构建分页参数 + // - 页码从 0 开始(前端传入的页码需要 -1) + // - 每页大小至少为 1 Pageable pageable = PageRequest.of( Math.max(queryDto.getPage() - 1, 0), Math.max(queryDto.getSize(), 1), Sort.by(Sort.Direction.DESC, "createdAt") ); + // 2. 执行分页查询 Page userPage = userRepository.findAll(buildSpecification(queryDto), pageable); + + // 3. 转换为视图对象列表 List records = userPage.getContent().stream() .map(this::convertToVo) .collect(Collectors.toList()); + // 4. 构建分页结果 return RestBean.success(PageResult.of( records, userPage.getTotalElements(), @@ -138,58 +338,127 @@ public class UserServiceImpl implements UserService { } } + /** + * 更新用户状态 + * 步骤: + * 1. 查询用户是否存在 + * 2. 检查是否修改当前登录用户的状态(禁止) + * 3. 更新用户状态 + * 4. 保存到数据库 + * + * 注意: + * - 不能禁用当前登录的用户 + * - 状态变更需要重新登录才能完全生效 + * + * @param userId 用户ID + * @param updateDto 状态更新请求 + * @return 更新后的用户信息 + */ @Override + @Transactional(rollbackFor = Exception.class) public RestBean updateUserStatus(Long userId, UserStatusUpdateDto updateDto) { try { + // 1. 查询用户 User user = userRepository.findById(userId).orElse(null); if (user == null) { return RestBean.failure(RestCode.DATA_NOT_FOUND, null); } + // 2. 检查是否修改当前登录用户 Long currentUserId = SecurityUtils.getUserId(); if (currentUserId != null && currentUserId.equals(userId)) { return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null); } + // 3. 更新用户状态 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) { log.error("更新用户状态失败, userId={}", userId, e); return RestBean.failure(RestCode.SYSTEM_ERROR, null); } } + /** + * 更新用户角色 + * 步骤: + * 1. 查询用户是否存在 + * 2. 检查是否修改当前登录用户的角色(禁止) + * 3. 验证所有角色ID是否存在 + * 4. 清空用户原有角色 + * 5. 添加新角色 + * 6. 保存到数据库 + * + * 注意: + * - 不能修改当前登录用户的角色 + * - 角色变更后需要重新登录才能生效 + * - 清空原有角色再添加新角色(避免重复) + * + * @param userId 用户ID + * @param updateDto 角色更新请求 + * @return 更新后的用户信息 + */ @Override + @Transactional(rollbackFor = Exception.class) public RestBean updateUserRole(Long userId, UserRoleUpdateDto updateDto) { try { + // 1. 查询用户 User user = userRepository.findById(userId).orElse(null); if (user == null) { return RestBean.failure(RestCode.DATA_NOT_FOUND, null); } + // 2. 检查是否修改当前登录用户 Long currentUserId = SecurityUtils.getUserId(); if (currentUserId != null && currentUserId.equals(userId)) { return RestBean.failure(403, "不允许修改当前登录用户的角色", null); } - String roleValue = updateDto.getRole().trim().toUpperCase(); - if (!"ADMIN".equals(roleValue) && !"USER".equals(roleValue)) { - return RestBean.failure(400, "角色不合法", null); + // 3. 根据角色ID查询所有角色 + Set roles = new HashSet<>(); + for (Long roleId : updateDto.getRoleIds()) { + SysRole role = roleRepository.findById(roleId) + .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); + roles.add(role); } - Role role = Role.valueOf(roleValue); - user.setRole(role); - return RestBean.success(convertToVo(userRepository.save(user))); + // 4. 更新用户角色(清空后添加) + user.getRoles().clear(); + 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) { log.error("更新用户角色失败, userId={}", userId, e); return RestBean.failure(RestCode.SYSTEM_ERROR, null); } } + /** + * 构建动态查询条件 + * 步骤: + * 1. 支持按关键词模糊搜索(用户名或邮箱) + * 2. 支持按状态精确筛选 + * 3. 返回 Specification 对象 + * + * 注意: + * - 角色查询需要使用 JOIN,此处简化处理 + * - 可以根据需要扩展更多查询条件 + * + * @param queryDto 查询条件对象 + * @return 动态查询 Specification + */ private Specification buildSpecification(UserQueryDto queryDto) { return (root, query, cb) -> { List predicates = new ArrayList<>(); + // 1. 关键词模糊搜索(用户名或邮箱) if (StringUtils.hasText(queryDto.getKeyword())) { String keyword = "%" + queryDto.getKeyword().trim() + "%"; predicates.add(cb.or( @@ -198,10 +467,7 @@ public class UserServiceImpl implements UserService { )); } - if (StringUtils.hasText(queryDto.getRole())) { - predicates.add(cb.equal(root.get("role"), Role.fromString(queryDto.getRole()))); - } - + // 2. 状态精确筛选 if (queryDto.getStatus() != null) { 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) { UserVo userVo = new UserVo(); userVo.setId(user.getId()); userVo.setUsername(user.getUsername()); userVo.setEmail(user.getEmail()); userVo.setStatus(user.getStatus()); - userVo.setRole(user.getRole().name()); + + // 1. 转换角色为字符串集合(只返回角色编码) + Set roleCodes = user.getRoles().stream() + .map(SysRole::getRoleCode) + .collect(Collectors.toSet()); + userVo.setRoles(roleCodes); + userVo.setCreatedAt(user.getCreatedAt()); userVo.setUpdatedAt(user.getUpdatedAt()); return userVo; diff --git a/src/main/java/com/aisi/template/utils/JwtUtil.java b/src/main/java/com/aisi/template/utils/JwtUtil.java index ca7b086..e73b135 100644 --- a/src/main/java/com/aisi/template/utils/JwtUtil.java +++ b/src/main/java/com/aisi/template/utils/JwtUtil.java @@ -8,37 +8,167 @@ import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; import java.security.Key; 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:用户信息、签发时间、过期时间、jti(JWT ID) + * - Signature:使用密钥签名的签名部分 + * + * @author Claude + * @since 2024-04-09 + */ @Component public class JwtUtil { + /** + * JWT 密钥 + * 从配置文件读取 + * HS512 算法要求密钥至少 64 字符(512 位) + */ @Value("${jwt.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() { // HS512 要求密钥至少 512 位 = 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)); } - public String generateToken(Long userId,String username) { + /** + * 为用户生成 Access Token + * 步骤: + * 1. 生成唯一的 jti(JWT 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. 生成唯一的 jti(JWT ID),用于黑名单追踪 + * 2. 计算过期时间(毫秒) + * 3. 构建 Token + * + * @param userId 用户ID + * @param username 用户名 + * @param expirationSeconds 过期时间(秒) + * @return JWT Token + */ + public String generateToken(Long userId, String username, long expirationSeconds) { + // 1. 生成唯一的 jti(JWT ID) + String jti = UUID.randomUUID().toString(); + long expirationMillis = expirationSeconds * 1000; + + // 2. 构建 Token return Jwts.builder() - .setSubject(username) - .claim("id", userId) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSigningKey(), SignatureAlgorithm.HS512) + .setSubject(username) // 主题:用户名 + .claim("id", userId) // 自定义声明:用户ID + .claim("jti", jti) // 自定义声明:JWT ID(用于黑名单) + .setIssuedAt(new Date()) // 签发时间:当前时间 + .setExpiration(new Date(System.currentTimeMillis() + expirationMillis)) // 过期时间 + .signWith(getSigningKey(), SignatureAlgorithm.HS512) // 签名算法和密钥 .compact(); } + /** + * 从 Token 中提取用户名 + * + * @param token JWT Token + * @return 用户名 + */ public String extractUsername(String token) { 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 ID(jti) + * 说明: + * - 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) { try { 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 parseClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) diff --git a/src/main/java/com/aisi/template/utils/RedisCache.java b/src/main/java/com/aisi/template/utils/RedisCache.java new file mode 100644 index 0000000..e80e00a --- /dev/null +++ b/src/main/java/com/aisi/template/utils/RedisCache.java @@ -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 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 get(String key, Class 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 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 map) { + try { + redisTemplate.opsForHash().putAll(key, map); + log.debug("批量设置哈希字段 - key: {}", key); + } catch (Exception e) { + log.error("批量设置哈希字段失败 - key: {}", key, e); + } + } + + /** + * 获取所有哈希字段 + * + * @param key 键 + * @return 字段-值映射 + */ + public Map 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 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 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 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 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 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; + } + } +} diff --git a/src/main/java/com/aisi/template/utils/RedisLock.java b/src/main/java/com/aisi/template/utils/RedisLock.java new file mode 100644 index 0000000..653f292 --- /dev/null +++ b/src/main/java/com/aisi/template/utils/RedisLock.java @@ -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 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; + } + } +} diff --git a/src/main/java/com/aisi/template/utils/RedisUtils.java b/src/main/java/com/aisi/template/utils/RedisUtils.java index 1bb4827..fa3c0e9 100644 --- a/src/main/java/com/aisi/template/utils/RedisUtils.java +++ b/src/main/java/com/aisi/template/utils/RedisUtils.java @@ -1,6 +1,7 @@ package com.aisi.template.utils; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; @@ -11,69 +12,187 @@ import java.util.Map; import java.util.Set; 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 @RequiredArgsConstructor public class RedisUtils { + /** + * Redis 模板 + */ private final RedisTemplate redisTemplate; - // ======================== 通用 ======================== + /** + * 字符串 Redis 模板,用于数值自增等原生命令 + */ + private final StringRedisTemplate stringRedisTemplate; + // ======================== 1. 通用操作 ======================== + + /** + * 删除键 + * + * @param key 键 + * @return 是否删除成功 + */ public Boolean delete(String key) { return redisTemplate.delete(key); } + /** + * 批量删除键 + * + * @param keys 键集合 + * @return 删除的数量 + */ public Long delete(Collection keys) { return redisTemplate.delete(keys); } + /** + * 判断键是否存在 + * + * @param key 键 + * @return 是否存在 + */ public Boolean hasKey(String key) { return redisTemplate.hasKey(key); } + /** + * 设置过期时间 + * + * @param key 键 + * @param timeout 过期时间 + * @param unit 时间单位 + * @return 是否设置成功 + */ public Boolean expire(String key, long timeout, TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } + /** + * 获取过期时间 + * + * @param key 键 + * @return 剩余过期时间(秒),-1 表示永不过期,-2 表示键不存在 + */ public Long getExpire(String key) { return redisTemplate.getExpire(key); } + /** + * 在指定时间戳过期 + * + * @param key 键 + * @param timestamp 时间戳(毫秒) + * @return 是否设置成功 + */ public Boolean expireAt(String key, long timestamp) { return redisTemplate.expireAt(key, new java.util.Date(timestamp)); } + /** + * 查找匹配模式的键 + * + * @param pattern 模式(如:user:*) + * @return 匹配的键集合 + */ public Set keys(String pattern) { return redisTemplate.keys(pattern); } + /** + * 重命名键 + * + * @param oldKey 旧键名 + * @param newKey 新键名 + * @return 是否重命名成功 + */ public Boolean rename(String oldKey, String newKey) { redisTemplate.rename(oldKey, newKey); return true; } + /** + * 获取键的剩余生存时间(秒) + * + * @param key 键 + * @return 剩余秒数 + */ public Long ttl(String key) { return redisTemplate.getExpire(key, TimeUnit.SECONDS); } - // ======================== String ======================== + // ======================== 2. String 类型操作 ======================== + /** + * 设置键值 + * + * @param key 键 + * @param value 值 + */ public void set(String key, Object 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) { 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) { redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } + /** + * 获取键值 + * + * @param key 键 + * @return 值 + */ public Object get(String key) { return redisTemplate.opsForValue().get(key); } + /** + * 获取键值(指定类型) + * + * @param key 键 + * @param clazz 类型 + * @return 值 + */ public T get(String key, Class clazz) { Object value = redisTemplate.opsForValue().get(key); if (value == null) { @@ -82,255 +201,680 @@ public class RedisUtils { return clazz.cast(value); } + /** + * 获取并设置新值 + * + * @param key 键 + * @param value 新值 + * @return 旧值 + */ public String getAndSet(String key, Object value) { Object old = redisTemplate.opsForValue().getAndSet(key, value); return old != null ? old.toString() : null; } + /** + * 自增(原子操作) + * + * @param key 键 + * @return 自增后的值 + */ 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) { - return redisTemplate.opsForValue().increment(key, delta); + return stringRedisTemplate.opsForValue().increment(key, delta); } + /** + * 自减(原子操作) + * + * @param key 键 + * @return 自减后的值 + */ 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) { - return redisTemplate.opsForValue().decrement(key, delta); + return stringRedisTemplate.opsForValue().decrement(key, delta); } + /** + * 获取字符串长度 + * + * @param key 键 + * @return 字符串长度 + */ public Long strLength(String key) { return redisTemplate.opsForValue().size(key); } - // ======================== Hash ======================== + // ======================== 3. Hash 类型操作 ======================== + /** + * 设置哈希字段 + * + * @param key 键 + * @param hashKey 哈希键 + * @param value 值 + */ public void hSet(String key, String hashKey, Object value) { redisTemplate.opsForHash().put(key, hashKey, value); } + /** + * 批量设置哈希字段 + * + * @param key 键 + * @param map 哈希键值映射 + */ public void hSetAll(String key, Map map) { redisTemplate.opsForHash().putAll(key, map); } + /** + * 获取哈希字段 + * + * @param key 键 + * @param hashKey 哈希键 + * @return 值 + */ public Object hGet(String key, String hashKey) { return redisTemplate.opsForHash().get(key, hashKey); } + /** + * 获取所有哈希字段 + * + * @param key 键 + * @return 哈希键值映射 + */ public Map hGetAll(String key) { return redisTemplate.opsForHash().entries(key); } + /** + * 删除哈希字段 + * + * @param key 键 + * @param hashKeys 哈希键集合 + */ public void hDelete(String key, Object... hashKeys) { redisTemplate.opsForHash().delete(key, hashKeys); } + /** + * 判断哈希字段是否存在 + * + * @param key 键 + * @param hashKey 哈希键 + * @return 是否存在 + */ public Boolean hHasKey(String key, String hashKey) { return redisTemplate.opsForHash().hasKey(key, hashKey); } + /** + * 获取哈希大小 + * + * @param key 键 + * @return 哈希大小 + */ public Long hSize(String key) { return redisTemplate.opsForHash().size(key); } + /** + * 哈希字段自增 + * + * @param key 键 + * @param hashKey 哈希键 + * @param delta 增量 + * @return 自增后的值 + */ public Long hIncrement(String key, String hashKey, long delta) { return redisTemplate.opsForHash().increment(key, hashKey, delta); } + /** + * 获取所有哈希键 + * + * @param key 键 + * @return 哈希键集合 + */ public Set hKeys(String key) { return redisTemplate.opsForHash().keys(key); } + /** + * 获取所有哈希值 + * + * @param key 键 + * @return 哈希值列表 + */ public List hValues(String key) { return redisTemplate.opsForHash().values(key); } - // ======================== List ======================== + // ======================== 4. List 类型操作 ======================== + /** + * 从左侧推入列表 + * + * @param key 键 + * @param value 值 + * @return 列表长度 + */ public Long lPush(String key, Object value) { return redisTemplate.opsForList().leftPush(key, value); } + /** + * 从左侧批量推入列表 + * + * @param key 键 + * @param values 值数组 + * @return 列表长度 + */ public Long lPushAll(String key, Object... values) { return redisTemplate.opsForList().leftPushAll(key, values); } + /** + * 从右侧推入列表 + * + * @param key 键 + * @param value 值 + * @return 列表长度 + */ public Long rPush(String key, Object value) { return redisTemplate.opsForList().rightPush(key, value); } + /** + * 从右侧批量推入列表 + * + * @param key 键 + * @param values 值数组 + * @return 列表长度 + */ public Long rPushAll(String key, Object... values) { return redisTemplate.opsForList().rightPushAll(key, values); } + /** + * 从左侧弹出元素 + * + * @param key 键 + * @return 弹出的元素 + */ public Object lPop(String key) { return redisTemplate.opsForList().leftPop(key); } + /** + * 从左侧批量弹出元素 + * + * @param key 键 + * @param count 数量 + * @return 弹出的元素列表 + */ public List lPop(String key, long count) { return redisTemplate.opsForList().leftPop(key, count); } + /** + * 从右侧弹出元素 + * + * @param key 键 + * @return 弹出的元素 + */ public Object rPop(String key) { return redisTemplate.opsForList().rightPop(key); } + /** + * 从右侧批量弹出元素 + * + * @param key 键 + * @param count 数量 + * @return 弹出的元素列表 + */ public List rPop(String key, long count) { return redisTemplate.opsForList().rightPop(key, count); } + /** + * 获取列表指定索引的元素 + * + * @param key 键 + * @param index 索引(0 表示第一个) + * @return 元素 + */ public Object lIndex(String key, long index) { return redisTemplate.opsForList().index(key, index); } + /** + * 获取列表长度 + * + * @param key 键 + * @return 列表长度 + */ public Long lSize(String key) { return redisTemplate.opsForList().size(key); } + /** + * 获取列表范围 + * + * @param key 键 + * @param start 开始索引 + * @param end 结束索引 + * @return 元素列表 + */ public List lRange(String key, long start, long end) { return redisTemplate.opsForList().range(key, start, end); } + /** + * 裁剪列表(只保留指定范围的元素) + * + * @param key 键 + * @param start 开始索引 + * @param end 结束索引 + */ public void lTrim(String key, long start, long end) { redisTemplate.opsForList().trim(key, start, end); } + /** + * 设置列表指定索引的元素 + * + * @param key 键 + * @param index 索引 + * @param value 值 + */ public void lSet(String key, long index, Object 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) { return redisTemplate.opsForList().remove(key, count, value); } - // ======================== Set ======================== + // ======================== 5. Set 类型操作 ======================== + /** + * 添加到集合 + * + * @param key 键 + * @param values 值数组 + * @return 添加的元素数量(不包含已存在的) + */ public Long sAdd(String key, Object... values) { return redisTemplate.opsForSet().add(key, values); } + /** + * 从集合移除元素 + * + * @param key 键 + * @param values 值数组 + * @return 实际移除的数量 + */ public Long sRemove(String key, Object... values) { return redisTemplate.opsForSet().remove(key, values); } + /** + * 获取集合所有元素 + * + * @param key 键 + * @return 元素集合 + */ public Set sMembers(String key) { return redisTemplate.opsForSet().members(key); } + /** + * 判断元素是否在集合中 + * + * @param key 键 + * @param value 值 + * @return 是否存在 + */ public Boolean sIsMember(String key, Object value) { return redisTemplate.opsForSet().isMember(key, value); } + /** + * 获取集合大小 + * + * @param key 键 + * @return 集合大小 + */ public Long sSize(String key) { return redisTemplate.opsForSet().size(key); } + /** + * 随机获取集合中的一个元素 + * + * @param key 键 + * @return 随机元素 + */ public Object sRandomMember(String key) { return redisTemplate.opsForSet().randomMember(key); } + /** + * 随机获取集合中的多个元素 + * + * @param key 键 + * @param count 数量 + * @return 随机元素集合 + */ public Set sRandomMembers(String key, long count) { return redisTemplate.opsForSet().distinctRandomMembers(key, count); } + /** + * 集合交集 + * + * @param key1 第一个集合键 + * @param key2 第二个集合键 + * @return 交集元素 + */ public Set sIntersect(String key1, String key2) { return redisTemplate.opsForSet().intersect(key1, key2); } + /** + * 集合并集 + * + * @param key1 第一个集合键 + * @param key2 第二个集合键 + * @return 并集元素 + */ public Set sUnion(String key1, String key2) { return redisTemplate.opsForSet().union(key1, key2); } + /** + * 集合差集(key1 - key2) + * + * @param key1 第一个集合键 + * @param key2 第二个集合键 + * @return 差集元素 + */ public Set sDifference(String key1, String 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) { return redisTemplate.opsForZSet().add(key, value, score); } + /** + * 批量添加到有序集合 + * + * @param key 键 + * @param tuples 分数-值对集合 + * @return 添加的数量 + */ public Long zAdd(String key, Set> tuples) { return redisTemplate.opsForZSet().add(key, tuples); } + /** + * 从有序集合移除元素 + * + * @param key 键 + * @param values 要移除的值 + * @return 移除的数量 + */ public Long zRemove(String key, Object... values) { return redisTemplate.opsForZSet().remove(key, values); } + /** + * 获取元素排名(升序) + * + * @param key 键 + * @param value 值 + * @return 排名(从 0 开始),不存在返回 null + */ public Long zRank(String key, Object value) { return redisTemplate.opsForZSet().rank(key, value); } + /** + * 获取元素排名(降序) + * + * @param key 键 + * @param value 值 + * @return 排名(从 0 开始),不存在返回 null + */ public Long zReverseRank(String key, Object value) { return redisTemplate.opsForZSet().reverseRank(key, value); } + /** + * 获取有序集合范围(升序) + * + * @param key 键 + * @param start 开始索引 + * @param end 结束索引 + * @return 元素集合 + */ public Set zRange(String key, long start, long end) { return redisTemplate.opsForZSet().range(key, start, end); } + /** + * 获取有序集合范围(降序) + * + * @param key 键 + * @param start 开始索引 + * @param end 结束索引 + * @return 元素集合 + */ public Set zReverseRange(String key, long start, long end) { return redisTemplate.opsForZSet().reverseRange(key, start, end); } + /** + * 获取有序集合范围(带分数,升序) + * + * @param key 键 + * @param start 开始索引 + * @param end 结束索引 + * @return 分数-值对集合 + */ public Set> zRangeWithScores(String key, long start, long end) { return redisTemplate.opsForZSet().rangeWithScores(key, start, end); } + /** + * 获取指定分数范围的元素 + * + * @param key 键 + * @param min 最小分数 + * @param max 最大分数 + * @return 元素集合 + */ public Set zRangeByScore(String key, double min, double max) { return redisTemplate.opsForZSet().rangeByScore(key, min, max); } + /** + * 获取指定分数范围的元素(分页) + * + * @param key 键 + * @param min 最小分数 + * @param max 最大分数 + * @param offset 偏移量 + * @param count 数量 + * @return 元素集合 + */ public Set zRangeByScore(String key, double min, double max, long offset, long 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) { return redisTemplate.opsForZSet().count(key, min, max); } + /** + * 获取有序集合大小 + * + * @param key 键 + * @return 集合大小 + */ public Long zSize(String key) { return redisTemplate.opsForZSet().size(key); } + /** + * 获取元素分数 + * + * @param key 键 + * @param value 值 + * @return 分数 + */ public Double zScore(String key, Object value) { return redisTemplate.opsForZSet().score(key, value); } + /** + * 移除指定排名范围的元素 + * + * @param key 键 + * @param start 开始排名 + * @param end 结束排名 + * @return 移除的数量 + */ public Long zRemoveRange(String key, long start, long end) { return redisTemplate.opsForZSet().removeRange(key, start, end); } + /** + * 移除指定分数范围的元素 + * + * @param key 键 + * @param min 最小分数 + * @param max 最大分数 + * @return 移除的数量 + */ public Long zRemoveRangeByScore(String key, double min, double max) { return redisTemplate.opsForZSet().removeRangeByScore(key, min, max); } - // ======================== Bitmap ======================== + // ======================== 7. Bitmap 类型操作 ======================== + /** + * 设置位图的指定位 + * 使用场景: + * - 用户签到统计 + * - 在线状态统计 + * - 布隆过滤器 + * + * @param key 键 + * @param offset 偏移量(位位置) + * @param value 值(true=1,false=0) + * @return 设置前的值 + */ public Boolean setBit(String key, long offset, boolean value) { return redisTemplate.opsForValue().setBit(key, offset, value); } + /** + * 获取位图的指定位 + * + * @param key 键 + * @param offset 偏移量(位位置) + * @return 位值(true=1,false=0) + */ public Boolean getBit(String key, long offset) { return redisTemplate.opsForValue().getBit(key, offset); } - // ======================== HyperLogLog ======================== + // ======================== 8. HyperLogLog 类型操作 ======================== + /** + * 添加元素到 HyperLogLog + * 使用场景: + * - 统计独立访客数(UV) + * - 统计独立元素数(基数统计) + * - 优点:内存占用小,适合大数据量 + * + * @param key 键 + * @param values 值数组 + * @return 添加后的基数 + */ public Long pfAdd(String key, Object... values) { return redisTemplate.opsForHyperLogLog().add(key, values); } + /** + * 统计基数 + * + * @param keys 键数组(可统计多个键的并集基数) + * @return 基数 + */ public Long pfCount(String... keys) { return redisTemplate.opsForHyperLogLog().size(keys); } + /** + * 合并多个 HyperLogLog + * + * @param destination 目标键 + * @param sourceKeys 源键数组 + */ public void pfMerge(String destination, String... sourceKeys) { redisTemplate.opsForHyperLogLog().union(destination, sourceKeys); } diff --git a/src/main/java/com/aisi/template/utils/SecurityUtils.java b/src/main/java/com/aisi/template/utils/SecurityUtils.java index af9825e..2f4d42c 100644 --- a/src/main/java/com/aisi/template/utils/SecurityUtils.java +++ b/src/main/java/com/aisi/template/utils/SecurityUtils.java @@ -6,26 +6,56 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +/** + * Security 工具类 + * 提供获取当前登录用户信息的便捷方法 + * + * 主要功能: + * 1. 获取当前认证对象 + * 2. 获取当前用户名 + * 3. 获取当前用户ID + * + * 使用场景: + * - 在 Service 层获取当前用户信息 + * - 在拦截器中获取当前用户信息 + * - 在日志中记录操作用户 + * + * @author Claude + * @since 2024-04-09 + */ @Component public class SecurityUtils { /** - * 获取当前 Authentication + * 获取当前 Authentication 对象 + * 说明: + * - Authentication 包含认证信息和用户详情 + * - 如果用户未认证,返回 null + * + * @return 当前认证对象 */ - public static Authentication getAuthentication(){ + public static Authentication getAuthentication() { return SecurityContextHolder.getContext().getAuthentication(); } /** - * 获取登录名 + * 获取当前登录用户的用户名 + * 步骤: + * 1. 获取当前认证对象 + * 2. 检查用户是否已认证 + * 3. 从 Principal 中提取用户名 + * + * @return 用户名,未登录返回 null */ - public static String getUsername(){ + public static String getUsername() { + // 1. 获取当前认证对象 Authentication auth = getAuthentication(); if (auth != null && auth.isAuthenticated()) { Object principal = auth.getPrincipal(); + // 2. 判断 Principal 类型并提取用户名 if (principal instanceof CustomUserDetails user) { - return user.getUsername(); - }else if (principal instanceof String username) { + return user.getUsername(); + } else if (principal instanceof String username) { return username; } } @@ -33,14 +63,21 @@ public class SecurityUtils { } /** - * 获取用户Id + * 获取当前登录用户的用户ID + * 步骤: + * 1. 获取当前认证对象 + * 2. 检查用户是否已认证 + * 3. 从 CustomUserDetails 中提取用户ID + * + * @return 用户ID,未登录返回 null */ public static Long getUserId() { + // 1. 获取当前认证对象 Authentication auth = getAuthentication(); + // 2. 检查是否已认证且 Principal 类型为 CustomUserDetails if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomUserDetails user) { return user.getId(); } return null; } - } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 19f5936..980386f 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -10,12 +10,17 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: none show-sql: false properties: hibernate: 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: redis: host: ${REDIS_HOST:localhost} @@ -23,6 +28,8 @@ spring: password: ${REDIS_PASSWORD:} database: ${REDIS_DB:0} timeout: 5000ms + repositories: + enabled: false lettuce: pool: max-active: 8 @@ -44,12 +51,19 @@ spring: jwt: 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: password-reset: code-expire-minutes: ${PASSWORD_RESET_EXPIRE_MINUTES:10} request-cooldown-seconds: ${PASSWORD_RESET_COOLDOWN_SECONDS:60} 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: level: diff --git a/src/main/resources/application-rocketmq.yaml b/src/main/resources/application-rocketmq.yaml new file mode 100644 index 0000000..7b61a82 --- /dev/null +++ b/src/main/resources/application-rocketmq.yaml @@ -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 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a59ea95..90f3a98 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -5,6 +5,11 @@ spring: active: dev config: import: optional:file:.env[.properties] + # 默认排除 RocketMQ 自动配置,启用时通过 profile 激活 + # 启用方式:spring.profiles.active=dev,rocketmq + autoconfigure: + exclude: + - org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration server: port: 8080 diff --git a/src/main/resources/db/migration/V1__baseline_schema.sql b/src/main/resources/db/migration/V1__baseline_schema.sql new file mode 100644 index 0000000..75382cd --- /dev/null +++ b/src/main/resources/db/migration/V1__baseline_schema.sql @@ -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'; diff --git a/src/main/resources/db/migration/V2__example_auto_migration.sql b/src/main/resources/db/migration/V2__example_auto_migration.sql new file mode 100644 index 0000000..b789da3 --- /dev/null +++ b/src/main/resources/db/migration/V2__example_auto_migration.sql @@ -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'); diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql deleted file mode 100644 index c97ca8c..0000000 --- a/src/main/resources/sql/init.sql +++ /dev/null @@ -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;