From d5e9f3bb0a033ab328b53df6743f7decd3d209ac Mon Sep 17 00:00:00 2001 From: zhoujinhua Date: Mon, 20 Oct 2025 15:19:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=99=BB=E5=BD=95=E6=B3=A8?= =?UTF-8?q?=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/README.md | 211 +++++++ .gitea/workflows/ci.yml | 55 ++ .gitea/workflows/deploy.yml | 80 +++ CLAUDE.md | 194 ++++++ build.gradle | 12 + gradle/wrapper/gradle-wrapper.properties | 2 +- .../java/com/corewing/app/common/Result.java | 86 +++ .../com/corewing/app/config/DruidConfig.java | 62 ++ .../app/config/MybatisPlusConfig.java | 37 ++ .../com/corewing/app/config/RedisConfig.java | 53 ++ .../corewing/app/config/SaTokenConfig.java | 37 ++ .../app/controller/AppUserController.java | 173 ++++++ .../com/corewing/app/dto/LoginRequest.java | 20 + .../com/corewing/app/dto/RegisterRequest.java | 45 ++ .../com/corewing/app/dto/SendCodeRequest.java | 24 + .../app/dto/UpdatePasswordRequest.java | 20 + .../java/com/corewing/app/entity/AppUser.java | 74 +++ .../app/handler/MyMetaObjectHandler.java | 35 ++ .../corewing/app/mapper/AppUserMapper.java | 13 + .../corewing/app/service/AppUserService.java | 68 +++ .../app/service/VerifyCodeService.java | 26 + .../app/service/impl/AppUserServiceImpl.java | 150 +++++ .../service/impl/VerifyCodeServiceImpl.java | 140 +++++ .../java/com/corewing/app/util/EmailUtil.java | 141 +++++ .../java/com/corewing/app/util/IpUtil.java | 44 ++ .../java/com/corewing/app/util/RedisUtil.java | 564 ++++++++++++++++++ .../com/corewing/app/util/SmsBaoUtil.java | 118 ++++ src/main/resources/application.properties | 74 +++ src/main/resources/db/user.sql | 26 + src/main/resources/docs/API接口说明.md | 356 +++++++++++ .../com/corewing/app/util/EmailUtilTest.java | 150 +++++ 邮件发送测试说明.md | 240 ++++++++ 32 files changed, 3329 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/README.md create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/deploy.yml create mode 100644 CLAUDE.md create mode 100644 src/main/java/com/corewing/app/common/Result.java create mode 100644 src/main/java/com/corewing/app/config/DruidConfig.java create mode 100644 src/main/java/com/corewing/app/config/MybatisPlusConfig.java create mode 100644 src/main/java/com/corewing/app/config/RedisConfig.java create mode 100644 src/main/java/com/corewing/app/config/SaTokenConfig.java create mode 100644 src/main/java/com/corewing/app/controller/AppUserController.java create mode 100644 src/main/java/com/corewing/app/dto/LoginRequest.java create mode 100644 src/main/java/com/corewing/app/dto/RegisterRequest.java create mode 100644 src/main/java/com/corewing/app/dto/SendCodeRequest.java create mode 100644 src/main/java/com/corewing/app/dto/UpdatePasswordRequest.java create mode 100644 src/main/java/com/corewing/app/entity/AppUser.java create mode 100644 src/main/java/com/corewing/app/handler/MyMetaObjectHandler.java create mode 100644 src/main/java/com/corewing/app/mapper/AppUserMapper.java create mode 100644 src/main/java/com/corewing/app/service/AppUserService.java create mode 100644 src/main/java/com/corewing/app/service/VerifyCodeService.java create mode 100644 src/main/java/com/corewing/app/service/impl/AppUserServiceImpl.java create mode 100644 src/main/java/com/corewing/app/service/impl/VerifyCodeServiceImpl.java create mode 100644 src/main/java/com/corewing/app/util/EmailUtil.java create mode 100644 src/main/java/com/corewing/app/util/IpUtil.java create mode 100644 src/main/java/com/corewing/app/util/RedisUtil.java create mode 100644 src/main/java/com/corewing/app/util/SmsBaoUtil.java create mode 100644 src/main/resources/db/user.sql create mode 100644 src/main/resources/docs/API接口说明.md create mode 100644 src/test/java/com/corewing/app/util/EmailUtilTest.java create mode 100644 邮件发送测试说明.md diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..f25614f --- /dev/null +++ b/.gitea/workflows/README.md @@ -0,0 +1,211 @@ +# Gitea Actions 工作流配置说明 + +本项目配置了两个 Gitea Actions 工作流,用于自动化构建、测试和部署。 + +## 工作流列表 + +### 1. ci.yml - 持续集成 +**触发时机:** +- 推送代码到 `main` 或 `master` 分支 +- 创建 Pull Request + +**执行内容:** +- 自动检出代码 +- 配置 Java 8 环境 +- 使用 Gradle 构建项目 +- 运行单元测试 +- 上传构建产物(JAR 包) +- 上传测试报告 + +**查看结果:** +构建完成后,可以在 Gitea 仓库的 "Actions" 标签页查看运行结果和下载构建产物。 + +--- + +### 2. deploy.yml - 自动部署 +**触发时机:** +- 推送代码到 `main` 或 `master` 分支 +- 手动触发(在 Gitea Actions 页面点击 "Run workflow") + +**执行内容:** +- 构建项目 +- 通过 SCP 将 JAR 包上传到服务器 +- 通过 SSH 重启应用 + +**配置步骤:** + +#### 第一步:在 Gitea 中配置 Secrets +进入仓库 → Settings → Secrets,添加以下密钥: + +| 密钥名称 | 说明 | 示例 | +|---------|------|------| +| `SERVER_HOST` | 服务器 IP 地址 | `120.24.204.180` | +| `SERVER_PORT` | SSH 端口 | `22` | +| `SERVER_USER` | SSH 用户名 | `root` 或 `ubuntu` | +| `SERVER_SSH_KEY` | SSH 私钥内容 | 完整的私钥文件内容 | +| `DEPLOY_PATH` | 部署目录路径 | `/opt/corewing` | + +#### 第二步:生成 SSH 密钥对(如果还没有) +```bash +# 在本地生成密钥对 +ssh-keygen -t rsa -b 4096 -C "gitea-deploy" -f ~/.ssh/gitea_deploy + +# 查看公钥 +cat ~/.ssh/gitea_deploy.pub + +# 查看私钥(复制到 Gitea Secrets 中) +cat ~/.ssh/gitea_deploy +``` + +#### 第三步:配置服务器 +```bash +# 1. SSH 登录到服务器 +ssh user@your-server + +# 2. 创建部署目录 +sudo mkdir -p /opt/corewing +sudo chown $USER:$USER /opt/corewing + +# 3. 添加公钥到 authorized_keys +echo "your-public-key-content" >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys + +# 4. 安装 Java(如果未安装) +sudo apt update +sudo apt install openjdk-8-jdk -y +java -version +``` + +#### 第四步:测试部署 +推送代码到 main 分支,或在 Gitea Actions 页面手动触发 deploy 工作流。 + +--- + +## 启用 Gitea Actions + +### Gitea 服务器端配置 + +如果你的 Gitea 实例还未启用 Actions,需要管理员配置: + +1. **修改 Gitea 配置文件** (`app.ini`): +```ini +[actions] +ENABLED = true +DEFAULT_ACTIONS_URL = https://gitea.com +``` + +2. **安装 Gitea Act Runner**: +```bash +# 下载 Act Runner +wget https://dl.gitea.com/act_runner/0.2.6/act_runner-0.2.6-linux-amd64 +chmod +x act_runner-0.2.6-linux-amd64 +mv act_runner-0.2.6-linux-amd64 /usr/local/bin/act_runner + +# 注册 Runner +act_runner register --no-interactive --instance http://120.24.204.180:3000 --token YOUR_RUNNER_TOKEN + +# 启动 Runner +act_runner daemon +``` + +3. **获取 Runner Token**: + - 登录 Gitea 管理后台 + - 进入 Site Administration → Actions → Runners + - 点击 "Create new Runner" 获取 Token + +--- + +## 自定义工作流 + +### 修改触发条件 +可以根据需要修改工作流的触发条件: + +```yaml +on: + push: + branches: [ main, develop ] # 监听多个分支 + tags: + - 'v*' # 监听标签 + schedule: + - cron: '0 0 * * *' # 定时执行(每天午夜) +``` + +### 添加环境变量 +在工作流中使用环境变量: + +```yaml +env: + JAVA_VERSION: '8' + SPRING_PROFILES_ACTIVE: 'prod' + +steps: + - name: 运行应用 + run: java -jar -Dspring.profiles.active=${{ env.SPRING_PROFILES_ACTIVE }} app.jar +``` + +### 添加通知 +可以添加构建成功/失败通知: + +```yaml +- name: 发送通知 + if: failure() + run: | + curl -X POST "https://your-notification-webhook" \ + -d "构建失败: ${{ github.repository }}" +``` + +--- + +## 常见问题 + +### 1. 工作流未触发? +- 检查 Gitea Actions 是否启用 +- 检查 Act Runner 是否正常运行 +- 确认触发条件是否匹配 + +### 2. 构建失败? +- 查看 Actions 日志获取详细错误信息 +- 检查 Java 版本是否正确 +- 确认依赖是否能正常下载 + +### 3. 部署失败? +- 检查 Secrets 配置是否正确 +- 测试 SSH 连接是否正常 +- 检查服务器目录权限 + +### 4. 如何查看日志? +- Gitea 仓库 → Actions 标签页 +- 点击具体的工作流运行记录 +- 展开各个步骤查看详细日志 + +--- + +## 最佳实践 + +1. **分支策略**: + - `main` 分支自动部署到生产环境 + - `develop` 分支自动部署到测试环境 + - Pull Request 只执行构建和测试 + +2. **安全性**: + - 不要在代码中硬编码密码和密钥 + - 使用 Secrets 管理敏感信息 + - 定期轮换 SSH 密钥 + +3. **性能优化**: + - 启用 Gradle 缓存加快构建速度 + - 使用 `build -x test` 跳过测试快速构建 + - 合理设置产物保留时间 + +4. **监控**: + - 定期检查工作流运行状态 + - 设置构建失败通知 + - 保存构建日志便于问题排查 + +--- + +## 更多资源 + +- [Gitea Actions 官方文档](https://docs.gitea.io/en-us/actions/) +- [GitHub Actions 语法参考](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions)(Gitea Actions 兼容 GitHub Actions 语法) +- [Act Runner 项目](https://gitea.com/gitea/act_runner) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8a9abdd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI Build and Test + +# 触发条件:推送到 main 分支或创建 Pull Request +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # 1. 检出代码 + - name: 检出代码 + uses: actions/checkout@v3 + + # 2. 设置 Java 环境 + - name: 设置 Java 8 + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'temurin' + cache: 'gradle' + + # 3. 赋予 Gradle wrapper 执行权限 + - name: 赋予 Gradle wrapper 执行权限 + run: chmod +x gradlew + + # 4. 构建项目(跳过测试) + - name: 使用 Gradle 构建项目 + run: ./gradlew build -x test + + # 5. 运行测试 + - name: 运行测试 + run: ./gradlew test + + # 6. 上传构建产物(JAR 文件) + - name: 上传 JAR 包 + uses: actions/upload-artifact@v3 + if: success() + with: + name: corewing-app + path: build/libs/*.jar + retention-days: 7 + + # 7. 上传测试报告 + - name: 上传测试报告 + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-reports + path: build/reports/tests/ + retention-days: 7 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..b0fae90 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Deploy to Server + +# 触发条件:手动触发或推送到 main 分支 +on: + push: + branches: [ main, master ] + workflow_dispatch: # 允许手动触发 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + # 1. 检出代码 + - name: 检出代码 + uses: actions/checkout@v3 + + # 2. 设置 Java 环境 + - name: 设置 Java 8 + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'temurin' + cache: 'gradle' + + # 3. 赋予 Gradle wrapper 执行权限 + - name: 赋予 Gradle wrapper 执行权限 + run: chmod +x gradlew + + # 4. 构建项目 + - name: 构建项目 + run: ./gradlew build -x test + + # 5. 打包 JAR 文件 + - name: 获取 JAR 文件名 + id: jar + run: echo "jar_file=$(ls build/libs/*.jar | head -n 1)" >> $GITHUB_OUTPUT + + # 6. 部署到服务器(使用 SCP) + # 需要在 Gitea 仓库设置中配置以下 Secrets: + # - SERVER_HOST: 服务器地址 + # - SERVER_PORT: SSH 端口(默认 22) + # - SERVER_USER: SSH 用户名 + # - SERVER_SSH_KEY: SSH 私钥 + # - DEPLOY_PATH: 部署路径(如 /opt/corewing) + - name: 部署到服务器 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + port: ${{ secrets.SERVER_PORT }} + source: "build/libs/*.jar" + target: ${{ secrets.DEPLOY_PATH }} + strip_components: 2 + + # 7. 重启应用(通过 SSH 执行命令) + - name: 重启应用 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + cd ${{ secrets.DEPLOY_PATH }} + # 停止旧进程 + pkill -f corewing || true + # 等待进程完全停止 + sleep 3 + # 启动新进程 + nohup java -jar *.jar > app.log 2>&1 & + # 检查启动状态 + sleep 5 + if pgrep -f corewing > /dev/null; then + echo "应用启动成功" + else + echo "应用启动失败" + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..463e192 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,194 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +CoreWing 是一个基于 Spring Boot 2.6.13 的 Java Web 应用,使用 Gradle 作为构建工具。 + +**技术栈:** +- Java 8 +- Spring Boot 2.6.13 +- MyBatis Plus 3.5.14 (ORM 框架) +- Sa-Token 1.44.0 (权限认证框架) +- Druid 1.2.27 (数据库连接池) +- MySQL 8.x (数据库) +- Redis (缓存) +- Lombok (代码简化工具) + +## 常用命令 + +### 构建和运行 +```bash +# 构建项目 +./gradlew build + +# 构建项目(跳过测试) +./gradlew build -x test + +# 运行应用 (端口 8080) +./gradlew bootRun + +# 清理构建文件 +./gradlew clean +``` + +### 测试 +```bash +# 运行所有测试 +./gradlew test + +# 运行单个测试类 +./gradlew test --tests com.corewing.app.CoreWingApplicationTests + +# 运行测试并生成报告 +./gradlew test --info +``` + +### 开发 +```bash +# 使用 Spring Boot DevTools 运行 (支持热重载) +./gradlew bootRun + +# 检查依赖 +./gradlew dependencies +``` + +## 代码架构 + +### 包结构 +``` +com.corewing.app/ +├── common/ # 通用类 +│ └── Result.java # 统一返回结果类 +├── config/ # 配置类 +│ ├── DruidConfig.java # Druid 数据源监控配置 +│ ├── MybatisPlusConfig.java # MyBatis-Plus 配置 +│ ├── RedisConfig.java # Redis 配置 +│ └── SaTokenConfig.java # Sa-Token 权限配置 +├── controller/ # 控制器层 +│ └── AppUserController.java # 用户控制器 +├── dto/ # 数据传输对象 +│ ├── LoginRequest.java # 登录请求参数 +│ ├── RegisterRequest.java # 注册请求参数 +│ ├── SendCodeRequest.java # 发送验证码请求参数 +│ └── UpdatePasswordRequest.java # 修改密码请求参数 +├── entity/ # 实体类 +│ └── AppUser.java # 用户实体 +├── handler/ # 处理器 +│ └── MyMetaObjectHandler.java # MyBatis-Plus 自动填充处理器 +├── mapper/ # Mapper 接口 +│ └── AppUserMapper.java # 用户 Mapper +├── service/ # 服务层 +│ ├── AppUserService.java # 用户服务接口 +│ ├── VerifyCodeService.java # 验证码服务接口 +│ └── impl/ +│ ├── AppUserServiceImpl.java # 用户服务实现 +│ └── VerifyCodeServiceImpl.java # 验证码服务实现 +└── util/ # 工具类 + ├── EmailUtil.java # 邮件发送工具类 + ├── IpUtil.java # IP 工具类 + ├── RedisUtil.java # Redis 工具类 + └── SmsBaoUtil.java # 短信宝工具类 +``` + +### 配置文件 +- `application.properties` - 应用配置文件 + - 服务端口:8080 + - 数据库:MySQL 8.x + - Redis 缓存 + - Druid 连接池 + - MyBatis-Plus 配置 + - Sa-Token 配置 + - 短信宝配置 + - 邮件配置 + +### 数据库 +- 建表 SQL:`src/main/resources/db/user.sql` +- 主表:`app_user` - 应用用户表 + +## 核心功能 + +### 1. 用户管理 +- 用户注册(密码 MD5 加密,需要验证码) +- 用户登录(Sa-Token 生成 token,支持用户名/邮箱/手机号) +- 用户登出 +- 获取用户信息 +- 更新用户信息 +- 修改密码 +- 发送验证码(短信/邮件) + +### 2. 权限认证 +- 使用 Sa-Token 进行身份认证 +- 登录拦截器配置 +- 排除路由:`/user/login`, `/user/register`, `/user/sendCode`, 静态资源等 + +### 3. 验证码功能 +- 支持手机号和邮箱发送验证码 +- 验证码存储在 Redis 中,有效期 5 分钟 +- 手机验证码通过短信宝发送 +- 邮件验证码通过 Spring Mail 发送,支持 HTML 格式的精美邮件模板 + +### 4. 数据持久化 +- MyBatis-Plus 作为 ORM 框架 +- 支持自动填充(创建时间、更新时间) +- 主键策略:数据库自增 +- 驼峰命名自动转换 + +### 5. 缓存 +- Redis 作为缓存 +- RedisUtil 工具类封装常用操作 +- 支持 String、Hash、Set、List 数据类型 +- 验证码存储在 Redis 中 + +### 6. 数据库连接池 +- Druid 连接池 +- 监控后台:`http://localhost:8080/druid/` +- 登录账号:admin / admin123 +- SQL 监控、慢 SQL 记录(>5秒) + +### 7. 短信服务 +- 使用短信宝(https://www.smsbao.com/)发送短信 +- SmsBaoUtil 工具类封装短信发送功能 +- 支持发送验证码短信 + +### 8. 邮件服务 +- 使用 Spring Boot Mail 发送邮件 +- EmailUtil 工具类封装邮件发送功能 +- 支持发送简单文本邮件和 HTML 邮件 +- 验证码邮件采用精美的 HTML 模板 +- 支持多种邮箱服务商(QQ、163、Gmail 等) + +## 开发规范 + +### 1. 依赖注入 +- 推荐使用构造函数注入 +- 使用 `final` 修饰依赖字段 + +### 2. 实体类 +- 使用 Lombok 简化代码 +- 时间类型使用 `LocalDateTime` +- 自动填充字段使用 `@TableField(fill = FieldFill.XXX)` + +### 3. 控制器 +- 统一返回 `Result` 类型 +- DTO 类独立定义,不使用内部类 +- 使用 RESTful 风格 API + +### 4. 密码安全 +- 密码使用 MD5 加密存储 +- 不直接返回密码字段给前端 + +### 5. IP 获取 +- 使用 `IpUtil.getClientIp()` 获取真实 IP +- 支持多级代理 + +## 项目特点 + +- 项目使用 MyBatis Plus 作为 ORM 框架,支持自动代码生成 +- 集成 Sa-Token 用于身份验证和权限管理 +- 支持 MySQL 数据库和 Redis 缓存 +- 使用 Lombok 简化实体类代码 +- 开发环境支持热重载 (Spring Boot DevTools) +- Druid 数据库连接池,提供 SQL 监控功能 +- 统一返回结果封装,规范 API 响应格式 \ No newline at end of file diff --git a/build.gradle b/build.gradle index ca26dde..419fac7 100644 --- a/build.gradle +++ b/build.gradle @@ -18,11 +18,23 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom "com.baomidou:mybatis-plus-bom:3.5.14" + } +} dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'com.baomidou:mybatis-plus-boot-starter' + implementation 'com.baomidou:mybatis-plus-generator' + implementation("com.baomidou:mybatis-plus-jsqlparser") + implementation 'cn.dev33:sa-token-spring-boot-starter:1.44.0' + implementation 'com.alibaba:druid-spring-boot-starter:1.2.27' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..f4babcf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://mirrors.aliyun.com/gradle/gradle-7.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/com/corewing/app/common/Result.java b/src/main/java/com/corewing/app/common/Result.java new file mode 100644 index 0000000..b08c5bf --- /dev/null +++ b/src/main/java/com/corewing/app/common/Result.java @@ -0,0 +1,86 @@ +package com.corewing.app.common; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一返回结果类 + */ +@Data +public class Result implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 状态码 + */ + private Integer code; + + /** + * 返回消息 + */ + private String message; + + /** + * 返回数据 + */ + private T data; + + /** + * 成功标识 + */ + private Boolean success; + + public Result() { + } + + public Result(Integer code, String message, T data, Boolean success) { + this.code = code; + this.message = message; + this.data = data; + this.success = success; + } + + /** + * 成功返回(无数据) + */ + public static Result success() { + return new Result<>(200, "操作成功", null, true); + } + + /** + * 成功返回(带数据) + */ + public static Result success(T data) { + return new Result<>(200, "操作成功", data, true); + } + + /** + * 成功返回(自定义消息和数据) + */ + public static Result success(String message, T data) { + return new Result<>(200, message, data, true); + } + + /** + * 失败返回 + */ + public static Result error() { + return new Result<>(500, "操作失败", null, false); + } + + /** + * 失败返回(自定义消息) + */ + public static Result error(String message) { + return new Result<>(500, message, null, false); + } + + /** + * 失败返回(自定义状态码和消息) + */ + public static Result error(Integer code, String message) { + return new Result<>(code, message, null, false); + } +} diff --git a/src/main/java/com/corewing/app/config/DruidConfig.java b/src/main/java/com/corewing/app/config/DruidConfig.java new file mode 100644 index 0000000..87c773c --- /dev/null +++ b/src/main/java/com/corewing/app/config/DruidConfig.java @@ -0,0 +1,62 @@ +package com.corewing.app.config; + +import com.alibaba.druid.support.http.StatViewServlet; +import com.alibaba.druid.support.http.WebStatFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +/** + * Druid 监控配置类 + */ +@Configuration +public class DruidConfig { + + /** + * 配置 Druid 监控后台 Servlet + */ + @Bean + public ServletRegistrationBean statViewServlet() { + ServletRegistrationBean bean = new ServletRegistrationBean<>( + new StatViewServlet(), "/druid/*" + ); + + Map initParams = new HashMap<>(); + // 监控页面登录用户名 + initParams.put("loginUsername", "admin"); + // 监控页面登录密码 + initParams.put("loginPassword", "admin123"); + // 允许访问的IP,默认允许所有 + initParams.put("allow", ""); + // 禁止访问的IP + initParams.put("deny", ""); + // 禁用HTML页面上的"Reset All"功能 + initParams.put("resetEnable", "false"); + + bean.setInitParameters(initParams); + return bean; + } + + /** + * 配置 Druid Web 监控 Filter + */ + @Bean + public FilterRegistrationBean webStatFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new WebStatFilter()); + + Map initParams = new HashMap<>(); + // 排除不需要监控的资源 + initParams.put("exclusions", "*.js,*.css,*.gif,*.jpg,*.png,*.ico,/druid/*"); + + bean.setInitParameters(initParams); + // 拦截所有请求 + bean.addUrlPatterns("/*"); + + return bean; + } +} diff --git a/src/main/java/com/corewing/app/config/MybatisPlusConfig.java b/src/main/java/com/corewing/app/config/MybatisPlusConfig.java new file mode 100644 index 0000000..4780173 --- /dev/null +++ b/src/main/java/com/corewing/app/config/MybatisPlusConfig.java @@ -0,0 +1,37 @@ +package com.corewing.app.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 配置类 + */ +@Configuration +@MapperScan("com.corewing.app.mapper") +public class MybatisPlusConfig { + + /** + * MyBatis-Plus 插件配置 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + + // 分页插件 + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + + // 乐观锁插件 + interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); + + // 防止全表更新与删除插件 + interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); + + return interceptor; + } +} diff --git a/src/main/java/com/corewing/app/config/RedisConfig.java b/src/main/java/com/corewing/app/config/RedisConfig.java new file mode 100644 index 0000000..e281429 --- /dev/null +++ b/src/main/java/com/corewing/app/config/RedisConfig.java @@ -0,0 +1,53 @@ +package com.corewing.app.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.cache.annotation.EnableCaching; +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.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置类 + */ +@Configuration +@EnableCaching +public class RedisConfig { + + /** + * RedisTemplate 配置 + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值 + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(Object.class); + + ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); + serializer.setObjectMapper(mapper); + + // 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + + // key 采用 String 的序列化方式 + template.setKeySerializer(stringSerializer); + // hash 的 key 也采用 String 的序列化方式 + template.setHashKeySerializer(stringSerializer); + // value 序列化方式采用 jackson + template.setValueSerializer(serializer); + // hash 的 value 序列化方式采用 jackson + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/corewing/app/config/SaTokenConfig.java b/src/main/java/com/corewing/app/config/SaTokenConfig.java new file mode 100644 index 0000000..e7f2450 --- /dev/null +++ b/src/main/java/com/corewing/app/config/SaTokenConfig.java @@ -0,0 +1,37 @@ +package com.corewing.app.config; + +import cn.dev33.satoken.interceptor.SaInterceptor; +import cn.dev33.satoken.router.SaRouter; +import cn.dev33.satoken.stp.StpUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Sa-Token 配置类 + */ +@Configuration +public class SaTokenConfig implements WebMvcConfigurer { + + /** + * 注册 Sa-Token 拦截器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。 + registry.addInterceptor(new SaInterceptor(handle -> { + // 指定一条 match 规则 + SaRouter + // 拦截所有路由 + .match("/**") + // 排除登录、注册、发送验证码接口 + .notMatch("/user/login", "/user/register", "/user/sendCode") + // 排除静态资源 + .notMatch("/", "/index.html", "/*.html", "/*.css", "/*.js", "/*.ico", "/static/**") + // 排除 Druid 监控 + .notMatch("/druid/**") + // 执行认证校验 + .check(r -> StpUtil.checkLogin()); + })).addPathPatterns("/**"); + } +} diff --git a/src/main/java/com/corewing/app/controller/AppUserController.java b/src/main/java/com/corewing/app/controller/AppUserController.java new file mode 100644 index 0000000..860ed57 --- /dev/null +++ b/src/main/java/com/corewing/app/controller/AppUserController.java @@ -0,0 +1,173 @@ +package com.corewing.app.controller; + +import cn.dev33.satoken.stp.StpUtil; +import com.corewing.app.common.Result; +import com.corewing.app.dto.LoginRequest; +import com.corewing.app.dto.RegisterRequest; +import com.corewing.app.dto.SendCodeRequest; +import com.corewing.app.dto.UpdatePasswordRequest; +import com.corewing.app.entity.AppUser; +import com.corewing.app.service.AppUserService; +import com.corewing.app.service.VerifyCodeService; +import com.corewing.app.util.IpUtil; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +/** + * 应用用户 Controller + */ +@RestController +@RequestMapping("/user") +public class AppUserController { + + private final AppUserService userService; + private final VerifyCodeService verifyCodeService; + + public AppUserController(AppUserService userService, VerifyCodeService verifyCodeService) { + this.userService = userService; + this.verifyCodeService = verifyCodeService; + } + + /** + * 发送验证码 + */ + @PostMapping("/sendCode") + public Result sendCode(@RequestBody SendCodeRequest request) { + try { + boolean success = verifyCodeService.sendCode(request.getAccount(), request.getType()); + if (success) { + return Result.success("验证码发送成功"); + } + return Result.error("验证码发送失败"); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } + + /** + * 用户登录(支持用户名/邮箱/手机号) + */ + @PostMapping("/login") + public Result> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) { + try { + String token = userService.login(request.getAccount(), request.getPassword()); + + // 更新登录IP + AppUser user = userService.getByAccount(request.getAccount()); + String loginIp = IpUtil.getClientIp(httpRequest); + userService.updateLoginIp(user.getId(), loginIp); + + Map data = new HashMap<>(); + data.put("token", token); + data.put("userId", user.getId()); + data.put("username", user.getUsername()); + + return Result.success("登录成功", data); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } + + /** + * 用户注册(需要验证码) + */ + @PostMapping("/register") + public Result register(@RequestBody RegisterRequest request) { + try { + AppUser user = new AppUser(); + user.setUsername(request.getUsername()); + user.setPassword(request.getPassword()); + user.setEmail(request.getEmail()); + user.setTelephone(request.getTelephone()); + user.setAvatar(request.getAvatar()); + + userService.register(user, request.getCode()); + return Result.success("注册成功"); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } + + /** + * 用户登出 + */ + @PostMapping("/logout") + public Result logout() { + StpUtil.logout(); + return Result.success("登出成功"); + } + + /** + * 获取当前登录用户信息 + */ + @GetMapping("/info") + public Result getUserInfo() { + Long userId = StpUtil.getLoginIdAsLong(); + AppUser user = userService.getById(userId); + // 隐藏密码 + user.setPassword(null); + return Result.success(user); + } + + /** + * 根据ID查询用户 + */ + @GetMapping("/{id}") + public Result getById(@PathVariable Long id) { + AppUser user = userService.getById(id); + if (user != null) { + // 隐藏密码 + user.setPassword(null); + return Result.success(user); + } + return Result.error("用户不存在"); + } + + /** + * 更新用户信息 + */ + @PutMapping + public Result update(@RequestBody AppUser user) { + // 不允许通过此接口修改密码 + user.setPassword(null); + + boolean success = userService.updateById(user); + if (success) { + return Result.success("更新成功"); + } + return Result.error("更新失败"); + } + + /** + * 修改密码 + */ + @PutMapping("/password") + public Result updatePassword(@RequestBody UpdatePasswordRequest request) { + try { + Long userId = StpUtil.getLoginIdAsLong(); + AppUser user = userService.getById(userId); + + // 验证旧密码 + String oldPasswordMd5 = org.springframework.util.DigestUtils.md5DigestAsHex( + request.getOldPassword().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + if (!oldPasswordMd5.equals(user.getPassword())) { + return Result.error("原密码错误"); + } + + // 更新新密码 + String newPasswordMd5 = org.springframework.util.DigestUtils.md5DigestAsHex( + request.getNewPassword().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + user.setPassword(newPasswordMd5); + userService.updateById(user); + + return Result.success("密码修改成功"); + } catch (Exception e) { + return Result.error(e.getMessage()); + } + } +} diff --git a/src/main/java/com/corewing/app/dto/LoginRequest.java b/src/main/java/com/corewing/app/dto/LoginRequest.java new file mode 100644 index 0000000..a47a856 --- /dev/null +++ b/src/main/java/com/corewing/app/dto/LoginRequest.java @@ -0,0 +1,20 @@ +package com.corewing.app.dto; + +import lombok.Data; + +/** + * 登录请求参数 + */ +@Data +public class LoginRequest { + + /** + * 账号(用户名/邮箱/手机号) + */ + private String account; + + /** + * 密码 + */ + private String password; +} diff --git a/src/main/java/com/corewing/app/dto/RegisterRequest.java b/src/main/java/com/corewing/app/dto/RegisterRequest.java new file mode 100644 index 0000000..23e26e0 --- /dev/null +++ b/src/main/java/com/corewing/app/dto/RegisterRequest.java @@ -0,0 +1,45 @@ +package com.corewing.app.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 用户注册请求 + */ +@Data +public class RegisterRequest { + + /** + * 用户名 + */ + @NotBlank(message = "用户名不能为空") + private String username; + + /** + * 密码 + */ + @NotBlank(message = "密码不能为空") + private String password; + + /** + * 邮箱(邮箱和手机号至少填一个) + */ + private String email; + + /** + * 手机号(邮箱和手机号至少填一个) + */ + private String telephone; + + /** + * 验证码 + */ + @NotBlank(message = "验证码不能为空") + private String code; + + /** + * 头像URL + */ + private String avatar; +} diff --git a/src/main/java/com/corewing/app/dto/SendCodeRequest.java b/src/main/java/com/corewing/app/dto/SendCodeRequest.java new file mode 100644 index 0000000..28b1ce0 --- /dev/null +++ b/src/main/java/com/corewing/app/dto/SendCodeRequest.java @@ -0,0 +1,24 @@ +package com.corewing.app.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 发送验证码请求 + */ +@Data +public class SendCodeRequest { + + /** + * 手机号或邮箱 + */ + @NotBlank(message = "手机号或邮箱不能为空") + private String account; + + /** + * 验证码类型:register-注册, login-登录, reset-重置密码 + */ + @NotBlank(message = "验证码类型不能为空") + private String type; +} diff --git a/src/main/java/com/corewing/app/dto/UpdatePasswordRequest.java b/src/main/java/com/corewing/app/dto/UpdatePasswordRequest.java new file mode 100644 index 0000000..f046f0d --- /dev/null +++ b/src/main/java/com/corewing/app/dto/UpdatePasswordRequest.java @@ -0,0 +1,20 @@ +package com.corewing.app.dto; + +import lombok.Data; + +/** + * 修改密码请求参数 + */ +@Data +public class UpdatePasswordRequest { + + /** + * 旧密码 + */ + private String oldPassword; + + /** + * 新密码 + */ + private String newPassword; +} diff --git a/src/main/java/com/corewing/app/entity/AppUser.java b/src/main/java/com/corewing/app/entity/AppUser.java new file mode 100644 index 0000000..2cba28a --- /dev/null +++ b/src/main/java/com/corewing/app/entity/AppUser.java @@ -0,0 +1,74 @@ +package com.corewing.app.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 应用用户实体类 + */ +@Data +@TableName("app_user") +public class AppUser implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 用户ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 邮箱 + */ + private String email; + + /** + * 手机号 + */ + private String telephone; + + /** + * 头像URL + */ + private String avatar; + + /** + * 最后登录IP + */ + private String loginIp; + + /** + * 状态:0-禁用 1-启用 + */ + private Integer status; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/corewing/app/handler/MyMetaObjectHandler.java b/src/main/java/com/corewing/app/handler/MyMetaObjectHandler.java new file mode 100644 index 0000000..ab0bfd7 --- /dev/null +++ b/src/main/java/com/corewing/app/handler/MyMetaObjectHandler.java @@ -0,0 +1,35 @@ +package com.corewing.app.handler; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 自动填充处理器 + * 用于自动填充创建时间和更新时间 + */ +@Component +public class MyMetaObjectHandler implements MetaObjectHandler { + + /** + * 插入时的填充策略 + */ + @Override + public void insertFill(MetaObject metaObject) { + // 自动填充创建时间 + this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); + // 自动填充更新时间 + this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } + + /** + * 更新时的填充策略 + */ + @Override + public void updateFill(MetaObject metaObject) { + // 自动填充更新时间 + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/corewing/app/mapper/AppUserMapper.java b/src/main/java/com/corewing/app/mapper/AppUserMapper.java new file mode 100644 index 0000000..b991b4d --- /dev/null +++ b/src/main/java/com/corewing/app/mapper/AppUserMapper.java @@ -0,0 +1,13 @@ +package com.corewing.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.corewing.app.entity.AppUser; +import org.apache.ibatis.annotations.Mapper; + +/** + * 应用用户 Mapper 接口 + */ +@Mapper +public interface AppUserMapper extends BaseMapper { + +} diff --git a/src/main/java/com/corewing/app/service/AppUserService.java b/src/main/java/com/corewing/app/service/AppUserService.java new file mode 100644 index 0000000..c5fc245 --- /dev/null +++ b/src/main/java/com/corewing/app/service/AppUserService.java @@ -0,0 +1,68 @@ +package com.corewing.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.corewing.app.entity.AppUser; + +/** + * 应用用户 Service 接口 + */ +public interface AppUserService extends IService { + + /** + * 根据用户名查询用户 + * + * @param username 用户名 + * @return 用户信息 + */ + AppUser getByUsername(String username); + + /** + * 根据账号查询用户(支持用户名/邮箱/手机号) + * + * @param account 账号 + * @return 用户信息 + */ + AppUser getByAccount(String account); + + /** + * 根据邮箱查询用户 + * + * @param email 邮箱 + * @return 用户信息 + */ + AppUser getByEmail(String email); + + /** + * 根据手机号查询用户 + * + * @param telephone 手机号 + * @return 用户信息 + */ + AppUser getByTelephone(String telephone); + + /** + * 用户登录 + * + * @param account 账号(用户名/邮箱/手机号) + * @param password 密码 + * @return token + */ + String login(String account, String password); + + /** + * 用户注册 + * + * @param user 用户信息 + * @param code 验证码 + * @return 是否成功 + */ + boolean register(AppUser user, String code); + + /** + * 更新登录IP + * + * @param userId 用户ID + * @param loginIp 登录IP + */ + void updateLoginIp(Long userId, String loginIp); +} diff --git a/src/main/java/com/corewing/app/service/VerifyCodeService.java b/src/main/java/com/corewing/app/service/VerifyCodeService.java new file mode 100644 index 0000000..44864d7 --- /dev/null +++ b/src/main/java/com/corewing/app/service/VerifyCodeService.java @@ -0,0 +1,26 @@ +package com.corewing.app.service; + +/** + * 验证码服务接口 + */ +public interface VerifyCodeService { + + /** + * 发送验证码 + * + * @param account 账号(手机号或邮箱) + * @param type 验证码类型(register-注册, login-登录, reset-重置密码) + * @return 是否发送成功 + */ + boolean sendCode(String account, String type); + + /** + * 验证验证码 + * + * @param account 账号(手机号或邮箱) + * @param code 验证码 + * @param type 验证码类型 + * @return 是否验证成功 + */ + boolean verifyCode(String account, String code, String type); +} diff --git a/src/main/java/com/corewing/app/service/impl/AppUserServiceImpl.java b/src/main/java/com/corewing/app/service/impl/AppUserServiceImpl.java new file mode 100644 index 0000000..8347a7b --- /dev/null +++ b/src/main/java/com/corewing/app/service/impl/AppUserServiceImpl.java @@ -0,0 +1,150 @@ +package com.corewing.app.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.corewing.app.entity.AppUser; +import com.corewing.app.mapper.AppUserMapper; +import com.corewing.app.service.AppUserService; +import com.corewing.app.service.VerifyCodeService; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; + +/** + * 应用用户 Service 实现类 + */ +@Service +public class AppUserServiceImpl extends ServiceImpl implements AppUserService { + + private final VerifyCodeService verifyCodeService; + + public AppUserServiceImpl(VerifyCodeService verifyCodeService) { + this.verifyCodeService = verifyCodeService; + } + + @Override + public AppUser getByUsername(String username) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppUser::getUsername, username); + return this.getOne(wrapper); + } + + @Override + public AppUser getByAccount(String account) { + if (!StringUtils.hasText(account)) { + return null; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppUser::getUsername, account) + .or() + .eq(AppUser::getEmail, account) + .or() + .eq(AppUser::getTelephone, account); + return this.getOne(wrapper); + } + + @Override + public AppUser getByEmail(String email) { + if (!StringUtils.hasText(email)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppUser::getEmail, email); + return this.getOne(wrapper); + } + + @Override + public AppUser getByTelephone(String telephone) { + if (!StringUtils.hasText(telephone)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppUser::getTelephone, telephone); + return this.getOne(wrapper); + } + + @Override + public String login(String account, String password) { + // 查询用户(支持用户名/邮箱/手机号) + AppUser user = getByAccount(account); + if (user == null) { + throw new RuntimeException("用户不存在"); + } + + // 验证密码(MD5加密) + String encryptPassword = DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8)); + if (!encryptPassword.equals(user.getPassword())) { + throw new RuntimeException("密码错误"); + } + + // 检查用户状态 + if (user.getStatus() == 0) { + throw new RuntimeException("账号已被禁用"); + } + + // 登录成功,使用 Sa-Token 生成 token + StpUtil.login(user.getId()); + return StpUtil.getTokenValue(); + } + + @Override + public boolean register(AppUser user, String code) { + // 检查用户名是否已存在 + AppUser existUser = getByUsername(user.getUsername()); + if (existUser != null) { + throw new RuntimeException("用户名已存在"); + } + + // 邮箱和手机号至少要有一个 + if (!StringUtils.hasText(user.getEmail()) && !StringUtils.hasText(user.getTelephone())) { + throw new RuntimeException("邮箱和手机号至少填写一个"); + } + + // 检查邮箱是否已存在 + if (StringUtils.hasText(user.getEmail())) { + existUser = getByEmail(user.getEmail()); + if (existUser != null) { + throw new RuntimeException("邮箱已被使用"); + } + } + + // 检查手机号是否已存在 + if (StringUtils.hasText(user.getTelephone())) { + existUser = getByTelephone(user.getTelephone()); + if (existUser != null) { + throw new RuntimeException("手机号已被使用"); + } + } + + // 验证验证码 + String account = StringUtils.hasText(user.getTelephone()) ? user.getTelephone() : user.getEmail(); + boolean codeValid = verifyCodeService.verifyCode(account, code, "register"); + if (!codeValid) { + throw new RuntimeException("验证码错误或已过期"); + } + + // 密码加密(MD5) + String encryptPassword = DigestUtils.md5DigestAsHex(user.getPassword().getBytes(StandardCharsets.UTF_8)); + user.setPassword(encryptPassword); + + // 设置默认状态为启用 + if (user.getStatus() == null) { + user.setStatus(1); + } + + // 保存用户 + return this.save(user); + } + + @Override + public void updateLoginIp(Long userId, String loginIp) { + AppUser user = new AppUser(); + user.setId(userId); + user.setLoginIp(loginIp); + this.updateById(user); + } +} diff --git a/src/main/java/com/corewing/app/service/impl/VerifyCodeServiceImpl.java b/src/main/java/com/corewing/app/service/impl/VerifyCodeServiceImpl.java new file mode 100644 index 0000000..34d7ff4 --- /dev/null +++ b/src/main/java/com/corewing/app/service/impl/VerifyCodeServiceImpl.java @@ -0,0 +1,140 @@ +package com.corewing.app.service.impl; + +import com.corewing.app.service.VerifyCodeService; +import com.corewing.app.util.EmailUtil; +import com.corewing.app.util.RedisUtil; +import com.corewing.app.util.SmsBaoUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +/** + * 验证码服务实现类 + */ +@Slf4j +@Service +public class VerifyCodeServiceImpl implements VerifyCodeService { + + private final RedisUtil redisUtil; + private final SmsBaoUtil smsBaoUtil; + private final EmailUtil emailUtil; + + /** + * 验证码有效期(分钟) + */ + private static final int CODE_EXPIRE_MINUTES = 5; + + /** + * 验证码长度 + */ + private static final int CODE_LENGTH = 6; + + /** + * 手机号正则 + */ + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + + /** + * 邮箱正则 + */ + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$"); + + public VerifyCodeServiceImpl(RedisUtil redisUtil, SmsBaoUtil smsBaoUtil, EmailUtil emailUtil) { + this.redisUtil = redisUtil; + this.smsBaoUtil = smsBaoUtil; + this.emailUtil = emailUtil; + } + + @Override + public boolean sendCode(String account, String type) { + if (!StringUtils.hasText(account)) { + throw new RuntimeException("账号不能为空"); + } + + if (!StringUtils.hasText(type)) { + throw new RuntimeException("验证码类型不能为空"); + } + + // 判断是手机号还是邮箱 + boolean isPhone = PHONE_PATTERN.matcher(account).matches(); + boolean isEmail = EMAIL_PATTERN.matcher(account).matches(); + + if (!isPhone && !isEmail) { + throw new RuntimeException("请输入正确的手机号或邮箱"); + } + + // 生成验证码 + String code = generateCode(); + + // 保存到 Redis,key格式:verify_code:{type}:{account} + String redisKey = String.format("verify_code:%s:%s", type, account); + redisUtil.set(redisKey, code, CODE_EXPIRE_MINUTES * 60); + + log.info("验证码已生成: account={}, type={}, code={}", account, type, code); + + // 发送验证码 + if (isPhone) { + // 发送短信验证码 + boolean success = smsBaoUtil.sendVerifyCode(account, code); + if (!success) { + throw new RuntimeException("短信发送失败,请稍后重试"); + } + } else { + // 发送邮件验证码 + boolean success = emailUtil.sendVerifyCode(account, code); + if (!success) { + throw new RuntimeException("邮件发送失败,请稍后重试"); + } + } + + return true; + } + + @Override + public boolean verifyCode(String account, String code, String type) { + if (!StringUtils.hasText(account) || !StringUtils.hasText(code) || !StringUtils.hasText(type)) { + return false; + } + + // 从 Redis 获取验证码 + String redisKey = String.format("verify_code:%s:%s", type, account); + Object savedCode = redisUtil.get(redisKey); + + if (savedCode == null) { + log.warn("验证码不存在或已过期: account={}, type={}", account, type); + return false; + } + + // 验证码比对 + boolean matched = code.equals(savedCode.toString()); + + if (matched) { + // 验证成功后删除验证码 + redisUtil.del(redisKey); + log.info("验证码验证成功: account={}, type={}", account, type); + } else { + log.warn("验证码错误: account={}, type={}, inputCode={}, savedCode={}", + account, type, code, savedCode); + } + + return matched; + } + + /** + * 生成随机验证码 + * + * @return 验证码 + */ + private String generateCode() { + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < CODE_LENGTH; i++) { + code.append(random.nextInt(10)); + } + return code.toString(); + } +} diff --git a/src/main/java/com/corewing/app/util/EmailUtil.java b/src/main/java/com/corewing/app/util/EmailUtil.java new file mode 100644 index 0000000..e825d8e --- /dev/null +++ b/src/main/java/com/corewing/app/util/EmailUtil.java @@ -0,0 +1,141 @@ +package com.corewing.app.util; + +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.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; + +/** + * 邮件发送工具类 + */ +@Slf4j +@Component +public class EmailUtil { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String from; + + public EmailUtil(JavaMailSender mailSender) { + this.mailSender = mailSender; + } + + /** + * 发送简单文本邮件 + * + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content 邮件内容 + * @return 是否发送成功 + */ + public boolean sendSimpleMail(String to, String subject, String content) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(content); + + mailSender.send(message); + log.info("简单文本邮件发送成功, 收件人: {}, 主题: {}", to, subject); + return true; + } catch (Exception e) { + log.error("简单文本邮件发送失败, 收件人: {}, 主题: {}, 异常信息: {}", to, subject, e.getMessage(), e); + return false; + } + } + + /** + * 发送HTML邮件 + * + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content HTML内容 + * @return 是否发送成功 + */ + public boolean sendHtmlMail(String to, String subject, String content) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + mailSender.send(message); + log.info("HTML邮件发送成功, 收件人: {}, 主题: {}", to, subject); + return true; + } catch (MessagingException e) { + log.error("HTML邮件发送失败, 收件人: {}, 主题: {}, 异常信息: {}", to, subject, e.getMessage(), e); + return false; + } + } + + /** + * 发送验证码邮件 + * + * @param to 收件人邮箱 + * @param code 验证码 + * @return 是否发送成功 + */ + public boolean sendVerifyCode(String to, String code) { + String subject = "【CoreWing】验证码"; + String content = buildVerifyCodeHtml(code); + return sendHtmlMail(to, subject, content); + } + + /** + * 构建验证码邮件HTML内容 + * + * @param code 验证码 + * @return HTML内容 + */ + private String buildVerifyCodeHtml(String code) { + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "
" + + "

