新增登录注册
Some checks failed
CI Build and Test / build (push) Has been cancelled
Deploy to Server / build-and-deploy (push) Has been cancelled

This commit is contained in:
2025-10-20 15:19:18 +08:00
parent 8c82a0feb2
commit d5e9f3bb0a
32 changed files with 3329 additions and 1 deletions

211
.gitea/workflows/README.md Normal file
View File

@@ -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)

55
.gitea/workflows/ci.yml Normal file
View File

@@ -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

View File

@@ -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

194
CLAUDE.md Normal file
View File

@@ -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<T>` 类型
- DTO 类独立定义,不使用内部类
- 使用 RESTful 风格 API
### 4. 密码安全
- 密码使用 MD5 加密存储
- 不直接返回密码字段给前端
### 5. IP 获取
- 使用 `IpUtil.getClientIp()` 获取真实 IP
- 支持多级代理
## 项目特点
- 项目使用 MyBatis Plus 作为 ORM 框架,支持自动代码生成
- 集成 Sa-Token 用于身份验证和权限管理
- 支持 MySQL 数据库和 Redis 缓存
- 使用 Lombok 简化实体类代码
- 开发环境支持热重载 (Spring Boot DevTools)
- Druid 数据库连接池,提供 SQL 监控功能
- 统一返回结果封装,规范 API 响应格式

View File

@@ -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'

View File

@@ -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

View File

@@ -0,0 +1,86 @@
package com.corewing.app.common;
import lombok.Data;
import java.io.Serializable;
/**
* 统一返回结果类
*/
@Data
public class Result<T> 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 <T> Result<T> success() {
return new Result<>(200, "操作成功", null, true);
}
/**
* 成功返回(带数据)
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data, true);
}
/**
* 成功返回(自定义消息和数据)
*/
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data, true);
}
/**
* 失败返回
*/
public static <T> Result<T> error() {
return new Result<>(500, "操作失败", null, false);
}
/**
* 失败返回(自定义消息)
*/
public static <T> Result<T> error(String message) {
return new Result<>(500, message, null, false);
}
/**
* 失败返回(自定义状态码和消息)
*/
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null, false);
}
}

View File

@@ -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> statViewServlet() {
ServletRegistrationBean<StatViewServlet> bean = new ServletRegistrationBean<>(
new StatViewServlet(), "/druid/*"
);
Map<String, String> 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> webStatFilter() {
FilterRegistrationBean<WebStatFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new WebStatFilter());
Map<String, String> initParams = new HashMap<>();
// 排除不需要监控的资源
initParams.put("exclusions", "*.js,*.css,*.gif,*.jpg,*.png,*.ico,/druid/*");
bean.setInitParameters(initParams);
// 拦截所有请求
bean.addUrlPatterns("/*");
return bean;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值
Jackson2JsonRedisSerializer<Object> 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;
}
}

View File

@@ -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("/**");
}
}

View File

@@ -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<String> 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<Map<String, Object>> 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<String, Object> 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<String> 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<String> logout() {
StpUtil.logout();
return Result.success("登出成功");
}
/**
* 获取当前登录用户信息
*/
@GetMapping("/info")
public Result<AppUser> getUserInfo() {
Long userId = StpUtil.getLoginIdAsLong();
AppUser user = userService.getById(userId);
// 隐藏密码
user.setPassword(null);
return Result.success(user);
}
/**
* 根据ID查询用户
*/
@GetMapping("/{id}")
public Result<AppUser> 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<String> update(@RequestBody AppUser user) {
// 不允许通过此接口修改密码
user.setPassword(null);
boolean success = userService.updateById(user);
if (success) {
return Result.success("更新成功");
}
return Result.error("更新失败");
}
/**
* 修改密码
*/
@PutMapping("/password")
public Result<String> 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());
}
}
}

View File

@@ -0,0 +1,20 @@
package com.corewing.app.dto;
import lombok.Data;
/**
* 登录请求参数
*/
@Data
public class LoginRequest {
/**
* 账号(用户名/邮箱/手机号)
*/
private String account;
/**
* 密码
*/
private String password;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,20 @@
package com.corewing.app.dto;
import lombok.Data;
/**
* 修改密码请求参数
*/
@Data
public class UpdatePasswordRequest {
/**
* 旧密码
*/
private String oldPassword;
/**
* 新密码
*/
private String newPassword;
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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<AppUser> {
}

View File

@@ -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<AppUser> {
/**
* 根据用户名查询用户
*
* @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);
}

View File

@@ -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);
}

View File

@@ -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<AppUserMapper, AppUser> implements AppUserService {
private final VerifyCodeService verifyCodeService;
public AppUserServiceImpl(VerifyCodeService verifyCodeService) {
this.verifyCodeService = verifyCodeService;
}
@Override
public AppUser getByUsername(String username) {
LambdaQueryWrapper<AppUser> 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<AppUser> 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<AppUser> 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<AppUser> 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);
}
}

View File

@@ -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();
// 保存到 Rediskey格式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();
}
}

View File

@@ -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 "<!DOCTYPE html>" +
"<html>" +
"<head>" +
"<meta charset=\"UTF-8\">" +
"<style>" +
"body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }" +
".container { max-width: 600px; margin: 0 auto; padding: 20px; }" +
".header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }" +
".content { background: #f9f9f9; padding: 30px; border-radius: 0 0 5px 5px; }" +
".code-box { background: white; border: 2px dashed #667eea; padding: 20px; margin: 20px 0; text-align: center; border-radius: 5px; }" +
".code { font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 5px; }" +
".tips { color: #666; font-size: 14px; margin-top: 20px; }" +
".footer { text-align: center; margin-top: 20px; color: #999; font-size: 12px; }" +
"</style>" +
"</head>" +
"<body>" +
"<div class=\"container\">" +
"<div class=\"header\">" +
"<h2>CoreWing 验证码</h2>" +
"</div>" +
"<div class=\"content\">" +
"<p>您好!</p>" +
"<p>您正在进行身份验证,您的验证码是:</p>" +
"<div class=\"code-box\">" +
"<div class=\"code\">" + code + "</div>" +
"</div>" +
"<div class=\"tips\">" +
"<p>⏰ 验证码有效期为 <strong>5分钟</strong>,请尽快使用。</p>" +
"<p>🔒 为了您的账户安全,请勿将验证码告知他人。</p>" +
"<p>❓ 如果这不是您本人的操作,请忽略此邮件。</p>" +
"</div>" +
"</div>" +
"<div class=\"footer\">" +
"<p>此邮件由系统自动发送,请勿回复。</p>" +
"<p>&copy; 2025 CoreWing. All rights reserved.</p>" +
"</div>" +
"</div>" +
"</body>" +
"</html>";
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, Object> redisTemplate;
public RedisUtil(RedisTemplate<String, Object> 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<String>) 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<Object, Object> hmget(String key) {
return redisTemplate.opsForHash().entries(key);
}
/**
* HashSet
*
* @param key 键
* @param map 对应多个键值
*/
public boolean hmset(String key, Map<String, Object> 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<String, Object> 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<Object> 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<Object> 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<Object> 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<Object> 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;
}
}
}

View File

@@ -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】您的验证码是%s5分钟内有效。请勿泄露给他人", 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 "未知错误";
}
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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 位随机数字
- 邮件模板包含品牌样式,提升用户体验

View File

@@ -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 = "<html>" +
"<body style='font-family: Arial, sans-serif;'>" +
"<div style='background-color: #f0f0f0; padding: 20px;'>" +
"<h2 style='color: #333;'>CoreWing 系统通知</h2>" +
"<p>这是一封 <strong>HTML格式</strong> 的测试邮件。</p>" +
"<p>如果您能看到这封邮件的样式说明HTML邮件发送功能正常。</p>" +
"<hr>" +
"<p style='color: #666; font-size: 12px;'>发送时间:" +
java.time.LocalDateTime.now() + "</p>" +
"</div>" +
"</body>" +
"</html>";
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));
}
}

240
邮件发送测试说明.md Normal file
View File

@@ -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接口测试完整流程