feat: rebuild email platform and operations console
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target
|
||||
db.sqlite3
|
||||
.env
|
||||
.git
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+4372
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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/`。
|
||||
@@ -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 = ""
|
||||
@@ -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. 已设置数据库定期备份
|
||||
@@ -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 接口文档"→"开发规范"的顺序阅读文档。
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# API 接口概览
|
||||
|
||||
本文档提供所有 API 接口的快速参考。
|
||||
|
||||
## 基础信息
|
||||
|
||||
### Base URL
|
||||
|
||||
```
|
||||
开发环境: http://localhost:3000
|
||||
生产环境: https://api.yourdomain.com
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
|
||||
本 API 使用 JWT(JSON Web Token)进行认证:
|
||||
|
||||
- **Access Token**:有效期 15 分钟,用于 API 请求认证
|
||||
- **Refresh Token**:有效期 7 天,用于获取新的 Access Token
|
||||
|
||||
### 认证 Header 格式
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
所有接口返回统一的 JSON 格式:
|
||||
|
||||
**成功响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "错误信息",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 通用错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权(Token 无效或过期) |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 公开接口(无需认证)
|
||||
|
||||
| 方法 | 路径 | 说明 | 详细文档 |
|
||||
|------|------|------|----------|
|
||||
| GET | `/health` | 健康检查 | [查看详情](endpoints/public.md#get-health) |
|
||||
| GET | `/info` | 服务器信息 | [查看详情](endpoints/public.md#get-info) |
|
||||
| POST | `/auth/register` | 用户注册 | [查看详情](endpoints/public.md#post-authregister) |
|
||||
| POST | `/auth/login` | 用户登录 | [查看详情](endpoints/public.md#post-authlogin) |
|
||||
| POST | `/auth/refresh` | 刷新 Token | [查看详情](endpoints/public.md#post-authrefresh) |
|
||||
|
||||
### 需要认证的接口
|
||||
|
||||
| 方法 | 路径 | 说明 | 详细文档 |
|
||||
|------|------|------|----------|
|
||||
| POST | `/auth/delete` | 删除账号 | [查看详情](endpoints/protected.md#post-authdelete) |
|
||||
| POST | `/auth/delete-refresh-token` | 删除 Refresh Token | [查看详情](endpoints/protected.md#post-authdelete-refresh-token) |
|
||||
|
||||
## 认证流程简述
|
||||
|
||||
### 1. 注册/登录
|
||||
|
||||
用户注册或登录成功后,会返回 Access Token 和 Refresh Token:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用 Access Token
|
||||
|
||||
将 Access Token 添加到请求头:
|
||||
|
||||
```http
|
||||
GET /auth/delete
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
### 3. 刷新 Token
|
||||
|
||||
当 Access Token 过期时,使用 Refresh Token 获取新的 Token:
|
||||
|
||||
```bash
|
||||
POST /auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
> 查看 [认证机制详解](authentication.md) 了解完整流程
|
||||
|
||||
## 快速示例
|
||||
|
||||
### 注册用户
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 用户登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 访问受保护接口
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/delete \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <your_access_token>" \
|
||||
-d '{
|
||||
"user_id": "1234567890",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
## 详细文档
|
||||
|
||||
- [公开接口详细文档](endpoints/public.md) - 所有公开接口的详细说明
|
||||
- [受保护接口详细文档](endpoints/protected.md) - 需要认证的接口详细说明
|
||||
- [认证机制详解](authentication.md) - JWT 认证流程和安全最佳实践
|
||||
- [前端集成示例](examples/frontend-integration.md) - JavaScript/TypeScript/React/Vue 集成代码示例
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速开始指南](../development/getting-started.md) - 安装和运行项目
|
||||
- [环境变量配置](../deployment/environment-variables.md) - 配置 API 服务器
|
||||
- [前端集成指南](examples/frontend-integration.md) - 前端开发集成示例
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果您在使用 API 时遇到问题:
|
||||
|
||||
1. 检查请求格式是否正确
|
||||
2. 确认 Token 是否有效(未过期)
|
||||
3. 查看日志输出获取详细错误信息
|
||||
4. 参考 [认证机制详解](authentication.md) 了解认证流程
|
||||
|
||||
---
|
||||
|
||||
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||
# 新增通用接口
|
||||
|
||||
所有受保护接口使用 `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` 选择认证错误语言。
|
||||
@@ -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
|
||||
- 后端:Redis(Key:`auth:refresh_token:<user_id>`)
|
||||
|
||||
---
|
||||
|
||||
## 认证流程
|
||||
|
||||
### 1. 用户注册流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant DB as 数据库
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 输入邮箱和密码
|
||||
Frontend->>API: POST /auth/register
|
||||
API->>API: 验证邮箱格式
|
||||
API->>API: 生成用户 ID
|
||||
API->>API: 哈希密码(Argon2)
|
||||
API->>DB: 创建用户记录
|
||||
DB-->>API: 用户创建成功
|
||||
API->>API: 生成 Access Token (15min)
|
||||
API->>API: 生成 Refresh Token (7days)
|
||||
API->>Redis: 存储 Refresh Token
|
||||
Redis-->>API: 存储成功
|
||||
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||
Frontend-->>User: 注册成功,自动登录
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 密码使用 Argon2 算法哈希,不可逆
|
||||
- Refresh Token 存储在 Redis,支持撤销
|
||||
- 注册成功后自动登录,返回 Token
|
||||
|
||||
### 2. 用户登录流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant DB as 数据库
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 输入邮箱和密码
|
||||
Frontend->>API: POST /auth/login
|
||||
API->>DB: 查询用户记录
|
||||
DB-->>API: 返回用户信息
|
||||
API->>API: 验证密码(Argon2)
|
||||
API->>API: 生成 Access Token (15min)
|
||||
API->>API: 生成 Refresh Token (7days)
|
||||
API->>Redis: 存储/更新 Refresh Token
|
||||
Redis-->>API: 存储成功
|
||||
API-->>Frontend: 返回 Access Token + Refresh Token
|
||||
Frontend->>Frontend: 存储 Token 到 localStorage
|
||||
Frontend-->>User: 登录成功
|
||||
```
|
||||
|
||||
**安全特性**:
|
||||
- 登录失败不返回具体错误信息(防止账号枚举)
|
||||
- 密码错误会记录日志用于风控
|
||||
- Refresh Token 每次登录都会更新
|
||||
|
||||
### 3. 访问受保护接口流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
Frontend->>API: GET /protected<br/>Authorization: Bearer <access_token>
|
||||
API->>API: 验证 JWT 签名
|
||||
API->>API: 检查 Token 类型
|
||||
API->>API: 检查 Token 过期时间
|
||||
alt Token 有效
|
||||
API-->>Frontend: 200 OK 返回数据
|
||||
else Token 无效或过期
|
||||
API-->>Frontend: 401 Unauthorized
|
||||
Frontend->>API: POST /auth/refresh
|
||||
API->>Redis: 获取 Refresh Token
|
||||
Redis-->>API: 返回 Refresh Token
|
||||
API->>API: 验证 Refresh Token
|
||||
API->>API: 生成新的 Token 对
|
||||
API->>Redis: 更新 Refresh Token
|
||||
API-->>Frontend: 返回新的 Token
|
||||
Frontend->>API: 重试原请求
|
||||
API-->>Frontend: 200 OK 返回数据
|
||||
end
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 所有受保护接口都需要在 Header 中携带 Access Token
|
||||
- Token 过期时前端自动刷新并重试请求
|
||||
- 刷新成功后旧 Refresh Token 立即失效
|
||||
|
||||
### 4. Token 刷新流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
Frontend->>API: POST /auth/refresh<br/>{"refresh_token": "..."}
|
||||
API->>API: 验证 Refresh Token 签名
|
||||
API->>API: 检查 Token 类型(必须是 Refresh Token)
|
||||
API->>API: 检查 Token 过期时间
|
||||
API->>Redis: 检查 Refresh Token 是否存在
|
||||
alt Token 有效
|
||||
API->>API: 生成新的 Access Token (15min)
|
||||
API->>API: 生成新的 Refresh Token (7days)
|
||||
API->>Redis: 删除旧的 Refresh Token
|
||||
API->>Redis: 存储新的 Refresh Token
|
||||
API-->>Frontend: 返回新的 Token 对
|
||||
else Token 无效或过期
|
||||
API-->>Frontend: 401 Unauthorized
|
||||
Frontend->>Frontend: 清除 Token
|
||||
Frontend->>Frontend: 跳转到登录页
|
||||
end
|
||||
```
|
||||
|
||||
**Token 轮换**:
|
||||
- 每次刷新都会生成新的 Refresh Token
|
||||
- 旧的 Refresh Token 立即失效
|
||||
- 防止 Token 重放攻击
|
||||
|
||||
### 5. 用户登出流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Frontend as 前端应用
|
||||
participant API as API 服务器
|
||||
participant Redis as Redis
|
||||
|
||||
User->>Frontend: 点击登出按钮
|
||||
Frontend->>API: POST /auth/delete-refresh-token<br/>Authorization: Bearer <access_token>
|
||||
API->>API: 验证 Access Token
|
||||
API->>API: 从 Token 中提取 user_id
|
||||
API->>Redis: 删除 Refresh Token
|
||||
Redis-->>API: 删除成功
|
||||
API-->>Frontend: 200 OK
|
||||
Frontend->>Frontend: 清除本地 Token
|
||||
Frontend->>Frontend: 跳转到登录页
|
||||
Frontend-->>User: 登出成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 管理
|
||||
|
||||
### Token 生成
|
||||
|
||||
```rust
|
||||
// src/utils/jwt.rs
|
||||
|
||||
// 生成 Access Token
|
||||
pub fn generate_access_token(
|
||||
user_id: &str,
|
||||
expiration_minutes: u64,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::minutes(expiration_minutes as i64))
|
||||
.expect("invalid expiration timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: expiration,
|
||||
token_type: TokenType::Access,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
// 生成 Refresh Token
|
||||
pub fn generate_refresh_token(
|
||||
user_id: &str,
|
||||
expiration_days: i64,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::days(expiration_days))
|
||||
.expect("invalid expiration timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: expiration,
|
||||
token_type: TokenType::Refresh,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(jwt_secret.as_ref()),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
```
|
||||
|
||||
### Token 验证
|
||||
|
||||
```rust
|
||||
// src/infra/middleware/auth.rs
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ErrorResponse> {
|
||||
// 1. 提取 Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?;
|
||||
|
||||
// 2. 验证 Bearer 格式
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(ErrorResponse::new("Authorization header 格式错误".to_string()));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..]; // 跳过 "Bearer "
|
||||
|
||||
// 3. 验证 JWT 签名和过期时间
|
||||
let claims = decode_token(token, &state.config.auth.jwt_secret)?;
|
||||
|
||||
// 4. 检查 Token 类型(必须是 Access Token)
|
||||
if claims.token_type != TokenType::Access {
|
||||
return Err(ErrorResponse::new("Token 类型错误".to_string()));
|
||||
}
|
||||
|
||||
// 5. 将 user_id 添加到请求扩展中
|
||||
let user_id = claims.sub;
|
||||
request.extensions_mut().insert(user_id);
|
||||
|
||||
// 6. 继续处理请求
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh Token 存储
|
||||
|
||||
```rust
|
||||
// src/services/auth_service.rs
|
||||
|
||||
async fn save_refresh_token(&self, user_id: &str, refresh_token: &str, expiration_days: i64) -> Result<()> {
|
||||
let key = RedisKey::new(BusinessType::Auth)
|
||||
.add_identifier("refresh_token")
|
||||
.add_identifier(user_id);
|
||||
|
||||
let expiration_seconds = expiration_days * 24 * 3600;
|
||||
|
||||
self.redis_client
|
||||
.set_ex(&key.build(), refresh_token, expiration_seconds as u64)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Redis 保存失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Redis Key 设计**:
|
||||
```
|
||||
auth:refresh_token:<user_id>
|
||||
```
|
||||
|
||||
**过期时间**:7 天(与 Refresh Token 有效期一致)
|
||||
|
||||
---
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 1. 密码安全
|
||||
|
||||
**Argon2 哈希**:
|
||||
- 使用 Argon2 算法(内存 hard,抗 GPU/ASIC 破解)
|
||||
- 自动生成随机盐值
|
||||
- 哈希结果不可逆
|
||||
|
||||
```rust
|
||||
// src/services/auth_service.rs
|
||||
|
||||
pub fn hash_password(&self, password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("密码哈希失败: {}", e))?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. JWT 安全
|
||||
|
||||
**签名算法**:HS256 (HMAC-SHA256)
|
||||
|
||||
**Claims 结构**:
|
||||
```rust
|
||||
pub struct Claims {
|
||||
pub sub: String, // 用户 ID
|
||||
pub exp: usize, // 过期时间(Unix 时间戳)
|
||||
pub token_type: TokenType, // Token 类型(Access/Refresh)
|
||||
}
|
||||
```
|
||||
|
||||
**安全措施**:
|
||||
- 使用强密钥(至少 32 位随机字符串)
|
||||
- Token 包含过期时间
|
||||
- Token 类型区分(防止混用)
|
||||
- 签名验证防止篡改
|
||||
|
||||
### 3. Refresh Token 安全
|
||||
|
||||
**存储安全**:
|
||||
- 存储在 Redis 中,支持快速撤销
|
||||
- 每次刷新生成新 Token,旧 Token 失效
|
||||
- 支持主动登出,删除 Refresh Token
|
||||
|
||||
**使用限制**:
|
||||
- Refresh Token 只能使用一次
|
||||
- 过期后无法续期
|
||||
- 需要重新登录
|
||||
|
||||
### 4. 防护措施
|
||||
|
||||
**防重放攻击**:
|
||||
- Refresh Token 单次使用
|
||||
- 刷新后立即失效
|
||||
|
||||
**防 Token 泄露**:
|
||||
- Access Token 短期有效(15 分钟)
|
||||
- 只通过 HTTPS 传输
|
||||
- 不在 URL 中传递
|
||||
|
||||
**防暴力破解**:
|
||||
- 限制登录频率(可选实现)
|
||||
- 记录失败尝试(日志)
|
||||
- 密码哈希使用 Argon2
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 前端集成
|
||||
|
||||
#### 1. Token 存储
|
||||
|
||||
**推荐方案**:
|
||||
```typescript
|
||||
// 存储 Token
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 读取 Token
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
// 清除 Token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
```
|
||||
|
||||
#### 2. 请求拦截器
|
||||
|
||||
```typescript
|
||||
// 添加 Token 到请求头
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 响应拦截器(自动刷新 Token)
|
||||
|
||||
```typescript
|
||||
// 处理 401 错误并自动刷新
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const response = await axios.post('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data.data;
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return axios(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,跳转登录页
|
||||
localStorage.clear();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 后端开发
|
||||
|
||||
#### 1. 密码强度要求
|
||||
|
||||
```rust
|
||||
// 验证密码强度
|
||||
fn validate_password(password: &str) -> Result<()> {
|
||||
if password.len() < 8 {
|
||||
return Err(anyhow!("密码长度至少 8 位"));
|
||||
}
|
||||
if password.len() > 100 {
|
||||
return Err(anyhow!("密码长度最多 100 位"));
|
||||
}
|
||||
// 可添加更多规则(如必须包含大小写、数字等)
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. JWT 密钥管理
|
||||
|
||||
**开发环境**:
|
||||
|
||||
使用 `config/` 目录下的配置文件:
|
||||
|
||||
```bash
|
||||
# 方式1:使用默认配置(推荐)
|
||||
# JWT 密钥已在 config/default.toml 中配置
|
||||
|
||||
# 方式2:创建本地配置文件
|
||||
cp config/default.toml config/local.toml
|
||||
# 编辑 config/local.toml,修改 jwt_secret
|
||||
nano config/local.toml
|
||||
|
||||
# 运行
|
||||
cargo run -- -c config/local.toml
|
||||
```
|
||||
|
||||
**生产环境**:
|
||||
```bash
|
||||
# 使用强随机密钥
|
||||
AUTH_JWT_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
#### 3. Token 过期时间配置
|
||||
|
||||
```bash
|
||||
# Access Token:15 分钟(推荐)
|
||||
AUTH_ACCESS_TOKEN_EXPIRATION_MINUTES=15
|
||||
|
||||
# Refresh Token:7 天(推荐)
|
||||
AUTH_REFRESH_TOKEN_EXPIRATION_DAYS=7
|
||||
```
|
||||
|
||||
**建议**:
|
||||
- Access Token:5-30 分钟(权衡安全性和用户体验)
|
||||
- Refresh Token:7-30 天(根据应用安全要求)
|
||||
|
||||
### 生产部署
|
||||
|
||||
#### 1. HTTPS 强制
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.yourdomain.com;
|
||||
# SSL 配置...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. CORS 配置
|
||||
|
||||
开发环境可以允许所有来源:
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
```
|
||||
|
||||
生产环境应该限制允许的来源:
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin("https://yourdomain.com".parse::<HeaderValue>().unwrap())
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
||||
.allow_headers([HeaderName::from_static("content-type"), HeaderName::from_static("authorization")])
|
||||
```
|
||||
|
||||
#### 3. 速率限制
|
||||
|
||||
防止暴力破解和 DDoS 攻击(需要额外实现):
|
||||
|
||||
```rust
|
||||
// 使用 governor 库实现速率限制
|
||||
use governor::{Quota, RateLimiter};
|
||||
|
||||
let limiter = RateLimiter::direct(Quota::per_second(5));
|
||||
// 每秒最多 5 个请求
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [公开接口文档](endpoints/public.md) - 注册、登录、刷新 Token 接口
|
||||
- [受保护接口文档](endpoints/protected.md) - 需要认证的接口
|
||||
- [前端集成示例](examples/frontend-integration.md) - 完整的前端集成代码
|
||||
- [环境变量配置](../deployment/environment-variables.md) - 认证相关配置说明
|
||||
|
||||
---
|
||||
|
||||
**提示**:生产环境部署前务必修改 JWT 密钥为强随机字符串!
|
||||
|
||||
@@ -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 | 用户 ID(10 位数字) |
|
||||
| email | string | 用户邮箱 |
|
||||
| created_at | string | 账号创建时间(ISO 8601 格式) |
|
||||
| access_token | string | Access Token,有效期 15 分钟 |
|
||||
| refresh_token | string | Refresh Token,有效期 7 天 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 邮箱或密码错误 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 登录失败不会返回具体的错误信息(如"邮箱不存在"或"密码错误"),统一返回"邮箱或密码错误"
|
||||
- 密码错误次数过多可能会被临时限制(取决于具体实现)
|
||||
- 登录成功后会生成新的 Token 对,旧的 Token 会失效
|
||||
|
||||
---
|
||||
|
||||
## POST /auth/refresh
|
||||
|
||||
使用 Refresh Token 获取新的 Access Token 和 Refresh Token。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
POST /auth/refresh
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| refresh_token | string | 是 | Refresh Token |
|
||||
|
||||
### 响应
|
||||
|
||||
**成功响应 (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| access_token | string | 新的 Access Token,有效期 15 分钟 |
|
||||
| refresh_token | string | 新的 Refresh Token,有效期 7 天 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}'
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | Refresh Token 无效或已过期 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 每次刷新会生成新的 Refresh Token,旧的 Refresh Token 会立即失效
|
||||
- Refresh Token 只能使用一次,重复使用会返回错误
|
||||
- Refresh Token 有效期为 7 天,过期后需要重新登录
|
||||
- Refresh Token 存储在 Redis 中,服务器重启不会丢失(如果 Redis 持久化配置正确)
|
||||
|
||||
### Token 刷新策略建议
|
||||
|
||||
**前端实现建议**:
|
||||
|
||||
1. 在每次 API 请求失败(401 错误)时尝试刷新 Token
|
||||
2. 刷新成功后重试原请求
|
||||
3. 刷新失败则跳转到登录页
|
||||
4. 不要在 Token 即将过期时主动刷新,而是在使用时检查有效性
|
||||
|
||||
查看 [前端集成示例](../examples/frontend-integration.md) 了解完整的实现代码。
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [受保护接口文档](protected.md) - 需要认证的接口说明
|
||||
- [认证机制详解](../authentication.md) - 完整的认证流程说明
|
||||
- [API 概览](../api-overview.md) - 所有接口快速索引
|
||||
- [前端集成示例](../examples/frontend-integration.md) - 前端集成代码示例
|
||||
|
||||
---
|
||||
|
||||
**提示**:建议使用 Postman、Insomnia 或类似工具测试 API 接口。
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
# 前端集成示例
|
||||
|
||||
本文档提供完整的前端集成代码示例,包括 JavaScript/TypeScript、React 和 Vue。
|
||||
|
||||
## 目录
|
||||
|
||||
- [TypeScript 基础示例](#typescript-基础示例)
|
||||
- [React 集成示例](#react-集成示例)
|
||||
- [Vue 集成示例](#vue-集成示例)
|
||||
- [Token 存储建议](#token-存储建议)
|
||||
- [错误处理](#错误处理)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript 基础示例
|
||||
|
||||
### 认证客户端类
|
||||
|
||||
以下是一个完整的 TypeScript 认证客户端实现,包含注册、登录、Token 刷新等功能:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
email: string;
|
||||
created_at: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
class AuthClient {
|
||||
private baseURL: string;
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
|
||||
constructor(baseURL: string = 'http://localhost:3000') {
|
||||
this.baseURL = baseURL;
|
||||
// 从 localStorage 加载 Token
|
||||
this.accessToken = localStorage.getItem('access_token');
|
||||
this.refreshToken = localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(email: string, password: string): Promise<RegisterResponse> {
|
||||
const response = await fetch(`${this.baseURL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<RegisterResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(email: string, password: string): Promise<LoginResponse> {
|
||||
const response = await fetch(`${this.baseURL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<LoginResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshTokens(): Promise<void> {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: this.refreshToken }),
|
||||
});
|
||||
|
||||
const result: ApiResponse<RefreshResponse> = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
this.saveTokens(result.data.access_token, result.data.refresh_token);
|
||||
} else {
|
||||
this.clearTokens();
|
||||
throw new Error(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起需要认证的请求
|
||||
*/
|
||||
async authenticatedFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
await this.refreshTokens();
|
||||
// 重试原请求
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 刷新失败,清除 Token 并抛出错误
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Token 到 localStorage
|
||||
*/
|
||||
private saveTokens(accessToken: string, refreshToken: string): void {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*/
|
||||
private clearTokens(): void {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout(): void {
|
||||
this.clearTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.accessToken !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const authClient = new AuthClient();
|
||||
|
||||
// 注册
|
||||
try {
|
||||
const result = await authClient.register('user@example.com', 'password123');
|
||||
console.log('注册成功:', result);
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error);
|
||||
}
|
||||
|
||||
// 登录
|
||||
try {
|
||||
const result = await authClient.login('user@example.com', 'password123');
|
||||
console.log('登录成功:', result);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
}
|
||||
|
||||
// 访问受保护接口
|
||||
try {
|
||||
const response = await authClient.authenticatedFetch(
|
||||
'http://localhost:3000/auth/delete',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: '1234567890', password: 'password123' }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
console.log('请求成功:', data);
|
||||
} catch (error) {
|
||||
console.error('请求失败:', error);
|
||||
}
|
||||
|
||||
// 登出
|
||||
authClient.logout();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React 集成示例
|
||||
|
||||
### AuthContext Provider
|
||||
|
||||
```typescript
|
||||
// AuthContext.tsx
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 从 localStorage 加载 Token
|
||||
const storedAccessToken = localStorage.getItem('access_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedAccessToken && storedUser) {
|
||||
setAccessToken(storedAccessToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id,
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
setAccessToken(result.data.access_token);
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id || '',
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
setAccessToken(result.data.access_token);
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated: !!accessToken,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
loading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### API Hook(带 Token 刷新)
|
||||
|
||||
```typescript
|
||||
// useApi.ts
|
||||
import { useCallback } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function useApi() {
|
||||
const { accessToken, setAccessToken, logout } = useAuth();
|
||||
|
||||
const fetchWithAuth = useCallback(
|
||||
async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
const refreshResponse = await fetch('http://localhost:3000/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
const refreshResult = await refreshResponse.json();
|
||||
|
||||
if (refreshResult.code === 200) {
|
||||
setAccessToken(refreshResult.data.access_token);
|
||||
localStorage.setItem('access_token', refreshResult.data.access_token);
|
||||
localStorage.setItem('refresh_token', refreshResult.data.refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
'Authorization': `Bearer ${refreshResult.data.access_token}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 刷新失败,登出
|
||||
logout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
} else {
|
||||
logout();
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[accessToken, setAccessToken, logout]
|
||||
);
|
||||
|
||||
return { fetchWithAuth };
|
||||
}
|
||||
```
|
||||
|
||||
### 登录组件示例
|
||||
|
||||
```typescript
|
||||
// Login.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
// 登录成功,路由跳转
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2>登录</h2>
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="邮箱"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vue 集成示例
|
||||
|
||||
### Auth Composable
|
||||
|
||||
```typescript
|
||||
// composables/useAuth.ts
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const user = ref<User | null>(null);
|
||||
const accessToken = ref<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
|
||||
// 初始化:从 localStorage 加载
|
||||
const init = () => {
|
||||
const storedAccessToken = localStorage.getItem('access_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedAccessToken && storedUser) {
|
||||
accessToken.value = storedAccessToken;
|
||||
user.value = JSON.parse(storedUser);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id,
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
user.value = userData;
|
||||
accessToken.value = result.data.access_token;
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string) => {
|
||||
const response = await fetch('http://localhost:3000/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
const userData: User = {
|
||||
id: result.data.id || '',
|
||||
email: result.data.email,
|
||||
created_at: result.data.created_at,
|
||||
};
|
||||
|
||||
user.value = userData;
|
||||
accessToken.value = result.data.access_token;
|
||||
|
||||
localStorage.setItem('access_token', result.data.access_token);
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
user.value = null;
|
||||
accessToken.value = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
init,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Axios 拦截器示例
|
||||
|
||||
```typescript
|
||||
// api/axios.ts
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
// 请求拦截器:添加 Authorization header
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:处理 401 错误并刷新 Token
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (response.data.code === 200) {
|
||||
const { access_token, refresh_token } = response.data.data;
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
|
||||
// 重试原请求
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return axios(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除 Token
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
} else {
|
||||
// 没有 Refresh Token,跳转到登录页
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 存储建议
|
||||
|
||||
### localStorage vs sessionStorage vs Cookie
|
||||
|
||||
| 存储方式 | 优点 | 缺点 | 推荐场景 |
|
||||
|---------|------|------|----------|
|
||||
| localStorage | 数据持久化,刷新页面不丢失 | 容易受到 XSS 攻击 | Access Token、Refresh Token |
|
||||
| sessionStorage | 关闭标签页自动清除 | 刷新页面会丢失 | 不推荐 |
|
||||
| Cookie | 可设置 HttpOnly 防止 XSS | 容易受到 CSRF 攻击 | 服务器渲染场景 |
|
||||
|
||||
### 推荐方案
|
||||
|
||||
**前端应用(SPA)**:
|
||||
- Access Token:localStorage
|
||||
- Refresh Token:localStorage
|
||||
- 添加适当的 XSS 防护(内容安全策略、输入验证)
|
||||
|
||||
**安全性要求高的场景**:
|
||||
- Access Token:内存(React Context/Vue Reactive)
|
||||
- Refresh Token:HttpOnly Cookie(需要后端支持)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 通用错误处理
|
||||
|
||||
```typescript
|
||||
async function handleApiCall<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error as Error);
|
||||
} else {
|
||||
console.error('API 调用失败:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const result = await handleApiCall(
|
||||
() => authClient.login('user@example.com', 'password123'),
|
||||
(error) => {
|
||||
alert(`登录失败: ${error.message}`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 网络错误重试
|
||||
|
||||
```typescript
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
maxRetries: number = 3
|
||||
): Promise<Response> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) {
|
||||
throw error;
|
||||
}
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
throw new Error('Max retries reached');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [公开接口文档](../endpoints/public.md) - API 接口详细说明
|
||||
- [认证机制详解](../authentication.md) - JWT 认证流程
|
||||
- [受保护接口文档](../endpoints/protected.md) - 需要认证的接口
|
||||
|
||||
---
|
||||
|
||||
**提示**:以上示例代码仅供参考,实际使用时请根据项目需求调整。
|
||||
@@ -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` 不允许包含 `*`。
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
#### DTO(Data Transfer Object)
|
||||
|
||||
**职责**:定义 API 请求和响应的数据结构
|
||||
|
||||
**位置**:`src/domain/dto/`
|
||||
|
||||
**示例**:
|
||||
```rust
|
||||
// src/domain/dto/auth.rs
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
```
|
||||
|
||||
#### Entities(实体)
|
||||
|
||||
**职责**:定义数据库表模型
|
||||
|
||||
**位置**:`src/domain/entities/`
|
||||
|
||||
**示例**:
|
||||
```rust
|
||||
// src/domain/entities/users.rs
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text", unique)]
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
```
|
||||
|
||||
#### VO(View Object)
|
||||
|
||||
**职责**:定义 API 响应的数据结构
|
||||
|
||||
**位置**:`src/domain/vo/`
|
||||
|
||||
**示例**:
|
||||
```rust
|
||||
// src/domain/vo/auth.rs
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterResult {
|
||||
pub email: String,
|
||||
pub created_at: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResult {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub created_at: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 基础设施层(infra/)
|
||||
|
||||
**职责**:提供技术基础设施
|
||||
|
||||
**位置**:`src/infra/`
|
||||
|
||||
#### 中间件(middleware/)
|
||||
|
||||
**职责**:请求拦截和处理
|
||||
|
||||
**位置**:`src/infra/middleware/`
|
||||
|
||||
**关键文件**:
|
||||
- `auth.rs`:JWT 认证中间件
|
||||
- `logging.rs`:日志中间件
|
||||
|
||||
**示例**:
|
||||
```rust
|
||||
// src/infra/middleware/auth.rs
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ErrorResponse> {
|
||||
// 1. 提取 Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ErrorResponse::new("缺少 Authorization header".to_string()))?;
|
||||
|
||||
// 2. 验证 Bearer 格式
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(ErrorResponse::new("Authorization header 格式错误".to_string()));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..];
|
||||
|
||||
// 3. 验证 JWT
|
||||
let claims = TokenService::decode_user_id(token, &state.config.auth.jwt_secret)?;
|
||||
|
||||
// 4. 将 user_id 添加到请求扩展
|
||||
request.extensions_mut().insert(claims.sub);
|
||||
|
||||
// 5. 继续处理请求
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
```
|
||||
|
||||
#### Redis 客户端(redis/)
|
||||
|
||||
**职责**:封装 Redis 操作
|
||||
|
||||
**位置**:`src/infra/redis/`
|
||||
|
||||
**关键文件**:
|
||||
- `redis_client.rs`:Redis 客户端封装
|
||||
- `redis_key.rs`:Redis Key 命名规范
|
||||
|
||||
### 6. 工具层(utils/)
|
||||
|
||||
**职责**:提供通用工具函数
|
||||
|
||||
**位置**:`src/utils/`
|
||||
|
||||
**关键文件**:
|
||||
- `jwt.rs`:JWT Token 生成和验证
|
||||
|
||||
**示例**:
|
||||
```rust
|
||||
// src/utils/jwt.rs
|
||||
|
||||
pub struct TokenService;
|
||||
|
||||
impl TokenService {
|
||||
/// 生成 Access Token
|
||||
pub fn generate_access_token(
|
||||
user_id: &str,
|
||||
expiration_minutes: u64,
|
||||
jwt_secret: &str,
|
||||
) -> Result<String> {
|
||||
// ... 生成 JWT Token
|
||||
}
|
||||
|
||||
/// 验证 Token 并提取 user_id
|
||||
pub fn decode_user_id(token: &str, jwt_secret: &str) -> Result<String> {
|
||||
// ... 验证并解码 JWT Token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流转
|
||||
|
||||
### 用户注册流程
|
||||
|
||||
```
|
||||
1. 客户端发起 POST /auth/register 请求
|
||||
↓
|
||||
2. handlers/auth.rs::register() 接收请求
|
||||
- 提取请求参数(RegisterRequest)
|
||||
↓
|
||||
3. services/auth_service.rs::register() 处理业务逻辑
|
||||
- 验证邮箱格式
|
||||
- 生成唯一用户 ID
|
||||
- 哈希密码
|
||||
- 创建用户实体
|
||||
↓
|
||||
4. repositories/user_repository.rs::create() 保存到数据库
|
||||
- 使用 SeaORM 插入数据
|
||||
↓
|
||||
5. services/auth_service.rs 生成 Token
|
||||
- 生成 Access Token
|
||||
- 生成 Refresh Token
|
||||
↓
|
||||
6. Redis 保存 Refresh Token
|
||||
↓
|
||||
7. handlers/auth.rs 封装响应(RegisterResult)
|
||||
↓
|
||||
8. 返回 JSON 响应给客户端
|
||||
```
|
||||
|
||||
### 访问受保护接口流程
|
||||
|
||||
```
|
||||
1. 客户端发起 POST /auth/delete 请求
|
||||
- 携带 Authorization: Bearer <access_token>
|
||||
↓
|
||||
2. infra/middleware/auth.rs::auth_middleware() 拦截
|
||||
- 验证 Token 格式
|
||||
- 验证 JWT 签名
|
||||
- 检查 Token 过期时间
|
||||
- 提取 user_id 并添加到请求扩展
|
||||
↓
|
||||
3. handlers/auth.rs::delete_account() 接收请求
|
||||
- 从扩展中提取 user_id
|
||||
↓
|
||||
4. services/auth_service.rs::delete_account() 处理业务逻辑
|
||||
- 验证密码
|
||||
- 调用 Repository 删除用户
|
||||
↓
|
||||
5. repositories/user_repository.rs::delete() 删除数据库记录
|
||||
↓
|
||||
6. 返回响应
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心组件
|
||||
|
||||
### AppState
|
||||
|
||||
**职责**:应用全局状态
|
||||
|
||||
**位置**:`src/main.rs`
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: db::DbPool, // 数据库连接池
|
||||
pub config: config::app::AppConfig, // 应用配置
|
||||
pub redis_client: infra::redis::redis_client::RedisClient, // Redis 客户端
|
||||
}
|
||||
```
|
||||
|
||||
**用途**:
|
||||
- 通过 Axum State 机制注入到所有处理器
|
||||
- 提供数据库访问
|
||||
- 提供配置信息
|
||||
- 提供 Redis 访问
|
||||
|
||||
### 路由配置
|
||||
|
||||
**位置**:`src/main.rs`
|
||||
|
||||
```rust
|
||||
// 公开路由
|
||||
let public_routes = Router::new()
|
||||
.route("/health", get(handlers::health::health_check))
|
||||
.route("/info", get(handlers::health::server_info))
|
||||
.route("/auth/register", post(handlers::auth::register))
|
||||
.route("/auth/login", post(handlers::auth::login))
|
||||
.route("/auth/refresh", post(handlers::auth::refresh));
|
||||
|
||||
// 受保护路由
|
||||
let protected_routes = Router::new()
|
||||
.route("/auth/delete", post(handlers::auth::delete_account))
|
||||
.route("//auth/delete-refresh-token", post(handlers::auth::delete_refresh_token))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
infra::middleware::auth::auth_middleware,
|
||||
));
|
||||
|
||||
// 合并所有路由
|
||||
let app = public_routes
|
||||
.merge(protected_routes)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(
|
||||
infra::middleware::logging::logging_middleware,
|
||||
));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [DDD 架构规范](ddd-architecture.md) - DDD 设计原则和最佳实践
|
||||
- [代码风格规范](code-style.md) - Rust 代码风格和命名规范
|
||||
- [快速开始指南](getting-started.md) - 安装和运行项目
|
||||
|
||||
---
|
||||
|
||||
**提示**:遵循 DDD 分层架构可以提高代码质量和可维护性。
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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: *** }}")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod mailbox;
|
||||
pub mod user;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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-only,balance_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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
/// 所属邮箱 ID(FK 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,
|
||||
/// 所属邮件 ID(FK 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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
/// 灰名单条目(triplet:sender_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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
/// 所属用户 ID(FK 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod entities;
|
||||
pub mod vo;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// 用户相关 VO(预留)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! 管理后台接口(RBAC:require_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
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! 临时邮箱与邮件 HTTP 接口 + WebSocket 实时推送。
|
||||
//!
|
||||
//! - 邮箱管理(创建/列表/吊销/轮换):用户 JWT
|
||||
//! - 邮件查询(列表/详情/删除/附件/WS):邮箱级 access_token(mailbox_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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod spf;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod mailer;
|
||||
pub mod worker;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod mail;
|
||||
pub mod middleware;
|
||||
pub mod redis;
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod redis_client;
|
||||
pub mod redis_key;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user