CoreWing 验证码

" + + "
" + + "
" + + "

您好!

" + + "

您正在进行身份验证,您的验证码是:

" + + "
" + + "
" + code + "
" + + "
" + + "
" + + "

⏰ 验证码有效期为 5分钟,请尽快使用。

" + + "

🔒 为了您的账户安全,请勿将验证码告知他人。

" + + "

❓ 如果这不是您本人的操作,请忽略此邮件。

" + + "
" + + "
" + + "
" + + "

此邮件由系统自动发送,请勿回复。

" + + "

© 2025 CoreWing. All rights reserved.

" + + "
" + + "
" + + "" + + ""; + } +} diff --git a/src/main/java/com/corewing/app/util/IpUtil.java b/src/main/java/com/corewing/app/util/IpUtil.java new file mode 100644 index 0000000..b22c766 --- /dev/null +++ b/src/main/java/com/corewing/app/util/IpUtil.java @@ -0,0 +1,44 @@ +package com.corewing.app.util; + +import javax.servlet.http.HttpServletRequest; + +/** + * IP 工具类 + */ +public class IpUtil { + + /** + * 获取客户端真实IP + * + * @param request HttpServletRequest + * @return 客户端IP地址 + */ + public static String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + + // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割 + if (ip != null && ip.length() > 15 && ip.contains(",")) { + ip = ip.substring(0, ip.indexOf(",")); + } + + return ip; + } +} diff --git a/src/main/java/com/corewing/app/util/RedisUtil.java b/src/main/java/com/corewing/app/util/RedisUtil.java new file mode 100644 index 0000000..2797f7e --- /dev/null +++ b/src/main/java/com/corewing/app/util/RedisUtil.java @@ -0,0 +1,564 @@ +package com.corewing.app.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +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; + +/** + * Redis 工具类 + */ +@SuppressWarnings("CallToPrintStackTrace") +@Component +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + public RedisUtil(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + // =============================common============================ + + /** + * 指定缓存失效时间 + * + * @param key 键 + * @param time 时间(秒) + */ + public boolean expire(String key, long time) { + try { + if (time > 0) { + redisTemplate.expire(key, time, TimeUnit.SECONDS); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 根据key 获取过期时间 + * + * @param key 键 不能为null + * @return 时间(秒) 返回0代表为永久有效 + */ + public long getExpire(String key) { + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + } + + /** + * 判断key是否存在 + * + * @param key 键 + * @return true 存在 false不存在 + */ + public boolean hasKey(String key) { + try { + return redisTemplate.hasKey(key); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 删除缓存 + * + * @param key 可以传一个值 或多个 + */ + public void del(String... key) { + if (key != null && key.length > 0) { + if (key.length == 1) { + redisTemplate.delete(key[0]); + } else { + redisTemplate.delete((Collection) List.of(key)); + } + } + } + + // ============================String============================= + + /** + * 普通缓存获取 + * + * @param key 键 + * @return 值 + */ + public Object get(String key) { + return key == null ? null : redisTemplate.opsForValue().get(key); + } + + /** + * 普通缓存放入 + * + * @param key 键 + * @param value 值 + * @return true成功 false失败 + */ + public boolean set(String key, Object value) { + try { + redisTemplate.opsForValue().set(key, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 普通缓存放入并设置时间 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期 + * @return true成功 false 失败 + */ + public boolean set(String key, Object value, long time) { + try { + if (time > 0) { + redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); + } else { + set(key, value); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 递增 + * + * @param key 键 + * @param delta 要增加几(大于0) + */ + public long incr(String key, long delta) { + if (delta < 0) { + throw new RuntimeException("递增因子必须大于0"); + } + Long result = redisTemplate.opsForValue().increment(key, delta); + return result != null ? result : 0L; + } + + /** + * 递减 + * + * @param key 键 + * @param delta 要减少几(小于0) + */ + public long decr(String key, long delta) { + if (delta < 0) { + throw new RuntimeException("递减因子必须大于0"); + } + Long result = redisTemplate.opsForValue().increment(key, -delta); + return result != null ? result : 0L; + } + + // ================================Hash================================= + + /** + * HashGet + * + * @param key 键 不能为null + * @param item 项 不能为null + */ + public Object hget(String key, String item) { + return redisTemplate.opsForHash().get(key, item); + } + + /** + * 获取hashKey对应的所有键值 + * + * @param key 键 + * @return 对应的多个键值 + */ + public Map hmget(String key) { + return redisTemplate.opsForHash().entries(key); + } + + /** + * HashSet + * + * @param key 键 + * @param map 对应多个键值 + */ + public boolean hmset(String key, Map map) { + try { + redisTemplate.opsForHash().putAll(key, map); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * HashSet 并设置时间 + * + * @param key 键 + * @param map 对应多个键值 + * @param time 时间(秒) + * @return true成功 false失败 + */ + public boolean hmset(String key, Map map, long time) { + try { + redisTemplate.opsForHash().putAll(key, map); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 向一张hash表中放入数据,如果不存在将创建 + * + * @param key 键 + * @param item 项 + * @param value 值 + * @return true 成功 false失败 + */ + public boolean hset(String key, String item, Object value) { + try { + redisTemplate.opsForHash().put(key, item, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 向一张hash表中放入数据,如果不存在将创建 + * + * @param key 键 + * @param item 项 + * @param value 值 + * @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间 + * @return true 成功 false失败 + */ + public boolean hset(String key, String item, Object value, long time) { + try { + redisTemplate.opsForHash().put(key, item, value); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 删除hash表中的值 + * + * @param key 键 不能为null + * @param item 项 可以使多个 不能为null + */ + public void hdel(String key, Object... item) { + redisTemplate.opsForHash().delete(key, item); + } + + /** + * 判断hash表中是否有该项的值 + * + * @param key 键 不能为null + * @param item 项 不能为null + * @return true 存在 false不存在 + */ + public boolean hHasKey(String key, String item) { + return redisTemplate.opsForHash().hasKey(key, item); + } + + /** + * hash递增 如果不存在,就会创建一个 并把新增后的值返回 + * + * @param key 键 + * @param item 项 + * @param by 要增加几(大于0) + */ + public double hincr(String key, String item, double by) { + return redisTemplate.opsForHash().increment(key, item, by); + } + + /** + * hash递减 + * + * @param key 键 + * @param item 项 + * @param by 要减少记(小于0) + */ + public double hdecr(String key, String item, double by) { + return redisTemplate.opsForHash().increment(key, item, -by); + } + + // ============================Set============================= + + /** + * 根据key获取Set中的所有值 + * + * @param key 键 + */ + public Set sGet(String key) { + try { + return redisTemplate.opsForSet().members(key); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 根据value从一个set中查询,是否存在 + * + * @param key 键 + * @param value 值 + * @return true 存在 false不存在 + */ + public boolean sHasKey(String key, Object value) { + try { + Boolean result = redisTemplate.opsForSet().isMember(key, value); + return result != null && result; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将数据放入set缓存 + * + * @param key 键 + * @param values 值 可以是多个 + * @return 成功个数 + */ + public long sSet(String key, Object... values) { + try { + Long result = redisTemplate.opsForSet().add(key, values); + return result != null ? result : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 将set数据放入缓存 + * + * @param key 键 + * @param time 时间(秒) + * @param values 值 可以是多个 + * @return 成功个数 + */ + public long sSetAndTime(String key, long time, Object... values) { + try { + Long count = redisTemplate.opsForSet().add(key, values); + if (time > 0) { + expire(key, time); + } + return count != null ? count : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 获取set缓存的长度 + * + * @param key 键 + */ + public long sGetSetSize(String key) { + try { + Long result = redisTemplate.opsForSet().size(key); + return result != null ? result : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 移除值为value的 + * + * @param key 键 + * @param values 值 可以是多个 + * @return 移除的个数 + */ + public long setRemove(String key, Object... values) { + try { + Long count = redisTemplate.opsForSet().remove(key, values); + return count != null ? count : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + // ===============================List================================= + + /** + * 获取list缓存的内容 + * + * @param key 键 + * @param start 开始 + * @param end 结束 0 到 -1代表所有值 + */ + public List lGet(String key, long start, long end) { + try { + return redisTemplate.opsForList().range(key, start, end); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 获取list缓存的长度 + * + * @param key 键 + */ + public long lGetListSize(String key) { + try { + Long result = redisTemplate.opsForList().size(key); + return result != null ? result : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 通过索引 获取list中的值 + * + * @param key 键 + * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推 + */ + public Object lGetIndex(String key, long index) { + try { + return redisTemplate.opsForList().index(key, index); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + */ + public boolean lSet(String key, Object value) { + try { + redisTemplate.opsForList().rightPush(key, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) + */ + public boolean lSet(String key, Object value, long time) { + try { + redisTemplate.opsForList().rightPush(key, value); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + */ + public boolean lSet(String key, List value) { + try { + redisTemplate.opsForList().rightPushAll(key, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将list放入缓存 + * + * @param key 键 + * @param value 值 + * @param time 时间(秒) + */ + public boolean lSet(String key, List value, long time) { + try { + redisTemplate.opsForList().rightPushAll(key, value); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 根据索引修改list中的某条数据 + * + * @param key 键 + * @param index 索引 + * @param value 值 + */ + public boolean lUpdateIndex(String key, long index, Object value) { + try { + redisTemplate.opsForList().set(key, index, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 移除N个值为value + * + * @param key 键 + * @param count 移除多少个 + * @param value 值 + * @return 移除的个数 + */ + public long lRemove(String key, long count, Object value) { + try { + Long remove = redisTemplate.opsForList().remove(key, count, value); + return remove != null ? remove : 0L; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } +} diff --git a/src/main/java/com/corewing/app/util/SmsBaoUtil.java b/src/main/java/com/corewing/app/util/SmsBaoUtil.java new file mode 100644 index 0000000..81612e5 --- /dev/null +++ b/src/main/java/com/corewing/app/util/SmsBaoUtil.java @@ -0,0 +1,118 @@ +package com.corewing.app.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * 短信宝工具类 + * 官方文档: https://www.smsbao.com/ + */ +@Slf4j +@Component +public class SmsBaoUtil { + + @Value("${smsbao.username:}") + private String username; + + @Value("${smsbao.password:}") + private String password; + + /** + * 短信宝API地址 + */ + private static final String SMS_API_URL = "http://api.smsbao.com/sms"; + + /** + * 发送短信 + * + * @param phone 手机号 + * @param content 短信内容 + * @return 是否发送成功 + */ + public boolean sendSms(String phone, String content) { + try { + // URL编码 + String encodedContent = URLEncoder.encode(content, StandardCharsets.UTF_8.name()); + + // 构建请求URL + String requestUrl = String.format("%s?u=%s&p=%s&m=%s&c=%s", + SMS_API_URL, username, password, phone, encodedContent); + + // 发送HTTP请求 + URL url = new URL(requestUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + // 读取响应 + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)); + String response = reader.readLine(); + reader.close(); + + // 解析响应码 + int code = Integer.parseInt(response); + if (code == 0) { + log.info("短信发送成功, 手机号: {}", phone); + return true; + } else { + log.error("短信发送失败, 手机号: {}, 错误码: {}, 错误信息: {}", + phone, code, getErrorMessage(code)); + return false; + } + } catch (Exception e) { + log.error("短信发送异常, 手机号: {}, 异常信息: {}", phone, e.getMessage(), e); + return false; + } + } + + /** + * 发送验证码短信 + * + * @param phone 手机号 + * @param code 验证码 + * @return 是否发送成功 + */ + public boolean sendVerifyCode(String phone, String code) { + String content = String.format("【CoreWing】您的验证码是%s,5分钟内有效。请勿泄露给他人!", code); + return sendSms(phone, content); + } + + /** + * 获取错误信息 + * + * @param code 错误码 + * @return 错误信息 + */ + private String getErrorMessage(int code) { + switch (code) { + case 0: + return "发送成功"; + case 30: + return "密码错误"; + case 40: + return "账号不存在"; + case 41: + return "余额不足"; + case 42: + return "账户已过期"; + case 43: + return "IP地址限制"; + case 50: + return "内容含有敏感词"; + case 51: + return "手机号码不正确"; + default: + return "未知错误"; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e52b498..842c78d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,77 @@ # 应用服务 WEB 访问端口 server.port=8080 +# 数据源配置 +spring.datasource.type=com.alibaba.druid.pool.DruidDataSource +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.url=jdbc:mysql://120.24.204.180:3306/app?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai +spring.datasource.username=app +spring.datasource.password=AB636NGhhH2cC5A5 + +# Druid 连接池配置 +spring.datasource.druid.initial-size=5 +spring.datasource.druid.min-idle=5 +spring.datasource.druid.max-active=20 +spring.datasource.druid.max-wait=60000 +spring.datasource.druid.test-while-idle=true +spring.datasource.druid.test-on-borrow=false +spring.datasource.druid.test-on-return=false +spring.datasource.druid.time-between-eviction-runs-millis=60000 +spring.datasource.druid.min-evictable-idle-time-millis=300000 +spring.datasource.druid.validation-query=SELECT 1 +spring.datasource.druid.pool-prepared-statements=true +spring.datasource.druid.max-pool-prepared-statement-per-connection-size=20 +# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙 +spring.datasource.druid.filters=stat,wall +# 通过connectProperties属性来打开mergeSql功能;慢SQL记录 +spring.datasource.druid.connection-properties=druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000 + +# MyBatis-Plus 配置 +mybatis-plus.mapper-locations=classpath*:/mapper/**/*.xml +mybatis-plus.type-aliases-package=com.corewing.app.entity +mybatis-plus.configuration.map-underscore-to-camel-case=true +mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl +mybatis-plus.global-config.db-config.id-type=AUTO +mybatis-plus.global-config.db-config.table-prefix= + +# Redis 配置 +spring.redis.host=localhost +spring.redis.port=6379 +spring.redis.password= +spring.redis.database=0 +spring.redis.timeout=3000 +spring.redis.lettuce.pool.max-active=8 +spring.redis.lettuce.pool.max-idle=8 +spring.redis.lettuce.pool.min-idle=0 +spring.redis.lettuce.pool.max-wait=-1ms + +# Sa-Token 配置 +sa-token.token-name=Authorization +sa-token.timeout=2592000 +sa-token.active-timeout=-1 +sa-token.is-concurrent=true +sa-token.is-share=true +sa-token.token-style=uuid +sa-token.is-log=false + +# 短信宝配置 +# 请前往 https://www.smsbao.com/ 注册账号并获取用户名和密码 +smsbao.username=your_username +smsbao.password=your_password + +# 邮件配置 +# SMTP 服务器地址 +spring.mail.host=smtp.chengmail.cn +# SMTP 服务器端口 +spring.mail.port=465 +# 发件人邮箱 +spring.mail.username=dev@corewing.com +# 邮箱授权码(不是邮箱密码!需要在邮箱设置中开启 SMTP 服务并获取授权码) +spring.mail.password=HRTmmNrBRjSxfwAk +# 编码格式 +spring.mail.default-encoding=UTF-8 +# 其他配置 +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.properties.mail.smtp.ssl.enable=false diff --git a/src/main/resources/db/user.sql b/src/main/resources/db/user.sql new file mode 100644 index 0000000..c8bc057 --- /dev/null +++ b/src/main/resources/db/user.sql @@ -0,0 +1,26 @@ +-- 用户表 +DROP TABLE IF EXISTS `app_user`; + +CREATE TABLE `app_user` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(100) NOT NULL COMMENT '密码', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `telephone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', + `login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + `status` TINYINT(1) DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_email` (`email`), + UNIQUE KEY `uk_telephone` (`telephone`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用用户表'; + +-- 插入测试用户(密码为:123456,实际使用时应该加密) +INSERT INTO `app_user` (`username`, `password`, `email`, `telephone`, `avatar`, `status`) +VALUES +('admin', '123456', 'admin@corewing.com', '13800138000', NULL, 1), +('test', '123456', 'test@corewing.com', '13800138001', NULL, 1); diff --git a/src/main/resources/docs/API接口说明.md b/src/main/resources/docs/API接口说明.md new file mode 100644 index 0000000..eb8c7bd --- /dev/null +++ b/src/main/resources/docs/API接口说明.md @@ -0,0 +1,356 @@ +# API 接口说明 + +## 用户相关接 + +### 1. 发送验证码 + +**接口地址:** `POST /user/sendCode` + +**请求参数:** +```json +{ + "account": "13800138000", // 手机号或邮箱 + "type": "register" // 验证码类型: register-注册, login-登录, reset-重置密码 +} +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "验证码发送成功", + "data": null +} +``` + +**说明:** +- 手机号格式: 1开头的11位数字 +- 邮箱格式: 标准邮箱格式 +- 验证码有效期: 5分钟 +- 验证码长度: 6位数字 + +--- + +### 2. 用户注册 + +**接口地址:** `POST /user/register` + +**请求参数:** +```json +{ + "username": "testuser", // 用户名(必填) + "password": "123456", // 密码(必填) + "email": "test@example.com", // 邮箱(邮箱和手机号至少填一个) + "telephone": "13800138000", // 手机号(邮箱和手机号至少填一个) + "code": "123456", // 验证码(必填) + "avatar": "http://..." // 头像URL(可选) +} +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "注册成功", + "data": null +} +``` + +**说明:** +- 用户名不能重复 +- 邮箱和手机号至少填写一个 +- 邮箱和手机号不能重复 +- 需要先调用发送验证码接口 +- 密码会自动进行 MD5 加密 + +--- + +### 3. 用户登录 + +**接口地址:** `POST /user/login` + +**请求参数:** +```json +{ + "account": "testuser", // 账号(用户名/邮箱/手机号) + "password": "123456" // 密码 +} +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "登录成功", + "data": { + "token": "uuid-token-string", + "userId": 1, + "username": "testuser" + } +} +``` + +**说明:** +- account 支持用户名、邮箱、手机号三种方式登录 +- 登录成功后返回 token,后续请求需要在 Header 中携带: `Authorization: token值` + +--- + +### 4. 用户登出 + +**接口地址:** `POST /user/logout` + +**请求头:** +``` +Authorization: your-token +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "登出成功", + "data": null +} +``` + +--- + +### 5. 获取当前用户信息 + +**接口地址:** `GET /user/info` + +**请求头:** +``` +Authorization: your-token +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "username": "testuser", + "password": null, + "email": "test@example.com", + "telephone": "13800138000", + "avatar": null, + "loginIp": "127.0.0.1", + "status": 1, + "createTime": "2025-01-01T12:00:00", + "updateTime": "2025-01-01T12:00:00" + } +} +``` + +--- + +### 6. 根据ID查询用户 + +**接口地址:** `GET /user/{id}` + +**请求头:** +``` +Authorization: your-token +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 1, + "username": "testuser", + "password": null, + "email": "test@example.com", + "telephone": "13800138000", + "avatar": null, + "loginIp": "127.0.0.1", + "status": 1, + "createTime": "2025-01-01T12:00:00", + "updateTime": "2025-01-01T12:00:00" + } +} +``` + +--- + +### 7. 更新用户信息 + +**接口地址:** `PUT /user` + +**请求头:** +``` +Authorization: your-token +``` + +**请求参数:** +```json +{ + "id": 1, + "username": "newusername", + "email": "newemail@example.com", + "telephone": "13900139000", + "avatar": "http://..." +} +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "更新成功", + "data": null +} +``` + +**说明:** +- 不能通过此接口修改密码 +- 需要修改密码请使用专门的修改密码接口 + +--- + +### 8. 修改密码 + +**接口地址:** `PUT /user/password` + +**请求头:** +``` +Authorization: your-token +``` + +**请求参数:** +```json +{ + "oldPassword": "123456", + "newPassword": "654321" +} +``` + +**响应示例:** +```json +{ + "code": 200, + "message": "密码修改成功", + "data": null +} +``` + +--- + +## 配置说明 + +### 短信宝配置 + +在 `application.properties` 中配置短信宝账号信息: + +```properties +# 短信宝配置 +# 请前往 https://www.smsbao.com/ 注册账号并获取用户名和密码 +smsbao.username=your_username +smsbao.password=your_password +``` + +**注册短信宝账号:** +1. 访问 https://www.smsbao.com/ +2. 注册账号并充值 +3. 获取用户名和密码(注意:密码是 MD5 加密后的值) +4. 配置到 application.properties 中 + +--- + +### 邮件配置 + +在 `application.properties` 中配置邮箱信息: + +```properties +# 邮件配置(以 QQ 邮箱为例) +spring.mail.host=smtp.qq.com +spring.mail.port=587 +spring.mail.username=your_email@qq.com +spring.mail.password=your_authorization_code +spring.mail.default-encoding=UTF-8 +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +``` + +**配置 QQ 邮箱:** +1. 登录 QQ 邮箱网页版 +2. 进入【设置】->【账户】 +3. 找到【POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务】 +4. 开启 SMTP 服务 +5. 生成授权码(注意:不是 QQ 密码!) +6. 将邮箱地址和授权码配置到 application.properties 中 + +**其他邮箱配置:** +- **163 邮箱**: `smtp.163.com`,端口 `465` 或 `25` +- **Gmail**: `smtp.gmail.com`,端口 `587` +- **企业邮箱**: 联系管理员获取 SMTP 服务器地址 + +--- + +## 数据库说明 + +### 用户表结构 + +```sql +CREATE TABLE `app_user` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL COMMENT '用户名', + `password` VARCHAR(100) NOT NULL COMMENT '密码', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `telephone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', + `login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', + `status` TINYINT(1) DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `create_time` DATETIME DEFAULT NULL COMMENT '创建时间', + `update_time` DATETIME DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_email` (`email`), + UNIQUE KEY `uk_telephone` (`telephone`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用用户表'; +``` + +--- + +## 错误码说明 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 500 | 失败 | + +--- + +## 开发说明 + +### 验证码存储 + +- 验证码存储在 Redis 中 +- Key 格式: `verify_code:{type}:{account}` +- 有效期: 5分钟 +- 验证成功后自动删除 + +### 密码加密 + +- 使用 MD5 加密 +- 前端传输明文密码,后端自动加密存储 + +### 登录认证 + +- 使用 Sa-Token 进行身份认证 +- Token 有效期: 30天 +- 除登录、注册、发送验证码接口外,其他接口都需要认证 + +### 验证码发送 + +- **手机验证码**: 通过短信宝自动发送短信 +- **邮件验证码**: 通过 Spring Mail 自动发送 HTML 格式的精美邮件 +- 验证码为 6 位随机数字 +- 邮件模板包含品牌样式,提升用户体验 diff --git a/src/test/java/com/corewing/app/util/EmailUtilTest.java b/src/test/java/com/corewing/app/util/EmailUtilTest.java new file mode 100644 index 0000000..8adb79f --- /dev/null +++ b/src/test/java/com/corewing/app/util/EmailUtilTest.java @@ -0,0 +1,150 @@ +package com.corewing.app.util; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * 邮件发送测试类 + * + * 使用说明: + * 1. 确保 application.properties 中已正确配置邮件服务器信息 + * 2. 将下面测试方法中的收件人邮箱改为你自己的邮箱 + * 3. 运行测试前确保网络连接正常 + */ +@SpringBootTest +public class EmailUtilTest { + + @Autowired + private EmailUtil emailUtil; + + /** + * 测试发送简单文本邮件 + */ + @Test + public void testSendSimpleMail() { + String to = "zmissu@163.com"; // 修改为实际收件人邮箱 + String subject = "【CoreWing】简单文本邮件测试"; + String content = "这是一封测试邮件,如果您收到这封邮件,说明邮件发送功能正常。\n\n发送时间:" + + java.time.LocalDateTime.now(); + + boolean success = emailUtil.sendSimpleMail(to, subject, content); + + if (success) { + System.out.println("✅ 简单文本邮件发送成功!请检查收件箱: " + to); + } else { + System.err.println("❌ 简单文本邮件发送失败!请检查邮件配置和网络连接。"); + } + } + + /** + * 测试发送HTML邮件 + */ + @Test + public void testSendHtmlMail() { + String to = "test@example.com"; // 修改为实际收件人邮箱 + String subject = "【CoreWing】HTML邮件测试"; + String content = "" + + "" + + "
" + + "

