feat: rebuild email platform and operations console

This commit is contained in:
2026-07-25 23:06:15 +08:00
parent c109abc6f5
commit 32e0969ca1
443 changed files with 56446 additions and 1585 deletions
+4
View File
@@ -0,0 +1,4 @@
target
db.sqlite3
.env
.git
+40
View File
@@ -0,0 +1,40 @@
# 环境变量参考配置
#
# ⚠️ 重要提示:
# - 本项目不支持 .env 文件
# - 开发环境和生产环境都请使用 config/ 目录下的 toml 配置文件
# - 环境变量仅用于 Docker/Kubernetes/systemd 等部署场景
#
# ============================================
# ============================================
# CLI 参数环境变量
# ============================================
# 运行环境:development, production
# ENV=development
# 调试模式:true, false
# DEBUG=false
# 配置文件路径
# CONFIG=config/production.toml
# 可选能力与 Web 防护
REDIS__ENABLED=false
EMAIL__ENABLED=false
EMAIL__SMTP_HOST=smtp.example.com
EMAIL__SMTP_PORT=587
EMAIL__SMTP_USERNAME=
EMAIL__SMTP_PASSWORD=
EMAIL__FROM_EMAIL=noreply@example.com
EMAIL__QUEUE_ENABLED=false
EMAIL__WORKER_POOL_SIZE=2
SERVER__CORS_ORIGINS=http://localhost:5173
SERVER__REQUEST_TIMEOUT_SECONDS=30
SERVER__MAX_BODY_BYTES=1048576
SERVER__CONCURRENCY_LIMIT=256
SERVER__RATE_LIMIT_PER_MINUTE=120
AUTH__JWT_SECRET=replace-with-at-least-32-random-characters
AUTH__INVITE_CODE=replace-with-at-least-16-random-characters
AUTH__BOOTSTRAP_ADMIN_EMAIL=admin@example.com
SMTP__BODY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
+24
View File
@@ -0,0 +1,24 @@
# Rust
/target/
**/*.rs.bk
*.pdb
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Config (不要提交敏感配置)
.env
db.sqlite*
data
API_FLOW*
SEAORM*
.claude/settings.local.json
+4372
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
[package]
name = "email-unlimit-server"
version = "0.1.0"
edition = "2021"
[dependencies]
# ===== Web 框架 =====
axum = { version = "0.7", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["limit", "timeout", "util"] }
tower-http = { version = "0.5", features = ["catch-panic", "cors", "limit", "set-header", "timeout", "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"
aes-gcm = "0.10"
# ===== 邮件发送 =====
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls", "builder", "smtp-transport", "webpki-roots", "ring"] }
# ===== 邮件接收(临时邮箱 SMTP 收信) =====
mail-parser = "0.9"
hickory-resolver = "0.24"
dashmap = "6"
# ===== Redis =====
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
# ===== HTTP 客户端(告警 webhook =====
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# ===== 工具库 =====
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
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"] }
validator = { version = "0.16", features = ["derive"] }
http-body-util = "0.1"
# 优化发布版本
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+20
View File
@@ -0,0 +1,20 @@
FROM rust:1.95-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --locked --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/email-unlimit-server /usr/local/bin/email-unlimit-server
COPY config ./config
EXPOSE 25 3000
ENTRYPOINT ["email-unlimit-server"]
CMD ["--env", "production"]
+56
View File
@@ -0,0 +1,56 @@
# Email Unlimited Server
Rust + Axum 实现的临时邮箱 HTTP API 与 SMTP 收信服务。
## 启动
开发环境默认使用 SQLite
```powershell
cargo run
```
指定配置与环境:
```powershell
cargo run -- --env production --config config/production.toml
```
嵌套环境变量使用双下划线分隔:
```powershell
$env:DATABASE__DATABASE_TYPE = "mysql"
$env:DATABASE__HOST = "localhost"
$env:DATABASE__PORT = "3306"
$env:DATABASE__USER = "root"
$env:DATABASE__PASSWORD = "your-password"
$env:DATABASE__DATABASE = "email_unlimit"
$env:AUTH__JWT_SECRET = "replace-with-at-least-32-random-characters"
$env:AUTH__INVITE_CODE = "replace-with-at-least-16-random-characters"
$env:AUTH__BOOTSTRAP_ADMIN_EMAIL = "admin@example.com"
$env:SMTP__BODY_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef"
cargo run -- --env production
```
## 核心接口
- `GET /health`
- `POST /auth/register`
- `POST /auth/login`
- `POST /auth/refresh`
- `GET|POST /api/mailboxes`
- `GET /api/mailboxes/:id/emails`
- `GET /api/mailboxes/:id/ws`
- `GET /admin/stats`
- `GET|PATCH /admin/users`
`/api` 路由使用用户或邮箱令牌,`/admin` 路由同时要求有效用户 JWT 与 `admin` 角色。
## 验证
```powershell
cargo test
cargo check
```
更完整的资料位于 `docs/`
+90
View File
@@ -0,0 +1,90 @@
# 开发环境配置 - SQLite 数据库
[server]
host = "0.0.0.0"
port = 3000
request_timeout_seconds = 30
max_body_bytes = 1048576
concurrency_limit = 256
rate_limit_per_minute = 120
cors_origins = ["http://localhost:3000", "http://localhost:5173"]
[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 天
registration_open = true # 开发环境开放注册
invite_code = "" # 空=不校验邀请码
bootstrap_admin_email = "" # 本地需要时填写管理员邮箱
[redis]
enabled = false
host = "localhost"
port = 6379
password = "" # 可选
db = 0
[email]
enabled = false
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_username = ""
smtp_password = ""
from_email = "noreply@example.com"
from_name = "Email Unlimited"
verification_code_ttl_seconds = 600
queue_enabled = false
worker_pool_size = 2
# SMTP 收信服务(临时邮箱核心)
[smtp]
enabled = true
listen_host = "0.0.0.0"
listen_port = 25
hostname = "mail.shenjianl.cn"
local_domains = ["mail.shenjianl.cn", "shenjianl.cn"]
max_message_bytes = 1048576
connection_timeout_seconds = 30
max_recipients_per_message = 1
ip_connect_per_min = 20
domain_per_min = 10
rcpt_per_min = 10
require_ptr = false # 开发环境关闭 PTR(本地无 PTR 记录)
email_ttl_days = 7
mailbox_ttl_hours = 24
mailbox_max_quota_bytes = 10485760
address_local_part_len = 10
sender_per_min = 10
pair_per_10min = 5
greylist_enabled = false # 开发环境关闭灰名单(避免本地测试被挡)
greylist_retry_delay_seconds = 300
greylist_entry_ttl_seconds = 86400
quarantine_threshold = 60
score_weight_subject_code = 30
score_weight_spf_fail = 20
score_weight_ptr_missing = 15
score_weight_bad_attachment = 25
[abuse]
domain_emails_threshold_10min = 50
ip_connects_threshold_1min = 60
auto_block_ttl_seconds = 3600
honeypot_addresses = ["admin@mail.shenjianl.cn", "noreply@mail.shenjianl.cn", "test@mail.shenjianl.cn"]
alert_webhook_url = ""
+91
View File
@@ -0,0 +1,91 @@
# 生产环境配置 - PostgreSQL 数据库
[server]
host = "0.0.0.0" # 服务器监听地址(0.0.0.0=允许所有网络访问)
port = 3000 # 服务器监听端口(确保防火墙已开放)
request_timeout_seconds = 30
max_body_bytes = 1048576
concurrency_limit = 256
rate_limit_per_minute = 120
cors_origins = ["https://example.com"]
[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 = "CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS" # 必须通过部署配置替换
access_token_expiration_minutes = 15 # Access Token 过期时间(分钟)
refresh_token_expiration_days = 7 # Refresh Token 过期时间(天)
registration_open = true # 生产建议改为 false 或配合邀请码
invite_code = "" # 设置非空值则注册需邀请码(如 "shenjianl-2026"
bootstrap_admin_email = "" # 首次部署可配置管理员邮箱,并同时设置强邀请码
[redis]
enabled = true
host = "localhost" # Redis 服务器地址
port = 6379 # Redis 端口(默认 6379
password = "" # Redis 密码(强烈建议设置密码)
db = 0 # Redis 数据库编号(0-15
[email]
enabled = false
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_username = ""
smtp_password = ""
from_email = "noreply@example.com"
from_name = "Email Unlimited"
verification_code_ttl_seconds = 600
queue_enabled = true
worker_pool_size = 4
# SMTP 收信服务(临时邮箱核心)
[smtp]
enabled = true
listen_host = "0.0.0.0"
listen_port = 25
hostname = "mail.shenjianl.cn"
local_domains = ["mail.shenjianl.cn", "shenjianl.cn"]
max_message_bytes = 1048576
connection_timeout_seconds = 30
max_recipients_per_message = 1
ip_connect_per_min = 20
domain_per_min = 10
rcpt_per_min = 10
require_ptr = true # 生产环境要求 PTR 记录
email_ttl_days = 7
mailbox_ttl_hours = 24
mailbox_max_quota_bytes = 10485760
address_local_part_len = 10
sender_per_min = 10
pair_per_10min = 5
greylist_enabled = true # 生产环境启用灰名单
greylist_retry_delay_seconds = 300
greylist_entry_ttl_seconds = 86400
quarantine_threshold = 60
score_weight_subject_code = 30
score_weight_spf_fail = 20
score_weight_ptr_missing = 15
score_weight_bad_attachment = 25
body_encryption_key = "" # 生产环境必须通过 SMTP__BODY_ENCRYPTION_KEY 注入 32 字节随机密钥
[abuse]
domain_emails_threshold_10min = 50
ip_connects_threshold_1min = 60
auto_block_ttl_seconds = 3600
honeypot_addresses = ["admin@mail.shenjianl.cn", "noreply@mail.shenjianl.cn", "test@mail.shenjianl.cn"]
alert_webhook_url = ""
# 安全检查清单:部署前请确认
# ✅ 1. 已修改 jwt_secret 为强随机字符串
# ✅ 2. 已修改数据库密码为强密码
# ✅ 3. 已设置 Redis 密码
# ✅ 4. 已配置防火墙规则
# ✅ 5. 已启用 HTTPS(使用 Nginx/Caddy 等反向代理)
# ✅ 6. 已设置数据库定期备份
+117
View File
@@ -0,0 +1,117 @@
# Email Unlimited 文档中心
欢迎使用 Email Unlimited 文档!本模板项目提供了生产级的 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 接口文档"→"开发规范"的顺序阅读文档。
+199
View File
@@ -0,0 +1,199 @@
# API 接口概览
本文档提供所有 API 接口的快速参考。
## 基础信息
### Base URL
```
开发环境: http://localhost:3000
生产环境: https://api.yourdomain.com
```
### 认证方式
本 API 使用 JWTJSON 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 接口。
# 新增通用接口
所有受保护接口使用 `Authorization: Bearer <access_token>`。Refresh Token 不能访问受保护接口。
| 方法 | 路径 | 认证 | 条件 |
|---|---|---|---|
| POST | `/auth/logout` | 是 | Redis 可用 |
| POST | `/auth/request-verification-code` | 否 | 邮件启用且 Redis 可用 |
| POST | `/auth/reset-password` | 否 | 邮件启用且 Redis 可用 |
| GET/PUT/DELETE | `/api/user/profile` | 是 | 始终注册 |
| GET | `/api/email/latest-log` | 是 | 邮件启用 |
| GET | `/api/email/queue-status` | 是 | 邮件启用 |
请求可通过 `Accept-Language: zh-CN``Accept-Language: en` 选择认证错误语言。
+609
View File
@@ -0,0 +1,609 @@
# 认证机制详解
本文档详细说明 Email Unlimited 的 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
- 后端:RedisKey`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 Token15 分钟(推荐)
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15
# Refresh Token7 天(推荐)
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7
```
**建议**
- Access Token:5-30 分钟(权衡安全性和用户体验)
- Refresh Token7-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 密钥为强随机字符串!
+369
View File
@@ -0,0 +1,369 @@
# 公开接口文档
本文档详细说明所有无需认证即可访问的 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": "email-unlimit-server",
"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 | 用户 ID10 位数字) |
| 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 接口。
@@ -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 TokenlocalStorage
- Refresh TokenlocalStorage
- 添加适当的 XSS 防护(内容安全策略、输入验证)
**安全性要求高的场景**
- Access Token:内存(React Context/Vue Reactive
- Refresh TokenHttpOnly 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) - 需要认证的接口
---
**提示**:以上示例代码仅供参考,实际使用时请根据项目需求调整。
@@ -0,0 +1,557 @@
# 环境变量配置说明
本文档提供所有可配置的环境变量说明。
## 目录
- [配置优先级](#配置优先级)
- [服务器配置](#服务器配置)
- [数据库配置](#数据库配置)
- [认证配置](#认证配置)
- [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__DATABASE_TYPE
数据库类型,支持 MySQL、PostgreSQL、SQLite。
| 属性 | 值 |
|------|-----|
| 类型 | 字符串 |
| 默认值 | `sqlite` |
| 可选值 | `mysql``postgresql``sqlite` |
**示例**
```bash
DATABASE__DATABASE_TYPE=sqlite # SQLite 数据库
DATABASE__DATABASE_TYPE=mysql # MySQL 数据库
DATABASE__DATABASE_TYPE=postgresql # PostgreSQL 数据库
```
### MySQL 配置
`DATABASE__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__DATABASE_TYPE=postgresql` 时使用,配置项与 MySQL 相同。
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| DATABASE__HOST | PostgreSQL 服务器地址 | localhost |
| DATABASE__PORT | PostgreSQL 服务器端口 | 5432 |
| DATABASE__USER | PostgreSQL 用户名 | - |
| DATABASE__PASSWORD | PostgreSQL 密码 | - |
| DATABASE__DATABASE | PostgreSQL 数据库名称 | - |
### SQLite 配置
`DATABASE__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
# 编辑开发环境配置文件
nano config/development.toml
# 设置 database.type = "mysql" 并修改连接信息
# 运行
cargo run
```
或使用环境变量(适用于 Docker/Kubernetes):
```bash
DATABASE__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__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: email-unlimit-server:latest
ports:
- "3000:3000"
environment:
- SERVER_HOST=0.0.0.0
- SERVER_PORT=3000
- DATABASE__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` 文件到版本控制。
# 可选基础设施与 Web 防护
Redis 和邮件能力默认关闭。常用环境变量:
```text
REDIS__ENABLED=false
EMAIL__ENABLED=false
EMAIL__SMTP_HOST=smtp.example.com
EMAIL__SMTP_PORT=587
EMAIL__SMTP_USERNAME=
EMAIL__SMTP_PASSWORD=
EMAIL__FROM_EMAIL=noreply@example.com
SERVER__REQUEST_TIMEOUT_SECONDS=30
SERVER__MAX_BODY_BYTES=1048576
SERVER__CONCURRENCY_LIMIT=256
SERVER__RATE_LIMIT_PER_MINUTE=120
SERVER__CORS_ORIGINS=["https://app.example.com"]
```
生产环境必须设置非默认 JWT 密钥,且 `cors_origins` 不允许包含 `*`
+473
View File
@@ -0,0 +1,473 @@
# 快速开始指南
本文档将指导你完成 Email Unlimited 项目的安装、配置和运行。
## 目录
- [环境要求](#环境要求)
- [安装步骤](#安装步骤)
- [配置说明](#配置说明)
- [运行项目](#运行项目)
- [验证安装](#验证安装)
- [常见问题](#常见问题)
---
## 环境要求
### 必需环境
- **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 email-unlimit-server
```
### 2. 安装依赖
使用 Cargo 构建项目(会自动下载依赖):
```bash
cargo build
```
### 3. 配置项目
#### 方式一:使用默认配置(SQLite,最简单)
**无需任何配置!** 直接运行即可:
```bash
cargo run
```
默认配置:
- 数据库:SQLite(自动创建 `db.sqlite3`
- 服务器:`127.0.0.1:3000`
- Redis`localhost:6379`
#### 方式二:使用 MySQL/PostgreSQL
**步骤 1**:修改配置文件
编辑 `config/development.toml`,修改数据库类型和连接信息:
```toml
[database]
# 修改数据库类型:mysql, postgresql 或 sqlite
type = "mysql" # 或 "postgresql"
# MySQL 配置
host = "localhost"
port = 3306
user = "root"
password = "your-password"
database = "web_template_dev"
# 或 PostgreSQL 配置
# type = "postgresql"
# host = "localhost"
# port = 5432
# user = "postgres"
# password = "your-password"
# database = "web_template_dev"
```
**步骤 2**:运行项目
```bash
cargo run
```
#### 方式三:通过环境变量覆盖(适用于 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**:修改配置文件
编辑 `config/development.toml`,设置数据库类型为 `mysql` 并修改连接信息:
```toml
[database]
type = "mysql"
host = "localhost"
port = 3306
user = "root"
password = "your-password"
database = "web_template_dev"
```
**选项 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
**优点**:功能强大,支持高级特性
**缺点**:资源占用较大
**适用场景**:需要高级数据库功能的应用
**配置方法**
编辑 `config/development.toml`,设置数据库类型为 `postgresql` 并修改连接信息:
```toml
[database]
type = "postgresql"
host = "localhost"
port = 5432
user = "postgres"
password = "your-password"
database = "web_template_dev"
```
或使用环境变量(格式与 MySQL 相同)。
### 认证配置
**开发环境**:使用默认配置即可(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
# 修改 config/development.toml 后运行
cargo run
```
**使用环境变量**
```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 email-unlimit-server
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": "email-unlimit-server",
"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/email-unlimit-server
# 或使用 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 到项目仓库。
@@ -0,0 +1,614 @@
# 项目结构详解
本文档详细说明 Email Unlimited 的项目结构、DDD 分层架构和各层职责。
## 目录
- [DDD 分层架构](#ddd-分层架构)
- [项目目录结构](#项目目录结构)
- [各层职责说明](#各层职责说明)
- [数据流转](#数据流转)
- [核心组件](#核心组件)
---
## DDD 分层架构
本系统采用**领域驱动设计(DDD)**的分层架构,将代码划分为不同的职责层次。
```
┌─────────────────────────────────────────┐
│ Interface Layer (handlers) │ HTTP 处理器层
│ 路由定义、请求处理、响应封装 │
└──────────────┬───────────────────────┘
┌──────────────▼───────────────────────┐
│ Application Layer (services) │ 业务逻辑层
│ 业务逻辑、Token 生成、认证 │
└──────────────┬───────────────────────┘
┌───────┴────────┐
│ │
┌──────▼──────┐ ┌─────▼──────────┐
│ Domain │ │ Infrastructure│
│ Layer │ │ Layer │
│ │ │ │
│ - DTO │ │ - Middleware │
│ - Entities │ │ - Redis │
│ - VO │ │ - Repositories│
└─────────────┘ └────────────────┘
```
### 分层优势
| 优势 | 说明 |
|------|------|
| 职责清晰 | 每层只关注自己的职责,降低耦合 |
| 易于测试 | 每层可独立测试,Mock 依赖 |
| 易于维护 | 修改某层不影响其他层 |
| 易于扩展 | 添加新功能只需扩展相应层 |
---
## 项目目录结构
```
email-unlimit-server/
├── 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.toml # 开发环境配置(支持 MySQL/PostgreSQL/SQLite
│ └── 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/`
#### DTOData 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,
}
```
#### VOView 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 分层架构可以提高代码质量和可维护性。
+35
View File
@@ -0,0 +1,35 @@
-- ============================================
-- 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 '更新时间'
,deleted_at DATETIME NULL COMMENT '软删除时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_profiles (
user_id VARCHAR(10) PRIMARY KEY,
display_name VARCHAR(80), avatar_url VARCHAR(2048), bio VARCHAR(500),
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS email_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(10), recipient VARCHAR(255) NOT NULL,
kind VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL, error TEXT, created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================
-- 初始化完成
-- ============================================
SELECT '✅ 数据库初始化完成' AS status;
SHOW TABLES;
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users (id VARCHAR(10) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS user_profiles (user_id VARCHAR(10) PRIMARY KEY, display_name VARCHAR(80), avatar_url VARCHAR(2048), bio VARCHAR(500), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS email_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(10), recipient VARCHAR(255) NOT NULL, kind VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL, error TEXT, created_at DATETIME NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX idx_email_logs_user_created ON email_logs(user_id, created_at DESC);
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users (id VARCHAR(10) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP NULL);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS user_profiles (user_id VARCHAR(10) PRIMARY KEY, display_name VARCHAR(80), avatar_url VARCHAR(2048), bio VARCHAR(500), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL);
CREATE TABLE IF NOT EXISTS email_logs (id BIGSERIAL PRIMARY KEY, user_id VARCHAR(10), recipient VARCHAR(255) NOT NULL, kind VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL, error TEXT, created_at TIMESTAMP NOT NULL);
CREATE INDEX IF NOT EXISTS idx_email_logs_user_created ON email_logs(user_id, created_at DESC);
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME NULL);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS user_profiles (user_id TEXT PRIMARY KEY, display_name TEXT, avatar_url TEXT, bio TEXT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL);
CREATE TABLE IF NOT EXISTS email_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, recipient TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, error TEXT, created_at DATETIME NOT NULL);
CREATE INDEX IF NOT EXISTS idx_email_logs_user_created ON email_logs(user_id, created_at DESC);
+350
View File
@@ -0,0 +1,350 @@
PRAGMA busy_timeout = 10000;
BEGIN IMMEDIATE;
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1200
)
INSERT INTO users (
id, email, password_hash, role, status, created_at, updated_at, deleted_at
)
SELECT
printf('scale-user-%04d', n),
printf('scale.user.%04d@example.test', n),
'disabled-scale-account',
'user',
CASE
WHEN n % 29 = 0 THEN 'banned'
WHEN n % 17 = 0 THEN 'suspended'
ELSE 'active'
END,
datetime('now', printf('-%d hours', n % 2160)),
datetime('now', printf('-%d hours', n % 72)),
NULL
FROM seq
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE id = printf('scale-user-%04d', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1200
)
INSERT INTO user_profiles (
user_id, display_name, avatar_url, bio, created_at, updated_at
)
SELECT
printf('scale-user-%04d', n),
printf('Scale User %04d', n),
NULL,
'Governance scale test account',
datetime('now', printf('-%d hours', n % 2160)),
datetime('now')
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM user_profiles
WHERE user_id = printf('scale-user-%04d', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1200
)
INSERT INTO credit_accounts (
user_id, balance, version, total_granted, total_consumed, updated_at
)
SELECT
printf('scale-user-%04d', n),
(n * 7) % 120,
1,
30 + (n % 40),
n % 25,
datetime('now', printf('-%d minutes', n % 1440))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM credit_accounts
WHERE user_id = printf('scale-user-%04d', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1200
)
INSERT INTO mailboxes (
user_id, address, local_part, domain, access_token_hash, note, status,
max_quota_bytes, used_bytes, expires_at, created_at, updated_at
)
SELECT
printf('scale-user-%04d', n),
printf('scale-box-%04d@mail.shenjianl.cn', n),
printf('scale-box-%04d', n),
'mail.shenjianl.cn',
printf('disabled-scale-token-%04d', n),
CASE n % 4
WHEN 0 THEN 'Automation'
WHEN 1 THEN 'Registration'
WHEN 2 THEN 'Monitoring'
ELSE 'QA inbox'
END,
CASE
WHEN n % 19 = 0 THEN 'revoked'
WHEN n % 13 = 0 THEN 'expired'
ELSE 'active'
END,
10485760,
0,
datetime(
'now',
CASE
WHEN n % 13 = 0 THEN printf('-%d hours', (n % 48) + 1)
ELSE printf('+%d hours', (n % 168) + 1)
END
),
datetime('now', printf('-%d hours', n % 720)),
datetime('now', printf('-%d minutes', n % 1440))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM mailboxes
WHERE address = printf('scale-box-%04d@mail.shenjianl.cn', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 3000
)
INSERT INTO emails (
mailbox_id, user_id, recipient, mail_from, rcpt_to, sender_name, subject,
text_body, html_body, source_ip, helo, ptr_result, spf_result, dkim_result,
dmarc_result, abuse_score, status, size_bytes, message_id, received_at,
expires_at
)
SELECT
mailbox.id,
mailbox.user_id,
mailbox.address,
printf('sender%03d@scale-domain-%02d.test', n % 240, n % 36),
mailbox.address,
printf('Scale Sender %03d', n % 240),
CASE n % 6
WHEN 0 THEN 'Account verification'
WHEN 1 THEN 'Build pipeline result'
WHEN 2 THEN 'Security notification'
WHEN 3 THEN 'Weekly account digest'
WHEN 4 THEN 'Service status update'
ELSE 'One-time access code'
END,
printf('Scale test email %04d for pagination and governance.', n),
printf(
'<p>Scale test email <strong>%04d</strong>.</p><p>Safe local fixture.</p>',
n
),
printf('198.51.%d.%d', (n / 250) % 250, (n % 250) + 1),
printf('mx%02d.scale-sender.test', n % 24),
CASE WHEN n % 11 = 0 THEN 'missing' ELSE 'pass' END,
CASE WHEN n % 9 = 0 THEN 'fail' ELSE 'pass' END,
CASE WHEN n % 8 = 0 THEN 'none' ELSE 'pass' END,
CASE WHEN n % 10 = 0 THEN 'fail' ELSE 'pass' END,
(n * 13) % 101,
CASE WHEN n % 7 = 0 THEN 'quarantined' ELSE 'received' END,
8192 + ((n * 791) % 65536),
printf('<scale-%05d@seed.local>', n),
datetime('now', printf('-%d hours', n % 720)),
datetime('now', printf('-%d hours', n % 720), '+7 days')
FROM seq
JOIN mailboxes AS mailbox
ON mailbox.address = printf(
'scale-box-%04d@mail.shenjianl.cn',
((n - 1) % 1200) + 1
)
WHERE NOT EXISTS (
SELECT 1
FROM emails
WHERE message_id = printf('<scale-%05d@seed.local>', n)
);
INSERT INTO email_attachments (
email_id, filename, content_type, size_bytes, content, created_at
)
SELECT
email.id,
printf('scale-report-%05d.txt', email.id),
'text/plain',
2048,
CAST('Scale fixture' AS BLOB),
email.received_at
FROM emails AS email
WHERE email.message_id LIKE '<scale-%@seed.local>'
AND email.id % 10 = 0
AND NOT EXISTS (
SELECT 1
FROM email_attachments AS attachment
WHERE attachment.email_id = email.id
AND attachment.filename = printf('scale-report-%05d.txt', email.id)
);
UPDATE mailboxes
SET used_bytes = COALESCE(
(
SELECT SUM(email.size_bytes)
FROM emails AS email
WHERE email.mailbox_id = mailboxes.id
),
0
)
WHERE address LIKE 'scale-box-%@mail.shenjianl.cn';
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 2000
)
INSERT INTO credit_transactions (
user_id, delta, balance_after, reason, related_mailbox_id,
related_email_id, description, operator_id, created_at
)
SELECT
printf('scale-user-%04d', ((n - 1) % 1200) + 1),
CASE WHEN n % 5 = 0 THEN 3 ELSE -1 END,
(n * 7) % 120,
CASE
WHEN n % 5 = 0 THEN 'daily_check_in'
WHEN n % 3 = 0 THEN 'create_mailbox'
ELSE 'receive_email'
END,
NULL,
NULL,
printf('Scale transaction %05d', n),
NULL,
datetime('now', printf('-%d hours', n % 720))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM credit_transactions
WHERE description = printf('Scale transaction %05d', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1500
)
INSERT INTO audit_logs (
event_type, source_ip, helo, mail_from, rcpt_to, action, reason,
operator_id, created_at, target_type, target_id, metadata_json
)
SELECT
CASE n % 4
WHEN 0 THEN 'smtp'
WHEN 1 THEN 'email_governance'
WHEN 2 THEN 'blacklist'
ELSE 'admin'
END,
printf('203.0.%d.%d', (n / 250) % 250, (n % 250) + 1),
printf('scale-mx-%02d.test', n % 24),
printf('audit%04d@scale.test', n),
printf('scale-box-%04d@mail.shenjianl.cn', ((n - 1) % 1200) + 1),
CASE n % 5
WHEN 0 THEN 'accepted'
WHEN 1 THEN 'rejected'
WHEN 2 THEN 'quarantined'
WHEN 3 THEN 'viewed'
ELSE 'released'
END,
'Scale governance fixture',
CASE WHEN n % 4 = 3 THEN 'demo-ops-admin' ELSE NULL END,
datetime('now', printf('-%d minutes', n % 43200)),
'email',
printf('scale-audit-%05d', n),
printf('{"demo":true,"scale_id":%d}', n)
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM audit_logs
WHERE target_id = printf('scale-audit-%05d', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 1600
)
INSERT INTO email_logs (
user_id, recipient, kind, status, error, created_at
)
SELECT
printf('scale-user-%04d', ((n - 1) % 1200) + 1),
printf('scale.out.%04d@example.test', n),
CASE n % 3
WHEN 0 THEN 'verification'
WHEN 1 THEN 'notification'
ELSE 'security'
END,
CASE WHEN n % 9 = 0 THEN 'failed' ELSE 'sent' END,
CASE WHEN n % 9 = 0 THEN 'Scale provider timeout' ELSE NULL END,
datetime('now', printf('-%d hours', n % 720))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM email_logs
WHERE recipient = printf('scale.out.%04d@example.test', n)
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 600
)
INSERT INTO blocked_senders (
kind, value, reason, source, expires_at, created_at
)
SELECT
CASE WHEN n % 3 = 0 THEN 'domain' ELSE 'email' END,
CASE
WHEN n % 3 = 0 THEN printf('scale-block-%04d.test', n)
ELSE printf('blocked%04d@scale.test', n)
END,
'Scale governance fixture',
'manual',
CASE WHEN n % 8 = 0 THEN datetime('now', '-1 hour') ELSE NULL END,
datetime('now', printf('-%d hours', n % 720))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM blocked_senders
WHERE value = CASE
WHEN n % 3 = 0 THEN printf('scale-block-%04d.test', n)
ELSE printf('blocked%04d@scale.test', n)
END
);
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 600
)
INSERT INTO blocked_ips (
ip, reason, source, expires_at, created_at
)
SELECT
printf('198.18.%d.%d', n / 256, n % 256),
'Scale governance fixture',
'manual',
CASE WHEN n % 8 = 0 THEN datetime('now', '-1 hour') ELSE NULL END,
datetime('now', printf('-%d hours', n % 720))
FROM seq
WHERE NOT EXISTS (
SELECT 1
FROM blocked_ips
WHERE ip = printf('198.18.%d.%d', n / 256, n % 256)
);
COMMIT;
+236
View File
@@ -0,0 +1,236 @@
/// 命令行参数和配置管理
/// 支持优先级: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 = "email-unlimit-server")]
#[command(about = "Email Unlimited API and SMTP server", long_about = None)]
#[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" => "email_unlimit_server=trace,tower_http=trace,axum=trace,sqlx=debug".into(),
"debug" => "email_unlimit_server=debug,tower_http=debug,axum=debug,sqlx=debug".into(),
_ => "email_unlimit_server=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!("Email Unlimited 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>,
}
+50
View File
@@ -0,0 +1,50 @@
use serde::Deserialize;
/// 滥用检测 / 自动封禁 / 告警配置
#[derive(Debug, Deserialize, Clone)]
pub struct AbuseConfig {
/// 单发件域 10 分钟邮件数阈值(超阈值自动临时封禁)
#[serde(default = "default_domain_emails_threshold_10min")]
pub domain_emails_threshold_10min: i64,
/// 单 IP 每分钟连接数阈值
#[serde(default = "default_ip_connects_threshold_1min")]
pub ip_connects_threshold_1min: i64,
/// 自动封禁 TTL(秒)
#[serde(default = "default_auto_block_ttl_seconds")]
pub auto_block_ttl_seconds: i64,
/// 蜜罐地址列表
#[serde(default = "default_honeypot_addresses")]
pub honeypot_addresses: Vec<String>,
/// 告警 webhook(留空则只写 audit_logs / abuse_events
#[serde(default)]
pub alert_webhook_url: String,
}
fn default_domain_emails_threshold_10min() -> i64 {
50
}
fn default_ip_connects_threshold_1min() -> i64 {
60
}
fn default_auto_block_ttl_seconds() -> i64 {
3600
}
fn default_honeypot_addresses() -> Vec<String> {
vec![
"admin@mail.shenjianl.cn".into(),
"noreply@mail.shenjianl.cn".into(),
"test@mail.shenjianl.cn".into(),
]
}
impl Default for AbuseConfig {
fn default() -> Self {
Self {
domain_emails_threshold_10min: default_domain_emails_threshold_10min(),
ip_connects_threshold_1min: default_ip_connects_threshold_1min(),
auto_block_ttl_seconds: default_auto_block_ttl_seconds(),
honeypot_addresses: default_honeypot_addresses(),
alert_webhook_url: String::new(),
}
}
}
+222
View File
@@ -0,0 +1,222 @@
use super::{
abuse::AbuseConfig, auth::AuthConfig, database::DatabaseConfig, email::EmailConfig,
redis::RedisConfig, server::ServerConfig, smtp::SmtpConfig,
};
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,
#[serde(default)]
pub email: EmailConfig,
#[serde(default)]
pub smtp: SmtpConfig,
#[serde(default)]
pub abuse: AbuseConfig,
}
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())?;
builder = builder.set_default("server.request_timeout_seconds", 30)?;
builder = builder.set_default("server.max_body_bytes", 1048576)?;
builder = builder.set_default("server.concurrency_limit", 256)?;
builder = builder.set_default("server.rate_limit_per_minute", 120)?;
builder = builder.set_default("server.cors_origins", vec!["http://localhost:3000"])?;
// 设置 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.enabled", false)?;
builder = builder.set_default("redis.port", 6379)?;
builder = builder.set_default("redis.db", 0)?;
builder = builder.set_default("email.enabled", false)?;
}
// 添加环境变量源(会覆盖配置文件的值)
builder = builder.add_source(environment_source());
// 应用 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()?;
validate_security(&config, _environment)?;
// 安全警告:检查是否使用了默认的 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_source())
.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 environment_source() -> Environment {
Environment::default()
.separator("__")
.try_parsing(true)
.list_separator(",")
.with_list_parse_key("server.cors_origins")
.with_list_parse_key("smtp.local_domains")
.with_list_parse_key("abuse.honeypot_addresses")
}
fn validate_security(config: &AppConfig, environment: &str) -> Result<(), ConfigError> {
if environment.eq_ignore_ascii_case("production") {
if config.auth.jwt_secret == default_jwt_secret()
|| config.auth.jwt_secret.contains("CHANGE_ME")
|| config.auth.jwt_secret.len() < 32
{
return Err(ConfigError::Message("生产环境禁止使用默认 JWT 密钥".into()));
}
if config.smtp.body_encryption_key.len() != 32
|| config.smtp.body_encryption_key.contains("CHANGE_ME")
{
return Err(ConfigError::Message(
"生产环境必须配置 32 字节 SMTP 正文加密密钥".into(),
));
}
if !config.auth.bootstrap_admin_email.is_empty()
&& (config.auth.invite_code.len() < 16 || config.auth.invite_code.contains("CHANGE_ME"))
{
return Err(ConfigError::Message(
"配置首个管理员邮箱时,生产环境必须同时设置至少 16 字符的邀请码".into(),
));
}
if config
.server
.cors_origins
.iter()
.any(|origin| origin == "*")
{
return Err(ConfigError::Message(
"生产环境禁止使用通配 CORS 来源".into(),
));
}
}
Ok(())
}
// 默认值函数(复用)
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn production_rejects_default_secret_and_wildcard_cors() {
let mut config = AppConfig::load_from_path("config/development.toml").unwrap();
config.auth.jwt_secret = default_jwt_secret();
assert!(validate_security(&config, "production").is_err());
config.auth.jwt_secret = "a-production-secret-with-32-bytes".into();
config.smtp.body_encryption_key = "0123456789abcdef0123456789abcdef".into();
config.auth.bootstrap_admin_email = String::new();
config.server.cors_origins = vec!["*".into()];
assert!(validate_security(&config, "production").is_err());
}
#[test]
fn development_accepts_safe_defaults() {
let config = AppConfig::load_from_path("config/development.toml").unwrap();
assert!(validate_security(&config, "development").is_ok());
}
}
+38
View File
@@ -0,0 +1,38 @@
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,
/// 是否开放注册
#[serde(default = "default_registration_open")]
pub registration_open: bool,
/// 邀请码(空则不校验)
#[serde(default)]
pub invite_code: String,
/// 注册该邮箱时授予 admin 角色;生产环境应同时配置强邀请码。
#[serde(default)]
pub bootstrap_admin_email: String,
}
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
}
fn default_registration_open() -> bool {
true
}
+168
View 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(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
}
+55
View File
@@ -0,0 +1,55 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct EmailConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
#[serde(default)]
pub from_email: String,
#[serde(default = "default_from_name")]
pub from_name: String,
#[serde(default = "default_code_ttl")]
pub verification_code_ttl_seconds: u64,
#[serde(default)]
pub queue_enabled: bool,
#[serde(default = "default_worker_pool_size")]
pub worker_pool_size: usize,
}
fn default_smtp_port() -> u16 {
587
}
fn default_from_name() -> String {
"Email Unlimited".into()
}
fn default_code_ttl() -> u64 {
600
}
fn default_worker_pool_size() -> usize {
2
}
impl Default for EmailConfig {
fn default() -> Self {
Self {
enabled: false,
smtp_host: String::new(),
smtp_port: default_smtp_port(),
smtp_username: String::new(),
smtp_password: String::new(),
from_email: String::new(),
from_name: default_from_name(),
verification_code_ttl_seconds: default_code_ttl(),
queue_enabled: false,
worker_pool_size: default_worker_pool_size(),
}
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod abuse;
pub mod app;
pub mod auth;
pub mod database;
pub mod email;
pub mod redis;
pub mod server;
pub mod smtp;
+54
View File
@@ -0,0 +1,54 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct RedisConfig {
#[serde(default)]
pub enabled: bool,
/// 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)
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
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,
#[serde(default = "default_request_timeout_seconds")]
pub request_timeout_seconds: u64,
#[serde(default = "default_max_body_bytes")]
pub max_body_bytes: usize,
#[serde(default = "default_concurrency_limit")]
pub concurrency_limit: usize,
#[serde(default = "default_rate_limit_per_minute")]
pub rate_limit_per_minute: u32,
#[serde(default = "default_cors_origins")]
pub cors_origins: Vec<String>,
}
fn default_request_timeout_seconds() -> u64 {
30
}
fn default_max_body_bytes() -> usize {
1024 * 1024
}
fn default_concurrency_limit() -> usize {
256
}
fn default_rate_limit_per_minute() -> u32 {
120
}
fn default_cors_origins() -> Vec<String> {
vec!["http://localhost:3000".to_string()]
}
fn default_server_host() -> String {
"127.0.0.1".to_string()
}
fn default_server_port() -> u16 {
3000
}
+203
View File
@@ -0,0 +1,203 @@
use serde::Deserialize;
/// SMTP 收信服务配置(临时邮箱核心)
#[derive(Debug, Deserialize, Clone)]
pub struct SmtpConfig {
/// 是否启用 SMTP 收信(监听 25 端口)
#[serde(default = "default_enabled")]
pub enabled: bool,
/// 监听地址
#[serde(default = "default_listen_host")]
pub listen_host: String,
/// 监听端口(默认 25
#[serde(default = "default_listen_port")]
pub listen_port: u16,
/// 本机主机名(SMTP 问候时使用,如 mail.shenjianl.cn
#[serde(default = "default_hostname")]
pub hostname: String,
/// 允许的收件域名白名单(只收这些域名的邮件)
#[serde(default = "default_local_domains")]
pub local_domains: Vec<String>,
/// 单封邮件最大字节数
#[serde(default = "default_max_message_bytes")]
pub max_message_bytes: usize,
/// 单连接超时秒数
#[serde(default = "default_connection_timeout_seconds")]
pub connection_timeout_seconds: u64,
/// 单封邮件最大收件人数
#[serde(default = "default_max_recipients")]
pub max_recipients_per_message: u32,
// ===== 限流(IP 连接 + 发件域 + 收件地址) =====
/// 单 IP 每分钟最大连接数
#[serde(default = "default_ip_connect_per_min")]
pub ip_connect_per_min: u32,
/// 单发件域每分钟最大邮件数
#[serde(default = "default_domain_per_min")]
pub domain_per_min: u32,
/// 单收件地址每分钟最大邮件数
#[serde(default = "default_rcpt_per_min")]
pub rcpt_per_min: u32,
/// 单发件人每分钟最大邮件数
#[serde(default = "default_sender_per_min")]
pub sender_per_min: u32,
/// 单(发件域→收件地址)每 10 分钟最大邮件数
#[serde(default = "default_pair_per_10min")]
pub pair_per_10min: u32,
// ===== PTR 反查 =====
/// 是否要求来源 IP 有有效 PTR 记录
#[serde(default = "default_require_ptr")]
pub require_ptr: bool,
// ===== 灰名单 =====
#[serde(default = "default_greylist_enabled")]
pub greylist_enabled: bool,
#[serde(default = "default_greylist_retry_delay_seconds")]
pub greylist_retry_delay_seconds: i64,
#[serde(default = "default_greylist_entry_ttl_seconds")]
pub greylist_entry_ttl_seconds: i64,
// ===== 滥用评分 =====
/// 达到此分数的邮件进入隔离区
#[serde(default = "default_quarantine_threshold")]
pub quarantine_threshold: i32,
#[serde(default = "default_score_weight_subject_code")]
pub score_weight_subject_code: i32,
#[serde(default = "default_score_weight_spf_fail")]
pub score_weight_spf_fail: i32,
#[serde(default = "default_score_weight_ptr_missing")]
pub score_weight_ptr_missing: i32,
#[serde(default = "default_score_weight_bad_attachment")]
pub score_weight_bad_attachment: i32,
// ===== 保留期与配额 =====
/// 邮件保留天数(超过自动清理)
#[serde(default = "default_email_ttl_days")]
pub email_ttl_days: i64,
/// 新建临时邮箱默认有效期(小时)
#[serde(default = "default_mailbox_ttl_hours")]
pub mailbox_ttl_hours: i64,
/// 单邮箱最大容量(字节)
#[serde(default = "default_mailbox_max_quota_bytes")]
pub mailbox_max_quota_bytes: i64,
/// 随机生成的邮箱本地部分长度
#[serde(default = "default_address_local_part_len")]
pub address_local_part_len: usize,
/// 邮件正文加密密钥(必须恰好 32 字符;留空则不加密)
#[serde(default)]
pub body_encryption_key: String,
}
fn default_enabled() -> bool {
true
}
fn default_listen_host() -> String {
"0.0.0.0".to_string()
}
fn default_listen_port() -> u16 {
25
}
fn default_hostname() -> String {
"mail.shenjianl.cn".to_string()
}
fn default_local_domains() -> Vec<String> {
vec!["mail.shenjianl.cn".to_string(), "shenjianl.cn".to_string()]
}
fn default_max_message_bytes() -> usize {
1024 * 1024
}
fn default_connection_timeout_seconds() -> u64 {
30
}
fn default_max_recipients() -> u32 {
1
}
fn default_ip_connect_per_min() -> u32 {
20
}
fn default_domain_per_min() -> u32 {
10
}
fn default_rcpt_per_min() -> u32 {
10
}
fn default_sender_per_min() -> u32 {
10
}
fn default_pair_per_10min() -> u32 {
5
}
fn default_require_ptr() -> bool {
true
}
fn default_greylist_enabled() -> bool {
true
}
fn default_greylist_retry_delay_seconds() -> i64 {
300
}
fn default_greylist_entry_ttl_seconds() -> i64 {
86400
}
fn default_quarantine_threshold() -> i32 {
60
}
fn default_score_weight_subject_code() -> i32 {
30
}
fn default_score_weight_spf_fail() -> i32 {
20
}
fn default_score_weight_ptr_missing() -> i32 {
15
}
fn default_score_weight_bad_attachment() -> i32 {
25
}
fn default_email_ttl_days() -> i64 {
7
}
fn default_mailbox_ttl_hours() -> i64 {
24
}
fn default_mailbox_max_quota_bytes() -> i64 {
10 * 1024 * 1024
}
fn default_address_local_part_len() -> usize {
10
}
impl Default for SmtpConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
listen_host: default_listen_host(),
listen_port: default_listen_port(),
hostname: default_hostname(),
local_domains: default_local_domains(),
max_message_bytes: default_max_message_bytes(),
connection_timeout_seconds: default_connection_timeout_seconds(),
max_recipients_per_message: default_max_recipients(),
ip_connect_per_min: default_ip_connect_per_min(),
domain_per_min: default_domain_per_min(),
rcpt_per_min: default_rcpt_per_min(),
sender_per_min: default_sender_per_min(),
pair_per_10min: default_pair_per_10min(),
require_ptr: default_require_ptr(),
greylist_enabled: default_greylist_enabled(),
greylist_retry_delay_seconds: default_greylist_retry_delay_seconds(),
greylist_entry_ttl_seconds: default_greylist_entry_ttl_seconds(),
quarantine_threshold: default_quarantine_threshold(),
score_weight_subject_code: default_score_weight_subject_code(),
score_weight_spf_fail: default_score_weight_spf_fail(),
score_weight_ptr_missing: default_score_weight_ptr_missing(),
score_weight_bad_attachment: default_score_weight_bad_attachment(),
email_ttl_days: default_email_ttl_days(),
mailbox_ttl_hours: default_mailbox_ttl_hours(),
mailbox_max_quota_bytes: default_mailbox_max_quota_bytes(),
address_local_part_len: default_address_local_part_len(),
body_encryption_key: String::new(),
}
}
}
+526
View File
@@ -0,0 +1,526 @@
use crate::config::database::{DatabaseConfig, DatabaseType};
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, EntityName,
EntityTrait, 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?;
migrate_existing_tables(&pool).await?;
create_indexes(&pool).await?;
Ok(pool)
}
async fn create_indexes(db: &DatabaseConnection) -> anyhow::Result<()> {
let backend = db.get_database_backend();
let statements = match backend {
DbBackend::MySql => vec![
"CREATE INDEX idx_users_email ON users(email)",
"CREATE INDEX idx_users_status_created ON users(status, created_at)",
"CREATE INDEX idx_email_logs_user_created ON email_logs(user_id, created_at)",
"CREATE INDEX idx_email_logs_status_created ON email_logs(status, created_at)",
"CREATE INDEX idx_mailboxes_user ON mailboxes(user_id)",
"CREATE INDEX idx_mailboxes_status ON mailboxes(status)",
"CREATE INDEX idx_mailboxes_expires_at ON mailboxes(expires_at)",
"CREATE INDEX idx_mailboxes_status_expires ON mailboxes(status, expires_at)",
"CREATE INDEX idx_emails_mailbox ON emails(mailbox_id)",
"CREATE INDEX idx_emails_user ON emails(user_id)",
"CREATE INDEX idx_emails_received_at ON emails(received_at)",
"CREATE INDEX idx_emails_expires_at ON emails(expires_at)",
"CREATE INDEX idx_emails_status_received ON emails(status, received_at)",
"CREATE INDEX idx_emails_source_received ON emails(source_ip, received_at)",
"CREATE INDEX idx_email_attachments_email ON email_attachments(email_id)",
"CREATE INDEX idx_credit_transactions_user_created ON credit_transactions(user_id, created_at)",
"CREATE INDEX idx_credit_transactions_reason_created ON credit_transactions(reason, created_at)",
"CREATE INDEX idx_credit_rule_changes_created ON credit_rule_changes(created_at)",
"CREATE INDEX idx_blocked_senders_value ON blocked_senders(value)",
"CREATE INDEX idx_blocked_ips_ip ON blocked_ips(ip)",
"CREATE INDEX idx_greylist_triplet ON greylist_entries(sender_ip, mail_from, rcpt_to)",
"CREATE INDEX idx_audit_logs_created ON audit_logs(created_at)",
"CREATE INDEX idx_audit_logs_source_ip ON audit_logs(source_ip)",
"CREATE INDEX idx_audit_logs_event_type ON audit_logs(event_type)",
"CREATE INDEX idx_audit_logs_action_created ON audit_logs(action, created_at)",
"CREATE INDEX idx_audit_logs_operator_created ON audit_logs(operator_id, created_at)",
"CREATE INDEX idx_abuse_events_rule ON abuse_events(rule_id)",
"CREATE INDEX idx_abuse_events_created ON abuse_events(created_at)",
],
_ => vec![
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
"CREATE INDEX IF NOT EXISTS idx_users_status_created ON users(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_email_logs_user_created ON email_logs(user_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_email_logs_status_created ON email_logs(status, created_at)",
"CREATE INDEX IF NOT EXISTS idx_mailboxes_user ON mailboxes(user_id)",
"CREATE INDEX IF NOT EXISTS idx_mailboxes_status ON mailboxes(status)",
"CREATE INDEX IF NOT EXISTS idx_mailboxes_expires_at ON mailboxes(expires_at)",
"CREATE INDEX IF NOT EXISTS idx_mailboxes_status_expires ON mailboxes(status, expires_at)",
"CREATE INDEX IF NOT EXISTS idx_emails_mailbox ON emails(mailbox_id)",
"CREATE INDEX IF NOT EXISTS idx_emails_user ON emails(user_id)",
"CREATE INDEX IF NOT EXISTS idx_emails_received_at ON emails(received_at)",
"CREATE INDEX IF NOT EXISTS idx_emails_expires_at ON emails(expires_at)",
"CREATE INDEX IF NOT EXISTS idx_emails_status_received ON emails(status, received_at)",
"CREATE INDEX IF NOT EXISTS idx_emails_source_received ON emails(source_ip, received_at)",
"CREATE INDEX IF NOT EXISTS idx_email_attachments_email ON email_attachments(email_id)",
"CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created ON credit_transactions(user_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_credit_transactions_reason_created ON credit_transactions(reason, created_at)",
"CREATE INDEX IF NOT EXISTS idx_credit_rule_changes_created ON credit_rule_changes(created_at)",
"CREATE INDEX IF NOT EXISTS idx_blocked_senders_value ON blocked_senders(value)",
"CREATE INDEX IF NOT EXISTS idx_blocked_ips_ip ON blocked_ips(ip)",
"CREATE INDEX IF NOT EXISTS idx_greylist_triplet ON greylist_entries(sender_ip, mail_from, rcpt_to)",
"CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)",
"CREATE INDEX IF NOT EXISTS idx_audit_logs_source_ip ON audit_logs(source_ip)",
"CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit_logs(event_type)",
"CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created ON audit_logs(action, created_at)",
"CREATE INDEX IF NOT EXISTS idx_audit_logs_operator_created ON audit_logs(operator_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_abuse_events_rule ON abuse_events(rule_id)",
"CREATE INDEX IF NOT EXISTS idx_abuse_events_created ON abuse_events(created_at)",
],
};
for statement in statements {
if let Err(error) = db.execute(Statement::from_string(backend, statement)).await {
let message = error.to_string().to_lowercase();
if !message.contains("duplicate") && !message.contains("already exists") {
return Err(anyhow::anyhow!("创建索引失败: {error}"));
}
}
}
Ok(())
}
async fn migrate_existing_tables(db: &DatabaseConnection) -> anyhow::Result<()> {
let backend = db.get_database_backend();
let datetime_type = match backend {
DbBackend::Postgres => "TIMESTAMP NULL",
_ => "DATETIME NULL",
};
let varchar_default = |default: &str| -> String {
match backend {
DbBackend::Sqlite => format!("TEXT NOT NULL DEFAULT '{}'", default),
_ => format!("VARCHAR(32) NOT NULL DEFAULT '{}'", default),
}
};
// 兼容旧表:users 增加 deleted_at(原有逻辑)
add_column_if_missing(db, backend, "users", "deleted_at", datetime_type).await?;
// 兼容旧表:users 增加角色与状态字段
add_column_if_missing(db, backend, "users", "role", &varchar_default("user")).await?;
add_column_if_missing(db, backend, "users", "status", &varchar_default("active")).await?;
let text_type = "TEXT NULL";
let nullable_identifier = match backend {
DbBackend::MySql | DbBackend::Postgres => "VARCHAR(255) NULL",
DbBackend::Sqlite => "TEXT NULL",
};
add_column_if_missing(db, backend, "credit_transactions", "description", text_type).await?;
add_column_if_missing(
db,
backend,
"credit_transactions",
"operator_id",
nullable_identifier,
)
.await?;
add_column_if_missing(
db,
backend,
"audit_logs",
"target_type",
nullable_identifier,
)
.await?;
add_column_if_missing(db, backend, "audit_logs", "target_id", nullable_identifier).await?;
add_column_if_missing(db, backend, "audit_logs", "metadata_json", text_type).await?;
Ok(())
}
/// 若列不存在则添加(忽略 duplicate / already exists 错误)
async fn add_column_if_missing(
db: &DatabaseConnection,
backend: DbBackend,
table: &str,
column: &str,
type_decl: &str,
) -> anyhow::Result<()> {
let statement = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, type_decl);
if let Err(error) = db.execute(Statement::from_string(backend, statement)).await {
let message = error.to_string().to_lowercase();
if !message.contains("duplicate") && !message.contains("already exists") {
return Err(anyhow::anyhow!("迁移 {}.{} 失败: {error}", table, column));
}
}
Ok(())
}
/// 获取端口号(根据数据库类型返回默认值)
fn get_database_port(config: &DatabaseConfig) -> u16 {
config.port.unwrap_or(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"))?;
validate_database_name(database_name)?;
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"))?;
validate_database_name(database_name)?;
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
.query_one(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
check_query,
))
.await
.map_err(|e| anyhow::anyhow!("检查 PostgreSQL 数据库失败: {e}"))?;
match result {
Some(_) => {
tracing::info!("PostgreSQL 数据库 '{}' 已存在", database_name);
}
None => {
// 数据库不存在,创建它
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(())
}
fn validate_database_name(name: &str) -> anyhow::Result<()> {
if name.is_empty()
|| !name
.bytes()
.all(|value| value.is_ascii_alphanumeric() || value == b'_')
{
anyhow::bail!("数据库名称只能包含字母、数字和下划线");
}
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::{
abuse_event, abuse_rule, audit_log, blocked_ip, blocked_sender, credit_account,
credit_check_in, credit_rule, credit_rule_change, credit_transaction, daily_quota, email,
email_attachment, email_logs, greylist_entry, mailbox, user_profiles, users,
};
// 创建所有表(添加新表只需一行!)
create_single_table(db, &schema, &builder, users::Entity, "用户表").await?;
create_single_table(db, &schema, &builder, user_profiles::Entity, "用户资料表").await?;
create_single_table(db, &schema, &builder, mailbox::Entity, "邮箱表").await?;
create_single_table(db, &schema, &builder, email::Entity, "邮件表").await?;
create_single_table(db, &schema, &builder, email_attachment::Entity, "附件表").await?;
create_single_table(db, &schema, &builder, email_logs::Entity, "邮件日志表").await?;
create_single_table(db, &schema, &builder, credit_account::Entity, "积分账户表").await?;
create_single_table(
db,
&schema,
&builder,
credit_transaction::Entity,
"积分流水表",
)
.await?;
create_single_table(db, &schema, &builder, credit_rule::Entity, "积分规则表").await?;
create_single_table(
db,
&schema,
&builder,
credit_rule_change::Entity,
"积分规则变更表",
)
.await?;
create_single_table(db, &schema, &builder, credit_check_in::Entity, "每日签到表").await?;
create_single_table(
db,
&schema,
&builder,
blocked_sender::Entity,
"发件黑名单表",
)
.await?;
create_single_table(db, &schema, &builder, blocked_ip::Entity, "IP黑名单表").await?;
create_single_table(db, &schema, &builder, greylist_entry::Entity, "灰名单表").await?;
create_single_table(db, &schema, &builder, audit_log::Entity, "审计日志表").await?;
create_single_table(db, &schema, &builder, daily_quota::Entity, "每日额度表").await?;
create_single_table(db, &schema, &builder, abuse_rule::Entity, "滥用规则表").await?;
create_single_table(db, &schema, &builder, abuse_event::Entity, "滥用事件表").await?;
tracing::info!("✅ 数据库表结构检查完成");
Ok(())
}
+72
View File
@@ -0,0 +1,72 @@
use serde::Deserialize;
use std::fmt;
use validator::Validate;
/// 注册请求
#[derive(Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email)]
pub email: String,
#[validate(length(min = 8, max = 128))]
pub password: String,
/// 邀请码(配置 invite_code 为空时不校验)
pub invite_code: Option<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, Validate)]
pub struct LoginRequest {
#[validate(email)]
pub email: String,
#[validate(length(min = 1, max = 128))]
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: *** }}")
}
}
+29
View File
@@ -0,0 +1,29 @@
use serde::Deserialize;
use validator::Validate;
/// 创建临时邮箱请求
#[derive(Deserialize, Validate)]
pub struct CreateMailboxRequest {
/// 自定义本地部分(不传则随机生成)
#[validate(length(min = 1, max = 64))]
pub local_part: Option<String>,
/// 自定义域名(不传则用配置的第一个 local_domain
#[validate(length(min = 1, max = 255))]
pub domain: Option<String>,
/// 用途备注
#[validate(length(max = 128))]
pub note: Option<String>,
/// 自定义有效期(小时),不传则用配置默认
pub ttl_hours: Option<i64>,
}
impl std::fmt::Debug for CreateMailboxRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CreateMailboxRequest")
.field("local_part", &self.local_part)
.field("domain", &self.domain)
.field("note", &self.note)
.field("ttl_hours", &self.ttl_hours)
.finish()
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod auth;
pub mod mailbox;
pub mod user;
+12
View File
@@ -0,0 +1,12 @@
use serde::Deserialize;
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateProfileRequest {
#[validate(length(max = 80))]
pub display_name: Option<String>,
#[validate(url)]
pub avatar_url: Option<String>,
#[validate(length(max = 500))]
pub bio: Option<String>,
}
+31
View File
@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 滥用规则触发事件
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "abuse_events")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub rule_id: i64,
/// 触发的 domain / ip
pub target: String,
/// JSON 详情
pub detail: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
+25
View File
@@ -0,0 +1,25 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
/// 滥用检测规则
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "abuse_rules")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
/// domain_emails_10min | ip_connects_1min | ...
pub metric: String,
pub window_seconds: i64,
pub threshold: i64,
/// auto_block_sender | auto_block_ip | alert | quarantine
pub action: String,
pub block_ttl_seconds: i64,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {}
+44
View File
@@ -0,0 +1,44 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 审计日志(SMTP 事件 + 管理操作)
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "audit_logs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// smtp_connect | smtp_rcpt | smtp_accept | smtp_reject | admin_* | ...
pub event_type: String,
pub source_ip: Option<String>,
pub helo: Option<String>,
pub mail_from: Option<String>,
pub rcpt_to: Option<String>,
/// accept | defer | reject | quarantine | ...
pub action: String,
pub reason: Option<String>,
/// 管理操作时填 user_id
pub operator_id: Option<String>,
/// 被操作资源类型:user | mailbox | email | attachment | blacklist | credit_rule
pub target_type: Option<String>,
/// 被操作资源 ID,统一使用字符串兼容不同主键类型
pub target_id: Option<String>,
/// 扩展审计上下文与批次 ID
#[sea_orm(column_type = "Text", nullable)]
pub metadata_json: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
+31
View File
@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 来源 IP 黑名单(单 IP 或 CIDR,应用层匹配)
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "blocked_ips")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub ip: String,
pub reason: String,
/// manual | auto
pub source: String,
pub expires_at: Option<DateTime>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
@@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 发件黑名单(domain | address | pattern
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "blocked_senders")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// domain | address | pattern
pub kind: String,
/// 如 flova.ai / spam@x.com / *@spam.com
pub value: String,
pub reason: String,
/// manual | auto
pub source: String,
/// None=永久
pub expires_at: Option<DateTime>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
@@ -0,0 +1,29 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 用户积分账户(1:1 users
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "credit_accounts")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: String,
pub balance: i64,
/// 乐观锁版本号(并发扣减用)
pub version: i64,
pub total_granted: i64,
pub total_consumed: i64,
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: ConnectionTrait>(self, _: &C, _insert: bool) -> Result<Self, DbErr> {
let mut value = self;
value.updated_at = Set(chrono::Utc::now().naive_utc());
Ok(value)
}
}
@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "credit_check_ins")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: String,
#[sea_orm(primary_key, auto_increment = false)]
pub date: chrono::NaiveDate,
pub reward_granted: i64,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
+37
View File
@@ -0,0 +1,37 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "credit_rules")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i32,
pub register_bonus: i64,
pub daily_check_in_reward: i64,
pub reward_balance_cap: i64,
pub create_mailbox_cost: i64,
pub receive_email_cost: i64,
pub daily_mailboxes_limit: i32,
pub daily_emails_limit: i32,
pub version: i64,
pub updated_by: Option<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: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
let now = chrono::Utc::now().naive_utc();
if insert {
value.created_at = Set(now);
}
value.updated_at = Set(now);
Ok(value)
}
}
@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "credit_rule_changes")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub operator_id: String,
#[sea_orm(column_type = "Text")]
pub previous_json: String,
#[sea_orm(column_type = "Text")]
pub next_json: String,
pub reason: String,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
@@ -0,0 +1,37 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 积分流水(append-onlybalance_after 保证可审计)
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "credit_transactions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: String,
/// 正=充值/赠送,负=消费
pub delta: i64,
/// 变更后余额(审计关键)
pub balance_after: i64,
/// register_bonus | create_mailbox | receive_email | admin_adjust | ...
pub reason: String,
pub related_mailbox_id: Option<i64>,
pub related_email_id: Option<i64>,
pub description: Option<String>,
pub operator_id: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
+29
View File
@@ -0,0 +1,29 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 每日额度计数(复合主键 user_id + date
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "daily_quotas")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: String,
#[sea_orm(primary_key, auto_increment = false)]
pub date: chrono::NaiveDate,
pub mailboxes_created: i32,
pub emails_received: i32,
pub bytes_received: i64,
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: ConnectionTrait>(self, _: &C, _insert: bool) -> Result<Self, DbErr> {
let mut value = self;
value.updated_at = Set(chrono::Utc::now().naive_utc());
Ok(value)
}
}
+62
View File
@@ -0,0 +1,62 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 接收到的邮件(含安全与审计字段)
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "emails")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// 所属邮箱 IDFK mailboxes.id
pub mailbox_id: i64,
/// 冗余:所属用户 ID,便于按用户聚合
pub user_id: String,
/// 完整收件地址
pub recipient: String,
/// envelope MAIL FROM
pub mail_from: String,
/// envelope RCPT TO
pub rcpt_to: String,
/// 发件人显示名
pub sender_name: Option<String>,
pub subject: Option<String>,
pub text_body: Option<String>,
pub html_body: Option<String>,
/// 来源 IP
pub source_ip: String,
/// SMTP HELO/EHLO
pub helo: Option<String>,
/// PTR 反查结果
pub ptr_result: Option<String>,
/// SPF 结果
pub spf_result: Option<String>,
/// DKIM 结果
pub dkim_result: Option<String>,
/// DMARC 结果
pub dmarc_result: Option<String>,
/// 滥用评分 0-100
pub abuse_score: i32,
/// 状态:received | quarantined | rejected
pub status: String,
pub size_bytes: i64,
/// Message-ID 头
pub message_id: Option<String>,
pub received_at: DateTime,
/// 邮件保留期截止时间(默认 received + email_ttl_days
pub expires_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.received_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
@@ -0,0 +1,33 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 邮件附件(小附件 inline 存 content;大附件后续可落盘存 storage_path
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "email_attachments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// 所属邮件 IDFK emails.id
pub email_id: i64,
pub filename: Option<String>,
pub content_type: String,
pub size_bytes: i64,
/// 二进制内容(小附件)
pub content: Option<Vec<u8>>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
+27
View File
@@ -0,0 +1,27 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "email_logs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: Option<String>,
pub recipient: String,
pub kind: String,
pub status: String,
pub error: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
if insert {
value.created_at = Set(chrono::Utc::now().naive_utc());
}
Ok(value)
}
}
@@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 灰名单条目(tripletsender_ip + mail_from + rcpt_to
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "greylist_entries")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub sender_ip: String,
pub mail_from: String,
pub rcpt_to: String,
pub first_seen: DateTime,
/// 期望重试时间(首次 + retry_delay
pub retry_after: DateTime,
pub delivered: bool,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
let now = chrono::Utc::now().naive_utc();
if insert {
value.created_at = Set(now);
value.first_seen = Set(now);
}
Ok(value)
}
}
+50
View File
@@ -0,0 +1,50 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
/// 临时邮箱地址(用户创建,绑定 user,带过期与配额)
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "mailboxes")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// 所属用户 IDFK users.id
pub user_id: String,
/// 完整地址,如 abc123@mail.shenjianl.cn(唯一)
#[sea_orm(unique)]
pub address: String,
/// 本地部分,如 abc123
pub local_part: String,
/// 域名,如 mail.shenjianl.cn
pub domain: String,
/// 邮箱级 access_token 的 SHA-256 哈希
pub access_token_hash: String,
/// 用途备注(可选)
pub note: Option<String>,
/// 状态:active | expired | revoked
pub status: String,
/// 单邮箱容量上限(字节)
pub max_quota_bytes: i64,
/// 已用容量(字节)
pub used_bytes: i64,
/// 邮箱过期时间
pub expires_at: DateTime,
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: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
let now = chrono::Utc::now().naive_utc();
if insert {
value.created_at = Set(now);
}
value.updated_at = Set(now);
Ok(value)
}
}
+18
View File
@@ -0,0 +1,18 @@
pub mod abuse_event;
pub mod abuse_rule;
pub mod audit_log;
pub mod blocked_ip;
pub mod blocked_sender;
pub mod credit_account;
pub mod credit_check_in;
pub mod credit_rule;
pub mod credit_rule_change;
pub mod credit_transaction;
pub mod daily_quota;
pub mod email;
pub mod email_attachment;
pub mod email_logs;
pub mod greylist_entry;
pub mod mailbox;
pub mod user_profiles;
pub mod users;
@@ -0,0 +1,29 @@
use sea_orm::entity::prelude::*;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_profiles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<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: ConnectionTrait>(self, _: &C, insert: bool) -> Result<Self, DbErr> {
let mut value = self;
let now = chrono::Utc::now().naive_utc();
if insert {
value.created_at = Set(now);
}
value.updated_at = Set(now);
Ok(value)
}
}
+46
View File
@@ -0,0 +1,46 @@
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,
/// 角色:user | admin
pub role: String,
/// 状态:active | suspended | banned
pub status: String,
pub created_at: DateTime,
pub updated_at: DateTime,
pub deleted_at: Option<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
View File
@@ -0,0 +1,3 @@
pub mod dto;
pub mod entities;
pub mod vo;
+72
View File
@@ -0,0 +1,72 @@
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 role: String,
pub status: 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,
role: user_model.role,
status: user_model.status,
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,
}
+63
View File
@@ -0,0 +1,63 @@
use crate::domain::entities::{email, email_attachment};
use serde::Serialize;
/// 邮件列表项(摘要)
#[derive(Debug, Serialize)]
pub struct EmailSummary {
pub id: i64,
pub mail_from: String,
pub sender_name: Option<String>,
pub subject: Option<String>,
pub size_bytes: i64,
pub status: String,
pub received_at: String,
}
impl From<email::Model> for EmailSummary {
fn from(m: email::Model) -> Self {
Self {
id: m.id,
mail_from: m.mail_from,
sender_name: m.sender_name,
subject: m.subject,
size_bytes: m.size_bytes,
status: m.status,
received_at: m.received_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
}
}
}
/// 附件摘要
#[derive(Debug, Serialize)]
pub struct AttachmentSummary {
pub id: i64,
pub filename: Option<String>,
pub content_type: String,
pub size_bytes: i64,
}
impl From<email_attachment::Model> for AttachmentSummary {
fn from(a: email_attachment::Model) -> Self {
Self {
id: a.id,
filename: a.filename,
content_type: a.content_type,
size_bytes: a.size_bytes,
}
}
}
/// 邮件详情
#[derive(Debug, Serialize)]
pub struct EmailDetail {
pub id: i64,
pub mail_from: String,
pub sender_name: Option<String>,
pub subject: Option<String>,
pub text_body: Option<String>,
pub html_body: Option<String>,
pub size_bytes: i64,
pub status: String,
pub received_at: String,
pub attachments: Vec<AttachmentSummary>,
}
+42
View File
@@ -0,0 +1,42 @@
use crate::domain::entities::mailbox;
use serde::Serialize;
/// 邮箱视图
#[derive(Debug, Serialize)]
pub struct MailboxVO {
pub id: i64,
pub address: String,
pub local_part: String,
pub domain: String,
pub note: Option<String>,
pub status: String,
pub max_quota_bytes: i64,
pub used_bytes: i64,
pub expires_at: String,
pub created_at: String,
}
impl From<mailbox::Model> for MailboxVO {
fn from(m: mailbox::Model) -> Self {
Self {
id: m.id,
address: m.address,
local_part: m.local_part,
domain: m.domain,
note: m.note,
status: m.status,
max_quota_bytes: m.max_quota_bytes,
used_bytes: m.used_bytes,
expires_at: m.expires_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
created_at: m.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
}
}
}
/// 创建邮箱结果(含一次性返回的明文 access_token
#[derive(Debug, Serialize)]
pub struct CreateMailboxResult {
#[serde(flatten)]
pub mailbox: MailboxVO,
pub access_token: String,
}
+58
View File
@@ -0,0 +1,58 @@
pub mod auth;
pub mod email;
pub mod mailbox;
pub mod user;
use axum::http::StatusCode;
/// 统一的 API 响应结构
use serde::Serialize;
#[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
View File
@@ -0,0 +1 @@
// 用户相关 VO(预留)
+86
View File
@@ -0,0 +1,86 @@
use crate::domain::vo::ApiResponse;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
#[derive(Debug)]
pub struct ErrorResponse {
pub status: StatusCode,
pub message: String,
}
impl ErrorResponse {
pub fn new(message: impl Into<String>) -> Self {
Self::bad_request(message)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.into(),
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
pub fn too_many_requests(message: impl Into<String>) -> Self {
Self {
status: StatusCode::TOO_MANY_REQUESTS,
message: message.into(),
}
}
pub fn payload_too_large(message: impl Into<String>) -> Self {
Self {
status: StatusCode::PAYLOAD_TOO_LARGE,
message: message.into(),
}
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: message.into(),
}
}
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 {
(
self.status,
Json(ApiResponse::<()> {
code: self.status.as_u16(),
message: self.message,
data: None,
}),
)
.into_response()
}
}
+568
View File
@@ -0,0 +1,568 @@
//! 管理后台接口(RBACrequire_admin)。
use crate::domain::entities::{blocked_ip, blocked_sender, email, users};
use crate::domain::vo::ApiResponse;
use crate::error::ErrorResponse;
use crate::handlers::admin_governance::admin_audit;
use crate::infra::middleware::UserId;
use crate::repositories::{
blacklist_repository::BlacklistRepository, quota_repository::QuotaRepository,
};
use crate::services::{
credit_rule_service::{CreditRuleService, CreditRuleValues},
credit_service::CreditService,
};
use crate::AppState;
use axum::{
extract::{Path, Query, State},
Json,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
// ===== 用户管理 =====
#[derive(Deserialize)]
pub struct UpdateUserBody {
pub role: Option<String>,
pub status: Option<String>,
}
pub async fn update_user(
State(state): State<AppState>,
UserId(operator_id): UserId,
Path(id): Path<String>,
Json(body): Json<UpdateUserBody>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
if id == operator_id && (body.role.is_some() || body.status.is_some()) {
return Err(ErrorResponse::bad_request("不能修改当前登录账号"));
}
let u = users::Entity::find_by_id(&id)
.one(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?
.ok_or_else(|| ErrorResponse::not_found("user not found"))?;
let previous_role = u.role.clone();
let previous_status = u.status.clone();
let mut a: users::ActiveModel = u.into();
if let Some(r) = body.role {
if !matches!(r.as_str(), "user" | "admin") {
return Err(ErrorResponse::bad_request("invalid role"));
}
a.role = Set(r);
}
if let Some(s) = body.status {
if !matches!(s.as_str(), "active" | "suspended" | "banned") {
return Err(ErrorResponse::bad_request("invalid status"));
}
a.status = Set(s);
}
let updated = a
.update(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_user_change",
"update",
Some("单个用户属性修改"),
"user",
&id,
Some(serde_json::json!({
"before": {"role": previous_role, "status": previous_status},
"after": {"role": updated.role, "status": updated.status},
})),
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "updated")))
}
// ===== 黑名单:发件人 =====
pub async fn list_blocked_senders(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, ErrorResponse> {
let list = BlacklistRepository::new(state.pool.clone())
.list_senders()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let value: Vec<_> = list
.into_iter()
.map(|b| {
serde_json::json!({
"id": b.id, "kind": b.kind, "value": b.value,
"reason": b.reason, "source": b.source,
"expires_at": b.expires_at.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()),
})
})
.collect();
Ok(Json(ApiResponse::success(value)))
}
#[derive(Deserialize)]
pub struct AddBlockedSenderBody {
pub kind: String,
pub value: String,
#[serde(default)]
pub reason: String,
pub ttl_seconds: Option<i64>,
}
pub async fn add_blocked_sender(
State(state): State<AppState>,
UserId(operator_id): UserId,
Json(body): Json<AddBlockedSenderBody>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
let expires_at = body
.ttl_seconds
.map(|t| chrono::Utc::now().naive_utc() + chrono::Duration::seconds(t));
let reason = body.reason.clone();
let model = BlacklistRepository::new(state.pool.clone())
.add_sender(
body.kind,
body.value,
body.reason,
"manual".into(),
expires_at,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_blacklist_change",
"add",
Some(&reason),
"blocked_sender",
model.id,
None,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "added")))
}
pub async fn delete_blocked_sender(
State(state): State<AppState>,
UserId(operator_id): UserId,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
BlacklistRepository::new(state.pool.clone())
.delete_sender(id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_blacklist_change",
"remove",
Some("兼容接口解除黑名单"),
"blocked_sender",
id,
None,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "deleted")))
}
// ===== 黑名单:IP =====
pub async fn list_blocked_ips(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, ErrorResponse> {
let list = BlacklistRepository::new(state.pool.clone())
.list_ips()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let value: Vec<_> = list
.into_iter()
.map(|b| {
serde_json::json!({
"id": b.id, "ip": b.ip, "reason": b.reason, "source": b.source,
"expires_at": b.expires_at.map(|t| t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()),
})
})
.collect();
Ok(Json(ApiResponse::success(value)))
}
#[derive(Deserialize)]
pub struct AddBlockedIpBody {
pub ip: String,
#[serde(default)]
pub reason: String,
pub ttl_seconds: Option<i64>,
}
pub async fn add_blocked_ip(
State(state): State<AppState>,
UserId(operator_id): UserId,
Json(body): Json<AddBlockedIpBody>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
let expires_at = body
.ttl_seconds
.map(|t| chrono::Utc::now().naive_utc() + chrono::Duration::seconds(t));
let reason = body.reason.clone();
let model = BlacklistRepository::new(state.pool.clone())
.add_ip(body.ip, body.reason, "manual".into(), expires_at)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_blacklist_change",
"add",
Some(&reason),
"blocked_ip",
model.id,
None,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "added")))
}
pub async fn delete_blocked_ip(
State(state): State<AppState>,
UserId(operator_id): UserId,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
BlacklistRepository::new(state.pool.clone())
.delete_ip(id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_blacklist_change",
"remove",
Some("兼容接口解除黑名单"),
"blocked_ip",
id,
None,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "deleted")))
}
// ===== 统计 =====
pub async fn stats(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let user_count = users::Entity::find()
.filter(users::Column::DeletedAt.is_null())
.count(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let email_count = email::Entity::find()
.count(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let quarantined = email::Entity::find()
.filter(email::Column::Status.eq("quarantined"))
.count(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let blocked_senders = blocked_sender::Entity::find()
.count(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let blocked_ips = blocked_ip::Entity::find()
.count(&state.pool)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(serde_json::json!({
"users": user_count,
"emails": email_count,
"quarantined": quarantined,
"blocked_senders": blocked_senders,
"blocked_ips": blocked_ips,
}))))
}
// ===== 积分调整 =====
#[derive(Deserialize)]
pub struct AdjustCreditsBody {
pub user_id: String,
pub delta: i64,
#[serde(default)]
pub reason: String,
}
pub async fn adjust_credits(
State(state): State<AppState>,
UserId(operator_id): UserId,
Json(body): Json<AdjustCreditsBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let new_balance = CreditService::new(state.pool.clone())
.adjust(&body.user_id, body.delta, &body.reason, &operator_id)
.await
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
admin_audit(
&state,
&operator_id,
"admin_credit_adjust",
"adjust",
Some(&body.reason),
"user",
&body.user_id,
Some(serde_json::json!({"delta": body.delta, "balance_after": new_balance})),
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::json!({ "user_id": body.user_id, "balance": new_balance }),
)))
}
// ===== 积分规则 =====
#[derive(Deserialize)]
pub struct UpdateCreditRulesBody {
#[serde(flatten)]
pub values: CreditRuleValues,
pub expected_version: i64,
pub reason: String,
}
#[derive(Deserialize)]
pub struct ResetCreditRulesBody {
pub expected_version: i64,
pub reason: String,
}
pub async fn get_credit_settings(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let current = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(serde_json::json!({
"defaults": CreditRuleValues::default(),
"current": current,
"timezone": "Asia/Shanghai",
}))))
}
pub async fn update_credit_settings(
State(state): State<AppState>,
UserId(operator_id): UserId,
Json(body): Json<UpdateCreditRulesBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let result = CreditRuleService::new(state.pool.clone())
.update(
body.values,
body.expected_version,
&operator_id,
&body.reason,
)
.await
.map_err(|error| {
let message = error.to_string();
if message.contains("版本冲突") {
ErrorResponse::conflict(message)
} else {
ErrorResponse::bad_request(message)
}
})?;
admin_audit(
&state,
&operator_id,
"admin_credit_rule_change",
"update",
Some(&body.reason),
"credit_rule",
1,
Some(serde_json::json!({"version": result.version})),
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(serde_json::json!({
"current": result,
"defaults": CreditRuleValues::default(),
"timezone": "Asia/Shanghai",
}))))
}
pub async fn reset_credit_settings(
State(state): State<AppState>,
UserId(operator_id): UserId,
Json(body): Json<ResetCreditRulesBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let result = CreditRuleService::new(state.pool.clone())
.reset(body.expected_version, &operator_id, &body.reason)
.await
.map_err(|error| {
let message = error.to_string();
if message.contains("版本冲突") {
ErrorResponse::conflict(message)
} else {
ErrorResponse::bad_request(message)
}
})?;
admin_audit(
&state,
&operator_id,
"admin_credit_rule_change",
"reset",
Some(&body.reason),
"credit_rule",
1,
Some(serde_json::json!({"version": result.version})),
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(serde_json::json!({
"current": result,
"defaults": CreditRuleValues::default(),
"timezone": "Asia/Shanghai",
}))))
}
// ===== 隔离区放行 =====
#[derive(Deserialize)]
pub struct QuarantinedEmailsQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Serialize)]
pub struct QuarantinedEmailItem {
pub id: i64,
pub recipient: String,
pub mail_from: String,
pub subject: Option<String>,
pub abuse_score: i32,
pub size_bytes: i64,
pub received_at: String,
}
pub async fn list_quarantined_emails(
State(state): State<AppState>,
Query(query): Query<QuarantinedEmailsQuery>,
) -> Result<Json<ApiResponse<Vec<QuarantinedEmailItem>>>, ErrorResponse> {
let limit = query.limit.unwrap_or(50).clamp(1, 100);
let offset = query.offset.unwrap_or(0);
let items = email::Entity::find()
.filter(email::Column::Status.eq("quarantined"))
.order_by_desc(email::Column::ReceivedAt)
.offset(offset)
.limit(limit)
.all(&state.pool)
.await
.map_err(|error| ErrorResponse::internal(error.to_string()))?
.into_iter()
.map(|message| QuarantinedEmailItem {
id: message.id,
recipient: message.recipient,
mail_from: message.mail_from,
subject: message.subject,
abuse_score: message.abuse_score,
size_bytes: message.size_bytes,
received_at: message
.received_at
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string(),
})
.collect();
Ok(Json(ApiResponse::success(items)))
}
pub async fn release_email(
State(state): State<AppState>,
UserId(operator_id): UserId,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
let rules = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let txn = state
.pool
.begin()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let operation = async {
let message = email::Entity::find_by_id(id)
.one(&txn)
.await?
.ok_or_else(|| anyhow::anyhow!("邮件不存在"))?;
if message.status != "quarantined" {
anyhow::bail!("邮件不在隔离区");
}
QuotaRepository::reserve_email(
&txn,
&message.user_id,
message.size_bytes,
rules.daily_emails_limit,
)
.await?;
CreditService::new(state.pool.clone())
.debit_in_transaction(
&txn,
&message.user_id,
rules.receive_email_cost,
"receive_email",
Some(message.mailbox_id),
Some(message.id),
)
.await?;
let mut active: email::ActiveModel = message.clone().into();
active.status = Set("received".into());
active.update(&txn).await?;
Ok::<_, anyhow::Error>(message)
}
.await;
let message = match operation {
Ok(message) => {
txn.commit()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
message
}
Err(error) => {
if let Err(rollback_error) = txn.rollback().await {
tracing::error!(%rollback_error, "隔离邮件放行事务回滚失败");
}
return Err(ErrorResponse::bad_request(error.to_string()));
}
};
let payload = serde_json::json!({
"event": "new_email",
"data": {
"id": message.id,
"mail_from": message.mail_from,
"sender_name": message.sender_name,
"subject": message.subject,
"size_bytes": message.size_bytes,
"received_at": message.received_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
}
});
state
.mail_hub
.notify(&message.recipient, &payload.to_string());
admin_audit(
&state,
&operator_id,
"admin_email_action",
"release",
Some("单封隔离邮件放行"),
"email",
id,
None,
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "released")))
}
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
use crate::domain::dto::auth::{DeleteUserRequest, LoginRequest, RefreshRequest, RegisterRequest};
use crate::domain::vo::auth::{LoginResult, RefreshResult, RegisterResult};
use crate::domain::vo::ApiResponse;
use crate::error::ErrorResponse;
use crate::infra::middleware::logging::{log_info, RequestId};
use crate::infra::middleware::UserId;
use crate::repositories::user_repository::UserRepository;
use crate::services::auth_service::AuthService;
use crate::services::credit_rule_service::CreditRuleService;
use crate::AppState;
use axum::{
extract::{Extension, State},
Json,
};
use serde_json::json;
use validator::Validate;
/// 注册
pub async fn register(
Extension(request_id): Extension<RequestId>,
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<Json<ApiResponse<RegisterResult>>, ErrorResponse> {
payload
.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
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(),
);
let rules = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
match service.register(payload, rules.register_bonus).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> {
payload
.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
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>,
UserId(user_id): UserId,
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>,
UserId(user_id): UserId,
) -> 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()))
}
}
}
+137
View File
@@ -0,0 +1,137 @@
//! 用户查询自己的积分余额与流水。
use crate::{
domain::vo::ApiResponse,
error::ErrorResponse,
infra::middleware::UserId,
repositories::quota_repository::QuotaRepository,
services::{credit_rule_service::CreditRuleService, credit_service::CreditService},
AppState,
};
use axum::{extract::Query, extract::State, Json};
use serde::Deserialize;
pub async fn get_balance(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let svc = CreditService::new(state.pool.clone());
let rules = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let account = svc
.get_account(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let usage = QuotaRepository::new(state.pool.clone())
.get_today(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let balance = account.as_ref().map(|value| value.balance).unwrap_or(0);
let mailboxes_created = usage
.as_ref()
.map(|value| value.mailboxes_created)
.unwrap_or(0);
let emails_received = usage
.as_ref()
.map(|value| value.emails_received)
.unwrap_or(0);
let bytes_received = usage
.as_ref()
.map(|value| value.bytes_received)
.unwrap_or(0);
let check_in = svc
.checked_in_today(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(serde_json::json!({
"enabled": true,
"balance": balance,
"total_granted": account.as_ref().map(|value| value.total_granted).unwrap_or(0),
"total_consumed": account.as_ref().map(|value| value.total_consumed).unwrap_or(0),
"rule_version": rules.version,
"pricing": {
"create_mailbox": rules.create_mailbox_cost,
"receive_email": rules.receive_email_cost,
},
"daily_limits": {
"mailboxes": rules.daily_mailboxes_limit,
"emails": rules.daily_emails_limit,
},
"usage_today": {
"mailboxes_created": mailboxes_created,
"emails_received": emails_received,
"bytes_received": bytes_received,
},
"remaining_today": {
"mailboxes": (rules.daily_mailboxes_limit - mailboxes_created).max(0),
"emails": (rules.daily_emails_limit - emails_received).max(0),
},
"check_in": {
"claimed": check_in.is_some(),
"available": check_in.is_none(),
"reward": rules.daily_check_in_reward,
"reward_granted": check_in.as_ref().map(|value| value.reward_granted),
"reward_balance_cap": rules.reward_balance_cap,
"date": QuotaRepository::today().to_string(),
}
}))))
}
pub async fn check_in(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let rules = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let result = CreditService::new(state.pool.clone())
.check_in(
&user_id,
rules.daily_check_in_reward,
rules.reward_balance_cap,
)
.await
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::to_value(result).map_err(|e| ErrorResponse::internal(e.to_string()))?,
)))
}
#[derive(Deserialize)]
pub struct TxQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
}
pub async fn list_transactions(
State(state): State<AppState>,
UserId(user_id): UserId,
Query(q): Query<TxQuery>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, ErrorResponse> {
let svc = CreditService::new(state.pool.clone());
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = q.offset.unwrap_or(0);
let list = svc
.list_transactions(&user_id, limit, offset)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let value: Vec<_> = list
.into_iter()
.map(|t| {
serde_json::json!({
"id": t.id,
"delta": t.delta,
"balance_after": t.balance_after,
"reason": t.reason,
"related_mailbox_id": t.related_mailbox_id,
"related_email_id": t.related_email_id,
"description": t.description,
"created_at": t.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
})
})
.collect();
Ok(Json(ApiResponse::success(value)))
}
+196
View File
@@ -0,0 +1,196 @@
use crate::{
domain::vo::ApiResponse,
error::ErrorResponse,
infra::{
mail::{
mailer,
worker::{EmailJob, MAIL_QUEUE},
},
middleware::UserId,
redis::redis_key::{BusinessType, RedisKey},
},
repositories::{email_log_repository::EmailLogRepository, user_repository::UserRepository},
services::auth_service::AuthService,
AppState,
};
use axum::{extract::State, Json};
use rand::Rng;
use serde::Deserialize;
use serde_json::json;
use validator::{Validate, ValidationError};
#[derive(Debug, Deserialize, Validate)]
pub struct VerificationRequest {
#[validate(email)]
pub email: String,
}
fn valid_code(code: &str) -> Result<(), ValidationError> {
if code.len() == 6 && code.bytes().all(|v| v.is_ascii_digit()) {
Ok(())
} else {
Err(ValidationError::new("code"))
}
}
#[derive(Debug, Deserialize, Validate)]
pub struct ResetPasswordRequest {
#[validate(email)]
pub email: String,
#[validate(custom = "valid_code")]
pub code: String,
#[validate(length(min = 8, max = 128))]
pub new_password: String,
}
fn code_key(email: &str) -> RedisKey {
RedisKey::new(BusinessType::Auth)
.add_identifier("verify_code")
.add_identifier(email)
}
pub async fn send_verification_code(
State(state): State<AppState>,
Json(input): Json<VerificationRequest>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
input
.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
let redis = state
.redis_client
.as_ref()
.ok_or_else(|| ErrorResponse::unavailable("Redis service unavailable"))?;
let key = code_key(&input.email);
if redis
.exists_key(&key)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?
{
return Err(ErrorResponse::too_many_requests(
"verification code already sent",
));
}
let code = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000));
if state.config.email.queue_enabled {
redis
.set_key_ex(
&key,
&code,
state.config.email.verification_code_ttl_seconds,
)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?;
redis
.queue_push(
MAIL_QUEUE,
&EmailJob {
recipient: input.email,
code,
},
)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?;
return Ok(Json(ApiResponse::success_with_message(
(),
"verification email queued",
)));
}
let result = mailer::send_verification(&state.config.email, &input.email, &code).await;
EmailLogRepository::new(state.pool.clone())
.add(
None,
input.email.clone(),
if result.is_ok() { "sent" } else { "failed" }.into(),
result.as_ref().err().map(ToString::to_string),
)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
result.map_err(|e| ErrorResponse::unavailable(e.to_string()))?;
redis
.set_key_ex(
&key,
&code,
state.config.email.verification_code_ttl_seconds,
)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message(
(),
"verification code sent",
)))
}
pub async fn reset_password(
State(state): State<AppState>,
Json(input): Json<ResetPasswordRequest>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
input
.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
let redis = state
.redis_client
.as_ref()
.ok_or_else(|| ErrorResponse::unavailable("Redis service unavailable"))?;
let key = code_key(&input.email);
let stored = redis
.get_key(&key)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?
.ok_or_else(|| ErrorResponse::bad_request("verification code expired"))?;
if stored != input.code {
return Err(ErrorResponse::bad_request("invalid verification code"));
}
let repo = UserRepository::new(state.pool.clone());
repo.update_password_by_email(
&input.email,
AuthService::hash_password(&input.new_password)
.map_err(|e| ErrorResponse::internal(e.to_string()))?,
)
.await
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
redis
.delete_key(&key)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message(
(),
"password reset",
)))
}
pub async fn latest_log(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let value = EmailLogRepository::new(state.pool)
.latest(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::to_value(value).unwrap_or_default(),
)))
}
pub async fn queue_status(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let pending = if let Some(redis) = &state.redis_client {
redis
.queue_len(MAIL_QUEUE)
.await
.map_err(|e| ErrorResponse::unavailable(e.to_string()))?
} else {
0
};
Ok(Json(ApiResponse::success(
json!({"enabled": state.config.email.queue_enabled, "redis_available": state.redis_client.is_some(), "pending": pending, "workers": state.config.email.worker_pool_size}),
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_code() {
assert!(valid_code("123456").is_ok());
assert!(valid_code("12x").is_err());
}
}
+27
View File
@@ -0,0 +1,27 @@
use crate::db;
use crate::AppState;
use axum::{
extract::State,
response::{IntoResponse, Json},
};
use serde_json::json;
/// 健康检查端点
pub async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
let database = db::health_check(&state.pool).await.is_ok();
Json(
json!({"status": if database { "ok" } else { "unavailable" }, "capabilities": {
"database": database, "redis": state.redis_client.is_some(), "email": state.config.email.enabled
}}),
)
}
/// 获取服务器信息
pub async fn server_info() -> impl IntoResponse {
Json(json!({
"name": "email-unlimit-server",
"version": "0.1.0",
"status": "running",
"timestamp": chrono::Utc::now().timestamp()
}))
}
+226
View File
@@ -0,0 +1,226 @@
//! 临时邮箱与邮件 HTTP 接口 + WebSocket 实时推送。
//!
//! - 邮箱管理(创建/列表/吊销/轮换):用户 JWT
//! - 邮件查询(列表/详情/删除/附件/WS):邮箱级 access_tokenmailbox_auth_middleware
use crate::{
domain::{
dto::mailbox::CreateMailboxRequest,
vo::{
email::{EmailDetail, EmailSummary},
mailbox::{CreateMailboxResult, MailboxVO},
ApiResponse,
},
},
error::ErrorResponse,
infra::middleware::{MailboxContext, UserId},
services::{
credit_rule_service::CreditRuleService, email_service::EmailService,
mailbox_service::MailboxService,
},
smtp::MailHub,
AppState,
};
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
http::{HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use validator::Validate;
// ===== 邮箱管理(用户 JWT =====
pub async fn create_mailbox(
State(state): State<AppState>,
UserId(user_id): UserId,
Json(req): Json<CreateMailboxRequest>,
) -> Result<Json<ApiResponse<CreateMailboxResult>>, ErrorResponse> {
req.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
let svc = MailboxService::new(state.pool.clone(), state.config.smtp.clone());
let rules = CreditRuleService::new(state.pool.clone())
.get()
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
let result = svc
.create_with_billing(&user_id, req, &rules)
.await
.map_err(|e| {
let message = e.to_string();
if message.contains("limit reached") || message.contains("积分不足") {
ErrorResponse::too_many_requests(message)
} else {
ErrorResponse::bad_request(message)
}
})?;
Ok(Json(ApiResponse::success(result)))
}
pub async fn list_mailboxes(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<Vec<MailboxVO>>>, ErrorResponse> {
let svc = MailboxService::new(state.pool.clone(), state.config.smtp.clone());
let list = svc
.list_by_user(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(list)))
}
pub async fn delete_mailbox(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
let svc = MailboxService::new(state.pool.clone(), state.config.smtp.clone());
svc.revoke(&user_id, id)
.await
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message(
(),
"mailbox revoked",
)))
}
pub async fn rotate_token(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let svc = MailboxService::new(state.pool.clone(), state.config.smtp.clone());
let token = svc
.rotate_token(&user_id, id)
.await
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::json!({ "access_token": token }),
)))
}
// ===== 邮件查询(邮箱 access_token =====
#[derive(Deserialize)]
pub struct ListEmailsQuery {
pub limit: Option<u64>,
pub offset: Option<u64>,
}
pub async fn list_emails(
State(state): State<AppState>,
ctx: MailboxContext,
Path(id): Path<i64>,
Query(q): Query<ListEmailsQuery>,
) -> Result<Json<ApiResponse<Vec<EmailSummary>>>, ErrorResponse> {
if ctx.id != id {
return Err(ErrorResponse::unauthorized("mailbox mismatch"));
}
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = q.offset.unwrap_or(0);
let svc = EmailService::new(state.pool.clone(), state.config.smtp.clone());
let list = svc
.list_by_mailbox(id, limit, offset)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(list)))
}
pub async fn get_email(
State(state): State<AppState>,
ctx: MailboxContext,
Path((id, eid)): Path<(i64, i64)>,
) -> Result<Json<ApiResponse<EmailDetail>>, ErrorResponse> {
if ctx.id != id {
return Err(ErrorResponse::unauthorized("mailbox mismatch"));
}
let svc = EmailService::new(state.pool.clone(), state.config.smtp.clone());
let detail = svc
.get_detail(id, eid)
.await
.map_err(|e| ErrorResponse::not_found(e.to_string()))?;
Ok(Json(ApiResponse::success(detail)))
}
pub async fn delete_email(
State(state): State<AppState>,
ctx: MailboxContext,
Path((id, eid)): Path<(i64, i64)>,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
if ctx.id != id {
return Err(ErrorResponse::unauthorized("mailbox mismatch"));
}
let svc = EmailService::new(state.pool.clone(), state.config.smtp.clone());
svc.delete(id, eid)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message((), "email deleted")))
}
pub async fn get_attachment(
State(state): State<AppState>,
ctx: MailboxContext,
Path((id, eid, aid)): Path<(i64, i64, i64)>,
) -> Result<Response, ErrorResponse> {
if ctx.id != id {
return Err(ErrorResponse::unauthorized("mailbox mismatch"));
}
let svc = EmailService::new(state.pool.clone(), state.config.smtp.clone());
let (filename, content_type, content) = svc
.get_attachment(id, eid, aid)
.await
.map_err(|e| ErrorResponse::not_found(e.to_string()))?;
let mut resp = (StatusCode::OK, content).into_response();
let headers = resp.headers_mut();
headers.insert(
axum::http::header::CONTENT_TYPE,
content_type
.parse()
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
let cd = format!(
"attachment; filename=\"{}\"",
filename.unwrap_or_else(|| "attachment".into())
);
if let Ok(v) = cd.parse() {
headers.insert(axum::http::header::CONTENT_DISPOSITION, v);
}
Ok(resp)
}
// ===== WebSocket 实时推送(邮箱 access_token?token= =====
pub async fn ws_subscribe(
State(state): State<AppState>,
ctx: MailboxContext,
Path(id): Path<i64>,
ws: WebSocketUpgrade,
) -> Response {
if ctx.id != id {
return ErrorResponse::unauthorized("mailbox mismatch").into_response();
}
let hub = state.mail_hub.clone();
ws.on_upgrade(move |socket| handle_ws(socket, hub, ctx.address))
}
async fn handle_ws(mut socket: WebSocket, hub: MailHub, address: String) {
let mut rx = hub.subscribe(&address);
loop {
match rx.recv().await {
Ok(payload) => {
if socket.send(Message::Text(payload)).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
}
}
hub.cleanup(&address);
}
+8
View File
@@ -0,0 +1,8 @@
pub mod admin;
pub mod admin_governance;
pub mod auth;
pub mod credit;
pub mod email;
pub mod health;
pub mod mailbox;
pub mod user_profile;
+51
View File
@@ -0,0 +1,51 @@
use crate::{
domain::{dto::user::UpdateProfileRequest, vo::ApiResponse},
error::ErrorResponse,
infra::middleware::UserId,
repositories::user_profile_repository::UserProfileRepository,
AppState,
};
use axum::{extract::State, Json};
use validator::Validate;
pub async fn get_profile(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
let value = UserProfileRepository::new(state.pool)
.get(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::to_value(value).unwrap_or_default(),
)))
}
pub async fn update_profile(
State(state): State<AppState>,
UserId(user_id): UserId,
Json(input): Json<UpdateProfileRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, ErrorResponse> {
input
.validate()
.map_err(|e| ErrorResponse::bad_request(e.to_string()))?;
let value = UserProfileRepository::new(state.pool)
.upsert(user_id, input)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success(
serde_json::to_value(value).unwrap_or_default(),
)))
}
pub async fn delete_profile(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<ApiResponse<()>>, ErrorResponse> {
UserProfileRepository::new(state.pool)
.delete(&user_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
Ok(Json(ApiResponse::success_with_message(
(),
"profile deleted",
)))
}
+1
View File
@@ -0,0 +1 @@
pub mod spf;
+195
View File
@@ -0,0 +1,195 @@
//! 邮件投递安全:轻量 SPF 校验、DKIM 头检测与 DMARC 策略发现。
use hickory_resolver::{config::ResolverConfig, config::ResolverOpts, TokioAsyncResolver};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::OnceLock;
static RESOLVER: OnceLock<TokioAsyncResolver> = std::sync::OnceLock::new();
/// 共享 DNS 解析器(SPF / PTR 复用)。
pub fn shared_resolver() -> &'static TokioAsyncResolver {
RESOLVER.get_or_init(|| {
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpfResult {
Pass,
Fail,
SoftFail,
Neutral,
None,
}
impl SpfResult {
pub fn as_str(&self) -> &'static str {
match self {
SpfResult::Pass => "pass",
SpfResult::Fail => "fail",
SpfResult::SoftFail => "softfail",
SpfResult::Neutral => "neutral",
SpfResult::None => "none",
}
}
}
/// SPF 校验(简化版:仅 ip4/ip6/all 机制)。
pub async fn check_spf(ip: IpAddr, helo_or_domain: &str, mail_from: &str) -> SpfResult {
let domain = extract_domain(mail_from).unwrap_or(helo_or_domain);
if domain.is_empty() {
return SpfResult::None;
}
let resolver = shared_resolver();
let txt = match resolver.txt_lookup(domain).await {
Ok(t) => t,
Err(_) => return SpfResult::None,
};
// 拼接所有 TXT 记录文本,定位 v=spf1 记录
let mut record_str: Option<String> = None;
for r in txt.into_iter() {
let s = r.to_string();
if s.contains("v=spf1") {
record_str = Some(s);
break;
}
}
let record = match record_str {
Some(r) => r,
None => return SpfResult::None,
};
let cleaned = record.replace('"', " ");
let idx = match cleaned.find("v=spf1") {
Some(i) => i,
None => return SpfResult::None,
};
let spf_part = &cleaned[idx..];
for term in spf_part.split_whitespace().skip(1) {
let (qual, mech) = split_qual(term);
let matched = match mech {
"all" => true,
m if m.starts_with("ip4:") => match_ip4(ip, &m[4..]),
m if m.starts_with("ip6:") => match_ip6(ip, &m[4..]),
_ => false, // a/mx/include/redirect 尚未解析
};
if matched {
return match qual {
'+' => SpfResult::Pass,
'-' => SpfResult::Fail,
'~' => SpfResult::SoftFail,
'?' => SpfResult::Neutral,
_ => SpfResult::Pass,
};
}
}
SpfResult::Neutral
}
/// 检测 DKIM-Signature 头是否存在(轻量;不验证签名本身,真实验签留 mail-auth)。
pub fn detect_dkim_signature(raw: &[u8]) -> bool {
let Ok(s) = std::str::from_utf8(raw) else {
return false;
};
s.lines().take_while(|l| !l.is_empty()).any(|l| {
l.trim_start()
.to_ascii_lowercase()
.starts_with("dkim-signature:")
})
}
/// 查询发件域 DMARC 策略(只表示策略存在,不表示 DMARC 验证通过)。
pub async fn query_dmarc(resolver: &TokioAsyncResolver, domain: &str) -> &'static str {
let dmarc_domain = format!("_dmarc.{domain}");
match resolver.txt_lookup(&dmarc_domain).await {
Ok(t) => {
if t.into_iter().next().is_some() {
"policy_present"
} else {
"none"
}
}
Err(_) => "none",
}
}
fn extract_domain(addr: &str) -> Option<&str> {
let a = addr.trim_matches(|c| c == '<' || c == '>' || c == ' ');
a.rsplit_once('@').map(|(_, d)| d)
}
fn split_qual(term: &str) -> (char, &str) {
match term.chars().next() {
Some(c @ ('+' | '-' | '~' | '?')) => (c, &term[1..]),
_ => ('+', term),
}
}
fn match_ip4(ip: IpAddr, spec: &str) -> bool {
let ip = match ip {
IpAddr::V4(v) => v,
_ => return false,
};
let (addr_str, prefix) = match spec.split_once('/') {
Some((a, p)) => (a, p.parse::<u8>().unwrap_or(32)),
None => (spec, 32),
};
let net: Ipv4Addr = match addr_str.parse() {
Ok(a) => a,
Err(_) => return false,
};
if prefix == 0 {
return true;
}
let mask: u32 = if prefix >= 32 {
!0u32
} else {
(!0u32) << (32 - prefix)
};
(u32::from(ip) & mask) == (u32::from(net) & mask)
}
fn match_ip6(ip: IpAddr, spec: &str) -> bool {
let ip = match ip {
IpAddr::V6(v) => v,
_ => return false,
};
let (addr_str, prefix) = match spec.split_once('/') {
Some((a, p)) => (a, p.parse::<u8>().unwrap_or(128)),
None => (spec, 128),
};
let net: Ipv6Addr = match addr_str.parse() {
Ok(a) => a,
Err(_) => return false,
};
if prefix == 0 {
return true;
}
let mask: u128 = if prefix >= 128 {
!0u128
} else {
(!0u128) << (128 - prefix)
};
(u128::from(ip) & mask) == (u128::from(net) & mask)
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn ip4_cidr_matching() {
let ip = IpAddr::V4(Ipv4Addr::from_str("192.168.1.5").unwrap());
assert!(match_ip4(ip, "192.168.1.0/24"));
assert!(match_ip4(ip, "192.168.1.5"));
assert!(!match_ip4(ip, "10.0.0.0/8"));
assert!(match_ip4(ip, "0.0.0.0/0"));
}
#[test]
fn qualifier_parsing() {
assert_eq!(split_qual("-all"), ('-', "all"));
assert_eq!(split_qual("~all"), ('~', "all"));
assert_eq!(split_qual("ip4:1.2.3.4"), ('+', "ip4:1.2.3.4"));
}
}
+23
View File
@@ -0,0 +1,23 @@
use crate::config::email::EmailConfig;
use anyhow::Result;
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
AsyncTransport, Message, Tokio1Executor,
};
pub async fn send_verification(config: &EmailConfig, recipient: &str, code: &str) -> Result<()> {
let email = Message::builder()
.from(format!("{} <{}>", config.from_name, config.from_email).parse::<Mailbox>()?)
.to(recipient.parse::<Mailbox>()?)
.subject("Verification code")
.body(format!(
"Your verification code is {code}. It expires soon."
))?;
let credentials = Credentials::new(config.smtp_username.clone(), config.smtp_password.clone());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)?
.port(config.smtp_port)
.credentials(credentials)
.build();
mailer.send(email).await?;
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
pub mod mailer;
pub mod worker;
+50
View File
@@ -0,0 +1,50 @@
use crate::{
config::email::EmailConfig,
db::DbPool,
infra::{mail::mailer, redis::redis_client::RedisClient},
repositories::email_log_repository::EmailLogRepository,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub const MAIL_QUEUE: &str = "mail:verification:queue";
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailJob {
pub recipient: String,
pub code: String,
}
pub fn start(redis: RedisClient, config: EmailConfig, pool: DbPool) {
for worker_id in 0..config.worker_pool_size.max(1) {
let redis = redis.clone();
let config = config.clone();
let pool = pool.clone();
tokio::spawn(async move {
loop {
match redis.queue_pop::<EmailJob>(MAIL_QUEUE).await {
Ok(Some(job)) => {
let result =
mailer::send_verification(&config, &job.recipient, &job.code).await;
if let Err(error) = EmailLogRepository::new(pool.clone())
.add(
None,
job.recipient,
if result.is_ok() { "sent" } else { "failed" }.into(),
result.err().map(|e| e.to_string()),
)
.await
{
tracing::error!(worker_id, %error, "failed to persist email log");
}
}
Ok(None) => tokio::time::sleep(Duration::from_millis(500)).await,
Err(error) => {
tracing::warn!(worker_id, %error, "mail queue unavailable");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
});
}
}
+31
View File
@@ -0,0 +1,31 @@
//! 管理员鉴权中间件(在 auth_middleware 之后):校验用户状态与管理员角色。
use crate::{
error::ErrorResponse, infra::middleware::UserId, repositories::user_repository::UserRepository,
AppState,
};
use axum::{extract::Request, extract::State, middleware::Next, response::Response};
pub async fn require_admin(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, ErrorResponse> {
let user_id = req
.extensions()
.get::<UserId>()
.cloned()
.ok_or_else(|| ErrorResponse::unauthorized("not authenticated"))?;
let user = UserRepository::new(state.pool.clone())
.find_by_id_raw(&user_id.0)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?
.ok_or_else(|| ErrorResponse::unauthorized("user not found"))?;
if user.status != "active" {
return Err(ErrorResponse::forbidden("account is not active"));
}
if user.role != "admin" {
return Err(ErrorResponse::forbidden("admin privilege required"));
}
Ok(next.run(req).await)
}
+101
View File
@@ -0,0 +1,101 @@
use crate::{
error::ErrorResponse,
infra::middleware::{Language, UserId},
repositories::user_repository::UserRepository,
utils::{
i18n::{message, ZH_CN},
jwt::TokenType,
},
AppState,
};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Claims {
pub sub: String,
#[allow(dead_code)]
pub exp: usize,
pub token_type: TokenType,
}
pub async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, ErrorResponse> {
let language = req
.extensions()
.get::<Language>()
.map(|v| v.0.as_str())
.unwrap_or(ZH_CN);
let header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
ErrorResponse::unauthorized(message(
language,
"缺少认证请求头",
"Missing authorization header",
))
})?;
let token = header
.strip_prefix("Bearer ")
.filter(|v| !v.is_empty())
.ok_or_else(|| {
ErrorResponse::unauthorized(message(
language,
"认证格式无效",
"Invalid authorization format",
))
})?;
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| {
ErrorResponse::unauthorized(message(
language,
"令牌无效或已过期",
"Token is invalid or expired",
))
})?
.claims;
if claims.token_type != TokenType::Access {
return Err(ErrorResponse::unauthorized(message(
language,
"令牌类型无效",
"Invalid token type",
)));
}
let user = UserRepository::new(state.pool.clone())
.find_by_id_raw(&claims.sub)
.await
.map_err(|_| {
ErrorResponse::internal(message(language, "验证用户失败", "Failed to verify user"))
})?;
let user = user.filter(|value| value.deleted_at.is_none());
let Some(user) = user else {
return Err(ErrorResponse::unauthorized(message(
language,
"用户不存在或已删除",
"User not found or deleted",
)));
};
if user.status != "active" {
return Err(ErrorResponse::forbidden(message(
language,
"账号已被停用",
"Account is not active",
)));
}
req.extensions_mut().insert(UserId(claims.sub));
Ok(next.run(req).await)
}
+19
View File
@@ -0,0 +1,19 @@
use crate::error::ErrorResponse;
use axum::{
body::HttpBody,
extract::{Request, State},
middleware::Next,
response::{IntoResponse, Response},
};
pub async fn enforce_body_limit(State(limit): State<usize>, req: Request, next: Next) -> Response {
if req
.body()
.size_hint()
.upper()
.is_some_and(|size| size > limit as u64)
{
return ErrorResponse::payload_too_large("request body too large").into_response();
}
next.run(req).await
}
+43
View File
@@ -0,0 +1,43 @@
use crate::utils::i18n::{EN, ZH_CN};
use async_trait::async_trait;
use axum::{
extract::{FromRequestParts, Request},
http::{request::Parts, StatusCode},
middleware::Next,
response::Response,
};
use std::ops::Deref;
#[derive(Debug, Clone)]
pub struct Language(pub String);
impl Deref for Language {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for Language {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts.extensions.get::<Language>().cloned().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"language context missing",
))
}
}
pub async fn language_middleware(mut req: Request, next: Next) -> Response {
let language = req
.headers()
.get("Accept-Language")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.filter(|v| *v == ZH_CN || *v == EN)
.unwrap_or(ZH_CN)
.to_string();
req.extensions_mut().insert(Language(language));
next.run(req).await
}
+68
View File
@@ -0,0 +1,68 @@
use axum::{
body::{to_bytes, Body, Bytes},
extract::Request,
middleware::Next,
response::Response,
};
use std::time::Instant;
#[derive(Clone, Debug)]
pub struct RequestId(pub String);
pub fn truncate_string(value: &str, max: usize) -> String {
if value.chars().count() > max {
value.chars().take(max).collect::<String>() + "....."
} else {
value.to_string()
}
}
fn truncate_json(value: &mut serde_json::Value, max: usize) {
match value {
serde_json::Value::String(v) => *v = truncate_string(v, max),
serde_json::Value::Array(v) => v.iter_mut().for_each(|v| truncate_json(v, max)),
serde_json::Value::Object(v) => v.values_mut().for_each(|v| truncate_json(v, max)),
_ => {}
}
}
fn pretty(bytes: &Bytes) -> String {
let raw = String::from_utf8_lossy(bytes);
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(mut value) => {
truncate_json(&mut value, 50);
serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.into_owned())
}
Err(_) => truncate_string(&raw, 50),
}
}
pub async fn request_logging_middleware(mut req: Request<Body>, next: Next) -> Response {
let started = Instant::now();
let id = uuid::Uuid::new_v4().to_string();
let method = req.method().clone();
let uri = req.uri().clone();
req.extensions_mut().insert(RequestId(id.clone()));
let (parts, body) = req.into_parts();
let request_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
tracing::info!(request_id=%id, %method, %uri, body=%pretty(&request_bytes), "request started");
let response = next
.run(Request::from_parts(parts, Body::from(request_bytes)))
.await;
let status = response.status();
let (parts, body) = response.into_parts();
let response_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
tracing::info!(request_id=%id, status=%status, elapsed_ms=started.elapsed().as_millis(), body=%pretty(&response_bytes), "request completed");
Response::from_parts(parts, Body::from(response_bytes))
}
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
tracing::info!(request_id=%request_id.0, %label, data=?data);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncates_at_character_boundary() {
assert_eq!(truncate_string("中文测试", 2), "中文.....");
}
}
@@ -0,0 +1,94 @@
//! 邮箱级 access_token 鉴权中间件。
//!
//! 从 `Authorization: Bearer <token>` 或 `?token=` 提取 mailbox token
//! 解析出 mailbox_id + secret,查库比对 hash,校验状态/有效期,
//! 通过后注入 [`MailboxContext`]。
use crate::{
error::ErrorResponse, repositories::mailbox_repository::MailboxRepository,
utils::mailbox_token, AppState,
};
use async_trait::async_trait;
use axum::extract::{FromRequestParts, Request, State};
use axum::http::request::Parts;
use axum::middleware::Next;
use axum::response::Response;
/// 当前请求解析出的邮箱上下文。
#[derive(Debug, Clone)]
pub struct MailboxContext {
pub id: i64,
pub address: String,
#[allow(dead_code)]
pub user_id: String,
}
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for MailboxContext {
type Rejection = ErrorResponse;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<MailboxContext>()
.cloned()
.ok_or_else(|| ErrorResponse::unauthorized("mailbox context not found"))
}
}
pub async fn mailbox_auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, ErrorResponse> {
let token =
extract_token(&req).ok_or_else(|| ErrorResponse::unauthorized("missing mailbox token"))?;
let (mailbox_id, secret) = mailbox_token::parse_token(&token)
.ok_or_else(|| ErrorResponse::unauthorized("invalid mailbox token"))?;
let m = MailboxRepository::new(state.pool.clone())
.find_by_id(mailbox_id)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?
.ok_or_else(|| ErrorResponse::unauthorized("mailbox not found"))?;
if m.status != "active" {
return Err(ErrorResponse::unauthorized("mailbox revoked"));
}
if chrono::Utc::now().naive_utc() > m.expires_at {
return Err(ErrorResponse::unauthorized("mailbox expired"));
}
if m.access_token_hash != mailbox_token::hash_token(&secret) {
return Err(ErrorResponse::unauthorized("invalid mailbox token"));
}
req.extensions_mut().insert(MailboxContext {
id: m.id,
address: m.address,
user_id: m.user_id,
});
Ok(next.run(req).await)
}
fn extract_token(req: &Request) -> Option<String> {
if let Some(h) = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
if let Some(t) = h.strip_prefix("Bearer ") {
if !t.is_empty() {
return Some(t.to_string());
}
}
}
req.uri().query().and_then(|q| {
q.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k == "token" {
Some(v.to_string())
} else {
None
}
})
})
}
+13
View File
@@ -0,0 +1,13 @@
pub mod admin;
pub mod auth;
pub mod body_limit;
pub mod language;
pub mod logging;
pub mod mailbox_auth;
pub mod rate_limit;
pub mod security;
pub mod user_id;
pub use language::Language;
pub use mailbox_auth::MailboxContext;
pub use user_id::UserId;
+64
View File
@@ -0,0 +1,64 @@
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct RateLimiter {
limit: u32,
entries: Arc<Mutex<HashMap<String, (Instant, u32)>>>,
}
impl RateLimiter {
pub fn new(limit: u32) -> Self {
Self {
limit,
entries: Arc::new(Mutex::new(HashMap::new())),
}
}
async fn allow(&self, key: String) -> bool {
let mut entries = self.entries.lock().await;
let value = entries.entry(key).or_insert((Instant::now(), 0));
if value.0.elapsed() >= Duration::from_secs(60) {
*value = (Instant::now(), 0);
}
if value.1 >= self.limit {
false
} else {
value.1 += 1;
true
}
}
}
pub async fn rate_limit_middleware(
State(limiter): State<RateLimiter>,
req: Request,
next: Next,
) -> Response {
let key = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|v| v.0.ip().to_string())
.or_else(|| {
req.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".into());
if limiter.allow(key).await {
next.run(req).await
} else {
(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response()
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::error::ErrorResponse;
use axum::response::IntoResponse;
use axum::{
extract::Request,
http::{
header::{HeaderName, HeaderValue},
StatusCode,
},
middleware::Next,
response::Response,
};
pub async fn security_headers(req: Request, next: Next) -> Response {
let mut response = next.run(req).await;
let headers = response.headers_mut();
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
response
}
pub async fn fallback_404() -> Response {
ErrorResponse::not_found("route not found").into_response()
}
pub async fn fallback_405() -> Response {
ErrorResponse {
status: StatusCode::METHOD_NOT_ALLOWED,
message: "method not allowed".into(),
}
.into_response()
}
+28
View File
@@ -0,0 +1,28 @@
use crate::{
error::ErrorResponse,
infra::middleware::Language,
utils::i18n::{message, ZH_CN},
};
use async_trait::async_trait;
use axum::extract::FromRequestParts;
#[derive(Debug, Clone)]
pub struct UserId(pub String);
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for UserId {
type Rejection = ErrorResponse;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_: &S,
) -> Result<Self, Self::Rejection> {
let lang = parts
.extensions
.get::<Language>()
.map(|v| v.0.as_str())
.unwrap_or(ZH_CN);
parts.extensions.get::<UserId>().cloned().ok_or_else(|| {
ErrorResponse::unauthorized(message(lang, "未找到用户身份", "User identity not found"))
})
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod mail;
pub mod middleware;
pub mod redis;
+20
View 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
View File
@@ -0,0 +1,2 @@
pub mod redis_client;
pub mod redis_key;
+262
View File
@@ -0,0 +1,262 @@
use super::redis_key::{namespaced_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 ping(&self) -> redis::RedisResult<()> {
let mut connection = self.conn.lock().await;
redis::cmd("PING")
.query_async::<String>(&mut *connection)
.await
.map(|_| ())
}
/// 设置字符串值
pub async fn set(&self, k: &str, v: &str) -> redis::RedisResult<()> {
let key = namespaced_key(k);
let mut c = self.conn.lock().await;
c.set(key, v).await
}
/// 获取字符串值
pub async fn get(&self, k: &str) -> redis::RedisResult<Option<String>> {
let key = namespaced_key(k);
let mut c = self.conn.lock().await;
c.get(key).await
}
/// 设置字符串值并指定过期时间(秒)
pub async fn set_ex(&self, k: &str, v: &str, seconds: u64) -> redis::RedisResult<()> {
let key = namespaced_key(k);
let mut c = self.conn.lock().await;
c.set_ex(key, v, seconds).await
}
/// 删除键
pub async fn del(&self, k: &str) -> redis::RedisResult<()> {
let key = namespaced_key(k);
let mut c = self.conn.lock().await;
c.del(key).await
}
/// 设置键的过期时间(秒)
pub async fn expire(&self, k: &str, seconds: u64) -> redis::RedisResult<()> {
let key = namespaced_key(k);
let mut c = self.conn.lock().await;
c.expire(key, 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
}
pub async fn queue_push<T: Serialize>(&self, key: &str, 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 key = namespaced_key(key);
let mut connection = self.conn.lock().await;
connection.lpush(key, json).await
}
pub async fn queue_pop<T: for<'de> serde::Deserialize<'de>>(
&self,
key: &str,
) -> redis::RedisResult<Option<T>> {
let key = namespaced_key(key);
let mut connection = self.conn.lock().await;
let value: Option<String> = connection.rpop(key, None).await?;
value
.map(|json| {
serde_json::from_str(&json).map_err(|e| {
redis::RedisError::from((
redis::ErrorKind::TypeError,
"JSON deserialization failed",
e.to_string(),
))
})
})
.transpose()
}
pub async fn queue_len(&self, key: &str) -> redis::RedisResult<usize> {
let key = namespaced_key(key);
let mut connection = self.conn.lock().await;
connection.llen(key).await
}
/// 滑动窗口限流(Lua 原子脚本)。
/// 返回 `true` 表示允许通过,`false` 表示窗口内已达上限。
///
/// - `key`: 限流键
/// - `max`: 窗口内允许的最大次数
/// - `window_ms`: 窗口大小(毫秒)
/// - `ttl_s`: 键过期时间(秒,应 >= window_ms/1000
pub async fn sliding_window_allow(
&self,
key: &str,
max: u32,
window_ms: u64,
ttl_s: u64,
) -> redis::RedisResult<bool> {
const SCRIPT: &str = r#"
local now = tonumber(ARGV[1])
local win = tonumber(ARGV[2])
local maxc = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - win)
local c = redis.call('ZCARD', KEYS[1])
if c >= maxc then return 0 end
redis.call('ZADD', KEYS[1], now, ARGV[5])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return 1
"#;
let now_ms = chrono::Utc::now().timestamp_millis();
let unique = uuid::Uuid::new_v4().to_string();
let key = namespaced_key(key);
let mut c = self.conn.lock().await;
let res: i64 = redis::cmd("EVAL")
.arg(SCRIPT)
.arg(1i64)
.arg(key)
.arg(now_ms)
.arg(window_ms as i64)
.arg(max as i64)
.arg(ttl_s as i64)
.arg(unique)
.query_async(&mut *c)
.await?;
Ok(res == 1)
}
/// 向 SET 添加成员(可选 TTL)
pub async fn sadd(
&self,
key: &str,
member: &str,
ttl_s: Option<u64>,
) -> redis::RedisResult<()> {
let key = namespaced_key(key);
let mut c = self.conn.lock().await;
let _: () = c.sadd(&key, member).await?;
if let Some(ttl) = ttl_s {
let _: () = c.expire(key, ttl as i64).await?;
}
Ok(())
}
/// 判断成员是否在 SET 中
pub async fn sismember(&self, key: &str, member: &str) -> redis::RedisResult<bool> {
let key = namespaced_key(key);
let mut c = self.conn.lock().await;
let n: i64 = c.sismember(key, member).await?;
Ok(n == 1)
}
/// 自增计数器(首次创建时设置 TTL),用于滥用计数
pub async fn incr_with_ttl(&self, key: &str, ttl_s: u64) -> redis::RedisResult<i64> {
let key = namespaced_key(key);
let mut c = self.conn.lock().await;
let n: i64 = c.incr(&key, 1i64).await?;
if n == 1 {
let _: () = c.expire(key, ttl_s as i64).await?;
}
Ok(n)
}
}
+123
View File
@@ -0,0 +1,123 @@
use serde::{Deserialize, Serialize};
use std::fmt;
pub const PROJECT_KEY_PREFIX: &str = "email-unlimit";
pub fn namespaced_key(key: &str) -> String {
if key == PROJECT_KEY_PREFIX
|| key
.strip_prefix(PROJECT_KEY_PREFIX)
.is_some_and(|suffix| suffix.starts_with(':'))
{
key.to_owned()
} else {
format!("{PROJECT_KEY_PREFIX}:{key}")
}
}
/// 业务类型枚举
#[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,
#[serde(rename = "smtp")]
Smtp,
#[serde(rename = "greylist")]
Greylist,
#[serde(rename = "blacklist")]
Blacklist,
#[serde(rename = "abuse")]
Abuse,
#[serde(rename = "mailbox")]
Mailbox,
}
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",
BusinessType::Smtp => "smtp",
BusinessType::Greylist => "greylist",
BusinessType::Blacklist => "blacklist",
BusinessType::Abuse => "abuse",
BusinessType::Mailbox => "mailbox",
}
}
}
/// 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 {
let business_key = if self.identifiers.is_empty() {
self.business.prefix().to_owned()
} else {
format!("{}:{}", self.business.prefix(), self.identifiers.join(":"))
};
namespaced_key(&business_key)
}
}
impl fmt::Display for RedisKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.build())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefixes_structured_keys_with_project_name() {
let key = RedisKey::new(BusinessType::Auth)
.add_identifier("refresh_token")
.add_identifier("user-1");
assert_eq!(key.build(), "email-unlimit:auth:refresh_token:user-1");
assert_eq!(
RedisKey::new(BusinessType::Cache).build(),
"email-unlimit:cache"
);
}
#[test]
fn prefixes_raw_keys_once() {
assert_eq!(
namespaced_key("smtp:rl:ip:127.0.0.1"),
"email-unlimit:smtp:rl:ip:127.0.0.1"
);
assert_eq!(
namespaced_key("email-unlimit:mail:verification:queue"),
"email-unlimit:mail:verification:queue"
);
}
}
+804
View File
@@ -0,0 +1,804 @@
#![recursion_limit = "512"]
mod cli;
mod config;
mod db;
mod domain;
mod error;
mod handlers;
mod infra;
mod repositories;
mod runtime_health;
mod services;
mod smtp;
mod utils;
mod workers;
use axum::{
http::{HeaderValue, Method},
middleware,
routing::{delete, get, patch, post},
Router,
};
use clap::Parser;
use cli::CliArgs;
use std::time::Duration;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::{
catch_panic::CatchPanicLayer, cors::CorsLayer, limit::RequestBodyLimitLayer,
timeout::TimeoutLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
pub struct AppState {
pub pool: db::DbPool,
pub config: config::app::AppConfig,
pub redis_client: Option<infra::redis::redis_client::RedisClient>,
pub mail_hub: smtp::MailHub,
pub runtime_health: runtime_health::RuntimeHealth,
}
fn cors_layer(config: &config::server::ServerConfig) -> anyhow::Result<CorsLayer> {
let origins = config
.cors_origins
.iter()
.map(|v| HeaderValue::from_str(v))
.collect::<Result<Vec<_>, _>>()?;
Ok(CorsLayer::new()
.allow_origin(origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
])
.allow_headers(tower_http::cors::Any))
}
fn build_router(state: AppState) -> anyhow::Result<Router> {
let mut public = 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));
if state.config.email.enabled {
public = public
.route(
"/auth/request-verification-code",
post(handlers::email::send_verification_code),
)
.route(
"/auth/reset-password",
post(handlers::email::reset_password),
);
}
let mut protected = Router::new()
.route("/auth/logout", post(handlers::auth::delete_refresh_token))
.route(
"/auth/delete-refresh-token",
post(handlers::auth::delete_refresh_token),
)
.route("/auth/delete", post(handlers::auth::delete_account))
.route(
"/api/user/profile",
get(handlers::user_profile::get_profile)
.put(handlers::user_profile::update_profile)
.delete(handlers::user_profile::delete_profile),
)
.route(
"/api/mailboxes",
post(handlers::mailbox::create_mailbox).get(handlers::mailbox::list_mailboxes),
)
.route(
"/api/mailboxes/:id",
delete(handlers::mailbox::delete_mailbox),
)
.route(
"/api/mailboxes/:id/rotate-token",
post(handlers::mailbox::rotate_token),
)
.route("/api/credits", get(handlers::credit::get_balance))
.route("/api/credits/check-in", post(handlers::credit::check_in))
.route(
"/api/credits/transactions",
get(handlers::credit::list_transactions),
);
if state.config.email.enabled {
protected = protected
.route("/api/email/latest-log", get(handlers::email::latest_log))
.route(
"/api/email/queue-status",
get(handlers::email::queue_status),
);
}
protected = protected.route_layer(middleware::from_fn_with_state(
state.clone(),
infra::middleware::auth::auth_middleware,
));
// 邮箱级 access_token 保护路由(查邮件 / WebSocket
let mailbox_protected = Router::new()
.route(
"/api/mailboxes/:id/emails",
get(handlers::mailbox::list_emails),
)
.route(
"/api/mailboxes/:id/emails/:eid",
get(handlers::mailbox::get_email).delete(handlers::mailbox::delete_email),
)
.route(
"/api/mailboxes/:id/emails/:eid/attachments/:aid",
get(handlers::mailbox::get_attachment),
)
.route(
"/api/mailboxes/:id/ws",
get(handlers::mailbox::ws_subscribe),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
infra::middleware::mailbox_auth::mailbox_auth_middleware,
));
// 管理后台路由(用户 JWT + admin 角色)
let admin_routes = Router::new()
.route("/admin/overview", get(handlers::admin_governance::overview))
.route("/admin/users", get(handlers::admin_governance::list_users))
.route(
"/admin/users/bulk-actions",
post(handlers::admin_governance::bulk_users),
)
.route("/admin/users/:id", patch(handlers::admin::update_user))
.route(
"/admin/mailboxes",
get(handlers::admin_governance::list_mailboxes),
)
.route(
"/admin/mailboxes/bulk-actions",
post(handlers::admin_governance::bulk_mailboxes),
)
.route(
"/admin/emails",
get(handlers::admin_governance::list_emails),
)
.route(
"/admin/emails/bulk-actions",
post(handlers::admin_governance::bulk_emails),
)
.route(
"/admin/emails/:id",
get(handlers::admin_governance::get_email_detail),
)
.route(
"/admin/emails/:id/attachments/:attachment_id",
get(handlers::admin_governance::get_email_attachment),
)
.route(
"/admin/blacklists/senders",
get(handlers::admin_governance::list_sender_blacklists)
.post(handlers::admin_governance::add_sender_blacklist),
)
.route(
"/admin/blacklists/senders/bulk-actions",
post(handlers::admin_governance::bulk_sender_blacklists),
)
.route(
"/admin/blacklists/ips",
get(handlers::admin_governance::list_ip_blacklists)
.post(handlers::admin_governance::add_ip_blacklist),
)
.route(
"/admin/blacklists/ips/bulk-actions",
post(handlers::admin_governance::bulk_ip_blacklists),
)
.route(
"/admin/audit-logs",
get(handlers::admin_governance::list_audit_logs),
)
.route(
"/admin/credit-transactions",
get(handlers::admin_governance::list_credit_transactions),
)
.route(
"/admin/email-logs",
get(handlers::admin_governance::list_email_logs),
)
.route(
"/admin/credits/settings/history",
get(handlers::admin_governance::list_credit_rule_changes),
)
.route(
"/admin/blocked-senders",
get(handlers::admin::list_blocked_senders).post(handlers::admin::add_blocked_sender),
)
.route(
"/admin/blocked-senders/:id",
delete(handlers::admin::delete_blocked_sender),
)
.route(
"/admin/blocked-ips",
get(handlers::admin::list_blocked_ips).post(handlers::admin::add_blocked_ip),
)
.route(
"/admin/blocked-ips/:id",
delete(handlers::admin::delete_blocked_ip),
)
.route("/admin/stats", get(handlers::admin::stats))
.route(
"/admin/credits/adjust",
post(handlers::admin::adjust_credits),
)
.route(
"/admin/credits/settings",
get(handlers::admin::get_credit_settings).put(handlers::admin::update_credit_settings),
)
.route(
"/admin/credits/settings/reset",
post(handlers::admin::reset_credit_settings),
)
.route(
"/admin/emails/quarantined",
get(handlers::admin::list_quarantined_emails),
)
.route(
"/admin/emails/:id/release",
post(handlers::admin::release_email),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
infra::middleware::admin::require_admin,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
infra::middleware::auth::auth_middleware,
));
let limiter =
infra::middleware::rate_limit::RateLimiter::new(state.config.server.rate_limit_per_minute);
let server = state.config.server.clone();
Ok(public
.merge(protected)
.merge(mailbox_protected)
.merge(admin_routes)
.fallback(infra::middleware::security::fallback_404)
.method_not_allowed_fallback(infra::middleware::security::fallback_405)
.layer(middleware::from_fn(
infra::middleware::security::security_headers,
))
.layer(middleware::from_fn(
infra::middleware::language::language_middleware,
))
.layer(cors_layer(&server)?)
.layer(middleware::from_fn(
infra::middleware::logging::request_logging_middleware,
))
.layer(middleware::from_fn_with_state(
limiter,
infra::middleware::rate_limit::rate_limit_middleware,
))
.layer(TimeoutLayer::new(Duration::from_secs(
server.request_timeout_seconds,
)))
.layer(CatchPanicLayer::new())
.layer(ConcurrencyLimitLayer::new(server.concurrency_limit))
.layer(RequestBodyLimitLayer::new(server.max_body_bytes))
.layer(middleware::from_fn_with_state(
server.max_body_bytes,
infra::middleware::body_limit::enforce_body_limit,
))
.with_state(state))
}
#[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 dir) = args.work_dir {
std::env::set_current_dir(dir)?;
}
let config = config::app::AppConfig::load_with_overrides(
args.resolve_config_path(),
args.get_overrides(),
args.env.as_str(),
)?;
let pool = db::init_database(&config.database).await?;
let credit_rules =
services::credit_rule_service::CreditRuleService::ensure_defaults(&pool).await?;
let backfilled = services::credit_service::CreditService::backfill_missing_accounts(
&pool,
credit_rules.register_bonus,
)
.await?;
if backfilled > 0 {
tracing::info!(backfilled, "已为存量用户补建积分账户");
}
let redis_client = if config.redis.enabled {
match infra::redis::redis_client::RedisClient::new(&config.redis.build_url()).await {
Ok(client) => {
tracing::info!("Redis connected");
Some(client)
}
Err(error) => {
tracing::warn!(%error, "Redis unavailable; dependent capabilities are disabled");
None
}
}
} else {
None
};
let address = format!("{}:{}", config.server.host, config.server.port);
let mail_hub = smtp::MailHub::new();
let state = AppState {
runtime_health: runtime_health::RuntimeHealth::new(
config.smtp.enabled,
config.email.enabled,
),
pool,
config,
redis_client,
mail_hub,
};
// 启动 SMTP 收信(监听 25
if state.config.smtp.enabled {
let smtp_state = state.clone();
let smtp_health = state.runtime_health.clone();
tokio::spawn(async move {
if let Err(e) = smtp::server::run(smtp_state).await {
smtp_health.smtp_failed(e.to_string()).await;
tracing::error!(%e, "SMTP 服务器退出");
}
});
}
// 启动定时任务(清理过期邮件/邮箱)
workers::start(state.clone());
if state.config.email.enabled && state.config.email.queue_enabled {
if let Some(redis) = state.redis_client.clone() {
infra::mail::worker::start(redis, state.config.email.clone(), state.pool.clone());
state.runtime_health.outbound_status("healthy").await;
} else {
tracing::warn!("mail queue requested but Redis is unavailable");
state.runtime_health.outbound_status("fault").await;
}
} else if state.config.email.enabled {
state.runtime_health.outbound_status("healthy").await;
}
let listener = tokio::net::TcpListener::bind(&address).await?;
tracing::info!(%address, "server listening");
axum::serve(
listener,
build_router(state)?.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
#[cfg(test)]
mod route_tests {
use super::*;
use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
};
use tower::ServiceExt;
async fn test_app(max_body_bytes: usize) -> Router {
let mut config = config::app::AppConfig::load_from_path("config/development.toml").unwrap();
let db_path =
std::env::temp_dir().join(format!("email-unlimit-{}.sqlite", uuid::Uuid::new_v4()));
config.database.path = Some(db_path);
config.redis.enabled = false;
config.email.enabled = false;
config.server.max_body_bytes = max_body_bytes;
let pool = db::init_database(&config.database).await.unwrap();
build_router(AppState {
runtime_health: runtime_health::RuntimeHealth::new(false, false),
pool,
config,
redis_client: None,
mail_hub: smtp::MailHub::new(),
})
.unwrap()
}
#[tokio::test]
async fn health_works_without_redis() {
let response = test_app(1024)
.await
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("\"redis\":false"));
}
#[tokio::test]
async fn fallback_is_structured_json() {
let response = test_app(1024)
.await
.oneshot(
Request::builder()
.uri("/missing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("\"code\":404"));
}
#[tokio::test]
async fn rejects_oversized_body() {
let response = test_app(8)
.await
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/login")
.header("content-type", "application/json")
.body(Body::from("0123456789"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn protected_route_requires_token_and_uses_language() {
let response = test_app(1024)
.await
.oneshot(
Request::builder()
.uri("/api/user/profile")
.header("accept-language", "en")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("Missing authorization header"));
}
#[tokio::test]
async fn method_not_allowed_is_structured() {
let response = test_app(1024)
.await
.oneshot(
Request::builder()
.method("PATCH")
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("\"code\":405"));
}
#[tokio::test]
async fn bootstrap_admin_can_access_admin_routes() {
let mut config = config::app::AppConfig::load_from_path("config/development.toml").unwrap();
let db_path = std::env::temp_dir().join(format!(
"email-unlimit-admin-{}.sqlite",
uuid::Uuid::new_v4()
));
config.database.path = Some(db_path);
config.redis.enabled = false;
config.email.enabled = false;
config.auth.bootstrap_admin_email = "admin@example.com".into();
let pool = db::init_database(&config.database).await.unwrap();
let app = build_router(AppState {
runtime_health: runtime_health::RuntimeHealth::new(false, false),
pool,
config,
redis_client: None,
mail_hub: smtp::MailHub::new(),
})
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/register")
.header("content-type", "application/json")
.body(Body::from(
r#"{"email":"admin@example.com","password":"password123"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = value["data"]["access_token"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/credits")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let credits: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(credits["data"]["balance"], 30);
assert_eq!(credits["data"]["pricing"]["create_mailbox"], 2);
assert_eq!(credits["data"]["daily_limits"]["mailboxes"], 3);
assert_eq!(credits["data"]["usage_today"]["mailboxes_created"], 0);
assert_eq!(credits["data"]["check_in"]["reward"], 3);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/stats")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/credits/settings")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/admin/credits/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{
"register_bonus":30,
"daily_check_in_reward":3,
"reward_balance_cap":90,
"create_mailbox_cost":4,
"receive_email_cost":1,
"daily_mailboxes_limit":3,
"daily_emails_limit":20,
"expected_version":0,
"reason":"route test"
}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/admin/credits/settings")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{
"register_bonus":30,
"daily_check_in_reward":3,
"reward_balance_cap":90,
"create_mailbox_cost":2,
"receive_email_cost":1,
"daily_mailboxes_limit":3,
"daily_emails_limit":20,
"expected_version":0,
"reason":"stale update"
}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/credits/check-in")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let check_in: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(check_in["data"]["claimed"], true);
assert_eq!(check_in["data"]["reward_granted"], 3);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/users")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let users: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(users["data"]["page"], 1);
assert_eq!(users["data"]["total"], 1);
let user_id = users["data"]["items"][0]["id"].as_str().unwrap();
assert_eq!(users["data"]["items"][0]["is_current"], true);
let response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/admin/users/{user_id}"))
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"status":"suspended"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/credits")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/credits/adjust")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"user_id":"{user_id}","delta":1,"reason":"self adjustment"}}"#
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn deleting_account_revokes_mailbox_tokens() {
let app = test_app(16 * 1024).await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/register")
.header("content-type", "application/json")
.body(Body::from(
r#"{"email":"delete@example.com","password":"password123"}"#,
))
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let registered: serde_json::Value = serde_json::from_slice(&body).unwrap();
let user_token = registered["data"]["access_token"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/mailboxes")
.header("authorization", format!("Bearer {user_token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"local_part":"delete-account"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let mailbox: serde_json::Value = serde_json::from_slice(&body).unwrap();
let mailbox_id = mailbox["data"]["id"].as_i64().unwrap();
let mailbox_token = mailbox["data"]["access_token"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/credits")
.header("authorization", format!("Bearer {user_token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
let credits: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(credits["data"]["balance"], 28);
assert_eq!(credits["data"]["usage_today"]["mailboxes_created"], 1);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/delete")
.header("authorization", format!("Bearer {user_token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"user_id":"ignored","password":"password123"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.uri(format!("/api/mailboxes/{mailbox_id}/emails"))
.header("authorization", format!("Bearer {mailbox_token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}
@@ -0,0 +1,52 @@
use crate::domain::entities::{abuse_event, abuse_rule};
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
/// 滥用规则与事件数据访问
pub struct AbuseRepository {
db: DatabaseConnection,
}
impl AbuseRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
#[allow(dead_code)]
pub async fn list_rules(&self, enabled_only: bool) -> Result<Vec<abuse_rule::Model>> {
let mut q = abuse_rule::Entity::find();
if enabled_only {
q = q.filter(abuse_rule::Column::Enabled.eq(true));
}
Ok(q.all(&self.db).await?)
}
pub async fn add_event(
&self,
rule_id: i64,
target: String,
detail: Option<String>,
) -> Result<()> {
abuse_event::ActiveModel {
rule_id: Set(rule_id),
target: Set(target),
detail: Set(detail),
..Default::default()
}
.insert(&self.db)
.await?;
Ok(())
}
#[allow(dead_code)]
pub async fn list_events(&self, limit: u64) -> Result<Vec<abuse_event::Model>> {
Ok(abuse_event::Entity::find()
.order_by_desc(abuse_event::Column::CreatedAt)
.limit(limit)
.all(&self.db)
.await?)
}
}
@@ -0,0 +1,68 @@
use crate::domain::entities::email_attachment;
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
};
/// 邮件附件数据访问
pub struct AttachmentRepository {
db: DatabaseConnection,
}
impl AttachmentRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
#[allow(dead_code)]
pub async fn insert(
&self,
model: email_attachment::ActiveModel,
) -> Result<email_attachment::Model> {
Ok(model.insert(&self.db).await?)
}
pub async fn find_by_id(&self, id: i64) -> Result<Option<email_attachment::Model>> {
Ok(email_attachment::Entity::find_by_id(id)
.one(&self.db)
.await?)
}
pub async fn list_by_email(&self, email_id: i64) -> Result<Vec<email_attachment::Model>> {
Ok(email_attachment::Entity::find()
.filter(email_attachment::Column::EmailId.eq(email_id))
.order_by_asc(email_attachment::Column::Id)
.all(&self.db)
.await?)
}
#[allow(dead_code)]
pub async fn delete_by_email(&self, email_id: i64) -> Result<u64> {
let res = email_attachment::Entity::delete_many()
.filter(email_attachment::Column::EmailId.eq(email_id))
.exec(&self.db)
.await?;
Ok(res.rows_affected)
}
/// 批量插入附件(收信时)
#[allow(dead_code)]
pub async fn insert_many(
&self,
email_id: i64,
attachments: Vec<(Option<String>, String, i64, Vec<u8>)>,
) -> Result<()> {
for (filename, content_type, size, content) in attachments {
let model = email_attachment::ActiveModel {
email_id: Set(email_id),
filename: Set(filename),
content_type: Set(content_type),
size_bytes: Set(size),
content: Set(Some(content)),
..Default::default()
};
model.insert(&self.db).await?;
}
Ok(())
}
}
@@ -0,0 +1,58 @@
use crate::domain::entities::audit_log;
use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set};
/// 审计日志数据访问
pub struct AuditRepository {
db: DatabaseConnection,
}
#[derive(Default)]
pub struct NewAuditLog {
pub event_type: String,
pub action: String,
pub source_ip: Option<String>,
pub helo: Option<String>,
pub mail_from: Option<String>,
pub rcpt_to: Option<String>,
pub reason: Option<String>,
pub operator_id: Option<String>,
pub target_type: Option<String>,
pub target_id: Option<String>,
pub metadata_json: Option<String>,
}
impl AuditRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn add(&self, event: NewAuditLog) -> Result<()> {
audit_log::ActiveModel {
event_type: Set(event.event_type),
action: Set(event.action),
source_ip: Set(event.source_ip),
helo: Set(event.helo),
mail_from: Set(event.mail_from),
rcpt_to: Set(event.rcpt_to),
reason: Set(event.reason),
operator_id: Set(event.operator_id),
target_type: Set(event.target_type),
target_id: Set(event.target_id),
metadata_json: Set(event.metadata_json),
..Default::default()
}
.insert(&self.db)
.await?;
Ok(())
}
#[allow(dead_code)]
pub async fn list_recent(&self, limit: u64) -> Result<Vec<audit_log::Model>> {
Ok(audit_log::Entity::find()
.order_by_desc(audit_log::Column::CreatedAt)
.limit(limit)
.all(&self.db)
.await?)
}
}
@@ -0,0 +1,113 @@
use crate::domain::entities::{blocked_ip, blocked_sender};
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
QueryOrder, Set,
};
/// 黑名单数据访问(发件人 + IP)
pub struct BlacklistRepository {
db: DatabaseConnection,
}
impl BlacklistRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
fn active_condition<C>(col: C) -> Condition
where
C: sea_orm::ColumnTrait,
{
let now = chrono::Utc::now().naive_utc();
Condition::any().add(col.is_null()).add(col.gt(now))
}
// ===== 发件黑名单 =====
/// 查询某 kind+value 是否在生效中的黑名单
pub async fn find_active_sender(
&self,
kind: &str,
value: &str,
) -> Result<Option<blocked_sender::Model>> {
Ok(blocked_sender::Entity::find()
.filter(blocked_sender::Column::Kind.eq(kind))
.filter(blocked_sender::Column::Value.eq(value))
.filter(Self::active_condition(blocked_sender::Column::ExpiresAt))
.one(&self.db)
.await?)
}
pub async fn add_sender(
&self,
kind: String,
value: String,
reason: String,
source: String,
expires_at: Option<chrono::NaiveDateTime>,
) -> Result<blocked_sender::Model> {
let model = blocked_sender::ActiveModel {
kind: Set(kind),
value: Set(value),
reason: Set(reason),
source: Set(source),
expires_at: Set(expires_at),
..Default::default()
};
Ok(model.insert(&self.db).await?)
}
pub async fn list_senders(&self) -> Result<Vec<blocked_sender::Model>> {
Ok(blocked_sender::Entity::find()
.order_by_desc(blocked_sender::Column::CreatedAt)
.all(&self.db)
.await?)
}
pub async fn delete_sender(&self, id: i64) -> Result<()> {
blocked_sender::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(())
}
// ===== IP 黑名单 =====
pub async fn find_active_ip(&self, ip: &str) -> Result<Option<blocked_ip::Model>> {
Ok(blocked_ip::Entity::find()
.filter(blocked_ip::Column::Ip.eq(ip))
.filter(Self::active_condition(blocked_ip::Column::ExpiresAt))
.one(&self.db)
.await?)
}
pub async fn add_ip(
&self,
ip: String,
reason: String,
source: String,
expires_at: Option<chrono::NaiveDateTime>,
) -> Result<blocked_ip::Model> {
let model = blocked_ip::ActiveModel {
ip: Set(ip),
reason: Set(reason),
source: Set(source),
expires_at: Set(expires_at),
..Default::default()
};
Ok(model.insert(&self.db).await?)
}
pub async fn list_ips(&self) -> Result<Vec<blocked_ip::Model>> {
Ok(blocked_ip::Entity::find()
.order_by_desc(blocked_ip::Column::CreatedAt)
.all(&self.db)
.await?)
}
pub async fn delete_ip(&self, id: i64) -> Result<()> {
blocked_ip::Entity::delete_by_id(id).exec(&self.db).await?;
Ok(())
}
}
@@ -0,0 +1,91 @@
use crate::domain::entities::{credit_account, credit_transaction};
use anyhow::Result;
use sea_orm::{
sea_query::Expr, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
QueryOrder, QuerySelect, Set,
};
/// 积分账户与流水数据访问
pub struct CreditRepository {
db: DatabaseConnection,
}
impl CreditRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_account(&self, user_id: &str) -> Result<Option<credit_account::Model>> {
Ok(credit_account::Entity::find()
.filter(credit_account::Column::UserId.eq(user_id))
.one(&self.db)
.await?)
}
#[allow(dead_code)]
pub async fn create_account(
&self,
user_id: String,
balance: i64,
total_granted: i64,
) -> Result<credit_account::Model> {
let model = credit_account::ActiveModel {
user_id: Set(user_id),
balance: Set(balance),
version: Set(0),
total_granted: Set(total_granted),
total_consumed: Set(0),
..Default::default()
};
Ok(model.insert(&self.db).await?)
}
/// 乐观锁扣减:仅当 version 匹配时更新。返回是否成功。
#[allow(dead_code)]
pub async fn debit_optimistic(
&self,
user_id: &str,
expected_version: i64,
new_balance: i64,
) -> Result<bool> {
let res = credit_account::Entity::update_many()
.col_expr(credit_account::Column::Balance, Expr::value(new_balance))
.col_expr(
credit_account::Column::TotalConsumed,
Expr::col(credit_account::Column::TotalConsumed).add(1),
)
.col_expr(
credit_account::Column::Version,
Expr::value(expected_version + 1),
)
.filter(credit_account::Column::UserId.eq(user_id))
.filter(credit_account::Column::Version.eq(expected_version))
.exec(&self.db)
.await?;
Ok(res.rows_affected > 0)
}
#[allow(dead_code)]
pub async fn insert_transaction(
&self,
model: credit_transaction::ActiveModel,
) -> Result<credit_transaction::Model> {
Ok(model.insert(&self.db).await?)
}
#[allow(dead_code)]
pub async fn list_transactions(
&self,
user_id: &str,
limit: u64,
offset: u64,
) -> Result<Vec<credit_transaction::Model>> {
Ok(credit_transaction::Entity::find()
.filter(credit_transaction::Column::UserId.eq(user_id))
.order_by_desc(credit_transaction::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?)
}
}
@@ -0,0 +1,39 @@
use crate::domain::entities::email_logs;
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
};
pub struct EmailLogRepository {
db: DatabaseConnection,
}
impl EmailLogRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn add(
&self,
user_id: Option<String>,
recipient: String,
status: String,
error: Option<String>,
) -> Result<()> {
email_logs::ActiveModel {
user_id: Set(user_id),
recipient: Set(recipient),
kind: Set("verification".into()),
status: Set(status),
error: Set(error),
..Default::default()
}
.insert(&self.db)
.await?;
Ok(())
}
pub async fn latest(&self, user_id: &str) -> Result<Option<email_logs::Model>> {
Ok(email_logs::Entity::find()
.filter(email_logs::Column::UserId.eq(user_id))
.order_by_desc(email_logs::Column::CreatedAt)
.one(&self.db)
.await?)
}
}
@@ -0,0 +1,93 @@
use crate::domain::entities::email;
use anyhow::Result;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
/// 接收邮件数据访问
pub struct EmailRepository {
db: DatabaseConnection,
}
impl EmailRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
#[allow(dead_code)]
pub async fn insert(&self, model: email::ActiveModel) -> Result<email::Model> {
Ok(model.insert(&self.db).await?)
}
pub async fn find_by_id(&self, id: i64) -> Result<Option<email::Model>> {
Ok(email::Entity::find_by_id(id).one(&self.db).await?)
}
/// 分页列出某邮箱的邮件(按接收时间倒序)
pub async fn list_by_mailbox(
&self,
mailbox_id: i64,
limit: u64,
offset: u64,
) -> Result<Vec<email::Model>> {
Ok(email::Entity::find()
.filter(email::Column::MailboxId.eq(mailbox_id))
.filter(email::Column::Status.eq("received"))
.order_by_desc(email::Column::ReceivedAt)
.offset(offset)
.limit(limit)
.all(&self.db)
.await?)
}
#[allow(dead_code)]
pub async fn delete_by_id(&self, id: i64) -> Result<()> {
email::Entity::delete_by_id(id).exec(&self.db).await?;
Ok(())
}
#[allow(dead_code)]
pub async fn delete_by_mailbox(&self, mailbox_id: i64) -> Result<u64> {
let res = email::Entity::delete_many()
.filter(email::Column::MailboxId.eq(mailbox_id))
.exec(&self.db)
.await?;
Ok(res.rows_affected)
}
/// 列出已过期邮件(清理任务用)
pub async fn list_expired(&self, batch_size: u64) -> Result<Vec<email::Model>> {
let now = chrono::Utc::now().naive_utc();
Ok(email::Entity::find()
.filter(email::Column::ExpiresAt.lte(now))
.limit(batch_size)
.all(&self.db)
.await?)
}
/// 更新状态(如隔离邮件放行)
#[allow(dead_code)]
pub async fn update_status(&self, id: i64, status: &str) -> Result<()> {
let m = email::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| anyhow::anyhow!("邮件不存在"))?;
let mut active: email::ActiveModel = m.into();
active.status = Set(status.into());
active.update(&self.db).await?;
Ok(())
}
#[allow(dead_code)]
pub async fn set_abuse_score(&self, id: i64, score: i32) -> Result<()> {
let m = email::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| anyhow::anyhow!("邮件不存在"))?;
let mut active: email::ActiveModel = m.into();
active.abuse_score = Set(score);
active.update(&self.db).await?;
Ok(())
}
}

Some files were not shown because too many files have changed in this diff Show More