first commit
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(cargo update:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 环境变量参考配置
|
||||||
|
#
|
||||||
|
# ⚠️ 重要提示:
|
||||||
|
# - 本项目不支持 .env 文件
|
||||||
|
# - 开发环境和生产环境都请使用 config/ 目录下的 toml 配置文件
|
||||||
|
# - 环境变量仅用于 Docker/Kubernetes/systemd 等部署场景
|
||||||
|
#
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CLI 参数环境变量
|
||||||
|
# ============================================
|
||||||
|
# 运行环境:development, production
|
||||||
|
# ENV=development
|
||||||
|
|
||||||
|
# 调试模式:true, false
|
||||||
|
# DEBUG=false
|
||||||
|
|
||||||
|
# 配置文件路径
|
||||||
|
# CONFIG=config/production.toml
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config (不要提交敏感配置)
|
||||||
|
.env
|
||||||
|
db.sqlite*
|
||||||
|
data
|
||||||
|
API_FLOW*
|
||||||
|
SEAORM*
|
||||||
3613
Cargo.lock
generated
Normal file
3613
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
Cargo.toml
Normal file
55
Cargo.toml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
[package]
|
||||||
|
name = "web-rust-template"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# ===== Web 框架 =====
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
|
|
||||||
|
# ===== 数据库(支持 MySQL、SQLite、PostgreSQL) =====
|
||||||
|
# SeaORM - 数据库 ORM(替代 SQLX 直接使用)
|
||||||
|
sea-orm = { version = "1.1", features = [
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"sqlx-mysql",
|
||||||
|
"sqlx-sqlite",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"macros",
|
||||||
|
"with-chrono",
|
||||||
|
"with-uuid",
|
||||||
|
] }
|
||||||
|
# ===== 序列化 =====
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# ===== 认证与加密 =====
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# ===== Redis =====
|
||||||
|
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||||
|
|
||||||
|
# ===== 工具库 =====
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
async-trait = "0.1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
config = "0.13"
|
||||||
|
rand = "0.8"
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# 优化发布版本
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
218
README.md
Normal file
218
README.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Web Rust Template
|
||||||
|
|
||||||
|
基于 Rust + Axum 0.7 的生产级 Web 服务器模板,采用 DDD 分层架构设计。
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 架构特色
|
||||||
|
- **DDD 分层架构**:领域层、基础设施层、应用层清晰分离
|
||||||
|
- **生产就绪**:JWT 双 Token 认证、Argon2 密码哈希、结构化日志
|
||||||
|
- **多数据库支持**:MySQL / PostgreSQL / SQLite 无缝切换
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **Web 框架**:Axum 0.7 + Tokio
|
||||||
|
- **数据库 ORM**:SeaORM 1.1(支持多数据库)
|
||||||
|
- **认证**:JWT (Access Token 15min + Refresh Token 7天)
|
||||||
|
- **缓存**:Redis 存储 Refresh Token
|
||||||
|
- **安全**:Argon2 密码哈希、CORS 支持
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆并安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository>
|
||||||
|
cd web-rust-template
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置项目
|
||||||
|
|
||||||
|
**使用默认配置(SQLite,最简单)**:
|
||||||
|
|
||||||
|
无需配置,直接运行即可:
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用 MySQL/PostgreSQL**:
|
||||||
|
|
||||||
|
复制对应的配置文件并修改:
|
||||||
|
```bash
|
||||||
|
# 使用 MySQL
|
||||||
|
cp config/development.mysql.toml config/local.toml
|
||||||
|
|
||||||
|
# 或使用 PostgreSQL
|
||||||
|
cp config/development.postgresql.toml config/local.toml
|
||||||
|
|
||||||
|
# 编辑 config/local.toml,修改数据库连接信息
|
||||||
|
# 然后运行
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 运行服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用默认配置(SQLite)
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# 或使用指定配置文件
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在 http://localhost:3000 启动
|
||||||
|
|
||||||
|
## 快速测试
|
||||||
|
|
||||||
|
### 用户注册
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email": "user@example.com", "password": "password123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"created_at": "2026-02-13T12:00:00.000Z",
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "eyJ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 查看 [完整 API 文档](docs/api/api-overview.md) 了解所有接口
|
||||||
|
|
||||||
|
## 核心配置
|
||||||
|
|
||||||
|
### 配置方式
|
||||||
|
|
||||||
|
**开发环境**:
|
||||||
|
|
||||||
|
使用 `config/` 目录下的配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SQLite(默认)
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
cp config/development.mysql.toml config/local.toml
|
||||||
|
# 编辑 config/local.toml
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
cp config/development.postgresql.toml config/local.toml
|
||||||
|
# 编辑 config/local.toml
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**生产环境**:
|
||||||
|
|
||||||
|
使用环境变量或配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用环境变量
|
||||||
|
DATABASE_TYPE=postgresql DATABASE_HOST=localhost cargo run -- -e production
|
||||||
|
|
||||||
|
# 或使用配置文件
|
||||||
|
cargo run -- -e production -c config/production.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
> 查看 [完整配置文档](docs/deployment/configuration.md) 或 [环境变量配置](docs/deployment/environment-variables.md)
|
||||||
|
|
||||||
|
## API 接口概览
|
||||||
|
|
||||||
|
### 公开接口
|
||||||
|
|
||||||
|
- `GET /health` - 健康检查
|
||||||
|
- `GET /info` - 服务器信息
|
||||||
|
- `POST /auth/register` - 用户注册
|
||||||
|
- `POST /auth/login` - 用户登录
|
||||||
|
- `POST /auth/refresh` - 刷新 Token
|
||||||
|
|
||||||
|
### 需要认证的接口
|
||||||
|
|
||||||
|
- `POST /auth/delete` - 删除账号
|
||||||
|
- `POST /auth/delete-refresh-token` - 删除 Refresh Token
|
||||||
|
|
||||||
|
> 查看 [完整 API 文档](docs/api/api-overview.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # 入口文件
|
||||||
|
├── config/ # 配置模块
|
||||||
|
│ ├── app.rs
|
||||||
|
│ ├── auth.rs
|
||||||
|
│ ├── database.rs
|
||||||
|
│ └── redis.rs
|
||||||
|
├── domain/ # 领域层(DDD)
|
||||||
|
│ ├── dto/ # 数据传输对象
|
||||||
|
│ ├── entities/ # 实体
|
||||||
|
│ └── vo/ # 视图对象
|
||||||
|
├── handlers/ # HTTP 处理器层
|
||||||
|
├── services/ # 业务逻辑层
|
||||||
|
├── repositories/ # 数据访问层
|
||||||
|
└── infra/ # 基础设施层
|
||||||
|
├── middleware/ # 中间件
|
||||||
|
└── redis/ # Redis 客户端
|
||||||
|
```
|
||||||
|
|
||||||
|
> 查看 [完整项目结构文档](docs/development/project-structure.md)
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 | 版本 |
|
||||||
|
|------|------|------|
|
||||||
|
| Web 框架 | Axum | 0.7 |
|
||||||
|
| 异步运行时 | Tokio | 1.x |
|
||||||
|
| 数据库 ORM | SeaORM | 1.1 |
|
||||||
|
| 认证 | JWT | 9.x |
|
||||||
|
| 密码哈希 | Argon2 | 0.5 |
|
||||||
|
| 缓存 | Redis | 0.27 |
|
||||||
|
| 日志 | tracing | 0.1 |
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
- [API 接口文档](docs/api/api-overview.md) - 完整的 API 接口说明和示例
|
||||||
|
- [快速开始指南](docs/development/getting-started.md) - 详细的安装和配置指南
|
||||||
|
- [开发规范](docs/development/ddd-architecture.md) - DDD 架构和代码规范
|
||||||
|
- [部署文档](docs/deployment/configuration.md) - 配置和部署指南
|
||||||
|
|
||||||
|
## 日志格式
|
||||||
|
|
||||||
|
日志采用三段式结构:
|
||||||
|
|
||||||
|
1. 📥 **请求开始**:显示请求方法和路径
|
||||||
|
2. 🔧 **请求处理**:显示请求参数和响应内容
|
||||||
|
3. ✅ **请求完成**:显示状态码和耗时
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
================================================================================
|
||||||
|
GET /health
|
||||||
|
================================================================================
|
||||||
|
[uuid-...] 📥 查询参数: 无 | 时间: 2026-02-12 13:30:45.123
|
||||||
|
[uuid-...] ✅ 状态码: 200 | 耗时: 5ms
|
||||||
|
================================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全特性
|
||||||
|
|
||||||
|
- ✅ 密码使用 Argon2 哈希
|
||||||
|
- ✅ JWT Token 认证
|
||||||
|
- ✅ Refresh Token 轮换机制
|
||||||
|
- ✅ Token 过期时间可配置
|
||||||
|
- ✅ 密码验证后才删除账号
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
33
config/development.toml
Normal file
33
config/development.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 开发环境配置 - SQLite 数据库
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 3000
|
||||||
|
|
||||||
|
[database]
|
||||||
|
# 数据库类型: mysql, sqlite, postgresql
|
||||||
|
database_type = "sqlite"
|
||||||
|
|
||||||
|
# MySQL/PostgreSQL 配置
|
||||||
|
# host = "localhost"
|
||||||
|
# port = 3306
|
||||||
|
# user = "root"
|
||||||
|
# password = "root"
|
||||||
|
# database = "web_template"
|
||||||
|
|
||||||
|
# SQLite 配置(当 database_type = "sqlite" 时使用)
|
||||||
|
path = "data/app.db"
|
||||||
|
|
||||||
|
# 连接池配置
|
||||||
|
max_connections = 10
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
jwt_secret = "9f7d3c7a564dfkopp26smb2644nqzfvbsao9f7d3c7a1a8f28544b5e6d7a"
|
||||||
|
# 分开配置两个 token 的过期时间
|
||||||
|
access_token_expiration_minutes = 15 # access_token 15 分钟
|
||||||
|
refresh_token_expiration_days = 7 # refresh_token 7 天
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
host = "localhost"
|
||||||
|
port = 6379
|
||||||
|
password = "" # 可选
|
||||||
|
db = 0
|
||||||
33
config/production.toml
Normal file
33
config/production.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 生产环境配置 - PostgreSQL 数据库
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0" # 服务器监听地址(0.0.0.0=允许所有网络访问)
|
||||||
|
port = 3000 # 服务器监听端口(确保防火墙已开放)
|
||||||
|
|
||||||
|
[database]
|
||||||
|
database_type = "postgresql" # 数据库类型:sqlite/mysql/postgresql
|
||||||
|
host = "localhost" # PostgreSQL 服务器地址
|
||||||
|
port = 5432 # PostgreSQL 端口(默认 5432)
|
||||||
|
user = "postgres" # PostgreSQL 用户名(请创建专用用户)
|
||||||
|
password = "postgres" # PostgreSQL 密码(请修改为强密码)
|
||||||
|
database = "web_template" # 数据库名称(不存在会自动创建)
|
||||||
|
max_connections = 20 # 最大连接数(生产环境建议 20-100)
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
jwt_secret = "9f7d3c7a564dfkopp26smb2644nqzfvbsao9f7d3c7a1a8f28544b5e6d7a" # JWT 签名密钥(必须修改为强随机字符串!)
|
||||||
|
access_token_expiration_minutes = 15 # Access Token 过期时间(分钟)
|
||||||
|
refresh_token_expiration_days = 7 # Refresh Token 过期时间(天)
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
host = "localhost" # Redis 服务器地址
|
||||||
|
port = 6379 # Redis 端口(默认 6379)
|
||||||
|
password = "" # Redis 密码(强烈建议设置密码)
|
||||||
|
db = 0 # Redis 数据库编号(0-15)
|
||||||
|
|
||||||
|
# 安全检查清单:部署前请确认
|
||||||
|
# ✅ 1. 已修改 jwt_secret 为强随机字符串
|
||||||
|
# ✅ 2. 已修改数据库密码为强密码
|
||||||
|
# ✅ 3. 已设置 Redis 密码
|
||||||
|
# ✅ 4. 已配置防火墙规则
|
||||||
|
# ✅ 5. 已启用 HTTPS(使用 Nginx/Caddy 等反向代理)
|
||||||
|
# ✅ 6. 已设置数据库定期备份
|
||||||
116
docs/README.md
Normal file
116
docs/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Web Rust Template 文档中心
|
||||||
|
|
||||||
|
欢迎使用 Web Rust Template 文档!本模板项目提供了生产级的 Rust Web 服务器基础架构,采用 DDD 分层设计,包含完整的认证、数据库、缓存等功能。
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
### 按角色查找文档
|
||||||
|
|
||||||
|
#### 前端开发者
|
||||||
|
- [API 接口概览](api/api-overview.md) - 快速了解所有可用的 API 接口
|
||||||
|
- [公开接口文档](api/endpoints/public.md) - 注册、登录等公开接口的详细说明
|
||||||
|
- [前端集成示例](api/examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例
|
||||||
|
- [认证机制详解](api/authentication.md) - JWT 认证流程和最佳实践
|
||||||
|
|
||||||
|
#### 后端开发者
|
||||||
|
- [快速开始指南](development/getting-started.md) - 安装、配置和运行项目
|
||||||
|
- [项目结构详解](development/project-structure.md) - DDD 分层架构说明
|
||||||
|
- [DDD 架构规范](development/ddd-architecture.md) - 各层设计原则和开发规范
|
||||||
|
- [代码风格规范](development/code-style.md) - Rust 代码风格和命名规范
|
||||||
|
- [Git 提交规范](development/git-workflow.md) - 提交信息规范和分支策略
|
||||||
|
- [测试规范](development/testing.md) - 单元测试和集成测试指南
|
||||||
|
|
||||||
|
#### 运维人员
|
||||||
|
- [环境变量配置](deployment/environment-variables.md) - 完整的环境变量列表和说明
|
||||||
|
- [配置文件详解](deployment/configuration.md) - 多环境配置文件组织
|
||||||
|
- [生产环境部署指南](deployment/production-guide.md) - 安全配置和部署最佳实践
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── api/ # API 接口文档
|
||||||
|
│ ├── api-overview.md # API 概览和快速参考
|
||||||
|
│ ├── authentication.md # 认证机制详解
|
||||||
|
│ ├── endpoints/
|
||||||
|
│ │ ├── public.md # 公开接口
|
||||||
|
│ │ └── protected.md # 需要认证的接口
|
||||||
|
│ └── examples/
|
||||||
|
│ └── frontend-integration.md # 前端集成代码示例
|
||||||
|
├── development/ # 开发指南
|
||||||
|
│ ├── getting-started.md # 快速开始
|
||||||
|
│ ├── project-structure.md # 项目结构详解
|
||||||
|
│ ├── ddd-architecture.md # DDD 分层架构规范
|
||||||
|
│ ├── code-style.md # 代码风格和命名规范
|
||||||
|
│ ├── git-workflow.md # Git 提交规范
|
||||||
|
│ └── testing.md # 测试规范
|
||||||
|
└── deployment/ # 部署文档
|
||||||
|
├── environment-variables.md # 环境变量配置说明
|
||||||
|
├── configuration.md # 配置文件详解
|
||||||
|
└── production-guide.md # 生产环境部署指南
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### DDD 分层架构
|
||||||
|
|
||||||
|
本项目采用领域驱动设计(DDD)分层架构:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Interface Layer (handlers) │ HTTP 处理器层
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ Application Layer (services) │ 业务逻辑层
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌─────▼──────────┐
|
||||||
|
│ Domain │ │ Infrastructure│
|
||||||
|
│ Layer │ │ Layer │
|
||||||
|
└─────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 双 Token 认证机制
|
||||||
|
|
||||||
|
- **Access Token**:15 分钟有效期,用于 API 请求认证
|
||||||
|
- **Refresh Token**:7 天有效期,存储在 Redis,用于获取新的 Access Token
|
||||||
|
- **Token 轮换**:每次刷新会生成新的 Refresh Token,旧 Token 自动失效
|
||||||
|
|
||||||
|
### 多数据库支持
|
||||||
|
|
||||||
|
支持 MySQL、PostgreSQL、SQLite 三种数据库,通过简单的环境变量配置即可切换。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 | 版本 |
|
||||||
|
|------|------|------|
|
||||||
|
| Web 框架 | Axum | 0.7 |
|
||||||
|
| 异步运行时 | Tokio | 1.x |
|
||||||
|
| 数据库 ORM | SeaORM | 1.1 |
|
||||||
|
| 认证 | JWT | 9.x |
|
||||||
|
| 密码哈希 | Argon2 | 0.5 |
|
||||||
|
| 缓存 | Redis | 0.27 |
|
||||||
|
| 日志 | tracing | 0.1 |
|
||||||
|
|
||||||
|
## 快速链接
|
||||||
|
|
||||||
|
- [项目 README](../README.md) - 返回项目主页
|
||||||
|
- [API 接口文档](api/api-overview.md) - 完整的 API 接口说明
|
||||||
|
- [快速开始指南](development/getting-started.md) - 安装和配置指南
|
||||||
|
- [开发规范](development/ddd-architecture.md) - DDD 架构和代码规范
|
||||||
|
- [部署文档](deployment/configuration.md) - 配置和部署指南
|
||||||
|
|
||||||
|
## 获取帮助
|
||||||
|
|
||||||
|
如果您在阅读文档时有任何疑问,请:
|
||||||
|
1. 查看相关主题的详细文档
|
||||||
|
2. 检查 [常见问题](deployment/production-guide.md#常见问题)
|
||||||
|
3. 提交 Issue 到项目仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:建议按照"快速开始指南"→"API 接口文档"→"开发规范"的顺序阅读文档。
|
||||||
185
docs/api/api-overview.md
Normal file
185
docs/api/api-overview.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# API 接口概览
|
||||||
|
|
||||||
|
本文档提供所有 API 接口的快速参考。
|
||||||
|
|
||||||
|
## 基础信息
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
开发环境: http://localhost:3000
|
||||||
|
生产环境: https://api.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
|
||||||
|
本 API 使用 JWT(JSON Web Token)进行认证:
|
||||||
|
|
||||||
|
- **Access Token**:有效期 15 分钟,用于 API 请求认证
|
||||||
|
- **Refresh Token**:有效期 7 天,用于获取新的 Access Token
|
||||||
|
|
||||||
|
### 认证 Header 格式
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
|
||||||
|
所有接口返回统一的 JSON 格式:
|
||||||
|
|
||||||
|
**成功响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "错误信息",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通用错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未授权(Token 无效或过期) |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
### 公开接口(无需认证)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 详细文档 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| GET | `/health` | 健康检查 | [查看详情](endpoints/public.md#get-health) |
|
||||||
|
| GET | `/info` | 服务器信息 | [查看详情](endpoints/public.md#get-info) |
|
||||||
|
| POST | `/auth/register` | 用户注册 | [查看详情](endpoints/public.md#post-authregister) |
|
||||||
|
| POST | `/auth/login` | 用户登录 | [查看详情](endpoints/public.md#post-authlogin) |
|
||||||
|
| POST | `/auth/refresh` | 刷新 Token | [查看详情](endpoints/public.md#post-authrefresh) |
|
||||||
|
|
||||||
|
### 需要认证的接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 详细文档 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| POST | `/auth/delete` | 删除账号 | [查看详情](endpoints/protected.md#post-authdelete) |
|
||||||
|
| POST | `/auth/delete-refresh-token` | 删除 Refresh Token | [查看详情](endpoints/protected.md#post-authdelete-refresh-token) |
|
||||||
|
|
||||||
|
## 认证流程简述
|
||||||
|
|
||||||
|
### 1. 注册/登录
|
||||||
|
|
||||||
|
用户注册或登录成功后,会返回 Access Token 和 Refresh Token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用 Access Token
|
||||||
|
|
||||||
|
将 Access Token 添加到请求头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /auth/delete
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 刷新 Token
|
||||||
|
|
||||||
|
当 Access Token 过期时,使用 Refresh Token 获取新的 Token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /auth/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 查看 [认证机制详解](authentication.md) 了解完整流程
|
||||||
|
|
||||||
|
## 快速示例
|
||||||
|
|
||||||
|
### 注册用户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问受保护接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/delete \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <your_access_token>" \
|
||||||
|
-d '{
|
||||||
|
"user_id": "1234567890",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细文档
|
||||||
|
|
||||||
|
- [公开接口详细文档](endpoints/public.md) - 所有公开接口的详细说明
|
||||||
|
- [受保护接口详细文档](endpoints/protected.md) - 需要认证的接口详细说明
|
||||||
|
- [认证机制详解](authentication.md) - JWT 认证流程和安全最佳实践
|
||||||
|
- [前端集成示例](examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [快速开始指南](../development/getting-started.md) - 安装和运行项目
|
||||||
|
- [环境变量配置](../deployment/environment-variables.md) - 配置 API 服务器
|
||||||
|
- [前端集成指南](examples/frontend-integration.md) - 前端开发集成示例
|
||||||
|
|
||||||
|
## 获取帮助
|
||||||
|
|
||||||
|
如果您在使用 API 时遇到问题:
|
||||||
|
|
||||||
|
1. 检查请求格式是否正确
|
||||||
|
2. 确认 Token 是否有效(未过期)
|
||||||
|
3. 查看日志输出获取详细错误信息
|
||||||
|
4. 参考 [认证机制详解](authentication.md) 了解认证流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||||
608
docs/api/authentication.md
Normal file
608
docs/api/authentication.md
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
# 认证机制详解
|
||||||
|
|
||||||
|
本文档详细说明 Web Rust Template 的 JWT 认证机制、安全特性和最佳实践。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [认证架构概述](#认证架构概述)
|
||||||
|
- [双 Token 机制](#双-token-机制)
|
||||||
|
- [认证流程](#认证流程)
|
||||||
|
- [Token 管理](#token-管理)
|
||||||
|
- [安全特性](#安全特性)
|
||||||
|
- [最佳实践](#最佳实践)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证架构概述
|
||||||
|
|
||||||
|
本系统采用 **JWT (JSON Web Token)** 进行用户认证,使用 **双 Token 机制**:
|
||||||
|
|
||||||
|
1. **Access Token**:短期有效,用于 API 请求认证
|
||||||
|
2. **Refresh Token**:长期有效,用于获取新的 Access Token
|
||||||
|
|
||||||
|
### 架构特点
|
||||||
|
|
||||||
|
- ✅ **无状态认证**:服务器不存储会话信息,易于扩展
|
||||||
|
- ✅ **安全性**:Token 泄露影响可控,自动过期
|
||||||
|
- ✅ **用户体验**:Refresh Token 可减少用户登录次数
|
||||||
|
- ✅ **可撤销性**:通过 Redis 存储 Refresh Token,支持主动撤销
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 双 Token 机制
|
||||||
|
|
||||||
|
### Access Token
|
||||||
|
|
||||||
|
**用途**:访问需要认证的 API 接口
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- 有效期:15 分钟(可配置)
|
||||||
|
- 包含用户 ID 和 Token 类型信息
|
||||||
|
- 不存储在服务器端(无状态)
|
||||||
|
- 每次请求都通过 HTTP Header 传递
|
||||||
|
|
||||||
|
**格式**:
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Token
|
||||||
|
|
||||||
|
**用途**:获取新的 Access Token
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- 有效期:7 天(可配置)
|
||||||
|
- 存储在 Redis 中
|
||||||
|
- 支持撤销和主动登出
|
||||||
|
- 每次刷新会生成新的 Refresh Token
|
||||||
|
|
||||||
|
**存储位置**:
|
||||||
|
- 前端:localStorage 或 sessionStorage
|
||||||
|
- 后端:Redis(Key:`auth:refresh_token:<user_id>`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证流程
|
||||||
|
|
||||||
|
### 1. 用户注册流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户
|
||||||
|
participant Frontend as 前端应用
|
||||||
|
participant API as API 服务器
|
||||||
|
participant DB as 数据库
|
||||||
|
participant Redis as Redis
|
||||||
|
|
||||||
|
User->>Frontend: 输入邮箱和密码
|
||||||
|
Frontend->>API: POST /auth/register
|
||||||
|
API->>API: 验证邮箱格式
|
||||||
|
API->>API: 生成用户 ID
|
||||||
|
API->>API: 哈希密码(Argon2)
|
||||||
|
API->>DB: 创建用户记录
|
||||||
|
DB-->>API: 用户创建成功
|
||||||
|
API->>API: 生成 Access Token (15min)
|
||||||
|
API->>API: 生成 Refresh Token (7days)
|
||||||
|
API->>Redis: 存储 Refresh Token
|
||||||
|
Redis-->>API: 存储成功
|
||||||
|
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||||
|
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||||
|
Frontend-->>User: 注册成功,自动登录
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- 密码使用 Argon2 算法哈希,不可逆
|
||||||
|
- Refresh Token 存储在 Redis,支持撤销
|
||||||
|
- 注册成功后自动登录,返回 Token
|
||||||
|
|
||||||
|
### 2. 用户登录流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户
|
||||||
|
participant Frontend as 前端应用
|
||||||
|
participant API as API 服务器
|
||||||
|
participant DB as 数据库
|
||||||
|
participant Redis as Redis
|
||||||
|
|
||||||
|
User->>Frontend: 输入邮箱和密码
|
||||||
|
Frontend->>API: POST /auth/login
|
||||||
|
API->>DB: 查询用户记录
|
||||||
|
DB-->>API: 返回用户信息
|
||||||
|
API->>API: 验证密码(Argon2)
|
||||||
|
API->>API: 生成 Access Token (15min)
|
||||||
|
API->>API: 生成 Refresh Token (7days)
|
||||||
|
API->>Redis: 存储/更新 Refresh Token
|
||||||
|
Redis-->>API: 存储成功
|
||||||
|
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||||
|
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||||
|
Frontend-->>User: 登录成功
|
||||||
|
```
|
||||||
|
|
||||||
|
**安全特性**:
|
||||||
|
- 登录失败不返回具体错误信息(防止账号枚举)
|
||||||
|
- 密码错误会记录日志用于风控
|
||||||
|
- Refresh Token 每次登录都会更新
|
||||||
|
|
||||||
|
### 3. 访问受保护接口流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Frontend as 前端应用
|
||||||
|
participant API as API 服务器
|
||||||
|
participant Redis as Redis
|
||||||
|
|
||||||
|
Frontend->>API: GET /protected<br/>Authorization: Bearer <access_token>
|
||||||
|
API->>API: 验证 JWT 签名
|
||||||
|
API->>API: 检查 Token 类型
|
||||||
|
API->>API: 检查 Token 过期时间
|
||||||
|
alt Token 有效
|
||||||
|
API-->>Frontend: 200 OK 返回数据
|
||||||
|
else Token 无效或过期
|
||||||
|
API-->>Frontend: 401 Unauthorized
|
||||||
|
Frontend->>API: POST /auth/refresh
|
||||||
|
API->>Redis: 获取 Refresh Token
|
||||||
|
Redis-->>API: 返回 Refresh Token
|
||||||
|
API->>API: 验证 Refresh Token
|
||||||
|
API->>API: 生成新的 Token 对
|
||||||
|
API->>Redis: 更新 Refresh Token
|
||||||
|
API-->>Frontend: 返回新的 Token
|
||||||
|
Frontend->>API: 重试原请求
|
||||||
|
API-->>Frontend: 200 OK 返回数据
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- 所有受保护接口都需要在 Header 中携带 Access Token
|
||||||
|
- Token 过期时前端自动刷新并重试请求
|
||||||
|
- 刷新成功后旧 Refresh Token 立即失效
|
||||||
|
|
||||||
|
### 4. Token 刷新流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Frontend as 前端应用
|
||||||
|
participant API as API 服务器
|
||||||
|
participant Redis as Redis
|
||||||
|
|
||||||
|
Frontend->>API: POST /auth/refresh<br/>{"refresh_token": "..."}
|
||||||
|
API->>API: 验证 Refresh Token 签名
|
||||||
|
API->>API: 检查 Token 类型(必须是 Refresh Token)
|
||||||
|
API->>API: 检查 Token 过期时间
|
||||||
|
API->>Redis: 检查 Refresh Token 是否存在
|
||||||
|
alt Token 有效
|
||||||
|
API->>API: 生成新的 Access Token (15min)
|
||||||
|
API->>API: 生成新的 Refresh Token (7days)
|
||||||
|
API->>Redis: 删除旧的 Refresh Token
|
||||||
|
API->>Redis: 存储新的 Refresh Token
|
||||||
|
API-->>Frontend: 返回新的 Token 对
|
||||||
|
else Token 无效或过期
|
||||||
|
API-->>Frontend: 401 Unauthorized
|
||||||
|
Frontend->>Frontend: 清除 Token
|
||||||
|
Frontend->>Frontend: 跳转到登录页
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token 轮换**:
|
||||||
|
- 每次刷新都会生成新的 Refresh Token
|
||||||
|
- 旧的 Refresh Token 立即失效
|
||||||
|
- 防止 Token 重放攻击
|
||||||
|
|
||||||
|
### 5. 用户登出流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 用户
|
||||||
|
participant Frontend as 前端应用
|
||||||
|
participant API as API 服务器
|
||||||
|
participant Redis as Redis
|
||||||
|
|
||||||
|
User->>Frontend: 点击登出按钮
|
||||||
|
Frontend->>API: POST /auth/delete-refresh-token<br/>Authorization: Bearer <access_token>
|
||||||
|
API->>API: 验证 Access Token
|
||||||
|
API->>API: 从 Token 中提取 user_id
|
||||||
|
API->>Redis: 删除 Refresh Token
|
||||||
|
Redis-->>API: 删除成功
|
||||||
|
API-->>Frontend: 200 OK
|
||||||
|
Frontend->>Frontend: 清除本地 Token
|
||||||
|
Frontend->>Frontend: 跳转到登录页
|
||||||
|
Frontend-->>User: 登出成功
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token 管理
|
||||||
|
|
||||||
|
### Token 生成
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/utils/jwt.rs
|
||||||
|
|
||||||
|
// 生成 Access Token
|
||||||
|
pub fn generate_access_token(
|
||||||
|
user_id: &str,
|
||||||
|
expiration_minutes: u64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let expiration = Utc::now()
|
||||||
|
.checked_add_signed(Duration::minutes(expiration_minutes as i64))
|
||||||
|
.expect("invalid expiration timestamp")
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
token_type: TokenType::Access,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Refresh Token
|
||||||
|
pub fn generate_refresh_token(
|
||||||
|
user_id: &str,
|
||||||
|
expiration_days: i64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let expiration = Utc::now()
|
||||||
|
.checked_add_signed(Duration::days(expiration_days))
|
||||||
|
.expect("invalid expiration timestamp")
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
token_type: TokenType::Refresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token 验证
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/infra/middleware/auth.rs
|
||||||
|
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ErrorResponse> {
|
||||||
|
// 1. 提取 Authorization header
|
||||||
|
let auth_header = request
|
||||||
|
.headers()
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?;
|
||||||
|
|
||||||
|
// 2. 验证 Bearer 格式
|
||||||
|
if !auth_header.starts_with("Bearer ") {
|
||||||
|
return Err(ErrorResponse::new("Authorization header 格式错误".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = &auth_header[7..]; // 跳过 "Bearer "
|
||||||
|
|
||||||
|
// 3. 验证 JWT 签名和过期时间
|
||||||
|
let claims = decode_token(token, &state.config.auth.jwt_secret)?;
|
||||||
|
|
||||||
|
// 4. 检查 Token 类型(必须是 Access Token)
|
||||||
|
if claims.token_type != TokenType::Access {
|
||||||
|
return Err(ErrorResponse::new("Token 类型错误".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 将 user_id 添加到请求扩展中
|
||||||
|
let user_id = claims.sub;
|
||||||
|
request.extensions_mut().insert(user_id);
|
||||||
|
|
||||||
|
// 6. 继续处理请求
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Token 存储
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/services/auth_service.rs
|
||||||
|
|
||||||
|
async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> {
|
||||||
|
let key = RedisKey::new(BusinessType::Auth)
|
||||||
|
.add_identifier("refresh_token")
|
||||||
|
.add_identifier(user_id);
|
||||||
|
|
||||||
|
let expiration_seconds = expiration_days * 24 * 3600;
|
||||||
|
|
||||||
|
self.redis_client
|
||||||
|
.set_ex(&key.build(), refresh_token, expiration_seconds as u64)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redis Key 设计**:
|
||||||
|
```
|
||||||
|
auth:refresh_token:<user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**过期时间**:7 天(与 Refresh Token 有效期一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全特性
|
||||||
|
|
||||||
|
### 1. 密码安全
|
||||||
|
|
||||||
|
**Argon2 哈希**:
|
||||||
|
- 使用 Argon2 算法(内存 hard,抗 GPU/ASIC 破解)
|
||||||
|
- 自动生成随机盐值
|
||||||
|
- 哈希结果不可逆
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/services/auth_service.rs
|
||||||
|
|
||||||
|
pub fn hash_password(&self, password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))?
|
||||||
|
.to_string();
|
||||||
|
Ok(password_hash)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. JWT 安全
|
||||||
|
|
||||||
|
**签名算法**:HS256 (HMAC-SHA256)
|
||||||
|
|
||||||
|
**Claims 结构**:
|
||||||
|
```rust
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // 用户 ID
|
||||||
|
pub exp: usize, // 过期时间(Unix 时间戳)
|
||||||
|
pub token_type: TokenType, // Token 类型(Access/Refresh)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**安全措施**:
|
||||||
|
- 使用强密钥(至少 32 位随机字符串)
|
||||||
|
- Token 包含过期时间
|
||||||
|
- Token 类型区分(防止混用)
|
||||||
|
- 签名验证防止篡改
|
||||||
|
|
||||||
|
### 3. Refresh Token 安全
|
||||||
|
|
||||||
|
**存储安全**:
|
||||||
|
- 存储在 Redis 中,支持快速撤销
|
||||||
|
- 每次刷新生成新 Token,旧 Token 失效
|
||||||
|
- 支持主动登出,删除 Refresh Token
|
||||||
|
|
||||||
|
**使用限制**:
|
||||||
|
- Refresh Token 只能使用一次
|
||||||
|
- 过期后无法续期
|
||||||
|
- 需要重新登录
|
||||||
|
|
||||||
|
### 4. 防护措施
|
||||||
|
|
||||||
|
**防重放攻击**:
|
||||||
|
- Refresh Token 单次使用
|
||||||
|
- 刷新后立即失效
|
||||||
|
|
||||||
|
**防 Token 泄露**:
|
||||||
|
- Access Token 短期有效(15 分钟)
|
||||||
|
- 只通过 HTTPS 传输
|
||||||
|
- 不在 URL 中传递
|
||||||
|
|
||||||
|
**防暴力破解**:
|
||||||
|
- 限制登录频率(可选实现)
|
||||||
|
- 记录失败尝试(日志)
|
||||||
|
- 密码哈希使用 Argon2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 前端集成
|
||||||
|
|
||||||
|
#### 1. Token 存储
|
||||||
|
|
||||||
|
**推荐方案**:
|
||||||
|
```typescript
|
||||||
|
// 存储 Token
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
localStorage.setItem('refresh_token', refresh_token);
|
||||||
|
|
||||||
|
// 读取 Token
|
||||||
|
const accessToken = localStorage.getItem('access_token');
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
// 清除 Token
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 请求拦截器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 添加 Token 到请求头
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const accessToken = localStorage.getItem('access_token');
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 响应拦截器(自动刷新 Token)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 处理 401 错误并自动刷新
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
const response = await axios.post('/auth/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { access_token, refresh_token } = response.data.data;
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
localStorage.setItem('refresh_token', refresh_token);
|
||||||
|
|
||||||
|
// 重试原请求
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
return axios(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// 刷新失败,跳转登录页
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端开发
|
||||||
|
|
||||||
|
#### 1. 密码强度要求
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 验证密码强度
|
||||||
|
fn validate_password(password: &str) -> Result<()> {
|
||||||
|
if password.len() < 8 {
|
||||||
|
return Err(anyhow!("密码长度至少 8 位"));
|
||||||
|
}
|
||||||
|
if password.len() > 100 {
|
||||||
|
return Err(anyhow!("密码长度最多 100 位"));
|
||||||
|
}
|
||||||
|
// 可添加更多规则(如必须包含大小写、数字等)
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. JWT 密钥管理
|
||||||
|
|
||||||
|
**开发环境**:
|
||||||
|
|
||||||
|
使用 `config/` 目录下的配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式1:使用默认配置(推荐)
|
||||||
|
# JWT 密钥已在 config/default.toml 中配置
|
||||||
|
|
||||||
|
# 方式2:创建本地配置文件
|
||||||
|
cp config/default.toml config/local.toml
|
||||||
|
# 编辑 config/local.toml,修改 jwt_secret
|
||||||
|
nano config/local.toml
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**生产环境**:
|
||||||
|
```bash
|
||||||
|
# 使用强随机密钥
|
||||||
|
AUTH_JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Token 过期时间配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access Token:15 分钟(推荐)
|
||||||
|
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15
|
||||||
|
|
||||||
|
# Refresh Token:7 天(推荐)
|
||||||
|
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- Access Token:5-30 分钟(权衡安全性和用户体验)
|
||||||
|
- Refresh Token:7-30 天(根据应用安全要求)
|
||||||
|
|
||||||
|
### 生产部署
|
||||||
|
|
||||||
|
#### 1. HTTPS 强制
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.yourdomain.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name api.yourdomain.com;
|
||||||
|
# SSL 配置...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. CORS 配置
|
||||||
|
|
||||||
|
开发环境可以允许所有来源:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境应该限制允许的来源:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin("https://yourdomain.com".parse::<HeaderValue>().unwrap())
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||||
|
.allow_headers([HeaderName::from_static("content-type"), HeaderName::from_static("authorization")])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 速率限制
|
||||||
|
|
||||||
|
防止暴力破解和 DDoS 攻击(需要额外实现):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 使用 governor 库实现速率限制
|
||||||
|
use governor::{Quota, RateLimiter};
|
||||||
|
|
||||||
|
let limiter = RateLimiter::direct(Quota::per_second(5));
|
||||||
|
// 每秒最多 5 个请求
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [公开接口文档](endpoints/public.md) - 注册、登录、刷新 Token 接口
|
||||||
|
- [受保护接口文档](endpoints/protected.md) - 需要认证的接口
|
||||||
|
- [前端集成示例](examples/frontend-integration.md) - 完整的前端集成代码
|
||||||
|
- [环境变量配置](../deployment/environment-variables.md) - 认证相关配置说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:生产环境部署前务必修改 JWT 密钥为强随机字符串!
|
||||||
368
docs/api/endpoints/public.md
Normal file
368
docs/api/endpoints/public.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
# 公开接口文档
|
||||||
|
|
||||||
|
本文档详细说明所有无需认证即可访问的 API 接口。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [GET /health - 健康检查](#get-health)
|
||||||
|
- [GET /info - 服务器信息](#get-info)
|
||||||
|
- [POST /auth/register - 用户注册](#post-authregister)
|
||||||
|
- [POST /auth/login - 用户登录](#post-authlogin)
|
||||||
|
- [POST /auth/refresh - 刷新 Token](#post-authrefresh)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /health
|
||||||
|
|
||||||
|
健康检查端点,用于检查服务是否正常运行。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**请求头**:无特殊要求
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
**成功响应 (200)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或服务不可用时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "unavailable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /info
|
||||||
|
|
||||||
|
获取服务器基本信息,包括应用名称、版本、状态等。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /info
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**请求头**:无特殊要求
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
**成功响应 (200)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "web-rust-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "running",
|
||||||
|
"timestamp": 1704112800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| name | string | 应用名称 |
|
||||||
|
| version | string | 应用版本 |
|
||||||
|
| status | string | 运行状态 |
|
||||||
|
| timestamp | number | 当前时间戳(Unix 时间戳) |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /auth/register
|
||||||
|
|
||||||
|
创建新用户账户。注册成功后自动登录,返回 Access Token 和 Refresh Token。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| email | string | 是 | 用户邮箱,必须是有效的邮箱格式 |
|
||||||
|
| password | string | 是 | 用户密码,建议长度至少 8 位 |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
**成功响应 (200)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"created_at": "2026-02-13T12:00:00.000Z",
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| email | string | 用户邮箱 |
|
||||||
|
| created_at | string | 账号创建时间(ISO 8601 格式) |
|
||||||
|
| access_token | string | Access Token,有效期 15 分钟 |
|
||||||
|
| refresh_token | string | Refresh Token,有效期 7 天 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 400 | 请求参数错误(邮箱格式错误、密码长度不够) |
|
||||||
|
| 409 | 邮箱已被注册 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 邮箱地址将作为用户的唯一标识符
|
||||||
|
- 密码会使用 Argon2 算法进行哈希存储
|
||||||
|
- 注册成功后会自动生成 Access Token 和 Refresh Token
|
||||||
|
- Refresh Token 会存储在 Redis 中,用于后续刷新 Token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /auth/login
|
||||||
|
|
||||||
|
用户登录。验证成功后返回 Access Token 和 Refresh Token。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| email | string | 是 | 用户邮箱 |
|
||||||
|
| password | string | 是 | 用户密码 |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
**成功响应 (200)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"id": "1234567890",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"created_at": "2026-02-13T12:00:00.000Z",
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | string | 用户 ID(10 位数字) |
|
||||||
|
| email | string | 用户邮箱 |
|
||||||
|
| created_at | string | 账号创建时间(ISO 8601 格式) |
|
||||||
|
| access_token | string | Access Token,有效期 15 分钟 |
|
||||||
|
| refresh_token | string | Refresh Token,有效期 7 天 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 邮箱或密码错误 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 登录失败不会返回具体的错误信息(如"邮箱不存在"或"密码错误"),统一返回"邮箱或密码错误"
|
||||||
|
- 密码错误次数过多可能会被临时限制(取决于具体实现)
|
||||||
|
- 登录成功后会生成新的 Token 对,旧的 Token 会失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /auth/refresh
|
||||||
|
|
||||||
|
使用 Refresh Token 获取新的 Access Token 和 Refresh Token。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| refresh_token | string | 是 | Refresh Token |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
**成功响应 (200)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| access_token | string | 新的 Access Token,有效期 15 分钟 |
|
||||||
|
| refresh_token | string | 新的 Refresh Token,有效期 7 天 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/refresh \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | Refresh Token 无效或已过期 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 每次刷新会生成新的 Refresh Token,旧的 Refresh Token 会立即失效
|
||||||
|
- Refresh Token 只能使用一次,重复使用会返回错误
|
||||||
|
- Refresh Token 有效期为 7 天,过期后需要重新登录
|
||||||
|
- Refresh Token 存储在 Redis 中,服务器重启不会丢失(如果 Redis 持久化配置正确)
|
||||||
|
|
||||||
|
### Token 刷新策略建议
|
||||||
|
|
||||||
|
**前端实现建议**:
|
||||||
|
|
||||||
|
1. 在每次 API 请求失败(401 错误)时尝试刷新 Token
|
||||||
|
2. 刷新成功后重试原请求
|
||||||
|
3. 刷新失败则跳转到登录页
|
||||||
|
4. 不要在 Token 即将过期时主动刷新,而是在使用时检查有效性
|
||||||
|
|
||||||
|
查看 [前端集成示例](../examples/frontend-integration.md) 了解完整的实现代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [受保护接口文档](protected.md) - 需要认证的接口说明
|
||||||
|
- [认证机制详解](../authentication.md) - 完整的认证流程说明
|
||||||
|
- [API 概览](../api-overview.md) - 所有接口快速索引
|
||||||
|
- [前端集成示例](../examples/frontend-integration.md) - 前端集成代码示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||||
768
docs/api/examples/frontend-integration.md
Normal file
768
docs/api/examples/frontend-integration.md
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
# 前端集成示例
|
||||||
|
|
||||||
|
本文档提供完整的前端集成代码示例,包括 JavaScript/TypeScript、React 和 Vue。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [TypeScript 基础示例](#typescript-基础示例)
|
||||||
|
- [React 集成示例](#react-集成示例)
|
||||||
|
- [Vue 集成示例](#vue-集成示例)
|
||||||
|
- [Token 存储建议](#token-存储建议)
|
||||||
|
- [错误处理](#错误处理)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript 基础示例
|
||||||
|
|
||||||
|
### 认证客户端类
|
||||||
|
|
||||||
|
以下是一个完整的 TypeScript 认证客户端实现,包含注册、登录、Token 刷新等功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterResponse {
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthClient {
|
||||||
|
private baseURL: string;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private refreshToken: string | null = null;
|
||||||
|
|
||||||
|
constructor(baseURL: string = 'http://localhost:3000') {
|
||||||
|
this.baseURL = baseURL;
|
||||||
|
// 从 localStorage 加载 Token
|
||||||
|
this.accessToken = localStorage.getItem('access_token');
|
||||||
|
this.refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*/
|
||||||
|
async register(email: string, password: string): Promise<RegisterResponse> {
|
||||||
|
const response = await fetch(`${this.baseURL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ApiResponse<RegisterResponse> = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
async login(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
const response = await fetch(`${this.baseURL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ApiResponse<LoginResponse> = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Token
|
||||||
|
*/
|
||||||
|
async refreshTokens(): Promise<void> {
|
||||||
|
if (!this.refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: this.refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ApiResponse<RefreshResponse> = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||||
|
} else {
|
||||||
|
this.clearTokens();
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起需要认证的请求
|
||||||
|
*/
|
||||||
|
async authenticatedFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
if (!this.accessToken) {
|
||||||
|
throw new Error('No access token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token 过期,尝试刷新
|
||||||
|
if (response.status === 401) {
|
||||||
|
try {
|
||||||
|
await this.refreshTokens();
|
||||||
|
// 重试原请求
|
||||||
|
response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 刷新失败,清除 Token 并抛出错误
|
||||||
|
this.clearTokens();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Token 到 localStorage
|
||||||
|
*/
|
||||||
|
private saveTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
localStorage.setItem('access_token', accessToken);
|
||||||
|
localStorage.setItem('refresh_token', refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除 Token
|
||||||
|
*/
|
||||||
|
private clearTokens(): void {
|
||||||
|
this.accessToken = null;
|
||||||
|
this.refreshToken = null;
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出
|
||||||
|
*/
|
||||||
|
logout(): void {
|
||||||
|
this.clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已登录
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.accessToken !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const authClient = new AuthClient();
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
try {
|
||||||
|
const result = await authClient.register('user@example.com', 'password123');
|
||||||
|
console.log('注册成功:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('注册失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
try {
|
||||||
|
const result = await authClient.login('user@example.com', 'password123');
|
||||||
|
console.log('登录成功:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 访问受保护接口
|
||||||
|
try {
|
||||||
|
const response = await authClient.authenticatedFetch(
|
||||||
|
'http://localhost:3000/auth/delete',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: '1234567890', password: 'password123' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('请求成功:', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('请求失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
authClient.logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React 集成示例
|
||||||
|
|
||||||
|
### AuthContext Provider
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AuthContext.tsx
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从 localStorage 加载 Token
|
||||||
|
const storedAccessToken = localStorage.getItem('access_token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (storedAccessToken && storedUser) {
|
||||||
|
setAccessToken(storedAccessToken);
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const userData: User = {
|
||||||
|
id: result.data.id,
|
||||||
|
email: result.data.email,
|
||||||
|
created_at: result.data.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
setAccessToken(result.data.access_token);
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', result.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string) => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const userData: User = {
|
||||||
|
id: result.data.id || '',
|
||||||
|
email: result.data.email,
|
||||||
|
created_at: result.data.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
setAccessToken(result.data.access_token);
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', result.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setAccessToken(null);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated: !!accessToken,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
loading,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Hook(带 Token 刷新)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useApi.ts
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
const { accessToken, setAccessToken, logout } = useAuth();
|
||||||
|
|
||||||
|
const fetchWithAuth = useCallback(
|
||||||
|
async (url: string, options?: RequestInit): Promise<Response> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token 过期,尝试刷新
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
const refreshResponse = await fetch('http://localhost:3000/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshResult = await refreshResponse.json();
|
||||||
|
|
||||||
|
if (refreshResult.code === 200) {
|
||||||
|
setAccessToken(refreshResult.data.access_token);
|
||||||
|
localStorage.setItem('access_token', refreshResult.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', refreshResult.data.refresh_token);
|
||||||
|
|
||||||
|
// 重试原请求
|
||||||
|
response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
'Authorization': `Bearer ${refreshResult.data.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 刷新失败,登出
|
||||||
|
logout();
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
[accessToken, setAccessToken, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { fetchWithAuth };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 登录组件示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Login.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
// 登录成功,路由跳转
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<h2>登录</h2>
|
||||||
|
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="邮箱"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue 集成示例
|
||||||
|
|
||||||
|
### Auth Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useAuth.ts
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const user = ref<User | null>(null);
|
||||||
|
const accessToken = ref<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!accessToken.value);
|
||||||
|
|
||||||
|
// 初始化:从 localStorage 加载
|
||||||
|
const init = () => {
|
||||||
|
const storedAccessToken = localStorage.getItem('access_token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (storedAccessToken && storedUser) {
|
||||||
|
accessToken.value = storedAccessToken;
|
||||||
|
user.value = JSON.parse(storedUser);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const userData: User = {
|
||||||
|
id: result.data.id,
|
||||||
|
email: result.data.email,
|
||||||
|
created_at: result.data.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
user.value = userData;
|
||||||
|
accessToken.value = result.data.access_token;
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', result.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (email: string, password: string) => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const userData: User = {
|
||||||
|
id: result.data.id || '',
|
||||||
|
email: result.data.email,
|
||||||
|
created_at: result.data.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
user.value = userData;
|
||||||
|
accessToken.value = result.data.access_token;
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', result.data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
user.value = null;
|
||||||
|
accessToken.value = null;
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
init,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Axios 拦截器示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/axios.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器:添加 Authorization header
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const accessToken = localStorage.getItem('access_token');
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应拦截器:处理 401 错误并刷新 Token
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/auth/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
const { access_token, refresh_token } = response.data.data;
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
localStorage.setItem('refresh_token', refresh_token);
|
||||||
|
|
||||||
|
// 重试原请求
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
return axios(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
// 刷新失败,清除 Token
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有 Refresh Token,跳转到登录页
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token 存储建议
|
||||||
|
|
||||||
|
### localStorage vs sessionStorage vs Cookie
|
||||||
|
|
||||||
|
| 存储方式 | 优点 | 缺点 | 推荐场景 |
|
||||||
|
|---------|------|------|----------|
|
||||||
|
| localStorage | 数据持久化,刷新页面不丢失 | 容易受到 XSS 攻击 | Access Token、Refresh Token |
|
||||||
|
| sessionStorage | 关闭标签页自动清除 | 刷新页面会丢失 | 不推荐 |
|
||||||
|
| Cookie | 可设置 HttpOnly 防止 XSS | 容易受到 CSRF 攻击 | 服务器渲染场景 |
|
||||||
|
|
||||||
|
### 推荐方案
|
||||||
|
|
||||||
|
**前端应用(SPA)**:
|
||||||
|
- Access Token:localStorage
|
||||||
|
- Refresh Token:localStorage
|
||||||
|
- 添加适当的 XSS 防护(内容安全策略、输入验证)
|
||||||
|
|
||||||
|
**安全性要求高的场景**:
|
||||||
|
- Access Token:内存(React Context/Vue Reactive)
|
||||||
|
- Refresh Token:HttpOnly Cookie(需要后端支持)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 通用错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleApiCall<T>(
|
||||||
|
apiCall: () => Promise<T>,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await apiCall();
|
||||||
|
} catch (error) {
|
||||||
|
if (onError) {
|
||||||
|
onError(error as Error);
|
||||||
|
} else {
|
||||||
|
console.error('API 调用失败:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const result = await handleApiCall(
|
||||||
|
() => authClient.login('user@example.com', 'password123'),
|
||||||
|
(error) => {
|
||||||
|
alert(`登录失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网络错误重试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
maxRetries: number = 3
|
||||||
|
): Promise<Response> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await fetch(url, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (i === maxRetries - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// 等待后重试
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Max retries reached');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [公开接口文档](../endpoints/public.md) - API 接口详细说明
|
||||||
|
- [认证机制详解](../authentication.md) - JWT 认证流程
|
||||||
|
- [受保护接口文档](../endpoints/protected.md) - 需要认证的接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:以上示例代码仅供参考,实际使用时请根据项目需求调整。
|
||||||
536
docs/deployment/environment-variables.md
Normal file
536
docs/deployment/environment-variables.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# 环境变量配置说明
|
||||||
|
|
||||||
|
本文档提供所有可配置的环境变量说明。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [配置优先级](#配置优先级)
|
||||||
|
- [服务器配置](#服务器配置)
|
||||||
|
- [数据库配置](#数据库配置)
|
||||||
|
- [认证配置](#认证配置)
|
||||||
|
- [Redis 配置](#redis-配置)
|
||||||
|
- [配置示例](#配置示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置优先级
|
||||||
|
|
||||||
|
配置的加载优先级从高到低为:
|
||||||
|
|
||||||
|
1. **环境变量**(最高优先级)
|
||||||
|
2. **配置文件**(config/ 目录)
|
||||||
|
3. **默认值**(代码中硬编码)
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
- 环境变量会覆盖配置文件中的设置
|
||||||
|
- 配置文件会覆盖代码中的默认值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务器配置
|
||||||
|
|
||||||
|
### SERVER_HOST
|
||||||
|
|
||||||
|
服务器监听地址。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | `0.0.0.0` |
|
||||||
|
| 说明 | `0.0.0.0` 表示监听所有网络接口 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
SERVER_HOST=127.0.0.1 # 仅本地访问
|
||||||
|
SERVER_HOST=0.0.0.0 # 允许外部访问
|
||||||
|
```
|
||||||
|
|
||||||
|
### SERVER_PORT
|
||||||
|
|
||||||
|
服务器监听端口。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `3000` |
|
||||||
|
| 说明 | 1-65535 之间的有效端口 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=3000 # 开发环境
|
||||||
|
SERVER_PORT=8080 # 生产环境
|
||||||
|
SERVER_PORT=80 # HTTP 标准端口
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库配置
|
||||||
|
|
||||||
|
### DATABASE_TYPE
|
||||||
|
|
||||||
|
数据库类型,支持 MySQL、PostgreSQL、SQLite。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | `sqlite` |
|
||||||
|
| 可选值 | `mysql`、`postgresql`、`sqlite` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_TYPE=sqlite # SQLite 数据库
|
||||||
|
DATABASE_TYPE=mysql # MySQL 数据库
|
||||||
|
DATABASE_TYPE=postgresql # PostgreSQL 数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
### MySQL 配置
|
||||||
|
|
||||||
|
当 `DATABASE_TYPE=mysql` 时使用。
|
||||||
|
|
||||||
|
#### DATABASE_HOST
|
||||||
|
|
||||||
|
MySQL 服务器地址。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | `localhost` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_HOST=192.168.1.100
|
||||||
|
DATABASE_HOST=mysql.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DATABASE_PORT
|
||||||
|
|
||||||
|
MySQL 服务器端口。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `3306` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_PORT=3306
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DATABASE_USER
|
||||||
|
|
||||||
|
MySQL 用户名。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 是 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_USER=root
|
||||||
|
DATABASE_USER=webapp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DATABASE_PASSWORD
|
||||||
|
|
||||||
|
MySQL 密码。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 是 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_PASSWORD=your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DATABASE_DATABASE
|
||||||
|
|
||||||
|
MySQL 数据库名称。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 是 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_DATABASE=web_template
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL 配置
|
||||||
|
|
||||||
|
当 `DATABASE_TYPE=postgresql` 时使用,配置项与 MySQL 相同。
|
||||||
|
|
||||||
|
| 环境变量 | 说明 | 默认值 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| DATABASE_HOST | PostgreSQL 服务器地址 | localhost |
|
||||||
|
| DATABASE_PORT | PostgreSQL 服务器端口 | 5432 |
|
||||||
|
| DATABASE_USER | PostgreSQL 用户名 | - |
|
||||||
|
| DATABASE_PASSWORD | PostgreSQL 密码 | - |
|
||||||
|
| DATABASE_DATABASE | PostgreSQL 数据库名称 | - |
|
||||||
|
|
||||||
|
### SQLite 配置
|
||||||
|
|
||||||
|
当 `DATABASE_TYPE=sqlite` 时使用。
|
||||||
|
|
||||||
|
#### DATABASE_PATH
|
||||||
|
|
||||||
|
SQLite 数据库文件路径。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 是 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_PATH=data/app.db
|
||||||
|
DATABASE_PATH=/var/data/webapp.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- 目录必须存在,程序不会自动创建目录
|
||||||
|
- 文件不存在时会自动创建
|
||||||
|
|
||||||
|
### DATABASE_MAX_CONNECTIONS
|
||||||
|
|
||||||
|
数据库连接池最大连接数。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `10` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
DATABASE_MAX_CONNECTIONS=10 # 开发环境
|
||||||
|
DATABASE_MAX_CONNECTIONS=100 # 生产环境
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- 开发环境:5-10
|
||||||
|
- 生产环境:根据应用负载调整(通常是 CPU 核心数的 2-4 倍)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证配置
|
||||||
|
|
||||||
|
### AUTH_JWT_SECRET
|
||||||
|
|
||||||
|
JWT 签名密钥。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 是 |
|
||||||
|
|
||||||
|
**安全建议**:
|
||||||
|
- 生产环境使用至少 32 位的随机字符串
|
||||||
|
- 定期更换密钥
|
||||||
|
- 不要在代码中硬编码
|
||||||
|
|
||||||
|
**生成强密钥**:
|
||||||
|
```bash
|
||||||
|
# 使用 OpenSSL
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
|
# 使用 Python
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
|
||||||
|
# 使用 Node.js
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
# 开发环境(不安全)
|
||||||
|
AUTH_JWT_SECRET=dev-secret-key
|
||||||
|
|
||||||
|
# 生产环境(安全)
|
||||||
|
AUTH_JWT_SECRET=Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7
|
||||||
|
```
|
||||||
|
|
||||||
|
### AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES
|
||||||
|
|
||||||
|
Access Token 过期时间(分钟)。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `15` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15 # 15 分钟(推荐)
|
||||||
|
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=30 # 30 分钟
|
||||||
|
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=60 # 1 小时
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- 安全性要求高:5-15 分钟
|
||||||
|
- 用户体验优先:30-60 分钟
|
||||||
|
- 权衡安全性和用户体验
|
||||||
|
|
||||||
|
### AUTH_REFRESH_TOKEN_EXPIRATION_DAYS
|
||||||
|
|
||||||
|
Refresh Token 过期时间(天)。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `7` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7 # 7 天(推荐)
|
||||||
|
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=30 # 30 天
|
||||||
|
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=90 # 90 天
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
- Web 应用:7-30 天
|
||||||
|
- 移动应用:30-90 天
|
||||||
|
- 安全性要求高的应用:7 天或更短
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis 配置
|
||||||
|
|
||||||
|
### REDIS_HOST
|
||||||
|
|
||||||
|
Redis 服务器地址。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | `localhost` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_HOST=192.168.1.100
|
||||||
|
REDIS_HOST=redis.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### REDIS_PORT
|
||||||
|
|
||||||
|
Redis 服务器端口。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `6379` |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
REDIS_PORT=6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### REDIS_PASSWORD
|
||||||
|
|
||||||
|
Redis 密码(如果设置了密码)。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 字符串 |
|
||||||
|
| 默认值 | - |
|
||||||
|
| 必填 | 否 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
REDIS_PASSWORD=your-redis-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### REDIS_DB
|
||||||
|
|
||||||
|
Redis 数据库编号。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 类型 | 整数 |
|
||||||
|
| 默认值 | `0` |
|
||||||
|
| 范围 | 0-15 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
REDIS_DB=0 # 默认数据库
|
||||||
|
REDIS_DB=1 # 数据库 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置示例
|
||||||
|
|
||||||
|
### 开发环境(SQLite)
|
||||||
|
|
||||||
|
**重要**:本项目不支持 .env 文件。开发环境请使用 `config/` 目录下的 toml 配置文件。
|
||||||
|
|
||||||
|
**方式一:使用默认配置(最简单)**
|
||||||
|
|
||||||
|
无需任何配置,直接运行即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:修改配置文件**
|
||||||
|
|
||||||
|
如果需要修改配置,编辑 `config/default.toml` 或创建 `config/local.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制默认配置
|
||||||
|
cp config/default.toml config/local.toml
|
||||||
|
|
||||||
|
# 编辑配置文件
|
||||||
|
nano config/local.toml # 或使用其他编辑器
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发环境(MySQL)
|
||||||
|
|
||||||
|
**重要**:本项目不支持 .env 文件。开发环境请使用 `config/` 目录下的 toml 配置文件。
|
||||||
|
|
||||||
|
复制并修改 MySQL 配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制 MySQL 配置模板
|
||||||
|
cp config/development.mysql.toml config/local.toml
|
||||||
|
|
||||||
|
# 编辑 config/local.toml,修改数据库连接信息
|
||||||
|
nano config/local.toml
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用环境变量(适用于 Docker/Kubernetes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_TYPE=mysql \
|
||||||
|
DATABASE_HOST=localhost \
|
||||||
|
DATABASE_PORT=3306 \
|
||||||
|
DATABASE_USER=root \
|
||||||
|
DATABASE_PASSWORD=root \
|
||||||
|
DATABASE_DATABASE=web_template_dev \
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产环境
|
||||||
|
|
||||||
|
**重要**:本项目不支持 .env 文件。生产环境请使用 `config/` 目录下的 toml 配置文件或环境变量。
|
||||||
|
|
||||||
|
**方式一:使用配置文件**
|
||||||
|
|
||||||
|
修改 `config/production.toml` 中的配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑生产环境配置文件
|
||||||
|
nano config/production.toml
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
cargo run -- -e production -c config/production.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:使用环境变量(Docker/Kubernetes 推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_TYPE=mysql \
|
||||||
|
DATABASE_HOST=mysql.production.example.com \
|
||||||
|
DATABASE_PORT=3306 \
|
||||||
|
DATABASE_USER=webapp \
|
||||||
|
DATABASE_PASSWORD=strong-password-here \
|
||||||
|
DATABASE_DATABASE=web_template_prod \
|
||||||
|
DATABASE_MAX_CONNECTIONS=100 \
|
||||||
|
AUTH_JWT_SECRET=Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7 \
|
||||||
|
REDIS_HOST=redis.production.example.com \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_PASSWORD=strong-redis-password \
|
||||||
|
REDIS_DB=0 \
|
||||||
|
cargo run -- -e production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose 配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: web-rust-template:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- SERVER_HOST=0.0.0.0
|
||||||
|
- SERVER_PORT=3000
|
||||||
|
- DATABASE_TYPE=postgresql
|
||||||
|
- DATABASE_HOST=db
|
||||||
|
- DATABASE_PORT=5432
|
||||||
|
- DATABASE_USER=webapp
|
||||||
|
- DATABASE_PASSWORD=password
|
||||||
|
- DATABASE_DATABASE=web_template
|
||||||
|
- DATABASE_MAX_CONNECTIONS=10
|
||||||
|
- AUTH_JWT_SECRET=${JWT_SECRET}
|
||||||
|
- AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15
|
||||||
|
- AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_DB=0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=webapp
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=web_template
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全检查清单
|
||||||
|
|
||||||
|
生产环境部署前检查:
|
||||||
|
|
||||||
|
- [ ] JWT 密钥使用强随机字符串(至少 32 位)
|
||||||
|
- [ ] 数据库密码使用强密码
|
||||||
|
- [ ] Redis 设置密码(如果可从外部访问)
|
||||||
|
- [ ] 服务器监听地址根据需求配置(0.0.0.0 或 127.0.0.1)
|
||||||
|
- [ ] 数据库连接数根据负载调整
|
||||||
|
- [ ] Token 过期时间根据安全要求配置
|
||||||
|
- [ ] 环境变量文件不提交到版本控制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [配置文件详解](configuration.md) - 配置文件组织说明
|
||||||
|
- [快速开始指南](../development/getting-started.md) - 安装和配置指南
|
||||||
|
- [生产环境部署](production-guide.md) - 生产部署最佳实践
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:使用 `.env.example` 作为模板,不要提交包含敏感信息的 `.env` 文件到版本控制。
|
||||||
459
docs/development/getting-started.md
Normal file
459
docs/development/getting-started.md
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
# 快速开始指南
|
||||||
|
|
||||||
|
本文档将指导你完成 Web Rust Template 项目的安装、配置和运行。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [环境要求](#环境要求)
|
||||||
|
- [安装步骤](#安装步骤)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [运行项目](#运行项目)
|
||||||
|
- [验证安装](#验证安装)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
### 必需环境
|
||||||
|
|
||||||
|
- **Rust**:1.70 或更高版本
|
||||||
|
- 安装方法:访问 [rustup.rs](https://rustup.rs/) 或使用 `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
|
||||||
|
- **Git**:用于克隆项目
|
||||||
|
|
||||||
|
### 数据库(任选其一)
|
||||||
|
|
||||||
|
- **SQLite**:默认选项,无需额外安装
|
||||||
|
- **MySQL**:5.7 或更高版本
|
||||||
|
- **PostgreSQL**:12 或更高版本
|
||||||
|
|
||||||
|
### 可选环境
|
||||||
|
|
||||||
|
- **Redis**:用于存储 Refresh Token(推荐)
|
||||||
|
- Windows:下载 [Redis for Windows](https://github.com/microsoftarchive/redis/releases)
|
||||||
|
- macOS:`brew install redis`
|
||||||
|
- Linux:`sudo apt-get install redis-server`
|
||||||
|
|
||||||
|
### 检查环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Rust 版本
|
||||||
|
rustc --version
|
||||||
|
|
||||||
|
# 检查 Cargo 版本
|
||||||
|
cargo --version
|
||||||
|
|
||||||
|
# 检查 Git 版本
|
||||||
|
git --version
|
||||||
|
|
||||||
|
# 检查 MySQL(如果使用)
|
||||||
|
mysql --version
|
||||||
|
|
||||||
|
# 检查 PostgreSQL(如果使用)
|
||||||
|
psql --version
|
||||||
|
|
||||||
|
# 检查 Redis(如果使用)
|
||||||
|
redis-cli --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装步骤
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd web-rust-template
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
使用 Cargo 构建项目(会自动下载依赖):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置项目
|
||||||
|
|
||||||
|
#### 方式一:使用默认配置(SQLite,最简单)
|
||||||
|
|
||||||
|
**无需任何配置!** 直接运行即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
默认配置:
|
||||||
|
- 数据库:SQLite(自动创建 `db.sqlite3`)
|
||||||
|
- 服务器:`127.0.0.1:3000`
|
||||||
|
- Redis:`localhost:6379`
|
||||||
|
|
||||||
|
#### 方式二:使用 MySQL/PostgreSQL
|
||||||
|
|
||||||
|
**步骤 1**:复制对应的配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 MySQL
|
||||||
|
cp config/development.mysql.toml config/local.toml
|
||||||
|
|
||||||
|
# 或使用 PostgreSQL
|
||||||
|
cp config/development.postgresql.toml config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 2**:修改配置文件
|
||||||
|
|
||||||
|
编辑 `config/local.toml`,修改数据库连接信息:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[database]
|
||||||
|
# MySQL 配置
|
||||||
|
host = "localhost"
|
||||||
|
port = 3306
|
||||||
|
user = "root"
|
||||||
|
password = "your-password"
|
||||||
|
database = "web_template_dev"
|
||||||
|
|
||||||
|
# 或 PostgreSQL 配置
|
||||||
|
# host = "localhost"
|
||||||
|
# port = 5432
|
||||||
|
# user = "postgres"
|
||||||
|
# password = "your-password"
|
||||||
|
# database = "web_template_dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 3**:运行项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用指定配置文件运行
|
||||||
|
cargo run -- -c config/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方式三:通过环境变量覆盖(适用于 Docker/Kubernetes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用环境变量
|
||||||
|
DATABASE_TYPE=postgresql \
|
||||||
|
DATABASE_HOST=localhost \
|
||||||
|
DATABASE_PORT=5432 \
|
||||||
|
DATABASE_USER=postgres \
|
||||||
|
DATABASE_PASSWORD=password \
|
||||||
|
DATABASE_DATABASE=web_template_dev \
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 数据库配置
|
||||||
|
|
||||||
|
#### SQLite(默认,推荐用于开发)
|
||||||
|
|
||||||
|
**优点**:无需额外安装,文件存储,易于测试
|
||||||
|
|
||||||
|
**缺点**:不支持高并发写入
|
||||||
|
|
||||||
|
**适用场景**:开发环境、小型应用
|
||||||
|
|
||||||
|
**使用方法**:无需配置,直接运行
|
||||||
|
|
||||||
|
#### MySQL
|
||||||
|
|
||||||
|
**优点**:成熟稳定,支持高并发
|
||||||
|
|
||||||
|
**缺点**:需要额外安装和配置
|
||||||
|
|
||||||
|
**适用场景**:生产环境、大型应用
|
||||||
|
|
||||||
|
**配置方法**:
|
||||||
|
|
||||||
|
**选项 1**:修改配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制 MySQL 配置模板
|
||||||
|
cp config/development.mysql.toml config/local.toml
|
||||||
|
|
||||||
|
# 编辑 config/local.toml,修改数据库连接信息
|
||||||
|
```
|
||||||
|
|
||||||
|
**选项 2**:使用环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_TYPE=mysql \
|
||||||
|
DATABASE_HOST=localhost \
|
||||||
|
DATABASE_PORT=3306 \
|
||||||
|
DATABASE_USER=root \
|
||||||
|
DATABASE_PASSWORD=your-password \
|
||||||
|
DATABASE_DATABASE=web_template_dev \
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PostgreSQL
|
||||||
|
|
||||||
|
**优点**:功能强大,支持高级特性
|
||||||
|
|
||||||
|
**缺点**:资源占用较大
|
||||||
|
|
||||||
|
**适用场景**:需要高级数据库功能的应用
|
||||||
|
|
||||||
|
**配置方法**:与 MySQL 类似,使用 `config/development.postgresql.toml` 或环境变量
|
||||||
|
|
||||||
|
### 认证配置
|
||||||
|
|
||||||
|
**开发环境**:使用默认配置即可(JWT 密钥已在配置文件中)
|
||||||
|
|
||||||
|
**生产环境**:必须修改配置文件中的 JWT 密钥
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
# 生产环境必须使用强密钥
|
||||||
|
jwt_secret = "Kx7Yn2Zp9qR8wF4tL6mN3vB5xC8zD1sE9aH2jK7"
|
||||||
|
```
|
||||||
|
|
||||||
|
生成强密钥:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis 配置
|
||||||
|
|
||||||
|
**开发环境**:默认连接 `localhost:6379`,无需配置
|
||||||
|
|
||||||
|
**生产环境**:修改配置文件或设置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDIS_HOST=your-redis-host \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_PASSWORD=your-password \
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运行项目
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
**使用默认配置(SQLite)**:
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用指定配置文件**:
|
||||||
|
```bash
|
||||||
|
cargo run -- -c config/development.mysql.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用环境变量**:
|
||||||
|
```bash
|
||||||
|
DATABASE_TYPE=mysql DATABASE_HOST=localhost cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 指定环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境
|
||||||
|
cargo run -- -e development
|
||||||
|
|
||||||
|
# 生产环境
|
||||||
|
cargo run -- -e production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台运行(生产环境)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 nohup
|
||||||
|
nohup cargo run -- -e production > app.log 2>&1 &
|
||||||
|
|
||||||
|
# 使用 screen
|
||||||
|
screen -S web-rust-template
|
||||||
|
cargo run -- -e production
|
||||||
|
# 按 Ctrl+A 然后 D 分离会话
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证安装
|
||||||
|
|
||||||
|
### 1. 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 服务器信息
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/info
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "web-rust-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "running",
|
||||||
|
"timestamp": 1704112800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 用户注册
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"created_at": "2026-02-13T12:00:00.000Z",
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 用户登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"id": "1234567890",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"created_at": "2026-02-13T12:00:00.000Z",
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 端口被占用
|
||||||
|
|
||||||
|
**错误信息**:`Os { code: 10048, kind: AddrInUse }` 或 `Address already in use`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
**选项 1**:修改配置文件中的端口
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
port = 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
**选项 2**:通过环境变量覆盖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVER_PORT=3001 cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
**选项 3**:停止占用端口的进程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :3000
|
||||||
|
taskkill /PID <pid> /F
|
||||||
|
|
||||||
|
# macOS/Linux
|
||||||
|
lsof -ti:3000 | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库连接失败
|
||||||
|
|
||||||
|
**错误信息**:`Database connection failed`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
- 检查数据库服务是否启动
|
||||||
|
- 检查配置文件中的数据库配置是否正确
|
||||||
|
- 确认数据库用户权限
|
||||||
|
- SQLite:检查是否有写入权限
|
||||||
|
|
||||||
|
### 3. Redis 连接失败
|
||||||
|
|
||||||
|
**错误信息**:`Redis 连接失败`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
- 检查 Redis 服务是否启动:`redis-cli ping`
|
||||||
|
- 检查配置文件中的 Redis 配置是否正确
|
||||||
|
- 如果不需要 Redis 功能,可以暂时禁用(需要修改代码)
|
||||||
|
|
||||||
|
### 4. 编译错误
|
||||||
|
|
||||||
|
**错误信息**:`error: linking with link.exe failed`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
- Windows 用户需要安装 [C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||||
|
- 或使用 `cargo install cargo-vcpkg` 安装依赖
|
||||||
|
|
||||||
|
### 5. 权限错误
|
||||||
|
|
||||||
|
**错误信息**:`Permission denied`
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
chmod +x target/debug/web-rust-template
|
||||||
|
|
||||||
|
# 或使用 sudo 运行(不推荐生产环境)
|
||||||
|
sudo cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
安装成功后,你可以:
|
||||||
|
|
||||||
|
1. 阅读 [API 接口文档](../api/api-overview.md) 了解所有可用的 API
|
||||||
|
2. 查看 [项目结构详解](project-structure.md) 了解代码组织
|
||||||
|
3. 学习 [DDD 架构规范](ddd-architecture.md) 了解设计原则
|
||||||
|
4. 参考 [前端集成示例](../api/examples/frontend-integration.md) 集成前端应用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [配置文件详解](../deployment/configuration.md) - 配置文件组织说明
|
||||||
|
- [环境变量配置](../deployment/environment-variables.md) - 完整的环境变量列表
|
||||||
|
- [API 接口文档](../api/api-overview.md) - 完整的 API 接口说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:遇到问题?查看 [常见问题](#常见问题) 或提交 Issue 到项目仓库。
|
||||||
615
docs/development/project-structure.md
Normal file
615
docs/development/project-structure.md
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
# 项目结构详解
|
||||||
|
|
||||||
|
本文档详细说明 Web Rust Template 的项目结构、DDD 分层架构和各层职责。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [DDD 分层架构](#ddd-分层架构)
|
||||||
|
- [项目目录结构](#项目目录结构)
|
||||||
|
- [各层职责说明](#各层职责说明)
|
||||||
|
- [数据流转](#数据流转)
|
||||||
|
- [核心组件](#核心组件)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DDD 分层架构
|
||||||
|
|
||||||
|
本系统采用**领域驱动设计(DDD)**的分层架构,将代码划分为不同的职责层次。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Interface Layer (handlers) │ HTTP 处理器层
|
||||||
|
│ 路由定义、请求处理、响应封装 │
|
||||||
|
└──────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼───────────────────────┐
|
||||||
|
│ Application Layer (services) │ 业务逻辑层
|
||||||
|
│ 业务逻辑、Token 生成、认证 │
|
||||||
|
└──────────────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌─────▼──────────┐
|
||||||
|
│ Domain │ │ Infrastructure│
|
||||||
|
│ Layer │ │ Layer │
|
||||||
|
│ │ │ │
|
||||||
|
│ - DTO │ │ - Middleware │
|
||||||
|
│ - Entities │ │ - Redis │
|
||||||
|
│ - VO │ │ - Repositories│
|
||||||
|
└─────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分层优势
|
||||||
|
|
||||||
|
| 优势 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 职责清晰 | 每层只关注自己的职责,降低耦合 |
|
||||||
|
| 易于测试 | 每层可独立测试,Mock 依赖 |
|
||||||
|
| 易于维护 | 修改某层不影响其他层 |
|
||||||
|
| 易于扩展 | 添加新功能只需扩展相应层 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
web-rust-template/
|
||||||
|
├── src/ # 源代码目录
|
||||||
|
│ ├── main.rs # 应用入口
|
||||||
|
│ ├── cli.rs # 命令行参数解析
|
||||||
|
│ ├── config.rs # 配置模块导出
|
||||||
|
│ ├── db.rs # 数据库连接池
|
||||||
|
│ ├── error.rs # 错误处理
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # 配置模块
|
||||||
|
│ │ ├── app.rs # 主配置结构
|
||||||
|
│ │ ├── auth.rs # 认证配置
|
||||||
|
│ │ ├── database.rs # 数据库配置
|
||||||
|
│ │ ├── redis.rs # Redis 配置
|
||||||
|
│ │ └── server.rs # 服务器配置
|
||||||
|
│ │
|
||||||
|
│ ├── domain/ # 领域层(DDD)
|
||||||
|
│ │ ├── dto/ # 数据传输对象(Data Transfer Object)
|
||||||
|
│ │ │ └── auth.rs # 认证相关 DTO
|
||||||
|
│ │ ├── entities/ # 实体(数据库模型)
|
||||||
|
│ │ │ └── users.rs # 用户实体
|
||||||
|
│ │ └── vo/ # 视图对象(View Object)
|
||||||
|
│ │ └── auth.rs # 认证相关 VO
|
||||||
|
│ │
|
||||||
|
│ ├── handlers/ # HTTP 处理器层(接口层)
|
||||||
|
│ │ ├── auth.rs # 认证接口
|
||||||
|
│ │ └── health.rs # 健康检查接口
|
||||||
|
│ │
|
||||||
|
│ ├── infra/ # 基础设施层
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ │ ├── auth.rs # JWT 认证中间件
|
||||||
|
│ │ │ └── logging.rs # 日志中间件
|
||||||
|
│ │ └── redis/ # Redis 客户端封装
|
||||||
|
│ │ ├── redis_client.rs
|
||||||
|
│ │ └── redis_key.rs
|
||||||
|
│ │
|
||||||
|
│ ├── repositories/ # 数据访问层
|
||||||
|
│ │ └── user_repository.rs # 用户数据访问
|
||||||
|
│ │
|
||||||
|
│ ├── services/ # 业务逻辑层
|
||||||
|
│ │ └── auth_service.rs # 认证业务逻辑
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # 工具函数
|
||||||
|
│ └── jwt.rs # JWT 工具类
|
||||||
|
│
|
||||||
|
├── config/ # 配置文件目录
|
||||||
|
│ ├── default.toml # 默认配置
|
||||||
|
│ ├── development.sqlite.toml # SQLite 开发环境配置
|
||||||
|
│ ├── development.mysql.toml # MySQL 开发环境配置
|
||||||
|
│ ├── development.postgresql.toml # PostgreSQL 开发环境配置
|
||||||
|
│ └── production.toml # 生产环境配置
|
||||||
|
│
|
||||||
|
├── sql/ # SQL 脚本
|
||||||
|
│ └── init.sql # 数据库初始化脚本
|
||||||
|
│
|
||||||
|
├── tests/ # 测试目录
|
||||||
|
│ └── integration_test.rs # 集成测试
|
||||||
|
│
|
||||||
|
├── docs/ # 文档目录
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── api/
|
||||||
|
│ ├── development/
|
||||||
|
│ └── deployment/
|
||||||
|
│
|
||||||
|
├── .env.example # 环境变量参考(仅用于 Docker/Kubernetes 等部署场景)
|
||||||
|
├── .gitignore # Git 忽略文件
|
||||||
|
├── Cargo.toml # 项目依赖定义
|
||||||
|
├── README.md # 项目说明
|
||||||
|
└── rust-toolchain.toml # Rust 工具链配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各层职责说明
|
||||||
|
|
||||||
|
### 1. 接口层(handlers/)
|
||||||
|
|
||||||
|
**职责**:处理 HTTP 请求和响应
|
||||||
|
|
||||||
|
**位置**:`src/handlers/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `auth.rs`:认证相关接口(注册、登录、刷新 Token、删除账号)
|
||||||
|
- `health.rs`:健康检查和服务器信息接口
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/handlers/auth.rs
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<RegisterResult>>, ErrorResponse> {
|
||||||
|
// 1. 记录日志
|
||||||
|
log_info(&request_id, "注册请求参数", &payload);
|
||||||
|
|
||||||
|
// 2. 调用服务层处理业务逻辑
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
// 3. 调用业务逻辑
|
||||||
|
match service.register(payload).await {
|
||||||
|
Ok((user_model, access_token, refresh_token)) => {
|
||||||
|
let data = RegisterResult::from((user_model, access_token, refresh_token));
|
||||||
|
let response = ApiResponse::success(data);
|
||||||
|
log_info(&request_id, "注册成功", &response);
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "注册失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责边界**:
|
||||||
|
- ✅ 接收 HTTP 请求
|
||||||
|
- ✅ 提取请求参数
|
||||||
|
- ✅ 调用服务层处理业务逻辑
|
||||||
|
- ✅ 封装响应数据
|
||||||
|
- ❌ 不包含业务逻辑
|
||||||
|
- ❌ 不直接访问数据库
|
||||||
|
|
||||||
|
### 2. 业务逻辑层(services/)
|
||||||
|
|
||||||
|
**职责**:实现核心业务逻辑
|
||||||
|
|
||||||
|
**位置**:`src/services/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `auth_service.rs`:认证业务逻辑(注册、登录、Token 刷新、密码哈希)
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/services/auth_service.rs
|
||||||
|
|
||||||
|
pub struct AuthService {
|
||||||
|
user_repo: UserRepository,
|
||||||
|
redis_client: RedisClient,
|
||||||
|
auth_config: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
/// 用户注册
|
||||||
|
pub async fn register(&self, payload: RegisterRequest) -> Result<(Model, String, String)> {
|
||||||
|
// 1. 验证邮箱格式
|
||||||
|
if !payload.email.contains('@') {
|
||||||
|
return Err(anyhow!("邮箱格式错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 生成唯一用户 ID
|
||||||
|
let user_id = self.generate_unique_user_id().await?;
|
||||||
|
|
||||||
|
// 3. 哈希密码
|
||||||
|
let password_hash = self.hash_password(&payload.password)?;
|
||||||
|
|
||||||
|
// 4. 创建用户实体
|
||||||
|
let user_model = users::Model {
|
||||||
|
id: user_id,
|
||||||
|
email: payload.email.clone(),
|
||||||
|
password_hash,
|
||||||
|
created_at: chrono::Utc::now().naive_utc(),
|
||||||
|
updated_at: chrono::Utc::now().naive_utc(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 保存到数据库
|
||||||
|
let created_user = self.user_repo.create(user_model).await?;
|
||||||
|
|
||||||
|
// 6. 生成 Token
|
||||||
|
let (access_token, refresh_token) = TokenService::generate_token_pair(
|
||||||
|
&created_user.id,
|
||||||
|
self.auth_config.access_token_expiration_minutes,
|
||||||
|
self.auth_config.refresh_token_expiration_days,
|
||||||
|
&self.auth_config.jwt_secret,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 7. 保存 Refresh Token 到 Redis
|
||||||
|
self.save_refresh_token(&created_user.id, &refresh_token, self.auth_config.refresh_token_expiration_days).await?;
|
||||||
|
|
||||||
|
Ok((created_user, access_token, refresh_token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责边界**:
|
||||||
|
- ✅ 实现业务逻辑
|
||||||
|
- ✅ 协调 Repository 和基础设施
|
||||||
|
- ✅ 事务管理
|
||||||
|
- ❌ 不处理 HTTP 请求/响应
|
||||||
|
- ❌ 不直接访问外部资源(通过 Repository)
|
||||||
|
|
||||||
|
### 3. 数据访问层(repositories/)
|
||||||
|
|
||||||
|
**职责**:封装数据库访问逻辑
|
||||||
|
|
||||||
|
**位置**:`src/repositories/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `user_repository.rs`:用户数据访问(增删改查)
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/repositories/user_repository.rs
|
||||||
|
|
||||||
|
pub struct UserRepository {
|
||||||
|
pool: DbPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository {
|
||||||
|
pub fn new(pool: DbPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建用户
|
||||||
|
pub async fn create(&self, user_model: users::Model) -> Result<users::Model> {
|
||||||
|
let result = users::Entity::insert(user_model.into_active_model())
|
||||||
|
.exec(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("创建用户失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(users::Entity::find_by_id(result.last_insert_id))
|
||||||
|
.one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("查询用户失败: {}", e))?
|
||||||
|
.ok_or_else(|| anyhow!("用户不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据邮箱查询用户
|
||||||
|
pub async fn find_by_email(&self, email: &str) -> Result<Option<users::Model>> {
|
||||||
|
Ok(users::Entity::find()
|
||||||
|
.filter(users::Column::Email.eq(email))
|
||||||
|
.one(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据ID查询用户
|
||||||
|
pub async fn find_by_id(&self, id: &str) -> Result<Option<users::Model>> {
|
||||||
|
Ok(users::Entity::find_by_id(id.to_string())
|
||||||
|
.one(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计相同ID的用户数量
|
||||||
|
pub async fn count_by_id(&self, id: &str) -> Result<u64> {
|
||||||
|
Ok(users::Entity::find_by_id(id.to_string())
|
||||||
|
.count(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责边界**:
|
||||||
|
- ✅ 数据库 CRUD 操作
|
||||||
|
- ✅ 封装 SeaORM 细节
|
||||||
|
- ❌ 不包含业务逻辑
|
||||||
|
- ❌ 不处理 HTTP 请求
|
||||||
|
|
||||||
|
### 4. 领域层(domain/)
|
||||||
|
|
||||||
|
**职责**:定义核心业务模型
|
||||||
|
|
||||||
|
**位置**:`src/domain/`
|
||||||
|
|
||||||
|
#### DTO(Data Transfer Object)
|
||||||
|
|
||||||
|
**职责**:定义 API 请求和响应的数据结构
|
||||||
|
|
||||||
|
**位置**:`src/domain/dto/`
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/domain/dto/auth.rs
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Entities(实体)
|
||||||
|
|
||||||
|
**职责**:定义数据库表模型
|
||||||
|
|
||||||
|
**位置**:`src/domain/entities/`
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/domain/entities/users.rs
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text", unique)]
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: DateTime,
|
||||||
|
pub updated_at: DateTime,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### VO(View Object)
|
||||||
|
|
||||||
|
**职责**:定义 API 响应的数据结构
|
||||||
|
|
||||||
|
**位置**:`src/domain/vo/`
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/domain/vo/auth.rs
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RegisterResult {
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 基础设施层(infra/)
|
||||||
|
|
||||||
|
**职责**:提供技术基础设施
|
||||||
|
|
||||||
|
**位置**:`src/infra/`
|
||||||
|
|
||||||
|
#### 中间件(middleware/)
|
||||||
|
|
||||||
|
**职责**:请求拦截和处理
|
||||||
|
|
||||||
|
**位置**:`src/infra/middleware/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `auth.rs`:JWT 认证中间件
|
||||||
|
- `logging.rs`:日志中间件
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/infra/middleware/auth.rs
|
||||||
|
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ErrorResponse> {
|
||||||
|
// 1. 提取 Authorization header
|
||||||
|
let auth_header = request
|
||||||
|
.headers()
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?;
|
||||||
|
|
||||||
|
// 2. 验证 Bearer 格式
|
||||||
|
if !auth_header.starts_with("Bearer ") {
|
||||||
|
return Err(ErrorResponse::new("Authorization header 格式错误".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = &auth_header[7..];
|
||||||
|
|
||||||
|
// 3. 验证 JWT
|
||||||
|
let claims = TokenService::decode_user_id(token, &state.config.auth.jwt_secret)?;
|
||||||
|
|
||||||
|
// 4. 将 user_id 添加到请求扩展
|
||||||
|
request.extensions_mut().insert(claims.sub);
|
||||||
|
|
||||||
|
// 5. 继续处理请求
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Redis 客户端(redis/)
|
||||||
|
|
||||||
|
**职责**:封装 Redis 操作
|
||||||
|
|
||||||
|
**位置**:`src/infra/redis/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `redis_client.rs`:Redis 客户端封装
|
||||||
|
- `redis_key.rs`:Redis Key 命名规范
|
||||||
|
|
||||||
|
### 6. 工具层(utils/)
|
||||||
|
|
||||||
|
**职责**:提供通用工具函数
|
||||||
|
|
||||||
|
**位置**:`src/utils/`
|
||||||
|
|
||||||
|
**关键文件**:
|
||||||
|
- `jwt.rs`:JWT Token 生成和验证
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```rust
|
||||||
|
// src/utils/jwt.rs
|
||||||
|
|
||||||
|
pub struct TokenService;
|
||||||
|
|
||||||
|
impl TokenService {
|
||||||
|
/// 生成 Access Token
|
||||||
|
pub fn generate_access_token(
|
||||||
|
user_id: &str,
|
||||||
|
expiration_minutes: u64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
// ... 生成 JWT Token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 Token 并提取 user_id
|
||||||
|
pub fn decode_user_id(token: &str, jwt_secret: &str) -> Result<String> {
|
||||||
|
// ... 验证并解码 JWT Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流转
|
||||||
|
|
||||||
|
### 用户注册流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 客户端发起 POST /auth/register 请求
|
||||||
|
↓
|
||||||
|
2. handlers/auth.rs::register() 接收请求
|
||||||
|
- 提取请求参数(RegisterRequest)
|
||||||
|
↓
|
||||||
|
3. services/auth_service.rs::register() 处理业务逻辑
|
||||||
|
- 验证邮箱格式
|
||||||
|
- 生成唯一用户 ID
|
||||||
|
- 哈希密码
|
||||||
|
- 创建用户实体
|
||||||
|
↓
|
||||||
|
4. repositories/user_repository.rs::create() 保存到数据库
|
||||||
|
- 使用 SeaORM 插入数据
|
||||||
|
↓
|
||||||
|
5. services/auth_service.rs 生成 Token
|
||||||
|
- 生成 Access Token
|
||||||
|
- 生成 Refresh Token
|
||||||
|
↓
|
||||||
|
6. Redis 保存 Refresh Token
|
||||||
|
↓
|
||||||
|
7. handlers/auth.rs 封装响应(RegisterResult)
|
||||||
|
↓
|
||||||
|
8. 返回 JSON 响应给客户端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问受保护接口流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 客户端发起 POST /auth/delete 请求
|
||||||
|
- 携带 Authorization: Bearer <access_token>
|
||||||
|
↓
|
||||||
|
2. infra/middleware/auth.rs::auth_middleware() 拦截
|
||||||
|
- 验证 Token 格式
|
||||||
|
- 验证 JWT 签名
|
||||||
|
- 检查 Token 过期时间
|
||||||
|
- 提取 user_id 并添加到请求扩展
|
||||||
|
↓
|
||||||
|
3. handlers/auth.rs::delete_account() 接收请求
|
||||||
|
- 从扩展中提取 user_id
|
||||||
|
↓
|
||||||
|
4. services/auth_service.rs::delete_account() 处理业务逻辑
|
||||||
|
- 验证密码
|
||||||
|
- 调用 Repository 删除用户
|
||||||
|
↓
|
||||||
|
5. repositories/user_repository.rs::delete() 删除数据库记录
|
||||||
|
↓
|
||||||
|
6. 返回响应
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### AppState
|
||||||
|
|
||||||
|
**职责**:应用全局状态
|
||||||
|
|
||||||
|
**位置**:`src/main.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: db::DbPool, // 数据库连接池
|
||||||
|
pub config: config::app::AppConfig, // 应用配置
|
||||||
|
pub redis_client: infra::redis::redis_client::RedisClient, // Redis 客户端
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 通过 Axum State 机制注入到所有处理器
|
||||||
|
- 提供数据库访问
|
||||||
|
- 提供配置信息
|
||||||
|
- 提供 Redis 访问
|
||||||
|
|
||||||
|
### 路由配置
|
||||||
|
|
||||||
|
**位置**:`src/main.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 公开路由
|
||||||
|
let public_routes = Router::new()
|
||||||
|
.route("/health", get(handlers::health::health_check))
|
||||||
|
.route("/info", get(handlers::health::server_info))
|
||||||
|
.route("/auth/register", post(handlers::auth::register))
|
||||||
|
.route("/auth/login", post(handlers::auth::login))
|
||||||
|
.route("/auth/refresh", post(handlers::auth::refresh));
|
||||||
|
|
||||||
|
// 受保护路由
|
||||||
|
let protected_routes = Router::new()
|
||||||
|
.route("/auth/delete", post(handlers::auth::delete_account))
|
||||||
|
.route("//auth/delete-refresh-token", post(handlers::auth::delete_refresh_token))
|
||||||
|
.route_layer(axum::middleware::from_fn_with_state(
|
||||||
|
app_state.clone(),
|
||||||
|
infra::middleware::auth::auth_middleware,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 合并所有路由
|
||||||
|
let app = public_routes
|
||||||
|
.merge(protected_routes)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.layer(axum::middleware::from_fn(
|
||||||
|
infra::middleware::logging::logging_middleware,
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [DDD 架构规范](ddd-architecture.md) - DDD 设计原则和最佳实践
|
||||||
|
- [代码风格规范](code-style.md) - Rust 代码风格和命名规范
|
||||||
|
- [快速开始指南](getting-started.md) - 安装和运行项目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提示**:遵循 DDD 分层架构可以提高代码质量和可维护性。
|
||||||
23
docs/sql/init.sql
Normal file
23
docs/sql/init.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Web Template 数据库初始化脚本
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS `web_template` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE `web_template`;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. 用户表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(10) PRIMARY KEY COMMENT '10位数字用户ID',
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL COMMENT '更新时间'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 初始化完成
|
||||||
|
-- ============================================
|
||||||
|
SELECT '✅ 数据库初始化完成' AS status;
|
||||||
|
SHOW TABLES;
|
||||||
234
src/cli.rs
Normal file
234
src/cli.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/// 命令行参数和配置管理
|
||||||
|
/// 支持优先级:CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 运行环境(强类型)
|
||||||
|
#[derive(ValueEnum, Clone, Debug)]
|
||||||
|
pub enum Environment {
|
||||||
|
/// 开发环境
|
||||||
|
Development,
|
||||||
|
/// 生产环境
|
||||||
|
Production,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Environment {
|
||||||
|
/// 转换为小写字符串
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Environment::Development => "development",
|
||||||
|
Environment::Production => "production",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命令行参数
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "web-rust-template")]
|
||||||
|
#[command(about = "Web Server Template", long_about = None)]
|
||||||
|
#[command(author = "Your Name <your.email@example.com>")]
|
||||||
|
#[command(version = "0.1.0")]
|
||||||
|
#[command(propagate_version = true)]
|
||||||
|
pub struct CliArgs {
|
||||||
|
/// 指定配置文件路径
|
||||||
|
///
|
||||||
|
/// 支持相对路径和绝对路径
|
||||||
|
/// 例如:-c config/production.toml
|
||||||
|
#[arg(short, long, value_name = "FILE")]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 指定运行环境
|
||||||
|
///
|
||||||
|
/// 自动加载对应环境的配置文件(如 config/development.toml)
|
||||||
|
/// 可通过环境变量 ENV 设置
|
||||||
|
#[arg(
|
||||||
|
short = 'e',
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
env = "ENV",
|
||||||
|
default_value = "development"
|
||||||
|
)]
|
||||||
|
pub env: Environment,
|
||||||
|
|
||||||
|
/// 指定服务器监听端口
|
||||||
|
///
|
||||||
|
/// 覆盖配置文件中的 port 设置
|
||||||
|
/// 可通过环境变量 SERVER_PORT 设置
|
||||||
|
#[arg(short, long, global = true, env = "SERVER_PORT")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
|
||||||
|
/// 指定服务器监听地址
|
||||||
|
///
|
||||||
|
/// 覆盖配置文件中的 host 设置
|
||||||
|
/// 可通过环境变量 SERVER_HOST 设置
|
||||||
|
#[arg(long, global = true, env = "SERVER_HOST")]
|
||||||
|
pub host: Option<String>,
|
||||||
|
|
||||||
|
/// 启用调试日志
|
||||||
|
///
|
||||||
|
/// 输出详细的日志信息,包括 SQL 查询
|
||||||
|
/// 可通过环境变量 DEBUG 设置
|
||||||
|
/// 注意:与 -v 冲突,推荐使用 -v/-vv/-vvv
|
||||||
|
#[arg(long, global = true, env = "DEBUG", conflicts_with = "verbose")]
|
||||||
|
pub debug: bool,
|
||||||
|
|
||||||
|
/// 工作目录
|
||||||
|
///
|
||||||
|
/// 指定配置文件和数据库的基准目录
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
pub work_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 显示详细日志(多级 verbose)
|
||||||
|
///
|
||||||
|
/// -v : info 级别日志
|
||||||
|
/// -vv : debug 级别日志(等同于 --debug)
|
||||||
|
/// -vvv : trace 级别日志(最详细)
|
||||||
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
|
pub verbose: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliArgs {
|
||||||
|
/// 获取是否启用调试
|
||||||
|
pub fn is_debug_enabled(&self) -> bool {
|
||||||
|
self.debug || self.verbose >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取日志级别
|
||||||
|
pub fn get_log_level(&self) -> &'static str {
|
||||||
|
if self.debug {
|
||||||
|
return "debug";
|
||||||
|
}
|
||||||
|
match self.verbose {
|
||||||
|
0 => "info",
|
||||||
|
1 => "debug",
|
||||||
|
_ => "trace",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取环境变量的日志过滤器(工程化版本)
|
||||||
|
pub fn get_log_filter(&self) -> String {
|
||||||
|
let level = self.get_log_level();
|
||||||
|
match level {
|
||||||
|
"trace" => "web_rust_template=trace,tower_http=trace,axum=trace,sqlx=debug".into(),
|
||||||
|
"debug" => "web_rust_template=debug,tower_http=debug,axum=debug,sqlx=debug".into(),
|
||||||
|
_ => "web_rust_template=info,tower_http=info,axum=info".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取配置文件路径
|
||||||
|
///
|
||||||
|
/// 优先级:
|
||||||
|
/// 1. CLI 参数 --config
|
||||||
|
/// 2. 环境变量 CONFIG
|
||||||
|
/// 3. {work_dir}/config/{env}.toml
|
||||||
|
/// 4. ./config/{env}.toml
|
||||||
|
/// 5. ./config/default.toml
|
||||||
|
///
|
||||||
|
/// 如果找不到配置文件,返回 None(允许仅使用环境变量运行)
|
||||||
|
pub fn resolve_config_path(&self) -> Option<PathBuf> {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
// 1. CLI 参数优先
|
||||||
|
if let Some(ref config) = self.config {
|
||||||
|
if config.exists() {
|
||||||
|
return Some(config.clone());
|
||||||
|
}
|
||||||
|
eprintln!("⚠ 警告:指定的配置文件不存在: {}", config.display());
|
||||||
|
eprintln!(" 将仅使用环境变量运行");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 环境变量
|
||||||
|
if let Ok(config_path) = env::var("CONFIG") {
|
||||||
|
let config = PathBuf::from(&config_path);
|
||||||
|
if config.exists() {
|
||||||
|
return Some(config);
|
||||||
|
}
|
||||||
|
eprintln!("⚠ 警告:环境变量 CONFIG 指定的配置文件不存在: {}", config_path);
|
||||||
|
eprintln!(" 将仅使用环境变量运行");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-6. 查找配置文件
|
||||||
|
let work_dir = self
|
||||||
|
.work_dir
|
||||||
|
.clone()
|
||||||
|
.or_else(|| env::current_dir().ok())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
|
||||||
|
let env_name = self.env.as_str();
|
||||||
|
|
||||||
|
// 按优先级尝试的位置
|
||||||
|
let candidates = [
|
||||||
|
// 工作目录下的环境配置
|
||||||
|
work_dir.join("config").join(format!("{}.toml", env_name)),
|
||||||
|
// 当前目录的环境配置
|
||||||
|
PathBuf::from(format!("config/{}.toml", env_name)),
|
||||||
|
// 工作目录下的默认配置
|
||||||
|
work_dir.join("config").join("default.toml"),
|
||||||
|
// 当前目录的默认配置
|
||||||
|
PathBuf::from("config/default.toml"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for candidate in &candidates {
|
||||||
|
if candidate.exists() {
|
||||||
|
// 使用 println! 而非 tracing::info!
|
||||||
|
println!("✓ Found configuration file: {}", candidate.display());
|
||||||
|
return Some(candidate.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有候选路径都找不到配置文件,返回 None
|
||||||
|
eprintln!("ℹ 未找到配置文件,将仅使用环境变量和默认值");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取覆盖配置
|
||||||
|
///
|
||||||
|
/// CLI 参数可以覆盖配置文件中的值(仅 Web 服务器参数)
|
||||||
|
pub fn get_overrides(&self) -> ConfigOverrides {
|
||||||
|
ConfigOverrides {
|
||||||
|
host: self.host.clone(),
|
||||||
|
port: self.port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示启动信息(工程化版本:打印实际解析的配置)
|
||||||
|
///
|
||||||
|
/// 使用 println! 而非 tracing::info!,因为 logger 可能尚未初始化
|
||||||
|
pub fn print_startup_info(&self) {
|
||||||
|
let separator = "=".repeat(60);
|
||||||
|
println!("{}", separator);
|
||||||
|
println!("Web Rust Template Server v0.1.0");
|
||||||
|
println!("Environment: {}", self.env.as_str());
|
||||||
|
|
||||||
|
// 打印实际解析的配置路径(而非 CLI 参数)
|
||||||
|
if let Some(config_path) = self.resolve_config_path() {
|
||||||
|
println!("Config file: {}", config_path.display());
|
||||||
|
} else {
|
||||||
|
println!("Config file: None (using environment variables)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref work_dir) = self.work_dir {
|
||||||
|
println!("Work directory: {}", work_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印实际的日志级别
|
||||||
|
println!("Log level: {}", self.get_log_level());
|
||||||
|
|
||||||
|
if self.is_debug_enabled() {
|
||||||
|
println!("Debug mode: ENABLED");
|
||||||
|
}
|
||||||
|
println!("{}", separator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLI 参数覆盖的配置(仅 Web 服务器参数)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConfigOverrides {
|
||||||
|
/// Web 服务器主机覆盖
|
||||||
|
pub host: Option<String>,
|
||||||
|
|
||||||
|
/// Web 服务器端口覆盖
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
135
src/config/app.rs
Normal file
135
src/config/app.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use super::{auth::AuthConfig, database::DatabaseConfig, redis::RedisConfig, server::ServerConfig};
|
||||||
|
use config::{Config, ConfigError, Environment, File};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// 导入 redis 默认值函数(使用完整路径)
|
||||||
|
use crate::config::redis::default_redis_host;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
pub redis: RedisConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// 加载配置(支持 CLI 覆盖)
|
||||||
|
///
|
||||||
|
/// 如果 config_path 为 None,则仅使用环境变量和默认值
|
||||||
|
pub fn load_with_overrides(
|
||||||
|
cli_config_path: Option<std::path::PathBuf>,
|
||||||
|
overrides: crate::cli::ConfigOverrides,
|
||||||
|
_environment: &str,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
// 使用 ConfigBuilder 设置配置
|
||||||
|
let mut builder = Config::builder();
|
||||||
|
|
||||||
|
// 如果提供了配置文件,先加载它
|
||||||
|
if let Some(config_path) = cli_config_path {
|
||||||
|
if !config_path.exists() {
|
||||||
|
tracing::error!("Configuration file not found: {}", config_path.display());
|
||||||
|
return Err(ConfigError::NotFound(
|
||||||
|
config_path.to_string_lossy().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::info!("Loading configuration from: {}", config_path.display());
|
||||||
|
builder = builder.add_source(File::from(config_path));
|
||||||
|
} else {
|
||||||
|
tracing::info!("No configuration file found, using environment variables and defaults");
|
||||||
|
tracing::warn!("⚠️ 没有找到配置文件,将使用 SQLite 作为默认数据库");
|
||||||
|
tracing::warn!(" 默认数据库路径: db.sqlite3");
|
||||||
|
tracing::warn!(" 如需使用其他数据库,请创建配置文件或设置环境变量");
|
||||||
|
|
||||||
|
// 直接使用 set_default 设置默认值
|
||||||
|
// 注意:这些值会被环境变量覆盖
|
||||||
|
builder = builder.set_default("server.host", default_server_host())?;
|
||||||
|
builder = builder.set_default("server.port", default_server_port())?;
|
||||||
|
|
||||||
|
// 设置 database 默认值(使用 SQLite 作为默认数据库)
|
||||||
|
builder = builder.set_default("database.database_type", "sqlite")?;
|
||||||
|
builder = builder.set_default("database.path", "db.sqlite3")?;
|
||||||
|
builder = builder.set_default("database.max_connections", 10)?;
|
||||||
|
|
||||||
|
// 设置 auth 默认值
|
||||||
|
builder = builder.set_default("auth.jwt_secret", default_jwt_secret())?;
|
||||||
|
builder = builder.set_default("auth.access_token_expiration_minutes", 15)?;
|
||||||
|
builder = builder.set_default("auth.refresh_token_expiration_days", 7)?;
|
||||||
|
|
||||||
|
// 设置 redis 默认值
|
||||||
|
builder = builder.set_default("redis.host", default_redis_host())?;
|
||||||
|
builder = builder.set_default("redis.port", 6379)?;
|
||||||
|
builder = builder.set_default("redis.db", 0)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加环境变量源(会覆盖配置文件的值)
|
||||||
|
builder = builder.add_source(Environment::default().separator("_"));
|
||||||
|
|
||||||
|
// 应用 CLI 覆盖(仅 Web 服务器参数)
|
||||||
|
if let Some(host) = overrides.host {
|
||||||
|
builder = builder.set_override("server.host", host)?;
|
||||||
|
}
|
||||||
|
if let Some(port) = overrides.port {
|
||||||
|
builder = builder.set_override("server.port", port)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = builder.build()?;
|
||||||
|
let config: AppConfig = settings.try_deserialize()?;
|
||||||
|
|
||||||
|
// 安全警告:检查是否使用了默认的 JWT 密钥
|
||||||
|
if config.auth.jwt_secret == "change-this-to-a-strong-secret-key-in-production" {
|
||||||
|
tracing::warn!("⚠️ 警告:正在使用不安全的默认 JWT 密钥!");
|
||||||
|
tracing::warn!(" 请通过环境变量 AUTH_JWT_SECRET 或配置文件设置强密钥");
|
||||||
|
tracing::warn!(" 示例:AUTH_JWT_SECRET=your-secure-random-string-here");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据库配置
|
||||||
|
if let Err(e) = config.database.validate() {
|
||||||
|
tracing::error!("数据库配置无效: {}", e);
|
||||||
|
return Err(ConfigError::Message(format!("数据库配置无效: {}", e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从指定路径加载配置
|
||||||
|
pub fn load_from_path(path: &str) -> Result<Self, ConfigError> {
|
||||||
|
tracing::info!("Loading configuration from: {}", path);
|
||||||
|
|
||||||
|
let settings = Config::builder()
|
||||||
|
.add_source(File::from(PathBuf::from(path)))
|
||||||
|
.add_source(Environment::default().separator("_"))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let config: AppConfig = settings.try_deserialize()?;
|
||||||
|
|
||||||
|
// 安全警告:检查是否使用了默认的 JWT 密钥
|
||||||
|
if config.auth.jwt_secret == "change-this-to-a-strong-secret-key-in-production" {
|
||||||
|
tracing::warn!("⚠️ 警告:正在使用不安全的默认 JWT 密钥!");
|
||||||
|
tracing::warn!(" 请通过环境变量 AUTH_JWT_SECRET 或配置文件设置强密钥");
|
||||||
|
tracing::warn!(" 示例:AUTH_JWT_SECRET=your-secure-random-string-here");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数据库配置
|
||||||
|
if let Err(e) = config.database.validate() {
|
||||||
|
tracing::error!("数据库配置无效: {}", e);
|
||||||
|
return Err(ConfigError::Message(format!("数据库配置无效: {}", e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认值函数(复用)
|
||||||
|
fn default_server_host() -> String {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_server_port() -> u16 {
|
||||||
|
3000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_jwt_secret() -> String {
|
||||||
|
"change-this-to-a-strong-secret-key-in-production".to_string()
|
||||||
|
}
|
||||||
25
src/config/auth.rs
Normal file
25
src/config/auth.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
#[serde(default = "default_jwt_secret")]
|
||||||
|
pub jwt_secret: String,
|
||||||
|
#[serde(default = "default_access_token_expiration_minutes")]
|
||||||
|
pub access_token_expiration_minutes: u64,
|
||||||
|
#[serde(default = "default_refresh_token_expiration_days")]
|
||||||
|
pub refresh_token_expiration_days: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_jwt_secret() -> String {
|
||||||
|
// ⚠️ 警告:这是一个不安全的默认值,仅用于开发测试
|
||||||
|
// 生产环境必须通过环境变量或配置文件设置强密钥
|
||||||
|
"change-this-to-a-strong-secret-key-in-production".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_access_token_expiration_minutes() -> u64 {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_refresh_token_expiration_days() -> i64 {
|
||||||
|
7
|
||||||
|
}
|
||||||
168
src/config/database.rs
Normal file
168
src/config/database.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 数据库类型
|
||||||
|
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DatabaseType {
|
||||||
|
MySQL,
|
||||||
|
SQLite,
|
||||||
|
PostgreSQL,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
/// 数据库类型
|
||||||
|
#[serde(default = "default_database_type")]
|
||||||
|
pub database_type: DatabaseType,
|
||||||
|
|
||||||
|
/// 网络数据库配置(MySQL/PostgreSQL)
|
||||||
|
pub host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub database: Option<String>,
|
||||||
|
|
||||||
|
/// SQLite 文件路径
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// 连接池最大连接数
|
||||||
|
#[serde(default = "default_max_connections")]
|
||||||
|
pub max_connections: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseConfig {
|
||||||
|
/// 获取端口号(根据数据库类型返回默认值)
|
||||||
|
pub fn get_port(&self) -> u16 {
|
||||||
|
self.port.unwrap_or_else(|| match self.database_type {
|
||||||
|
DatabaseType::MySQL => 3306,
|
||||||
|
DatabaseType::PostgreSQL => 5432,
|
||||||
|
DatabaseType::SQLite => 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建数据库连接 URL
|
||||||
|
///
|
||||||
|
/// # 错误
|
||||||
|
///
|
||||||
|
/// 当缺少必需的配置字段时返回错误
|
||||||
|
pub fn build_url(&self) -> Result<String, String> {
|
||||||
|
match self.database_type {
|
||||||
|
DatabaseType::MySQL => {
|
||||||
|
let host = self
|
||||||
|
.host
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "MySQL 需要配置 database.host".to_string())?;
|
||||||
|
let user = self
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "MySQL 需要配置 database.user".to_string())?;
|
||||||
|
let password = self
|
||||||
|
.password
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "MySQL 需要配置 database.password".to_string())?;
|
||||||
|
let database = self
|
||||||
|
.database
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "MySQL 需要配置 database.database".to_string())?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"mysql://{}:{}@{}:{}/{}",
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
self.get_port(),
|
||||||
|
database
|
||||||
|
))
|
||||||
|
}
|
||||||
|
DatabaseType::SQLite => {
|
||||||
|
let path = self
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "SQLite 需要配置 database.path".to_string())?;
|
||||||
|
|
||||||
|
// SQLite URL 格式
|
||||||
|
// 相对路径:sqlite:./db.sqlite3
|
||||||
|
// 绝对路径:sqlite:C:/path/to/db.sqlite3
|
||||||
|
let path_str = path.to_string_lossy().replace('\\', "/");
|
||||||
|
Ok(format!("sqlite:{}", path_str))
|
||||||
|
}
|
||||||
|
DatabaseType::PostgreSQL => {
|
||||||
|
let host = self
|
||||||
|
.host
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "PostgreSQL 需要配置 database.host".to_string())?;
|
||||||
|
let user = self
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "PostgreSQL 需要配置 database.user".to_string())?;
|
||||||
|
let password = self
|
||||||
|
.password
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "PostgreSQL 需要配置 database.password".to_string())?;
|
||||||
|
let database = self
|
||||||
|
.database
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "PostgreSQL 需要配置 database.database".to_string())?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"postgresql://{}:{}@{}:{}/{}",
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
self.get_port(),
|
||||||
|
database
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证配置是否完整
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
match self.database_type {
|
||||||
|
DatabaseType::MySQL => {
|
||||||
|
if self.host.is_none() {
|
||||||
|
return Err("MySQL 需要配置 database.host".to_string());
|
||||||
|
}
|
||||||
|
if self.user.is_none() {
|
||||||
|
return Err("MySQL 需要配置 database.user".to_string());
|
||||||
|
}
|
||||||
|
if self.password.is_none() {
|
||||||
|
return Err("MySQL 需要配置 database.password".to_string());
|
||||||
|
}
|
||||||
|
if self.database.is_none() {
|
||||||
|
return Err("MySQL 需要配置 database.database".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DatabaseType::SQLite => {
|
||||||
|
if self.path.is_none() {
|
||||||
|
return Err("SQLite 需要配置 database.path".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DatabaseType::PostgreSQL => {
|
||||||
|
if self.host.is_none() {
|
||||||
|
return Err("PostgreSQL 需要配置 database.host".to_string());
|
||||||
|
}
|
||||||
|
if self.user.is_none() {
|
||||||
|
return Err("PostgreSQL 需要配置 database.user".to_string());
|
||||||
|
}
|
||||||
|
if self.password.is_none() {
|
||||||
|
return Err("PostgreSQL 需要配置 database.password".to_string());
|
||||||
|
}
|
||||||
|
if self.database.is_none() {
|
||||||
|
return Err("PostgreSQL 需要配置 database.database".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_database_type() -> DatabaseType {
|
||||||
|
DatabaseType::MySQL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_connections() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
5
src/config/mod.rs
Normal file
5
src/config/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod database;
|
||||||
|
pub mod redis;
|
||||||
|
pub mod server;
|
||||||
52
src/config/redis.rs
Normal file
52
src/config/redis.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct RedisConfig {
|
||||||
|
/// Redis 主机地址
|
||||||
|
#[serde(default = "default_redis_host")]
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// Redis 端口
|
||||||
|
#[serde(default = "default_redis_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Redis 密码(可选)
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// Redis 数据库编号(可选)
|
||||||
|
#[serde(default = "default_redis_db")]
|
||||||
|
pub db: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_redis_host() -> String {
|
||||||
|
"localhost".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_redis_port() -> u16 {
|
||||||
|
6379
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_redis_db() -> u8 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisConfig {
|
||||||
|
/// 构建 Redis 连接 URL
|
||||||
|
pub fn build_url(&self) -> String {
|
||||||
|
// 判断密码是否存在且非空
|
||||||
|
match &self.password {
|
||||||
|
Some(password) if !password.is_empty() => {
|
||||||
|
// 有密码:redis://:password@host:port/db
|
||||||
|
format!(
|
||||||
|
"redis://:{}@{}:{}/{}",
|
||||||
|
password, self.host, self.port, self.db
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 无密码(None 或空字符串):redis://host:port/db
|
||||||
|
format!("redis://{}:{}/{}", self.host, self.port, self.db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/config/server.rs
Normal file
17
src/config/server.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
#[serde(default = "default_server_host")]
|
||||||
|
pub host: String,
|
||||||
|
#[serde(default = "default_server_port")]
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_server_host() -> String {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_server_port() -> u16 {
|
||||||
|
3000
|
||||||
|
}
|
||||||
329
src/db.rs
Normal file
329
src/db.rs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
use crate::config::database::{DatabaseConfig, DatabaseType};
|
||||||
|
use sea_orm::{
|
||||||
|
ConnectionTrait, Database, DatabaseConnection, DbBackend, EntityName, EntityTrait, ConnectOptions, Schema,
|
||||||
|
Statement,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 数据库连接池(SeaORM 统一接口)
|
||||||
|
pub type DbPool = DatabaseConnection;
|
||||||
|
|
||||||
|
/// 创建数据库连接池
|
||||||
|
pub async fn create_pool(config: &DatabaseConfig) -> anyhow::Result<DbPool> {
|
||||||
|
let url = config
|
||||||
|
.build_url()
|
||||||
|
.map_err(|e| anyhow::anyhow!("数据库配置错误: {}", e))?;
|
||||||
|
|
||||||
|
tracing::debug!("数据库连接 URL: {}", url);
|
||||||
|
|
||||||
|
let mut opt = ConnectOptions::new(&url);
|
||||||
|
opt.max_connections(config.max_connections)
|
||||||
|
.min_connections(1)
|
||||||
|
.connect_timeout(Duration::from_secs(8))
|
||||||
|
.idle_timeout(Duration::from_secs(8))
|
||||||
|
.max_lifetime(Duration::from_secs(7200))
|
||||||
|
.sqlx_logging(true);
|
||||||
|
|
||||||
|
let pool = Database::connect(opt)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("数据库连接失败: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("已连接到数据库: {}", sanitize_url(&url));
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏 URL 中的敏感信息(用于日志输出)
|
||||||
|
fn sanitize_url(url: &str) -> String {
|
||||||
|
// 隐藏密码:mysql://user:password@host -> mysql://user:***@host
|
||||||
|
if let Some(at_pos) = url.find('@') {
|
||||||
|
if let Some(scheme_end) = url.find("://") {
|
||||||
|
if scheme_end < at_pos {
|
||||||
|
return format!("{}***@{}", &url[..scheme_end + 3], &url[at_pos + 1..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康检查(保持向后兼容)
|
||||||
|
pub async fn health_check(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
|
// 使用官方推荐的 ping 方法
|
||||||
|
pool.ping()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("数据库健康检查失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化数据库和表结构
|
||||||
|
/// 每次启动时检查数据库和表是否存在,不存在则创建
|
||||||
|
pub async fn init_database(config: &DatabaseConfig) -> anyhow::Result<DatabaseConnection> {
|
||||||
|
match config.database_type {
|
||||||
|
DatabaseType::MySQL => {
|
||||||
|
init_mysql_database(config).await?;
|
||||||
|
}
|
||||||
|
DatabaseType::PostgreSQL => {
|
||||||
|
init_postgresql_database(config).await?;
|
||||||
|
}
|
||||||
|
DatabaseType::SQLite => {
|
||||||
|
// 确保 SQLite 数据库文件的目录存在
|
||||||
|
init_sqlite_database(config).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接到数据库
|
||||||
|
let pool = create_pool(config).await?;
|
||||||
|
|
||||||
|
// 创建表
|
||||||
|
create_tables(&pool).await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取端口号(根据数据库类型返回默认值)
|
||||||
|
fn get_database_port(config: &DatabaseConfig) -> u16 {
|
||||||
|
config.port.unwrap_or_else(|| match config.database_type {
|
||||||
|
DatabaseType::MySQL => 3306,
|
||||||
|
DatabaseType::PostgreSQL => 5432,
|
||||||
|
DatabaseType::SQLite => 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 MySQL 创建数据库(如果不存在)
|
||||||
|
async fn init_mysql_database(config: &DatabaseConfig) -> anyhow::Result<()> {
|
||||||
|
let database_name = config
|
||||||
|
.database
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.database"))?;
|
||||||
|
|
||||||
|
let host = config
|
||||||
|
.host
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.host"))?;
|
||||||
|
let user = config
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.user"))?;
|
||||||
|
let password = config
|
||||||
|
.password
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("MySQL 需要配置 database.password"))?;
|
||||||
|
|
||||||
|
// 连接到 MySQL 服务器(不指定数据库)
|
||||||
|
let url = format!(
|
||||||
|
"mysql://{}:{}@{}:{}",
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
get_database_port(config)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut opt = ConnectOptions::new(&url);
|
||||||
|
opt.max_connections(1)
|
||||||
|
.connect_timeout(Duration::from_secs(8))
|
||||||
|
.sqlx_logging(true);
|
||||||
|
|
||||||
|
let conn = Database::connect(opt)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("连接 MySQL 服务器失败: {}", e))?;
|
||||||
|
|
||||||
|
// 检查数据库是否存在,不存在则创建
|
||||||
|
let query = format!(
|
||||||
|
"CREATE DATABASE IF NOT EXISTS `{}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||||
|
database_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::MySql,
|
||||||
|
query,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("创建 MySQL 数据库失败: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("✅ MySQL 数据库 '{}' 检查完成", database_name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 PostgreSQL 创建数据库(如果不存在)
|
||||||
|
async fn init_postgresql_database(config: &DatabaseConfig) -> anyhow::Result<()> {
|
||||||
|
let database_name = config
|
||||||
|
.database
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.database"))?;
|
||||||
|
|
||||||
|
let host = config
|
||||||
|
.host
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.host"))?;
|
||||||
|
let user = config
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.user"))?;
|
||||||
|
let password = config
|
||||||
|
.password
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("PostgreSQL 需要配置 database.password"))?;
|
||||||
|
|
||||||
|
// 连接到 PostgreSQL 默认数据库(postgres)
|
||||||
|
let url = format!(
|
||||||
|
"postgresql://{}:{}@{}:{}/postgres",
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
get_database_port(config)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut opt = ConnectOptions::new(&url);
|
||||||
|
opt.max_connections(1)
|
||||||
|
.connect_timeout(Duration::from_secs(8))
|
||||||
|
.sqlx_logging(true);
|
||||||
|
|
||||||
|
let conn = Database::connect(opt)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("连接 PostgreSQL 服务器失败: {}", e))?;
|
||||||
|
|
||||||
|
// 检查数据库是否存在,不存在则创建
|
||||||
|
// PostgreSQL 不支持 CREATE DATABASE IF NOT EXISTS,需要先查询
|
||||||
|
let check_query = format!(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname='{}'",
|
||||||
|
database_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = conn
|
||||||
|
.execute(Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
check_query,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!("PostgreSQL 数据库 '{}' 已存在", database_name);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// 数据库不存在,创建它
|
||||||
|
let create_query = format!(
|
||||||
|
"CREATE DATABASE {} WITH ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8'",
|
||||||
|
database_name
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
create_query,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("创建 PostgreSQL 数据库失败: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("✅ PostgreSQL 数据库 '{}' 创建成功", database_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 SQLite 确保数据库文件目录存在
|
||||||
|
async fn init_sqlite_database(config: &DatabaseConfig) -> anyhow::Result<()> {
|
||||||
|
let path = config
|
||||||
|
.path
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("SQLite 需要配置 database.path"))?;
|
||||||
|
|
||||||
|
// 如果是相对路径,转换为绝对路径
|
||||||
|
let absolute_path = if path.is_absolute() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map_err(|e| anyhow::anyhow!("获取当前目录失败: {}", e))?
|
||||||
|
.join(path)
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("SQLite 数据库路径: {}", absolute_path.display());
|
||||||
|
|
||||||
|
// 获取数据库文件的父目录
|
||||||
|
if let Some(parent) = absolute_path.parent() {
|
||||||
|
// 如果父目录不存在,则创建
|
||||||
|
if !parent.exists() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| anyhow::anyhow!("创建 SQLite 数据库目录失败: {}", e))?;
|
||||||
|
tracing::info!("✅ SQLite 数据库目录创建成功: {}", parent.display());
|
||||||
|
} else {
|
||||||
|
tracing::info!("SQLite 数据库目录已存在: {}", parent.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果数据库文件不存在,创建空文件
|
||||||
|
if !absolute_path.exists() {
|
||||||
|
std::fs::File::create(&absolute_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("创建 SQLite 数据库文件失败: {}", e))?;
|
||||||
|
tracing::info!("✅ SQLite 数据库文件创建成功: {}", absolute_path.display());
|
||||||
|
} else {
|
||||||
|
tracing::info!("SQLite 数据库文件已存在: {}", absolute_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 辅助函数:创建单个表(如果不存在)
|
||||||
|
async fn create_single_table<E>(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
schema: &Schema,
|
||||||
|
builder: &DbBackend,
|
||||||
|
entity: E,
|
||||||
|
table_name: &str,
|
||||||
|
) -> anyhow::Result<()>
|
||||||
|
where
|
||||||
|
E: EntityName + EntityTrait,
|
||||||
|
{
|
||||||
|
let create_table = schema.create_table_from_entity(entity);
|
||||||
|
|
||||||
|
let sql = match builder {
|
||||||
|
DbBackend::MySql => {
|
||||||
|
use sea_orm::sea_query::MysqlQueryBuilder;
|
||||||
|
create_table.to_string(MysqlQueryBuilder {})
|
||||||
|
}
|
||||||
|
DbBackend::Postgres => {
|
||||||
|
use sea_orm::sea_query::PostgresQueryBuilder;
|
||||||
|
create_table.to_string(PostgresQueryBuilder {})
|
||||||
|
}
|
||||||
|
DbBackend::Sqlite => {
|
||||||
|
use sea_orm::sea_query::SqliteQueryBuilder;
|
||||||
|
create_table.to_string(SqliteQueryBuilder {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
|
||||||
|
match db.execute(Statement::from_string(*builder, sql)).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!("✅ {}检查完成", table_name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = e.to_string();
|
||||||
|
if err_msg.contains("already exists") || (err_msg.contains("table") && err_msg.contains("exists")) {
|
||||||
|
tracing::info!("✅ {}已存在", table_name);
|
||||||
|
} else {
|
||||||
|
return Err(anyhow::anyhow!("创建{}失败: {}", table_name, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建数据库表结构
|
||||||
|
async fn create_tables(db: &DatabaseConnection) -> anyhow::Result<()> {
|
||||||
|
tracing::info!("检查数据库表结构...");
|
||||||
|
|
||||||
|
let builder = db.get_database_backend();
|
||||||
|
let schema = Schema::new(builder);
|
||||||
|
|
||||||
|
// 导入所有 entities
|
||||||
|
use crate::domain::entities::users;
|
||||||
|
|
||||||
|
// 创建所有表(添加新表只需一行!)
|
||||||
|
create_single_table(db, &schema, &builder, users::Entity, "用户表").await?;
|
||||||
|
|
||||||
|
tracing::info!("✅ 数据库表结构检查完成");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
57
src/domain/dto/auth.rs
Normal file
57
src/domain/dto/auth.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// 注册请求
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现 Debug trait,对密码进行脱敏
|
||||||
|
impl fmt::Debug for RegisterRequest {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "RegisterRequest {{ email: {}, password: *** }}", self.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录请求
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现 Debug trait
|
||||||
|
impl fmt::Debug for LoginRequest {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "LoginRequest {{ email: {}, password: *** }}", self.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除用户请求
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteUserRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现 Debug trait
|
||||||
|
impl fmt::Debug for DeleteUserRequest {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "DeleteUserRequest {{ user_id: {}, password: *** }}", self.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新令牌请求
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RefreshRequest {
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshRequest 的 refresh_token 是敏感字段,需要脱敏
|
||||||
|
impl fmt::Debug for RefreshRequest {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "RefreshRequest {{ refresh_token: *** }}")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/domain/dto/mod.rs
Normal file
1
src/domain/dto/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
1
src/domain/dto/user.rs
Normal file
1
src/domain/dto/user.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// 用户相关 DTO(预留)
|
||||||
2
src/domain/entities/mod.rs
Normal file
2
src/domain/entities/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod users;
|
||||||
|
|
||||||
41
src/domain/entities/users.rs
Normal file
41
src/domain/entities/users.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::Set;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: DateTime,
|
||||||
|
pub updated_at: DateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
/// 在保存前自动填充时间戳
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let mut this = self;
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
|
||||||
|
if insert {
|
||||||
|
// 插入时:设置创建时间和更新时间
|
||||||
|
this.created_at = Set(now);
|
||||||
|
this.updated_at = Set(now);
|
||||||
|
} else {
|
||||||
|
// 更新时:只更新更新时间
|
||||||
|
this.updated_at = Set(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/domain/mod.rs
Normal file
3
src/domain/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod dto;
|
||||||
|
pub mod vo;
|
||||||
|
pub mod entities;
|
||||||
50
src/domain/vo/auth.rs
Normal file
50
src/domain/vo/auth.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// 注册结果
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RegisterResult {
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: String, // ISO 8601 格式
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(crate::domain::entities::users::Model, String, String)> for RegisterResult {
|
||||||
|
fn from((user_model, access_token, refresh_token): (crate::domain::entities::users::Model, String, String)) -> Self {
|
||||||
|
Self {
|
||||||
|
email: user_model.email,
|
||||||
|
created_at: user_model.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录结果
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub created_at: String, // ISO 8601 格式
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(crate::domain::entities::users::Model, String, String)> for LoginResult {
|
||||||
|
fn from((user_model, access_token, refresh_token): (crate::domain::entities::users::Model, String, String)) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user_model.id,
|
||||||
|
email: user_model.email,
|
||||||
|
created_at: user_model.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新 Token 结果
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RefreshResult {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
56
src/domain/vo/mod.rs
Normal file
56
src/domain/vo/mod.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
/// 统一的 API 响应结构
|
||||||
|
use serde::Serialize;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
/// HTTP 状态码
|
||||||
|
pub code: u16,
|
||||||
|
/// 响应消息
|
||||||
|
pub message: String,
|
||||||
|
/// 响应数据
|
||||||
|
pub data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
|
/// 成功响应(200)
|
||||||
|
pub fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
code: 200,
|
||||||
|
message: "Success".to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成功响应(自定义消息)
|
||||||
|
pub fn success_with_message(data: T, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
code: 200,
|
||||||
|
message: message.to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误响应
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn error(status_code: StatusCode, message: &str) -> ApiResponse<()> {
|
||||||
|
ApiResponse {
|
||||||
|
code: status_code.as_u16(),
|
||||||
|
message: message.to_string(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误响应(带数据)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn error_with_data(status_code: StatusCode, message: &str, data: T) -> ApiResponse<T> {
|
||||||
|
ApiResponse {
|
||||||
|
code: status_code.as_u16(),
|
||||||
|
message: message.to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/domain/vo/user.rs
Normal file
1
src/domain/vo/user.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// 用户相关 VO(预留)
|
||||||
89
src/error.rs
Normal file
89
src/error.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::domain::vo::ApiResponse;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 应用错误类型
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AppError(pub anyhow::Error);
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
tracing::error!("Application error: {:?}", self.0);
|
||||||
|
|
||||||
|
let (status, message) = match self.0.downcast_ref::<&str>() {
|
||||||
|
Some(&"not_found") => (StatusCode::NOT_FOUND, "Resource not found"),
|
||||||
|
Some(&"unauthorized") => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||||
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ApiResponse::<()> {
|
||||||
|
code: status.as_u16(),
|
||||||
|
message: message.to_string(),
|
||||||
|
data: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为具体类型实现 From
|
||||||
|
impl From<anyhow::Error> for AppError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
Self(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一的 API 错误响应结构
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorResponse {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNAUTHORIZED,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ErrorResponse {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let body = ApiResponse::<()> {
|
||||||
|
code: self.status.as_u16(),
|
||||||
|
message: self.message,
|
||||||
|
data: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(self.status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/handlers/auth.rs
Normal file
157
src/handlers/auth.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
use crate::error::ErrorResponse;
|
||||||
|
use crate::infra::middleware::logging::{log_info, RequestId};
|
||||||
|
use crate::domain::dto::auth::{RegisterRequest, LoginRequest, RefreshRequest, DeleteUserRequest};
|
||||||
|
use crate::domain::vo::auth::{RegisterResult, LoginResult, RefreshResult};
|
||||||
|
use crate::domain::vo::ApiResponse;
|
||||||
|
use crate::repositories::user_repository::UserRepository;
|
||||||
|
use crate::services::auth_service::AuthService;
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 注册
|
||||||
|
pub async fn register(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<RegisterResult>>, ErrorResponse> {
|
||||||
|
log_info(&request_id, "注册请求参数", &payload);
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
match service.register(payload).await {
|
||||||
|
Ok((user_model, access_token, refresh_token)) => {
|
||||||
|
let data = RegisterResult::from((user_model, access_token, refresh_token));
|
||||||
|
let response = ApiResponse::success(data);
|
||||||
|
log_info(&request_id, "注册成功", &response);
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "注册失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录
|
||||||
|
pub async fn login(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<LoginResult>>, ErrorResponse> {
|
||||||
|
log_info(&request_id, "登录请求参数", &payload);
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
match service.login(payload).await {
|
||||||
|
Ok((user_model, access_token, refresh_token)) => {
|
||||||
|
let data = LoginResult::from((user_model, access_token, refresh_token));
|
||||||
|
let response = ApiResponse::success(data);
|
||||||
|
log_info(&request_id, "登录成功", &response);
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "登录失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新 Token
|
||||||
|
pub async fn refresh(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RefreshRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<RefreshResult>>, ErrorResponse> {
|
||||||
|
log_info(
|
||||||
|
&request_id,
|
||||||
|
"刷新 token 请求",
|
||||||
|
&json!({"device_id": "default"}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
match service
|
||||||
|
.refresh_access_token(&payload.refresh_token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((access_token, refresh_token)) => {
|
||||||
|
let data = RefreshResult {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
};
|
||||||
|
let response = ApiResponse::success(data);
|
||||||
|
|
||||||
|
log_info(
|
||||||
|
&request_id,
|
||||||
|
"刷新成功",
|
||||||
|
&json!({"access_token": "***"}),
|
||||||
|
);
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "刷新失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除账号
|
||||||
|
pub async fn delete_account(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user_id): Extension<String>,
|
||||||
|
Json(payload): Json<DeleteUserRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
|
||||||
|
log_info(&request_id, "删除账号请求", &format!("user_id={}", user_id));
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
let delete_request = DeleteUserRequest {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
password: payload.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
match service.delete_user(delete_request).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log_info(&request_id, "账号删除成功", &format!("user_id={}", user_id));
|
||||||
|
let response = ApiResponse::success_with_message((), "账号删除成功");
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "账号删除失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新令牌
|
||||||
|
pub async fn delete_refresh_token(
|
||||||
|
Extension(request_id): Extension<RequestId>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user_id): Extension<String>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
|
||||||
|
log_info(&request_id, "删除刷新令牌请求", &format!("user_id={}", user_id));
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(state.pool.clone());
|
||||||
|
let service = AuthService::new(user_repo, state.redis_client.clone(), state.config.auth.clone());
|
||||||
|
|
||||||
|
match service.delete_refresh_token(&user_id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log_info(&request_id, "刷新令牌删除成功", &format!("user_id={}", user_id));
|
||||||
|
let response = ApiResponse::success_with_message((), "刷新令牌删除成功");
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_info(&request_id, "刷新令牌删除失败", &e.to_string());
|
||||||
|
Err(ErrorResponse::new(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/handlers/health.rs
Normal file
25
src/handlers/health.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use crate::db;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
response::{IntoResponse, Json},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 健康检查端点
|
||||||
|
pub async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
match db::health_check(&state.pool).await {
|
||||||
|
Ok(_) => Json(json!({"status": "ok"})),
|
||||||
|
Err(_) => Json(json!({"status": "unavailable"})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取服务器信息
|
||||||
|
pub async fn server_info() -> impl IntoResponse {
|
||||||
|
Json(json!({
|
||||||
|
"name": "web-rust-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "running",
|
||||||
|
"timestamp": chrono::Utc::now().timestamp()
|
||||||
|
}))
|
||||||
|
}
|
||||||
2
src/handlers/mod.rs
Normal file
2
src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod health;
|
||||||
51
src/infra/middleware/auth.rs
Normal file
51
src/infra/middleware/auth.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // user_id
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWT 认证中间件
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
// 1. 提取 Authorization header
|
||||||
|
let auth_header = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
if !auth_header.starts_with("Bearer ") {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = &auth_header[7..];
|
||||||
|
|
||||||
|
// 2. 验证 JWT
|
||||||
|
let jwt_secret = &state.config.auth.jwt_secret;
|
||||||
|
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
// 3. 将 user_id 添加到请求扩展
|
||||||
|
req.extensions_mut().insert(token_data.claims.sub);
|
||||||
|
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
71
src/infra/middleware/logging.rs
Normal file
71
src/infra/middleware/logging.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use axum::{extract::Request, response::Response};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Request ID 标记
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RequestId(pub String);
|
||||||
|
|
||||||
|
/// 请求日志中间件
|
||||||
|
pub async fn request_logging_middleware(
|
||||||
|
mut req: Request,
|
||||||
|
next: axum::middleware::Next,
|
||||||
|
) -> Response {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// 提取请求信息
|
||||||
|
let method = req.method().clone();
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let query = req.uri().query().map(|s| s.to_string());
|
||||||
|
|
||||||
|
// 生成请求 ID
|
||||||
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// 将 request_id 存储到请求扩展中
|
||||||
|
req.extensions_mut().insert(RequestId(request_id.clone()));
|
||||||
|
|
||||||
|
// 第1条日志:请求开始
|
||||||
|
let separator = "=".repeat(80);
|
||||||
|
let header = format!("{} {}", method, path);
|
||||||
|
|
||||||
|
tracing::info!("{}", separator);
|
||||||
|
tracing::info!("{}", header);
|
||||||
|
tracing::info!("{}", separator);
|
||||||
|
|
||||||
|
let now_beijing = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
|
||||||
|
let query_str = query.as_deref().unwrap_or("无");
|
||||||
|
tracing::info!(
|
||||||
|
"[{}] 📥 查询参数: {} | 时间: {}",
|
||||||
|
request_id,
|
||||||
|
query_str,
|
||||||
|
now_beijing
|
||||||
|
);
|
||||||
|
|
||||||
|
// 调用下一个处理器
|
||||||
|
let response = next.run(req).await;
|
||||||
|
|
||||||
|
// 第3条日志:请求完成
|
||||||
|
let duration = start.elapsed();
|
||||||
|
let status = response.status();
|
||||||
|
tracing::info!(
|
||||||
|
"[{}] ✅ 状态码: {} | 耗时: {}ms",
|
||||||
|
request_id,
|
||||||
|
status.as_u16(),
|
||||||
|
duration.as_millis()
|
||||||
|
);
|
||||||
|
|
||||||
|
tracing::info!("{}", separator);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求日志辅助工具
|
||||||
|
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
|
||||||
|
let data_str = format!("{:?}", data);
|
||||||
|
let truncated = if data_str.len() > 300 {
|
||||||
|
format!("{}...", &data_str[..300])
|
||||||
|
} else {
|
||||||
|
data_str
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("[{}] 🔧 {} | {}", request_id.0, label, truncated);
|
||||||
|
}
|
||||||
2
src/infra/middleware/mod.rs
Normal file
2
src/infra/middleware/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod logging;
|
||||||
2
src/infra/mod.rs
Normal file
2
src/infra/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod middleware;
|
||||||
|
pub mod redis;
|
||||||
20
src/infra/redis/errors.rs
Normal file
20
src/infra/redis/errors.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Redis 错误类型
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum RedisError {
|
||||||
|
#[error("Redis 连接失败: {0}")]
|
||||||
|
ConnectionError(#[from] redis::RedisError),
|
||||||
|
|
||||||
|
#[error("Redis 序列化失败: {0}")]
|
||||||
|
SerializationError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Redis 数据不存在: {key}")]
|
||||||
|
NotFound { key: String },
|
||||||
|
|
||||||
|
#[error("Redis 操作失败: {message}")]
|
||||||
|
OperationError { message: String },
|
||||||
|
|
||||||
|
#[error("Failed to create redis pool: {0}")]
|
||||||
|
PoolCreation(#[from] deadpool_redis::CreatePoolError),
|
||||||
|
}
|
||||||
2
src/infra/redis/mod.rs
Normal file
2
src/infra/redis/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod redis_client;
|
||||||
|
pub mod redis_key;
|
||||||
135
src/infra/redis/redis_client.rs
Normal file
135
src/infra/redis/redis_client.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use super::redis_key::RedisKey;
|
||||||
|
use redis::aio::MultiplexedConnection;
|
||||||
|
use redis::{AsyncCommands, Client};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// Redis 客户端(使用 MultiplexedConnection)
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RedisClient {
|
||||||
|
conn: Arc<Mutex<MultiplexedConnection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisClient {
|
||||||
|
/// 创建新的 Redis 客户端
|
||||||
|
pub async fn new(url: &str) -> redis::RedisResult<Self> {
|
||||||
|
let client = Client::open(url)?;
|
||||||
|
let conn = client.get_multiplexed_async_connection().await?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置字符串值
|
||||||
|
pub async fn set(&self, k: &str, v: &str) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.set(k, v).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取字符串值
|
||||||
|
pub async fn get(&self, k: &str) -> redis::RedisResult<Option<String>> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.get(k).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置字符串值并指定过期时间(秒)
|
||||||
|
pub async fn set_ex(&self, k: &str, v: &str, seconds: u64) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.set_ex(k, v, seconds).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除键
|
||||||
|
pub async fn del(&self, k: &str) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.del(k).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置键的过期时间(秒)
|
||||||
|
pub async fn expire(&self, k: &str, seconds: u64) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.expire(k, seconds as i64).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 设置 JSON 值
|
||||||
|
pub async fn set_key<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
key: &RedisKey,
|
||||||
|
value: &T,
|
||||||
|
) -> redis::RedisResult<()> {
|
||||||
|
let json = serde_json::to_string(value).map_err(|e| {
|
||||||
|
redis::RedisError::from((
|
||||||
|
redis::ErrorKind::TypeError,
|
||||||
|
"JSON serialization failed",
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.set(key.build(), json).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 设置 JSON 值并指定过期时间(秒)
|
||||||
|
pub async fn set_key_ex<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
key: &RedisKey,
|
||||||
|
value: &T,
|
||||||
|
expiration_seconds: u64,
|
||||||
|
) -> redis::RedisResult<()> {
|
||||||
|
let json = serde_json::to_string(value).map_err(|e| {
|
||||||
|
redis::RedisError::from((
|
||||||
|
redis::ErrorKind::TypeError,
|
||||||
|
"JSON serialization failed",
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.set_ex(key.build(), json, expiration_seconds).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 获取字符串值
|
||||||
|
pub async fn get_key(&self, key: &RedisKey) -> redis::RedisResult<Option<String>> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
let json: Option<String> = c.get(key.build()).await?;
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 获取并反序列化 JSON 值
|
||||||
|
pub async fn get_key_json<T: for<'de> serde::Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
key: &RedisKey,
|
||||||
|
) -> redis::RedisResult<Option<T>> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
let json: Option<String> = c.get(key.build()).await?;
|
||||||
|
match json {
|
||||||
|
Some(data) => {
|
||||||
|
let value = serde_json::from_str(&data).map_err(|e| {
|
||||||
|
redis::RedisError::from((
|
||||||
|
redis::ErrorKind::TypeError,
|
||||||
|
"JSON deserialization failed",
|
||||||
|
e.to_string(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 删除键
|
||||||
|
pub async fn delete_key(&self, key: &RedisKey) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.del(key.build()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 检查键是否存在
|
||||||
|
pub async fn exists_key(&self, key: &RedisKey) -> redis::RedisResult<bool> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.exists(key.build()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 RedisKey 设置键的过期时间(秒)
|
||||||
|
pub async fn expire_key(&self, key: &RedisKey, seconds: u64) -> redis::RedisResult<()> {
|
||||||
|
let mut c = self.conn.lock().await;
|
||||||
|
c.expire(key.build(), seconds as i64).await
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/infra/redis/redis_key.rs
Normal file
61
src/infra/redis/redis_key.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// 业务类型枚举
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum BusinessType {
|
||||||
|
#[serde(rename = "auth")]
|
||||||
|
Auth,
|
||||||
|
#[serde(rename = "user")]
|
||||||
|
User,
|
||||||
|
#[serde(rename = "cache")]
|
||||||
|
Cache,
|
||||||
|
#[serde(rename = "session")]
|
||||||
|
Session,
|
||||||
|
#[serde(rename = "rate_limit")]
|
||||||
|
RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BusinessType {
|
||||||
|
pub fn prefix(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BusinessType::Auth => "auth",
|
||||||
|
BusinessType::User => "user",
|
||||||
|
BusinessType::Cache => "cache",
|
||||||
|
BusinessType::Session => "session",
|
||||||
|
BusinessType::RateLimit => "rate_limit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redis 键构建器
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RedisKey {
|
||||||
|
business: BusinessType,
|
||||||
|
identifiers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisKey {
|
||||||
|
pub fn new(business: BusinessType) -> Self {
|
||||||
|
Self {
|
||||||
|
business,
|
||||||
|
identifiers: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_identifier(mut self, id: impl Into<String>) -> Self {
|
||||||
|
self.identifiers.push(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(&self) -> String {
|
||||||
|
format!("{}:{}", self.business.prefix(), self.identifiers.join(":"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容现有格式
|
||||||
|
impl fmt::Display for RedisKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/main.rs
Normal file
131
src/main.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod domain;
|
||||||
|
mod error;
|
||||||
|
mod handlers;
|
||||||
|
mod infra;
|
||||||
|
mod repositories;
|
||||||
|
mod services;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use clap::Parser;
|
||||||
|
use cli::CliArgs;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
/// 应用状态
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: db::DbPool,
|
||||||
|
pub config: config::app::AppConfig,
|
||||||
|
pub redis_client: infra::redis::redis_client::RedisClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// 解析命令行参数
|
||||||
|
let args = CliArgs::parse();
|
||||||
|
|
||||||
|
// 初始化日志
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| args.get_log_filter().into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// 打印启动信息
|
||||||
|
args.print_startup_info();
|
||||||
|
|
||||||
|
// 设置工作目录(如果指定)
|
||||||
|
if let Some(ref work_dir) = args.work_dir {
|
||||||
|
std::env::set_current_dir(work_dir).ok();
|
||||||
|
println!("Working directory set to: {}", work_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析配置文件路径(可选)
|
||||||
|
let config_path = args.resolve_config_path();
|
||||||
|
|
||||||
|
// 加载配置(支持 CLI 覆盖)
|
||||||
|
// 如果没有配置文件,将仅使用环境变量和默认值
|
||||||
|
let config = config::app::AppConfig::load_with_overrides(
|
||||||
|
config_path,
|
||||||
|
args.get_overrides(),
|
||||||
|
args.env.as_str(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tracing::info!("Configuration loaded successfully");
|
||||||
|
tracing::info!("Environment: {}", args.env.as_str());
|
||||||
|
tracing::info!("Debug mode: {}", args.is_debug_enabled());
|
||||||
|
|
||||||
|
// 初始化数据库(自动创建数据库和表)
|
||||||
|
let pool = db::init_database(&config.database).await?;
|
||||||
|
|
||||||
|
// 初始化 Redis 客户端
|
||||||
|
let redis_client = infra::redis::redis_client::RedisClient::new(&config.redis.build_url())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 初始化失败: {}", e))?;
|
||||||
|
|
||||||
|
tracing::info!("Redis 连接池初始化成功");
|
||||||
|
|
||||||
|
// 创建应用状态
|
||||||
|
let app_state = AppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
config: config.clone(),
|
||||||
|
redis_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 公开路由 ==========
|
||||||
|
let public_routes = Router::new()
|
||||||
|
.route("/health", get(handlers::health::health_check))
|
||||||
|
.route("/info", get(handlers::health::server_info))
|
||||||
|
.route("/auth/register", post(handlers::auth::register))
|
||||||
|
.route("/auth/login", post(handlers::auth::login))
|
||||||
|
.route("/auth/refresh", post(handlers::auth::refresh));
|
||||||
|
|
||||||
|
// ========== 受保护路由 ==========
|
||||||
|
let protected_routes = Router::new()
|
||||||
|
.route("/auth/delete", post(handlers::auth::delete_account))
|
||||||
|
.route(
|
||||||
|
"/auth/delete-refresh-token",
|
||||||
|
post(handlers::auth::delete_refresh_token),
|
||||||
|
)
|
||||||
|
// JWT 认证中间件(仅应用于受保护路由)
|
||||||
|
.route_layer(axum::middleware::from_fn_with_state(
|
||||||
|
app_state.clone(),
|
||||||
|
infra::middleware::auth::auth_middleware,
|
||||||
|
));
|
||||||
|
|
||||||
|
// ========== 合并路由 ==========
|
||||||
|
let app = public_routes
|
||||||
|
.merge(protected_routes)
|
||||||
|
// CORS(应用于所有路由)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
)
|
||||||
|
// 日志中间件(应用于所有路由)
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
app_state.clone(),
|
||||||
|
infra::middleware::logging::request_logging_middleware,
|
||||||
|
))
|
||||||
|
.with_state(app_state);
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
let addr = format!("{}:{}", config.server.host, config.server.port);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
tracing::info!("Server listening on {}", addr);
|
||||||
|
tracing::info!("Press Ctrl+C to stop");
|
||||||
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
src/repositories/mod.rs
Normal file
2
src/repositories/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod user_repository;
|
||||||
|
|
||||||
85
src/repositories/user_repository.rs
Normal file
85
src/repositories/user_repository.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, Set, ActiveModelTrait, PaginatorTrait};
|
||||||
|
use crate::domain::entities::users;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// 用户数据访问仓库
|
||||||
|
pub struct UserRepository {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 email 查询用户
|
||||||
|
pub async fn find_by_email(&self, email: &str) -> Result<Option<users::Model>> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(users::Column::Email.eq(email))
|
||||||
|
.one(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("查询失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计邮箱数量
|
||||||
|
pub async fn count_by_email(&self, email: &str) -> Result<i64> {
|
||||||
|
let count = users::Entity::find()
|
||||||
|
.filter(users::Column::Email.eq(email))
|
||||||
|
.count(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("查询失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(count as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计用户 ID 数量
|
||||||
|
pub async fn count_by_id(&self, id: &str) -> Result<i64> {
|
||||||
|
let count = users::Entity::find()
|
||||||
|
.filter(users::Column::Id.eq(id))
|
||||||
|
.count(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("查询失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(count as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取密码哈希
|
||||||
|
pub async fn get_password_hash(&self, email: &str) -> Result<Option<String>> {
|
||||||
|
let user = users::Entity::find()
|
||||||
|
.filter(users::Column::Email.eq(email))
|
||||||
|
.one(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("查询失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(user.map(|u| u.password_hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 插入用户(created_at 和 updated_at 会自动填充),返回插入后的用户对象
|
||||||
|
pub async fn insert(&self, id: String, email: String, password_hash: String) -> Result<users::Model> {
|
||||||
|
let user_model = users::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
email: Set(email),
|
||||||
|
password_hash: Set(password_hash),
|
||||||
|
// created_at 和 updated_at 由 ActiveModelBehavior 自动填充
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let inserted_user = user_model.insert(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("插入失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(inserted_user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 ID 删除用户
|
||||||
|
pub async fn delete_by_id(&self, id: &str) -> Result<()> {
|
||||||
|
users::Entity::delete_by_id(id)
|
||||||
|
.exec(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/services/auth_service.rs
Normal file
232
src/services/auth_service.rs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::utils::jwt::TokenService;
|
||||||
|
use crate::domain::dto::auth::{RegisterRequest, LoginRequest, DeleteUserRequest};
|
||||||
|
use crate::domain::entities::users;
|
||||||
|
use crate::config::auth::AuthConfig;
|
||||||
|
use crate::infra::redis::{redis_client::RedisClient, redis_key::{BusinessType, RedisKey}};
|
||||||
|
use crate::repositories::user_repository::UserRepository;
|
||||||
|
|
||||||
|
pub struct AuthService {
|
||||||
|
user_repo: UserRepository,
|
||||||
|
redis_client: RedisClient,
|
||||||
|
auth_config: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new(user_repo: UserRepository, redis_client: RedisClient, auth_config: AuthConfig) -> Self {
|
||||||
|
Self { user_repo, redis_client, auth_config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 哈希密码
|
||||||
|
pub fn hash_password(&self, password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))?
|
||||||
|
.to_string();
|
||||||
|
Ok(password_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成用户 ID
|
||||||
|
pub fn generate_user_id(&self) -> String {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
rng.gen_range(1_000_000_000i64..10_000_000_000i64)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成唯一的用户 ID
|
||||||
|
pub async fn generate_unique_user_id(&self) -> Result<String> {
|
||||||
|
let mut attempts = 0;
|
||||||
|
const MAX_ATTEMPTS: u32 = 10;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let candidate_id = self.generate_user_id();
|
||||||
|
|
||||||
|
let existing = self.user_repo.count_by_id(&candidate_id).await?;
|
||||||
|
if existing == 0 {
|
||||||
|
return Ok(candidate_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
if attempts >= MAX_ATTEMPTS {
|
||||||
|
return Err(anyhow::anyhow!("生成唯一用户 ID 失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 refresh_token 到 Redis
|
||||||
|
async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> {
|
||||||
|
let key = RedisKey::new(BusinessType::Auth)
|
||||||
|
.add_identifier("refresh_token")
|
||||||
|
.add_identifier(user_id);
|
||||||
|
|
||||||
|
let expiration_seconds = expiration_days * 24 * 3600;
|
||||||
|
|
||||||
|
self.redis_client
|
||||||
|
.set_ex(&key.build(), refresh_token, expiration_seconds as u64)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取并删除 refresh_token
|
||||||
|
async fn get_and_delete_refresh_token(&self, user_id: &str) -> Result<String> {
|
||||||
|
let key = RedisKey::new(BusinessType::Auth)
|
||||||
|
.add_identifier("refresh_token")
|
||||||
|
.add_identifier(user_id);
|
||||||
|
|
||||||
|
let token: Option<String> = self.redis_client
|
||||||
|
.get(&key.build())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 查询失败: {}", e))?;
|
||||||
|
|
||||||
|
if token.is_some() {
|
||||||
|
self.redis_client
|
||||||
|
.delete_key(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 删除失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ok_or_else(|| anyhow::anyhow!("刷新令牌无效或已过期"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除用户的 refresh_token
|
||||||
|
pub async fn delete_refresh_token(&self, user_id: &str) -> Result<()> {
|
||||||
|
let key = RedisKey::new(BusinessType::Auth)
|
||||||
|
.add_identifier("refresh_token")
|
||||||
|
.add_identifier(user_id);
|
||||||
|
|
||||||
|
self.redis_client
|
||||||
|
.delete_key(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Redis 删除失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册用户
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
request: RegisterRequest,
|
||||||
|
) -> Result<(users::Model, String, String)> {
|
||||||
|
// 1. 检查邮箱是否已存在
|
||||||
|
let existing = self.user_repo.count_by_email(&request.email).await?;
|
||||||
|
|
||||||
|
if existing > 0 {
|
||||||
|
return Err(anyhow::anyhow!("邮箱已注册"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 哈希密码
|
||||||
|
let password_hash = self.hash_password(&request.password)?;
|
||||||
|
|
||||||
|
// 3. 生成用户 ID
|
||||||
|
let user_id = self.generate_unique_user_id().await?;
|
||||||
|
|
||||||
|
// 4. 插入数据库并获取包含真实 created_at 的用户对象
|
||||||
|
let user = self.user_repo.insert(user_id.clone(), request.email, password_hash).await?;
|
||||||
|
|
||||||
|
// 5. 生成 token
|
||||||
|
let (access_token, refresh_token) = TokenService::generate_token_pair(
|
||||||
|
&user_id,
|
||||||
|
self.auth_config.access_token_expiration_minutes,
|
||||||
|
self.auth_config.refresh_token_expiration_days,
|
||||||
|
&self.auth_config.jwt_secret,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 6. 保存 refresh_token
|
||||||
|
self.save_refresh_token(&user_id, &refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?;
|
||||||
|
|
||||||
|
Ok((user, access_token, refresh_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
request: LoginRequest,
|
||||||
|
) -> Result<(users::Model, String, String)> {
|
||||||
|
// 1. 查询用户
|
||||||
|
let user = self.user_repo.find_by_email(&request.email).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("邮箱或密码错误"))?;
|
||||||
|
|
||||||
|
// 2. 验证密码
|
||||||
|
let password_hash = self.user_repo.get_password_hash(&request.email).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("邮箱或密码错误"))?;
|
||||||
|
|
||||||
|
let parsed_hash = PasswordHash::new(&password_hash)
|
||||||
|
.map_err(|e| anyhow::anyhow!("解析密码哈希失败: {}", e))?;
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
argon2
|
||||||
|
.verify_password(request.password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| anyhow::anyhow!("邮箱或密码错误"))?;
|
||||||
|
|
||||||
|
// 3. 生成 token
|
||||||
|
let (access_token, refresh_token) = TokenService::generate_token_pair(
|
||||||
|
&user.id,
|
||||||
|
self.auth_config.access_token_expiration_minutes,
|
||||||
|
self.auth_config.refresh_token_expiration_days,
|
||||||
|
&self.auth_config.jwt_secret,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 4. 保存 refresh_token
|
||||||
|
self.save_refresh_token(&user.id, &refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?;
|
||||||
|
|
||||||
|
Ok((user, access_token, refresh_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 refresh_token 刷新 access_token
|
||||||
|
pub async fn refresh_access_token(
|
||||||
|
&self,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
// 1. 从 refresh_token 中解码出 user_id
|
||||||
|
let user_id = TokenService::decode_user_id(refresh_token, &self.auth_config.jwt_secret)?;
|
||||||
|
|
||||||
|
// 2. 从 Redis 获取存储的 token 并删除
|
||||||
|
let stored_token = self.get_and_delete_refresh_token(&user_id).await?;
|
||||||
|
|
||||||
|
// 3. 验证 token 是否匹配
|
||||||
|
if stored_token != refresh_token {
|
||||||
|
return Err(anyhow::anyhow!("刷新令牌无效"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 生成新的 token 对
|
||||||
|
let (new_access_token, new_refresh_token) = TokenService::generate_token_pair(
|
||||||
|
&user_id,
|
||||||
|
self.auth_config.access_token_expiration_minutes,
|
||||||
|
self.auth_config.refresh_token_expiration_days,
|
||||||
|
&self.auth_config.jwt_secret,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 5. 保存新的 refresh_token
|
||||||
|
self.save_refresh_token(&user_id, &new_refresh_token, self.auth_config.refresh_token_expiration_days as i64).await?;
|
||||||
|
|
||||||
|
Ok((new_access_token, new_refresh_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除用户
|
||||||
|
pub async fn delete_user(&self, request: DeleteUserRequest) -> Result<()> {
|
||||||
|
let password_hash = self.user_repo.get_password_hash(&request.user_id).await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("用户不存在"))?;
|
||||||
|
|
||||||
|
let parsed_hash = PasswordHash::new(&password_hash)
|
||||||
|
.map_err(|e| anyhow::anyhow!("解析密码哈希失败: {}", e))?;
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
argon2
|
||||||
|
.verify_password(request.password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| anyhow::anyhow!("密码错误"))?;
|
||||||
|
|
||||||
|
self.user_repo.delete_by_id(&request.user_id).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth_service;
|
||||||
101
src/utils/jwt.rs
Normal file
101
src/utils/jwt.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// JWT 工具类,负责生成和验证 JWT token
|
||||||
|
pub struct TokenService;
|
||||||
|
|
||||||
|
impl TokenService {
|
||||||
|
/// 生成 JWT access token
|
||||||
|
pub fn generate_access_token(
|
||||||
|
user_id: &str,
|
||||||
|
expiration_minutes: u64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let expiration = Utc::now()
|
||||||
|
.checked_add_signed(Duration::minutes(expiration_minutes as i64))
|
||||||
|
.expect("invalid expiration timestamp")
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
token_type: TokenType::Access,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 refresh token
|
||||||
|
pub fn generate_refresh_token(
|
||||||
|
user_id: &str,
|
||||||
|
expiration_days: i64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let expiration = Utc::now()
|
||||||
|
.checked_add_signed(Duration::days(expiration_days))
|
||||||
|
.expect("invalid expiration timestamp")
|
||||||
|
.timestamp() as usize;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
token_type: TokenType::Refresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 access token 和 refresh token
|
||||||
|
pub fn generate_token_pair(
|
||||||
|
user_id: &str,
|
||||||
|
access_token_expiration_minutes: u64,
|
||||||
|
refresh_token_expiration_days: i64,
|
||||||
|
jwt_secret: &str,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
let access_token =
|
||||||
|
Self::generate_access_token(user_id, access_token_expiration_minutes, jwt_secret)?;
|
||||||
|
let refresh_token =
|
||||||
|
Self::generate_refresh_token(user_id, refresh_token_expiration_days, jwt_secret)?;
|
||||||
|
|
||||||
|
Ok((access_token, refresh_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 token 中解码出 user_id
|
||||||
|
pub fn decode_user_id(token: &str, jwt_secret: &str) -> Result<String> {
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Token 解码失败: {}", e))?;
|
||||||
|
|
||||||
|
Ok(token_data.claims.sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // user_id
|
||||||
|
pub exp: usize, // 过期时间
|
||||||
|
pub token_type: TokenType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum TokenType {
|
||||||
|
Access,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
1
src/utils/mod.rs
Normal file
1
src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod jwt;
|
||||||
Reference in New Issue
Block a user