From 3a9bf61839cbf0961450e294dfaa8b6321287408 Mon Sep 17 00:00:00 2001 From: shenjianZ Date: Tue, 31 Mar 2026 08:54:06 +0800 Subject: [PATCH] =?UTF-8?q?=20=20feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Spr?= =?UTF-8?q?ing=20Boot=20=E9=A1=B9=E7=9B=AE=E6=A8=A1=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E6=90=AD=E5=BB=BA=E5=AE=8C=E6=95=B4=E7=9A=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E4=B8=8E=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增项目基础配置:pom.xml 依赖管理、多环境配置(dev/prod)、Dockerfile、.env.example - 新增安全认证模块:JWT 工具类、JWT 过滤器、Spring Security 配置、自定义 UserDetails - 新增用户管理功能:注册/登录/查询/修改、角色管理(USER/ADMIN/ROOT)、分页查询、状态启禁用 - 新增密码重置功能:邮箱验证码发送、验证码校验重置、频率限制与过期机制 - 新增基础架构层:统一响应体 RestBean、全局异常处理、日志拦截器、Redis 工具类、JPA 配置 - 新增 Swagger/OpenAPI 文档配置与完整的 API 接口文档(API_DOCUMENT.md) - 新增数据库初始化 SQL 脚本(init.sql) --- .env.example | 27 ++ .gitignore | 34 ++ API_DOCUMENT.md | 312 ++++++++++++++++ Dockerfile | 18 + README.md | 159 +++++++++ pom.xml | 130 +++++++ .../aisi/template/TemplateApplication.java | 13 + .../aisi/template/config/JacksonConfig.java | 34 ++ .../com/aisi/template/config/JpaConfig.java | 12 + .../aisi/template/config/OpenApiConfig.java | 15 + .../com/aisi/template/config/RedisConfig.java | 48 +++ .../aisi/template/config/SecurityConfig.java | 66 ++++ .../com/aisi/template/config/WebConfig.java | 22 ++ .../template/controller/UserController.java | 87 +++++ .../template/domain/CustomUserDetails.java | 63 ++++ .../com/aisi/template/domain/RestBean.java | 118 ++++++ .../com/aisi/template/domain/RestCode.java | 44 +++ .../aisi/template/domain/dto/PageResult.java | 59 +++ .../domain/dto/PasswordResetConfirmDto.java | 28 ++ .../domain/dto/PasswordResetRequestDto.java | 16 + .../com/aisi/template/domain/dto/UserDto.java | 24 ++ .../template/domain/dto/UserQueryDto.java | 24 ++ .../domain/dto/UserRoleUpdateDto.java | 14 + .../domain/dto/UserStatusUpdateDto.java | 18 + .../domain/entity/PasswordResetCode.java | 47 +++ .../com/aisi/template/domain/entity/User.java | 59 +++ .../aisi/template/domain/enums/ErrorCode.java | 24 ++ .../com/aisi/template/domain/enums/Role.java | 49 +++ .../com/aisi/template/domain/vo/UserVo.java | 35 ++ .../template/exception/BusinessException.java | 39 ++ .../filter/JwtAuthenticationFilter.java | 67 ++++ .../handler/GlobalExceptionHandler.java | 156 ++++++++ .../interceptor/LoggingInterceptor.java | 61 ++++ .../PasswordResetCodeRepository.java | 17 + .../template/repository/UserRepository.java | 42 +++ .../aisi/template/service/EmailService.java | 6 + .../service/PasswordResetService.java | 12 + .../aisi/template/service/UserService.java | 24 ++ .../impl/CustomUserDetailsService.java | 38 ++ .../service/impl/EmailServiceImpl.java | 41 +++ .../impl/PasswordResetServiceImpl.java | 171 +++++++++ .../service/impl/UserServiceImpl.java | 224 ++++++++++++ .../java/com/aisi/template/utils/JwtUtil.java | 57 +++ .../com/aisi/template/utils/RedisUtils.java | 337 ++++++++++++++++++ .../aisi/template/utils/SecurityUtils.java | 46 +++ src/main/resources/application-dev.yaml | 58 +++ src/main/resources/application-prod.yaml | 46 +++ src/main/resources/application.yaml | 10 + src/main/resources/sql/init.sql | 35 ++ .../template/TemplateApplicationTests.java | 12 + 50 files changed, 3098 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 API_DOCUMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/aisi/template/TemplateApplication.java create mode 100644 src/main/java/com/aisi/template/config/JacksonConfig.java create mode 100644 src/main/java/com/aisi/template/config/JpaConfig.java create mode 100644 src/main/java/com/aisi/template/config/OpenApiConfig.java create mode 100644 src/main/java/com/aisi/template/config/RedisConfig.java create mode 100644 src/main/java/com/aisi/template/config/SecurityConfig.java create mode 100644 src/main/java/com/aisi/template/config/WebConfig.java create mode 100644 src/main/java/com/aisi/template/controller/UserController.java create mode 100644 src/main/java/com/aisi/template/domain/CustomUserDetails.java create mode 100644 src/main/java/com/aisi/template/domain/RestBean.java create mode 100644 src/main/java/com/aisi/template/domain/RestCode.java create mode 100644 src/main/java/com/aisi/template/domain/dto/PageResult.java create mode 100644 src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/UserDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/UserQueryDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.java create mode 100644 src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java create mode 100644 src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java create mode 100644 src/main/java/com/aisi/template/domain/entity/User.java create mode 100644 src/main/java/com/aisi/template/domain/enums/ErrorCode.java create mode 100644 src/main/java/com/aisi/template/domain/enums/Role.java create mode 100644 src/main/java/com/aisi/template/domain/vo/UserVo.java create mode 100644 src/main/java/com/aisi/template/exception/BusinessException.java create mode 100644 src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java create mode 100644 src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java create mode 100644 src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java create mode 100644 src/main/java/com/aisi/template/repository/UserRepository.java create mode 100644 src/main/java/com/aisi/template/service/EmailService.java create mode 100644 src/main/java/com/aisi/template/service/PasswordResetService.java create mode 100644 src/main/java/com/aisi/template/service/UserService.java create mode 100644 src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java create mode 100644 src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java create mode 100644 src/main/java/com/aisi/template/service/impl/UserServiceImpl.java create mode 100644 src/main/java/com/aisi/template/utils/JwtUtil.java create mode 100644 src/main/java/com/aisi/template/utils/RedisUtils.java create mode 100644 src/main/java/com/aisi/template/utils/SecurityUtils.java create mode 100644 src/main/resources/application-dev.yaml create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/sql/init.sql create mode 100644 src/test/java/com/aisi/template/TemplateApplicationTests.java diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dcacf89 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=template +DB_USER=root +DB_PASS=your_mysql_password + +# JWT +JWT_SECRET=replace_with_a_random_secret_at_least_64_characters_long_for_HS512 + +# Mail +MAIL_HOST=smtp.163.com +MAIL_PORT=465 +MAIL_PROTOCOL=smtps +MAIL_USERNAME=your_account@163.com +MAIL_PASSWORD=your_smtp_auth_code + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Password Reset +PASSWORD_RESET_EXPIRE_MINUTES=10 +PASSWORD_RESET_COOLDOWN_SECONDS=60 +PASSWORD_RESET_MAX_ATTEMPTS=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26fdcde --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +.env diff --git a/API_DOCUMENT.md b/API_DOCUMENT.md new file mode 100644 index 0000000..ac83cc9 --- /dev/null +++ b/API_DOCUMENT.md @@ -0,0 +1,312 @@ +# API 接口文档 + +Base URL: `http://localhost:8080/api/v1` + +在线文档: http://localhost:8080/swagger-ui/index.html + +## 通用说明 + +### 响应格式 + +所有接口统一返回 `RestBean`: + +```json +{ + "status": 200, + "message": "success", + "data": {} +} +``` + +### 认证方式 + +需要认证的接口在请求头中携带 JWT Token: + +``` +Authorization: Bearer +``` + +登录/注册接口会返回 Token。 + +--- + +## 用户接口 + +### 注册 + +`POST /api/v1/user/register` + +**公开接口**,无需认证。 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | String | 是 | 用户名,2-50 位 | +| password | String | 是 | 密码,6-64 位 | +| email | String | 否 | 邮箱 | + +请求示例: + +```json +{ + "username": "testuser", + "password": "123456", + "email": "test@example.com" +} +``` + +响应示例: + +```json +{ + "status": 200, + "message": "success", + "data": "eyJhbGciOiJIUzUxMiJ9..." +} +``` + +--- + +### 登录 + +`POST /api/v1/user/login` + +**公开接口**,无需认证。 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| username | String | 是 | 用户名 | +| password | String | 是 | 密码 | + +请求示例: + +```json +{ + "username": "testuser", + "password": "123456" +} +``` + +响应示例: + +```json +{ + "status": 200, + "message": "success", + "data": "eyJhbGciOiJIUzUxMiJ9..." +} +``` + +--- + +### 获取用户信息 + +`GET /api/v1/user/info` + +**需要认证**。 + +请求头: + +``` +Authorization: Bearer +``` + +响应示例: + +```json +{ + "status": 200, + "message": "success", + "data": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "status": 1, + "role": "USER", + "createdAt": "2026-03-31 08:00:00", + "updatedAt": "2026-03-31 08:00:00" + } +} +``` + +--- + +### 发送密码找回验证码 + +`POST /api/v1/user/password-reset/request` + +**公开接口**,无需认证。 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| email | String | 是 | 已注册的邮箱 | + +请求示例: + +```json +{ + "email": "test@example.com" +} +``` + +频率限制: +- 两次请求间隔至少 60 秒 +- 同一邮箱 1 小时内最多 5 次 + +--- + +### 验证码重置密码 + +`POST /api/v1/user/password-reset/confirm` + +**公开接口**,无需认证。 + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| email | String | 是 | 邮箱 | +| code | String | 是 | 6 位数字验证码 | +| newPassword | String | 是 | 新密码,6-64 位 | + +请求示例: + +```json +{ + "email": "test@example.com", + "code": "123456", + "newPassword": "newpass123" +} +``` + +验证码限制: +- 有效期 10 分钟 +- 最多尝试 5 次 + +--- + +### 用户列表(管理员) + +`GET /api/v1/user/list` + +**需要认证 + ADMIN 角色**。 + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | Integer | 否 | 页码,默认 1 | +| size | Integer | 否 | 每页条数,默认 10 | +| keyword | String | 否 | 搜索关键词(用户名/邮箱) | +| role | String | 否 | 角色筛选(USER/ADMIN) | +| status | Integer | 否 | 状态筛选(1=正常 0=禁用) | + +响应示例: + +```json +{ + "status": 200, + "message": "success", + "data": { + "records": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "status": 1, + "role": "ADMIN", + "createdAt": "2026-03-31 08:00:00", + "updatedAt": "2026-03-31 08:00:00" + } + ], + "total": 1, + "page": 1, + "size": 10 + } +} +``` + +--- + +### 更新用户状态(管理员) + +`PUT /api/v1/user/{userId}/status` + +**需要认证 + ADMIN 角色**。 + +路径参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| userId | Long | 用户 ID | + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| status | Integer | 是 | 1=启用,0=禁用 | + +请求示例: + +```json +{ + "status": 0 +} +``` + +> 不允许修改自己的状态。 + +--- + +### 更新用户角色(管理员) + +`PUT /api/v1/user/{userId}/role` + +**需要认证 + ADMIN 角色**。 + +路径参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| userId | Long | 用户 ID | + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| role | String | 是 | 角色名称:USER 或 ADMIN | + +请求示例: + +```json +{ + "role": "ADMIN" +} +``` + +> 不允许修改自己的角色。 + +--- + +## 错误码 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 400 | 参数错误 / 请求格式错误 | +| 403 | 权限不足 / 账号被禁用 | +| 404 | 数据不存在 | +| 500 | 系统异常 | + +业务错误码: + +| 错误码 | 说明 | +|--------|------| +| 1001 | 用户不存在 | +| 1002 | 密码错误 | +| 1003 | 账号已被锁定 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..791497e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM maven:3.9.9-eclipse-temurin-17 AS builder + +WORKDIR /workspace + +COPY pom.xml ./ +COPY src ./src + +RUN mvn -B -DskipTests package + +FROM eclipse-temurin:17-jre + +WORKDIR /app + +COPY --from=builder /workspace/target/*.jar /app/app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..878c0f6 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Spring Boot Template + +基于 Spring Boot 3.5 的通用后端模板项目,开箱即用的认证体系和基础设施,快速启动新业务项目。 + +## 内置功能 + +| 模块 | 说明 | +|------|------| +| **JWT 认证** | 注册、登录、Token 验证 | +| **用户管理** | 用户 CRUD、角色(USER/ADMIN)、状态管理 | +| **密码找回** | 邮箱验证码方式重置密码 | +| **Spring Security** | 方法级权限控制、CORS、路由权限 | +| **Redis** | 完整的工具类(String/Hash/List/Set/ZSet/Bitmap/HyperLogLog) | +| **全局异常处理** | 统一响应格式、参数校验、业务异常、系统异常 | +| **JPA 审计** | 自动填充 createdAt / updatedAt | +| **日志拦截** | 请求 ID、用户信息、耗时统计 | +| **Swagger UI** | 在线 API 文档 + 调试(Bearer Token 认证) | +| **多环境配置** | dev / prod 配置分离,支持 .env 环境变量 | + +## 技术栈 + +- Java 17 + Spring Boot 3.5.6 +- Spring Data JPA + MySQL +- Spring Data Redis + Lettuce +- Spring Security + JWT (jjwt) +- Springdoc OpenAPI (Swagger) +- Lombok +- Maven + +## 快速开始 + +### 1. 环境准备 + +- JDK 17+ +- MySQL 8.0+ +- Redis 6.0+ +- Maven 3.9+ + +### 2. 克隆并配置 + +```bash +# 复制项目 +cp -r springboot-template your-project +cd your-project + +# 修改包名(按需) +# com.aisi.template -> com.yourcompany.project + +# 配置环境变量 +cp .env.example .env +# 编辑 .env,填入实际的数据库密码、JWT 密钥等 +``` + +### 3. 初始化数据库 + +```sql +CREATE DATABASE IF NOT EXISTS template DEFAULT CHARSET utf8mb4; +``` + +执行 `src/main/resources/sql/init.sql`,或依赖 JPA `ddl-auto: update` 自动建表。 + +### 4. 启动 + +```bash +mvn spring-boot:run +``` + +启动后访问: +- Swagger UI: http://localhost:8080/swagger-ui/index.html +- OpenAPI JSON: http://localhost:8080/v3/api-docs + +## 项目结构 + +``` +src/main/java/com/aisi/template/ +├── TemplateApplication.java # 入口 +├── config/ +│ ├── SecurityConfig.java # Security + CORS + JWT 过滤器 +│ ├── JacksonConfig.java # JSON 日期格式 +│ ├── JpaConfig.java # JPA 审计 +│ ├── RedisConfig.java # Redis 序列化配置 +│ ├── WebConfig.java # 日志拦截器注册 +│ └── OpenApiConfig.java # Swagger Bearer Token +├── controller/ +│ └── UserController.java # 用户相关接口 +├── domain/ +│ ├── RestBean.java # 统一响应包装 +│ ├── RestCode.java # 响应状态码 +│ ├── CustomUserDetails.java # Spring Security UserDetails +│ ├── dto/ # 请求 DTO +│ ├── vo/ # 响应 VO +│ ├── entity/ # JPA 实体 +│ └── enums/ # 枚举(Role, ErrorCode) +├── exception/ +│ └── BusinessException.java # 业务异常 +├── filter/ +│ └── JwtAuthenticationFilter.java # JWT 认证过滤器 +├── handler/ +│ └── GlobalExceptionHandler.java # 全局异常处理 +├── interceptor/ +│ └── LoggingInterceptor.java # 请求日志 +├── repository/ # Spring Data JPA 仓库 +├── service/ # 业务接口 + 实现 +│ └── impl/ +└── utils/ + ├── JwtUtil.java # JWT 工具 + ├── SecurityUtils.java # 安全上下文工具 + └── RedisUtils.java # Redis 工具(全数据类型) +``` + +## 配置说明 + +通过 `.env` 文件或环境变量配置,关键配置项: + +| 配置项 | 默认值 | 说明 | +|--------|--------|------| +| `DB_HOST` | localhost | MySQL 地址 | +| `DB_PORT` | 3306 | MySQL 端口 | +| `DB_NAME` | template | 数据库名 | +| `DB_USER` | root | 数据库用户 | +| `DB_PASS` | root | 数据库密码 | +| `REDIS_HOST` | localhost | Redis 地址 | +| `REDIS_PORT` | 6379 | Redis 端口 | +| `REDIS_PASSWORD` | 空 | Redis 密码 | +| `JWT_SECRET` | (内置默认值) | JWT 签名密钥,**生产环境必须替换** | +| `MAIL_HOST` | smtp.163.com | 邮件 SMTP 地址 | +| `MAIL_USERNAME` | 空 | 邮件账号 | +| `MAIL_PASSWORD` | 空 | 邮件授权码 | + +## Docker 部署 + +```bash +# 构建镜像 +docker build -t springboot-template . + +# 运行 +docker run -d -p 8080:8080 \ + -e DB_HOST=host.docker.internal \ + -e REDIS_HOST=host.docker.internal \ + -e JWT_SECRET=your_production_secret_at_least_64_chars_long \ + springboot-template +``` + +## 使用模板创建新项目 + +1. 复制整个项目目录 +2. 全局替换 `com.aisi.template` 为你的包名 +3. 修改 `pom.xml` 中的 `artifactId`、`name`、`description` +4. 修改 `application.yaml` 中的 `spring.application.name` +5. 删除不需要的 Controller/Service,添加你的业务代码 +6. 在 `ErrorCode` 枚举中添加业务错误码(预留了 2000-2999 范围) + +## API 文档 + +详见 [API_DOCUMENT.md](API_DOCUMENT.md) + +## License + +Apache License 2.0 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bbd9c97 --- /dev/null +++ b/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + com.aisi + springboot-template + 0.1.0 + springboot-template + Spring Boot Template Project + + 17 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.mysql + mysql-connector-j + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-mail + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.6 + + + + org.springframework.boot + spring-boot-starter-logging + + + + org.hibernate.validator + hibernate-validator + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/aisi/template/TemplateApplication.java b/src/main/java/com/aisi/template/TemplateApplication.java new file mode 100644 index 0000000..6e5d9b1 --- /dev/null +++ b/src/main/java/com/aisi/template/TemplateApplication.java @@ -0,0 +1,13 @@ +package com.aisi.template; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TemplateApplication { + + public static void main(String[] args) { + SpringApplication.run(TemplateApplication.class, args); + System.out.println("Swagger UI: http://localhost:8080/swagger-ui/index.html"); + } +} diff --git a/src/main/java/com/aisi/template/config/JacksonConfig.java b/src/main/java/com/aisi/template/config/JacksonConfig.java new file mode 100644 index 0000000..f8d2890 --- /dev/null +++ b/src/main/java/com/aisi/template/config/JacksonConfig.java @@ -0,0 +1,34 @@ +package com.aisi.template.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + + // 配置日期时间格式 + JavaTimeModule javaTimeModule = new JavaTimeModule(); + + // 定义日期时间格式 + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); + + mapper.registerModule(javaTimeModule); + + // 禁用时间戳格式 + 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 new file mode 100644 index 0000000..4f617cd --- /dev/null +++ b/src/main/java/com/aisi/template/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.aisi.template.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 配置类 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/aisi/template/config/OpenApiConfig.java b/src/main/java/com/aisi/template/config/OpenApiConfig.java new file mode 100644 index 0000000..813576a --- /dev/null +++ b/src/main/java/com/aisi/template/config/OpenApiConfig.java @@ -0,0 +1,15 @@ +package com.aisi.template.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SecurityScheme( + name = "Bearer Authentication", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/aisi/template/config/RedisConfig.java b/src/main/java/com/aisi/template/config/RedisConfig.java new file mode 100644 index 0000000..9f7445c --- /dev/null +++ b/src/main/java/com/aisi/template/config/RedisConfig.java @@ -0,0 +1,48 @@ +package com.aisi.template.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory, ObjectMapper objectMapper) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + ObjectMapper redisMapper = objectMapper.copy(); + redisMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + redisMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisMapper); + + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + template.afterPropertiesSet(); + + return template; + } + + @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 new file mode 100644 index 0000000..9c3d8e0 --- /dev/null +++ b/src/main/java/com/aisi/template/config/SecurityConfig.java @@ -0,0 +1,66 @@ +package com.aisi.template.config; + +import com.aisi.template.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll() + .requestMatchers("/api/v1/user/password-reset/**").permitAll() + .requestMatchers("/api/v1/user/info").authenticated() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setExposedHeaders(List.of("*")); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @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 new file mode 100644 index 0000000..cea7ae7 --- /dev/null +++ b/src/main/java/com/aisi/template/config/WebConfig.java @@ -0,0 +1,22 @@ +package com.aisi.template.config; + +import com.aisi.template.interceptor.LoggingInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final LoggingInterceptor loggingInterceptor; + + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(loggingInterceptor) + .addPathPatterns("/**") + .excludePathPatterns(); + } +} diff --git a/src/main/java/com/aisi/template/controller/UserController.java b/src/main/java/com/aisi/template/controller/UserController.java new file mode 100644 index 0000000..541a4a8 --- /dev/null +++ b/src/main/java/com/aisi/template/controller/UserController.java @@ -0,0 +1,87 @@ +package com.aisi.template.controller; + +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.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.UserVo; +import com.aisi.template.service.PasswordResetService; +import com.aisi.template.service.UserService; +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.*; + +@RestController +@RequestMapping("/api/v1/user") +@RequiredArgsConstructor +@Tag(name = "用户接口") +public class UserController { + + private final UserService userService; + private final PasswordResetService passwordResetService; + + @GetMapping("info") + @Operation(summary = "用户信息") + @SecurityRequirement(name = "Bearer Authentication") + public RestBean getUserInfo() { + return userService.getUserInfo(); + } + + @PostMapping("register") + @Operation(summary = "用户注册") + public RestBean register(@Valid @RequestBody UserDto userDto) { + return userService.register(userDto); + } + + @PostMapping("login") + @Operation(summary = "用户登录") + RestBean login(@Valid @RequestBody UserDto userDto) { + return userService.login(userDto); + } + + @PostMapping("password-reset/request") + @Operation(summary = "发送找回密码验证码") + public RestBean sendPasswordResetCode(@Valid @RequestBody PasswordResetRequestDto requestDto) { + return passwordResetService.sendResetCode(requestDto); + } + + @PostMapping("password-reset/confirm") + @Operation(summary = "验证码重置密码") + public RestBean confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmDto confirmDto) { + return passwordResetService.resetPassword(confirmDto); + } + + @GetMapping("list") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "分页获取用户列表") + @SecurityRequirement(name = "Bearer Authentication") + public RestBean> getUserList(UserQueryDto queryDto) { + return userService.getUserList(queryDto); + } + + @PutMapping("{userId}/status") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "更新用户状态") + @SecurityRequirement(name = "Bearer Authentication") + public RestBean updateUserStatus(@PathVariable Long userId, + @Valid @RequestBody UserStatusUpdateDto updateDto) { + return userService.updateUserStatus(userId, updateDto); + } + + @PutMapping("{userId}/role") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "更新用户角色") + @SecurityRequirement(name = "Bearer Authentication") + public RestBean updateUserRole(@PathVariable Long userId, + @Valid @RequestBody UserRoleUpdateDto updateDto) { + return userService.updateUserRole(userId, updateDto); + } +} diff --git a/src/main/java/com/aisi/template/domain/CustomUserDetails.java b/src/main/java/com/aisi/template/domain/CustomUserDetails.java new file mode 100644 index 0000000..dfc3027 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/CustomUserDetails.java @@ -0,0 +1,63 @@ +package com.aisi.template.domain; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class CustomUserDetails implements UserDetails { + + @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) { + this.id = id; + this.username = username; + this.password = password; + this.authorities = authorities; + this.enabled = enabled; + this.role = role; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @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 new file mode 100644 index 0000000..8e8f948 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/RestBean.java @@ -0,0 +1,118 @@ +package com.aisi.template.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 通用的 REST API 响应封装类 + * + * @param 返回数据的类型 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RestBean { + + /** 状态码 */ + private int code; + /** 提示消息 */ + private String message; + /** 具体数据 */ + private V data; + + /** + * 成功响应(默认使用 {@link RestCode#SUCCESS}) + * + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean success(V data) { + return success(RestCode.SUCCESS, data); + } + + /** + * 成功响应(指定 RestCode 和数据) + * + * @param restCode 状态码枚举 + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean success(RestCode restCode, V data) { + return success(restCode.getCode(), restCode.getMessage(), data); + } + + /** + * 成功响应(只返回状态码和消息,不带数据) + * + * @param restCode 状态码枚举 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean success(RestCode restCode) { + return success(restCode, null); + } + + /** + * 成功响应(自定义 code 和 message) + * + * @param code 状态码 + * @param message 提示消息 + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean success(Integer code, String message, V data) { + return new RestBean<>(code, message, data); + } + + /** + * 失败响应(默认使用 {@link RestCode#FAILURE}) + * + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean failure(V data) { + return new RestBean<>(RestCode.FAILURE.getCode(), RestCode.FAILURE.getMessage(), data); + } + + /** + * 失败响应(指定 RestCode 和数据) + * + * @param restCode 状态码枚举 + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean failure(RestCode restCode, V data) { + return new RestBean<>(restCode.getCode(), restCode.getMessage(), data); + } + + /** + * 失败响应(只返回状态码和消息,不带数据) + * + * @param restCode 状态码枚举 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean failure(RestCode restCode) { + return new RestBean<>(restCode.getCode(), restCode.getMessage(), null); + } + + /** + * 失败响应(自定义 code 和 message) + * + * @param code 状态码 + * @param message 提示消息 + * @param data 返回的数据 + * @param 泛型参数 + * @return RestBean 包装对象 + */ + public static RestBean failure(int code, String message, V data) { + return new RestBean<>(code, message, data); + } + +} diff --git a/src/main/java/com/aisi/template/domain/RestCode.java b/src/main/java/com/aisi/template/domain/RestCode.java new file mode 100644 index 0000000..dd6e663 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/RestCode.java @@ -0,0 +1,44 @@ +package com.aisi.template.domain; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.ToString; + +import java.util.HashMap; +import java.util.Map; + +@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,"不支持该请求方法"); + + private final int code; + private final String message; + private final Map json; // 预先创建的不可变 Map + RestCode(Integer code, String message) { + this.code = code; + this.message = message; + HashMap map = new HashMap<>(); + map.put("code", code); + map.put("message", message); + this.json = map; + } + + @JsonValue + public Map toJson(){ + return json; + } + +} diff --git a/src/main/java/com/aisi/template/domain/dto/PageResult.java b/src/main/java/com/aisi/template/domain/dto/PageResult.java new file mode 100644 index 0000000..f6ce373 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/PageResult.java @@ -0,0 +1,59 @@ +package com.aisi.template.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 分页结果封装类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "分页结果") +public class PageResult { + + @Schema(description = "数据列表") + private List records; + + @Schema(description = "总记录数") + private Long total; + + @Schema(description = "当前页") + private Integer page; + + @Schema(description = "每页大小") + private Integer size; + + @Schema(description = "总页数") + private Integer pages; + + /** + * 创建分页结果 + */ + public static PageResult of(List records, Long total, Integer page, Integer size) { + return new PageResult<>( + records, + total, + page, + size, + (int) Math.ceil((double) total / size) + ); + } + + /** + * 创建空的分页结果 + */ + public static PageResult empty(Integer page, Integer size) { + return new PageResult<>( + List.of(), + 0L, + page, + size, + 0 + ); + } +} diff --git a/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java b/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java new file mode 100644 index 0000000..d8354bb --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/PasswordResetConfirmDto.java @@ -0,0 +1,28 @@ +package com.aisi.template.domain.dto; + +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; + +@Data +@Schema(description = "重置密码确认请求") +public class PasswordResetConfirmDto { + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱") + private String email; + + @NotBlank(message = "验证码不能为空") + @Pattern(regexp = "\\d{6}", message = "验证码必须为 6 位数字") + @Schema(description = "6位验证码") + private String code; + + @NotBlank(message = "新密码不能为空") + @Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") + @Schema(description = "新密码") + 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 new file mode 100644 index 0000000..393d398 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/PasswordResetRequestDto.java @@ -0,0 +1,16 @@ +package com.aisi.template.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "找回密码请求") +public class PasswordResetRequestDto { + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱") + private String email; +} diff --git a/src/main/java/com/aisi/template/domain/dto/UserDto.java b/src/main/java/com/aisi/template/domain/dto/UserDto.java new file mode 100644 index 0000000..bbf1f67 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/UserDto.java @@ -0,0 +1,24 @@ +package com.aisi.template.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "用户注册/登录请求") +public class UserDto { + + @NotBlank(message = "用户名不能为空") + @Size(min = 2, max = 50, message = "用户名长度必须在 2-50 位之间") + @Schema(description = "用户名", example = "admin") + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 64, message = "密码长度必须在 6-64 位之间") + @Schema(description = "密码", example = "123456") + private String password; + + @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 new file mode 100644 index 0000000..d84d240 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/UserQueryDto.java @@ -0,0 +1,24 @@ +package com.aisi.template.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "用户查询参数") +public class UserQueryDto { + + @Schema(description = "页码", example = "1") + private Integer page = 1; + + @Schema(description = "每页大小", example = "10") + private Integer size = 10; + + @Schema(description = "关键词(用户名/邮箱)") + private String keyword; + + @Schema(description = "角色(USER/ADMIN)") + private String role; + + @Schema(description = "状态(1=正常 0=禁用)") + 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 new file mode 100644 index 0000000..e1d5109 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/UserRoleUpdateDto.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 UserRoleUpdateDto { + + @NotBlank(message = "角色不能为空") + @Schema(description = "角色(USER/ADMIN)", example = "ADMIN") + private String role; +} diff --git a/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java b/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java new file mode 100644 index 0000000..c20c837 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/dto/UserStatusUpdateDto.java @@ -0,0 +1,18 @@ +package com.aisi.template.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "用户状态更新请求") +public class UserStatusUpdateDto { + + @NotNull(message = "状态不能为空") + @Min(value = 0, message = "状态只能为 0 或 1") + @Max(value = 1, message = "状态只能为 0 或 1") + @Schema(description = "状态(1=正常 0=禁用)", example = "1") + private Integer status; +} diff --git a/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java b/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java new file mode 100644 index 0000000..13a50f0 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/PasswordResetCode.java @@ -0,0 +1,47 @@ +package com.aisi.template.domain.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "password_reset_codes", indexes = { + @Index(name = "idx_password_reset_email", columnList = "email"), + @Index(name = "idx_password_reset_expires_at", columnList = "expires_at") +}) +@Data +public class PasswordResetCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String email; + + @Column(name = "code_hash", nullable = false, length = 64) + private String codeHash; + + @Column(name = "expires_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") + private LocalDateTime expiresAt; + + @Column(name = "used", nullable = false) + private Boolean used = false; + + @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) { + createdAt = 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 new file mode 100644 index 0000000..fc0af4d --- /dev/null +++ b/src/main/java/com/aisi/template/domain/entity/User.java @@ -0,0 +1,59 @@ +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.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; + +@Entity +@Table(name = "users") +@Data +@EntityListeners(AuditingEntityListener.class) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @ToString.Exclude + @JsonIgnore + @Column(nullable = false) + private String password; + + @Column(unique = true) + private String email; + + @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; + + @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") + private LocalDateTime updatedAt; + + /** + * 检查用户是否启用 + */ + public boolean isEnabled() { + return status != null && status == 1; + } +} diff --git a/src/main/java/com/aisi/template/domain/enums/ErrorCode.java b/src/main/java/com/aisi/template/domain/enums/ErrorCode.java new file mode 100644 index 0000000..ae1e723 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/enums/ErrorCode.java @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..0d5c0b9 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/enums/Role.java @@ -0,0 +1,49 @@ +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/UserVo.java b/src/main/java/com/aisi/template/domain/vo/UserVo.java new file mode 100644 index 0000000..e9d5828 --- /dev/null +++ b/src/main/java/com/aisi/template/domain/vo/UserVo.java @@ -0,0 +1,35 @@ +package com.aisi.template.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "用户视图对象") +public class UserVo { + + @Schema(description = "用户ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "状态(1=正常 0=禁用)") + private Integer status; + + @Schema(description = "角色(USER=普通用户,ADMIN=管理员)") + private String role; + + @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/exception/BusinessException.java b/src/main/java/com/aisi/template/exception/BusinessException.java new file mode 100644 index 0000000..46efefb --- /dev/null +++ b/src/main/java/com/aisi/template/exception/BusinessException.java @@ -0,0 +1,39 @@ +package com.aisi.template.exception; +import com.aisi.template.domain.enums.ErrorCode; + +import lombok.Getter; + +/** + * 自定义业务异常 + * 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息 + */ +@Getter // 使用 Lombok 自动生成 getCode() 方法 +public class BusinessException extends RuntimeException { + + // 错误码 (例如 400, 403, 1001 等) + private final int code; + + /** + * 构造方法 1:手动指定 code 和 message + * 使用:throw new BusinessException(404, "找不到该新闻"); + */ + public BusinessException(int code, String message) { + super(message); // 把 message 传给父类,方便 log 打印 + this.code = code; + } + + /** + * 构造方法 2:使用通用错误码 (默认为 400 或 500) + * 使用:throw new BusinessException("操作失败"); + */ + public BusinessException(String message) { + super(message); + this.code = 400; // 默认给个 400 + } + + // 在 BusinessException 类里添加这个构造方法 + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } +} \ No newline at end of file diff --git a/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java b/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..72d10dc --- /dev/null +++ b/src/main/java/com/aisi/template/filter/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.aisi.template.filter; + + +import com.aisi.template.domain.CustomUserDetails; +import com.aisi.template.service.impl.CustomUserDetailsService; +import com.aisi.template.utils.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = extractToken(request); + if (token != null && jwtUtil.validateToken(token)) { + // token 有效 + String username = jwtUtil.extractUsername(token); + if (username != null && !username.isEmpty()) { + CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username); + if (userDetails == null) throw new UsernameNotFoundException("User not found"); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } + } else { + // token 缺失或无效时,不抛异常,直接放行到下一个过滤器 + } + + filterChain.doFilter(request, response); + } + + /** + * 提取出token + * + * @return token + */ + 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); + } +} diff --git a/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java b/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..17c3284 --- /dev/null +++ b/src/main/java/com/aisi/template/handler/GlobalExceptionHandler.java @@ -0,0 +1,156 @@ +package com.aisi.template.handler; + +import com.aisi.template.domain.RestBean; +import com.aisi.template.domain.RestCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authorization.AuthorizationDeniedException; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import com.aisi.template.exception.BusinessException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + // ========================================== + // 1. 客户端请求错误 (400 Bad Request 等) + // 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈 + // ========================================== + + /** + * 1. 参数校验失败异常 (@Valid / @Validated) + * 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求 + */ + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public RestBean handleValidationException(Exception e) { + BindingResult bindingResult = null; + if (e instanceof MethodArgumentNotValidException) { + bindingResult = ((MethodArgumentNotValidException) e).getBindingResult(); + } else if (e instanceof BindException) { + bindingResult = ((BindException) e).getBindingResult(); + } + + // 提取具体的错误信息(例如:"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); + } + + /** + * 2. JSON 格式解析错误 + * 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public RestBean handleJsonParseException(HttpMessageNotReadableException e) { + log.warn("JSON解析失败: {}", e.getMessage()); + return RestBean.failure(RestCode.FAILURE, "请求Body格式错误,请检查JSON语法"); + } + + /** + * 3. 缺少必要的 URL 参数 + * 场景:接口定义了 @RequestParam(required=true) 但前端没传 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public RestBean handleMissingParam(MissingServletRequestParameterException e) { + log.warn("缺少请求参数: {}", e.getParameterName()); + return RestBean.failure(RestCode.FAILURE, "缺少必要参数: " + e.getParameterName()); + } + + /** + * 4. 请求方法不支持 + * 场景:接口只写了 @PostMapping,前端却用 GET 请求访问 + */ + @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()); + } + + /** + * 5. 参数类型不匹配 (刚才你遇到的那个) + */ + @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); + } + + // ========================================== + // 2. 业务逻辑与数据库错误 + // ========================================== + + /** + * 6. 自定义业务异常 (最常用!) + * 场景:你在 Service 层手动抛出 throw new BusinessException(403, "权限不足"); + */ + @ExceptionHandler(BusinessException.class) + public RestBean handleBusinessException(BusinessException e) { + // 业务异常通常是预期内的,记录 WARN 即可 + log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); + return RestBean.failure(e.getCode(), e.getMessage(),""); + } + + /** + * 7. 数据库唯一键冲突 + * 场景:注册时用户名已存在,插入数据库时触发 Unique Constraint + */ + @ExceptionHandler(DuplicateKeyException.class) + public RestBean handleDuplicateKeyException(DuplicateKeyException e) { + log.warn("数据库数据冲突: {}", e.getMessage()); + return RestBean.failure(RestCode.DATA_ALREADY_FOUND, "数据已存在,请勿重复操作"); + } + + // ========================================== + // 3. 致命系统错误 (500) + // 这类错误是 Bug,必须记录堆栈信息 (e),并报警 + // ========================================== + + /** + * 8. 空指针异常 (NullPointerException) + * 场景:代码里没做判空,a.b() 时 a 是 null + */ + @ExceptionHandler(NullPointerException.class) + public RestBean handleNPE(NullPointerException e) { + // 必须打印堆栈! + log.error("发生空指针异常: ", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员"); + } + + /** + * 处理权限拒绝异常 + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public RestBean handleAuthorizationDenied(AuthorizationDeniedException e) { + log.warn("权限拒绝: {}", e.getMessage()); + return RestBean.failure(RestCode.UNAUTHORIZED); + } + + /** + * 处理其他异常 + */ + @ExceptionHandler(Exception.class) + public RestBean handleException(Exception e) { + log.error("系统异常: ", e); + return RestBean.failure(RestCode.SYSTEM_ERROR); + } +} diff --git a/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java b/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java new file mode 100644 index 0000000..2d35ba1 --- /dev/null +++ b/src/main/java/com/aisi/template/interceptor/LoggingInterceptor.java @@ -0,0 +1,61 @@ +package com.aisi.template.interceptor; + +import com.aisi.template.utils.SecurityUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class LoggingInterceptor implements HandlerInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + long startTime = System.currentTimeMillis(); + request.setAttribute("startTime", startTime); + + String requestId = UUID.randomUUID().toString().substring(0, 8); + request.setAttribute("requestId", requestId); + + Long userId = SecurityUtils.getUserId(); + request.setAttribute("userId", userId); + + String username = SecurityUtils.getUsername(); + request.setAttribute("username", username); + + if (isApiRequest(request.getRequestURI())) { + logger.info("[{}] API请求开始 -> method: {}, path: {}, userId: {}, username: {}", + requestId, request.getMethod(), request.getRequestURI(), userId, username); + } + return true; // ✅ 必须返回true才能继续执行请求 + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + Long startTime = (Long) request.getAttribute("startTime"); + if (startTime != null && isApiRequest(request.getRequestURI())) { + long duration = System.currentTimeMillis() - startTime; + 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", + requestId, request.getMethod(), request.getRequestURI(), response.getStatus(), userId, username, duration); + + if (ex != null) { + logger.error("[{}] 请求异常: {}", requestId, ex.getMessage(), ex); + } + } + } + + private boolean isApiRequest(String path) { + return path.startsWith("/api/"); + } +} diff --git a/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java b/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java new file mode 100644 index 0000000..2a1d681 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/PasswordResetCodeRepository.java @@ -0,0 +1,17 @@ +package com.aisi.template.repository; + +import com.aisi.template.domain.entity.PasswordResetCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface PasswordResetCodeRepository extends JpaRepository { + + List findByEmailAndUsedFalse(String email); + + Optional findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(String email); + + long countByEmailAndCreatedAtAfter(String email, LocalDateTime createdAt); +} diff --git a/src/main/java/com/aisi/template/repository/UserRepository.java b/src/main/java/com/aisi/template/repository/UserRepository.java new file mode 100644 index 0000000..3bb5f91 --- /dev/null +++ b/src/main/java/com/aisi/template/repository/UserRepository.java @@ -0,0 +1,42 @@ +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 java.util.Optional; + +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByUsername(String username); + + Optional findByEmail(String email); + + Optional findByEmailIgnoreCase(String email); + + Optional findByUsernameAndPassword(String username, String password); + + Optional findByEmailAndPassword(String email, String password); + + /** + * 根据用户名查找启用的用户 + */ + Optional findByUsernameAndStatus(String username, Integer status); + + /** + * 根据邮箱查找启用的用户 + */ + Optional findByEmailAndStatus(String email, Integer status); + + /** + * 检查用户名是否存在 + */ + boolean existsByUsername(String username); + + /** + * 检查邮箱是否存在 + */ + boolean existsByEmail(String email); + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/src/main/java/com/aisi/template/service/EmailService.java b/src/main/java/com/aisi/template/service/EmailService.java new file mode 100644 index 0000000..50f0f8a --- /dev/null +++ b/src/main/java/com/aisi/template/service/EmailService.java @@ -0,0 +1,6 @@ +package com.aisi.template.service; + +public interface EmailService { + + void sendPasswordResetCode(String email, String code); +} diff --git a/src/main/java/com/aisi/template/service/PasswordResetService.java b/src/main/java/com/aisi/template/service/PasswordResetService.java new file mode 100644 index 0000000..706ae6a --- /dev/null +++ b/src/main/java/com/aisi/template/service/PasswordResetService.java @@ -0,0 +1,12 @@ +package com.aisi.template.service; + +import com.aisi.template.domain.RestBean; +import com.aisi.template.domain.dto.PasswordResetConfirmDto; +import com.aisi.template.domain.dto.PasswordResetRequestDto; + +public interface PasswordResetService { + + RestBean sendResetCode(PasswordResetRequestDto requestDto); + + RestBean resetPassword(PasswordResetConfirmDto confirmDto); +} diff --git a/src/main/java/com/aisi/template/service/UserService.java b/src/main/java/com/aisi/template/service/UserService.java new file mode 100644 index 0000000..d01907e --- /dev/null +++ b/src/main/java/com/aisi/template/service/UserService.java @@ -0,0 +1,24 @@ +package com.aisi.template.service; + +import com.aisi.template.domain.RestBean; +import com.aisi.template.domain.dto.PageResult; +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.UserVo; + +public interface UserService { + + RestBean getUserInfo(); + + RestBean register(UserDto userDto); + + RestBean login(UserDto userDto); + + RestBean> getUserList(UserQueryDto queryDto); + + RestBean updateUserStatus(Long userId, UserStatusUpdateDto updateDto); + + RestBean updateUserRole(Long userId, UserRoleUpdateDto updateDto); +} diff --git a/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java b/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java new file mode 100644 index 0000000..5b541db --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/CustomUserDetailsService.java @@ -0,0 +1,38 @@ +package com.aisi.template.service.impl; + +import com.aisi.template.domain.CustomUserDetails; +import com.aisi.template.domain.entity.User; +import com.aisi.template.repository.UserRepository; +import lombok.RequiredArgsConstructor; +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.List; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); + + // 检查用户状态 + if (!user.isEnabled()) { + throw new UsernameNotFoundException("用户已被禁用: " + username); + } + + return new CustomUserDetails( + user.getId(), + user.getUsername(), + user.getPassword(), + List.of(() -> user.getRole().getAuthority()), + user.isEnabled(), + user.getRole().name() + ); + } +} diff --git a/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java b/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..3d32dfb --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/EmailServiceImpl.java @@ -0,0 +1,41 @@ +package com.aisi.template.service.impl; + +import com.aisi.template.service.EmailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailServiceImpl implements EmailService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String from; + + @Value("${app.password-reset.code-expire-minutes:10}") + private Integer expireMinutes; + + @Override + public void sendPasswordResetCode(String email, String code) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(email); + message.setSubject("密码找回验证码"); + message.setText(""" + 您正在进行密码找回操作。 + + 验证码:%s + 有效期:%d 分钟 + + 如果这不是您的操作,请忽略此邮件。 + """.formatted(code, expireMinutes)); + mailSender.send(message); + log.info("已发送密码找回验证码到邮箱: {}", email); + } +} diff --git a/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java b/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java new file mode 100644 index 0000000..de83323 --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/PasswordResetServiceImpl.java @@ -0,0 +1,171 @@ +package com.aisi.template.service.impl; + +import com.aisi.template.domain.RestBean; +import com.aisi.template.domain.RestCode; +import com.aisi.template.domain.dto.PasswordResetConfirmDto; +import com.aisi.template.domain.dto.PasswordResetRequestDto; +import com.aisi.template.domain.entity.PasswordResetCode; +import com.aisi.template.domain.entity.User; +import com.aisi.template.repository.PasswordResetCodeRepository; +import com.aisi.template.repository.UserRepository; +import com.aisi.template.service.EmailService; +import com.aisi.template.service.PasswordResetService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.security.SecureRandom; + +@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; + + @Value("${app.password-reset.code-expire-minutes:10}") + private Integer expireMinutes; + + @Value("${app.password-reset.request-cooldown-seconds:60}") + private Integer cooldownSeconds; + + @Value("${app.password-reset.max-attempts:5}") + private Integer maxAttempts; + + @Override + @Transactional + public RestBean sendResetCode(PasswordResetRequestDto requestDto) { + String email = requestDto.getEmail().trim().toLowerCase(); + + Optional userOptional = userRepository.findByEmailIgnoreCase(email); + if (userOptional.isEmpty()) { + return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null); + } + + Optional latestCode = passwordResetCodeRepository + .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email); + if (latestCode.isPresent()) { + LocalDateTime nextAllowedAt = latestCode.get().getCreatedAt().plusSeconds(cooldownSeconds); + if (nextAllowedAt.isAfter(LocalDateTime.now())) { + long seconds = java.time.Duration.between(LocalDateTime.now(), nextAllowedAt).getSeconds(); + return RestBean.failure(429, "请求过于频繁,请 " + Math.max(seconds, 1) + " 秒后重试", null); + } + } + + long recentCount = passwordResetCodeRepository.countByEmailAndCreatedAtAfter(email, LocalDateTime.now().minusHours(1)); + if (recentCount >= 5) { + return RestBean.failure(429, "该邮箱在 1 小时内请求次数过多,请稍后再试", null); + } + + List activeCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); + for (PasswordResetCode item : activeCodes) { + item.setUsed(true); + } + passwordResetCodeRepository.saveAll(activeCodes); + + String code = generateCode(); + PasswordResetCode resetCode = new PasswordResetCode(); + resetCode.setEmail(email); + resetCode.setCodeHash(sha256(code)); + resetCode.setExpiresAt(LocalDateTime.now().plusMinutes(expireMinutes)); + resetCode.setUsed(false); + resetCode.setAttemptCount(0); + passwordResetCodeRepository.save(resetCode); + + emailService.sendPasswordResetCode(email, code); + return RestBean.success(200, "如果邮箱已注册,验证码将发送至该邮箱", null); + } + + @Override + @Transactional + public RestBean resetPassword(PasswordResetConfirmDto confirmDto) { + String email = confirmDto.getEmail().trim().toLowerCase(); + Optional userOptional = userRepository.findByEmailIgnoreCase(email); + if (userOptional.isEmpty()) { + return RestBean.failure(400, "验证码或邮箱不正确", null); + } + + PasswordResetCode resetCode = passwordResetCodeRepository + .findFirstByEmailAndUsedFalseOrderByCreatedAtDesc(email) + .orElse(null); + if (resetCode == null) { + return RestBean.failure(400, "请先获取验证码", null); + } + + if (Boolean.TRUE.equals(resetCode.getUsed())) { + return RestBean.failure(400, "验证码已失效,请重新获取", null); + } + + if (resetCode.getExpiresAt().isBefore(LocalDateTime.now())) { + resetCode.setUsed(true); + passwordResetCodeRepository.save(resetCode); + return RestBean.failure(400, "验证码已过期,请重新获取", null); + } + + if (resetCode.getAttemptCount() >= maxAttempts) { + resetCode.setUsed(true); + passwordResetCodeRepository.save(resetCode); + return RestBean.failure(400, "验证码尝试次数过多,请重新获取", null); + } + + if (!sha256(confirmDto.getCode()).equals(resetCode.getCodeHash())) { + resetCode.setAttemptCount(resetCode.getAttemptCount() + 1); + if (resetCode.getAttemptCount() >= maxAttempts) { + resetCode.setUsed(true); + } + passwordResetCodeRepository.save(resetCode); + return RestBean.failure(400, "验证码不正确", null); + } + + User user = userOptional.get(); + user.setPassword(passwordEncoder.encode(confirmDto.getNewPassword())); + userRepository.save(user); + + resetCode.setUsed(true); + passwordResetCodeRepository.save(resetCode); + + List otherCodes = passwordResetCodeRepository.findByEmailAndUsedFalse(email); + for (PasswordResetCode item : otherCodes) { + item.setUsed(true); + } + passwordResetCodeRepository.saveAll(otherCodes); + + log.info("用户通过邮箱验证码重置密码成功: {}", email); + return RestBean.success(RestCode.SUCCESS); + } + + private String generateCode() { + return String.format("%06d", SECURE_RANDOM.nextInt(1_000_000)); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + builder.append('0'); + } + builder.append(hex); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("无法生成验证码哈希", 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 new file mode 100644 index 0000000..65e6fec --- /dev/null +++ b/src/main/java/com/aisi/template/service/impl/UserServiceImpl.java @@ -0,0 +1,224 @@ +package com.aisi.template.service.impl; + +import com.aisi.template.domain.RestBean; +import com.aisi.template.domain.RestCode; +import com.aisi.template.domain.dto.PageResult; +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.User; +import com.aisi.template.domain.enums.Role; +import com.aisi.template.domain.vo.UserVo; +import com.aisi.template.repository.UserRepository; +import com.aisi.template.service.UserService; +import com.aisi.template.utils.JwtUtil; +import com.aisi.template.utils.SecurityUtils; +import jakarta.persistence.criteria.Predicate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final SecurityUtils securityUtils; + + @Override + public RestBean getUserInfo() { + String username = SecurityUtils.getUsername(); + User user = userRepository.findByUsername(username) + .orElse(null); + 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()); + return RestBean.success(userVo); + } + + @Override + public RestBean register(UserDto userDto) { + String normalizedEmail = userDto.getEmail() == null ? null : userDto.getEmail().trim().toLowerCase(Locale.ROOT); + // 检查用户名是否存在 + if (userRepository.existsByUsername(userDto.getUsername())) { + return RestBean.failure(400, "用户名已被使用", null); + } + // 检查邮箱是否存在 + if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) { + return RestBean.failure(400, "邮箱已被使用", null); + } + + User user = new User(); + user.setUsername(userDto.getUsername()); + user.setPassword(passwordEncoder.encode(userDto.getPassword())); + user.setEmail(normalizedEmail); + user.setRole(Role.USER); // 新注册用户默认为普通用户 + // 默认状态为1(正常),已在实体类中设置 + + userRepository.save(user); + + // 生成token + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + return RestBean.success(token); + } + + @Override + public RestBean login(UserDto userDto) { + // 查找用户 + User user = userRepository.findByUsername(userDto.getUsername()) + .orElse(null); + + if (user == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, "用户不存在"); + } + + // 检查用户状态 + if (!user.isEnabled()) { + return RestBean.failure(403, "用户已被禁用", null); + } + + // 验证密码 + if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) { + return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); + } + + // 生成token + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); + return RestBean.success(token); + } + + @Override + public RestBean> getUserList(UserQueryDto queryDto) { + try { + Pageable pageable = PageRequest.of( + Math.max(queryDto.getPage() - 1, 0), + Math.max(queryDto.getSize(), 1), + Sort.by(Sort.Direction.DESC, "createdAt") + ); + + Page userPage = userRepository.findAll(buildSpecification(queryDto), pageable); + List records = userPage.getContent().stream() + .map(this::convertToVo) + .collect(Collectors.toList()); + + return RestBean.success(PageResult.of( + records, + userPage.getTotalElements(), + queryDto.getPage(), + queryDto.getSize() + )); + } catch (Exception e) { + log.error("获取用户列表失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean updateUserStatus(Long userId, UserStatusUpdateDto updateDto) { + try { + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + + Long currentUserId = SecurityUtils.getUserId(); + if (currentUserId != null && currentUserId.equals(userId)) { + return RestBean.failure(403, "不允许修改当前登录用户的启用状态", null); + } + + user.setStatus(updateDto.getStatus()); + return RestBean.success(convertToVo(userRepository.save(user))); + } catch (Exception e) { + log.error("更新用户状态失败, userId={}", userId, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean updateUserRole(Long userId, UserRoleUpdateDto updateDto) { + try { + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + + 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); + } + + Role role = Role.valueOf(roleValue); + user.setRole(role); + return RestBean.success(convertToVo(userRepository.save(user))); + } catch (Exception e) { + log.error("更新用户角色失败, userId={}", userId, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + private Specification buildSpecification(UserQueryDto queryDto) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (StringUtils.hasText(queryDto.getKeyword())) { + String keyword = "%" + queryDto.getKeyword().trim() + "%"; + predicates.add(cb.or( + cb.like(root.get("username"), keyword), + cb.like(root.get("email"), keyword) + )); + } + + if (StringUtils.hasText(queryDto.getRole())) { + predicates.add(cb.equal(root.get("role"), Role.fromString(queryDto.getRole()))); + } + + if (queryDto.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), queryDto.getStatus())); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + 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()); + 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 new file mode 100644 index 0000000..ca7b086 --- /dev/null +++ b/src/main/java/com/aisi/template/utils/JwtUtil.java @@ -0,0 +1,57 @@ +package com.aisi.template.utils; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + private final long expiration = 1000 * 60 * 60 *24; // 24小时 + + private Key getSigningKey() { + // HS512 要求密钥至少 512 位 = 64 字节 + if (secret.length() < 64) { + throw new IllegalArgumentException("JWT secret must be at least 64 characters long for HS512"); + } + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(Long userId,String username) { + return Jwts.builder() + .setSubject(username) + .claim("id", userId) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey(), SignatureAlgorithm.HS512) + .compact(); + } + + public String extractUsername(String token) { + return parseClaims(token).getBody().getSubject(); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + private Jws parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + } +} diff --git a/src/main/java/com/aisi/template/utils/RedisUtils.java b/src/main/java/com/aisi/template/utils/RedisUtils.java new file mode 100644 index 0000000..1bb4827 --- /dev/null +++ b/src/main/java/com/aisi/template/utils/RedisUtils.java @@ -0,0 +1,337 @@ +package com.aisi.template.utils; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class RedisUtils { + + private final RedisTemplate redisTemplate; + + // ======================== 通用 ======================== + + public Boolean delete(String key) { + return redisTemplate.delete(key); + } + + public Long delete(Collection keys) { + return redisTemplate.delete(keys); + } + + public Boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } + + public Boolean expire(String key, long timeout, TimeUnit unit) { + return redisTemplate.expire(key, timeout, unit); + } + + public Long getExpire(String key) { + return redisTemplate.getExpire(key); + } + + public Boolean expireAt(String key, long timestamp) { + return redisTemplate.expireAt(key, new java.util.Date(timestamp)); + } + + public Set keys(String pattern) { + return redisTemplate.keys(pattern); + } + + public Boolean rename(String oldKey, String newKey) { + redisTemplate.rename(oldKey, newKey); + return true; + } + + public Long ttl(String key) { + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + } + + // ======================== String ======================== + + public void set(String key, Object value) { + redisTemplate.opsForValue().set(key, value); + } + + public void set(String key, Object value, long timeout, TimeUnit unit) { + redisTemplate.opsForValue().set(key, value, timeout, unit); + } + + public void setIfAbsent(String key, Object value, long timeout, TimeUnit unit) { + redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public T get(String key, Class clazz) { + Object value = redisTemplate.opsForValue().get(key); + if (value == null) { + return null; + } + return clazz.cast(value); + } + + public String getAndSet(String key, Object value) { + Object old = redisTemplate.opsForValue().getAndSet(key, value); + return old != null ? old.toString() : null; + } + + public Long increment(String key) { + return redisTemplate.opsForValue().increment(key); + } + + public Long increment(String key, long delta) { + return redisTemplate.opsForValue().increment(key, delta); + } + + public Long decrement(String key) { + return redisTemplate.opsForValue().decrement(key); + } + + public Long decrement(String key, long delta) { + return redisTemplate.opsForValue().decrement(key, delta); + } + + public Long strLength(String key) { + return redisTemplate.opsForValue().size(key); + } + + // ======================== Hash ======================== + + public void hSet(String key, String hashKey, Object value) { + redisTemplate.opsForHash().put(key, hashKey, value); + } + + public void hSetAll(String key, Map map) { + redisTemplate.opsForHash().putAll(key, map); + } + + public Object hGet(String key, String hashKey) { + return redisTemplate.opsForHash().get(key, hashKey); + } + + public Map hGetAll(String key) { + return redisTemplate.opsForHash().entries(key); + } + + public void hDelete(String key, Object... hashKeys) { + redisTemplate.opsForHash().delete(key, hashKeys); + } + + public Boolean hHasKey(String key, String hashKey) { + return redisTemplate.opsForHash().hasKey(key, hashKey); + } + + public Long hSize(String key) { + return redisTemplate.opsForHash().size(key); + } + + public Long hIncrement(String key, String hashKey, long delta) { + return redisTemplate.opsForHash().increment(key, hashKey, delta); + } + + public Set hKeys(String key) { + return redisTemplate.opsForHash().keys(key); + } + + public List hValues(String key) { + return redisTemplate.opsForHash().values(key); + } + + // ======================== List ======================== + + public Long lPush(String key, Object value) { + return redisTemplate.opsForList().leftPush(key, value); + } + + public Long lPushAll(String key, Object... values) { + return redisTemplate.opsForList().leftPushAll(key, values); + } + + public Long rPush(String key, Object value) { + return redisTemplate.opsForList().rightPush(key, value); + } + + public Long rPushAll(String key, Object... values) { + return redisTemplate.opsForList().rightPushAll(key, values); + } + + public Object lPop(String key) { + return redisTemplate.opsForList().leftPop(key); + } + + public List lPop(String key, long count) { + return redisTemplate.opsForList().leftPop(key, count); + } + + public Object rPop(String key) { + return redisTemplate.opsForList().rightPop(key); + } + + public List rPop(String key, long count) { + return redisTemplate.opsForList().rightPop(key, count); + } + + public Object lIndex(String key, long index) { + return redisTemplate.opsForList().index(key, index); + } + + public Long lSize(String key) { + return redisTemplate.opsForList().size(key); + } + + public List lRange(String key, long start, long end) { + return redisTemplate.opsForList().range(key, start, end); + } + + public void lTrim(String key, long start, long end) { + redisTemplate.opsForList().trim(key, start, end); + } + + public void lSet(String key, long index, Object value) { + redisTemplate.opsForList().set(key, index, value); + } + + public Long lRemove(String key, long count, Object value) { + return redisTemplate.opsForList().remove(key, count, value); + } + + // ======================== Set ======================== + + public Long sAdd(String key, Object... values) { + return redisTemplate.opsForSet().add(key, values); + } + + public Long sRemove(String key, Object... values) { + return redisTemplate.opsForSet().remove(key, values); + } + + public Set sMembers(String key) { + return redisTemplate.opsForSet().members(key); + } + + public Boolean sIsMember(String key, Object value) { + return redisTemplate.opsForSet().isMember(key, value); + } + + public Long sSize(String key) { + return redisTemplate.opsForSet().size(key); + } + + public Object sRandomMember(String key) { + return redisTemplate.opsForSet().randomMember(key); + } + + public Set sRandomMembers(String key, long count) { + return redisTemplate.opsForSet().distinctRandomMembers(key, count); + } + + public Set sIntersect(String key1, String key2) { + return redisTemplate.opsForSet().intersect(key1, key2); + } + + public Set sUnion(String key1, String key2) { + return redisTemplate.opsForSet().union(key1, key2); + } + + public Set sDifference(String key1, String key2) { + return redisTemplate.opsForSet().difference(key1, key2); + } + + // ======================== ZSet(有序集合)======================== + + public Boolean zAdd(String key, Object value, double score) { + return redisTemplate.opsForZSet().add(key, value, score); + } + + public Long zAdd(String key, Set> tuples) { + return redisTemplate.opsForZSet().add(key, tuples); + } + + public Long zRemove(String key, Object... values) { + return redisTemplate.opsForZSet().remove(key, values); + } + + public Long zRank(String key, Object value) { + return redisTemplate.opsForZSet().rank(key, value); + } + + public Long zReverseRank(String key, Object value) { + return redisTemplate.opsForZSet().reverseRank(key, value); + } + + public Set zRange(String key, long start, long end) { + return redisTemplate.opsForZSet().range(key, start, end); + } + + public Set zReverseRange(String key, long start, long end) { + return redisTemplate.opsForZSet().reverseRange(key, start, end); + } + + public Set> zRangeWithScores(String key, long start, long end) { + return redisTemplate.opsForZSet().rangeWithScores(key, start, end); + } + + public Set zRangeByScore(String key, double min, double max) { + return redisTemplate.opsForZSet().rangeByScore(key, min, max); + } + + public Set zRangeByScore(String key, double min, double max, long offset, long count) { + return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count); + } + + public Long zCount(String key, double min, double max) { + return redisTemplate.opsForZSet().count(key, min, max); + } + + public Long zSize(String key) { + return redisTemplate.opsForZSet().size(key); + } + + public Double zScore(String key, Object value) { + return redisTemplate.opsForZSet().score(key, value); + } + + public Long zRemoveRange(String key, long start, long end) { + return redisTemplate.opsForZSet().removeRange(key, start, end); + } + + public Long zRemoveRangeByScore(String key, double min, double max) { + return redisTemplate.opsForZSet().removeRangeByScore(key, min, max); + } + + // ======================== Bitmap ======================== + + public Boolean setBit(String key, long offset, boolean value) { + return redisTemplate.opsForValue().setBit(key, offset, value); + } + + public Boolean getBit(String key, long offset) { + return redisTemplate.opsForValue().getBit(key, offset); + } + + // ======================== HyperLogLog ======================== + + public Long pfAdd(String key, Object... values) { + return redisTemplate.opsForHyperLogLog().add(key, values); + } + + public Long pfCount(String... keys) { + return redisTemplate.opsForHyperLogLog().size(keys); + } + + 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 new file mode 100644 index 0000000..af9825e --- /dev/null +++ b/src/main/java/com/aisi/template/utils/SecurityUtils.java @@ -0,0 +1,46 @@ +package com.aisi.template.utils; + +import com.aisi.template.domain.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtils { + + /** + * 获取当前 Authentication + */ + public static Authentication getAuthentication(){ + return SecurityContextHolder.getContext().getAuthentication(); + } + + /** + * 获取登录名 + */ + public static String getUsername(){ + Authentication auth = getAuthentication(); + if (auth != null && auth.isAuthenticated()) { + Object principal = auth.getPrincipal(); + if (principal instanceof CustomUserDetails user) { + return user.getUsername(); + }else if (principal instanceof String username) { + return username; + } + } + return null; + } + + /** + * 获取用户Id + */ + public static Long getUserId() { + Authentication auth = getAuthentication(); + 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 new file mode 100644 index 0000000..19f5936 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,58 @@ +spring: + output: + ansi: + enabled: always + datasource: + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:template}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USER:root} + password: ${DB_PASS:root} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQLDialect + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DB:0} + timeout: 5000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 2 + max-wait: -1ms + mail: + host: ${MAIL_HOST:smtp.163.com} + port: ${MAIL_PORT:465} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + protocol: ${MAIL_PROTOCOL:smtps} + properties: + mail: + smtp: + auth: true + ssl: + enable: true + +jwt: + secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!} + +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} + +logging: + level: + org.hibernate.SQL: warn + org.hibernate.orm.jdbc.bind: warn + org.hibernate.type.descriptor.sql.BasicBinder: warn diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..207c0c6 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,46 @@ +spring: + datasource: + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:template}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USER:root} + password: ${DB_PASS:root} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + database-platform: org.hibernate.dialect.MySQLDialect + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DB:0} + timeout: 5000ms + lettuce: + pool: + max-active: 16 + max-idle: 8 + min-idle: 4 + max-wait: -1ms + mail: + host: ${MAIL_HOST:smtp.163.com} + port: ${MAIL_PORT:465} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + protocol: ${MAIL_PROTOCOL:smtps} + properties: + mail: + smtp: + auth: true + ssl: + enable: true + +jwt: + secret: ${JWT_SECRET:templateSecretKeyForJWT2024MustBeLongEnoughForHS512AlgorithmPleaseReplaceInProduction!!} + +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} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..a59ea95 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +spring: + application: + name: springboot-template + profiles: + active: dev + config: + import: optional:file:.env[.properties] + +server: + port: 8080 diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql new file mode 100644 index 0000000..c97ca8c --- /dev/null +++ b/src/main/resources/sql/init.sql @@ -0,0 +1,35 @@ +-- ============================================ +-- 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; diff --git a/src/test/java/com/aisi/template/TemplateApplicationTests.java b/src/test/java/com/aisi/template/TemplateApplicationTests.java new file mode 100644 index 0000000..04918df --- /dev/null +++ b/src/test/java/com/aisi/template/TemplateApplicationTests.java @@ -0,0 +1,12 @@ +package com.aisi.template; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TemplateApplicationTests { + + @Test + void contextLoads() { + } +}