CoreWing 系统通知

" + + "

这是一封 HTML格式 的测试邮件。

" + + "

如果您能看到这封邮件的样式,说明HTML邮件发送功能正常。

" + + "
" + + "

发送时间:" + + java.time.LocalDateTime.now() + "

" + + "
" + + "" + + ""; + + boolean success = emailUtil.sendHtmlMail(to, subject, content); + + if (success) { + System.out.println("✅ HTML邮件发送成功!请检查收件箱: " + to); + } else { + System.err.println("❌ HTML邮件发送失败!请检查邮件配置和网络连接。"); + } + } + + /** + * 测试发送验证码邮件 + */ + @Test + public void testSendVerifyCode() { + String to = "test@example.com"; // 修改为实际收件人邮箱 + String code = "123456"; // 测试验证码 + + boolean success = emailUtil.sendVerifyCode(to, code); + + if (success) { + System.out.println("✅ 验证码邮件发送成功!"); + System.out.println(" 收件人: " + to); + System.out.println(" 验证码: " + code); + System.out.println(" 请检查收件箱(可能在垃圾邮件中)"); + } else { + System.err.println("❌ 验证码邮件发送失败!"); + System.err.println(" 可能的原因:"); + System.err.println(" 1. 邮件服务器配置错误"); + System.err.println(" 2. 邮箱授权码不正确"); + System.err.println(" 3. 网络连接问题"); + System.err.println(" 4. 邮件服务器防火墙限制"); + } + } + + /** + * 测试发送多个验证码邮件(模拟实际场景) + */ + @Test + public void testSendMultipleVerifyCode() { + String[] emails = { + "test1@example.com", // 修改为实际收件人邮箱 + "test2@example.com" // 可以添加更多测试邮箱 + }; + + System.out.println("🚀 开始批量发送验证码邮件测试...\n"); + + int successCount = 0; + int failCount = 0; + + for (int i = 0; i < emails.length; i++) { + String email = emails[i]; + String code = generateRandomCode(); + + System.out.println("📧 正在发送第 " + (i + 1) + " 封邮件..."); + System.out.println(" 收件人: " + email); + System.out.println(" 验证码: " + code); + + boolean success = emailUtil.sendVerifyCode(email, code); + + if (success) { + System.out.println(" ✅ 发送成功\n"); + successCount++; + } else { + System.err.println(" ❌ 发送失败\n"); + failCount++; + } + + // 避免发送过快被限流,间隔1秒 + if (i < emails.length - 1) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + System.out.println("📊 测试完成统计:"); + System.out.println(" 总计: " + emails.length + " 封"); + System.out.println(" 成功: " + successCount + " 封"); + System.out.println(" 失败: " + failCount + " 封"); + System.out.println(" 成功率: " + (successCount * 100 / emails.length) + "%"); + } + + /** + * 生成随机6位验证码 + */ + private String generateRandomCode() { + return String.format("%06d", (int)(Math.random() * 1000000)); + } +} diff --git a/邮件发送测试说明.md b/邮件发送测试说明.md new file mode 100644 index 0000000..00d1383 --- /dev/null +++ b/邮件发送测试说明.md @@ -0,0 +1,240 @@ +# 邮件发送测试说明 + +## 📧 功能概述 + +项目已集成邮件发送功能,支持: +- 简单文本邮件 +- HTML格式邮件 +- 验证码邮件(带精美HTML模板) + +## 🔧 配置步骤 + +### 1. 邮箱配置 + +在 `src/main/resources/application.properties` 中配置邮件服务器信息: + +```properties +# 邮件配置 +spring.mail.host=smtp.chengmail.cn # SMTP服务器地址 +spring.mail.port=465 # SMTP端口 +spring.mail.username=dev@corewing.com # 发件人邮箱 +spring.mail.password=HRTmmNrBRjSxfwAk # 邮箱授权码 +spring.mail.default-encoding=UTF-8 +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.properties.mail.smtp.ssl.enable=false +``` + +### 2. 常用邮箱SMTP配置 + +#### QQ邮箱 +```properties +spring.mail.host=smtp.qq.com +spring.mail.port=587 # 或 465 (SSL) +spring.mail.username=your_email@qq.com +spring.mail.password=your_authorization_code +``` + +**获取QQ邮箱授权码:** +1. 登录QQ邮箱网页版 +2. 设置 -> 账户 +3. 开启 "POP3/SMTP服务" 或 "IMAP/SMTP服务" +4. 生成授权码(16位字符) + +#### 163邮箱 +```properties +spring.mail.host=smtp.163.com +spring.mail.port=465 +spring.mail.username=your_email@163.com +spring.mail.password=your_authorization_code +``` + +#### Gmail +```properties +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=your_email@gmail.com +spring.mail.password=your_app_password +``` + +#### 企业邮箱(腾讯企业邮箱为例) +```properties +spring.mail.host=smtp.exmail.qq.com +spring.mail.port=465 +spring.mail.username=your_email@your_domain.com +spring.mail.password=your_password +``` + +## 🧪 运行测试 + +### 方法一:使用IDE运行 + +1. 打开测试文件:`src/test/java/com/corewing/app/util/EmailUtilTest.java` + +2. 修改测试邮箱地址: + ```java + String to = "test@example.com"; // 改为你的邮箱 + ``` + +3. 运行测试方法: + - `testSendSimpleMail()` - 测试简单文本邮件 + - `testSendHtmlMail()` - 测试HTML邮件 + - `testSendVerifyCode()` - 测试验证码邮件 + - `testSendMultipleVerifyCode()` - 测试批量发送 + +### 方法二:使用Gradle命令行 + +```bash +# 运行所有邮件测试 +./gradlew test --tests EmailUtilTest + +# 运行单个测试方法 +./gradlew test --tests EmailUtilTest.testSendVerifyCode +``` + +## 📝 测试示例 + +### 1. 简单文本邮件测试 + +```java +@Test +public void testSendSimpleMail() { + String to = "your_email@example.com"; + String subject = "【CoreWing】简单文本邮件测试"; + String content = "这是一封测试邮件"; + + boolean success = emailUtil.sendSimpleMail(to, subject, content); + System.out.println(success ? "发送成功" : "发送失败"); +} +``` + +### 2. 验证码邮件测试 + +```java +@Test +public void testSendVerifyCode() { + String to = "your_email@example.com"; + String code = "123456"; + + boolean success = emailUtil.sendVerifyCode(to, code); + System.out.println(success ? "发送成功" : "发送失败"); +} +``` + +## ✅ 预期结果 + +### 成功标志 +``` +✅ 验证码邮件发送成功! + 收件人: test@example.com + 验证码: 123456 + 请检查收件箱(可能在垃圾邮件中) +``` + +### 验证码邮件样式 +邮件将以精美的HTML格式展示: +- 紫色渐变标题 +- 大字号验证码显示 +- 友好的提示信息 +- 有效期说明(5分钟) + +## ❌ 常见问题 + +### 1. 发送失败:认证失败 +**错误信息:** `Authentication failed` + +**解决方法:** +- 确认邮箱授权码是否正确(不是邮箱密码!) +- QQ邮箱需要开启SMTP服务并生成授权码 +- 检查用户名格式是否正确(完整邮箱地址) + +### 2. 发送失败:连接超时 +**错误信息:** `Connection timed out` + +**解决方法:** +- 检查网络连接 +- 确认SMTP服务器地址和端口是否正确 +- 检查防火墙设置 +- 尝试切换端口(587/465/25) + +### 3. 发送失败:SSL错误 +**错误信息:** `SSL handshake failed` + +**解决方法:** +- 端口465使用SSL,需要设置 `spring.mail.properties.mail.smtp.ssl.enable=true` +- 端口587使用TLS,需要设置 `spring.mail.properties.mail.smtp.starttls.enable=true` + +### 4. 邮件进入垃圾箱 +**解决方法:** +- 这是正常现象,测试邮件可能被识别为垃圾邮件 +- 在垃圾箱中将发件人标记为"非垃圾邮件" +- 添加发件人到通讯录 + +### 5. QQ邮箱授权码获取失败 +**解决方法:** +1. 进入QQ邮箱网页版 +2. 设置 -> 账户 -> POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 +3. 开启"POP3/SMTP服务" +4. 通过密保验证后生成授权码 +5. 授权码仅显示一次,请妥善保存 + +## 🔒 安全建议 + +1. **不要将授权码提交到代码仓库** + - 使用环境变量或配置中心管理敏感信息 + - 在 `.gitignore` 中添加 `application.properties` + +2. **生产环境配置** + ```properties + # 使用环境变量 + spring.mail.username=${MAIL_USERNAME} + spring.mail.password=${MAIL_PASSWORD} + ``` + +3. **限流保护** + - 建议添加发送频率限制 + - 防止被恶意利用大量发送邮件 + +## 📊 测试检查清单 + +- [ ] 配置文件中邮箱信息填写正确 +- [ ] 邮箱SMTP服务已开启 +- [ ] 授权码获取成功 +- [ ] 网络连接正常 +- [ ] 测试邮箱地址修改为实际邮箱 +- [ ] 运行测试方法 +- [ ] 检查收件箱(包括垃圾邮件) +- [ ] 验证邮件内容和格式 + +## 🎯 实际应用 + +在验证码功能中的使用: + +```java +// 发送邮箱验证码 +POST /user/sendCode +{ + "account": "user@example.com", + "type": "register" +} +``` + +系统将自动: +1. 生成6位随机验证码 +2. 存储到Redis(有效期5分钟) +3. 发送精美的HTML邮件到用户邮箱 +4. 用户输入验证码完成验证 + +## 📚 相关文档 + +- [Spring Boot Mail 官方文档](https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.email) +- [API接口说明.md](./API接口说明.md) - 查看完整的API文档 +- [CLAUDE.md](./CLAUDE.md) - 项目开发指南 + +## 💡 提示 + +- 首次测试建议使用 `testSendVerifyCode()` 方法 +- 确保收件人邮箱地址正确 +- 检查垃圾邮件箱 +- 测试成功后可以通过API接口测试完整流程