Implement compression quota refunds and admin manual subscription

This commit is contained in:
2025-12-19 23:28:32 +08:00
commit 11f48fd3dd
106 changed files with 27848 additions and 0 deletions

6
.cargo/config.toml Normal file
View File

@@ -0,0 +1,6 @@
[http]
timeout = 600
low-speed-limit = 1
[net]
retry = 10

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
# 运行模式api | worker
IMAGEFORGE_ROLE=api
# 服务配置
HOST=0.0.0.0
PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
RUST_LOG=info,tower_http=info,imageforge=debug
# 数据库
DATABASE_URL=postgres://imageforge:devpassword@localhost:5432/imageforge
DATABASE_MAX_CONNECTIONS=10
# Redis
REDIS_URL=redis://localhost:6379
# JWT网站/管理后台)
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRY_HOURS=168
# API Key仅 Pro/Business 可创建)
API_KEY_PEPPER=please-change-this-in-production
# 存储(生产建议 S3/MinIO + 预签名 URL
STORAGE_TYPE=local # local | s3
STORAGE_PATH=./uploads
SIGNED_URL_TTL_MINUTES=60
# S3 配置(如果使用 S3/MinIO
# S3_ENDPOINT=http://localhost:9000
# S3_BUCKET=your-bucket
# S3_REGION=us-east-1
# S3_ACCESS_KEY=xxx
# S3_SECRET_KEY=xxx
# 计费已确认Stripe
BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# 邮件服务(注册验证 + 密码重置)
MAIL_ENABLED=false
MAIL_LOG_LINKS_WHEN_DISABLED=true
MAIL_PROVIDER=qq # qq | 163 | aliyun_enterprise | tencent_enterprise | gmail | outlook | custom
MAIL_FROM=noreply@example.com
MAIL_PASSWORD=your-smtp-authorization-code
MAIL_FROM_NAME=ImageForge
# MAIL_SMTP_HOST=smtp.example.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_ENCRYPTION=ssl # ssl | starttls | none
# 限制(默认值;最终以套餐/用户覆盖为准)
ALLOW_ANONYMOUS_UPLOAD=true
ANON_MAX_FILE_SIZE_MB=5
ANON_MAX_FILES_PER_BATCH=5
ANON_DAILY_UNITS=10
MAX_IMAGE_PIXELS=40000000
IDEMPOTENCY_TTL_HOURS=24
# 结果保留(匿名默认;登录用户按套餐 retention_days
ANON_RETENTION_HOURS=24
# 管理员初始账户(首启可自动创建)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme123

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
/target
/.sqlx
/.env
/.env.*
!/\.env.example
/uploads
/static
/frontend/node_modules
/frontend/dist
.DS_Store

4997
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

56
Cargo.toml Normal file
View File

@@ -0,0 +1,56 @@
[package]
name = "imageforge"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-full", "fs"] }
axum-extra = { version = "0.12", features = ["cookie"] }
time = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] }
redis = { version = "0.24", features = ["tokio-comp", "connection-manager", "streams"] }
# Auth / security
argon2 = "0.5"
base64 = "0.22"
hex = "0.4"
hmac = "0.12"
jsonwebtoken = "9"
rand = "0.8"
sha2 = "0.10"
aes-gcm = "0.10"
# Images
image = { version = "0.25", features = ["avif-native"] }
oxipng = "9"
ravif = "0.11"
webp = "0.3"
rgb = "0.8"
img-parts = "0.4"
# HTTP client (Stripe API)
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
# Mail
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"] }
# ZIP download (batch)
tokio-util = { version = "0.7", features = ["io"] }
zip = "2"

149
README.md Normal file
View File

@@ -0,0 +1,149 @@
# ImageForge - Rust 图片压缩服务
一个基于 Rust 的高性能图片压缩服务,提供 Web 界面 + 对外 APIAPI Key+ 计费能力,支持用户系统与管理员后台。
## 项目概述
### 核心功能
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态)
- **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100数值越大压缩越强
- **用户系统**注册、登录、API Key 管理
- **计费与用量**:套餐/订阅/配额/发票
- **管理员后台**:用户管理、系统监控、配置管理
### 技术栈
| 层级 | 技术选型 | 说明 |
|------|----------|------|
| 后端框架 | Axum | 高性能异步 Web 框架 |
| 图片处理 | image-rs + oxipng + webp + ravif | PNG/WebP/AVIF 编解码与优化 |
| 数据库 | PostgreSQL + SQLx | 类型安全的异步数据库 |
| 缓存/队列 | Redis | 会话管理、限流、任务队列Streams |
| 前端 | Vue3 + TypeScript | SPA 单页应用 |
| 认证 | JWT + API Key | 双重认证机制 |
| 存储 | S3 兼容 / 本地 | 对象存储 + 预签名 URL推荐 |
| 计费 | Stripe | Checkout/Portal/Webhook |
## 目录结构
> 说明:以下为目标目录结构(规划),会随实现逐步补齐。
```
imageforge/
├── Cargo.toml
├── src/
│ ├── main.rs # 入口
│ ├── config.rs # 配置管理
│ ├── error.rs # 错误处理
│ ├── lib.rs
│ │
│ ├── api/ # API 路由
│ │ ├── mod.rs
│ │ ├── auth.rs # 认证相关
│ │ ├── compress.rs # 压缩相关
│ │ ├── user.rs # 用户相关
│ │ └── admin.rs # 管理员相关
│ │
│ ├── services/ # 业务逻辑
│ │ ├── mod.rs
│ │ ├── compress.rs # 压缩服务
│ │ ├── auth.rs # 认证服务
│ │ ├── user.rs # 用户服务
│ │ └── storage.rs # 存储服务
│ │
│ ├── models/ # 数据模型
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ ├── image.rs
│ │ └── api_key.rs
│ │
│ ├── compress/ # 压缩核心
│ │ ├── mod.rs
│ │ ├── png.rs # PNG 压缩oxipng + pngquant
│ │ ├── jpeg.rs # JPEG 压缩mozjpeg
│ │ ├── webp.rs # WebP 压缩
│ │ └── avif.rs # AVIF 压缩
│ │
│ └── middleware/ # 中间件
│ ├── mod.rs
│ ├── auth.rs # 认证中间件
│ └── rate_limit.rs # 限流中间件
├── migrations/ # 数据库迁移
├── static/ # 静态资源
├── frontend/ # 前端项目
└── docker/ # Docker 配置
```
## 快速开始
### 环境要求
- Rust建议使用最新 stable当前依赖链要求较新的 Rust建议 `>= 1.85`
- PostgreSQL 16+
- Redis 7+
- Node.js 20+(前端构建)
### 本地开发
```bash
# 配置环境变量
cp .env.example .env
# 启动 PostgreSQL / Redis开发
docker compose -f docker/docker-compose.dev.yml up -d
# 初始化数据库
psql "$DATABASE_URL" -f migrations/001_init.sql
# 启动后端
cargo run
# 启动前端(另一个终端)
cd frontend && npm run dev
```
### Docker 部署
```bash
# 该 compose 仅包含 postgres/redis服务本体请按 docs/deployment.md 构建运行
docker compose -f docker/docker-compose.dev.yml up -d
```
## 文档索引
- [开工前确认清单](./docs/confirm.md)
- [产品需求PRD](./docs/prd.md)
- [技术架构设计](./docs/architecture.md)
- [API 接口文档](./docs/api.md)
- [数据库设计](./docs/database.md)
- [前端设计](./docs/frontend.md)
- [UI/UX 设计](./docs/ui.md)
- [计费与用量设计](./docs/billing.md)
- [安全与风控](./docs/security.md)
- [邮件服务](./docs/email.md)
- [可观测性](./docs/observability.md)
- [部署指南](./docs/deployment.md)
- [服务条款(模板)](./docs/terms.md)
- [隐私政策(模板)](./docs/privacy.md)
## 已确认清单
- [x] 支付渠道首期选型Stripe
- [x] 计费策略:硬配额(无超额按量)
- [x] 匿名试用:支持,每日 10 次
- [x] 订阅周期:按订阅周期(非自然月)
- [x] 邮件服务:注册验证 + 密码重置SMTP预置多服务商模板
- [x] 默认语言:中文
- [x] Free 套餐 API不开放仅 Pro/Business 可用)
## 待完成清单(上线前)
- [x] 法务页面:隐私政策、服务条款(已提供模板)
- [ ] 域名与品牌名最终确认
## License
MIT

46
docker/Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
FROM rust:1.92-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
nasm \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY templates ./templates
RUN cargo build --release
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/imageforge ./imageforge
COPY --from=frontend-builder /app/frontend/dist ./static
COPY migrations ./migrations
RUN mkdir -p uploads
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
CMD ["./imageforge"]

View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: imageforge
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

53
docker/nginx.conf Normal file
View File

@@ -0,0 +1,53 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
client_max_body_size 100M;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
upstream imageforge_api {
server api:8080;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://imageforge_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /downloads/ {
proxy_pass http://imageforge_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://imageforge_api;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
}

685
docs/api.md Normal file
View File

@@ -0,0 +1,685 @@
# API 接口文档v1- ImageForge
面向两类使用者:
- **网站Web**:上传/批量/历史/账单等(可能包含匿名试用)。
- **对外 APIDeveloper API**API Key 调用、可计量可计费、适配 CI/CD 与服务端集成。
产品范围与计费口径见:
- `docs/prd.md`
- `docs/billing.md`
---
## 1. 基础信息
- **Base URL**: `https://your-domain.com/api/v1`
- **数据格式**: JSON除明确标注“返回二进制”接口
- **时间格式**: ISO 8601 / UTC`2025-01-15T10:30:00Z`
- **ID 格式**: UUID 字符串
---
## 2. 认证
支持三种身份:
### 2.1 JWT网站/管理后台)
```http
Authorization: Bearer <token>
```
### 2.2 API Key对外 API
```http
X-API-Key: <your-api-key>
```
> **注意**:仅 **Pro** 和 **Business** 套餐用户可创建 API Key。Free 用户尝试创建时返回 `FORBIDDEN`HTTP `403`)。
### 2.3 匿名试用(仅网站场景)
- 不提供 API Key
- 通过 Cookie 维持匿名会话(服务端签发),仅允许较小文件与较低频率。
- 每日 10 次(以成功压缩文件数计);超出返回 `QUOTA_EXCEEDED`HTTP `402`)。
- 日界自然日UTC+8次日 00:00 重置。
- **匿名试用硬限制Cookie + IP 双限制**(两者任一超出都拒绝),降低刷会话绕过风险。
---
## 3. 通用约定
### 3.1 幂等(强烈建议)
对会产生计费/创建任务的接口,建议客户端传:
```http
Idempotency-Key: <uuid-or-random-string>
```
规则(建议口径):
- 同一个 `Idempotency-Key` 在 TTL 内重复请求,若请求参数一致则返回首次结果(不重复扣费/不重复创建任务)。
- 若参数不一致,返回 `409 IDEMPOTENCY_CONFLICT`
### 3.2 限流Rate Limit
超出限制返回:
- HTTP `429`
- 头:`Retry-After: <seconds>`
建议头(可选):
- `RateLimit-Limit`
- `RateLimit-Remaining`
- `RateLimit-Reset`
### 3.3 配额Quota / Billing
配额不足(当期额度耗尽)返回:
- HTTP `402`
- 错误码:`QUOTA_EXCEEDED`
配额周期:
- Pro/Business付费按订阅周期重置`period_start` ~ `period_end`),不是自然月。
- Free未订阅按自然月UTC+8重置。
- 匿名试用按自然日UTC+8重置。
建议头(可选):
- `X-Quota-Limit`
- `X-Quota-Remaining`
- `X-Quota-Reset-At`
### 3.4 通用响应格式JSON
成功:
```json
{ "success": true, "data": {} }
```
错误:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "错误描述",
"request_id": "req_..."
}
}
```
### 3.5 错误码(建议集合)
| 错误码 | HTTP | 说明 |
|---|---:|---|
| `INVALID_REQUEST` | 400 | 参数不合法 |
| `INVALID_IMAGE` | 400 | 图片解码失败/文件损坏 |
| `UNSUPPORTED_FORMAT` | 400 | 不支持的格式 |
| `TOO_MANY_PIXELS` | 400 | 像素超限(防图片炸弹) |
| `UNAUTHORIZED` | 401 | 未认证 |
| `FORBIDDEN` | 403 | 权限不足 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `IDEMPOTENCY_CONFLICT` | 409 | 幂等 key 冲突 |
| `QUOTA_EXCEEDED` | 402 | 配额不足 |
| `FILE_TOO_LARGE` | 413 | 文件过大 |
| `RATE_LIMITED` | 429 | 请求过于频繁 |
| `EMAIL_NOT_VERIFIED` | 403 | 邮箱未验证 |
| `INVALID_TOKEN` | 400 | Token 无效或已过期 |
| `COMPRESSION_FAILED` | 500 | 压缩失败 |
| `STORAGE_UNAVAILABLE` | 503 | 存储不可用 |
| `MAIL_SEND_FAILED` | 500 | 邮件发送失败 |
---
## 4. 认证接口
### 4.1 用户注册
```http
POST /auth/register
Content-Type: application/json
```
请求体:
```json
{ "email": "user@example.com", "password": "securepassword123", "username": "myusername" }
```
响应:
```json
{
"success": true,
"data": {
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "myusername",
"email_verified": false,
"created_at": "2025-01-15T10:30:00Z"
},
"token": "eyJhbGciOi...",
"message": "注册成功,验证邮件已发送至您的邮箱"
}
}
```
> **注意**:注册后自动发送验证邮件。用户需验证邮箱后才能使用压缩功能(未验证时调用压缩接口返回 `EMAIL_NOT_VERIFIED`)。
### 4.2 用户登录
```http
POST /auth/login
Content-Type: application/json
```
请求体:
```json
{ "email": "user@example.com", "password": "securepassword123" }
```
响应:
```json
{
"success": true,
"data": {
"token": "eyJhbGciOi...",
"expires_at": "2025-01-22T10:30:00Z",
"user": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "username": "myusername", "role": "user" }
}
}
```
### 4.3 刷新 Token
```http
POST /auth/refresh
Authorization: Bearer <token>
```
### 4.4 登出
```http
POST /auth/logout
Authorization: Bearer <token>
```
### 4.5 发送验证邮件
用户注册后自动发送一次;此接口用于重新发送。
```http
POST /auth/send-verification
Authorization: Bearer <token>
```
**限流**:同一用户 1 分钟内最多 1 次
响应:
```json
{ "success": true, "data": { "message": "验证邮件已发送,请查收" } }
```
### 4.6 验证邮箱
```http
POST /auth/verify-email
Content-Type: application/json
```
请求体:
```json
{ "token": "verification-token-from-email" }
```
响应:
```json
{ "success": true, "data": { "message": "邮箱验证成功" } }
```
### 4.7 请求密码重置
```http
POST /auth/forgot-password
Content-Type: application/json
```
请求体:
```json
{ "email": "user@example.com" }
```
**限流**:同一 IP 1 分钟内最多 3 次
响应(无论邮箱是否存在都返回成功,防止枚举):
```json
{ "success": true, "data": { "message": "如果该邮箱已注册,您将收到重置邮件" } }
```
### 4.8 重置密码
```http
POST /auth/reset-password
Content-Type: application/json
```
请求体:
```json
{ "token": "reset-token-from-email", "new_password": "new-secure-password" }
```
响应:
```json
{ "success": true, "data": { "message": "密码重置成功,请重新登录" } }
```
---
## 5. 图片压缩接口
### 5.1 单图压缩(同步,返回 JSON + 下载链接)
适用于网站与轻量同步调用(服务端可选择是否落盘/落对象存储)。
```http
POST /compress
Content-Type: multipart/form-data
Authorization: Bearer <token> # X-API-Key
Idempotency-Key: <key> #
```
表单字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `file` | File | 是 | 图片文件 |
| `compression_rate` | Integer | 否 | 压缩率 1-100数值越大压缩越强优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` |
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
| `max_width` | Integer | 否 | 最大宽度(等比缩放) |
| `max_height` | Integer | 否 | 最大高度(等比缩放) |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |
响应:
```json
{
"success": true,
"data": {
"task_id": "550e8400-e29b-41d4-a716-446655440100",
"file_id": "550e8400-e29b-41d4-a716-446655440101",
"format_in": "png",
"format_out": "png",
"original_size": 1024000,
"compressed_size": 256000,
"saved_bytes": 768000,
"saved_percent": 75.0,
"download_url": "/downloads/550e8400-e29b-41d4-a716-446655440101",
"expires_at": "2025-01-15T11:30:00Z",
"billing": { "units_charged": 1 }
}
}
```
### 5.2 单图压缩(同步,直接返回二进制)
更贴近开发者体验,适用于 SDK/CI。
```http
POST /compress/direct
Content-Type: multipart/form-data
X-API-Key: <your-api-key> # Bearer token
Idempotency-Key: <key> #
```
成功响应:
- HTTP `200`
- Body压缩后的图片二进制
- `Content-Type`: `image/png` / `image/jpeg` / `image/webp` / `image/avif` / `image/gif` / `image/bmp` / `image/tiff` / `image/x-icon`
建议响应头(示例):
```http
ImageForge-Original-Size: 1024000
ImageForge-Compressed-Size: 256000
ImageForge-Saved-Bytes: 768000
ImageForge-Saved-Percent: 75.0
ImageForge-Units-Charged: 1
```
### 5.3 批量压缩(异步任务)
适用于多文件或大文件;由 Worker 处理并持续更新进度。
```http
POST /compress/batch
Content-Type: multipart/form-data
Authorization: Bearer <token> # X-API-Key
Idempotency-Key: <key> #
```
表单字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
| `compression_rate` | Integer | 否 | 压缩率 1-100数值越大压缩越强优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |
响应:
```json
{
"success": true,
"data": {
"task_id": "550e8400-e29b-41d4-a716-446655440200",
"total_files": 10,
"status": "pending",
"status_url": "/compress/tasks/550e8400-e29b-41d4-a716-446655440200"
}
}
```
配额规则补充:
- 若本周期剩余单位不足以覆盖本次上传的文件数,服务端应直接返回 `402 QUOTA_EXCEEDED`(不创建任务)。
### 5.4 查询任务状态
```http
GET /compress/tasks/{task_id}
Authorization: Bearer <token> # X-API-Key Cookie
```
响应:
```json
{
"success": true,
"data": {
"task_id": "550e8400-e29b-41d4-a716-446655440200",
"status": "completed",
"progress": 100,
"total_files": 10,
"completed_files": 10,
"failed_files": 0,
"files": [
{
"file_id": "550e8400-e29b-41d4-a716-446655440201",
"original_name": "photo1.png",
"original_size": 1024000,
"compressed_size": 256000,
"saved_percent": 75.0,
"status": "completed",
"download_url": "/downloads/550e8400-e29b-41d4-a716-446655440201"
}
],
"download_all_url": "/downloads/tasks/550e8400-e29b-41d4-a716-446655440200",
"created_at": "2025-01-15T10:30:00Z",
"completed_at": "2025-01-15T10:31:00Z",
"expires_at": "2025-01-22T10:30:00Z"
}
}
```
### 5.5 取消任务(可选)
```http
POST /compress/tasks/{task_id}/cancel
Authorization: Bearer <token>
```
### 5.6 删除任务与文件(隐私/合规)
```http
DELETE /compress/tasks/{task_id}
Authorization: Bearer <token>
```
---
## 6. 下载接口
### 6.1 下载单个文件
```http
GET /downloads/{file_id}
Authorization: Bearer <token> # X-API-Key Cookie
```
### 6.2 下载批量 ZIP
```http
GET /downloads/tasks/{task_id}
Authorization: Bearer <token> # X-API-Key Cookie
```
---
## 7. 用户接口
### 7.1 获取当前用户信息
```http
GET /user/profile
Authorization: Bearer <token>
```
响应(示例):
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "myusername",
"role": "user"
}
}
```
### 7.2 更新用户信息
```http
PUT /user/profile
Authorization: Bearer <token>
Content-Type: application/json
```
### 7.3 修改密码
```http
PUT /user/password
Authorization: Bearer <token>
Content-Type: application/json
```
### 7.4 获取压缩历史
```http
GET /user/history?page=1&limit=20
Authorization: Bearer <token>
```
---
## 8. API Key 管理
### 8.1 获取 API Key 列表
```http
GET /user/api-keys
Authorization: Bearer <token>
```
### 8.2 创建 API Key
```http
POST /user/api-keys
Authorization: Bearer <token>
Content-Type: application/json
```
请求体:
```json
{ "name": "Production Server", "permissions": ["compress", "batch_compress"] }
```
响应:
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440300",
"name": "Production Server",
"key": "if_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"message": "请保存此 Key它只会显示一次"
}
}
```
### 8.3 轮换 API Key可选
```http
POST /user/api-keys/{key_id}/rotate
Authorization: Bearer <token>
```
### 8.4 删除/禁用 API Key
```http
DELETE /user/api-keys/{key_id}
Authorization: Bearer <token>
```
---
## 9. 计费与用量Billing
### 9.1 获取套餐列表(公开)
```http
GET /billing/plans
```
响应(示例):
```json
{
"success": true,
"data": {
"plans": [
{
"id": "550e8400-e29b-41d4-a716-446655440900",
"code": "pro_monthly",
"name": "Pro月付",
"currency": "CNY",
"amount_cents": 1999,
"interval": "monthly",
"included_units_per_period": 10000,
"max_file_size_mb": 20,
"max_files_per_batch": 50,
"retention_days": 7,
"features": { "webhook": true }
}
]
}
}
```
### 9.2 获取当前订阅
```http
GET /billing/subscription
Authorization: Bearer <token>
```
### 9.3 获取当期用量
```http
GET /billing/usage
Authorization: Bearer <token>
```
响应:
```json
{
"success": true,
"data": {
"period_start": "2025-01-01T00:00:00Z",
"period_end": "2025-02-01T00:00:00Z",
"used_units": 120,
"included_units": 10000,
"bonus_units": 500,
"total_units": 10500,
"remaining_units": 10380
}
}
```
### 9.4 创建 Checkout订阅/升级)
```http
POST /billing/checkout
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <key>
```
请求体:
```json
{ "plan_id": "550e8400-e29b-41d4-a716-446655440900" }
```
响应:
```json
{ "success": true, "data": { "checkout_url": "https://pay.example.com/..." } }
```
### 9.5 打开客户 Portal管理支付方式/取消订阅)
```http
POST /billing/portal
Authorization: Bearer <token>
```
### 9.6 发票列表
```http
GET /billing/invoices?page=1&limit=20
Authorization: Bearer <token>
```
---
## 10. Webhooks支付回调
> 无需登录;必须验签与幂等处理,详见 `docs/billing.md` 与 `docs/security.md`。
### 10.1 Stripe 回调(示例)
```http
POST /webhooks/stripe
Content-Type: application/json
Stripe-Signature: t=...,v1=...
```
---
## 11. 管理员接口
> 需要管理员权限(`role: admin`
### 11.1 获取系统统计
```http
GET /admin/stats
Authorization: Bearer <admin_token>
```
### 11.2 用户管理(示例)
```http
GET /admin/users?page=1&limit=20&search=keyword
Authorization: Bearer <admin_token>
```
### 11.3 系统配置
```http
GET /admin/config
Authorization: Bearer <admin_token>
```
```http
PUT /admin/config
Authorization: Bearer <admin_token>
Content-Type: application/json
```
### 11.4 任务管理
```http
GET /admin/tasks?status=processing&page=1
Authorization: Bearer <admin_token>
```
```http
POST /admin/tasks/{task_id}/cancel
Authorization: Bearer <admin_token>
```
### 11.5 计费管理(建议)
```http
GET /admin/billing/subscriptions?page=1&limit=20
Authorization: Bearer <admin_token>
```
```http
POST /admin/billing/credits
Authorization: Bearer <admin_token>
Content-Type: application/json
```
---
## 12. WebSocket网站任务进度
网站侧可用 WebSocket 或 SSESSE 更易穿透代理)。当前先保留 WebSocket 方案:
```
ws://your-domain.com/ws/tasks/{task_id}?token=<jwt_token>
```
消息(示例):
```json
{ "type": "progress", "data": { "task_id": "550e8400-e29b-41d4-a716-446655440200", "progress": 50, "completed_files": 5 } }
```

334
docs/architecture.md Normal file
View File

@@ -0,0 +1,334 @@
# 技术架构设计
> 目标:支撑“网站 + 对外 API + 计费”的商用闭环。产品范围见 `docs/prd.md`,计费口径见 `docs/billing.md`。
## 系统架构图
```
┌───────────────────────────────┐
│ CDN (静态资源/下载可选加速) │
└──────────────┬────────────────┘
┌──────▼──────┐
│ Nginx/Caddy │
│ TLS/反向代理 │
└──────┬──────┘
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web 前端 (Vue3) │ │ API 服务 (Axum) │ │ Admin 前端(Vue3)│
│ 上传/结果/账单 │ │ 认证/计费/接口 │ │ 运营/风控/配置 │
└─────────────────┘ └───────┬─────────┘ └─────────────────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PostgreSQL (DB) │ │ Redis (缓存/队列)│ │ 对象存储 (S3) │
│ 用户/任务/账单/用量│ │ Streams/RateLimit│ │ 原图/压缩结果 │
└─────────┬───────┘ └───────┬─────────┘ └─────────┬───────┘
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Worker (Rust) │ │
│ │ 压缩/计量/回写 │ │
│ └───────────────┘ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 支付渠道/网关 │◄──────Webhooks───────│ API(Webhook处理) │
│ Stripe │ │ 订阅/发票/状态 │
└─────────────────┘ └─────────────────┘
```
## 核心组件设计
### 0. 服务拆分(推荐)
为避免 CPU 密集的压缩任务影响 API 延迟,建议最小拆分为:
- **API 服务**:认证、限流、计费/订阅、任务编排、回调、签名 URL、管理后台 API。
- **Worker 服务**执行图片压缩CPU 密集)、写入结果、落用量账本、推送进度。
本地开发可以合并进一个进程feature flag生产建议分开部署并可独立扩容。
### 1. 压缩引擎
```rust
// 压缩配置
pub enum CompressionLevel {
/// 高压缩比 - 有损压缩,文件最小
High,
/// 中等压缩 - 平衡模式
Medium,
/// 低压缩比 - 无损/近无损,质量优先
Low,
}
pub struct CompressionConfig {
pub level: CompressionLevel,
pub output_format: Option<ImageFormat>, // 可选转换格式
pub max_width: Option<u32>, // 可选调整尺寸
pub max_height: Option<u32>,
pub preserve_metadata: bool, // 是否保留元数据(默认 false
}
```
### 2. 压缩策略
| 格式 | 高压缩比(有损) | 低压缩比(无损) | 使用库 |
|------|-----------------|-----------------|--------|
| PNG | pngquant 量化到 256 色 | oxipng 无损优化 | `imagequant` + `oxipng` |
| JPEG | mozjpeg quality=60 | mozjpeg quality=90 | `mozjpeg` |
| WebP | lossy quality=75 | lossless | `webp` |
| AVIF | quality=50 | quality=90 | `ravif` |
```rust
// 压缩核心逻辑
pub trait ImageCompressor: Send + Sync {
async fn compress(&self, input: &[u8], config: &CompressionConfig) -> Result<Vec<u8>>;
fn supported_formats(&self) -> Vec<ImageFormat>;
}
pub struct PngCompressor;
pub struct JpegCompressor;
pub struct WebpCompressor;
pub struct AvifCompressor;
// 统一压缩入口
pub struct CompressionEngine {
compressors: HashMap<ImageFormat, Box<dyn ImageCompressor>>,
}
impl CompressionEngine {
pub async fn compress(&self, input: &[u8], config: &CompressionConfig) -> Result<CompressionResult> {
let format = detect_format(input)?;
let compressor = self.compressors.get(&format)
.ok_or(Error::UnsupportedFormat)?;
let output = compressor.compress(input, config).await?;
Ok(CompressionResult {
original_size: input.len(),
compressed_size: output.len(),
format,
data: output,
})
}
}
```
### 3. 任务处理模型
支持两种模式:
#### 同步模式(小文件/单文件)
```
请求 -> 压缩 -> 直接返回结果
```
#### 异步模式(大文件/批量)
```
请求 -> 创建任务 -> 返回任务ID
后台Worker处理
客户端轮询/WebSocket通知
下载结果
```
```rust
pub enum TaskStatus {
Pending,
Processing,
Completed,
Failed,
}
pub struct CompressionTask {
pub id: Uuid,
pub user_id: Option<Uuid>, // 游客为空
pub session_id: Option<String>, // 游客会话Cookie
pub status: TaskStatus,
pub files: Vec<FileTask>,
pub config: CompressionConfig,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
pub struct FileTask {
pub id: Uuid,
pub original_name: String,
pub original_size: u64,
pub compressed_size: Option<u64>,
pub status: TaskStatus,
pub error: Option<String>,
}
```
### 4. 用户认证系统
```rust
// JWT Claims
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid, // 用户ID
pub role: UserRole, // 用户角色
pub exp: i64, // 过期时间
}
pub enum UserRole {
User,
Admin,
}
// API Key 认证
pub struct ApiKey {
pub id: Uuid,
pub user_id: Uuid,
pub key_prefix: String, // 前缀索引(仅展示用)
pub key_hash: String, // 推荐HMAC-SHA256(key, server_pepper) 或 sha256+pepper
pub name: String,
pub permissions: Vec<Permission>,
pub rate_limit: u32, // 每分钟请求数
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
pub enum Permission {
Compress,
BatchCompress,
ReadStats,
BillingRead, // 查看账单/用量(可选)
WebhookManage, // 管理 Webhook可选
}
```
### 5. 限流与配额Rate Limit & Quota
用途区分:
- **限流**:保护服务(超出返回 HTTP `429`
- **配额**:试用/计费限制(超出返回 HTTP `402` / `QUOTA_EXCEEDED`
默认建议(最终以 `system_config` + 套餐 `plans.*` 为准):
- 匿名试用:`10 req/min` + `10 units/day` + `5MB/文件` + `5 文件/批量`
- 登录用户:`60 req/min`;文件大小/批量/保留期/周期额度由套餐决定
- API Key`100 req/min`(可配置);文件大小/批量/周期额度由套餐/Key 覆盖决定
### 6. 用量计量与计费Metering & Billing
计量口径见 `docs/billing.md`,架构上建议:
- Worker 在每个文件成功输出后写入 `usage_events`(账本明细),并更新 `usage_periods`(按订阅周期聚合)。
- API 在创建任务/接收同步压缩请求时做**配额预检**快速失败Worker 做**最终扣减**(账本落地,保证一致性)。
- 对外 API 强烈建议支持 `Idempotency-Key`DB 侧存储“幂等记录 + 响应摘要”,避免重复扣减与重复任务。
### 7. 支付回调Webhooks
Stripe 通常通过 webhook 推送订阅/支付状态变更:
- API 服务提供 `/webhooks/{provider}` 入口,**验签 + 幂等 + 可重放**。
- webhook 事件入库后异步处理(避免回调超时),更新订阅/发票状态,并写审计日志。
## 核心依赖
```toml
[dependencies]
# Web 框架
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "compression"] }
# 图片处理
image = "0.25"
oxipng = "9"
imagequant = "4" # PNG 有损压缩pngquant 核心)
mozjpeg = "0.10" # JPEG 压缩
webp = "0.3" # WebP 编解码
ravif = "0.11" # AVIF 编码
# 数据库
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
# Redis
redis = { version = "0.24", features = ["tokio-comp"] }
# 认证
jsonwebtoken = "9"
argon2 = "0.5" # 密码哈希
# 工具
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
# 配置
config = "0.14"
dotenvy = "0.15"
```
## 性能考虑
### 1. 并发处理
- 使用 Tokio 异步运行时
- 图片压缩使用 `spawn_blocking` 避免阻塞异步线程
- 可配置 Worker 线程数
```rust
// 在独立线程池中执行 CPU 密集型压缩
let result = tokio::task::spawn_blocking(move || {
compress_image_sync(&data, &config)
}).await??;
```
### 2. 内存管理
- 流式处理大文件
- 限制并发压缩任务数
- 压缩完成后立即清理临时文件
### 3. 缓存策略
- Redis 缓存用户会话
- 可选:相同图片哈希缓存结果(去重)
## 安全考虑
1. **输入验证**:检查文件魔数,不仅依赖扩展名
2. **文件大小限制**:防止 DoS
3. **像素/维度限制**:防止“图片炸弹”(解码后超大)
4. **路径遍历防护**:存储时使用 UUID 命名
5. **SQL 注入防护**使用参数化查询SQLx 自动处理)
6. **XSS 防护**:前端输出转义
7. **CSRF 防护**SameSite Cookie + Token
8. **速率限制**:防止滥用
9. **默认移除元数据**:避免泄露定位/设备信息(除非用户明确开启保留)
## 可扩展性
### 水平扩展
```
┌─────────────┐
│ Load Balancer│
└──────┬──────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ API 实例1 │ │ API 实例2 │ │ API 实例3 │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└───────────────┼───────────────┘
┌─────────────────┐
│ 共享 PostgreSQL │
│ 共享 Redis │
│ 共享 S3 存储 │
└─────────────────┘
```
### 后续可添加功能
- 消息队列RabbitMQ/NATS处理异步任务
- 分布式任务调度
- CDN 加速下载

191
docs/billing.md Normal file
View File

@@ -0,0 +1,191 @@
# 计费与用量设计Stripe + 硬配额)- ImageForge
目标:形成“套餐/订阅/用量/发票/支付/风控”可落地的闭环,为后续实现提供清晰边界与数据模型依据。
---
## 1. 计费模型(已确认)
已确认口径:
- 支付渠道:**Stripe**
- 超额策略:**硬配额**(超过当期额度返回 `QUOTA_EXCEEDED` / HTTP `402`
- 配额周期:**按订阅周期**Stripe `current_period_start` ~ `current_period_end`),不是自然月
### 1.0 周期定义(统一口径)
为避免前后端与账本口径不一致,本项目将“周期”分为三类:
- **Pro/Business付费**:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`)。
- **Free未订阅**按自然月UTC+8重置用于展示“本月已用/剩余/重置时间”。
- **匿名试用**按自然日UTC+8重置仅用于网站试用默认每日 10 次)。
### 1.1 计量单位
**compression_unit**:每成功压缩 1 个输出文件计 1 单位。
规则:
- 同一任务中失败的文件不计费。
- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。
- 输出格式转换png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
### 1.2 套餐Plan
套餐是“功能 + 配额 + 限制”的集合建议存为可配置DB 或配置中心)。
最小字段:
- `included_units_per_period`:周期含量(对付费为订阅周期;对 Free 为自然月)。
- `max_file_size_mb` / `max_files_per_batch` / `concurrency_limit`
- `retention_days`:结果保留期。
- `features`:例如 webhook、团队、IP 白名单等开关。
### 1.3 订阅Subscription
采用“月度订阅 + 含量(硬配额)”:
- 用户在每个订阅周期获得固定含量(`included_units_per_period`)。
- 超出含量:直接拒绝(`QUOTA_EXCEEDED` / HTTP `402`)。
---
## 2. 用量计算与配额扣减Metering
### 2.1 何时扣减
- **成功生成输出**时扣减(以文件粒度)。
- 对于异步任务Worker 完成文件压缩后写入用量事件API/前端通过任务查询看到实时用量。
### 2.2 幂等与去重
需要两层保护:
1) **请求幂等**`Idempotency-Key` 防止重复创建任务/重复扣减。
2) **用量幂等**:每个输出文件生成唯一 `usage_event_id`(或以 `task_file_id` 唯一)确保不会重复入账。
### 2.3 用量数据结构(建议)
- `usage_events`明细账本append-only用于可追溯与对账。
- `user_id` / `api_key_id` / `source`web/api
- `task_id` / `task_file_id`
- `units`(通常为 1
- `bytes_in` / `bytes_out` / `format_in` / `format_out`
- `occurred_at`
- `usage_periods`:按订阅周期聚合(加速配额判断)。
- `user_id` + `period_start` + `period_end` 唯一
- `used_units``bytes_in``bytes_out`
配额判断:
- 写入 `usage_events` 前先检查本周期 `used_units` 是否已达 `included_units_per_period`(并发场景需原子性:事务/锁/原子计数)。
- 失败时返回 `QUOTA_EXCEEDED`,并在响应中附带“已用/上限/重置时间”。
### 2.4 并发安全的扣减算法(推荐)
目标:在高并发下也不能“超扣”。
推荐做法Worker 侧每成功文件 1 次扣减):
- 先拿到当期 `included_units`(来自 plan
- 用单条 SQL 做“条件更新”,只有未超额时才 +1
```sql
UPDATE usage_periods
SET used_units = used_units + 1,
bytes_in = bytes_in + $1,
bytes_out = bytes_out + $2,
updated_at = NOW()
WHERE user_id = $3
AND period_start = $4
AND period_end = $5
AND used_units + 1 <= $6
RETURNING used_units;
```
如果 `RETURNING` 无行0 行更新),表示额度已耗尽:该文件应标记为 `QUOTA_EXCEEDED`,并丢弃输出(不返回/不落盘/不入账)。
> 说明为保持“成功才扣减”的口径2.4 默认按“成功后尝试扣减”实现;在配额临界点可能出现“先压缩、后发现额度不足”的少量无效计算。若要完全避免,可升级为“预留额度 + 失败回滚”的方案(复杂度更高,后续可做 V1+ 优化)。
### 2.5 批量任务与配额(硬配额)
为尽量减少“上传后才发现额度不足”的体验问题,建议双层策略:
- **创建任务前预检**`remaining_units < files_count` 时直接返回 `402 QUOTA_EXCEEDED`(不创建任务)。
- **扣减时最终校验**:写入账本/聚合时仍按 2.4 做原子扣减,防止并发竞态导致超额。
说明:
- 批量任务的计量仍以“成功文件数”为准;失败文件(含 `QUOTA_EXCEEDED`)不计费。
- 前端建议在上传前调用 `GET /billing/usage`登录或读取配额头API做本地提示/拦截。
---
## 3. 订阅生命周期(状态机)
### 3.1 状态
建议:
- `trialing`:试用中(可选)
- `active`:正常
- `past_due`:欠费(支付失败)
- `paused`:暂停(可选)
- `canceled`:已取消
- `incomplete`:创建未完成(例如支付页未完成)
### 3.2 关键动作
- 升级/降级套餐:下周期生效(首期建议),避免按天结算复杂度。
- 取消订阅:`cancel_at_period_end=true`,到期生效;到期后自动降级到 Free不续费
---
## 4. 支付集成Stripe
### 4.1 核心对象映射(建议)
- `users.billing_customer_id` ↔ Stripe `customer.id`
- `plans` ↔ Stripe `product/price`(建议每个 plan 对应一个 Stripe `price`
- `subscriptions.provider_subscription_id` ↔ Stripe `subscription.id`
- `invoices.provider_invoice_id` ↔ Stripe `invoice.id`
- `payments.provider_payment_id` ↔ Stripe `payment_intent.id`(或 charge id按实现选
### 4.2 Checkout / Portal
- Checkout后端创建 Stripe Checkout Session前端跳转 `checkout_url`
- Portal后端创建 Stripe Billing Portal Session前端跳转管理支付方式/取消订阅。
### 4.3 Stripe Webhook商用必须
要求:
- **验签**:使用 `STRIPE_WEBHOOK_SECRET` 校验 `Stripe-Signature`
- **事件幂等**:按 `provider_event_id` 去重(落库 `webhook_events`)。
- **乱序容忍**:以 Stripe 事件时间 + 当前 DB 状态做“只前进不回退”更新。
- **可重放**:保存原始 payload脱敏用于排查。
建议首期处理的事件(示例):
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.paid`
- `invoice.payment_failed`
---
## 5. 发票与对账
### 5.1 发票Invoice
发票用于:
- 向用户展示本期费用、税务信息(后续)、支付状态。
- 与支付记录/订阅周期关联。
最小字段:
- `invoice_number`(展示用)
- `currency``total_amount`
- `status`draft/open/paid/void/uncollectible可简化
- `period_start` / `period_end`
- `paid_at`
### 5.2 对账原则
-`usage_events` 为最终真相source of truth
-`invoice` 为结算结果(面向用户)。
- 管理后台可以按用户/周期拉取用量明细,支持导出 CSV。
---
## 6. 风控策略(与计费强相关)
### 6.1 防盗刷
- API Key 支持权限范围、速率限制、可选IP 白名单、(可选)域名白名单(仅对 Web 场景有效)。
- 异常检测:短时间内用量突增、来自异常国家/ASN、重复失败率高。
### 6.2 欠费与滥用处理
- 欠费past_due建议限制创建新任务并在 UI 引导用户去 Portal 完成支付。
- 严重滥用:冻结用户/API Key并记录审计日志。
---
## 7. 需要在文档中保持一致的“最终口径”
上线前必须定稿并在 `docs/api.md``docs/database.md``docs/ui.md` 同步:
- 套餐表(默认配额值)。
- 配额刷新周期(按订阅周期)。
- 超额策略(硬配额 / HTTP 402
- 匿名试用是否开放、开放到什么程度。

46
docs/confirm.md Normal file
View File

@@ -0,0 +1,46 @@
# 开工前确认清单 - ImageForge
目的:把“产品口径/计费口径/关键体验”一次性定稿,避免边做边改导致返工。
---
## 1. 已确认(按你的要求已写入各文档)
- 支付Stripe
- 超额策略:硬配额(超出返回 `402 QUOTA_EXCEEDED`
- 订阅周期:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`),不是自然月
- 匿名试用:支持;每日 10 次(以成功压缩文件数计)
- Free 套餐:不开放对外 API仅 Pro/Business 可创建 API Key
- 邮件:注册邮箱验证 + 密码重置SMTP
- 默认语言:中文
---
## 2. 需要你确认的默认口径(我已在文档里按“建议默认值”写死)
1) **Free 配额周期**
- 当前写法Free未订阅按自然月UTC+8重置Pro/Business 按订阅周期。
2) **匿名试用的“日界”与识别**
- 当前写法匿名试用按自然日UTC+800:00 重置;采用 Cookie + IP 双限制。
3) **批量任务遇到额度不足时的行为**
- 当前写法:`POST /compress/batch` 若本周期剩余单位不足以覆盖上传文件数,直接返回 `402`,不创建任务。
4) **默认套餐参数(可改)**
- Free500 / 月5MB 单文件10/批量,保留 24h
- Pro10,000 / 订阅周期20MB 单文件50/批量,保留 7 天
- Business100,000+ / 订阅周期50MB 单文件200/批量,保留 30 天
5) **邮箱未验证是否禁止压缩**
- 当前文档口径:注册用户未验证邮箱时,调用压缩接口返回 `EMAIL_NOT_VERIFIED`(匿名试用不受影响)。
---
## 3. 你只需要回复我 5 个点(同意/修改)
- Free 配额周期按自然月UTC+8是否 OK
- 匿名试用按自然日UTC+8是否 OK是否要“仅 Cookie”还是“Cookie + IP 双限制”?
- 批量额度不足:是否坚持“直接 402 不建任务”,还是允许“部分成功/部分失败”?
- 套餐默认值Free/Pro/Business 的配额、大小、保留期是否调整?
- 邮箱验证:是否必须验证后才能压缩?

583
docs/database.md Normal file
View File

@@ -0,0 +1,583 @@
# 数据库设计PostgreSQL + Redis- ImageForge
目标:支撑“网站 + 对外 API + 计费”的核心数据闭环,并为后续实现提供可迁移的表结构参考。
相关口径:
- 产品范围:`docs/prd.md`
- 计费与用量:`docs/billing.md`
- 安全:`docs/security.md`
---
## 1. 概述
### 1.1 技术选型
- **PostgreSQL**:系统真相来源(用户/任务/用量账本/订阅/发票/审计)。
- **Redis**缓存会话、API Key 缓存、限流计数、队列Streams与进度推送PubSub 可选)。
### 1.2 设计原则
- **用量可追溯**:以 `usage_events`(明细账本)作为最终真相,可对账/可追责。
- **幂等可落地**`idempotency_keys` 保障重试不重复扣费/不重复建任务。
- **多租户可扩展**:后续可加 team/org不影响现有表的主键与关系。
- **安全默认**:不存明文 API Key审计日志不写入敏感明文。
---
## 2. PostgreSQL 扩展与类型
### 2.1 UUID 生成
本设计默认使用 `gen_random_uuid()`,需要启用 `pgcrypto`
```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
```
> 注意:如果改用 `uuid-ossp`,则应统一改为 `uuid_generate_v4()`,避免文档与实现不一致。
### 2.2 枚举类型(建议)
```sql
CREATE TYPE user_role AS ENUM ('user', 'admin');
CREATE TYPE task_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');
CREATE TYPE file_status AS ENUM ('pending', 'processing', 'completed', 'failed');
CREATE TYPE compression_level AS ENUM ('high', 'medium', 'low');
CREATE TYPE task_source AS ENUM ('web', 'api');
CREATE TYPE subscription_status AS ENUM ('trialing', 'active', 'past_due', 'paused', 'canceled', 'incomplete');
CREATE TYPE invoice_status AS ENUM ('draft', 'open', 'paid', 'void', 'uncollectible');
CREATE TYPE payment_status AS ENUM ('pending', 'succeeded', 'failed', 'refunded');
```
---
## 3. ER 图(核心关系)
```
┌──────────────┐ ┌──────────────┐
│ plans │◄───────│ subscriptions│
└──────┬───────┘ └──────┬───────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ invoices │◄────┐
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ payments │ │
│ └──────────────┘ │
│ │
▼ │
┌──────────────┐ ┌──────────────┐ │
│ users │──────►│ api_keys │ │
└──────┬───────┘ └──────────────┘ │
│ │
▼ │
┌──────────────┐ ┌──────────────┐ │
│ tasks │──────►│ task_files │ │
└──────┬───────┘ └──────┬───────┘ │
│ │ │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ usage_events │◄──────│ idempotency │ │
└──────┬───────┘ └──────────────┘ │
│ │
▼ │
┌──────────────┐ │
│ usage_periods│ │
└──────────────┘ │
┌───────────────────────────────▼┐
│ webhook_events │
└────────────────────────────────┘
```
---
## 4. 表结构(建议)
> 以下 SQL 用于“设计说明”真正落地时建议拆分迁移文件core/billing/metering
### 4.1 users - 用户
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT true,
-- 邮箱验证
email_verified_at TIMESTAMPTZ,
-- 计费侧映射(可选,取决于支付渠道)
billing_customer_id VARCHAR(200),
-- 覆盖限制运营用NULL 表示用套餐默认)
rate_limit_override INTEGER,
storage_limit_mb INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_email_verified ON users(email_verified_at) WHERE email_verified_at IS NULL;
```
### 4.2 api_keys - API Key
```sql
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
key_prefix VARCHAR(20) NOT NULL, -- 展示/索引用(如 "if_live_abcd"
key_hash VARCHAR(255) NOT NULL, -- 推荐HMAC-SHA256(full_key, API_KEY_PEPPER)
permissions JSONB NOT NULL DEFAULT '["compress"]',
rate_limit INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
last_used_ip INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_api_keys_user_id ON api_keys(user_id);
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
CREATE INDEX idx_api_keys_is_active ON api_keys(is_active);
```
### 4.3 email_verifications - 邮箱验证
```sql
CREATE TABLE email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token),不存明文
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_email_verifications_token ON email_verifications(token_hash);
CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at) WHERE verified_at IS NULL;
```
### 4.4 password_resets - 密码重置
```sql
CREATE TABLE password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token),不存明文
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_password_resets_token ON password_resets(token_hash);
CREATE INDEX idx_password_resets_user_id ON password_resets(user_id);
CREATE INDEX idx_password_resets_expires ON password_resets(expires_at) WHERE used_at IS NULL;
```
### 4.5 plans - 套餐
```sql
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE, -- 展示/运营用(如 "free", "pro_monthly"
name VARCHAR(100) NOT NULL,
description TEXT,
-- Stripe 映射(对接 Checkout 时使用)
stripe_product_id VARCHAR(200),
stripe_price_id VARCHAR(200),
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
amount_cents INTEGER NOT NULL DEFAULT 0,
interval VARCHAR(20) NOT NULL DEFAULT 'monthly', -- 可后续改 ENUM
included_units_per_period INTEGER NOT NULL,
max_file_size_mb INTEGER NOT NULL,
max_files_per_batch INTEGER NOT NULL,
concurrency_limit INTEGER NOT NULL,
retention_days INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_plans_is_active ON plans(is_active);
```
### 4.4 subscriptions - 订阅
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
status subscription_status NOT NULL DEFAULT 'incomplete',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
canceled_at TIMESTAMPTZ,
provider VARCHAR(20) NOT NULL DEFAULT 'none', -- none/stripe
provider_customer_id VARCHAR(200),
provider_subscription_id VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
```
### 4.5 invoices - 发票/账单
```sql
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
invoice_number VARCHAR(50) NOT NULL UNIQUE,
status invoice_status NOT NULL DEFAULT 'open',
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
total_amount_cents INTEGER NOT NULL DEFAULT 0,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
provider VARCHAR(20) NOT NULL DEFAULT 'none',
provider_invoice_id VARCHAR(200),
hosted_invoice_url TEXT,
pdf_url TEXT,
due_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invoices_user_id ON invoices(user_id);
CREATE INDEX idx_invoices_status ON invoices(status);
```
### 4.6 payments - 支付记录
```sql
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL,
provider VARCHAR(20) NOT NULL DEFAULT 'none',
provider_payment_id VARCHAR(200),
status payment_status NOT NULL DEFAULT 'pending',
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
amount_cents INTEGER NOT NULL DEFAULT 0,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_user_id ON payments(user_id);
CREATE INDEX idx_payments_status ON payments(status);
```
### 4.7 webhook_events - Webhook 事件(支付回调)
```sql
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(20) NOT NULL,
provider_event_id VARCHAR(200) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'received', -- received/processed/failed
error_message TEXT
);
CREATE UNIQUE INDEX idx_webhook_events_unique ON webhook_events(provider, provider_event_id);
CREATE INDEX idx_webhook_events_status ON webhook_events(status);
```
### 4.8 tasks - 压缩任务
```sql
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- 游客为空
session_id VARCHAR(100), -- 游客会话
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, -- API 调用可记录
source task_source NOT NULL DEFAULT 'web',
status task_status NOT NULL DEFAULT 'pending',
compression_level compression_level NOT NULL DEFAULT 'medium',
output_format VARCHAR(10), -- NULL 表示保持原格式
max_width INTEGER,
max_height INTEGER,
preserve_metadata BOOLEAN NOT NULL DEFAULT false,
total_files INTEGER NOT NULL DEFAULT 0,
completed_files INTEGER NOT NULL DEFAULT 0,
failed_files INTEGER NOT NULL DEFAULT 0,
total_original_size BIGINT NOT NULL DEFAULT 0,
total_compressed_size BIGINT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- 到期清理:匿名可默认 24h登录用户应由应用按套餐写入更长 retention
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours')
);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_session_id ON tasks(session_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_created_at ON tasks(created_at);
CREATE INDEX idx_tasks_expires_at ON tasks(expires_at);
```
### 4.9 task_files - 任务文件
```sql
CREATE TABLE task_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
original_name VARCHAR(255) NOT NULL,
original_format VARCHAR(10) NOT NULL,
output_format VARCHAR(10) NOT NULL,
original_size BIGINT NOT NULL,
compressed_size BIGINT,
saved_percent DECIMAL(6, 2),
storage_path VARCHAR(500), -- S3 key 或本地路径
status file_status NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_task_files_task_id ON task_files(task_id);
CREATE INDEX idx_task_files_status ON task_files(status);
```
### 4.10 idempotency_keys - 幂等记录
```sql
CREATE TABLE idempotency_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE,
idempotency_key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status INTEGER NOT NULL,
response_body JSONB, -- JSON 接口可存;二进制接口建议存“元信息 + 对象指针”
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX idx_idempotency_user_key ON idempotency_keys(user_id, idempotency_key) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX idx_idempotency_api_key_key ON idempotency_keys(api_key_id, idempotency_key) WHERE api_key_id IS NOT NULL;
CREATE INDEX idx_idempotency_expires_at ON idempotency_keys(expires_at);
```
### 4.11 usage_events - 用量账本(明细)
```sql
CREATE TABLE usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
source task_source NOT NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
task_file_id UUID REFERENCES task_files(id) ON DELETE SET NULL,
units INTEGER NOT NULL DEFAULT 1,
bytes_in BIGINT NOT NULL DEFAULT 0,
bytes_out BIGINT NOT NULL DEFAULT 0,
format_in VARCHAR(10),
format_out VARCHAR(10),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_usage_events_task_file_unique ON usage_events(task_file_id) WHERE task_file_id IS NOT NULL;
CREATE INDEX idx_usage_events_user_time ON usage_events(user_id, occurred_at);
CREATE INDEX idx_usage_events_api_key_time ON usage_events(api_key_id, occurred_at);
```
### 4.12 usage_periods - 用量聚合(按订阅周期)
```sql
CREATE TABLE usage_periods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
used_units INTEGER NOT NULL DEFAULT 0,
bytes_in BIGINT NOT NULL DEFAULT 0,
bytes_out BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, period_start, period_end)
);
CREATE INDEX idx_usage_periods_user_period ON usage_periods(user_id, period_start);
```
### 4.13 system_config - 系统配置
```sql
CREATE TABLE system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
```
### 4.14 audit_logs - 审计日志
```sql
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL, -- login/compress/config_change/billing/...
resource_type VARCHAR(50),
resource_id UUID,
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
```
---
## 5. Redis 数据结构(建议)
### 5.1 会话(匿名/网站)
```
Key: session:{session_id}
Value: JSON { user_id, role, created_at, expires_at }
TTL: 7 days
```
### 5.2 限流计数
```
Key: rate_limit:{scope}:{id}:{minute}
Value: request_count
TTL: 60 seconds
```
### 5.2.1 匿名试用每日额度(硬限制)
```
Key: anon_quota:{session_id}:{yyyy-mm-dd}
Value: used_units
TTL: 48 hours
# yyyy-mm-dd自然日UTC+8次日 00:00 重置
# 必须:叠加 IP 维度,降低刷 session 的风险(两者任一超出都拒绝)
Key: anon_quota_ip:{ip}:{yyyy-mm-dd}
Value: used_units
TTL: 48 hours
```
### 5.3 队列Redis Streams
```
Stream: stream:compress_jobs
Group: compress_workers
Message fields: { task_id, priority, created_at }
```
### 5.4 任务进度(可选)
```
PubSub: pubsub:task:{task_id}
Message: JSON { progress, completed_files, current_file }
```
### 5.5 API Key 缓存(可选)
```
Key: api_key:{key_prefix}
Value: JSON { api_key_id, user_id, permissions, rate_limit, ... }
TTL: 5 minutes
```
---
## 6. 数据清理策略(必须)
- **过期任务**:按 `tasks.expires_at` 清理任务/文件/对象存储。
- **幂等记录**:按 `idempotency_keys.expires_at` 清理。
- **邮箱验证 Token**:按 `email_verifications.expires_at` 清理已过期且未验证的记录。
- **密码重置 Token**:按 `password_resets.expires_at` 清理已过期或已使用的记录。
- **Webhook 事件**:保留 30~90 天(便于排查),过期清理或归档。
- **用量账本**`usage_events` 建议长期保留(对账),必要时做分区(按月/按季度)。
示例:
```sql
-- 清理过期任务
DELETE FROM tasks WHERE expires_at < NOW();
-- 清理幂等记录
DELETE FROM idempotency_keys WHERE expires_at < NOW();
-- 清理邮箱验证 Token保留已验证的清理过期未验证的
DELETE FROM email_verifications WHERE expires_at < NOW() AND verified_at IS NULL;
-- 清理密码重置 Token保留 7 天内的记录用于审计)
DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '7 days';
-- 清理 Webhook 事件
DELETE FROM webhook_events WHERE received_at < NOW() - INTERVAL '90 days';
```
---
## 7. 初始化数据(建议)
### 7.1 默认套餐
```sql
INSERT INTO plans (code, name, stripe_price_id, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, concurrency_limit, retention_days, features)
VALUES
('free', 'Free', NULL, 'CNY', 0, 'monthly', 500, 5, 10, 2, 1, '{"api": false, "webhook": false}'),
('pro_monthly', 'Pro月付', 'price_xxx_pro_monthly', 'CNY', 1999, 'monthly', 10000, 20, 50, 8, 7, '{"api": true, "webhook": true}'),
('business_monthly', 'Business月付', 'price_xxx_business_monthly', 'CNY', 9999, 'monthly', 100000, 50, 200, 32, 30, '{"api": true, "webhook": true, "ip_allowlist": true}');
```
### 7.2 默认系统配置
```sql
INSERT INTO system_config (key, value, description) VALUES
('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true, "email_verification_required": true}', '功能开关'),
('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'),
('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)'),
('mail', '{"enabled": true, "provider": "custom", "from": "noreply@example.com", "from_name": "ImageForge"}', '邮件服务配置(密码加密存储)');
```

711
docs/deployment.md Normal file
View File

@@ -0,0 +1,711 @@
# 部署指南
## 环境准备
### 系统要求
- Linux (Ubuntu 22.04+ / Debian 12+ 推荐)
- 2+ CPU 核心(启用独立 Worker 建议 4+
- 4GB+ 内存
- 50GB+ 磁盘空间
### 依赖安装
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install -y \
build-essential \
pkg-config \
libssl-dev \
libpq-dev \
cmake \
nasm \
libjpeg-dev \
libpng-dev \
libwebp-dev
# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
# 初始化数据库会用到 psql建议安装 PostgreSQL client
sudo apt install -y postgresql-client
# 安装 Node.js (前端构建)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
```
---
## 本地开发
### 1. 启动数据库服务
```bash
# 使用 Docker Compose 启动 PostgreSQL 和 Redis
docker-compose -f docker/docker-compose.dev.yml up -d
```
`docker/docker-compose.dev.yml`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: imageforge
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
# 可选MinIOS3 兼容,本地开发更接近生产)
# minio:
# image: minio/minio:RELEASE.2024-01-28T20-20-01Z
# command: server /data --console-address ":9001"
# environment:
# MINIO_ROOT_USER: minioadmin
# MINIO_ROOT_PASSWORD: minioadmin
# ports:
# - "9000:9000"
# - "9001:9001"
# volumes:
# - minio_data:/data
volumes:
postgres_data:
redis_data:
minio_data:
```
### 2. 配置环境变量
```bash
cp .env.example .env
```
`.env.example`:
```bash
# 运行模式:建议将 API 与 Worker 分开运行
IMAGEFORGE_ROLE=api # api | worker
# 服务配置
HOST=0.0.0.0
PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
RUST_LOG=info,imageforge=debug
# 数据库
DATABASE_URL=postgres://imageforge:devpassword@localhost:5432/imageforge
# Redis
REDIS_URL=redis://localhost:6379
# JWT网站/管理后台)
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRY_HOURS=168
# API Key
API_KEY_PEPPER=please-change-this-in-production
# 存储(生产建议 S3/MinIO + 预签名 URL
STORAGE_TYPE=local # local | s3
STORAGE_PATH=./uploads
# 预签名下载链接过期(分钟)
SIGNED_URL_TTL_MINUTES=60
# S3 配置(如果使用 S3/MinIO
# S3_ENDPOINT=http://localhost:9000
# S3_BUCKET=your-bucket
# S3_REGION=us-east-1
# S3_ACCESS_KEY=xxx
# S3_SECRET_KEY=xxx
# 计费已确认Stripe
BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# 限制(默认值;最终以套餐/用户覆盖为准)
ALLOW_ANONYMOUS_UPLOAD=true
ANON_MAX_FILE_SIZE_MB=5
ANON_MAX_FILES_PER_BATCH=5
ANON_DAILY_UNITS=10
MAX_IMAGE_PIXELS=40000000
IDEMPOTENCY_TTL_HOURS=24
# 结果保留(匿名默认;登录用户按套餐 retention_days
ANON_RETENTION_HOURS=24
# 管理员初始账户
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme123
```
### 3. 初始化数据库
```bash
# 运行首期迁移(可重复执行)
psql "$DATABASE_URL" -f migrations/001_init.sql
```
### 4. 启动开发服务器
```bash
# 后端 API (热重载)
cargo install cargo-watch
IMAGEFORGE_ROLE=api cargo watch -x run
# 后端 Worker另一个终端处理异步/批量任务)
IMAGEFORGE_ROLE=worker cargo watch -x run
# 前端 (另一个终端)
cd frontend
npm install
npm run dev
```
### 5. Stripe Webhook 本地调试(可选)
本地调试 Stripe 订阅/支付状态,通常需要将 Stripe Webhook 转发到本机:
```bash
# 1) 安装并登录 Stripe CLI按官方文档
# 2) 监听并转发到你的后端回调地址
stripe listen --forward-to http://localhost:8080/api/v1/webhooks/stripe
# CLI 会输出一个 whsec_...,写入 .env 的 STRIPE_WEBHOOK_SECRET
```
---
## 生产部署
> 注意:以下内容为生产部署模板示例;仓库当前首期仅提供开发用 `docker/docker-compose.dev.yml`,生产 compose/Dockerfile/nginx/k8s 等可在开工阶段按需落地并调整。
### 方案一Docker Compose推荐小规模
`docker/docker-compose.prod.yml`:
```yaml
version: '3.8'
services:
api:
build:
context: ..
dockerfile: docker/Dockerfile
environment:
- IMAGEFORGE_ROLE=api
- BILLING_PROVIDER=stripe
- PUBLIC_BASE_URL=https://your-domain.com
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- STORAGE_TYPE=local
- STORAGE_PATH=/app/uploads
ports:
- "8080:8080"
volumes:
- uploads:/app/uploads
depends_on:
- postgres
- redis
restart: unless-stopped
worker:
build:
context: ..
dockerfile: docker/Dockerfile
environment:
- IMAGEFORGE_ROLE=worker
- DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- STORAGE_TYPE=local
- STORAGE_PATH=/app/uploads
volumes:
- uploads:/app/uploads
depends_on:
- postgres
- redis
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: imageforge
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped
volumes:
uploads:
postgres_data:
redis_data:
```
`docker/Dockerfile`:
```dockerfile
FROM rust:1.92-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
nasm \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY templates ./templates
RUN cargo build --release
# 前端构建阶段
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/imageforge ./imageforge
COPY --from=frontend-builder /app/frontend/dist ./static
COPY migrations ./migrations
RUN mkdir -p uploads
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
CMD ["./imageforge"]
```
`docker/nginx.conf`:
```nginx
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# 日志
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 文件上传大小限制
client_max_body_size 100M;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript;
upstream backend {
server api:8080;
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# 静态文件
location /static/ {
proxy_pass http://backend;
expires 30d;
add_header Cache-Control "public, immutable";
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# API 和其他请求
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
```
### 部署步骤
```bash
# 1. 创建 .env 文件
cat > .env << EOF
DB_PASSWORD=your-secure-db-password
JWT_SECRET=your-very-long-random-jwt-secret-at-least-32-chars
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
EOF
# 2. 获取 SSL 证书
sudo certbot certonly --standalone -d your-domain.com
# 3. 构建并启动
docker-compose -f docker/docker-compose.prod.yml up -d --build
# 4. 查看日志
docker-compose -f docker/docker-compose.prod.yml logs -f api
```
---
### 方案二Kubernetes大规模
`k8s/deployment.yaml`:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: imageforge
spec:
replicas: 3
selector:
matchLabels:
app: imageforge
template:
metadata:
labels:
app: imageforge
spec:
containers:
- name: imageforge
image: your-registry/imageforge:latest
ports:
- containerPort: 8080
env:
- name: IMAGEFORGE_ROLE
value: api
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: redis-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: jwt-secret
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: imageforge
spec:
selector:
app: imageforge
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: imageforge
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- your-domain.com
secretName: imageforge-tls
rules:
- host: your-domain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: imageforge
port:
number: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: imageforge-worker
spec:
replicas: 2
selector:
matchLabels:
app: imageforge-worker
template:
metadata:
labels:
app: imageforge-worker
spec:
containers:
- name: imageforge-worker
image: your-registry/imageforge:latest
env:
- name: IMAGEFORGE_ROLE
value: worker
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: redis-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: jwt-secret
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "4000m"
```
---
## 监控与日志
### Prometheus 指标
应用暴露 `/metrics` 端点:
```rust
// 在代码中添加指标
use prometheus::{Counter, Histogram};
lazy_static! {
static ref COMPRESSION_REQUESTS: Counter = Counter::new(
"imageforge_compression_requests_total",
"Total number of compression requests"
).unwrap();
static ref COMPRESSION_DURATION: Histogram = Histogram::with_opts(
HistogramOpts::new(
"imageforge_compression_duration_seconds",
"Time spent compressing images"
)
).unwrap();
}
```
### Grafana 仪表板
监控项目:
- 请求量 / QPS
- 响应时间 P50/P95/P99
- 错误率
- 压缩任务队列长度
- CPU / 内存使用率
- 磁盘使用率
### 日志聚合
使用 ELK Stack 或 Loki
```yaml
# docker-compose 添加 Loki
loki:
image: grafana/loki:2.9.0
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:2.9.0
volumes:
- /var/log:/var/log
- ./promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
```
---
## 备份策略
### 数据库备份
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/backups
# PostgreSQL 备份
docker exec postgres pg_dump -U imageforge imageforge | gzip > $BACKUP_DIR/db_$DATE.sql.gz
# 保留最近 7 天的备份
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
# 可选:上传到 S3
# aws s3 cp $BACKUP_DIR/db_$DATE.sql.gz s3://your-bucket/backups/
```
添加到 crontab
```bash
0 3 * * * /path/to/backup.sh
```
### 上传文件备份
如果使用本地存储,定期同步到 S3
```bash
aws s3 sync /app/uploads s3://your-bucket/uploads --delete
```
---
## 故障排查
### 常见问题
**1. 数据库连接失败**
```bash
# 检查 PostgreSQL 状态
docker-compose logs postgres
# 测试连接
docker exec -it postgres psql -U imageforge -d imageforge -c "SELECT 1"
```
**2. 压缩失败**
```bash
# 检查应用日志
docker-compose logs api | grep ERROR
docker-compose logs worker | grep ERROR
# 检查磁盘空间
df -h
```
**3. 内存不足**
```bash
# 查看内存使用
docker stats
# 调整容器内存限制
```
**4. 上传超时**
```bash
# 检查 Nginx 配置
# client_max_body_size 和 proxy_read_timeout
```
### 健康检查端点
```
GET /health
{
"status": "healthy",
"database": "connected",
"redis": "connected",
"storage": "available",
"uptime": 3600
}
```

471
docs/email.md Normal file
View File

@@ -0,0 +1,471 @@
# 邮件服务设计 - ImageForge
目标:提供开箱即用的邮件服务,支持注册验证和密码重置,管理员只需配置邮箱地址和授权码即可使用。
---
## 1. 功能范围
### 1.1 首期必须
- **注册邮箱验证**:用户注册后发送验证邮件,点击链接完成激活
- **密码重置**:用户申请重置密码,发送重置链接邮件
### 1.2 后续可选V1+
- 订阅到期提醒
- 配额即将用尽提醒
- 支付成功/失败通知
- 安全告警异地登录、API Key 创建等)
---
## 2. 技术方案
### 2.1 SMTP 直连
使用标准 SMTP 协议,通过 `lettre` (Rust) 发送邮件,无需第三方 SaaS 依赖。
```toml
# Cargo.toml
[dependencies]
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"] }
```
### 2.2 预置服务商模板
管理员只需选择服务商并填写邮箱/授权码,系统自动填充 SMTP 配置:
| 服务商 | SMTP 地址 | 端口 | 加密 | 备注 |
|--------|-----------|------|------|------|
| QQ 邮箱 | `smtp.qq.com` | 465 | SSL | 需开启 SMTP 服务并获取授权码 |
| 163 邮箱 | `smtp.163.com` | 465 | SSL | 需开启 SMTP 服务并获取授权码 |
| 阿里企业邮箱 | `smtp.qiye.aliyun.com` | 465 | SSL | 使用邮箱密码 |
| 腾讯企业邮箱 | `smtp.exmail.qq.com` | 465 | SSL | 需获取授权码 |
| Gmail | `smtp.gmail.com` | 587 | STARTTLS | 需开启两步验证并生成应用专用密码 |
| Outlook/365 | `smtp.office365.com` | 587 | STARTTLS | 使用账号密码 |
| 自定义 | 用户填写 | 用户填写 | 用户选择 | 支持任意 SMTP 服务器 |
---
## 3. 配置设计
### 3.1 环境变量
```bash
# .env.example
# 邮件服务配置
MAIL_ENABLED=true
# 开发环境:当 MAIL_ENABLED=false 时,可打开该开关把验证/重置链接打印到日志(便于本地联调)
MAIL_LOG_LINKS_WHEN_DISABLED=true
# 预置服务商可选qq / 163 / aliyun_enterprise / tencent_enterprise / gmail / outlook / custom
MAIL_PROVIDER=qq
# 发件邮箱(必填)
MAIL_FROM=noreply@example.com
# 授权码/密码(必填)
MAIL_PASSWORD=your-smtp-authorization-code
# 发件人名称(可选,默认 "ImageForge"
MAIL_FROM_NAME=ImageForge
# === 以下仅 MAIL_PROVIDER=custom 时需要 ===
# MAIL_SMTP_HOST=smtp.example.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_ENCRYPTION=ssl # ssl / starttls / none
```
### 3.2 数据库配置(管理后台可改)
```sql
-- system_config 表
INSERT INTO system_config (key, value, description) VALUES
('mail', '{
"enabled": true,
"provider": "qq",
"from": "noreply@example.com",
"from_name": "ImageForge",
"password_encrypted": "...",
"custom_smtp": null
}', '邮件服务配置');
```
### 3.3 Rust 配置结构
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct MailConfig {
pub enabled: bool,
pub provider: MailProvider,
pub from: String,
pub from_name: String,
pub password: String, // 运行时解密
pub custom_smtp: Option<CustomSmtpConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub enum MailProvider {
QQ,
NetEase163,
AliyunEnterprise,
TencentEnterprise,
Gmail,
Outlook,
Custom,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CustomSmtpConfig {
pub host: String,
pub port: u16,
pub encryption: SmtpEncryption,
}
#[derive(Debug, Clone, Deserialize)]
pub enum SmtpEncryption {
Ssl,
StartTls,
None,
}
impl MailProvider {
pub fn smtp_config(&self) -> (String, u16, SmtpEncryption) {
match self {
Self::QQ => ("smtp.qq.com".into(), 465, SmtpEncryption::Ssl),
Self::NetEase163 => ("smtp.163.com".into(), 465, SmtpEncryption::Ssl),
Self::AliyunEnterprise => ("smtp.qiye.aliyun.com".into(), 465, SmtpEncryption::Ssl),
Self::TencentEnterprise => ("smtp.exmail.qq.com".into(), 465, SmtpEncryption::Ssl),
Self::Gmail => ("smtp.gmail.com".into(), 587, SmtpEncryption::StartTls),
Self::Outlook => ("smtp.office365.com".into(), 587, SmtpEncryption::StartTls),
Self::Custom => panic!("Custom provider requires explicit config"),
}
}
}
```
---
## 4. 邮件模板
### 4.1 模板存储
建议使用内嵌模板(编译时包含),支持变量替换:
```
templates/
├── email_verification.html
├── email_verification.txt
├── password_reset.html
└── password_reset.txt
```
### 4.2 注册验证邮件
**主题**`验证您的 ImageForge 账号`
**HTML 模板** (`email_verification.html`)
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证您的邮箱</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 40px 20px; }
.header { text-align: center; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #4a90d9; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin-bottom: 30px; }
.button { display: inline-block; background: #4a90d9; color: #fff !important; text-decoration: none; padding: 12px 30px; border-radius: 6px; font-weight: 500; }
.button:hover { background: #357abd; }
.footer { text-align: center; font-size: 12px; color: #666; }
.link { word-break: break-all; color: #4a90d9; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">ImageForge</div>
</div>
<div class="content">
<h2>欢迎注册 ImageForge</h2>
<p>您好,{{username}}</p>
<p>感谢您注册 ImageForge。请点击下方按钮验证您的邮箱地址</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{verification_url}}" class="button">验证邮箱</a>
</p>
<p>或复制以下链接到浏览器打开:</p>
<p class="link">{{verification_url}}</p>
<p style="margin-top: 20px; font-size: 14px; color: #666;">
此链接将在 <strong>24 小时</strong>后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。
</p>
</div>
<div class="footer">
<p>© {{year}} ImageForge. All rights reserved.</p>
<p>此邮件由系统自动发送,请勿直接回复。</p>
</div>
</div>
</body>
</html>
```
**纯文本模板** (`email_verification.txt`)
```text
欢迎注册 ImageForge
您好,{{username}}
感谢您注册 ImageForge。请点击以下链接验证您的邮箱地址
{{verification_url}}
此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。
---
© {{year}} ImageForge
此邮件由系统自动发送,请勿直接回复。
```
### 4.3 密码重置邮件
**主题**`重置您的 ImageForge 密码`
**HTML 模板** (`password_reset.html`)
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>重置密码</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 40px 20px; }
.header { text-align: center; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #4a90d9; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin-bottom: 30px; }
.button { display: inline-block; background: #4a90d9; color: #fff !important; text-decoration: none; padding: 12px 30px; border-radius: 6px; font-weight: 500; }
.footer { text-align: center; font-size: 12px; color: #666; }
.link { word-break: break-all; color: #4a90d9; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">ImageForge</div>
</div>
<div class="content">
<h2>重置您的密码</h2>
<p>您好,{{username}}</p>
<p>我们收到了重置您 ImageForge 账号密码的请求。请点击下方按钮设置新密码:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{reset_url}}" class="button">重置密码</a>
</p>
<p>或复制以下链接到浏览器打开:</p>
<p class="link">{{reset_url}}</p>
<div class="warning">
<strong>安全提示:</strong>此链接将在 <strong>1 小时</strong>后失效。如果您没有请求重置密码,请忽略此邮件,您的账号仍然安全。
</div>
</div>
<div class="footer">
<p>© {{year}} ImageForge. All rights reserved.</p>
<p>此邮件由系统自动发送,请勿直接回复。</p>
</div>
</div>
</body>
</html>
```
---
## 5. 数据库设计
### 5.1 邮箱验证 Token
```sql
CREATE TABLE email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token)
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_email_verifications_token ON email_verifications(token_hash);
CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);
```
### 5.2 密码重置 Token
```sql
CREATE TABLE password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token)
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_password_resets_token ON password_resets(token_hash);
CREATE INDEX idx_password_resets_user_id ON password_resets(user_id);
CREATE INDEX idx_password_resets_expires_at ON password_resets(expires_at);
```
### 5.3 用户表扩展
```sql
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
```
---
## 6. API 接口
### 6.1 发送验证邮件
```http
POST /auth/send-verification
Authorization: Bearer <token>
```
**响应**
```json
{ "success": true, "data": { "message": "验证邮件已发送,请查收" } }
```
**限流**:同一用户 1 分钟内最多发送 1 次
### 6.2 验证邮箱
```http
POST /auth/verify-email
Content-Type: application/json
```
**请求体**
```json
{ "token": "verification-token-from-email" }
```
**响应**
```json
{ "success": true, "data": { "message": "邮箱验证成功" } }
```
### 6.3 请求密码重置
```http
POST /auth/forgot-password
Content-Type: application/json
```
**请求体**
```json
{ "email": "user@example.com" }
```
**响应**(无论邮箱是否存在都返回成功,防止枚举):
```json
{ "success": true, "data": { "message": "如果该邮箱已注册,您将收到重置邮件" } }
```
**限流**:同一 IP 1 分钟内最多请求 3 次
### 6.4 重置密码
```http
POST /auth/reset-password
Content-Type: application/json
```
**请求体**
```json
{
"token": "reset-token-from-email",
"new_password": "new-secure-password"
}
```
**响应**
```json
{ "success": true, "data": { "message": "密码重置成功,请重新登录" } }
```
---
## 7. 安全考虑
### 7.1 Token 安全
- Token 使用 `crypto-secure random`32 字节 base64
- 数据库只存 `SHA256(token)`,不存明文
- Token 单次有效,使用后立即标记 `used_at`
### 7.2 时效控制
- 邮箱验证 Token24 小时有效
- 密码重置 Token1 小时有效
### 7.3 防滥用
- 发送邮件接口严格限流
- 密码重置不泄露"邮箱是否存在"
- 失败尝试记录审计日志
### 7.4 授权码加密存储
- SMTP 授权码在数据库中加密存储AES-256-GCM
- 密钥来自环境变量或密钥管理服务
---
## 8. 管理后台配置界面
管理后台提供邮件服务配置页面:
```
邮件服务配置
├── 启用状态:[开关]
├── 服务商选择:[下拉QQ邮箱 / 163邮箱 / 阿里企业邮 / 腾讯企业邮 / Gmail / Outlook / 自定义]
├── 发件邮箱:[输入框]
├── 授权码/密码:[密码输入框]
├── 发件人名称:[输入框,默认 ImageForge]
├── (自定义时显示)
│ ├── SMTP 服务器:[输入框]
│ ├── 端口:[输入框]
│ └── 加密方式:[下拉SSL / STARTTLS / 无]
└── [测试发送] [保存配置]
```
**测试发送**:向管理员邮箱发送测试邮件,验证配置是否正确。
---
## 9. 常见邮件服务商配置指南
### 9.1 QQ 邮箱
1. 登录 QQ 邮箱 → 设置 → 账户
2. 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」
3. 开启「SMTP 服务」
4. 按提示发送短信获取授权码
5. 将授权码填入系统配置
### 9.2 163 邮箱
1. 登录 163 邮箱 → 设置 → POP3/SMTP/IMAP
2. 开启「SMTP 服务」
3. 设置客户端授权密码
4. 将授权密码填入系统配置
### 9.3 Gmail
1. 登录 Google 账号 → 安全性
2. 开启「两步验证」
3. 生成「应用专用密码」(选择"邮件"+"其他"
4. 将应用专用密码填入系统配置
### 9.4 阿里企业邮箱
1. 使用邮箱地址和登录密码即可
2. SMTP 服务默认开启

158
docs/frontend.md Normal file
View File

@@ -0,0 +1,158 @@
# 前端工程设计Vue3- ImageForge
目标:支撑“网站压缩 + 开发者 API 控制台 + 计费/发票 + 管理后台”的一个 Vue3 SPA或同仓多入口
UI/UX 规格见 `docs/ui.md`
---
## 1. 技术栈(确定)
- Vue 3 + TypeScript + Vite
- 路由Vue Router
- 状态Pinia
- 网络Fetch 或 Axios统一封装支持幂等头、错误归一
- 样式Tailwind CSS推荐或 CSS Variables + 自研组件
- 工具VueUse、Day.js或 date-fns、Zod表单校验可选
---
## 2. 路由与页面
### 2.1 公共
```
/ 首页压缩
/pricing 套餐与 FAQ
/docs 开发者文档(引导)
/login
/register
/terms
/privacy
```
### 2.2 用户控制台(登录)
```
/dashboard
/dashboard/history
/dashboard/api-keys
/dashboard/billing
/dashboard/settings
```
### 2.3 管理后台(管理员)
```
/admin
/admin/users
/admin/tasks
/admin/billing
/admin/config
```
---
## 3. 前端项目结构(建议)
```
src/
app/ # 路由、布局、鉴权守卫
pages/ # 页面route components
components/ # 通用组件UI、上传、表格等
features/
compress/ # 压缩:上传、任务、下载
billing/ # 套餐、订阅、发票、用量
apiKeys/ # API Key 管理
admin/ # 管理后台
services/ # API 封装http client + endpoints
stores/ # Pinia stores
styles/ # 主题变量、tailwind 入口
utils/ # 格式化、文件校验、错误处理
```
---
## 4. API 调用规范(前端约定)
### 4.1 Base URL
- 统一使用 `/api/v1`
### 4.2 幂等与重试
-`POST /compress/*``POST /billing/checkout` 等请求默认注入 `Idempotency-Key`UUID
- 网络重试仅限“明确幂等”的请求(否则会重复扣费/重复建任务)。
### 4.3 错误处理
将后端错误码映射为统一 UI 提示:
- `QUOTA_EXCEEDED`:引导升级/查看账单页
- `RATE_LIMITED`:展示倒计时(读取 `Retry-After`
- `FILE_TOO_LARGE` / `TOO_MANY_PIXELS`:定位到具体文件并提示如何处理
---
## 5. 压缩流程Web
### 5.1 同步 vs 异步
- 小文件/少量:可直接调用 `POST /compress`,拿到 `download_url`
- 批量/大文件:调用 `POST /compress/batch`,拿到 `task_id` 后:
- 优先 WebSocket/SSE 订阅进度;
- fallback轮询 `GET /compress/tasks/{task_id}`
### 5.2 上传前校验
前端必须做“用户体验级校验”(后端仍需二次校验):
- 格式白名单png/jpg/jpeg/webp/avif/gif/bmp/tiff/icoGIF 仅静态)
- 文件大小与数量(按匿名/登录/套餐提示不同上限)
- 匿名试用:每日 10 次限制提示(达到后引导登录/升级)
- 可选:读取图片宽高(避免明显超限)
---
## 6. 计费与用量(前端展示)
对接 `docs/api.md` 的 Billing 模块:
- `/pricing` 页面:读取 `GET /billing/plans`
- 控制台概览:读取 `GET /billing/usage``GET /billing/subscription`
- 订阅升级:调用 `POST /billing/checkout` 获取 `checkout_url` 并跳转
- 支付方式/取消订阅:调用 `POST /billing/portal` 获取 portal 链接
- 发票列表:`GET /billing/invoices`
UI 必须展示:
- 当期已用/剩余、重置时间
- 当前订阅状态active/past_due/canceled
---
## 7. API Key 控制台(开发者体验)
页面提供三类信息:
1) Key 管理:创建/禁用/轮换(创建时只展示一次完整 Key
2) 用量:本周期已用/剩余(与 Billing 用量一致)
3) 快速接入curl 示例 + 常见错误码 + 幂等建议
---
## 8. 安全建议(前端侧)
- 若使用 Bearer Token避免 localStorageXSS 风险),优先 HttpOnly Cookie 会话(需要 CSRF 策略)。
- 上传与下载链接:明确到期时间与隐私说明(默认去 EXIF
- 管理后台路由加守卫:`role=admin` 才可进入。
---
## 9. 主题变量CSS Variables
首期可用 Tailwind 或自研组件,但建议保留一层 CSS 变量,方便后续主题化/暗色模式:
```css
:root {
--bg: 248 250 252;
--card: 255 255 255;
--text: 15 23 42;
--muted: 71 85 105;
--border: 226 232 240;
--brand: 99 102 241;
--brand-strong: 79 70 229;
--success: 34 197 94;
--warning: 245 158 11;
--danger: 239 68 68;
}
```

99
docs/observability.md Normal file
View File

@@ -0,0 +1,99 @@
# 可观测性设计(日志/指标/追踪)- ImageForge
目标:让“压缩效果、性能瓶颈、队列健康、计费正确性、滥用风险”都能被观测与告警,便于商用运营。
---
## 1. 统一规范
### 1.1 请求标识
- 每个 HTTP 请求生成 `request_id`(或从网关透传),写入:
- 响应头:`X-Request-Id`
- 日志字段:`request_id`
- Trace`trace_id/span_id`(如启用 OpenTelemetry
### 1.2 日志格式
- 结构化日志JSON优先便于 Loki/ELK 聚合。
- 禁止记录明文密码、JWT、API Key、Webhook secret。
建议最小字段:
- `timestamp``level``service`api/worker`request_id`
- `user_id`(可空)、`api_key_id`(可空)、`ip``user_agent`
- `route``method``status``latency_ms`
- `task_id``task_file_id`(压缩链路)
- `bytes_in``bytes_out``format_in/out``compression_level`
---
## 2. 指标Prometheus
### 2.1 API 服务指标
请求类:
- `http_requests_total{route,method,status}`
- `http_request_duration_seconds_bucket{route,method}`
鉴权与风控:
- `auth_fail_total{reason}`
- `rate_limited_total{scope}`anonymous/user/api_key
- `quota_exceeded_total{plan}`
计费链路:
- `billing_webhook_total{provider,event_type,result}`
- `subscription_state_total{state}`
- `invoice_total{status}`
### 2.2 Worker 指标
队列与吞吐:
- `jobs_received_total`
- `jobs_inflight`
- `jobs_completed_total{result}`
- `job_duration_seconds_bucket{format,level}`
压缩效果:
- `bytes_in_total``bytes_out_total``bytes_saved_total`
- `compression_ratio_bucket{format,level}`
资源与异常:
- `decode_failed_total{reason}`
- `pixel_limit_hit_total`
### 2.3 Redis/队列指标(可选)
- Streams 消费延迟、pending 数量、dead-letter 数量(如实现)。
---
## 3. 追踪Tracing
建议API 与 Worker 使用 OpenTelemetry打通跨服务链路
- API`create_task` span、`auth` span、`db` span、`redis` span
- Worker`fetch_job` span、`download_input` span、`compress` span、`upload_output` span、`metering` span
价值:
- 发现耗时集中点(解码/编码/S3/DB
- 对账问题定位(用量事件写入失败/重复)。
---
## 4. 仪表板与告警(建议)
### 4.1 SLO建议起点
- APIP95 < 300ms不含压缩直返、错误率 < 0.5%
- Worker队列积压 < N按规模定义失败率 < 1%
### 4.2 告警
可用性:
- `http 5xx` 激增
- `/health` 探活失败
队列健康:
- pending/inflight 持续上升
- 单任务耗时异常增长
计费正确性:
- webhook 处理失败
- 订阅状态异常active->incomplete 回退等)
滥用风险:
- 单 key/单 IP 用量突增
- 格式探测失败率异常

162
docs/prd.md Normal file
View File

@@ -0,0 +1,162 @@
# 产品需求文档PRD- ImageForge
目标:做一个接近商用的图片压缩网站 + 对外 API + 计费系统(可订阅/可计量)。
> 本文只描述“做什么/做到什么程度”。技术实现细节在 `docs/architecture.md`、`docs/api.md`、`docs/database.md`、`docs/deployment.md`、`docs/ui.md`。
---
## 1. 产品定位
### 1.1 一句话
提供高质量、稳定、可规模化的图片压缩服务:既能让普通用户在网页上批量压缩,也能让开发者通过 API Key 集成到 CI/CD 或业务系统,并按使用量/套餐计费。
### 1.2 核心价值
- **效果**:压缩比与画质控制可预期(可选有损/无损/近无损)。
- **体验**:网站拖拽即用、批量任务、可下载 ZIP、历史可追溯。
- **工程化**:对外 API 稳定、可观测、可限流、可计量、可计费。
- **安全合规**:默认去除隐私元数据、明确保留期限、支持删除。
### 1.3 非目标(明确不做/后做)
- 图像编辑(裁剪/滤镜/水印)不作为核心能力(后续可扩展)。
- CDN 图片处理(按 URL 在线压缩、自动适配)不作为首期必须。
- 视频/动图GIF/APNG暂不纳入首期除非强需求
---
## 2. 用户与角色
### 2.1 用户类型
- **游客Anonymous**:无需注册即可试用网站压缩(强限制、短保留)。
- **注册用户User**:使用网站 + 管理 API Key + 查看用量/发票。
- **企业/团队用户Team可选**:多人协作、共享额度、角色权限(可作为 V1+)。
- **管理员Admin**:风控、配置、用户/任务/账单审核、运营数据。
### 2.2 典型场景
- 设计师:批量压缩并打包下载,保留 7 天内历史。
- 开发者CI 里调用 API在发布前批量压缩静态资源。
- 运营:导出周期用量、查看节省带宽、按部门拆分账单(团队版)。
---
## 3. 功能范围(按模块)
### 3.1 网站压缩Web
- 拖拽/选择图片(单次多文件)。
- 压缩参数:
- 压缩率1-100数值越大压缩越强
- 输出格式:保持原格式。
- 可选:限制宽高(等比缩放)。
- 可选:是否保留元数据(默认不保留)。
- 结果展示:
- 逐文件:原始大小、压缩后大小、压缩率、状态、下载。
- 汇总:总节省、下载 ZIP、失败文件原因。
- 历史记录:
- 最近任务列表(可筛选:日期/格式/状态)。
- 可再次下载(未过期)、或重新发起压缩(使用相同参数)。
- 游客试用:
- 自动创建匿名会话Cookie只允许较小文件/较少数量。
- 到期自动清理,不提供“历史”永久保存。
### 3.2 对外 APIDeveloper API
- API Key创建/禁用/轮换/权限范围(最小权限)。
- 压缩接口:
- 同步单图(可返回二进制或下载链接)。
- 批量/大文件异步任务(任务状态、下载 ZIP
- 可选Webhook 回调(替代轮询/WS用于服务端集成
- 工程化能力:
- 幂等Idempotency-Key避免重复扣费/重复任务。
- 配额与用量头信息(本订阅周期已用/剩余/上限)。
- 速率限制(标准 `Retry-After` + 速率头)。
### 3.3 计费与用量Billing & Metering
详见 `docs/billing.md`PRD 层面要求:
- 具备 **套餐**Free/Pro/Business**配额**(每订阅周期压缩次数、文件大小/批量上限、保留期等)。
- 具备 **订阅** 生命周期:试用、激活、到期、欠费、取消、恢复。
- 具备 **发票**Invoice**支付记录**Payment可追溯。
- 具备 **用量计量**:以“成功压缩的文件数”为主计量单位(可扩展到字节、转换格式等)。
- 具备 **风控策略**:异常调用/盗刷/滥用限制与告警。
### 3.4 管理后台Admin
- 用户管理:冻结/解冻、限流覆盖、手动调整套餐/额度、查看登录/调用记录。
- 任务管理:查看任务队列/失败原因、取消任务、重试。
- 计费管理:查看订阅与发票、手动赠送额度、处理退款(首期可做“手动退款登记”)。
- 系统配置:全局限流、文件限制、保留期、功能开关(注册开关等)。
- 监控面板QPS、延迟、错误率、队列长度、CPU/内存、S3/DB/Redis 状态。
---
## 4. 套餐与配额(建议默认值,可调)
> 这些是“产品默认建议”,最终可在上线前确认并固化到配置/数据库。
| 项 | Free | Pro | Business |
|---|---:|---:|---:|
| 每周期压缩次数(成功文件数) | 500 | 10,000 | 100,000+ |
| 单文件大小上限 | 5 MB | 20 MB | 50 MB |
| 单次批量上限 | 10 | 50 | 200 |
| 并发(建议) | 2 | 8 | 32 |
| 结果保留期 | 24 小时 | 7 天 | 30 天 |
| API 访问 | ❌ | ✅ | ✅ |
| Webhook | ❌ | ✅ | ✅ |
| SSO/团队 | ❌ | ❌ | 可选 |
周期定义(用于“本周期已用/剩余/重置时间”的展示与扣减):
- **Pro/Business付费**:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`),不是自然月。
- **Free未订阅**按自然月重置UTC+8
匿名试用(无需登录):
- 每日 10 次(以成功压缩文件数计,失败不计)
- 日界自然日UTC+8次日 00:00 重置
- 不提供 API Key
- 结果保留 24 小时
计量单位:
- **compression_unit**:每成功压缩 1 个输出文件计 1。
- 对于批量任务:按文件粒度计量;失败文件不计量。
- 幂等:同一个 Idempotency-Key 重试不重复计量。
---
## 5. 关键体验与质量指标NFR
### 5.1 性能与稳定性(建议目标)
- 99% 的同步单图压缩在 3s 内完成(小图、常见格式)。
- 批量任务在可预期时间内完成(提供进度/预估)。
- 系统可水平扩展API 与 Worker 可独立扩容。
### 5.2 安全与合规(必须)
- 默认移除 EXIF 等隐私元数据(可配置允许保留,但需明确提示)。
- 上传内容按保留期自动删除,且支持用户主动删除。
- API Key 仅创建时显示一次;支持轮换与禁用。
- 具备基础风控IP/账号/API Key 限流、异常突增告警。
---
## 6. MVP / V1 里程碑建议
### MVP可上线收费的最小闭环
- 网站压缩(同步 + 批量异步)+ 下载/ZIP + 历史(登录用户)。
- API Key + 同步单图(直接返回二进制)+ 异步批量(任务/下载)。
- 用量计量 + 套餐配额Free/Pro+ 订阅(至少一种支付渠道)+ 发票列表。
- 管理后台:用户/任务/配置/用量查看 + 手动赠送额度。
### V1+(商用增强)
- Webhook + SDKTS/Python/Go+ OpenAPI 自动生成。
- 团队/组织、多 Key 管理、细粒度权限、IP 白名单。
- 企业发票/税务字段、对公转账/线下支付流程。
- 风控升级:验证码、黑名单、设备指纹、异常画像。
---
## 7. 已确认口径(开工前)
- 支付渠道Stripe
- 计费策略:硬配额(超额返回 `QUOTA_EXCEEDED` / HTTP 402
- 配额周期:按订阅周期(非自然月)
- 匿名试用:支持(每日 10 次),不提供 API Key
- Free 套餐 API不开放仅 Pro/Business 可创建 API Key
- 邮件服务:注册需邮箱验证 + 密码重置SMTP预置多服务商模板
- 默认语言:中文
## 8. 待完成清单(上线前必须定稿)
- 法务页面:隐私政策、服务条款、数据保留与删除说明。(已提供模板,建议上线前法务审核)

114
docs/privacy.md Normal file
View File

@@ -0,0 +1,114 @@
# 隐私政策(示例模板)- ImageForge
> 提示:本文为通用模板,**不构成法律意见**。上线前建议由专业人士结合你的主体信息、所在地法律与实际数据流进行审核与调整。
最后更新2025-12-18
---
## 1. 我们收集哪些信息
### 1.1 账号信息
- 邮箱、用户名、密码哈希(不存明文)
- 邮箱验证状态
### 1.2 使用与设备信息(日志/审计)
- IP 地址、User-Agent、请求时间、接口路径、错误信息
- 与安全、对账相关的审计记录不包含明文密码、JWT、API Key
### 1.3 计费信息
- 订阅状态、发票与支付记录(通过 Stripe
- Stripe 返回的客户/订阅/支付标识(如 customer_id、subscription_id 等)
### 1.4 你上传的内容
- 你上传的图片文件(用于压缩处理与结果下载)
- 与图片相关的必要元信息(如格式、大小、压缩比例)
> 默认情况下我们会移除图片 EXIF 等元数据(定位/设备信息),除非你在压缩时明确选择保留。
---
## 2. 我们如何使用信息
我们使用上述信息用于:
- 提供与改进图片压缩服务(生成压缩结果、任务状态、下载)
- 账号与安全(登录、邮箱验证、密码重置、防滥用)
- 计费与对账(订阅、发票、支付状态同步)
- 客服与故障排查(定位问题、处理投诉与支持)
---
## 3. 我们如何共享信息
我们不会出售你的个人信息。我们可能在以下场景共享必要信息:
- **Stripe**:用于订阅、支付与账单管理
- **邮件服务商/SMTP**:用于发送验证邮件与密码重置邮件
- **基础设施服务**(如对象存储/CDN用于存储与分发压缩结果
我们仅在提供服务所必需范围内共享信息,并尽力要求第三方采取合理安全措施。
---
## 4. 数据存储与保留
### 4.1 图片与结果文件
- 匿名试用与不同套餐有不同的保留期(例如:匿名 24 小时Pro 7 天Business 30 天)。
- 过期后系统会自动删除任务与相关文件(可能存在一定延迟)。
- 你可能可以在控制台手动删除任务/文件(如提供该功能)。
### 4.2 日志与审计
出于安全与对账需要,我们可能会保留部分审计记录更长时间(并尽量脱敏)。
---
## 5. Cookies 与本地存储
我们可能使用 Cookie/本地存储用于:
- 匿名试用会话(维持试用状态与配额计数)
- 登录状态(如使用 Cookie 会话)
- 安全控制(如 CSRF 防护)
你可以通过浏览器设置清除 Cookie但这可能影响部分功能可用性。
---
## 6. 你的权利
你可以:
- 访问与修改账号信息
- 请求删除账号(如提供该功能)
- 请求删除任务/文件(如提供该功能)
如需人工协助,请通过页面或邮箱联系我们。
---
## 7. 安全措施
我们采取合理措施保护数据安全,例如:
- 密码使用安全算法哈希存储
- API Key 不以明文存储
- 访问控制、限流与审计日志
但互联网并非绝对安全,我们无法保证百分之百安全。
---
## 8. 未成年人
本服务不面向未成年人提供。如你是未成年人,请在监护人同意与指导下使用。
---
## 9. 本政策的变更
我们可能更新本隐私政策。重大变更会通过站内公告、邮件或其他方式提示。你继续使用服务即视为接受更新后的政策。
---
## 10. 联系方式
如对隐私政策有疑问或请求,请联系:
- 邮箱privacy@your-domain.com
- 网站https://your-domain.com

119
docs/security.md Normal file
View File

@@ -0,0 +1,119 @@
# 安全与风控设计 - ImageForge
目标:在“上传文件 + 对外 API + 计费”场景下,将最常见、最致命的安全与滥用风险前置到设计阶段,确保后续实现时有统一口径。
---
## 1. 威胁模型(摘要)
核心资产:
- 用户账号、API Key、订阅与账单数据
- 计算资源CPU/内存/带宽/存储)与服务可用性
- 用户上传图片(可能包含隐私/商业机密)
主要攻击面:
- 上传入口文件炸弹、DoS、恶意内容、路径/存储穿越)
- 认证入口撞库、弱密码、Token 泄露)
- API Key盗用、重放、暴力猜测
- Webhook伪造事件、重放、乱序
- 管理后台(权限越权、配置投毒)
---
## 2. 认证与会话
### 2.1 用户登录
- 密码哈希:`argon2id`(带独立 salt参数可配置
- 登录保护:基础限速 + 失败次数冷却可选验证码V1+)。
- 账号状态:`is_active=false` 直接拒绝登录与 API。
### 2.2 JWT 使用建议
- 对外 API支持 Bearer Token适合 CLI/SDK
- 网站Vue3优先使用 HttpOnly Cookie 承载会话(降低 XSS 泄露风险),如使用 localStorage 必须配合严格 CSP。
---
## 3. API Key 安全
### 3.1 Key 生成与展示
- Key 仅在创建时展示一次(前端明确提示“请立即保存”)。
- Key 前缀(`key_prefix`)用于列表展示与快速检索。
### 3.2 Key 存储与校验
推荐:`key_hash = HMAC-SHA256(full_key, API_KEY_PEPPER)`,只存 hash不存明文。
理由:
- 校验快,适合高 QPS
- pepper 作为服务器秘密(配置/密钥管理系统),泄露风险可控;
- 避免 bcrypt/argon2 用在高频 key 校验导致性能瓶颈。
### 3.3 权限与限制
- 最小权限permissionscompress/batch/read_stats/billing_read 等)。
- 支持禁用/轮换;可选 IP 白名单Business/V1+)。
- 每次请求记录 `last_used_at/last_used_ip/user_agent`(审计)。
---
## 4. 上传与图片处理安全
### 4.1 输入校验
- 只依赖扩展名不安全:必须校验魔数/探测真实格式。
- 设定上限:
- `max_file_size_mb`
- `max_pixels`(宽×高)
- `max_dimension`(单边)
- 解码超时Worker 层,避免卡死)
### 4.2 资源隔离
- 压缩属 CPU 密集型:放到 WorkerAPI 只做编排与轻量校验。
- Worker 限制并发:按“用户/套餐”与“全局”双维度控制。
- 对异常图片:快速失败并记录审计与指标(格式错误/像素超限/解码失败)。
### 4.3 元数据(隐私)
- 默认移除 EXIF定位/设备信息),除非用户明确开启 `preserve_metadata=true`
- UI 必须清晰提示该开关的隐私含义。
---
## 5. 计费风控(防盗刷/滥用)
- **幂等**`Idempotency-Key` 防止重试导致重复扣费。
- **配额硬限制**:到达当期额度返回 `QUOTA_EXCEEDED`HTTP 402
- **匿名试用**:每日 10 次(成功文件数计),采用 **Cookie + IP** 双维度 Redis 计数做硬限制。
- **异常检测**(告警即可,首期不必自动封禁):
- 短时间内用量突增
- 失败率异常升高(疑似 fuzzing/探测)
- 单 Key 多 IP 快速切换
---
## 6. Webhook 安全
必须要求:
- 验签provider 签名 + webhook secret
- 事件幂等:按 `provider_event_id` 去重。
- 重放保护:记录 `received_at` 与处理状态,拒绝重复处理。
- 最小暴露webhook 路由不接受浏览器跨域调用,不返回敏感信息。
---
## 7. Web 安全(前端/网关)
### 7.1 HTTP 安全头(建议由 Nginx 设置)
- `Strict-Transport-Security`
- `Content-Security-Policy`(至少限制脚本来源;如用第三方支付跳转按需放开)
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy`
- `Permissions-Policy`
### 7.2 CORS 策略
- 若前后端同域:尽量不启用宽松 CORS。
- 若分离部署CORS 白名单仅放行前端域名;对 `/webhooks/*` 禁止 CORS。
---
## 8. 数据安全与保留
- 结果保留期按套餐Free 24h、Pro 7d、Business 30d 等),匿名更短。
- 支持用户主动删除任务/文件(立即删除对象存储 + DB 标记/审计)。
- 审计日志留存与脱敏保留必要字段IP、UA、动作、对象 ID避免写入明文密钥/Token。

117
docs/terms.md Normal file
View File

@@ -0,0 +1,117 @@
# 服务条款(示例模板)- ImageForge
> 提示:本文为通用模板,**不构成法律意见**。上线前建议由专业人士结合你的主体信息、所在地法律与实际业务流程进行审核与调整。
最后更新2025-12-18
---
## 1. 接受条款
欢迎使用 ImageForge下称“本服务”。当你访问或使用本服务包括网站与 API即表示你已阅读、理解并同意受本条款约束。如果你不同意本条款请停止使用本服务。
---
## 2. 服务内容
本服务提供图片压缩与格式转换能力,包括但不限于:
- 网站上传压缩与批量压缩(含匿名试用)
- 开发者 APIAPI Key 调用)
- 订阅与计费Stripe
- 任务历史、下载与结果存储(按保留期自动清理)
我们可能随时调整服务功能、参数与限制(例如文件大小、并发、速率、保留期等),并在合理范围内进行公告或提示。
---
## 3. 账号与安全
3.1 你可能需要注册账号才能使用部分功能。你应提供真实、准确、完整的信息并及时更新。
3.2 你应妥善保管账号凭据与 API Key。因你保管不善导致的损失由你自行承担。
3.3 我们可能对异常登录、滥用行为采取限制、冻结或终止服务措施。
---
## 4. 匿名试用与限制
4.1 匿名试用用于体验本服务,存在使用限制(例如:每日次数、文件大小、批量数量、保留期等)。
4.2 我们可基于安全与风控原因随时调整匿名试用策略或停止匿名试用。
---
## 5. API 使用与开发者责任
5.1 开发者 API 仅对符合条件的用户开放例如Pro/Business 套餐)。
5.2 你不得绕过鉴权、限流、配额或其他安全控制。
5.3 你应在你的产品中向最终用户提供必要的告知(如你上传处理了用户图片、保留期、隐私政策等)。
---
## 6. 计费、订阅与退款
6.1 本服务通过 Stripe 提供订阅计费能力。你在购买订阅时应确认价格、周期、包含额度、超额策略与取消规则。
6.2 **硬配额**:当期额度耗尽后,新增压缩请求将被拒绝(可能返回 `402 QUOTA_EXCEEDED`)。
6.3 取消订阅通常在当前计费周期结束后生效。
6.4 退款政策:可根据你的实际运营策略另行约定(建议在上线前补充清晰规则)。
---
## 7. 内容与知识产权
7.1 你上传的图片及其相关权利归你或权利人所有。你应确保你拥有上传、处理该内容的合法权利。
7.2 你授予我们在提供本服务所必需范围内处理、存储、传输该内容的许可(例如生成压缩结果、提供下载链接、用于排障日志中的必要元信息)。
7.3 本服务的软件、界面、商标、文档等知识产权归我们或权利人所有,除非另有明确授权。
---
## 8. 禁止行为
你不得利用本服务进行包括但不限于以下行为:
- 上传或传播违法、侵权、恶意内容
- 使用自动化方式进行超出合理范围的抓取、压测、滥用
- 试图入侵、绕过鉴权、伪造请求或篡改数据
- 传播病毒、木马或其他破坏性代码
- 违反适用法律法规或本条款的其他行为
---
## 9. 数据保留与删除
9.1 压缩结果与任务记录会按你的套餐/设置保留一定时间,过期后自动删除。
9.2 你可能可以在控制台手动删除任务/文件(如提供该功能)。
9.3 与服务安全、对账、审计相关的必要日志可能会保留更长时间(并进行脱敏处理)。
---
## 10. 免责声明
10.1 本服务按“现状”提供。我们将尽力提供稳定服务,但不对完全无故障、无中断作出承诺。
10.2 由于网络、第三方服务(如 Stripe、邮件服务商、对象存储等原因造成的延迟或失败我们将在合理范围内协助处理但不承担超出法律允许范围的责任。
---
## 11. 责任限制
在适用法律允许的范围内,我们对因使用或无法使用本服务所导致的间接损失、利润损失、数据丢失等不承担责任。若法律要求承担责任,我们的责任上限可按你最近一个计费周期实际支付金额(或合理上限)计算(具体上限可按你运营策略补充)。
---
## 12. 终止
12.1 你可以停止使用本服务并按指引取消订阅。
12.2 如你违反本条款或存在风险行为,我们可暂停或终止向你提供服务,并保留追究责任的权利。
---
## 13. 条款变更
我们可能会更新本条款。重大变更将通过站内公告、邮件或其他方式提示。你继续使用服务即视为接受更新后的条款。
---
## 14. 联系方式
如对本条款有疑问,请联系:
- 邮箱support@your-domain.com
- 网站https://your-domain.com

134
docs/ui.md Normal file
View File

@@ -0,0 +1,134 @@
# UI/UX 设计文档 - ImageForge
目标:做一个接近商用的体验(简单、可信、可解释),同时覆盖“网站压缩 + 开发者 API + 计费”三条主线。
---
## 1. 设计原则
1) **一眼可用**:首页即工具,不把用户“逼进登录”才能体验。
2) **结果可信**:清晰展示“压缩前后对比、节省多少、是否去除元数据、链接多久过期”。
3) **开发者友好**API Key、用量、示例代码和错误处理在控制台里一站式找到。
4) **商用闭环**:升级/取消/发票/支付状态明确,避免“扣费但不知道扣了什么”。
---
## 2. 信息架构(站点地图)
### 2.1 公共区域(无需登录)
- `/` 首页(压缩工具)
- `/pricing` 价格页套餐对比、FAQ
- `/docs` 开发者文档入口API 概览、SDK、示例
- `/login` `/register`
- `/terms` `/privacy`
### 2.2 用户控制台(需要登录)
- `/dashboard` 概览(当期用量、套餐、最近任务)
- `/dashboard/history` 历史任务
- `/dashboard/api-keys` API Key 管理 + 用量头说明
- `/dashboard/billing` 订阅与发票
- `/dashboard/settings` 账号设置(密码、删除账号等)
### 2.3 管理后台(管理员)
- `/admin` 概览QPS、错误率、队列长度、订阅状态分布
- `/admin/users` 用户管理(冻结、限流覆盖、赠送额度)
- `/admin/tasks` 任务管理(取消/重试、失败原因)
- `/admin/billing` 订阅/发票/支付事件Webhook排查
- `/admin/config` 全局配置(开关、限流、文件限制、保留期默认)
---
## 3. 关键页面规格(线框级)
### 3.1 首页(压缩工具)
核心组件:
- 顶栏Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单)
- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量)
- 参数区:压缩率、尺寸限制、元数据开关(输出格式保持原图)
- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试)
- 汇总区:总节省、下载 ZIP、清空
- 信任区:隐私说明(默认去 EXIF、保留期说明、状态页/联系入口
交互要点:
- 上传后立即生成本地缩略图与原始大小;压缩进度逐文件显示。
- 默认提示“匿名试用:每日 10 次”,登录后提示“升级解锁更大额度/保留期”。
### 3.2 价格页Pricing
结构建议:
- Hero一句话价值 + CTA开始压缩 / 查看 API
- 套餐卡片Free / Pro / Business三列
- 对比表:文件大小/批量上限/保留期/Webhook/团队
- FAQ计量单位、超额策略、退款、隐私与保留
### 3.3 控制台概览Dashboard
上半区KPI 卡片):
- 当期已用/剩余(进度条)
- 节省流量累计(本周期/总计)
- API 调用数(本周期)
- 当前套餐与到期时间
下半区:
- 最近任务列表(状态、文件数、节省、操作)
- 升级提示(当剩余额度 < 20%
### 3.4 API Key 管理
列表字段:
- 名称、前缀、权限、限流、最后使用时间/IP、状态启用/禁用)
创建/轮换:
- 创建时弹窗展示一次完整 Key支持“一键复制”
- 轮换说明:首期默认“立即轮换”(生成新 Key 并立即禁用旧 Key避免双 Key 过渡带来的复杂度
开发者引导:
- 显示 curl 示例(调用 `POST /compress/direct`
- 显示常见错误码(`QUOTA_EXCEEDED``RATE_LIMITED`与重试策略Idempotency-Key
### 3.5 订阅与发票Billing
模块:
- 当前订阅套餐、周期、状态active/past_due、升级/取消按钮
- 发票列表:编号、金额、状态、周期、下载/跳转支付provider
- 支付方式入口:跳转客户 Portal如 Stripe portal
### 3.6 历史任务History
筛选:
- 时间范围、状态、来源web/api、格式、压缩模式
列表:
- 任务创建时间、文件数、节省、到期时间、下载 ZIP、删除隐私
---
## 4. 视觉与组件规范(建议)
### 4.1 设计风格
- 清爽、留白、强调数据对比(节省% 是视觉重点)
- 状态色:成功/警告/失败明确
### 4.2 主题变量(示例)
沿用 `docs/frontend.md` 的 CSS 变量,并补充:
- `--info``--border``--bg``--text`
- 暗色模式V1+
### 4.3 组件清单(公共)
- `AppLayout` / `AuthLayout` / `AdminLayout`
- `DropZone``FileCard``OptionsPanel`
- `UsageMeter``PlanCard``InvoiceTable`
- `Toast``Modal``Skeleton``EmptyState`
---
## 5. 关键文案(必须在 UI 中出现)
- 元数据:默认提示“默认会移除 EXIF 等元数据(定位/设备信息)”
- 保留期下载链接到期时间例如“24 小时后自动删除”)
- 计量:说明“成功压缩 1 个文件计 1 次”
- 错误:配额不足/限流时给出“如何解决”(登录/升级/稍后重试)
---
## 6. 可访问性与体验细节
- 键盘可达:上传区、弹窗、按钮可 Tab 导航
- 颜色对比:状态色满足可读性
- 大文件/批量:明确“后台处理中,可关闭页面稍后回来”
- 移动端:首页只保留必要参数,高级参数折叠

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImageForge - 图片压缩</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2756
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,69 @@
<template>
<div class="mx-auto max-w-6xl px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">管理后台</h1>
<p class="text-sm text-slate-600">仅管理员可访问系统概览与运营配置</p>
</div>
<router-link
to="/dashboard"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
>
返回控制台
</router-link>
</div>
<div class="mt-6 flex flex-col gap-6 lg:flex-row">
<nav class="w-full shrink-0 rounded-xl border border-slate-200 bg-white p-4 lg:w-60">
<div class="space-y-1 text-sm text-slate-700">
<router-link
to="/admin"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
概览
</router-link>
<router-link
to="/admin/users"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
用户管理
</router-link>
<router-link
to="/admin/tasks"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
任务管理
</router-link>
<router-link
to="/admin/billing"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
订阅与额度
</router-link>
<router-link
to="/admin/integrations"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
支付与邮件
</router-link>
<router-link
to="/admin/config"
class="block rounded-md px-3 py-2 hover:bg-slate-100"
active-class="bg-slate-100 text-slate-900"
>
系统配置
</router-link>
</div>
</nav>
<div class="min-w-0 flex-1">
<router-view />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const username = computed(() => auth.user?.username ?? auth.user?.email ?? '用户')
function logout() {
auth.logout()
void router.push({ name: 'home' })
}
</script>
<template>
<div class="min-h-full bg-slate-50">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-4">
<RouterLink
to="/"
class="text-lg font-semibold tracking-tight text-slate-900 hover:no-underline"
>
ImageForge
</RouterLink>
<span class="text-sm text-slate-500">控制台</span>
</div>
<div class="flex items-center gap-3">
<span class="hidden text-sm text-slate-600 md:inline">你好{{ username }}</span>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
@click="logout"
>
退出
</button>
</div>
</div>
</header>
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-8 md:grid-cols-12">
<aside class="md:col-span-3">
<nav class="rounded-lg border border-slate-200 bg-white p-2 text-sm">
<RouterLink
to="/dashboard"
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
exact-active-class="bg-indigo-50 text-indigo-700"
>
概览
</RouterLink>
<RouterLink
to="/dashboard/history"
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
exact-active-class="bg-indigo-50 text-indigo-700"
>
历史任务
</RouterLink>
<RouterLink
to="/dashboard/api-keys"
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
exact-active-class="bg-indigo-50 text-indigo-700"
>
API Keys
</RouterLink>
<RouterLink
to="/dashboard/billing"
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
exact-active-class="bg-indigo-50 text-indigo-700"
>
订阅与发票
</RouterLink>
<RouterLink
to="/dashboard/settings"
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
exact-active-class="bg-indigo-50 text-indigo-700"
>
账号设置
</RouterLink>
</nav>
</aside>
<main class="md:col-span-9">
<router-view />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const isLoggedIn = computed(() => auth.isLoggedIn)
function logout() {
auth.logout()
void router.push({ name: 'home' })
}
</script>
<template>
<div class="min-h-full">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/80 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-6">
<RouterLink
to="/"
class="text-lg font-semibold tracking-tight text-slate-900 hover:no-underline"
>
ImageForge
</RouterLink>
<nav class="hidden items-center gap-4 text-sm text-slate-600 md:flex">
<RouterLink to="/pricing" class="hover:text-slate-900">价格</RouterLink>
<RouterLink to="/docs" class="hover:text-slate-900">开发者</RouterLink>
</nav>
</div>
<div class="flex items-center gap-3">
<template v-if="isLoggedIn">
<RouterLink
to="/dashboard"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
>
控制台
</RouterLink>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
@click="logout"
>
退出
</button>
</template>
<template v-else>
<RouterLink
to="/login"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
>
登录
</RouterLink>
<RouterLink
to="/register"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
注册
</RouterLink>
</template>
</div>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-8">
<router-view />
</main>
<footer class="border-t border-slate-200 bg-white">
<div
class="mx-auto flex max-w-6xl flex-col items-start justify-between gap-2 px-4 py-6 text-sm text-slate-500 md:flex-row md:items-center"
>
<div>© {{ new Date().getFullYear() }} ImageForge</div>
<div class="flex items-center gap-4">
<RouterLink to="/terms" class="hover:text-slate-700">服务条款</RouterLink>
<RouterLink to="/privacy" class="hover:text-slate-700">隐私政策</RouterLink>
</div>
</div>
</footer>
</div>
</template>

View File

@@ -0,0 +1,93 @@
import type { Pinia } from 'pinia'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
export function createAppRouter(pinia: Pinia) {
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/app/layouts/PublicLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
{ path: 'docs', name: 'docs', component: () => import('@/pages/DocsPage.vue') },
{ path: 'login', name: 'login', component: () => import('@/pages/LoginPage.vue') },
{ path: 'register', name: 'register', component: () => import('@/pages/RegisterPage.vue') },
{ path: 'verify-email', name: 'verify-email', component: () => import('@/pages/VerifyEmailPage.vue') },
{ path: 'forgot-password', name: 'forgot-password', component: () => import('@/pages/ForgotPasswordPage.vue') },
{ path: 'reset-password', name: 'reset-password', component: () => import('@/pages/ResetPasswordPage.vue') },
{ path: 'terms', name: 'terms', component: () => import('@/pages/TermsPage.vue') },
{ path: 'privacy', name: 'privacy', component: () => import('@/pages/PrivacyPage.vue') },
],
},
{
path: '/dashboard',
component: () => import('@/app/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'dashboard', component: () => import('@/pages/dashboard/DashboardHomePage.vue') },
{
path: 'history',
name: 'dashboard-history',
component: () => import('@/pages/dashboard/DashboardHistoryPage.vue'),
},
{
path: 'api-keys',
name: 'dashboard-api-keys',
component: () => import('@/pages/dashboard/DashboardApiKeysPage.vue'),
},
{
path: 'billing',
name: 'dashboard-billing',
component: () => import('@/pages/dashboard/DashboardBillingPage.vue'),
},
{
path: 'settings',
name: 'dashboard-settings',
component: () => import('@/pages/dashboard/DashboardSettingsPage.vue'),
},
],
},
{
path: '/admin',
component: () => import('@/app/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{ path: '', name: 'admin', component: () => import('@/pages/admin/AdminHomePage.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/pages/admin/AdminUsersPage.vue') },
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.vue') },
{ path: 'billing', name: 'admin-billing', component: () => import('@/pages/admin/AdminBillingPage.vue') },
{ path: 'integrations', name: 'admin-integrations', component: () => import('@/pages/admin/AdminIntegrationsPage.vue') },
{ path: 'config', name: 'admin-config', component: () => import('@/pages/admin/AdminConfigPage.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
router.beforeEach((to) => {
const auth = useAuthStore(pinia)
if (to.meta?.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if ((to.name === 'login' || to.name === 'register') && auth.isLoggedIn) {
return { name: 'dashboard' }
}
if (to.meta?.requiresAdmin && auth.user?.role !== 'admin') {
return { name: 'dashboard' }
}
return true
})
return router
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

20
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { createAppRouter } from './app/router'
import { useAuthStore } from './stores/auth'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
const auth = useAuthStore(pinia)
auth.initFromStorage()
const router = createAppRouter(pinia)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,30 @@
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">开发者</h1>
<p class="text-sm text-slate-600">对外 API + 计费 + 额度硬配额一体化</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
<div class="font-medium text-slate-900">快速开始</div>
<ol class="mt-2 list-decimal space-y-1 pl-5">
<li>登录后在控制台创建 API Key Pro/Business</li>
<li>调用 <code>POST /api/v1/compress/direct</code> 获得二进制输出</li>
<li>配额不足返回 <code>402 QUOTA_EXCEEDED</code>请升级或等待周期重置</li>
</ol>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
<div class="font-medium text-slate-900">示例curl</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \\
-H \"X-API-Key: if_live_xxx\" \\
-F \"file=@./demo.png\" \\
-F \"compression_rate=70\" \\
https://your-domain.com/api/v1/compress/direct -o out.png</code></pre>
</div>
<div class="text-sm text-slate-600">
更完整的接口说明请查看仓库内文档<code>docs/api.md</code>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from 'vue'
import { forgotPassword } from '@/services/api'
import { ApiError } from '@/services/http'
const email = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const success = ref<string | null>(null)
async function submit() {
busy.value = true
error.value = null
success.value = null
try {
const resp = await forgotPassword(email.value.trim())
success.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '发送失败,请稍后再试'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h1 class="text-xl font-semibold text-slate-900">找回密码</h1>
<p class="mt-1 text-sm text-slate-600">输入邮箱我们会发送重置链接</p>
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ error }}
</div>
<div
v-if="success"
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
>
{{ success }}
</div>
<form class="mt-5 space-y-4" @submit.prevent="submit">
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">邮箱</div>
<input
v-model="email"
type="email"
autocomplete="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="you@example.com"
required
/>
</label>
<button
type="submit"
class="w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy"
>
{{ busy ? '发送中…' : '发送重置链接' }}
</button>
</form>
<div class="mt-4 text-sm text-slate-600">
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">返回登录</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,422 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { compressFile, getSubscription, getUsage, sendVerification, type CompressResponse } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatBytes } from '@/utils/format'
type ItemStatus = 'idle' | 'compressing' | 'done' | 'error'
interface UploadItem {
id: string
file: File
previewUrl: string
status: ItemStatus
result?: CompressResponse
error?: string
}
const auth = useAuthStore()
const options = reactive({
compressionRate: 60,
maxWidth: '' as string,
maxHeight: '' as string,
})
const dragActive = ref(false)
const items = ref<UploadItem[]>([])
const busy = computed(() => items.value.some((x) => x.status === 'compressing'))
const alert = ref<{ type: 'info' | 'success' | 'error'; message: string } | null>(null)
const sendingVerification = ref(false)
const quotaLoading = ref(false)
const quotaError = ref<string | null>(null)
const usage = ref<Awaited<ReturnType<typeof getUsage>> | null>(null)
const subscription = ref<Awaited<ReturnType<typeof getSubscription>>['subscription'] | null>(null)
const needVerifyEmail = computed(() => auth.isLoggedIn && auth.user && !auth.user.email_verified)
onMounted(async () => {
if (!auth.token) return
quotaLoading.value = true
quotaError.value = null
try {
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
usage.value = u
subscription.value = s.subscription
} catch (err) {
if (err instanceof ApiError) {
quotaError.value = `[${err.code}] ${err.message}`
} else {
quotaError.value = '额度加载失败'
}
} finally {
quotaLoading.value = false
}
})
function addFiles(fileList: FileList | null) {
if (!fileList || fileList.length === 0) return
alert.value = null
for (const file of Array.from(fileList)) {
const id = crypto.randomUUID()
const previewUrl = URL.createObjectURL(file)
items.value.push({ id, file, previewUrl, status: 'idle' })
}
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
addFiles(input.files)
input.value = ''
}
function handleDrop(event: DragEvent) {
dragActive.value = false
if (busy.value) return
addFiles(event.dataTransfer?.files ?? null)
}
function removeItem(id: string) {
const idx = items.value.findIndex((x) => x.id === id)
if (idx === -1) return
const item = items.value[idx]
if (!item) return
URL.revokeObjectURL(item.previewUrl)
items.value.splice(idx, 1)
}
function clearAll() {
for (const item of items.value) URL.revokeObjectURL(item.previewUrl)
items.value = []
alert.value = null
}
function toInt(v: string): number | undefined {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return undefined
return Math.floor(n)
}
async function runOne(item: UploadItem) {
item.status = 'compressing'
item.error = undefined
try {
const result = await compressFile(
item.file,
{
compression_rate: options.compressionRate,
max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight),
},
auth.token,
)
item.result = result
item.status = 'done'
} catch (err) {
item.status = 'error'
if (err instanceof ApiError) {
item.error = `[${err.code}] ${err.message}`
return
}
item.error = '压缩失败,请稍后再试'
}
}
async function runAll() {
alert.value = null
for (const item of items.value) {
if (item.status === 'done') continue
await runOne(item)
}
}
async function download(item: UploadItem) {
if (!item.result) return
const url = item.result.download_url
if (!auth.token) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
const res = await fetch(url, {
headers: { authorization: `Bearer ${auth.token}` },
})
if (!res.ok) {
alert.value = { type: 'error', message: `下载失败HTTP ${res.status}` }
return
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
const base = item.file.name.replace(/\.[^/.]+$/, '')
const ext = item.result.format_out === 'jpeg' ? 'jpg' : item.result.format_out
a.download = `${base}.${ext}`
a.click()
URL.revokeObjectURL(objectUrl)
}
async function resendVerification() {
if (!auth.token) return
sendingVerification.value = true
alert.value = null
try {
const resp = await sendVerification(auth.token)
alert.value = { type: 'success', message: resp.message }
} catch (err) {
if (err instanceof ApiError) {
alert.value = { type: 'error', message: `[${err.code}] ${err.message}` }
} else {
alert.value = { type: 'error', message: '发送失败,请稍后再试' }
}
} finally {
sendingVerification.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">图片压缩</h1>
<p class="text-sm text-slate-600">
默认移除 EXIF 等元数据匿名试用每天 10 UTC+8自然日Cookie + IP 双限制
</p>
</div>
<div
v-if="needVerifyEmail"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"
>
<div class="font-medium">你的邮箱尚未验证</div>
<div class="mt-1 text-amber-800">验证后才能使用登录态压缩与 API 能力</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-md bg-amber-600 px-3 py-1.5 font-medium text-white hover:bg-amber-700 disabled:opacity-50"
:disabled="sendingVerification"
@click="resendVerification"
>
重新发送验证邮件
</button>
<router-link class="text-amber-900 underline" to="/dashboard/settings">前往账号设置</router-link>
</div>
</div>
<div
v-if="alert"
class="rounded-lg border p-4 text-sm"
:class="{
'border-slate-200 bg-white text-slate-700': alert.type === 'info',
'border-emerald-200 bg-emerald-50 text-emerald-900': alert.type === 'success',
'border-rose-200 bg-rose-50 text-rose-900': alert.type === 'error',
}"
>
{{ alert.message }}
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<div class="lg:col-span-7">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-slate-900">上传图片</div>
<div class="text-xs text-slate-500">支持 PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICOGIF 仅静态</div>
</div>
<div class="mt-4 flex flex-col gap-3">
<div
class="relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 text-center transition"
:class="[
dragActive ? 'border-indigo-400 bg-indigo-50' : 'border-slate-200 bg-slate-50',
busy ? 'opacity-60' : '',
]"
@dragenter.prevent="dragActive = true"
@dragover.prevent
@dragleave.prevent="dragActive = false"
@drop.prevent="handleDrop"
>
<div class="text-sm font-medium text-slate-700">拖拽图片到这里</div>
<div class="mt-1 text-xs text-slate-500">或点击选择文件支持批量上传</div>
<input
class="absolute inset-0 cursor-pointer opacity-0"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,image/avif,image/gif,image/bmp,image/x-ms-bmp,image/tiff,image/x-icon,image/vnd.microsoft.icon"
multiple
:disabled="busy"
@change="handleFileChange"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy || items.length === 0"
@click="runAll"
>
开始压缩
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="busy || items.length === 0"
@click="clearAll"
>
清空
</button>
</div>
</div>
<div v-if="items.length" class="mt-6 space-y-3">
<div
v-for="item in items"
:key="item.id"
class="flex items-center gap-3 rounded-lg border border-slate-200 p-3"
>
<img :src="item.previewUrl" class="h-12 w-12 rounded object-cover" alt="" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-900">{{ item.file.name }}</div>
<div class="text-xs text-slate-500">
原始{{ formatBytes(item.file.size) }}
<template v-if="item.result">
· 压缩后{{ formatBytes(item.result.compressed_size) }} · 节省
{{ item.result.saved_percent.toFixed(2) }}%
</template>
</div>
<div v-if="item.error" class="mt-1 text-xs text-rose-700">{{ item.error }}</div>
</div>
<div class="flex items-center gap-2">
<span
v-if="item.status === 'compressing'"
class="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
处理中
</span>
<span
v-else-if="item.status === 'done'"
class="rounded-full bg-emerald-50 px-2 py-1 text-xs text-emerald-700"
>
完成
</span>
<span
v-else-if="item.status === 'error'"
class="rounded-full bg-rose-50 px-2 py-1 text-xs text-rose-700"
>
失败
</span>
<button
v-if="item.status !== 'compressing' && item.result"
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-700 hover:bg-slate-50"
@click="download(item)"
>
下载
</button>
<button
v-if="item.status === 'error' && !busy"
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-700 hover:bg-slate-50"
@click="runOne(item)"
>
重试
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-500 hover:bg-slate-50"
:disabled="busy"
@click="removeItem(item.id)"
>
移除
</button>
</div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-5 space-y-6">
<div v-if="auth.isLoggedIn" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">我的额度</div>
<div v-if="quotaLoading" class="mt-2 text-sm text-slate-500">加载中</div>
<div v-else-if="quotaError" class="mt-2 text-sm text-rose-600">{{ quotaError }}</div>
<div v-else class="mt-2 space-y-1 text-sm text-slate-700">
<div class="text-2xl font-semibold text-slate-900">
{{ usage?.remaining_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div>当期已用 {{ usage?.used_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="text-xs text-slate-500">
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
</div>
<div>套餐{{ subscription?.plan.name ?? 'Free' }}</div>
<router-link to="/dashboard/billing" class="mt-3 inline-flex text-sm text-indigo-600 hover:text-indigo-700">
充值额度
</router-link>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">压缩参数</div>
<div class="mt-4 grid grid-cols-1 gap-4">
<label class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium text-slate-600">
<span>压缩率</span>
<span class="text-slate-500">{{ options.compressionRate }}%</span>
</div>
<input
v-model.number="options.compressionRate"
type="range"
min="1"
max="100"
step="1"
class="w-full"
/>
<div class="text-xs text-slate-500">数值越大压缩越强输出保持原格式</div>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大宽度px</div>
<input
v-model="options.maxWidth"
inputmode="numeric"
placeholder="例如 2000"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大高度px</div>
<input
v-model="options.maxHeight"
inputmode="numeric"
placeholder="例如 2000"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-600">
<div class="font-medium text-slate-700">计量说明</div>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>成功压缩 1 个文件计 1 </li>
<li>超过当期配额将返回 <code>402 QUOTA_EXCEEDED</code>硬配额</li>
<li>下载链接按套餐/匿名试用的保留期自动过期</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { login } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
busy.value = true
try {
const resp = await login(email.value.trim(), password.value)
auth.setAuth(resp.token, resp.user)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.push(redirect)
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '登录失败,请稍后再试'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h1 class="text-xl font-semibold text-slate-900">登录</h1>
<p class="mt-1 text-sm text-slate-600">登录后可查看用量订阅与 API Keys</p>
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ error }}
</div>
<form class="mt-5 space-y-4" @submit.prevent="submit">
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">账号</div>
<input
v-model="email"
type="text"
autocomplete="username"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
required
/>
</label>
<label class="block space-y-1">
<div class="flex items-center justify-between">
<div class="text-xs font-medium text-slate-600">密码</div>
<router-link to="/forgot-password" class="text-xs text-indigo-600 hover:text-indigo-700">
忘记密码
</router-link>
</div>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="至少 8 位"
required
/>
</label>
<button
type="submit"
class="w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy"
>
{{ busy ? '登录中…' : '登录' }}
</button>
</form>
<div class="mt-4 text-sm text-slate-600">
还没有账号
<router-link to="/register" class="text-indigo-600 hover:text-indigo-700">去注册</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<div class="mx-auto max-w-lg">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-center">
<div class="text-2xl font-semibold text-slate-900">404</div>
<div class="mt-2 text-sm text-slate-600">页面不存在</div>
<div class="mt-5">
<router-link
to="/"
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
返回首页
</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { listPlans, type PlanView } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCents } from '@/utils/format'
const loading = ref(true)
const plans = ref<PlanView[]>([])
const error = ref<string | null>(null)
onMounted(async () => {
try {
const resp = await listPlans()
plans.value = resp.plans
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
</script>
<template>
<div class="space-y-8">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">价格</h1>
<p class="text-sm text-slate-600">硬配额计费到达当期额度会直接返回 402 QUOTA_EXCEEDED</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div v-for="plan in plans" :key="plan.id" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-2xl font-semibold text-slate-900">
{{ plan.amount_cents > 0 ? formatCents(plan.amount_cents, plan.currency) : '免费' }}
</div>
<div class="text-xs text-slate-500">/ {{ plan.interval }}</div>
</div>
<div class="mt-4 space-y-2 text-sm text-slate-700">
<div>当期额度{{ plan.included_units_per_period.toLocaleString() }} </div>
<div>单文件{{ plan.max_file_size_mb }} MB</div>
<div>批量{{ plan.max_files_per_batch }} / </div>
<div>保留{{ plan.retention_days }} </div>
</div>
<router-link
to="/dashboard/billing"
class="mt-5 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
立即开始
</router-link>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
<div class="font-medium text-slate-900">FAQ</div>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>计量单位成功压缩 1 个文件计 1 </li>
<li>超额策略硬配额超额直接拒绝不创建任务</li>
<li>隐私默认移除 EXIF 等元数据下载链接到期后自动删除</li>
</ul>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<div class="prose prose-slate max-w-none">
<h1>隐私政策</h1>
<p>最后更新2025-12-18</p>
<h2>1. 我们收集什么</h2>
<ul>
<li>账号信息邮箱用户名用于登录与计费</li>
<li>调用信息为防滥用与计费统计可能记录请求时间IP用量等</li>
</ul>
<h2>2. 图片与元数据</h2>
<p>默认会移除 EXIF 等元数据可能包含定位/设备信息</p>
<h2>3. 保留期</h2>
<p>压缩结果仅在保留期内可下载到期后自动删除</p>
<h2>4. Cookie</h2>
<p>匿名试用会使用 Cookie结合 IP进行每日次数限制</p>
</div>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const email = ref('')
const username = ref('')
const password = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
busy.value = true
try {
const resp = await register(email.value.trim(), password.value, username.value.trim())
auth.setAuth(resp.token, resp.user)
await router.push({ name: 'dashboard', query: { welcome: '1' } })
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '注册失败,请稍后再试'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h1 class="text-xl font-semibold text-slate-900">注册</h1>
<p class="mt-1 text-sm text-slate-600">注册后必须验证邮箱才能使用登录态压缩与 API 能力</p>
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ error }}
</div>
<form class="mt-5 space-y-4" @submit.prevent="submit">
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">邮箱</div>
<input
v-model="email"
type="email"
autocomplete="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="you@example.com"
required
/>
</label>
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">用户名</div>
<input
v-model="username"
type="text"
autocomplete="username"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="例如 imagefan"
required
/>
</label>
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">密码</div>
<input
v-model="password"
type="password"
autocomplete="new-password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="至少 8 位"
required
/>
</label>
<button
type="submit"
class="w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy"
>
{{ busy ? '注册中…' : '注册并进入控制台' }}
</button>
</form>
<div class="mt-4 text-sm text-slate-600">
已有账号
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">去登录</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { resetPassword } from '@/services/api'
import { ApiError } from '@/services/http'
const route = useRoute()
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''))
const newPassword = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const success = ref<string | null>(null)
async function submit() {
error.value = null
success.value = null
if (!token.value) {
error.value = '缺少 token'
return
}
busy.value = true
try {
const resp = await resetPassword(token.value, newPassword.value)
success.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '重置失败,请稍后再试'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h1 class="text-xl font-semibold text-slate-900">重置密码</h1>
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ error }}
</div>
<div
v-if="success"
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
>
{{ success }}
</div>
<form class="mt-5 space-y-4" @submit.prevent="submit">
<label class="block space-y-1">
<div class="text-xs font-medium text-slate-600">新密码</div>
<input
v-model="newPassword"
type="password"
autocomplete="new-password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="至少 8 位"
required
/>
</label>
<button
type="submit"
class="w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy"
>
{{ busy ? '提交中…' : '重置密码' }}
</button>
</form>
<div class="mt-4 text-sm text-slate-600">
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">返回登录</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div class="prose prose-slate max-w-none">
<h1>服务条款</h1>
<p>最后更新2025-12-18</p>
<h2>1. 服务说明</h2>
<p>ImageForge 提供图片压缩与相关开发者 API 服务你需要对上传内容拥有合法权利</p>
<h2>2. 计费与配额</h2>
<ul>
<li>成功压缩 1 个文件计 1 </li>
<li>采用硬配额当期额度不足时新的压缩请求会被拒绝HTTP 402 / QUOTA_EXCEEDED</li>
</ul>
<h2>3. 内容与合规</h2>
<p>禁止上传违法侵权或包含敏感个人信息且未经授权的内容</p>
<h2>4. 免责声明</h2>
<p>服务按现状提供我们会尽力保证稳定但不对不可抗力导致的服务中断承担责任</p>
<h2>5. 联系方式</h2>
<p>如有问题请联系站点管理员</p>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { verifyEmail } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''))
const loading = ref(true)
const message = ref<string>('')
const error = ref<string | null>(null)
const auth = useAuthStore()
onMounted(async () => {
if (!token.value) {
loading.value = false
error.value = '缺少 token'
return
}
try {
const resp = await verifyEmail(token.value)
message.value = resp.message
auth.markEmailVerified()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '验证失败,请稍后再试'
}
} finally {
loading.value = false
}
})
</script>
<template>
<div class="mx-auto max-w-md">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h1 class="text-xl font-semibold text-slate-900">验证邮箱</h1>
<div v-if="loading" class="mt-4 text-sm text-slate-600">处理中</div>
<div v-else-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ message || '邮箱验证成功' }}
</div>
<div class="mt-5 text-sm text-slate-600">
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">去登录</router-link>
<router-link to="/" class="text-indigo-600 hover:text-indigo-700">返回首页</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
createAdminSubscription,
grantAdminCredits,
listAdminSubscriptions,
listAdminPlans,
type AdminManualSubscriptionResponse,
type AdminPlanView,
type AdminSubscriptionView,
type AdminCreditResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatCents } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const subscriptions = ref<AdminSubscriptionView[]>([])
const plans = ref<AdminPlanView[]>([])
const page = ref(1)
const limit = ref(20)
const total = ref(0)
const subForm = ref({ user_id: '', plan_id: '', months: 1, note: '' })
const subBusy = ref(false)
const subMessage = ref<string | null>(null)
const subError = ref<string | null>(null)
const subResult = ref<AdminManualSubscriptionResponse | null>(null)
const creditForm = ref({ user_id: '', units: 100, note: '' })
const creditBusy = ref(false)
const creditMessage = ref<string | null>(null)
const creditError = ref<string | null>(null)
const creditResult = ref<AdminCreditResponse | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
async function loadSubscriptions(targetPage = page.value) {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listAdminSubscriptions(auth.token, { page: targetPage, limit: limit.value })
subscriptions.value = resp.subscriptions
page.value = resp.page
limit.value = resp.limit
total.value = resp.total
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
async function loadPlans() {
if (!auth.token) return
try {
const resp = await listAdminPlans(auth.token)
plans.value = resp.plans
if (!subForm.value.plan_id) {
const active = resp.plans.find((plan) => plan.is_active)
if (active) subForm.value.plan_id = active.id
}
} catch (err) {
// Ignore plan load errors; subscription list can still render.
}
}
async function createSubscription() {
if (!auth.token) return
subBusy.value = true
subMessage.value = null
subError.value = null
subResult.value = null
try {
if (!subForm.value.user_id.trim()) {
subError.value = '请填写用户 ID'
return
}
if (!subForm.value.plan_id) {
subError.value = '请选择套餐'
return
}
if (!subForm.value.months || subForm.value.months <= 0) {
subError.value = '月份必须大于 0'
return
}
const resp = await createAdminSubscription(auth.token, {
user_id: subForm.value.user_id.trim(),
plan_id: subForm.value.plan_id,
months: Number(subForm.value.months),
note: subForm.value.note.trim() || undefined,
})
subResult.value = resp
subMessage.value = resp.message
await loadSubscriptions(1)
} catch (err) {
if (err instanceof ApiError) {
subError.value = `[${err.code}] ${err.message}`
} else {
subError.value = '操作失败,请稍后再试'
}
} finally {
subBusy.value = false
}
}
async function grantCredits() {
if (!auth.token) return
creditBusy.value = true
creditMessage.value = null
creditError.value = null
creditResult.value = null
try {
if (!creditForm.value.user_id.trim()) {
creditError.value = '请填写用户 ID'
return
}
if (!creditForm.value.units || creditForm.value.units <= 0) {
creditError.value = '增加单位必须大于 0'
return
}
const resp = await grantAdminCredits(auth.token, {
user_id: creditForm.value.user_id.trim(),
units: Number(creditForm.value.units),
note: creditForm.value.note.trim() || undefined,
})
creditResult.value = resp
creditMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
creditError.value = `[${err.code}] ${err.message}`
} else {
creditError.value = '操作失败,请稍后再试'
}
} finally {
creditBusy.value = false
}
}
onMounted(async () => {
await Promise.all([loadSubscriptions(1), loadPlans()])
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">订阅与额度</h2>
<p class="text-sm text-slate-600">查看订阅列表并为用户增加当期额度</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">手动开通套餐</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4">
<label class="space-y-1 md:col-span-2">
<div class="text-xs font-medium text-slate-600">用户 ID / 邮箱 / 用户名</div>
<input
v-model="subForm.user_id"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="UUID / 邮箱 / 用户名"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">套餐</div>
<select
v-model="subForm.plan_id"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
>
<option value="" disabled>请选择套餐</option>
<option v-for="plan in plans" :key="plan.id" :value="plan.id" :disabled="!plan.is_active">
{{ plan.name }} · {{ plan.code }}{{ plan.is_active ? '' : '已停用' }}
</option>
</select>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">开通月数</div>
<input
v-model.number="subForm.months"
type="number"
min="1"
max="24"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
<label class="space-y-1 md:col-span-2">
<div class="text-xs font-medium text-slate-600">备注</div>
<input
v-model="subForm.note"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="可选"
/>
</label>
</div>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="subBusy"
@click="createSubscription"
>
{{ subBusy ? '提交中…' : '立即开通' }}
</button>
<span class="text-xs text-slate-500">会取消该用户当前有效订阅并按月数顺延</span>
</div>
<div v-if="subMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ subMessage }}
</div>
<div v-if="subError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ subError }}
</div>
<div v-if="subResult" class="mt-2 text-xs text-slate-500">
周期{{ new Date(subResult.period_start).toLocaleString() }}
{{ new Date(subResult.period_end).toLocaleString() }}套餐 {{ subResult.plan_name }}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">增加额度</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4">
<label class="space-y-1 md:col-span-2">
<div class="text-xs font-medium text-slate-600">用户 ID / 邮箱 / 用户名</div>
<input
v-model="creditForm.user_id"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="UUID / 邮箱 / 用户名"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">增加单位</div>
<input
v-model.number="creditForm.units"
type="number"
min="1"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">备注</div>
<input
v-model="creditForm.note"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="可选"
/>
</label>
</div>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
class="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
:disabled="creditBusy"
@click="grantCredits"
>
{{ creditBusy ? '提交中…' : '提交增加' }}
</button>
<span class="text-xs text-slate-500">会增加赠送额度叠加到当期总额度</span>
</div>
<div v-if="creditMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ creditMessage }}
</div>
<div v-if="creditError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ creditError }}
</div>
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
当前周期{{ new Date(creditResult.period_start).toLocaleString() }}
{{ new Date(creditResult.period_end).toLocaleString() }}已用 {{ creditResult.used_units }} /
{{ creditResult.total_units }}含赠送 {{ creditResult.bonus_units }}剩余 {{ creditResult.remaining_units }}
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="subscriptions.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无订阅
</div>
<div v-else class="overflow-auto rounded-xl border border-slate-200 bg-white">
<table class="min-w-full text-left text-sm">
<thead class="text-xs text-slate-500">
<tr>
<th class="px-4 py-3">用户</th>
<th class="px-4 py-3">套餐</th>
<th class="px-4 py-3">状态</th>
<th class="px-4 py-3">周期</th>
<th class="px-4 py-3">金额</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="sub in subscriptions" :key="sub.id" class="border-t border-slate-100">
<td class="px-4 py-3">
<div class="text-sm font-medium text-slate-900">{{ sub.user_email }}</div>
<div class="text-xs text-slate-400">ID {{ sub.user_id.slice(0, 8) }}</div>
</td>
<td class="px-4 py-3">
{{ sub.plan_name }}
<div class="text-xs text-slate-400">{{ sub.plan_code }}</div>
</td>
<td class="px-4 py-3">{{ sub.status }}</td>
<td class="px-4 py-3">
{{ new Date(sub.current_period_start).toLocaleDateString() }}
{{ new Date(sub.current_period_end).toLocaleDateString() }}
</td>
<td class="px-4 py-3">
{{ formatCents(sub.amount_cents, sub.currency) }}
<span class="text-xs text-slate-500">/ {{ sub.interval }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between text-sm text-slate-600">
<div> {{ page }} / {{ totalPages }} {{ total }} </div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page <= 1 || loading"
@click="loadSubscriptions(page - 1)"
>
上一页
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page >= totalPages || loading"
@click="loadSubscriptions(page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { listAdminConfig, updateAdminConfig, type AdminConfigEntry } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const configs = ref<AdminConfigEntry[]>([])
const drafts = ref<Record<string, string>>({})
const savingKey = ref<string | null>(null)
const messages = ref<Record<string, string>>({})
const errors = ref<Record<string, string>>({})
function formatJson(value: unknown) {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value ?? '')
}
}
function setDrafts(list: AdminConfigEntry[]) {
const next: Record<string, string> = {}
for (const item of list) {
next[item.key] = formatJson(item.value)
}
drafts.value = next
}
async function loadConfigs() {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listAdminConfig(auth.token)
configs.value = resp.configs
setDrafts(resp.configs)
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
async function saveConfig(key: string) {
if (!auth.token) return
savingKey.value = key
messages.value[key] = ''
errors.value[key] = ''
try {
const raw = drafts.value[key] ?? ''
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('JSON 解析失败,请检查格式')
}
const resp = await updateAdminConfig(auth.token, { key, value: parsed })
configs.value = configs.value.map((item) => (item.key === key ? resp : item))
messages.value[key] = '已更新'
} catch (err) {
if (err instanceof ApiError) {
errors.value[key] = `[${err.code}] ${err.message}`
} else {
errors.value[key] = err instanceof Error ? err.message : '更新失败'
}
} finally {
savingKey.value = null
}
}
onMounted(async () => {
await loadConfigs()
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">系统配置</h2>
<p class="text-sm text-slate-600">更新全局开关限流与文件限制配置</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="configs.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无配置
</div>
<div v-else class="space-y-4">
<div v-for="item in configs" :key="item.key" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-medium text-slate-900">{{ item.key }}</div>
<div v-if="item.description" class="text-xs text-slate-500">{{ item.description }}</div>
</div>
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="savingKey === item.key"
@click="saveConfig(item.key)"
>
{{ savingKey === item.key ? '保存中…' : '保存' }}
</button>
</div>
<textarea
v-model="drafts[item.key]"
class="mt-3 h-40 w-full rounded-md border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-slate-800"
></textarea>
<div v-if="messages[item.key]" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ messages[item.key] }}
</div>
<div v-if="errors[item.key]" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ errors[item.key] }}
</div>
<div class="mt-2 text-xs text-slate-400">最近更新{{ new Date(item.updated_at).toLocaleString() }}</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getAdminStats, type AdminStats } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const stats = ref<AdminStats | null>(null)
onMounted(async () => {
if (!auth.token) return
try {
stats.value = await getAdminStats(auth.token)
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">系统概览</h2>
<p class="text-sm text-slate-600">快速查看用户任务与订阅的关键指标</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-xs font-medium text-slate-500">总用户数</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.total_users ?? 0 }}</div>
<div class="mt-1 text-xs text-slate-500">活跃 {{ stats?.active_users ?? 0 }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-xs font-medium text-slate-500">有效订阅</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.active_subscriptions ?? 0 }}</div>
<div class="mt-1 text-xs text-slate-500">含试用/欠费</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-xs font-medium text-slate-500">24h 使用次数</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.usage_events_24h ?? 0 }}</div>
<div class="mt-1 text-xs text-slate-500"> 24 小时</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-xs font-medium text-slate-500">任务处理中</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.processing_tasks ?? 0 }}</div>
<div class="mt-1 text-xs text-slate-500">排队 {{ stats?.pending_tasks ?? 0 }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-xs font-medium text-slate-500">任务完成</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.completed_tasks ?? 0 }}</div>
<div class="mt-1 text-xs text-slate-500">失败 {{ stats?.failed_tasks ?? 0 }}</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
getMailConfig,
getStripeConfig,
listAdminPlans,
sendMailTest,
updateAdminPlan,
updateMailConfig,
updateStripeConfig,
type AdminMailConfig,
type AdminPlanView,
type AdminStripeConfig,
type MailCustomSmtp,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatCents } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const stripeConfig = ref<AdminStripeConfig | null>(null)
const stripeForm = ref({ secret_key: '', webhook_secret: '' })
const stripeBusy = ref(false)
const stripeMessage = ref<string | null>(null)
const plans = ref<AdminPlanView[]>([])
const planBusy = ref<string | null>(null)
const planMessage = ref<string | null>(null)
const mailConfig = ref<AdminMailConfig | null>(null)
const mailForm = ref({
enabled: false,
provider: 'qq',
from: '',
from_name: 'ImageForge',
password: '',
log_links_when_disabled: false,
custom_smtp: {
host: '',
port: 465,
encryption: 'ssl',
} as MailCustomSmtp,
})
const mailBusy = ref(false)
const mailMessage = ref<string | null>(null)
const mailError = ref<string | null>(null)
const testEmail = ref('')
const testBusy = ref(false)
const testMessage = ref<string | null>(null)
const testError = ref<string | null>(null)
const isCustomProvider = computed(() => mailForm.value.provider === 'custom')
const intervalLabel = (interval: string) => {
switch (interval) {
case 'monthly':
return '月付'
case 'yearly':
return '年付'
case 'weekly':
return '周付'
default:
return interval
}
}
const providerOptions = [
{ value: 'qq', label: 'QQ 邮箱' },
{ value: '163', label: '163 邮箱' },
{ value: 'aliyun_enterprise', label: '阿里企业邮' },
{ value: 'tencent_enterprise', label: '腾讯企业邮' },
{ value: 'gmail', label: 'Gmail' },
{ value: 'outlook', label: 'Outlook' },
{ value: 'custom', label: '自定义 SMTP' },
]
function applyMailConfig(config: AdminMailConfig) {
mailForm.value.enabled = config.enabled
mailForm.value.provider = config.provider || 'qq'
mailForm.value.from = config.from || ''
mailForm.value.from_name = config.from_name || 'ImageForge'
mailForm.value.log_links_when_disabled = config.log_links_when_disabled ?? false
if (config.custom_smtp) {
mailForm.value.custom_smtp = {
host: config.custom_smtp.host || '',
port: config.custom_smtp.port || 465,
encryption: config.custom_smtp.encryption || 'ssl',
}
}
}
async function loadAll() {
if (!auth.token) return
loading.value = true
error.value = null
try {
const [stripe, mailCfg, planResp] = await Promise.all([
getStripeConfig(auth.token),
getMailConfig(auth.token),
listAdminPlans(auth.token),
])
stripeConfig.value = stripe
mailConfig.value = mailCfg
applyMailConfig(mailCfg)
plans.value = planResp.plans
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
async function saveStripe() {
if (!auth.token) return
stripeBusy.value = true
stripeMessage.value = null
try {
const payload: { secret_key?: string; webhook_secret?: string } = {}
if (stripeForm.value.secret_key.trim()) payload.secret_key = stripeForm.value.secret_key.trim()
if (stripeForm.value.webhook_secret.trim()) payload.webhook_secret = stripeForm.value.webhook_secret.trim()
const resp = await updateStripeConfig(auth.token, payload)
stripeConfig.value = resp
stripeMessage.value = 'Stripe 配置已更新'
stripeForm.value.secret_key = ''
stripeForm.value.webhook_secret = ''
} catch (err) {
if (err instanceof ApiError) {
stripeMessage.value = `[${err.code}] ${err.message}`
} else {
stripeMessage.value = '更新失败,请稍后再试'
}
} finally {
stripeBusy.value = false
}
}
async function savePlan(plan: AdminPlanView) {
if (!auth.token) return
planBusy.value = plan.id
planMessage.value = null
try {
const resp = await updateAdminPlan(auth.token, plan.id, {
stripe_price_id: plan.stripe_price_id?.trim() || undefined,
stripe_product_id: plan.stripe_product_id?.trim() || undefined,
is_active: plan.is_active,
})
plans.value = plans.value.map((p) => (p.id === resp.id ? resp : p))
planMessage.value = `已更新 ${resp.name}`
} catch (err) {
if (err instanceof ApiError) {
planMessage.value = `[${err.code}] ${err.message}`
} else {
planMessage.value = '更新失败,请稍后再试'
}
} finally {
planBusy.value = null
}
}
async function saveMail() {
if (!auth.token) return
mailBusy.value = true
mailMessage.value = null
mailError.value = null
try {
const payload: {
enabled: boolean
provider: string
from: string
from_name: string
password?: string
custom_smtp?: MailCustomSmtp | null
log_links_when_disabled?: boolean
} = {
enabled: mailForm.value.enabled,
provider: mailForm.value.provider,
from: mailForm.value.from.trim(),
from_name: mailForm.value.from_name.trim(),
log_links_when_disabled: mailForm.value.log_links_when_disabled,
}
if (mailForm.value.password.trim()) {
payload.password = mailForm.value.password.trim()
}
payload.custom_smtp = isCustomProvider.value ? { ...mailForm.value.custom_smtp } : null
const resp = await updateMailConfig(auth.token, payload)
mailConfig.value = resp
mailMessage.value = '邮件配置已保存'
mailForm.value.password = ''
} catch (err) {
if (err instanceof ApiError) {
mailError.value = `[${err.code}] ${err.message}`
} else {
mailError.value = '更新失败,请稍后再试'
}
} finally {
mailBusy.value = false
}
}
async function sendTest() {
if (!auth.token) return
testBusy.value = true
testMessage.value = null
testError.value = null
try {
const resp = await sendMailTest(auth.token, testEmail.value.trim() || undefined)
testMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
testError.value = `[${err.code}] ${err.message}`
} else {
testError.value = '发送失败,请稍后再试'
}
} finally {
testBusy.value = false
}
}
onMounted(loadAll)
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">支付与邮件配置</h2>
<p class="text-sm text-slate-600">配置 Stripe 密钥套餐价格与邮件服务</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-6">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-medium text-slate-900">Stripe 配置</div>
<div class="text-xs text-slate-500">
{{ stripeConfig?.secret_key_configured ? '已配置' : '未配置' }}
<span v-if="stripeConfig?.secret_key_prefix">({{ stripeConfig?.secret_key_prefix }})</span>
</div>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">Secret Key</div>
<input
v-model="stripeForm.secret_key"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="sk_live_..."
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">Webhook Secret</div>
<input
v-model="stripeForm.webhook_secret"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="whsec_..."
/>
</label>
</div>
<div class="mt-3 flex items-center gap-3">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="stripeBusy"
@click="saveStripe"
>
{{ stripeBusy ? '保存中…' : '保存配置' }}
</button>
<div v-if="stripeMessage" class="text-xs text-slate-600">{{ stripeMessage }}</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">套餐价格配置</div>
<div v-if="plans.length === 0" class="mt-3 text-sm text-slate-600">暂无套餐</div>
<div v-else class="mt-4 space-y-3">
<div v-for="plan in plans" :key="plan.id" class="rounded-lg border border-slate-200 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
<div class="text-xs text-slate-500">
{{ plan.code }} · {{ formatCents(plan.amount_cents, plan.currency) }} / {{ intervalLabel(plan.interval) }}
</div>
</div>
<label class="flex items-center gap-2 text-xs text-slate-600">
<input v-model="plan.is_active" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
启用
</label>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">Stripe 产品 ID</div>
<input
v-model="plan.stripe_product_id"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="例如 prod_xxx"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">Stripe 价格 ID</div>
<input
v-model="plan.stripe_price_id"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="例如 price_xxx"
/>
</label>
</div>
<div class="mt-3 flex items-center gap-3">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="planBusy === plan.id"
@click="savePlan(plan)"
>
{{ planBusy === plan.id ? '保存中…' : '保存' }}
</button>
<div v-if="planMessage && planBusy !== plan.id" class="text-xs text-slate-500">{{ planMessage }}</div>
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">邮件服务配置</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input v-model="mailForm.enabled" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
启用邮件服务
</label>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input v-model="mailForm.log_links_when_disabled" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
关闭时记录链接
</label>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">服务商</div>
<select v-model="mailForm.provider" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">发件邮箱</div>
<input
v-model="mailForm.from"
type="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="noreply@example.com"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">发件人名称</div>
<input
v-model="mailForm.from_name"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="ImageForge"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">授权码/密码</div>
<input
v-model="mailForm.password"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
:placeholder="mailConfig?.password_configured ? '已配置(留空不修改)' : '输入授权码'"
/>
</label>
</div>
<div v-if="isCustomProvider" class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">SMTP Host</div>
<input
v-model="mailForm.custom_smtp.host"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="smtp.example.com"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">端口</div>
<input
v-model.number="mailForm.custom_smtp.port"
type="number"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="465"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">加密方式</div>
<select
v-model="mailForm.custom_smtp.encryption"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
<option value="none"></option>
</select>
</label>
</div>
<div v-if="mailMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ mailMessage }}
</div>
<div v-if="mailError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ mailError }}
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
class="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
:disabled="mailBusy"
@click="saveMail"
>
{{ mailBusy ? '保存中…' : '保存邮件配置' }}
</button>
<div class="text-xs text-slate-500">保存后可发送测试邮件确认</div>
</div>
<div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="text-sm font-medium text-slate-900">测试发送</div>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
<label class="flex-1 space-y-1">
<div class="text-xs font-medium text-slate-600">收件人可选</div>
<input
v-model="testEmail"
type="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="留空则发送到当前管理员邮箱"
/>
</label>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="testBusy"
@click="sendTest"
>
{{ testBusy ? '发送中…' : '发送测试邮件' }}
</button>
</div>
<div v-if="testMessage" class="mt-3 text-sm text-emerald-700">{{ testMessage }}</div>
<div v-if="testError" class="mt-3 text-sm text-rose-700">{{ testError }}</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { cancelAdminTask, listAdminTasks, type AdminTaskView } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const tasks = ref<AdminTaskView[]>([])
const page = ref(1)
const limit = ref(20)
const total = ref(0)
const status = ref('')
const cancelling = ref<string | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '排队' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]
function statusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? value
}
function statusClass(value: string) {
switch (value) {
case 'completed':
return 'bg-emerald-50 text-emerald-700'
case 'processing':
return 'bg-indigo-50 text-indigo-700'
case 'failed':
return 'bg-rose-50 text-rose-700'
case 'cancelled':
return 'bg-slate-100 text-slate-600'
default:
return 'bg-amber-50 text-amber-700'
}
}
function progress(task: AdminTaskView) {
if (!task.total_files) return 0
return Math.round(((task.completed_files + task.failed_files) / task.total_files) * 100)
}
async function loadTasks(targetPage = page.value) {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listAdminTasks(auth.token, {
page: targetPage,
limit: limit.value,
status: status.value || undefined,
})
tasks.value = resp.tasks
page.value = resp.page
limit.value = resp.limit
total.value = resp.total
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
function applyFilters() {
loadTasks(1)
}
async function cancelTask(taskId: string) {
if (!auth.token) return
if (!confirm('确定取消该任务吗?')) return
cancelling.value = taskId
error.value = null
try {
await cancelAdminTask(auth.token, taskId)
await loadTasks(page.value)
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '操作失败,请稍后再试'
}
} finally {
cancelling.value = null
}
}
onMounted(async () => {
await loadTasks(1)
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">任务管理</h2>
<p class="text-sm text-slate-600">查看任务状态失败原因并手动取消</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">状态</div>
<select v-model="status" class="w-44 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</label>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="loading"
@click="applyFilters"
>
{{ loading ? '查询中…' : '查询' }}
</button>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="tasks.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无任务
</div>
<div v-else class="space-y-3">
<div v-for="task in tasks" :key="task.id" class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-medium text-slate-900">任务 {{ task.id.slice(0, 8) }}</div>
<div class="mt-1 text-xs text-slate-500">
来源 {{ task.source }} · 用户 {{ task.user_email ?? '匿名' }} · 创建
{{ new Date(task.created_at).toLocaleString() }}
</div>
</div>
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-1 text-xs" :class="statusClass(task.status)">
{{ statusLabel(task.status) }}
</span>
<button
v-if="task.status === 'pending' || task.status === 'processing'"
type="button"
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
:disabled="cancelling === task.id"
@click="cancelTask(task.id)"
>
{{ cancelling === task.id ? '取消中…' : '取消任务' }}
</button>
</div>
</div>
<div class="mt-3 space-y-2">
<div class="text-xs text-slate-500">
进度 {{ progress(task) }}% · 完成 {{ task.completed_files }}/{{ task.total_files }} · 失败
{{ task.failed_files }}
</div>
<div class="h-2 w-full rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-indigo-500" :style="{ width: `${progress(task)}%` }"></div>
</div>
<div v-if="task.error_message" class="text-xs text-rose-600">错误{{ task.error_message }}</div>
</div>
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between text-sm text-slate-600">
<div> {{ page }} / {{ totalPages }} {{ total }} </div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page <= 1 || loading"
@click="loadTasks(page - 1)"
>
上一页
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page >= totalPages || loading"
@click="loadTasks(page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { listAdminUsers, type AdminUserView } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const users = ref<AdminUserView[]>([])
const page = ref(1)
const limit = ref(20)
const total = ref(0)
const search = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
async function loadUsers(targetPage = page.value) {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listAdminUsers(auth.token, {
page: targetPage,
limit: limit.value,
search: search.value.trim() || undefined,
})
users.value = resp.users
page.value = resp.page
limit.value = resp.limit
total.value = resp.total
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
function applySearch() {
loadUsers(1)
}
onMounted(async () => {
await loadUsers(1)
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-slate-900">用户管理</h2>
<p class="text-sm text-slate-600">支持检索用户查看订阅状态与验证情况</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<label class="flex-1 space-y-1">
<div class="text-xs font-medium text-slate-600">搜索邮箱/用户名</div>
<input
v-model="search"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="输入关键词"
/>
</label>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="loading"
@click="applySearch"
>
{{ loading ? '查询中…' : '查询' }}
</button>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="users.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无用户
</div>
<div v-else class="overflow-auto rounded-xl border border-slate-200 bg-white">
<table class="min-w-full text-left text-sm">
<thead class="text-xs text-slate-500">
<tr>
<th class="px-4 py-3">邮箱</th>
<th class="px-4 py-3">用户名</th>
<th class="px-4 py-3">角色</th>
<th class="px-4 py-3">状态</th>
<th class="px-4 py-3">验证</th>
<th class="px-4 py-3">订阅</th>
<th class="px-4 py-3">创建时间</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-100">
<td class="px-4 py-3">
<div class="text-sm font-medium text-slate-900">{{ user.email }}</div>
<div class="text-xs text-slate-400">ID {{ user.id.slice(0, 8) }}</div>
</td>
<td class="px-4 py-3">{{ user.username }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-1 text-xs" :class="user.role === 'admin' ? 'bg-indigo-50 text-indigo-700' : 'bg-slate-100 text-slate-600'">
{{ user.role }}
</span>
</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-1 text-xs"
:class="user.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'"
>
{{ user.is_active ? '启用' : '禁用' }}
</span>
</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-1 text-xs"
:class="user.email_verified ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'"
>
{{ user.email_verified ? '已验证' : '未验证' }}
</span>
</td>
<td class="px-4 py-3">{{ user.subscription_status ?? '—' }}</td>
<td class="px-4 py-3">{{ new Date(user.created_at).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between text-sm text-slate-600">
<div> {{ page }} / {{ totalPages }} {{ total }} </div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page <= 1 || loading"
@click="loadUsers(page - 1)"
>
上一页
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page >= totalPages || loading"
@click="loadUsers(page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createApiKey,
disableApiKey,
listApiKeys,
rotateApiKey,
type ApiKeyView,
type CreateApiKeyResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const apiKeys = ref<ApiKeyView[]>([])
const name = ref('')
const creating = ref(false)
const created = ref<CreateApiKeyResponse | null>(null)
const createdContext = ref<'create' | 'rotate' | null>(null)
const copyStatus = ref<string | null>(null)
const rotating = ref<string | null>(null)
async function refresh() {
if (!auth.token) return
error.value = null
try {
const resp = await listApiKeys(auth.token)
apiKeys.value = resp.api_keys
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
}
}
onMounted(async () => {
await refresh()
loading.value = false
})
async function create() {
if (!auth.token) return
creating.value = true
error.value = null
copyStatus.value = null
try {
const resp = await createApiKey(auth.token, name.value.trim())
created.value = resp
createdContext.value = 'create'
name.value = ''
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '创建失败,请稍后再试'
}
} finally {
creating.value = false
}
}
async function disable(keyId: string) {
if (!auth.token) return
if (!confirm('确定要禁用这个 Key 吗?')) return
try {
await disableApiKey(auth.token, keyId)
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '操作失败,请稍后再试'
}
}
}
async function rotate(keyId: string) {
if (!auth.token) return
if (!confirm('确定要轮换这个 Key 吗?旧 Key 将立即失效。')) return
rotating.value = keyId
error.value = null
copyStatus.value = null
try {
const resp = await rotateApiKey(auth.token, keyId)
created.value = resp
createdContext.value = 'rotate'
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '操作失败,请稍后再试'
}
} finally {
rotating.value = null
}
}
async function copyKey() {
if (!created.value?.key) return
try {
await navigator.clipboard.writeText(created.value.key)
copyStatus.value = '已复制'
} catch {
copyStatus.value = '复制失败'
}
}
function clearCreated() {
created.value = null
createdContext.value = null
copyStatus.value = null
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">API Keys</h1>
<p class="text-sm text-slate-600"> Pro/Business 可创建创建时只展示一次完整 Key</p>
</div>
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-if="created" class="rounded-xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-950">
<div class="font-medium">{{ createdContext === 'rotate' ? '已轮换' : '已创建' }}{{ created.name }}</div>
<div class="mt-2 text-xs text-emerald-900">请保存此 Key它只会显示一次</div>
<pre class="mt-2 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100"><code>{{ created.key }}</code></pre>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
@click="copyKey"
>
一键复制
</button>
<div v-if="copyStatus" class="text-xs text-emerald-900">{{ copyStatus }}</div>
<button
type="button"
class="ml-auto rounded-md border border-emerald-200 bg-white px-3 py-1.5 text-sm text-emerald-800 hover:bg-emerald-100"
@click="clearCreated"
>
我已保存
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">创建 API Key</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
<label class="flex-1 space-y-1">
<div class="text-xs font-medium text-slate-600">名称</div>
<input
v-model="name"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="例如 CI / prod / local"
/>
</label>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="creating || !name.trim()"
@click="create"
>
{{ creating ? '创建中…' : '创建' }}
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-slate-900">已有 Keys</div>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
@click="refresh"
>
刷新
</button>
</div>
<div v-if="loading" class="mt-3 text-sm text-slate-600">加载中</div>
<div v-else-if="apiKeys.length === 0" class="mt-3 text-sm text-slate-600">暂无</div>
<div v-else class="mt-4 space-y-2">
<div
v-for="k in apiKeys"
:key="k.id"
class="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-900">{{ k.name }}</div>
<div class="text-xs text-slate-500">
{{ k.key_prefix }} · rate: {{ k.rate_limit }}/min
<span v-if="k.last_used_at"> · 上次使用 {{ new Date(k.last_used_at).toLocaleString() }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-1 text-xs"
:class="k.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
{{ k.is_active ? '启用' : '禁用' }}
</span>
<button
v-if="k.is_active"
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
:disabled="rotating === k.id"
@click="rotate(k.id)"
>
{{ rotating === k.id ? '轮换中…' : '轮换' }}
</button>
<button
v-if="k.is_active"
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
@click="disable(k.id)"
>
禁用
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createCheckout,
createPortal,
getSubscription,
getUsage,
listInvoices,
listPlans,
type InvoiceView,
type PlanView,
type SubscriptionView,
type UsageResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatCents } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const plans = ref<PlanView[]>([])
const subscription = ref<SubscriptionView | null>(null)
const usage = ref<UsageResponse | null>(null)
const invoices = ref<InvoiceView[]>([])
const busy = ref(false)
onMounted(async () => {
if (!auth.token) return
try {
const [p, s, u, inv] = await Promise.all([
listPlans(),
getSubscription(auth.token),
getUsage(auth.token),
listInvoices(auth.token),
])
plans.value = p.plans
subscription.value = s.subscription
usage.value = u
invoices.value = inv.invoices
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
async function openCheckout(planId: string) {
if (!auth.token) return
busy.value = true
error.value = null
try {
const resp = await createCheckout(auth.token, planId)
window.location.href = resp.checkout_url
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '创建支付链接失败'
}
} finally {
busy.value = false
}
}
async function openPortal() {
if (!auth.token) return
busy.value = true
error.value = null
try {
const resp = await createPortal(auth.token)
window.location.href = resp.url
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '打开 Portal 失败'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">订阅与额度</h1>
<p class="text-sm text-slate-600">充值额度或购买套餐通过 Stripe 完成</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当前套餐</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ subscription?.plan.name ?? 'Free' }}</div>
<div class="mt-1 text-sm text-slate-600">状态{{ subscription?.status ?? 'free' }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当期用量</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ usage?.used_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div class="mt-1 text-sm text-slate-600">剩余 {{ usage?.remaining_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">周期</div>
<div class="mt-2 text-sm text-slate-700">
{{ subscription?.current_period_start ? new Date(subscription.current_period_start).toLocaleString() : '—' }}
<span class="mx-1"></span>
{{ subscription?.current_period_end ? new Date(subscription.current_period_end).toLocaleString() : '—' }}
</div>
<div class="mt-3">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="busy"
@click="openPortal"
>
打开 Stripe Portal
</button>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">充值额度 / 购买套餐</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
<div v-for="plan in plans" :key="plan.id" class="rounded-lg border border-slate-200 p-4">
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
<div class="mt-1 text-sm text-slate-700">
{{ plan.amount_cents > 0 ? formatCents(plan.amount_cents, plan.currency) : '免费' }}
<span class="text-xs text-slate-500">/ {{ plan.interval }}</span>
</div>
<div class="mt-2 text-xs text-slate-600">
{{ plan.included_units_per_period.toLocaleString() }} / 周期
</div>
<button
type="button"
class="mt-3 w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy || plan.amount_cents <= 0"
@click="openCheckout(plan.id)"
>
充值额度
</button>
<div v-if="plan.amount_cents <= 0" class="mt-2 text-xs text-slate-500">Free 无需订阅</div>
</div>
</div>
<div class="mt-3 text-xs text-slate-500">
提示示例数据中的 Stripe Price ID 为占位符接入真实 Price 后即可用管理员赠送额度会直接叠加到当期总额度
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">发票</div>
<div v-if="invoices.length === 0" class="mt-3 text-sm text-slate-600">暂无发票</div>
<div v-else class="mt-3 overflow-auto">
<table class="min-w-full text-left text-sm">
<thead class="text-xs text-slate-500">
<tr>
<th class="py-2 pr-4">编号</th>
<th class="py-2 pr-4">状态</th>
<th class="py-2 pr-4">金额</th>
<th class="py-2 pr-4">创建时间</th>
<th class="py-2 pr-4">链接</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="inv in invoices" :key="inv.invoice_number" class="border-t border-slate-100">
<td class="py-2 pr-4">{{ inv.invoice_number }}</td>
<td class="py-2 pr-4">{{ inv.status }}</td>
<td class="py-2 pr-4">{{ formatCents(inv.total_amount_cents, inv.currency) }}</td>
<td class="py-2 pr-4">{{ new Date(inv.created_at).toLocaleString() }}</td>
<td class="py-2 pr-4">
<a v-if="inv.hosted_invoice_url" :href="inv.hosted_invoice_url" target="_blank" rel="noreferrer">
查看
</a>
<span v-else class="text-slate-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { listHistory, type HistoryTaskView } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatBytes } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const downloadBusy = ref(false)
const tasks = ref<HistoryTaskView[]>([])
const page = ref(1)
const limit = ref(10)
const total = ref(0)
const status = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '排队' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]
function statusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? value
}
function statusClass(value: string) {
switch (value) {
case 'completed':
return 'bg-emerald-50 text-emerald-700'
case 'processing':
return 'bg-indigo-50 text-indigo-700'
case 'failed':
return 'bg-rose-50 text-rose-700'
case 'cancelled':
return 'bg-slate-100 text-slate-600'
default:
return 'bg-amber-50 text-amber-700'
}
}
function formatDate(value?: string | null) {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function formatPercent(value?: number | null) {
if (value === null || value === undefined) return '—'
return `${value.toFixed(1)}%`
}
function sourceLabel(value: string) {
switch (value) {
case 'web':
return '网页'
case 'api':
return 'API'
case 'batch':
return '批量'
default:
return value || '—'
}
}
function extractFilename(disposition: string | null) {
if (!disposition) return null
const match = /filename="([^"]+)"/i.exec(disposition)
return match?.[1] ?? null
}
function outputExt(format: string) {
return format.toLowerCase() === 'jpeg' ? 'jpg' : format.toLowerCase()
}
function buildFileName(originalName: string, outputFormat: string) {
const trimmed = originalName.trim()
const base = trimmed ? trimmed.replace(/\.[^/.]+$/, '') : 'download'
return `${base}.${outputExt(outputFormat)}`
}
async function downloadWithAuth(url: string, fallbackName: string) {
if (!auth.token) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
downloadBusy.value = true
downloadError.value = null
try {
const res = await fetch(url, {
headers: { authorization: `Bearer ${auth.token}` },
})
if (!res.ok) {
downloadError.value = `下载失败HTTP ${res.status}`
return
}
const blob = await res.blob()
const filename = extractFilename(res.headers.get('content-disposition')) ?? fallbackName
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
a.click()
URL.revokeObjectURL(objectUrl)
} catch (err) {
downloadError.value = '下载失败,请稍后再试'
} finally {
downloadBusy.value = false
}
}
async function downloadTaskZip(task: HistoryTaskView) {
if (!task.download_all_url) return
await downloadWithAuth(task.download_all_url, `task_${task.task_id}.zip`)
}
async function downloadFile(file: HistoryTaskView['files'][number]) {
if (!file.download_url) return
const fallback = buildFileName(file.original_name, file.output_format)
await downloadWithAuth(file.download_url, fallback)
}
async function loadHistory(targetPage = page.value) {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listHistory(auth.token, {
page: targetPage,
limit: limit.value,
status: status.value || undefined,
})
tasks.value = resp.tasks
page.value = resp.page
limit.value = resp.limit
total.value = resp.total
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
function applyFilters() {
loadHistory(1)
}
function resetFilters() {
status.value = ''
limit.value = 10
loadHistory(1)
}
onMounted(async () => {
await loadHistory(1)
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">历史任务</h1>
<p class="text-sm text-slate-600">查看压缩任务下载结果与过期时间</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="flex flex-1 flex-wrap items-end gap-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">状态</div>
<select v-model="status" class="w-44 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">每页数量</div>
<select v-model.number="limit" class="w-32 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
</label>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="loading"
@click="applyFilters"
>
{{ loading ? '查询中…' : '查询' }}
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
:disabled="loading"
@click="resetFilters"
>
重置
</button>
</div>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="downloadError" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ downloadError }}
</div>
<div v-if="tasks.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无历史任务
</div>
<div v-for="task in tasks" :key="task.task_id" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-slate-900">任务 {{ task.task_id.slice(0, 8) }}</div>
<div class="text-xs text-slate-500">
来源 {{ sourceLabel(task.source) }} · 创建 {{ formatDate(task.created_at) }} · 过期 {{ formatDate(task.expires_at) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full px-2 py-1 text-xs" :class="statusClass(task.status)">
{{ statusLabel(task.status) }}
</span>
<button
v-if="task.download_all_url"
type="button"
class="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
:disabled="downloadBusy"
@click="downloadTaskZip(task)"
>
下载全部 ZIP
</button>
<span v-else class="rounded-md border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-400">
ZIP 未就绪
</span>
</div>
</div>
<div class="mt-4 space-y-2">
<div class="flex flex-wrap items-center justify-between text-xs text-slate-500">
<span>
进度 {{ task.progress }}% · 完成 {{ task.completed_files }}/{{ task.total_files }} · 失败
{{ task.failed_files }}
</span>
<span>完成时间 {{ formatDate(task.completed_at) }}</span>
</div>
<div class="h-2 w-full rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-indigo-500" :style="{ width: `${task.progress}%` }"></div>
</div>
</div>
<div v-if="task.files.length > 0" class="mt-4 overflow-auto">
<table class="min-w-full text-left text-xs">
<thead class="text-slate-500">
<tr>
<th class="py-2 pr-4">文件</th>
<th class="py-2 pr-4">状态</th>
<th class="py-2 pr-4">原始大小</th>
<th class="py-2 pr-4">压缩后</th>
<th class="py-2 pr-4">节省</th>
<th class="py-2 pr-4">输出格式</th>
<th class="py-2 pr-4">下载/错误</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="file in task.files" :key="file.file_id" class="border-t border-slate-100">
<td class="py-2 pr-4">
<div class="max-w-[240px] truncate text-sm font-medium text-slate-900">{{ file.original_name }}</div>
<div class="text-[11px] text-slate-400">ID {{ file.file_id.slice(0, 8) }}</div>
</td>
<td class="py-2 pr-4">
<span class="rounded-full px-2 py-1 text-[11px]" :class="statusClass(file.status)">
{{ statusLabel(file.status) }}
</span>
</td>
<td class="py-2 pr-4">{{ formatBytes(file.original_size) }}</td>
<td class="py-2 pr-4">
{{ file.compressed_size ? formatBytes(file.compressed_size) : '—' }}
</td>
<td class="py-2 pr-4">{{ formatPercent(file.saved_percent) }}</td>
<td class="py-2 pr-4 uppercase">{{ file.output_format }}</td>
<td class="py-2 pr-4">
<button
v-if="file.download_url"
type="button"
class="text-indigo-600 hover:text-indigo-700 disabled:opacity-50"
:disabled="downloadBusy"
@click="downloadFile(file)"
>
下载
</button>
<span v-else-if="file.error_message" class="text-rose-600">{{ file.error_message }}</span>
<span v-else class="text-slate-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between text-sm text-slate-600">
<div> {{ page }} / {{ totalPages }} {{ total }} </div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page <= 1 || loading"
@click="loadHistory(page - 1)"
>
上一页
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="page >= totalPages || loading"
@click="loadHistory(page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getSubscription, getUsage, sendVerification } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const route = useRoute()
const usage = ref<Awaited<ReturnType<typeof getUsage>> | null>(null)
const subscription = ref<Awaited<ReturnType<typeof getSubscription>>['subscription'] | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const alert = ref<{ type: 'success' | 'error'; message: string } | null>(null)
const sendingVerification = ref(false)
onMounted(async () => {
if (!auth.token) return
try {
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
usage.value = u
subscription.value = s.subscription
if (route.query.welcome === '1') {
alert.value = { type: 'success', message: '欢迎加入 ImageForge请尽快完成邮箱验证。' }
}
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
async function resendVerification() {
if (!auth.token) return
sendingVerification.value = true
alert.value = null
try {
const resp = await sendVerification(auth.token)
alert.value = { type: 'success', message: resp.message }
} catch (err) {
if (err instanceof ApiError) {
alert.value = { type: 'error', message: `[${err.code}] ${err.message}` }
} else {
alert.value = { type: 'error', message: '发送失败,请稍后再试' }
}
} finally {
sendingVerification.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">概览</h1>
<p class="text-sm text-slate-600">查看当期用量套餐与订阅状态</p>
</div>
<router-link
to="/"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
>
返回首页工具
</router-link>
</div>
<div
v-if="alert"
class="rounded-lg border p-4 text-sm"
:class="{
'border-emerald-200 bg-emerald-50 text-emerald-900': alert.type === 'success',
'border-rose-200 bg-rose-50 text-rose-900': alert.type === 'error',
}"
>
{{ alert.message }}
</div>
<div
v-if="auth.user && !auth.user.email_verified"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"
>
<div class="font-medium">邮箱未验证</div>
<div class="mt-1 text-amber-800">验证后才能使用登录态压缩与 API 能力</div>
<div class="mt-3">
<button
type="button"
class="rounded-md bg-amber-600 px-3 py-1.5 font-medium text-white hover:bg-amber-700 disabled:opacity-50"
:disabled="sendingVerification"
@click="resendVerification"
>
重新发送验证邮件
</button>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当期用量</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ usage?.used_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div class="mt-1 text-sm text-slate-600">剩余 {{ usage?.remaining_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
含赠送 {{ usage?.bonus_units ?? 0 }}
</div>
<div class="mt-3">
<router-link to="/dashboard/billing" class="text-sm text-indigo-600 hover:text-indigo-700">
充值额度
</router-link>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当前套餐</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ subscription?.plan.name ?? 'Free' }}</div>
<div class="mt-1 text-sm text-slate-600">状态{{ subscription?.status ?? 'free' }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">周期结束</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ subscription?.current_period_end ? new Date(subscription.current_period_end).toLocaleDateString() : '—' }}
</div>
<div class="mt-1 text-sm text-slate-600">
<router-link to="/dashboard/billing" class="text-indigo-600 hover:text-indigo-700">管理订阅</router-link>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getProfile, sendVerification, updatePassword, updateProfile, type UserProfile } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const profileForm = ref({ email: '', username: '' })
const profileBusy = ref(false)
const profileMessage = ref<string | null>(null)
const profileError = ref<string | null>(null)
const passwordForm = ref({ current: '', next: '', confirm: '' })
const passwordBusy = ref(false)
const passwordMessage = ref<string | null>(null)
const passwordError = ref<string | null>(null)
const verificationBusy = ref(false)
const verificationMessage = ref<string | null>(null)
const verificationError = ref<string | null>(null)
const canResendVerification = computed(() => Boolean(auth.user && !auth.user.email_verified))
function syncProfile(user: UserProfile) {
profileForm.value = { email: user.email ?? '', username: user.username ?? '' }
}
onMounted(async () => {
if (!auth.token) {
loading.value = false
return
}
try {
const user = await getProfile(auth.token)
auth.updateUser(user)
syncProfile(user)
} catch (err) {
if (err instanceof ApiError) {
profileError.value = `[${err.code}] ${err.message}`
} else {
profileError.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
async function resendVerification() {
if (!auth.token) return
verificationBusy.value = true
verificationMessage.value = null
verificationError.value = null
try {
const resp = await sendVerification(auth.token)
verificationMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
verificationError.value = `[${err.code}] ${err.message}`
} else {
verificationError.value = '发送失败,请稍后再试'
}
} finally {
verificationBusy.value = false
}
}
async function saveProfile() {
if (!auth.token || !auth.user) return
profileBusy.value = true
profileMessage.value = null
profileError.value = null
try {
const email = profileForm.value.email.trim().toLowerCase()
const username = profileForm.value.username.trim()
const payload: { email?: string; username?: string } = {}
if (email && email !== auth.user.email) payload.email = email
if (username && username !== auth.user.username) payload.username = username
if (!payload.email && !payload.username) {
profileMessage.value = '暂无更新'
return
}
const resp = await updateProfile(auth.token, payload)
auth.updateUser(resp.user)
syncProfile(resp.user)
profileMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
profileError.value = `[${err.code}] ${err.message}`
} else {
profileError.value = '更新失败,请稍后再试'
}
} finally {
profileBusy.value = false
}
}
async function changePassword() {
if (!auth.token) return
passwordBusy.value = true
passwordMessage.value = null
passwordError.value = null
try {
const currentPassword = passwordForm.value.current.trim()
const nextPassword = passwordForm.value.next.trim()
const confirm = passwordForm.value.confirm.trim()
if (!currentPassword || !nextPassword) {
passwordError.value = '请填写当前密码与新密码'
return
}
if (nextPassword !== confirm) {
passwordError.value = '两次输入的新密码不一致'
return
}
const resp = await updatePassword(auth.token, currentPassword, nextPassword)
passwordMessage.value = resp.message
passwordForm.value = { current: '', next: '', confirm: '' }
} catch (err) {
if (err instanceof ApiError) {
passwordError.value = `[${err.code}] ${err.message}`
} else {
passwordError.value = '更新失败,请稍后再试'
}
} finally {
passwordBusy.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">账号设置</h1>
<p class="text-sm text-slate-600">更新个人资料邮箱验证与密码</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else class="space-y-6">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium text-slate-900">账号资料</div>
<span
class="rounded-full px-2 py-1 text-xs"
:class="auth.user?.email_verified ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-800'"
>
{{ auth.user?.email_verified ? '邮箱已验证' : '邮箱未验证' }}
</span>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">邮箱</div>
<input
v-model="profileForm.email"
type="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="name@company.com"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">用户名</div>
<input
v-model="profileForm.username"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="你的用户名"
/>
</label>
</div>
<div v-if="profileMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ profileMessage }}
</div>
<div v-if="profileError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ profileError }}
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="profileBusy"
@click="saveProfile"
>
{{ profileBusy ? '保存中…' : '保存资料' }}
</button>
<div v-if="canResendVerification">
<button
type="button"
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 hover:bg-amber-100 disabled:opacity-50"
:disabled="verificationBusy"
@click="resendVerification"
>
{{ verificationBusy ? '发送中…' : '重新发送验证邮件' }}
</button>
</div>
</div>
<div
v-if="verificationMessage"
class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
>
{{ verificationMessage }}
</div>
<div v-if="verificationError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ verificationError }}
</div>
<div class="mt-2 text-xs text-slate-500">开发环境邮件关闭时链接会打印在后端日志中</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="text-sm font-medium text-slate-900">修改密码</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">当前密码</div>
<input
v-model="passwordForm.current"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="当前密码"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">新密码</div>
<input
v-model="passwordForm.next"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="至少 8 位"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">确认新密码</div>
<input
v-model="passwordForm.confirm"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="再次输入新密码"
/>
</label>
</div>
<div v-if="passwordMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ passwordMessage }}
</div>
<div v-if="passwordError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ passwordError }}
</div>
<div class="mt-4">
<button
type="button"
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-950 disabled:opacity-50"
:disabled="passwordBusy"
@click="changePassword"
>
{{ passwordBusy ? '更新中…' : '更新密码' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,524 @@
import type { User } from '@/stores/auth'
import { apiGet, apiJson, apiMultipart } from './http'
export type CompressionLevel = 'high' | 'medium' | 'low'
export type OutputFormat = 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico'
export interface RegisterResponse {
user: User
token: string
message: string
}
export interface LoginResponse {
token: string
expires_at: string
user: User
}
export async function register(email: string, password: string, username: string): Promise<RegisterResponse> {
return apiJson<RegisterResponse>('/api/v1/auth/register', { email, password, username }, null)
}
export async function login(email: string, password: string): Promise<LoginResponse> {
return apiJson<LoginResponse>('/api/v1/auth/login', { email, password }, null)
}
export async function sendVerification(token: string): Promise<{ message: string }> {
return apiJson<{ message: string }>('/api/v1/auth/send-verification', undefined, token, { method: 'POST' })
}
export async function verifyEmail(verificationToken: string): Promise<{ message: string }> {
return apiJson<{ message: string }>('/api/v1/auth/verify-email', { token: verificationToken }, null)
}
export async function forgotPassword(email: string): Promise<{ message: string }> {
return apiJson<{ message: string }>('/api/v1/auth/forgot-password', { email }, null)
}
export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> {
return apiJson<{ message: string }>('/api/v1/auth/reset-password', { token: resetToken, new_password: newPassword }, null)
}
export type UserProfile = User
export async function getProfile(token: string): Promise<UserProfile> {
return apiGet<UserProfile>('/api/v1/user/profile', token)
}
export async function updateProfile(
token: string,
payload: { email?: string; username?: string },
): Promise<{ user: UserProfile; message: string }> {
return apiJson<{ user: UserProfile; message: string }>('/api/v1/user/profile', payload, token, { method: 'PUT' })
}
export async function updatePassword(
token: string,
currentPassword: string,
newPassword: string,
): Promise<{ message: string }> {
return apiJson<{ message: string }>(
'/api/v1/user/password',
{ current_password: currentPassword, new_password: newPassword },
token,
{ method: 'PUT' },
)
}
export interface CompressResponse {
task_id: string
file_id: string
format_in: string
format_out: string
original_size: number
compressed_size: number
saved_bytes: number
saved_percent: number
download_url: string
expires_at: string
billing: { units_charged: number }
}
export interface CompressOptions {
level?: CompressionLevel
compression_rate?: number
output_format?: OutputFormat
max_width?: number
max_height?: number
preserve_metadata?: boolean
}
export async function compressFile(file: File, options: CompressOptions, token?: string | null): Promise<CompressResponse> {
const form = new FormData()
form.append('file', file, file.name)
if (options.level) form.append('level', options.level)
if (options.compression_rate) form.append('compression_rate', String(options.compression_rate))
if (options.output_format) form.append('output_format', options.output_format)
if (options.max_width) form.append('max_width', String(options.max_width))
if (options.max_height) form.append('max_height', String(options.max_height))
if (options.preserve_metadata) form.append('preserve_metadata', 'true')
return apiMultipart<CompressResponse>('/api/v1/compress', form, token)
}
export interface PlanView {
id: string
code: string
name: string
currency: string
amount_cents: number
interval: string
included_units_per_period: number
max_file_size_mb: number
max_files_per_batch: number
retention_days: number
features: unknown
}
export async function listPlans(): Promise<{ plans: PlanView[] }> {
return apiGet<{ plans: PlanView[] }>('/api/v1/billing/plans', null)
}
export interface UsageResponse {
period_start: string
period_end: string
used_units: number
included_units: number
bonus_units: number
total_units: number
remaining_units: number
}
export async function getUsage(token: string): Promise<UsageResponse> {
return apiGet<UsageResponse>('/api/v1/billing/usage', token)
}
export interface SubscriptionView {
status: string
current_period_start: string
current_period_end: string
cancel_at_period_end: boolean
plan: PlanView
}
export async function getSubscription(token: string): Promise<{ subscription: SubscriptionView }> {
return apiGet<{ subscription: SubscriptionView }>('/api/v1/billing/subscription', token)
}
export interface InvoiceView {
invoice_number: string
status: string
currency: string
total_amount_cents: number
period_start?: string | null
period_end?: string | null
hosted_invoice_url?: string | null
pdf_url?: string | null
paid_at?: string | null
created_at: string
}
export async function listInvoices(
token: string,
page = 1,
limit = 20,
): Promise<{ invoices: InvoiceView[]; page: number; limit: number }> {
const qs = new URLSearchParams({ page: String(page), limit: String(limit) }).toString()
return apiGet<{ invoices: InvoiceView[]; page: number; limit: number }>(`/api/v1/billing/invoices?${qs}`, token)
}
export async function createCheckout(token: string, planId: string): Promise<{ checkout_url: string }> {
return apiJson<{ checkout_url: string }>('/api/v1/billing/checkout', { plan_id: planId }, token)
}
export async function createPortal(token: string): Promise<{ url: string }> {
return apiJson<{ url: string }>('/api/v1/billing/portal', undefined, token, { method: 'POST' })
}
export interface ApiKeyView {
id: string
name: string
key_prefix: string
permissions: unknown
rate_limit: number
is_active: boolean
last_used_at?: string | null
last_used_ip?: string | null
created_at: string
}
export async function listApiKeys(token: string): Promise<{ api_keys: ApiKeyView[] }> {
return apiGet<{ api_keys: ApiKeyView[] }>('/api/v1/user/api-keys', token)
}
export interface CreateApiKeyResponse {
id: string
name: string
key_prefix: string
key: string
message: string
}
export async function createApiKey(
token: string,
name: string,
permissions?: string[],
): Promise<CreateApiKeyResponse> {
return apiJson<CreateApiKeyResponse>('/api/v1/user/api-keys', { name, permissions }, token)
}
export async function disableApiKey(token: string, keyId: string): Promise<{ message: string }> {
return apiJson<{ message: string }>(`/api/v1/user/api-keys/${keyId}`, undefined, token, { method: 'DELETE' })
}
export async function rotateApiKey(token: string, keyId: string): Promise<CreateApiKeyResponse> {
return apiJson<CreateApiKeyResponse>(`/api/v1/user/api-keys/${keyId}/rotate`, undefined, token)
}
export interface HistoryFileView {
file_id: string
original_name: string
original_size: number
compressed_size?: number | null
saved_percent?: number | null
status: string
output_format: string
error_message?: string | null
download_url?: string | null
}
export interface HistoryTaskView {
task_id: string
status: string
source: string
progress: number
total_files: number
completed_files: number
failed_files: number
created_at: string
completed_at?: string | null
expires_at: string
download_all_url?: string | null
files: HistoryFileView[]
}
export interface HistoryResponse {
tasks: HistoryTaskView[]
page: number
limit: number
total: number
}
export async function listHistory(
token: string,
params: { page?: number; limit?: number; status?: string } = {},
): Promise<HistoryResponse> {
const qs = new URLSearchParams()
if (params.page) qs.set('page', String(params.page))
if (params.limit) qs.set('limit', String(params.limit))
if (params.status) qs.set('status', params.status)
const suffix = qs.toString()
return apiGet<HistoryResponse>(`/api/v1/user/history${suffix ? `?${suffix}` : ''}`, token)
}
export interface AdminStats {
total_users: number
active_users: number
pending_tasks: number
processing_tasks: number
failed_tasks: number
completed_tasks: number
usage_events_24h: number
active_subscriptions: number
}
export async function getAdminStats(token: string): Promise<AdminStats> {
return apiGet<AdminStats>('/api/v1/admin/stats', token)
}
export interface AdminUserView {
id: string
email: string
username: string
role: string
is_active: boolean
email_verified: boolean
rate_limit_override?: number | null
storage_limit_mb?: number | null
created_at: string
subscription_status?: string | null
}
export interface AdminUserListResponse {
users: AdminUserView[]
page: number
limit: number
total: number
}
export async function listAdminUsers(
token: string,
params: { page?: number; limit?: number; search?: string } = {},
): Promise<AdminUserListResponse> {
const qs = new URLSearchParams()
if (params.page) qs.set('page', String(params.page))
if (params.limit) qs.set('limit', String(params.limit))
if (params.search) qs.set('search', params.search)
const suffix = qs.toString()
return apiGet<AdminUserListResponse>(`/api/v1/admin/users${suffix ? `?${suffix}` : ''}`, token)
}
export interface AdminTaskView {
id: string
status: string
source: string
total_files: number
completed_files: number
failed_files: number
error_message?: string | null
created_at: string
completed_at?: string | null
expires_at: string
user_id?: string | null
user_email?: string | null
}
export interface AdminTaskListResponse {
tasks: AdminTaskView[]
page: number
limit: number
total: number
}
export async function listAdminTasks(
token: string,
params: { page?: number; limit?: number; status?: string } = {},
): Promise<AdminTaskListResponse> {
const qs = new URLSearchParams()
if (params.page) qs.set('page', String(params.page))
if (params.limit) qs.set('limit', String(params.limit))
if (params.status) qs.set('status', params.status)
const suffix = qs.toString()
return apiGet<AdminTaskListResponse>(`/api/v1/admin/tasks${suffix ? `?${suffix}` : ''}`, token)
}
export async function cancelAdminTask(token: string, taskId: string): Promise<{ message: string }> {
return apiJson<{ message: string }>(`/api/v1/admin/tasks/${taskId}/cancel`, undefined, token)
}
export interface AdminSubscriptionView {
id: string
status: string
current_period_start: string
current_period_end: string
cancel_at_period_end: boolean
plan_name: string
plan_code: string
currency: string
amount_cents: number
interval: string
user_id: string
user_email: string
}
export interface AdminSubscriptionListResponse {
subscriptions: AdminSubscriptionView[]
page: number
limit: number
total: number
}
export async function listAdminSubscriptions(
token: string,
params: { page?: number; limit?: number } = {},
): Promise<AdminSubscriptionListResponse> {
const qs = new URLSearchParams()
if (params.page) qs.set('page', String(params.page))
if (params.limit) qs.set('limit', String(params.limit))
const suffix = qs.toString()
return apiGet<AdminSubscriptionListResponse>(
`/api/v1/admin/billing/subscriptions${suffix ? `?${suffix}` : ''}`,
token,
)
}
export interface AdminManualSubscriptionResponse {
message: string
subscription_id: string
user_id: string
plan_id: string
plan_name: string
period_start: string
period_end: string
status: string
}
export async function createAdminSubscription(
token: string,
payload: { user_id: string; plan_id: string; months?: number; note?: string },
): Promise<AdminManualSubscriptionResponse> {
return apiJson<AdminManualSubscriptionResponse>('/api/v1/admin/billing/subscriptions/manual', payload, token)
}
export interface AdminCreditResponse {
message: string
period_start: string
period_end: string
used_units: number
bonus_units: number
total_units: number
remaining_units: number
}
export async function grantAdminCredits(
token: string,
payload: { user_id: string; units: number; note?: string },
): Promise<AdminCreditResponse> {
return apiJson<AdminCreditResponse>('/api/v1/admin/billing/credits', payload, token)
}
export interface AdminConfigEntry {
key: string
value: unknown
description?: string | null
updated_at: string
updated_by?: string | null
}
export async function listAdminConfig(token: string): Promise<{ configs: AdminConfigEntry[] }> {
return apiGet<{ configs: AdminConfigEntry[] }>('/api/v1/admin/config', token)
}
export async function updateAdminConfig(
token: string,
payload: { key: string; value: unknown; description?: string | null },
): Promise<AdminConfigEntry> {
return apiJson<AdminConfigEntry>('/api/v1/admin/config', payload, token, { method: 'PUT' })
}
export interface AdminPlanView {
id: string
code: string
name: string
currency: string
amount_cents: number
interval: string
included_units_per_period: number
max_file_size_mb: number
max_files_per_batch: number
retention_days: number
stripe_product_id?: string | null
stripe_price_id?: string | null
is_active: boolean
}
export async function listAdminPlans(token: string): Promise<{ plans: AdminPlanView[] }> {
return apiGet<{ plans: AdminPlanView[] }>('/api/v1/admin/plans', token)
}
export async function updateAdminPlan(
token: string,
planId: string,
payload: { stripe_product_id?: string | null; stripe_price_id?: string | null; is_active?: boolean },
): Promise<AdminPlanView> {
return apiJson<AdminPlanView>(`/api/v1/admin/plans/${planId}`, payload, token, { method: 'PUT' })
}
export interface AdminStripeConfig {
secret_key_configured: boolean
webhook_secret_configured: boolean
secret_key_prefix?: string | null
}
export async function getStripeConfig(token: string): Promise<AdminStripeConfig> {
return apiGet<AdminStripeConfig>('/api/v1/admin/stripe', token)
}
export async function updateStripeConfig(
token: string,
payload: { secret_key?: string; webhook_secret?: string },
): Promise<AdminStripeConfig> {
return apiJson<AdminStripeConfig>('/api/v1/admin/stripe', payload, token, { method: 'PUT' })
}
export interface MailCustomSmtp {
host: string
port: number
encryption: string
}
export interface AdminMailConfig {
enabled: boolean
provider: string
from: string
from_name: string
custom_smtp?: MailCustomSmtp | null
password_configured: boolean
log_links_when_disabled: boolean
}
export async function getMailConfig(token: string): Promise<AdminMailConfig> {
return apiGet<AdminMailConfig>('/api/v1/admin/mail', token)
}
export async function updateMailConfig(
token: string,
payload: {
enabled: boolean
provider: string
from: string
from_name: string
password?: string
custom_smtp?: MailCustomSmtp | null
log_links_when_disabled?: boolean
},
): Promise<AdminMailConfig> {
return apiJson<AdminMailConfig>('/api/v1/admin/mail', payload, token, { method: 'PUT' })
}
export async function sendMailTest(token: string, to?: string): Promise<{ message: string }> {
return apiJson<{ message: string }>('/api/v1/admin/mail/test', { to }, token)
}

View File

@@ -0,0 +1,129 @@
export type ApiSuccess<T> = {
success: true
data: T
}
export type ApiFailure = {
success: false
error: {
code: string
message: string
request_id: string
}
}
export type ApiEnvelope<T> = ApiSuccess<T> | ApiFailure
export class ApiError extends Error {
readonly code: string
readonly status: number
readonly requestId?: string
constructor(code: string, status: number, message: string, requestId?: string) {
super(message)
this.code = code
this.status = status
this.requestId = requestId
}
}
async function parseEnvelope<T>(res: Response): Promise<ApiEnvelope<T>> {
const text = await res.text()
if (!text) {
if (res.ok) {
return { success: true, data: undefined as T }
}
return {
success: false,
error: {
code: 'HTTP_ERROR',
message: `HTTP ${res.status}`,
request_id: '',
},
}
}
try {
return JSON.parse(text) as ApiEnvelope<T>
} catch {
if (res.ok) {
return { success: true, data: text as T }
}
return {
success: false,
error: {
code: 'INVALID_JSON',
message: `无法解析响应HTTP ${res.status}`,
request_id: '',
},
}
}
}
function mergeHeaders(a?: HeadersInit, b?: HeadersInit): Headers {
const out = new Headers(a ?? {})
for (const [k, v] of new Headers(b ?? {})) out.set(k, v)
return out
}
export async function apiJson<T>(
path: string,
body: unknown | undefined,
token?: string | null,
init?: RequestInit,
): Promise<T> {
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
if (body !== undefined) headers.set('content-type', 'application/json')
if (token) headers.set('authorization', `Bearer ${token}`)
const res = await fetch(path, {
...init,
method: init?.method ?? 'POST',
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const envelope = await parseEnvelope<T>(res)
if (envelope.success) return envelope.data
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
}
export async function apiGet<T>(path: string, token?: string | null, init?: RequestInit): Promise<T> {
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
if (token) headers.set('authorization', `Bearer ${token}`)
const res = await fetch(path, {
...init,
method: 'GET',
headers,
})
const envelope = await parseEnvelope<T>(res)
if (envelope.success) return envelope.data
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
}
export async function apiMultipart<T>(
path: string,
form: FormData,
token?: string | null,
init?: RequestInit,
): Promise<T> {
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
if (token) headers.set('authorization', `Bearer ${token}`)
const res = await fetch(path, {
...init,
method: 'POST',
headers,
body: form,
})
const envelope = await parseEnvelope<T>(res)
if (envelope.success) return envelope.data
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
}

View File

@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
export type UserRole = 'user' | 'admin'
export interface User {
id: string
email: string
username: string
role: UserRole
email_verified: boolean
}
interface StoredAuth {
token: string
user: User
}
const STORAGE_KEY = 'imageforge_auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: null as string | null,
user: null as User | null,
}),
getters: {
isLoggedIn: (state) => Boolean(state.token),
},
actions: {
initFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as StoredAuth
if (!parsed?.token || !parsed?.user) return
this.token = parsed.token
this.user = parsed.user
} catch {
localStorage.removeItem(STORAGE_KEY)
}
},
setAuth(token: string, user: User) {
this.token = token
this.user = user
const stored: StoredAuth = { token, user }
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
},
updateUser(user: User) {
this.user = user
if (!this.token) return
const stored: StoredAuth = { token: this.token, user }
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
},
logout() {
this.token = null
this.user = null
localStorage.removeItem(STORAGE_KEY)
},
markEmailVerified() {
if (!this.user || this.user.email_verified) return
this.user = { ...this.user, email_verified: true }
if (!this.token) return
const stored: StoredAuth = { token: this.token, user: this.user }
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
},
},
})

45
frontend/src/style.css Normal file
View File

@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: 248 250 252;
--card: 255 255 255;
--text: 15 23 42;
--muted: 71 85 105;
--border: 226 232 240;
--brand: 99 102 241;
--brand-strong: 79 70 229;
--success: 34 197 94;
--warning: 245 158 11;
--danger: 239 68 68;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: rgb(var(--bg));
color: rgb(var(--text));
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
'Apple Color Emoji', 'Segoe UI Emoji';
}
a {
color: rgb(var(--brand));
text-decoration: none;
}
a:hover {
color: rgb(var(--brand-strong));
text-decoration: underline;
}

View File

@@ -0,0 +1,23 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
const digits = unit === 0 ? 0 : unit === 1 ? 1 : 2
return `${value.toFixed(digits)} ${units[unit]}`
}
export function formatCents(amountCents: number, currency: string): string {
const amount = (amountCents ?? 0) / 100
const cc = (currency ?? 'CNY').toUpperCase()
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: cc,
maximumFractionDigits: 2,
}).format(amount)
}

View File

@@ -0,0 +1,10 @@
import typography from '@tailwindcss/typography'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [typography],
}

View File

@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

29
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': {
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/downloads': {
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/health': {
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})

423
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,423 @@
-- ImageForge initial schema
-- NOTE: This is a starting point; evolve via new migrations.
BEGIN;
-- Extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Enums
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('user', 'admin');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE task_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE file_status AS ENUM ('pending', 'processing', 'completed', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE compression_level AS ENUM ('high', 'medium', 'low');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE task_source AS ENUM ('web', 'api');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE subscription_status AS ENUM ('trialing', 'active', 'past_due', 'paused', 'canceled', 'incomplete');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE invoice_status AS ENUM ('draft', 'open', 'paid', 'void', 'uncollectible');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE payment_status AS ENUM ('pending', 'succeeded', 'failed', 'refunded');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- users
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT true,
email_verified_at TIMESTAMPTZ,
billing_customer_id VARCHAR(200),
rate_limit_override INTEGER,
storage_limit_mb INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified_at) WHERE email_verified_at IS NULL;
-- email_verifications
CREATE TABLE IF NOT EXISTS email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash);
CREATE INDEX IF NOT EXISTS idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verifications_expires ON email_verifications(expires_at) WHERE verified_at IS NULL;
-- password_resets
CREATE TABLE IF NOT EXISTS password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id);
CREATE INDEX IF NOT EXISTS idx_password_resets_expires ON password_resets(expires_at) WHERE used_at IS NULL;
-- plans
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
stripe_product_id VARCHAR(200),
stripe_price_id VARCHAR(200),
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
amount_cents INTEGER NOT NULL DEFAULT 0,
interval VARCHAR(20) NOT NULL DEFAULT 'monthly',
included_units_per_period INTEGER NOT NULL,
max_file_size_mb INTEGER NOT NULL,
max_files_per_batch INTEGER NOT NULL,
concurrency_limit INTEGER NOT NULL,
retention_days INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_plans_is_active ON plans(is_active);
-- subscriptions
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
status subscription_status NOT NULL DEFAULT 'incomplete',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
canceled_at TIMESTAMPTZ,
provider VARCHAR(20) NOT NULL DEFAULT 'none',
provider_customer_id VARCHAR(200),
provider_subscription_id VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
-- api_keys (Pro/Business only; enforced at application layer)
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
key_prefix VARCHAR(20) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
permissions JSONB NOT NULL DEFAULT '["compress"]',
rate_limit INTEGER NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
last_used_ip INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active);
-- tasks
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
session_id VARCHAR(100),
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
source task_source NOT NULL DEFAULT 'web',
status task_status NOT NULL DEFAULT 'pending',
compression_level compression_level NOT NULL DEFAULT 'medium',
output_format VARCHAR(10),
max_width INTEGER,
max_height INTEGER,
preserve_metadata BOOLEAN NOT NULL DEFAULT false,
total_files INTEGER NOT NULL DEFAULT 0,
completed_files INTEGER NOT NULL DEFAULT 0,
failed_files INTEGER NOT NULL DEFAULT 0,
total_original_size BIGINT NOT NULL DEFAULT 0,
total_compressed_size BIGINT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours')
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_tasks_expires_at ON tasks(expires_at);
-- task_files
CREATE TABLE IF NOT EXISTS task_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
original_name VARCHAR(255) NOT NULL,
original_format VARCHAR(10) NOT NULL,
output_format VARCHAR(10) NOT NULL,
original_size BIGINT NOT NULL,
compressed_size BIGINT,
saved_percent DECIMAL(6, 2),
storage_path VARCHAR(500),
status file_status NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id);
CREATE INDEX IF NOT EXISTS idx_task_files_status ON task_files(status);
-- idempotency_keys
CREATE TABLE IF NOT EXISTS idempotency_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE,
idempotency_key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status INTEGER NOT NULL,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_user_key
ON idempotency_keys(user_id, idempotency_key) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_api_key_key
ON idempotency_keys(api_key_id, idempotency_key) WHERE api_key_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_idempotency_expires_at ON idempotency_keys(expires_at);
-- usage_events
CREATE TABLE IF NOT EXISTS usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
source task_source NOT NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
task_file_id UUID REFERENCES task_files(id) ON DELETE SET NULL,
units INTEGER NOT NULL DEFAULT 1,
bytes_in BIGINT NOT NULL DEFAULT 0,
bytes_out BIGINT NOT NULL DEFAULT 0,
format_in VARCHAR(10),
format_out VARCHAR(10),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_events_task_file_unique
ON usage_events(task_file_id) WHERE task_file_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_usage_events_user_time ON usage_events(user_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_usage_events_api_key_time ON usage_events(api_key_id, occurred_at);
-- usage_periods
CREATE TABLE IF NOT EXISTS usage_periods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
used_units INTEGER NOT NULL DEFAULT 0,
bytes_in BIGINT NOT NULL DEFAULT 0,
bytes_out BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_usage_periods_user_period ON usage_periods(user_id, period_start);
-- invoices
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
invoice_number VARCHAR(50) NOT NULL UNIQUE,
status invoice_status NOT NULL DEFAULT 'open',
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
total_amount_cents INTEGER NOT NULL DEFAULT 0,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
provider VARCHAR(20) NOT NULL DEFAULT 'none',
provider_invoice_id VARCHAR(200),
hosted_invoice_url TEXT,
pdf_url TEXT,
due_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
-- payments
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL,
provider VARCHAR(20) NOT NULL DEFAULT 'none',
provider_payment_id VARCHAR(200),
status payment_status NOT NULL DEFAULT 'pending',
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
amount_cents INTEGER NOT NULL DEFAULT 0,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id);
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
-- webhook_events
CREATE TABLE IF NOT EXISTS webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(20) NOT NULL,
provider_event_id VARCHAR(200) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'received',
error_message TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_unique ON webhook_events(provider, provider_event_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(status);
-- system_config
CREATE TABLE IF NOT EXISTS system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- audit_logs
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(50),
resource_id UUID,
details JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
-- Default config seeds
INSERT INTO system_config (key, value, description)
VALUES
('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true}', '功能开关'),
('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'),
('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)')
ON CONFLICT (key) DO NOTHING;
INSERT INTO plans (code, name, stripe_price_id, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, concurrency_limit, retention_days, features)
VALUES
('free', 'Free', NULL, 'CNY', 0, 'monthly', 500, 5, 10, 2, 1, '{"webhook": false, "api": false}'),
('pro_monthly', 'Pro月付', 'price_xxx_pro_monthly', 'CNY', 1999, 'monthly', 10000, 20, 50, 8, 7, '{"webhook": true, "api": true}'),
('business_monthly', 'Business月付', 'price_xxx_business_monthly', 'CNY', 9999, 'monthly', 100000, 50, 200, 32, 30, '{"webhook": true, "api": true, "ip_allowlist": true}')
ON CONFLICT (code) DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS client_ip INET;
CREATE INDEX IF NOT EXISTS idx_tasks_client_ip ON tasks(client_ip);
COMMIT;

View File

@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS compression_rate SMALLINT;
COMMIT;

View File

@@ -0,0 +1,11 @@
BEGIN;
ALTER TABLE usage_periods
ADD COLUMN IF NOT EXISTS bonus_units INTEGER NOT NULL DEFAULT 0;
UPDATE usage_periods
SET bonus_units = bonus_units + ABS(used_units),
used_units = 0
WHERE used_units < 0;
COMMIT;

1553
src/api/admin.rs Normal file

File diff suppressed because it is too large Load Diff

596
src/api/auth.rs Normal file
View File

@@ -0,0 +1,596 @@
use crate::auth;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::mail;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
extract::State,
http::HeaderMap,
routing::post,
Json, Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/send-verification", post(send_verification))
.route("/verify-email", post(verify_email))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
}
#[derive(Debug, Deserialize)]
struct RegisterRequest {
email: String,
password: String,
username: String,
}
#[derive(Debug, Serialize)]
struct RegisterResponse {
user: UserView,
token: String,
message: String,
}
#[derive(Debug, Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Debug, Serialize)]
struct LoginResponse {
token: String,
expires_at: DateTime<Utc>,
user: UserView,
}
#[derive(Debug, Serialize)]
struct UserView {
id: Uuid,
email: String,
username: String,
role: String,
email_verified: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
password_hash: String,
role: String,
is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<Envelope<RegisterResponse>>, AppError> {
validate_email(&req.email)?;
validate_username(&req.username)?;
validate_password(&req.password)?;
let password_hash = hash_password(&req.password)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
"#,
)
.bind(req.email.to_lowercase())
.bind(&req.username)
.bind(password_hash)
.fetch_one(&state.db)
.await
.map_err(map_unique_violation)?;
let (token, _expires_at) =
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
let expires_at_db = Utc::now() + Duration::hours(24);
sqlx::query(
r#"
INSERT INTO email_verifications (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
let verification_url = format!(
"{}/verify-email?token={}",
state.config.public_base_url, verification_token
);
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
let body = RegisterResponse {
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
token,
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
};
Ok(Json(Envelope {
success: true,
data: body,
}))
}
async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Envelope<LoginResponse>>, AppError> {
let identity = req.email.trim();
if identity.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空"));
}
let user = if identity.contains('@') {
validate_email(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE email = $1
"#,
)
.bind(identity.to_lowercase())
.fetch_optional(&state.db)
.await
} else {
validate_username(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE username = $1
"#,
)
.bind(identity)
.fetch_optional(&state.db)
.await
}
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
if !user.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
verify_password(&req.password, &user.password_hash)?;
let (token, expires_at) =
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
Ok(Json(Envelope {
success: true,
data: LoginResponse {
token,
expires_at,
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
},
}))
}
#[derive(Debug, Serialize)]
struct MessageResponse {
message: String,
}
async fn send_verification(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
// Rate limit: 1 per minute per user
let key = format!("rate:send_verification:{}:{}", claims.sub, Utc::now().format("%Y%m%d%H%M"));
let mut redis = state.redis.clone();
let count: i64 = redis::cmd("INCR")
.arg(&key)
.query_async(&mut redis)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?;
if count == 1 {
let _: () = redis::cmd("EXPIRE")
.arg(&key)
.arg(60)
.query_async(&mut redis)
.await
.unwrap_or(());
}
if count > 1 {
return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试"));
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
if user.email_verified_at.is_some() {
return Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱已验证,无需重复验证".to_string(),
},
}));
}
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
let expires_at_db = Utc::now() + Duration::hours(24);
sqlx::query(
r#"
INSERT INTO email_verifications (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
let verification_url = format!(
"{}/verify-email?token={}",
state.config.public_base_url, verification_token
);
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "验证邮件已发送,请查收".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct VerifyEmailRequest {
token: String,
}
async fn verify_email(
State(state): State<AppState>,
Json(req): Json<VerifyEmailRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
if req.token.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
}
let token_hash = sha256_hex(&req.token);
let now = Utc::now();
let updated = sqlx::query(
r#"
WITH v AS (
SELECT user_id
FROM email_verifications
WHERE token_hash = $1
AND verified_at IS NULL
AND expires_at > $2
LIMIT 1
),
u AS (
UPDATE users
SET email_verified_at = $2
WHERE id = (SELECT user_id FROM v)
AND email_verified_at IS NULL
RETURNING id
)
UPDATE email_verifications
SET verified_at = $2
WHERE token_hash = $1
AND verified_at IS NULL
AND expires_at > $2
AND user_id IN (SELECT id FROM u)
"#,
)
.bind(token_hash)
.bind(now)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "邮箱验证失败").with_source(err))?;
if updated.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
}
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱验证成功".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct ForgotPasswordRequest {
email: String,
}
async fn forgot_password(
State(state): State<AppState>,
Json(req): Json<ForgotPasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
validate_email(&req.email)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE email = $1
"#,
)
.bind(req.email.to_lowercase())
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
if let Some(user) = user {
let reset_token = generate_token();
let token_hash = sha256_hex(&reset_token);
let expires_at_db = Utc::now() + Duration::hours(1);
let _ = sqlx::query(
r#"
INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await;
let reset_url = format!(
"{}/reset-password?token={}",
state.config.public_base_url, reset_token
);
let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
}
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "如果该邮箱已注册,您将收到重置邮件".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct ResetPasswordRequest {
token: String,
new_password: String,
}
async fn reset_password(
State(state): State<AppState>,
Json(req): Json<ResetPasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
if req.token.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
}
validate_password(&req.new_password)?;
let token_hash = sha256_hex(&req.token);
let now = Utc::now();
let user_id: Option<Uuid> = sqlx::query_scalar(
r#"
SELECT user_id
FROM password_resets
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > $2
"#,
)
.bind(&token_hash)
.bind(now)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
let Some(user_id) = user_id else {
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
};
let password_hash = hash_password(&req.new_password)?;
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(password_hash)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
sqlx::query("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL")
.bind(token_hash)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新重置记录失败").with_source(err))?;
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "密码重置成功".to_string(),
},
}))
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
}
Ok(())
}
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
}
Ok(())
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?
.to_string()
.pipe(Ok)
}
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
let parsed = PasswordHash::new(password_hash)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
Ok(())
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn sha256_hex(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn map_unique_violation(err: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &err {
if let Some(code) = db_err.code() {
if code == "23505" {
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
}
}
}
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
}
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}

738
src/api/billing.rs Normal file
View File

@@ -0,0 +1,738 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::idempotency;
use crate::services::settings;
use crate::state::AppState;
use axum::extract::{ConnectInfo, State};
use axum::http::HeaderMap;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::net::SocketAddr;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/billing/plans", get(list_plans))
.route("/billing/subscription", get(get_subscription))
.route("/billing/usage", get(get_usage))
.route("/billing/invoices", get(list_invoices))
.route("/billing/checkout", post(create_checkout))
.route("/billing/portal", post(create_portal))
}
#[derive(Debug, FromRow, Serialize)]
struct PlanView {
id: Uuid,
code: String,
name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct PlansResponse {
plans: Vec<PlanView>,
}
async fn list_plans(State(state): State<AppState>) -> Result<Json<Envelope<PlansResponse>>, AppError> {
let plans = sqlx::query_as::<_, PlanView>(
r#"
SELECT
id, code, name, currency, amount_cents, interval,
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
features
FROM plans
WHERE is_active = true
ORDER BY amount_cents ASC
"#,
)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: PlansResponse { plans },
}))
}
#[derive(Debug, Serialize)]
struct SubscriptionPlanView {
id: Uuid,
code: String,
name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct SubscriptionView {
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
cancel_at_period_end: bool,
plan: SubscriptionPlanView,
}
#[derive(Debug, Serialize)]
struct SubscriptionResponse {
subscription: SubscriptionView,
}
async fn get_subscription(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<SubscriptionResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
#[derive(Debug, FromRow)]
struct SubRow {
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
cancel_at_period_end: bool,
plan_id: Uuid,
plan_code: String,
plan_name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
let sub = sqlx::query_as::<_, SubRow>(
r#"
SELECT
s.status::text AS status,
s.current_period_start,
s.current_period_end,
s.cancel_at_period_end,
p.id AS plan_id,
p.code AS plan_code,
p.name AS plan_name,
p.currency,
p.amount_cents,
p.interval,
p.included_units_per_period,
p.max_file_size_mb,
p.max_files_per_batch,
p.retention_days,
p.features
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.user_id = $1
AND s.status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete')
ORDER BY s.current_period_end DESC
LIMIT 1
"#,
)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
let (status, period_start, period_end, cancel_at_period_end, plan) = if let Some(sub) = sub {
(
sub.status,
sub.current_period_start,
sub.current_period_end,
sub.cancel_at_period_end,
SubscriptionPlanView {
id: sub.plan_id,
code: sub.plan_code,
name: sub.plan_name,
currency: sub.currency,
amount_cents: sub.amount_cents,
interval: sub.interval,
included_units_per_period: sub.included_units_per_period,
max_file_size_mb: sub.max_file_size_mb,
max_files_per_batch: sub.max_files_per_batch,
retention_days: sub.retention_days,
features: sub.features,
},
)
} else {
let plan: PlanView = sqlx::query_as::<_, PlanView>(
r#"
SELECT
id, code, name, currency, amount_cents, interval,
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
features
FROM plans
WHERE code = 'free'
LIMIT 1
"#,
)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
let (start, end) = billing::current_month_period_utc8(Utc::now());
(
"free".to_string(),
start,
end,
false,
SubscriptionPlanView {
id: plan.id,
code: plan.code,
name: plan.name,
currency: plan.currency,
amount_cents: plan.amount_cents,
interval: plan.interval,
included_units_per_period: plan.included_units_per_period,
max_file_size_mb: plan.max_file_size_mb,
max_files_per_batch: plan.max_files_per_batch,
retention_days: plan.retention_days,
features: plan.features,
},
)
};
Ok(Json(Envelope {
success: true,
data: SubscriptionResponse {
subscription: SubscriptionView {
status,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end,
plan,
},
},
}))
}
#[derive(Debug, Serialize)]
struct UsageResponse {
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
used_units: i32,
included_units: i32,
bonus_units: i32,
total_units: i32,
remaining_units: i32,
}
async fn get_usage(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<UsageResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let billing = billing::get_user_billing(&state, user_id).await?;
#[derive(Debug, FromRow)]
struct UsageRow {
used_units: i32,
bonus_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
r#"
SELECT used_units, bonus_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsageRow {
used_units: 0,
bonus_units: 0,
});
let included = billing.plan.included_units_per_period;
let total = included + usage.bonus_units;
let remaining = (total - usage.used_units).max(0);
Ok(Json(Envelope {
success: true,
data: UsageResponse {
period_start: billing.period_start,
period_end: billing.period_end,
used_units: usage.used_units,
included_units: included,
bonus_units: usage.bonus_units,
total_units: total,
remaining_units: remaining,
},
}))
}
#[derive(Debug, Deserialize)]
struct PagingQuery {
page: Option<u32>,
limit: Option<u32>,
}
#[derive(Debug, FromRow, Serialize)]
struct InvoiceView {
invoice_number: String,
status: String,
currency: String,
total_amount_cents: i32,
period_start: Option<DateTime<Utc>>,
period_end: Option<DateTime<Utc>>,
hosted_invoice_url: Option<String>,
pdf_url: Option<String>,
paid_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct InvoicesResponse {
invoices: Vec<InvoiceView>,
page: u32,
limit: u32,
}
async fn list_invoices(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<PagingQuery>,
) -> Result<Json<Envelope<InvoicesResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let invoices = sqlx::query_as::<_, InvoiceView>(
r#"
SELECT
invoice_number,
status::text AS status,
currency,
total_amount_cents,
period_start,
period_end,
hosted_invoice_url,
pdf_url,
paid_at,
created_at
FROM invoices
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询发票失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: InvoicesResponse {
invoices,
page,
limit,
},
}))
}
#[derive(Debug, Deserialize)]
struct CheckoutRequest {
plan_id: Uuid,
}
#[derive(Debug, Serialize, Deserialize)]
struct CheckoutResponse {
checkout_url: String,
}
async fn create_checkout(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CheckoutRequest>,
) -> Result<Json<Envelope<CheckoutResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let idempotency_key = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
let request_hash = idempotency_key.as_ref().map(|_| {
let plan = req.plan_id.to_string();
idempotency::sha256_hex(&[b"billing_checkout", plan.as_bytes()])
});
let mut idem_acquired = false;
if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) {
match idempotency::begin(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
state.config.idempotency_ttl_hours as i64,
)
.await?
{
idempotency::BeginResult::Replay { response_body, .. } => {
let resp: CheckoutResponse = serde_json::from_value(response_body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok(Json(Envelope { success: true, data: resp }));
}
idempotency::BeginResult::InProgress => {
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
10_000,
)
.await?
{
let resp: CheckoutResponse = serde_json::from_value(body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok(Json(Envelope { success: true, data: resp }));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idem_acquired = true;
}
}
}
let session_result: Result<String, AppError> = (async {
let stripe_secret = settings::get_stripe_secret(&state)
.await
.map_err(|err| err.with_source("stripe secret not configured"))?;
#[derive(Debug, FromRow)]
struct PlanStripeRow {
stripe_price_id: Option<String>,
amount_cents: i32,
is_active: bool,
}
let plan = sqlx::query_as::<_, PlanStripeRow>(
r#"
SELECT stripe_price_id, amount_cents, is_active
FROM plans
WHERE id = $1
"#,
)
.bind(req.plan_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?;
if !plan.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用"));
}
let Some(price_id) = plan.stripe_price_id.filter(|v| !v.trim().is_empty()) else {
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
};
if plan.amount_cents <= 0 {
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
}
#[derive(Debug, FromRow)]
struct UserStripeRow {
email: String,
billing_customer_id: Option<String>,
}
let user = sqlx::query_as::<_, UserStripeRow>(
"SELECT email, billing_customer_id FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let customer_id = if let Some(cus) = user.billing_customer_id {
cus
} else {
let cus = stripe_create_customer(&stripe_secret, &user.email, user_id).await?;
let _ = sqlx::query("UPDATE users SET billing_customer_id = $2 WHERE id = $1")
.bind(user_id)
.bind(&cus)
.execute(&state.db)
.await;
cus
};
let success_url =
format!("{}/dashboard/billing?checkout=success", state.config.public_base_url);
let cancel_url = format!("{}/pricing?checkout=cancel", state.config.public_base_url);
stripe_create_checkout_session(
&stripe_secret,
&customer_id,
&price_id,
&success_url,
&cancel_url,
user_id,
)
.await
})
.await;
match session_result {
Ok(session) => {
if let (Some(idem), Some(request_hash)) =
(idempotency_key.as_deref(), request_hash.as_deref())
{
if idem_acquired {
let _ = idempotency::complete(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
200,
serde_json::to_value(&CheckoutResponse {
checkout_url: session.clone(),
})
.unwrap_or(serde_json::Value::Null),
)
.await;
}
}
Ok(Json(Envelope {
success: true,
data: CheckoutResponse {
checkout_url: session,
},
}))
}
Err(err) => {
if let (Some(idem), Some(request_hash)) =
(idempotency_key.as_deref(), request_hash.as_deref())
{
if idem_acquired {
let _ = idempotency::abort(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
)
.await;
}
}
Err(err)
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct PortalResponse {
url: String,
}
async fn create_portal(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<PortalResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let stripe_secret = settings::get_stripe_secret(&state)
.await
.map_err(|err| err.with_source("stripe secret not configured"))?;
let customer_id: Option<String> =
sqlx::query_scalar("SELECT billing_customer_id FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(customer_id) = customer_id.filter(|v| !v.trim().is_empty()) else {
return Err(AppError::new(ErrorCode::InvalidRequest, "未找到 Stripe Customer"));
};
let return_url = format!("{}/dashboard/billing", state.config.public_base_url);
let url = stripe_create_portal_session(&stripe_secret, &customer_id, &return_url).await?;
Ok(Json(Envelope {
success: true,
data: PortalResponse { url },
}))
}
async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/customers",
vec![
("email".to_string(), email.to_string()),
("metadata[user_id]".to_string(), user_id.to_string()),
],
)
.await?;
let id = resp
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe customer 创建失败"))?;
Ok(id.to_string())
}
async fn stripe_create_checkout_session(
secret: &str,
customer_id: &str,
price_id: &str,
success_url: &str,
cancel_url: &str,
user_id: Uuid,
) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/checkout/sessions",
vec![
("mode".to_string(), "subscription".to_string()),
("customer".to_string(), customer_id.to_string()),
("line_items[0][price]".to_string(), price_id.to_string()),
("line_items[0][quantity]".to_string(), "1".to_string()),
("success_url".to_string(), success_url.to_string()),
("cancel_url".to_string(), cancel_url.to_string()),
("allow_promotion_codes".to_string(), "true".to_string()),
("client_reference_id".to_string(), user_id.to_string()),
("metadata[user_id]".to_string(), user_id.to_string()),
],
)
.await?;
let url = resp
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe checkout 创建失败"))?;
Ok(url.to_string())
}
async fn stripe_create_portal_session(
secret: &str,
customer_id: &str,
return_url: &str,
) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/billing_portal/sessions",
vec![
("customer".to_string(), customer_id.to_string()),
("return_url".to_string(), return_url.to_string()),
],
)
.await?;
let url = resp
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe portal 创建失败"))?;
Ok(url.to_string())
}
async fn stripe_post_form(
secret: &str,
path: &str,
form: Vec<(String, String)>,
) -> Result<serde_json::Value, AppError> {
let url = format!("https://api.stripe.com{path}");
let client = reqwest::Client::new();
let resp = client
.post(url)
.bearer_auth(secret)
.form(&form)
.send()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 请求失败").with_source(err))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应读取失败").with_source(err))?;
if !status.is_success() {
tracing::error!(status = %status, body = %body, "Stripe API error");
return Err(AppError::new(ErrorCode::Internal, "Stripe API 调用失败"));
}
serde_json::from_str(&body)
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应解析失败").with_source(err))
}

1218
src/api/compress.rs Normal file

File diff suppressed because it is too large Load Diff

229
src/api/context.rs Normal file
View File

@@ -0,0 +1,229 @@
use crate::auth;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::Serialize;
use sha2::Sha256;
use sqlx::FromRow;
use std::net::IpAddr;
use time::Duration as TimeDuration;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Principal {
Anonymous { session_id: String },
User { user_id: Uuid, role: String, email_verified: bool },
ApiKey {
user_id: Uuid,
api_key_id: Uuid,
role: String,
email_verified: bool,
},
}
pub fn client_ip(headers: &HeaderMap, connect_ip: IpAddr) -> IpAddr {
if let Some(ip) = parse_forwarded_for(headers) {
return ip;
}
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<IpAddr>().ok())
{
return ip;
}
connect_ip
}
fn parse_forwarded_for(headers: &HeaderMap) -> Option<IpAddr> {
let value = headers.get("x-forwarded-for")?.to_str().ok()?;
value
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<IpAddr>().ok())
}
pub async fn authenticate(
state: &AppState,
jar: CookieJar,
headers: &HeaderMap,
ip: IpAddr,
) -> Result<(CookieJar, Principal), AppError> {
if let Some(principal) = try_jwt(state, headers).await? {
return Ok((jar, principal));
}
if let Some(principal) = try_api_key(state, headers, ip).await? {
return Ok((jar, principal));
}
if !state.config.allow_anonymous_upload {
return Err(AppError::new(ErrorCode::Unauthorized, "未登录"));
}
let (jar, session_id) = ensure_session_cookie(jar);
Ok((jar, Principal::Anonymous { session_id }))
}
async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result<Option<Principal>, AppError> {
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
let claims = auth::require_jwt(&state.config.jwt_secret, headers)?;
#[derive(Debug, FromRow)]
struct UserAuthRow {
id: Uuid,
role: String,
is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserAuthRow>(
r#"
SELECT id, role::text AS role, is_active, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
if !user.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
Ok(Some(Principal::User {
user_id: user.id,
role: user.role,
email_verified: user.email_verified_at.is_some(),
}))
}
async fn try_api_key(
state: &AppState,
headers: &HeaderMap,
ip: IpAddr,
) -> Result<Option<Principal>, AppError> {
let key = headers
.get("x-api-key")
.or_else(|| headers.get("X-API-Key"))
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty());
let Some(full_key) = key else {
return Ok(None);
};
let key_prefix = full_key
.get(0..16)
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 格式错误"))?;
#[derive(Debug, FromRow)]
struct ApiKeyAuthRow {
id: Uuid,
user_id: Uuid,
key_hash: String,
is_active: bool,
user_role: String,
user_is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
let row = sqlx::query_as::<_, ApiKeyAuthRow>(
r#"
SELECT
k.id,
k.user_id,
k.key_hash,
k.is_active,
u.role::text AS user_role,
u.is_active AS user_is_active,
u.email_verified_at
FROM api_keys k
JOIN users u ON u.id = k.user_id
WHERE k.key_prefix = $1
"#,
)
.bind(key_prefix)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 无效"))?;
if !row.user_is_active || !row.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "API Key 已禁用"));
}
let expected = api_key_hash(full_key, &state.config.api_key_pepper)?;
if expected != row.key_hash {
return Err(AppError::new(ErrorCode::Unauthorized, "API Key 无效"));
}
let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW(), last_used_ip = $2 WHERE id = $1")
.bind(row.id)
.bind(ip.to_string())
.execute(&state.db)
.await;
Ok(Some(Principal::ApiKey {
user_id: row.user_id,
api_key_id: row.id,
role: row.user_role,
email_verified: row.email_verified_at.is_some(),
}))
}
pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) {
if let Some(cookie) = jar.get("if_session") {
let session_id = cookie.value().trim().to_string();
if !session_id.is_empty() {
return (jar, session_id);
}
}
let session_id = generate_session_id();
let cookie = Cookie::build(("if_session", session_id.clone()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(7))
.build();
(jar.add(cookie), session_id)
}
fn generate_session_id() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn api_key_hash(full_key: &str, pepper: &str) -> Result<String, AppError> {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(pepper.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "API Key pepper 错误").with_source(err))?;
mac.update(full_key.as_bytes());
let result = mac.finalize().into_bytes();
Ok(hex::encode(result))
}

343
src/api/downloads.rs Normal file
View File

@@ -0,0 +1,343 @@
use crate::api::context;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{header, HeaderMap};
use axum::body::Body;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use tokio_util::io::ReaderStream;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/tasks/{task_id}", get(download_task_zip))
.route("/{file_id}", get(download_file))
}
#[derive(Debug, FromRow)]
struct DownloadRow {
storage_path: Option<String>,
output_format: String,
original_name: String,
file_status: String,
task_user_id: Option<Uuid>,
task_session_id: Option<String>,
expires_at: DateTime<Utc>,
}
async fn download_file(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(file_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let row = sqlx::query_as::<_, DownloadRow>(
r#"
SELECT
f.storage_path,
f.output_format,
f.original_name,
f.status::text AS file_status,
t.user_id AS task_user_id,
t.session_id AS task_session_id,
t.expires_at
FROM task_files f
JOIN tasks t ON t.id = f.task_id
WHERE f.id = $1
"#,
)
.bind(file_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "文件不存在"))?;
if row.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "文件已过期或不存在"));
}
if row.file_status != "completed" {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
}
authorize_download(&principal, &row)?;
let Some(path) = &row.storage_path else {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let bytes = tokio::fs::read(path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?;
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
header::CONTENT_TYPE,
content_type(&row.output_format).parse().unwrap(),
);
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", sanitize_filename(&row.original_name))
.parse()
.unwrap(),
);
Ok((jar, (resp_headers, bytes).into_response()))
}
fn authorize_download(principal: &context::Principal, row: &DownloadRow) -> Result<(), AppError> {
if let Some(user_id) = row.task_user_id {
match principal {
context::Principal::User { user_id: me, .. } if *me == user_id => Ok(()),
context::Principal::ApiKey { user_id: me, .. } if *me == user_id => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")),
}
} else {
let expected = row.task_session_id.as_deref().unwrap_or("");
match principal {
context::Principal::Anonymous { session_id } if session_id == expected => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")),
}
}
}
fn content_type(format: &str) -> &'static str {
match format.trim().to_ascii_lowercase().as_str() {
"png" => "image/png",
"jpeg" | "jpg" => "image/jpeg",
"webp" => "image/webp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
fn sanitize_filename(name: &str) -> String {
let mut out = name.trim().to_string();
if out.is_empty() {
out = "download".to_string();
}
out = out.replace(['\r', '\n', '"', '\\'], "_");
if out.len() > 120 {
out.truncate(120);
}
out
}
#[derive(Debug, FromRow)]
struct TaskZipRow {
user_id: Option<Uuid>,
session_id: Option<String>,
status: String,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct TaskZipFileRow {
id: Uuid,
storage_path: Option<String>,
original_name: String,
output_format: String,
}
async fn download_task_zip(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskZipRow>(
r#"
SELECT
user_id,
session_id,
status::text AS status,
completed_at,
expires_at
FROM tasks
WHERE id = $1
"#,
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
if task.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在"));
}
if task.completed_at.is_none() || matches!(task.status.as_str(), "pending" | "processing") {
return Err(AppError::new(ErrorCode::InvalidRequest, "任务尚未完成"));
}
if let Some(user_id) = task.user_id {
match principal {
context::Principal::User { user_id: me, .. } if me == user_id => {}
context::Principal::ApiKey { user_id: me, .. } if me == user_id => {}
_ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")),
}
} else {
let expected = task.session_id.as_deref().unwrap_or("");
match principal {
context::Principal::Anonymous { session_id } if session_id == expected => {}
_ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")),
}
}
if state.config.storage_type.to_ascii_lowercase() != "local" {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let zip_dir = format!("{}/zips", state.config.storage_path);
tokio::fs::create_dir_all(&zip_dir)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?;
let zip_path = PathBuf::from(format!("{zip_dir}/{task_id}.zip"));
if tokio::fs::try_exists(&zip_path).await.unwrap_or(false) {
return stream_zip(jar, zip_path, task_id).await;
}
let rows = sqlx::query_as::<_, TaskZipFileRow>(
r#"
SELECT id, storage_path, original_name, output_format
FROM task_files
WHERE task_id = $1 AND status = 'completed'
ORDER BY created_at ASC
"#,
)
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
if rows.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let mut used_names: HashMap<String, usize> = HashMap::new();
let mut entries: Vec<(String, String)> = Vec::new();
for row in rows {
let Some(path) = row.storage_path else { continue };
let name = build_zip_entry_name(&row.original_name, &row.output_format, &mut used_names);
entries.push((name, path));
}
if entries.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let zip_path_cloned = zip_path.clone();
let task_id_str = task_id.to_string();
tokio::task::spawn_blocking(move || generate_zip_file(&zip_path_cloned, &task_id_str, &entries))
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?;
stream_zip(jar, zip_path, task_id).await
}
async fn stream_zip(
jar: axum_extra::extract::cookie::CookieJar,
zip_path: PathBuf,
task_id: Uuid,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let file = tokio::fs::File::open(&zip_path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取 ZIP 失败").with_source(err))?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(header::CONTENT_TYPE, "application/zip".parse().unwrap());
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"task_{task_id}.zip\"")
.parse()
.unwrap(),
);
Ok((jar, (resp_headers, body).into_response()))
}
fn build_zip_entry_name(
original_name: &str,
output_format: &str,
used: &mut HashMap<String, usize>,
) -> String {
let mut base = sanitize_zip_name(original_name);
if let Some((head, _ext)) = base.rsplit_once('.') {
base = head.to_string();
}
let ext = match output_format.trim().to_ascii_lowercase().as_str() {
"jpeg" | "jpg" => "jpg",
"png" => "png",
"webp" => "webp",
"avif" => "avif",
_ => "bin",
};
let base = if base.is_empty() { "file".to_string() } else { base };
let candidate = format!("{base}.{ext}");
let counter = used.entry(candidate.clone()).or_insert(0);
if *counter == 0 {
*counter = 1;
return candidate;
}
let name = format!("{base} ({counter}).{ext}");
*counter += 1;
name
}
fn sanitize_zip_name(name: &str) -> String {
let mut out = name.trim().to_string();
out = out.replace(['\r', '\n', '"', '\\', '/', ':'], "_");
if out.len() > 120 {
out.truncate(120);
}
out
}
fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, String)]) -> Result<(), String> {
let tmp = PathBuf::from(format!("{}.tmp", zip_path.to_string_lossy()));
let file = std::fs::File::create(&tmp).map_err(|e| format!("create zip: {e}"))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, path) in entries {
zip.start_file(name, options)
.map_err(|e| format!("zip start_file: {e}"))?;
let mut f = std::fs::File::open(path).map_err(|e| format!("open file: {e}"))?;
std::io::copy(&mut f, &mut zip).map_err(|e| format!("copy: {e}"))?;
}
zip.finish().map_err(|e| format!("finish: {e}"))?;
std::fs::rename(&tmp, zip_path).map_err(|e| format!("rename: {e}"))?;
tracing::info!(task_id = %task_id, path = %zip_path.to_string_lossy(), "ZIP generated");
Ok(())
}

8
src/api/envelope.rs Normal file
View File

@@ -0,0 +1,8 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Envelope<T> {
pub success: bool,
pub data: T,
}

39
src/api/health.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::state::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct HealthResponse {
status: &'static str,
database: &'static str,
redis: &'static str,
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
let database_ok = sqlx::query("SELECT 1")
.execute(&state.db)
.await
.is_ok();
let mut redis_conn = state.redis.clone();
let redis_ok = redis::cmd("PING")
.query_async::<_, String>(&mut redis_conn)
.await
.is_ok();
let status = if database_ok && redis_ok {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let body = HealthResponse {
status: if status == StatusCode::OK { "healthy" } else { "unhealthy" },
database: if database_ok { "connected" } else { "unavailable" },
redis: if redis_ok { "connected" } else { "unavailable" },
};
(status, Json(body))
}

66
src/api/mod.rs Normal file
View File

@@ -0,0 +1,66 @@
mod auth;
mod context;
mod envelope;
mod compress;
mod downloads;
mod billing;
mod webhooks;
mod user;
mod tasks;
mod admin;
mod health;
mod response;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
use axum::Router;
use std::net::SocketAddr;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
pub async fn run(state: AppState) -> Result<(), AppError> {
let addr = format!("{}:{}", state.config.host, state.config.port);
if let Err(err) = crate::services::bootstrap::ensure_schema(&state).await {
tracing::error!(error = %err, "数据库结构初始化失败");
}
if let Err(err) = crate::services::bootstrap::ensure_admin_user(&state).await {
tracing::error!(error = %err, "管理员账号初始化失败");
}
let static_service = ServeDir::new("static").not_found_service(ServeFile::new("static/index.html"));
let v1 = v1_router().layer(DefaultBodyLimit::max(100 * 1024 * 1024));
let app = Router::new()
.route("/health", axum::routing::get(health::health))
.nest("/downloads", downloads::router())
.nest("/api/v1", v1)
.fallback_service(static_service)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "监听端口失败").with_source(err))?;
tracing::info!(addr = %addr, "API server listening");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "HTTP 服务异常退出").with_source(err))
}
fn v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.merge(compress::router())
.merge(tasks::router())
.merge(billing::router())
.merge(webhooks::router())
.merge(user::router())
.merge(admin::router())
.fallback(response::not_found)
}

6
src/api/response.rs Normal file
View File

@@ -0,0 +1,6 @@
use crate::error::{AppError, ErrorCode};
pub async fn not_found() -> AppError {
AppError::new(ErrorCode::NotFound, "接口不存在")
}

987
src/api/tasks.rs Normal file
View File

@@ -0,0 +1,987 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::billing::{BillingContext, Plan};
use crate::services::compress;
use crate::services::compress::{CompressionLevel, ImageFmt};
use crate::services::idempotency;
use crate::state::AppState;
use axum::extract::{ConnectInfo, Multipart, Path, State};
use axum::http::HeaderMap;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::net::{IpAddr, SocketAddr};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/compress/batch", post(create_batch_task))
.route("/compress/tasks/{task_id}", get(get_task))
.route("/compress/tasks/{task_id}/cancel", post(cancel_task))
.route("/compress/tasks/{task_id}", delete(delete_task))
}
#[derive(Debug, Serialize, Deserialize)]
struct BatchCreateResponse {
task_id: Uuid,
total_files: i32,
status: String,
status_url: String,
}
#[derive(Debug)]
struct BatchFileInput {
file_id: Uuid,
original_name: String,
original_format: ImageFmt,
output_format: ImageFmt,
original_size: u64,
storage_path: String,
}
#[derive(Debug)]
struct BatchOptions {
level: CompressionLevel,
compression_rate: Option<u8>,
output_format: Option<ImageFmt>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
}
async fn create_batch_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<BatchCreateResponse>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
if state.config.storage_type.to_ascii_lowercase() != "local" {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let idempotency_key = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
let idempotency_scope = match &principal {
context::Principal::User { user_id, .. } => Some(idempotency::Scope::User(*user_id)),
context::Principal::ApiKey { api_key_id, .. } => Some(idempotency::Scope::ApiKey(*api_key_id)),
_ => None,
};
let task_id = Uuid::new_v4();
let (files, opts, request_hash) = parse_batch_request(&state, task_id, &mut multipart).await?;
if files.is_empty() {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "缺少 files[]"));
}
let mut idem_acquired = false;
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
match idempotency::begin(
&state,
scope,
idem_key,
&request_hash,
state.config.idempotency_ttl_hours as i64,
)
.await?
{
idempotency::BeginResult::Replay { response_body, .. } => {
cleanup_file_paths(&files).await;
let resp: BatchCreateResponse =
serde_json::from_value(response_body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok((jar, Json(Envelope { success: true, data: resp })));
}
idempotency::BeginResult::InProgress => {
cleanup_file_paths(&files).await;
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
scope,
idem_key,
&request_hash,
10_000,
)
.await?
{
let resp: BatchCreateResponse =
serde_json::from_value(body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok((jar, Json(Envelope { success: true, data: resp })));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idem_acquired = true;
}
}
}
let create_result: Result<BatchCreateResponse, AppError> = (async {
let (retention, task_owner, source) = match &principal {
context::Principal::Anonymous { session_id } => {
enforce_batch_limits_anonymous(&state, &files)?;
let remaining = anonymous_remaining_units(&state, session_id, ip).await?;
if remaining < files.len() as i64 {
return Err(AppError::new(
ErrorCode::QuotaExceeded,
"匿名试用次数已用完(每日 10 次)",
));
}
Ok((
Duration::hours(state.config.anon_retention_hours as i64),
TaskOwner::Anonymous {
session_id: session_id.clone(),
},
"web",
))
}
context::Principal::User {
user_id,
email_verified,
..
} => {
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let billing = billing::get_user_billing(&state, *user_id).await?;
enforce_batch_limits_plan(&billing.plan, &files)?;
ensure_quota_available(&state, &billing, files.len() as i32).await?;
Ok((
Duration::days(billing.plan.retention_days as i64),
TaskOwner::User { user_id: *user_id },
"web",
))
}
context::Principal::ApiKey {
user_id,
api_key_id,
email_verified,
..
} => {
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let billing = billing::get_user_billing(&state, *user_id).await?;
if !billing.plan.feature_api_enabled {
return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API"));
}
enforce_batch_limits_plan(&billing.plan, &files)?;
ensure_quota_available(&state, &billing, files.len() as i32).await?;
Ok((
Duration::days(billing.plan.retention_days as i64),
TaskOwner::ApiKey {
user_id: *user_id,
api_key_id: *api_key_id,
},
"api",
))
}
}?;
tokio::fs::create_dir_all(&state.config.storage_path)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let expires_at = Utc::now() + retention;
let (user_id, session_id, api_key_id) = match &task_owner {
TaskOwner::Anonymous { session_id } => (None, Some(session_id.clone()), None),
TaskOwner::User { user_id } => (Some(*user_id), None, None),
TaskOwner::ApiKey { user_id, api_key_id } => (Some(*user_id), None, Some(*api_key_id)),
};
let total_original_size: i64 = files.iter().map(|f| f.original_size as i64).sum();
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
sqlx::query(
r#"
INSERT INTO tasks (
id, user_id, session_id, api_key_id, client_ip, source, status,
compression_level, output_format, max_width, max_height, preserve_metadata,
compression_rate,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
expires_at
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
$7::compression_level, $8, $9, $10, $11, $12,
$13, 0, 0,
$14, 0,
$15
)
"#,
)
.bind(task_id)
.bind(user_id)
.bind(session_id)
.bind(api_key_id)
.bind(ip.to_string())
.bind(source)
.bind(opts.level.as_str())
.bind(opts.output_format.map(|f| f.as_str()))
.bind(opts.max_width.map(|v| v as i32))
.bind(opts.max_height.map(|v| v as i32))
.bind(false)
.bind(opts.compression_rate.map(|v| v as i16))
.bind(files.len() as i32)
.bind(total_original_size)
.bind(expires_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?;
for file in &files {
sqlx::query(
r#"
INSERT INTO task_files (
id, task_id,
original_name, original_format, output_format,
original_size,
storage_path, status
) VALUES (
$1, $2,
$3, $4, $5,
$6,
$7, 'pending'
)
"#,
)
.bind(file.file_id)
.bind(task_id)
.bind(&file.original_name)
.bind(file.original_format.as_str())
.bind(file.output_format.as_str())
.bind(file.original_size as i64)
.bind(&file.storage_path)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?;
}
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
if let Err(err) = enqueue_task(&state, task_id).await {
let _ = sqlx::query("UPDATE tasks SET status = 'failed', error_message = $2 WHERE id = $1")
.bind(task_id)
.bind("队列提交失败")
.execute(&state.db)
.await;
return Err(err);
}
Ok(BatchCreateResponse {
task_id,
total_files: files.len() as i32,
status: "pending".to_string(),
status_url: format!("/api/v1/compress/tasks/{task_id}"),
})
})
.await;
match create_result {
Ok(resp) => {
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
if idem_acquired {
let _ = idempotency::complete(
&state,
scope,
idem_key,
&request_hash,
200,
serde_json::to_value(&resp).unwrap_or(serde_json::Value::Null),
)
.await;
}
}
Ok((jar, Json(Envelope { success: true, data: resp })))
}
Err(err) => {
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
if idem_acquired {
let _ = idempotency::abort(&state, scope, idem_key, &request_hash).await;
}
}
cleanup_file_paths(&files).await;
Err(err)
}
}
}
#[derive(Debug)]
enum TaskOwner {
Anonymous { session_id: String },
User { user_id: Uuid },
ApiKey { user_id: Uuid, api_key_id: Uuid },
}
async fn enqueue_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
let mut conn = state.redis.clone();
let now = Utc::now().to_rfc3339();
redis::cmd("XADD")
.arg("stream:compress_jobs")
.arg("*")
.arg("task_id")
.arg(task_id.to_string())
.arg("created_at")
.arg(now)
.query_async::<_, redis::Value>(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "写入队列失败").with_source(err))?;
Ok(())
}
async fn parse_batch_request(
state: &AppState,
task_id: Uuid,
multipart: &mut Multipart,
) -> Result<(Vec<BatchFileInput>, BatchOptions, String), AppError> {
let mut files: Vec<BatchFileInput> = Vec::new();
let mut file_digests: Vec<String> = Vec::new();
let mut opts = BatchOptions {
level: CompressionLevel::Medium,
compression_rate: None,
output_format: None,
max_width: None,
max_height: None,
preserve_metadata: false,
};
let base_dir = format!("{}/orig/{task_id}", state.config.storage_path);
tokio::fs::create_dir_all(&base_dir)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?;
loop {
let next = multipart.next_field().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err)
});
let field = match next {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
};
let Some(field) = field else { break };
let name = field.name().unwrap_or("").to_string();
if name == "files" || name == "files[]" {
let file_id = Uuid::new_v4();
let original_name = field.file_name().unwrap_or("upload").to_string();
let bytes = match field.bytes().await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err)
);
}
};
let original_size = bytes.len() as u64;
let file_digest = {
let mut h = Sha256::new();
h.update(&bytes);
h.update(original_name.as_bytes());
hex::encode(h.finalize())
};
let original_format = match compress::detect_format(&bytes) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
};
let output_format = opts.output_format.unwrap_or(original_format);
let path = format!("{base_dir}/{file_id}.{}", original_format.extension());
let mut f = match tokio::fs::File::create(&path).await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err),
);
}
};
if let Err(err) = f.write_all(&bytes).await {
let _ = tokio::fs::remove_file(&path).await;
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err),
);
}
files.push(BatchFileInput {
file_id,
original_name,
original_format,
output_format,
original_size,
storage_path: path,
});
file_digests.push(file_digest);
continue;
}
let text = match field.text().await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err),
);
}
};
match name.as_str() {
"level" => {
opts.level = match compress::parse_level(&text) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
}
}
"output_format" => {
let v = text.trim();
if !v.is_empty() {
opts.output_format = Some(match compress::parse_output_format(v) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
});
}
}
"compression_rate" | "quality" => {
let v = text.trim();
if !v.is_empty() {
opts.compression_rate = Some(match compress::parse_compression_rate(v) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
});
}
}
"max_width" => {
let v = text.trim();
if !v.is_empty() {
opts.max_width = Some(match v.parse::<u32>() {
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"));
}
});
}
}
"max_height" => {
let v = text.trim();
if !v.is_empty() {
opts.max_height = Some(match v.parse::<u32>() {
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误"));
}
});
}
}
"preserve_metadata" => {
opts.preserve_metadata = matches!(
text.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "y" | "on"
);
}
_ => {}
}
}
if let Some(rate) = opts.compression_rate {
opts.level = compress::rate_to_level(rate);
}
if opts.output_format.is_some() {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"当前仅支持保持原图片格式",
));
}
let mw = opts.max_width.map(|v| v.to_string()).unwrap_or_default();
let mh = opts.max_height.map(|v| v.to_string()).unwrap_or_default();
let out_fmt = opts.output_format.map(|f| f.as_str()).unwrap_or("");
let rate_key = opts
.compression_rate
.map(|v| v.to_string())
.unwrap_or_default();
let preserve = if opts.preserve_metadata { "1" } else { "0" };
let mut h = Sha256::new();
h.update(b"compress_batch_v1");
h.update(opts.level.as_str().as_bytes());
h.update(out_fmt.as_bytes());
h.update(rate_key.as_bytes());
h.update(mw.as_bytes());
h.update(mh.as_bytes());
h.update(preserve.as_bytes());
for d in &file_digests {
h.update(d.as_bytes());
}
let request_hash = hex::encode(h.finalize());
Ok((files, opts, request_hash))
}
fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> Result<(), AppError> {
let max_files = state.config.anon_max_files_per_batch as usize;
if files.len() > max_files {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("匿名试用单次最多 {} 个文件", max_files),
));
}
let max_bytes = state.config.anon_max_file_size_mb * 1024 * 1024;
for f in files {
if f.original_size > max_bytes {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb),
));
}
}
Ok(())
}
fn enforce_batch_limits_plan(plan: &Plan, files: &[BatchFileInput]) -> Result<(), AppError> {
let max_files = plan.max_files_per_batch as usize;
if files.len() > max_files {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("当前套餐单次最多 {} 个文件", plan.max_files_per_batch),
));
}
let max_bytes = (plan.max_file_size_mb as u64) * 1024 * 1024;
for f in files {
if f.original_size > max_bytes {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("当前套餐单文件最大 {} MB", plan.max_file_size_mb),
));
}
}
Ok(())
}
async fn ensure_quota_available(
state: &AppState,
ctx: &BillingContext,
needed_units: i32,
) -> Result<(), AppError> {
if needed_units <= 0 {
return Ok(());
}
#[derive(Debug, FromRow)]
struct UsageRow {
used_units: i32,
bonus_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
r#"
SELECT used_units, bonus_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(ctx.user_id)
.bind(ctx.period_start)
.bind(ctx.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsageRow {
used_units: 0,
bonus_units: 0,
});
let total_units = ctx.plan.included_units_per_period + usage.bonus_units;
let remaining = total_units - usage.used_units;
if remaining < needed_units {
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
}
Ok(())
}
async fn anonymous_remaining_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
) -> Result<i64, AppError> {
let date = utc8_date();
let session_key = format!("anon_quota:{session_id}:{date}");
let ip_key = format!("anon_quota_ip:{ip}:{date}");
let mut conn = state.redis.clone();
let v1: Option<i64> = redis::cmd("GET")
.arg(session_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let v2: Option<i64> = redis::cmd("GET")
.arg(ip_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let limit = state.config.anon_daily_units as i64;
Ok(std::cmp::min(limit - v1.unwrap_or(0), limit - v2.unwrap_or(0)))
}
fn utc8_date() -> String {
let now = Utc::now() + Duration::hours(8);
now.format("%Y-%m-%d").to_string()
}
#[derive(Debug, FromRow)]
struct TaskRow {
id: Uuid,
status: String,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
user_id: Option<Uuid>,
session_id: Option<String>,
}
#[derive(Debug, FromRow)]
struct TaskFileRow {
id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
}
#[derive(Debug, Serialize)]
struct TaskFileView {
file_id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
download_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct TaskView {
task_id: Uuid,
status: String,
progress: i32,
total_files: i32,
completed_files: i32,
failed_files: i32,
files: Vec<TaskFileView>,
download_all_url: String,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
async fn get_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<TaskView>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at,
user_id,
session_id
FROM tasks
WHERE id = $1
"#,
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
if task.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在"));
}
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
let files = sqlx::query_as::<_, TaskFileRow>(
r#"
SELECT
id,
original_name,
original_size,
compressed_size,
saved_percent::float8 AS saved_percent,
status::text AS status
FROM task_files
WHERE task_id = $1
ORDER BY created_at ASC
"#,
)
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
let file_views = files
.into_iter()
.map(|f| TaskFileView {
file_id: f.id,
original_name: f.original_name,
original_size: f.original_size,
compressed_size: f.compressed_size,
saved_percent: f.saved_percent,
status: f.status.clone(),
download_url: if f.status == "completed" {
Some(format!("/downloads/{}", f.id))
} else {
None
},
})
.collect::<Vec<_>>();
let processed = task.completed_files + task.failed_files;
let progress = if task.total_files <= 0 {
0
} else {
((processed as f64) * 100.0 / (task.total_files as f64))
.round()
.clamp(0.0, 100.0) as i32
};
Ok((
jar,
Json(Envelope {
success: true,
data: TaskView {
task_id,
status: task.status,
progress,
total_files: task.total_files,
completed_files: task.completed_files,
failed_files: task.failed_files,
files: file_views,
download_all_url: format!("/downloads/tasks/{task_id}"),
created_at: task.created_at,
completed_at: task.completed_at,
expires_at: task.expires_at,
},
}),
))
}
async fn cancel_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<serde_json::Value>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskRow>(
"SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") {
return Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "任务已结束" }),
}),
));
}
let updated = sqlx::query(
"UPDATE tasks SET status = 'cancelled', completed_at = NOW() WHERE id = $1 AND status IN ('pending', 'processing')",
)
.bind(task_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "取消任务失败").with_source(err))?;
if updated.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::InvalidRequest, "任务状态不可取消"));
}
Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已取消" }),
}),
))
}
async fn delete_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<serde_json::Value>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskRow>(
"SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
if task.status == "processing" {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"任务处理中,请先取消后再删除",
));
}
let paths: Vec<Option<String>> =
sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1")
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?;
for p in paths.into_iter().flatten() {
let _ = tokio::fs::remove_file(p).await;
}
if state.config.storage_type.to_ascii_lowercase() == "local" {
let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
}
let deleted = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(task_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "删除任务失败").with_source(err))?;
if deleted.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::NotFound, "任务不存在"));
}
Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已删除" }),
}),
))
}
fn authorize_task(
principal: &context::Principal,
user_id: Option<Uuid>,
session_id: &str,
) -> Result<(), AppError> {
if let Some(owner) = user_id {
match principal {
context::Principal::User { user_id: me, .. } if *me == owner => Ok(()),
context::Principal::ApiKey { user_id: me, .. } if *me == owner => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")),
}
} else {
match principal {
context::Principal::Anonymous { session_id: sid } if sid == session_id => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")),
}
}
}
async fn cleanup_file_paths(files: &[BatchFileInput]) {
for f in files {
let _ = tokio::fs::remove_file(&f.storage_path).await;
}
}

912
src/api/user.rs Normal file
View File

@@ -0,0 +1,912 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::mail;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::HeaderMap;
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::net::SocketAddr;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/user/profile", get(get_profile))
.route("/user/profile", put(update_profile))
.route("/user/password", put(update_password))
.route("/user/history", get(list_history))
.route("/user/api-keys", get(list_api_keys))
.route("/user/api-keys", post(create_api_key))
.route("/user/api-keys/{key_id}/rotate", post(rotate_api_key))
.route("/user/api-keys/{key_id}", delete(disable_api_key))
}
#[derive(Debug, FromRow, Serialize)]
struct ApiKeyView {
id: Uuid,
name: String,
key_prefix: String,
permissions: serde_json::Value,
rate_limit: i32,
is_active: bool,
last_used_at: Option<chrono::DateTime<chrono::Utc>>,
last_used_ip: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
struct ApiKeyListResponse {
api_keys: Vec<ApiKeyView>,
}
#[derive(Debug, Serialize)]
struct UserView {
id: Uuid,
email: String,
username: String,
role: String,
email_verified: bool,
}
#[derive(Debug, Serialize)]
struct MessageResponse {
message: String,
}
async fn get_profile(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<UserView>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
#[derive(Debug, FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
role: String,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT id, email, username, role::text AS role, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdateProfileRequest {
email: Option<String>,
username: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateProfileResponse {
user: UserView,
message: String,
}
async fn update_profile(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<Envelope<UpdateProfileResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
if req.email.is_none() && req.username.is_none() {
return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段"));
}
#[derive(Debug, FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
role: String,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT id, email, username, role::text AS role, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let mut next_email = user.email.clone();
let mut next_username = user.username.clone();
let mut email_changed = false;
if let Some(email) = req.email.as_ref() {
let email = email.trim().to_lowercase();
validate_email(&email)?;
if email != user.email {
next_email = email;
email_changed = true;
}
}
if let Some(username) = req.username.as_ref() {
let username = username.trim().to_string();
validate_username(&username)?;
if username != user.username {
next_username = username;
}
}
if next_email == user.email && next_username == user.username {
return Ok(Json(Envelope {
success: true,
data: UpdateProfileResponse {
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
message: "暂无更新".to_string(),
},
}));
}
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let email_verified_at = if email_changed { None } else { user.email_verified_at };
let updated = sqlx::query_as::<_, UserRow>(
r#"
UPDATE users
SET email = $2,
username = $3,
email_verified_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, email, username, role::text AS role, email_verified_at
"#,
)
.bind(user_id)
.bind(&next_email)
.bind(&next_username)
.bind(email_verified_at)
.fetch_one(&mut *tx)
.await
.map_err(map_unique_violation)?;
let mut verification_link: Option<String> = None;
if email_changed {
let token = generate_token();
let token_hash = sha256_hex(&token);
let expires_at = Utc::now() + Duration::hours(24);
sqlx::query(
r#"
INSERT INTO email_verifications (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
verification_link = Some(format!(
"{}/verify-email?token={}",
state.config.public_base_url, token
));
}
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
if let Some(link) = verification_link.as_deref() {
mail::send_verification_email(&state, &updated.email, &updated.username, link)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
}
let message = if email_changed {
"资料已更新,请验证新邮箱".to_string()
} else {
"资料已更新".to_string()
};
Ok(Json(Envelope {
success: true,
data: UpdateProfileResponse {
user: UserView {
id: updated.id,
email: updated.email,
username: updated.username,
role: updated.role,
email_verified: updated.email_verified_at.is_some(),
},
message,
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdatePasswordRequest {
current_password: String,
new_password: String,
}
async fn update_password(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<UpdatePasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
validate_password(&req.new_password)?;
#[derive(Debug, FromRow)]
struct PasswordRow {
password_hash: String,
}
let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
verify_password(&req.current_password, &row.password_hash)?;
let new_hash = hash_password(&req.new_password)?;
sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1")
.bind(user_id)
.bind(new_hash)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "密码已更新,请重新登录以确保安全".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
page: Option<u32>,
limit: Option<u32>,
status: Option<String>,
}
#[derive(Debug, Serialize)]
struct HistoryFileView {
file_id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
output_format: String,
error_message: Option<String>,
download_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct HistoryTaskView {
task_id: Uuid,
status: String,
source: String,
progress: i32,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
download_all_url: Option<String>,
files: Vec<HistoryFileView>,
}
#[derive(Debug, Serialize)]
struct HistoryResponse {
tasks: Vec<HistoryTaskView>,
page: u32,
limit: u32,
total: i64,
}
async fn list_history(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(query): Query<HistoryQuery>,
) -> Result<Json<Envelope<HistoryResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let status = query.status.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let total: i64 = if let Some(status) = &status {
sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status::text = $2",
)
.bind(user_id)
.bind(status)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
} else {
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE user_id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
};
#[derive(Debug, FromRow)]
struct TaskRow {
id: Uuid,
status: String,
source: String,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
let tasks: Vec<TaskRow> = if let Some(status) = &status {
sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
source::text AS source,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at
FROM tasks
WHERE user_id = $1 AND status::text = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(user_id)
.bind(status)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
} else {
sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
source::text AS source,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at
FROM tasks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
};
#[derive(Debug, FromRow)]
struct FileRow {
id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
output_format: String,
error_message: Option<String>,
storage_path: Option<String>,
}
let now = Utc::now();
let mut result_tasks = Vec::with_capacity(tasks.len());
for task in tasks {
let files: Vec<FileRow> = sqlx::query_as::<_, FileRow>(
r#"
SELECT
id,
original_name,
original_size,
compressed_size,
saved_percent::float8 AS saved_percent,
status::text AS status,
output_format,
error_message,
storage_path
FROM task_files
WHERE task_id = $1
ORDER BY created_at ASC
"#,
)
.bind(task.id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
let file_views = files
.into_iter()
.map(|file| HistoryFileView {
file_id: file.id,
original_name: file.original_name,
original_size: file.original_size,
compressed_size: file.compressed_size,
saved_percent: file.saved_percent,
status: file.status.clone(),
output_format: file.output_format,
error_message: file.error_message,
download_url: if file.status == "completed" && file.storage_path.is_some() && task.expires_at > now {
Some(format!("/downloads/{}", file.id))
} else {
None
},
})
.collect::<Vec<_>>();
let progress = if task.total_files > 0 {
((task.completed_files + task.failed_files) * 100 / task.total_files).clamp(0, 100)
} else {
0
};
let download_all_url = if task.status == "completed" && task.expires_at > now {
Some(format!("/downloads/tasks/{}", task.id))
} else {
None
};
result_tasks.push(HistoryTaskView {
task_id: task.id,
status: task.status,
source: task.source,
progress,
total_files: task.total_files,
completed_files: task.completed_files,
failed_files: task.failed_files,
created_at: task.created_at,
completed_at: task.completed_at,
expires_at: task.expires_at,
download_all_url,
files: file_views,
});
}
Ok(Json(Envelope {
success: true,
data: HistoryResponse {
tasks: result_tasks,
page,
limit,
total,
},
}))
}
async fn list_api_keys(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<ApiKeyListResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, _email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let rows = sqlx::query_as::<_, ApiKeyView>(
r#"
SELECT
id,
name,
key_prefix,
permissions,
rate_limit,
is_active,
last_used_at,
last_used_ip::text AS last_used_ip,
created_at
FROM api_keys
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: ApiKeyListResponse { api_keys: rows },
}))
}
#[derive(Debug, Deserialize)]
struct CreateApiKeyRequest {
name: String,
permissions: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct CreateApiKeyResponse {
id: Uuid,
name: String,
key_prefix: String,
key: String,
message: String,
}
async fn create_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
if req.name.trim().is_empty() || req.name.len() > 100 {
return Err(AppError::new(ErrorCode::InvalidRequest, "name 不合法"));
}
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let billing = billing::get_user_billing(&state, user_id).await?;
if !billing.plan.feature_api_enabled {
return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key"));
}
let permissions = normalize_permissions(req.permissions)?;
let (full_key, key_prefix) = generate_api_key();
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
let row_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, permissions, rate_limit)
VALUES ($1, $2, $3, $4, $5, 100)
RETURNING id
"#,
)
.bind(user_id)
.bind(req.name.trim())
.bind(&key_prefix)
.bind(key_hash)
.bind(&permissions)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建 API Key 失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: CreateApiKeyResponse {
id: row_id,
name: req.name.trim().to_string(),
key_prefix,
key: full_key,
message: "请保存此 Key它只会显示一次".to_string(),
},
}))
}
async fn disable_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(key_id): Path<Uuid>,
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let result = sqlx::query("UPDATE api_keys SET is_active = false WHERE id = $1 AND user_id = $2")
.bind(key_id)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?;
if result.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::NotFound, "API Key 不存在"));
}
Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已禁用" }),
}))
}
async fn rotate_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(key_id): Path<Uuid>,
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let (full_key, key_prefix) = generate_api_key();
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
#[derive(Debug, FromRow)]
struct RotateRow {
id: Uuid,
name: String,
}
let row = sqlx::query_as::<_, RotateRow>(
r#"
UPDATE api_keys
SET key_prefix = $1,
key_hash = $2,
is_active = true
WHERE id = $3 AND user_id = $4
RETURNING id, name
"#,
)
.bind(&key_prefix)
.bind(key_hash)
.bind(key_id)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "API Key 不存在"))?;
Ok(Json(Envelope {
success: true,
data: CreateApiKeyResponse {
id: row.id,
name: row.name,
key_prefix,
key: full_key,
message: "请保存此 Key它只会显示一次".to_string(),
},
}))
}
fn generate_api_key() -> (String, String) {
let mut prefix_bytes = [0u8; 4];
rand::rngs::OsRng.fill_bytes(&mut prefix_bytes);
let prefix = hex::encode(prefix_bytes);
let key_prefix = format!("if_live_{prefix}");
let mut secret_bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret_bytes);
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
let full = format!("{key_prefix}_{secret}");
(full, key_prefix)
}
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"];
let mut perms = Vec::<String>::new();
if let Some(values) = input {
for value in values {
let v = value.trim().to_ascii_lowercase();
if v.is_empty() {
continue;
}
if !allowed.contains(&v.as_str()) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("不支持的权限: {v}"),
));
}
if !perms.contains(&v) {
perms.push(v);
}
}
}
if perms.is_empty() {
perms.push("compress".to_string());
}
Ok(serde_json::json!(perms))
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
}
Ok(())
}
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
}
Ok(())
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
let hashed = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?;
Ok(hashed.to_string())
}
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
let parsed = PasswordHash::new(password_hash)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "密码错误"))?;
Ok(())
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn sha256_hex(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn map_unique_violation(err: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &err {
if let Some(code) = db_err.code() {
if code == "23505" {
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
}
}
}
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
}

520
src/api/webhooks.rs Normal file
View File

@@ -0,0 +1,520 @@
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::settings;
use crate::state::AppState;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::routing::post;
use axum::{Json, Router};
use chrono::{TimeZone, Utc};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
pub fn router() -> Router<AppState> {
Router::new().route("/webhooks/stripe", post(stripe_webhook))
}
#[derive(Debug, Deserialize)]
struct StripeEvent {
id: String,
#[serde(rename = "type")]
type_: String,
data: StripeEventData,
}
#[derive(Debug, Deserialize)]
struct StripeEventData {
object: serde_json::Value,
}
async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
let secret = settings::get_stripe_webhook_secret(&state)
.await
.map_err(|err| err.with_source("stripe webhook secret not configured"))?;
let sig = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 Stripe-Signature"))?;
verify_stripe_signature(&body, sig, &secret)?;
let payload_str = std::str::from_utf8(&body)
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "Webhook payload 非 UTF-8"))?;
let event: StripeEvent = serde_json::from_str(payload_str)
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "Webhook JSON 解析失败").with_source(err))?;
let inserted: Option<String> = sqlx::query_scalar(
r#"
INSERT INTO webhook_events (provider, provider_event_id, event_type, payload)
VALUES ('stripe', $1, $2, $3)
ON CONFLICT (provider, provider_event_id) DO NOTHING
RETURNING provider_event_id
"#,
)
.bind(&event.id)
.bind(&event.type_)
.bind(&event.data.object)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 入库失败").with_source(err))?;
if inserted.is_none() {
return Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "duplicate" }),
}));
}
if let Err(err) = process_stripe_event(&state, &event).await {
let _ = sqlx::query(
"UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
)
.bind(&event.id)
.bind(err.to_string())
.execute(&state.db)
.await;
return Err(err);
}
let _ = sqlx::query(
"UPDATE webhook_events SET status = 'processed', processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
)
.bind(&event.id)
.execute(&state.db)
.await;
Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "ok" }),
}))
}
fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Result<(), AppError> {
let mut timestamp: Option<i64> = None;
let mut signatures = Vec::<String>::new();
for part in sig_header.split(',') {
let part = part.trim();
if let Some(v) = part.strip_prefix("t=") {
timestamp = v.parse::<i64>().ok();
} else if let Some(v) = part.strip_prefix("v1=") {
signatures.push(v.to_string());
}
}
let Some(ts) = timestamp else {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t"));
};
if signatures.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1"));
}
// 5 minutes tolerance
let now = Utc::now().timestamp();
if (now - ts).abs() > 300 {
return Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 时间戳过期"));
}
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook secret 错误").with_source(err))?;
mac.update(ts.to_string().as_bytes());
mac.update(b".");
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
if signatures.iter().any(|sig| secure_eq(sig, &expected)) {
Ok(())
} else {
Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 验签失败"))
}
}
fn secure_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut out = 0u8;
for (x, y) in a.as_bytes().iter().zip(b.as_bytes().iter()) {
out |= x ^ y;
}
out == 0
}
async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<(), AppError> {
match event.type_.as_str() {
"checkout.session.completed" => {
map_checkout_session_completed(state, &event.data.object).await
}
"customer.subscription.created" | "customer.subscription.updated" => {
upsert_subscription(state, &event.data.object).await
}
"customer.subscription.deleted" => cancel_subscription(state, &event.data.object).await,
"invoice.paid" | "invoice.payment_failed" => upsert_invoice(state, &event.data.object).await,
_ => Ok(()),
}
}
async fn map_checkout_session_completed(
state: &AppState,
object: &serde_json::Value,
) -> Result<(), AppError> {
let customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty());
let user_id = object
.get("client_reference_id")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
.or_else(|| {
object
.pointer("/metadata/user_id")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
});
let Some(customer_id) = customer_id else {
tracing::warn!("checkout.session.completed missing customer");
return Ok(());
};
let Some(user_id) = user_id else {
tracing::warn!(customer = %customer_id, "checkout.session.completed missing user_id");
return Ok(());
};
let updated = sqlx::query(
r#"
UPDATE users
SET billing_customer_id = $2,
updated_at = NOW()
WHERE id = $1
AND (billing_customer_id IS NULL OR billing_customer_id = '')
"#,
)
.bind(user_id)
.bind(customer_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?;
if updated.rows_affected() == 0 {
let existing: Option<String> = sqlx::query_scalar::<_, Option<String>>(
"SELECT billing_customer_id FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.flatten();
if let Some(existing) = existing.filter(|v| !v.trim().is_empty()) {
if existing != customer_id {
tracing::warn!(
user_id = %user_id,
existing_customer = %existing,
new_customer = %customer_id,
"user already mapped to different stripe customer"
);
}
} else {
tracing::warn!(user_id = %user_id, "user not found for checkout.session.completed");
}
}
Ok(())
}
async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_subscription_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
let provider_customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.customer 缺失"))?;
let status = object
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("incomplete");
let mapped_status = map_subscription_status(status);
let cps = object
.get("current_period_start")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let cpe = object
.get("current_period_end")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let current_period_start = Utc.timestamp_opt(cps, 0).single().unwrap_or_else(Utc::now);
let current_period_end = Utc.timestamp_opt(cpe, 0).single().unwrap_or_else(Utc::now);
let cancel_at_period_end = object
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let price_id = object
.pointer("/items/data/0/price/id")
.and_then(|v| v.as_str())
.or_else(|| object.pointer("/items/data/0/plan/id").and_then(|v| v.as_str()))
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.price 缺失"))?;
let user_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
.bind(provider_customer_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(user_id) = user_id else {
tracing::warn!(customer = %provider_customer_id, "stripe customer not mapped to user");
return Ok(());
};
let plan_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM plans WHERE stripe_price_id = $1 LIMIT 1")
.bind(price_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
let Some(plan_id) = plan_id else {
tracing::warn!(price = %price_id, "stripe price not mapped to plan");
return Ok(());
};
let updated: Option<uuid::Uuid> = sqlx::query_scalar(
r#"
UPDATE subscriptions
SET user_id = $1,
plan_id = $2,
status = $3::subscription_status,
current_period_start = $4,
current_period_end = $5,
cancel_at_period_end = $6,
provider = 'stripe',
provider_customer_id = $7,
updated_at = NOW()
WHERE provider = 'stripe' AND provider_subscription_id = $8
RETURNING id
"#,
)
.bind(user_id)
.bind(plan_id)
.bind(mapped_status)
.bind(current_period_start)
.bind(current_period_end)
.bind(cancel_at_period_end)
.bind(provider_customer_id)
.bind(provider_subscription_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新订阅失败").with_source(err))?;
if updated.is_none() {
let _ = sqlx::query(
r#"
INSERT INTO subscriptions (
user_id, plan_id, status,
current_period_start, current_period_end,
cancel_at_period_end,
provider, provider_customer_id, provider_subscription_id
) VALUES (
$1, $2, $3::subscription_status,
$4, $5,
$6,
'stripe', $7, $8
)
"#,
)
.bind(user_id)
.bind(plan_id)
.bind(mapped_status)
.bind(current_period_start)
.bind(current_period_end)
.bind(cancel_at_period_end)
.bind(provider_customer_id)
.bind(provider_subscription_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?;
}
Ok(())
}
async fn cancel_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_subscription_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
let _ = sqlx::query(
r#"
UPDATE subscriptions
SET status = 'canceled',
cancel_at_period_end = false,
canceled_at = NOW(),
updated_at = NOW()
WHERE provider = 'stripe' AND provider_subscription_id = $1
"#,
)
.bind(provider_subscription_id)
.execute(&state.db)
.await;
Ok(())
}
async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_invoice_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.id 缺失"))?;
let provider_customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.customer 缺失"))?;
let user_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
.bind(provider_customer_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(user_id) = user_id else {
return Ok(());
};
let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open");
let status = map_invoice_status(stripe_status);
let invoice_number = object
.get("number")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.map(|v| v.to_string())
.unwrap_or_else(|| format!("stripe_{provider_invoice_id}"));
let currency = object
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("cny")
.to_uppercase();
let total_amount_cents = object.get("total").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let hosted_invoice_url = object.get("hosted_invoice_url").and_then(|v| v.as_str()).map(|v| v.to_string());
let pdf_url = object.get("invoice_pdf").and_then(|v| v.as_str()).map(|v| v.to_string());
let period_start = object.get("period_start").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let period_end = object.get("period_end").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let paid_at = object
.pointer("/status_transitions/paid_at")
.and_then(|v| v.as_i64())
.and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let updated = sqlx::query(
r#"
UPDATE invoices
SET status = $1::invoice_status,
currency = $2,
total_amount_cents = $3,
hosted_invoice_url = $4,
pdf_url = $5,
period_start = $6,
period_end = $7,
paid_at = $8
WHERE provider = 'stripe' AND provider_invoice_id = $9
"#,
)
.bind(status)
.bind(&currency)
.bind(total_amount_cents)
.bind(hosted_invoice_url.as_deref())
.bind(pdf_url.as_deref())
.bind(period_start)
.bind(period_end)
.bind(paid_at)
.bind(provider_invoice_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新发票失败").with_source(err))?;
if updated.rows_affected() == 0 {
let invoice_number = truncate(invoice_number, 50);
let _ = sqlx::query(
r#"
INSERT INTO invoices (
user_id, invoice_number, status, currency, total_amount_cents,
period_start, period_end,
provider, provider_invoice_id, hosted_invoice_url, pdf_url,
paid_at
) VALUES (
$1, $2, $3::invoice_status, $4, $5,
$6, $7,
'stripe', $8, $9, $10,
$11
)
"#,
)
.bind(user_id)
.bind(invoice_number)
.bind(status)
.bind(&currency)
.bind(total_amount_cents)
.bind(period_start)
.bind(period_end)
.bind(provider_invoice_id)
.bind(hosted_invoice_url.as_deref())
.bind(pdf_url.as_deref())
.bind(paid_at)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建发票失败").with_source(err))?;
}
Ok(())
}
fn truncate(mut s: String, max: usize) -> String {
if s.len() > max {
s.truncate(max);
}
s
}
fn map_subscription_status(status: &str) -> &'static str {
match status {
"trialing" => "trialing",
"active" => "active",
"past_due" => "past_due",
"canceled" => "canceled",
_ => "incomplete",
}
}
fn map_invoice_status(status: &str) -> &'static str {
match status {
"draft" => "draft",
"paid" => "paid",
"void" => "void",
"uncollectible" => "uncollectible",
_ => "open",
}
}

60
src/auth.rs Normal file
View File

@@ -0,0 +1,60 @@
use crate::error::{AppError, ErrorCode};
use axum::http::HeaderMap;
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: Uuid,
pub role: String,
pub exp: usize,
}
pub fn issue_jwt(
jwt_secret: &str,
jwt_expiry_hours: i64,
user_id: Uuid,
role: &str,
) -> Result<(String, DateTime<Utc>), AppError> {
let expires_at = Utc::now() + Duration::hours(jwt_expiry_hours);
let claims = Claims {
sub: user_id,
role: role.to_string(),
exp: expires_at.timestamp() as usize,
};
let token = jsonwebtoken::encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_secret.as_bytes()),
)
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 Token 失败").with_source(err))?;
Ok((token, expires_at))
}
pub fn require_jwt(jwt_secret: &str, headers: &HeaderMap) -> Result<Claims, AppError> {
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth.strip_prefix("Bearer ").ok_or_else(|| {
AppError::new(ErrorCode::Unauthorized, "缺少 Authorization: Bearer <token>")
})?;
decode_jwt(jwt_secret, token)
}
pub fn decode_jwt(jwt_secret: &str, token: &str) -> Result<Claims, AppError> {
jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "Token 无效或已过期"))
}

161
src/config.rs Normal file
View File

@@ -0,0 +1,161 @@
use crate::error::{AppError, ErrorCode};
#[derive(Debug, Clone)]
pub struct Config {
pub role: String,
pub host: String,
pub port: u16,
pub public_base_url: String,
pub database_url: String,
pub database_max_connections: u32,
pub redis_url: String,
pub jwt_secret: String,
pub jwt_expiry_hours: i64,
pub api_key_pepper: String,
pub billing_provider: String,
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub storage_type: String,
pub storage_path: String,
pub signed_url_ttl_minutes: u64,
pub allow_anonymous_upload: bool,
pub anon_max_file_size_mb: u64,
pub anon_max_files_per_batch: u32,
pub anon_daily_units: u32,
pub anon_retention_hours: u64,
pub max_image_pixels: u64,
pub idempotency_ttl_hours: u64,
pub mail_enabled: bool,
pub mail_log_links_when_disabled: bool,
pub mail_provider: String,
pub mail_from: String,
pub mail_password: String,
pub mail_from_name: String,
pub mail_smtp_host: Option<String>,
pub mail_smtp_port: Option<u16>,
pub mail_smtp_encryption: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self, AppError> {
let role = env_string("IMAGEFORGE_ROLE").unwrap_or_else(|| "api".to_string());
let host = env_string("HOST").unwrap_or_else(|| "0.0.0.0".to_string());
let port = env_u16("PORT").unwrap_or(8080);
let public_base_url =
env_string("PUBLIC_BASE_URL").unwrap_or_else(|| "http://localhost:8080".to_string());
let database_url = env_string("DATABASE_URL")
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 DATABASE_URL"))?;
let database_max_connections = env_u32("DATABASE_MAX_CONNECTIONS").unwrap_or(10);
let redis_url = env_string("REDIS_URL")
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 REDIS_URL"))?;
let jwt_secret = env_string("JWT_SECRET")
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 JWT_SECRET"))?;
let jwt_expiry_hours = env_i64("JWT_EXPIRY_HOURS").unwrap_or(168);
let api_key_pepper = env_string("API_KEY_PEPPER")
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 API_KEY_PEPPER"))?;
let billing_provider =
env_string("BILLING_PROVIDER").unwrap_or_else(|| "stripe".to_string());
let stripe_secret_key = env_string("STRIPE_SECRET_KEY");
let stripe_webhook_secret = env_string("STRIPE_WEBHOOK_SECRET");
let storage_type = env_string("STORAGE_TYPE").unwrap_or_else(|| "local".to_string());
let storage_path = env_string("STORAGE_PATH").unwrap_or_else(|| "./uploads".to_string());
let signed_url_ttl_minutes = env_u64("SIGNED_URL_TTL_MINUTES").unwrap_or(60);
let allow_anonymous_upload = env_bool("ALLOW_ANONYMOUS_UPLOAD").unwrap_or(true);
let anon_max_file_size_mb = env_u64("ANON_MAX_FILE_SIZE_MB").unwrap_or(5);
let anon_max_files_per_batch = env_u32("ANON_MAX_FILES_PER_BATCH").unwrap_or(5);
let anon_daily_units = env_u32("ANON_DAILY_UNITS").unwrap_or(10);
let anon_retention_hours = env_u64("ANON_RETENTION_HOURS").unwrap_or(24);
let max_image_pixels = env_u64("MAX_IMAGE_PIXELS").unwrap_or(40_000_000);
let idempotency_ttl_hours = env_u64("IDEMPOTENCY_TTL_HOURS").unwrap_or(24);
let mail_enabled = env_bool("MAIL_ENABLED").unwrap_or(false);
let mail_log_links_when_disabled = env_bool("MAIL_LOG_LINKS_WHEN_DISABLED").unwrap_or(false);
let mail_provider = env_string("MAIL_PROVIDER").unwrap_or_else(|| "qq".to_string());
let mail_from = env_string("MAIL_FROM").unwrap_or_else(|| "noreply@example.com".to_string());
let mail_password = env_string("MAIL_PASSWORD").unwrap_or_default();
let mail_from_name = env_string("MAIL_FROM_NAME").unwrap_or_else(|| "ImageForge".to_string());
let mail_smtp_host = env_string("MAIL_SMTP_HOST");
let mail_smtp_port = env_u16("MAIL_SMTP_PORT");
let mail_smtp_encryption = env_string("MAIL_SMTP_ENCRYPTION");
Ok(Self {
role,
host,
port,
public_base_url,
database_url,
database_max_connections,
redis_url,
jwt_secret,
jwt_expiry_hours,
api_key_pepper,
billing_provider,
stripe_secret_key,
stripe_webhook_secret,
storage_type,
storage_path,
signed_url_ttl_minutes,
allow_anonymous_upload,
anon_max_file_size_mb,
anon_max_files_per_batch,
anon_daily_units,
anon_retention_hours,
max_image_pixels,
idempotency_ttl_hours,
mail_enabled,
mail_log_links_when_disabled,
mail_provider,
mail_from,
mail_password,
mail_from_name,
mail_smtp_host,
mail_smtp_port,
mail_smtp_encryption,
})
}
}
fn env_string(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.trim().is_empty())
}
fn env_u16(key: &str) -> Option<u16> {
env_string(key).and_then(|v| v.parse::<u16>().ok())
}
fn env_u32(key: &str) -> Option<u32> {
env_string(key).and_then(|v| v.parse::<u32>().ok())
}
fn env_i64(key: &str) -> Option<i64> {
env_string(key).and_then(|v| v.parse::<i64>().ok())
}
fn env_u64(key: &str) -> Option<u64> {
env_string(key).and_then(|v| v.parse::<u64>().ok())
}
fn env_bool(key: &str) -> Option<bool> {
env_string(key).and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "y" | "on" => Some(true),
"0" | "false" | "no" | "n" | "off" => Some(false),
_ => None,
})
}

136
src/error.rs Normal file
View File

@@ -0,0 +1,136 @@
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
use std::fmt::{Display, Formatter};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
InvalidRequest,
InvalidImage,
UnsupportedFormat,
TooManyPixels,
FileTooLarge,
InvalidToken,
Unauthorized,
Forbidden,
NotFound,
IdempotencyConflict,
RateLimited,
QuotaExceeded,
EmailNotVerified,
CompressionFailed,
StorageUnavailable,
MailSendFailed,
Internal,
}
#[derive(Debug)]
pub struct AppError {
pub code: ErrorCode,
pub message: String,
pub source: Option<String>,
}
impl AppError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
source: None,
}
}
pub fn with_source(mut self, err: impl std::fmt::Display) -> Self {
self.source = Some(err.to_string());
self
}
}
impl Display for AppError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code.as_str(), self.message)
}
}
impl std::error::Error for AppError {}
impl ErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
ErrorCode::InvalidRequest => "INVALID_REQUEST",
ErrorCode::InvalidImage => "INVALID_IMAGE",
ErrorCode::UnsupportedFormat => "UNSUPPORTED_FORMAT",
ErrorCode::TooManyPixels => "TOO_MANY_PIXELS",
ErrorCode::FileTooLarge => "FILE_TOO_LARGE",
ErrorCode::InvalidToken => "INVALID_TOKEN",
ErrorCode::Unauthorized => "UNAUTHORIZED",
ErrorCode::Forbidden => "FORBIDDEN",
ErrorCode::NotFound => "NOT_FOUND",
ErrorCode::IdempotencyConflict => "IDEMPOTENCY_CONFLICT",
ErrorCode::RateLimited => "RATE_LIMITED",
ErrorCode::QuotaExceeded => "QUOTA_EXCEEDED",
ErrorCode::EmailNotVerified => "EMAIL_NOT_VERIFIED",
ErrorCode::CompressionFailed => "COMPRESSION_FAILED",
ErrorCode::StorageUnavailable => "STORAGE_UNAVAILABLE",
ErrorCode::MailSendFailed => "MAIL_SEND_FAILED",
ErrorCode::Internal => "INTERNAL",
}
}
}
#[derive(Debug, Serialize)]
struct ErrorEnvelope {
success: bool,
error: ErrorPayload,
}
#[derive(Debug, Serialize)]
struct ErrorPayload {
code: ErrorCode,
message: String,
request_id: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let request_id = format!("req_{}", Uuid::new_v4());
if let Some(source) = &self.source {
tracing::error!(code = %self.code.as_str(), request_id = %request_id, message = %self.message, source = %source);
} else {
tracing::error!(code = %self.code.as_str(), request_id = %request_id, message = %self.message);
}
let status = match self.code {
ErrorCode::InvalidRequest => StatusCode::BAD_REQUEST,
ErrorCode::InvalidImage => StatusCode::BAD_REQUEST,
ErrorCode::UnsupportedFormat => StatusCode::BAD_REQUEST,
ErrorCode::TooManyPixels => StatusCode::BAD_REQUEST,
ErrorCode::FileTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::InvalidToken => StatusCode::BAD_REQUEST,
ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
ErrorCode::Forbidden => StatusCode::FORBIDDEN,
ErrorCode::NotFound => StatusCode::NOT_FOUND,
ErrorCode::IdempotencyConflict => StatusCode::CONFLICT,
ErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::QuotaExceeded => StatusCode::PAYMENT_REQUIRED,
ErrorCode::EmailNotVerified => StatusCode::FORBIDDEN,
ErrorCode::CompressionFailed => StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::StorageUnavailable => StatusCode::SERVICE_UNAVAILABLE,
ErrorCode::MailSendFailed => StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = ErrorEnvelope {
success: false,
error: ErrorPayload {
code: self.code,
message: self.message,
request_id,
},
};
(status, Json(body)).into_response()
}
}

64
src/main.rs Normal file
View File

@@ -0,0 +1,64 @@
mod api;
mod auth;
mod config;
mod error;
mod services;
mod state;
mod worker;
use crate::config::Config;
use crate::error::{AppError, ErrorCode};
use crate::services::mail::Mailer;
use crate::state::AppState;
use sqlx::postgres::PgPoolOptions;
use tracing::Level;
#[tokio::main]
async fn main() -> Result<(), AppError> {
dotenvy::dotenv().ok();
init_tracing();
let config = Config::from_env()?;
let mailer = Mailer::new(&config)?;
let db = PgPoolOptions::new()
.max_connections(config.database_max_connections)
.connect(&config.database_url)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "数据库连接失败").with_source(err))?;
let redis = redis::Client::open(config.redis_url.clone())
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 配置错误").with_source(err))?
.get_connection_manager()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 连接失败").with_source(err))?;
let state = AppState {
config,
db,
redis,
mailer: std::sync::Arc::new(mailer),
};
match state.config.role.as_str() {
"api" => api::run(state).await,
"worker" => worker::run(state).await,
other => Err(AppError::new(
ErrorCode::InvalidRequest,
format!("未知 IMAGEFORGE_ROLE: {other}(仅支持 api/worker"),
)),
}
}
fn init_tracing() {
let env_filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("info,tower_http=info,imageforge=info")
});
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_max_level(Level::INFO)
.init();
}

135
src/services/billing.rs Normal file
View File

@@ -0,0 +1,135 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{DateTime, Datelike, TimeZone, Utc};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Plan {
pub id: Uuid,
pub code: String,
pub included_units_per_period: i32,
pub max_file_size_mb: i32,
pub max_files_per_batch: i32,
pub retention_days: i32,
pub feature_api_enabled: bool,
}
#[derive(Debug, Clone)]
pub struct BillingContext {
pub user_id: Uuid,
pub subscription_id: Option<Uuid>,
pub plan: Plan,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct SubscriptionRow {
id: Uuid,
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
plan_id: Uuid,
}
#[derive(Debug, FromRow)]
struct PlanRow {
id: Uuid,
code: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<BillingContext, AppError> {
let subscription = sqlx::query_as::<_, SubscriptionRow>(
r#"
SELECT id, status::text AS status, current_period_start, current_period_end, plan_id
FROM subscriptions
WHERE user_id = $1
AND status IN ('active', 'trialing', 'past_due')
ORDER BY current_period_end DESC
LIMIT 1
"#,
)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
let (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = subscription {
if sub.status == "past_due" {
return Err(AppError::new(
ErrorCode::Forbidden,
"订阅欠费,请先完成支付",
));
}
(Some(sub.id), sub.current_period_start, sub.current_period_end, sub.plan_id)
} else {
let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'free' LIMIT 1")
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
let (start, end) = current_month_period_utc8(Utc::now());
(None, start, end, plan_id)
};
let plan_row = sqlx::query_as::<_, PlanRow>(
r#"
SELECT id, code, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features
FROM plans
WHERE id = $1
"#,
)
.bind(plan_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
let feature_api_enabled = plan_row
.features
.get("api")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(BillingContext {
user_id,
subscription_id,
plan: Plan {
id: plan_row.id,
code: plan_row.code,
included_units_per_period: plan_row.included_units_per_period,
max_file_size_mb: plan_row.max_file_size_mb,
max_files_per_batch: plan_row.max_files_per_batch,
retention_days: plan_row.retention_days,
feature_api_enabled,
},
period_start,
period_end,
})
}
pub fn current_month_period_utc8(now_utc: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let tz = chrono::FixedOffset::east_opt(8 * 3600).unwrap();
let now = now_utc.with_timezone(&tz);
let year = now.year();
let month = now.month();
let start = tz.with_ymd_and_hms(year, month, 1, 0, 0, 0).single().unwrap();
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let end = tz
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
.single()
.unwrap();
(start.with_timezone(&Utc), end.with_timezone(&Utc))
}

204
src/services/bootstrap.rs Normal file
View File

@@ -0,0 +1,204 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use argon2::{Argon2, PasswordHasher};
use chrono::Utc;
use sqlx::FromRow;
use tracing::{info, warn};
use uuid::Uuid;
#[derive(Debug, FromRow)]
struct AdminRow {
id: Uuid,
username: String,
role: String,
}
pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
let Some(admin_email) = env_string("ADMIN_EMAIL") else {
return Ok(());
};
let Some(admin_password) = env_string("ADMIN_PASSWORD") else {
return Ok(());
};
let admin_email = admin_email.trim().to_lowercase();
let admin_password = admin_password.trim().to_string();
if admin_email.is_empty() || admin_password.is_empty() {
return Ok(());
}
let admin_username = env_string("ADMIN_USERNAME").unwrap_or_else(|| {
admin_email
.split('@')
.next()
.unwrap_or("admin")
.to_string()
});
let admin_username = admin_username.trim().to_string();
validate_email(&admin_email)?;
validate_username(&admin_username)?;
validate_password(&admin_password)?;
let existing = sqlx::query_as::<_, AdminRow>(
r#"
SELECT id, username, role::text AS role
FROM users
WHERE email = $1
"#,
)
.bind(&admin_email)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?;
let password_hash = hash_password(&admin_password)?;
if let Some(row) = existing {
sqlx::query(
r#"
UPDATE users
SET password_hash = $1,
role = 'admin',
is_active = true,
email_verified_at = COALESCE(email_verified_at, NOW()),
updated_at = NOW()
WHERE id = $2
"#,
)
.bind(&password_hash)
.bind(row.id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员账号失败").with_source(err))?;
if row.username != admin_username {
let name_taken: bool = sqlx::query_scalar(
r#"
SELECT EXISTS(
SELECT 1 FROM users WHERE username = $1 AND id <> $2
)
"#,
)
.bind(&admin_username)
.bind(row.id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "校验管理员用户名失败").with_source(err))?;
if name_taken {
warn!(
admin_email = %admin_email,
admin_username = %admin_username,
"管理员用户名已被占用,保留原用户名"
);
} else {
sqlx::query(
r#"
UPDATE users
SET username = $1, updated_at = NOW()
WHERE id = $2
"#,
)
.bind(&admin_username)
.bind(row.id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err))?;
}
}
if row.role != "admin" {
info!(admin_email = %admin_email, "管理员权限已启用");
}
} else {
sqlx::query(
r#"
INSERT INTO users (email, username, password_hash, role, email_verified_at)
VALUES ($1, $2, $3, 'admin', $4)
"#,
)
.bind(&admin_email)
.bind(&admin_username)
.bind(&password_hash)
.bind(Utc::now())
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建管理员账号失败").with_source(err))?;
info!(
admin_email = %admin_email,
admin_username = %admin_username,
"管理员账号已创建"
);
}
Ok(())
}
pub async fn ensure_schema(state: &AppState) -> Result<(), AppError> {
sqlx::query(
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS compression_rate SMALLINT",
)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?;
sqlx::query(
"ALTER TABLE usage_periods ADD COLUMN IF NOT EXISTS bonus_units INTEGER NOT NULL DEFAULT 0",
)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?;
let _ = sqlx::query(
"UPDATE usage_periods SET bonus_units = bonus_units + ABS(used_units), used_units = 0 WHERE used_units < 0",
)
.execute(&state.db)
.await;
Ok(())
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱格式不正确"));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱过长"));
}
Ok(())
}
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名不能为空"));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名过长"));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码至少 8 位"));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码过长"));
}
Ok(())
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
let hashed = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?;
Ok(hashed.to_string())
}
fn env_string(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.trim().is_empty())
}

533
src/services/compress.rs Normal file
View File

@@ -0,0 +1,533 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC};
use image::codecs::bmp::BmpEncoder;
use image::codecs::gif::{GifDecoder, GifEncoder};
use image::codecs::ico::IcoEncoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::codecs::tiff::TiffEncoder;
use image::{DynamicImage, ExtendedColorType, ImageEncoder};
use image::{AnimationDecoder, GenericImageView};
use oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
#[derive(Debug, Clone, Copy)]
pub enum CompressionLevel {
High,
Medium,
Low,
}
impl CompressionLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFmt {
Png,
Jpeg,
Webp,
Avif,
Gif,
Bmp,
Tiff,
Ico,
}
impl ImageFmt {
pub fn as_str(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Gif => "gif",
Self::Bmp => "bmp",
Self::Tiff => "tiff",
Self::Ico => "ico",
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Gif => "gif",
Self::Bmp => "bmp",
Self::Tiff => "tiff",
Self::Ico => "ico",
}
}
pub fn content_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Avif => "image/avif",
Self::Gif => "image/gif",
Self::Bmp => "image/bmp",
Self::Tiff => "image/tiff",
Self::Ico => "image/x-icon",
}
}
}
pub fn parse_level(value: &str) -> Result<CompressionLevel, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "medium" => Ok(CompressionLevel::Medium),
"high" => Ok(CompressionLevel::High),
"low" => Ok(CompressionLevel::Low),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"level 仅支持 high/medium/low",
)),
}
}
pub fn parse_compression_rate(value: &str) -> Result<u8, AppError> {
let rate: u8 = value
.trim()
.parse()
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "compression_rate 需为 1-100 的整数"))?;
if !(1..=100).contains(&rate) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需在 1-100 之间",
));
}
Ok(rate)
}
pub fn rate_to_level(rate: u8) -> CompressionLevel {
match rate {
1..=33 => CompressionLevel::Low,
34..=66 => CompressionLevel::Medium,
_ => CompressionLevel::High,
}
}
pub fn parse_output_format(value: &str) -> Result<ImageFmt, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"png" => Ok(ImageFmt::Png),
"jpeg" | "jpg" => Ok(ImageFmt::Jpeg),
"webp" => Ok(ImageFmt::Webp),
"avif" => Ok(ImageFmt::Avif),
"gif" => Ok(ImageFmt::Gif),
"bmp" => Ok(ImageFmt::Bmp),
"tif" | "tiff" => Ok(ImageFmt::Tiff),
"ico" => Ok(ImageFmt::Ico),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"output_format 仅支持 png/jpeg/webp/avif/gif/bmp/tiff/ico",
)),
}
}
pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Ok(ImageFmt::Png);
}
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
return Ok(ImageFmt::Jpeg);
}
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
return Ok(ImageFmt::Webp);
}
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
if bytes[8..12].eq_ignore_ascii_case(b"avif")
|| bytes[8..12].eq_ignore_ascii_case(b"avis")
{
return Ok(ImageFmt::Avif);
}
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Ok(ImageFmt::Gif);
}
if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D {
return Ok(ImageFmt::Bmp);
}
if bytes.len() >= 4 {
if &bytes[0..4] == b"II*\x00" || &bytes[0..4] == b"MM\x00*" {
return Ok(ImageFmt::Tiff);
}
if bytes[0] == 0x00
&& bytes[1] == 0x00
&& (bytes[2] == 0x01 || bytes[2] == 0x02)
&& bytes[3] == 0x00
{
return Ok(ImageFmt::Ico);
}
}
Err(AppError::new(
ErrorCode::UnsupportedFormat,
"不支持的图片格式",
))
}
pub async fn compress_image_bytes(
state: &AppState,
input: &[u8],
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
if format_in == ImageFmt::Gif {
if is_animated_gif(input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
}
let rate = effective_rate(compression_rate, level);
let (icc_profile, exif) = if preserve_metadata {
extract_metadata(input)
} else {
(None, None)
};
let mut resized = false;
let mut output = if format_in == ImageFmt::Png
&& format_out == ImageFmt::Png
&& max_width.is_none()
&& max_height.is_none()
{
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
oxipng::optimize_from_memory(input, &opts)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err))?
} else {
let image = image::load_from_memory(input)
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err))?;
enforce_pixel_limit(state, &image)?;
let (image, did_resize) = resize_if_needed(image, max_width, max_height);
resized = did_resize;
match format_out {
ImageFmt::Png => encode_png(image, rate, preserve_metadata)?,
ImageFmt::Jpeg => encode_jpeg(image, rate)?,
ImageFmt::Webp => encode_webp(image, rate)?,
ImageFmt::Avif => encode_avif(image, rate)?,
ImageFmt::Gif => encode_gif(image, rate)?,
ImageFmt::Bmp => encode_bmp(image)?,
ImageFmt::Tiff => encode_tiff(image)?,
ImageFmt::Ico => encode_ico(image)?,
}
};
if preserve_metadata {
output = apply_metadata(output, icc_profile, exif)?;
}
if !resized && output.len() >= input.len() {
if preserve_metadata {
return Ok(input.to_vec());
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
return Ok(if stripped.len() <= input.len() {
stripped
} else {
input.to_vec()
});
}
Ok(output)
}
fn enforce_pixel_limit(state: &AppState, image: &DynamicImage) -> Result<(), AppError> {
let (w, h) = image.dimensions();
let pixels = (w as u64).saturating_mul(h as u64);
if pixels > state.config.max_image_pixels {
return Err(AppError::new(
ErrorCode::TooManyPixels,
format!("图片像素过大({}x{}", w, h),
));
}
Ok(())
}
fn resize_if_needed(
image: DynamicImage,
max_width: Option<u32>,
max_height: Option<u32>,
) -> (DynamicImage, bool) {
if max_width.is_none() && max_height.is_none() {
return (image, false);
}
let (w, h) = image.dimensions();
let (target_w, target_h) = fit_within(w, h, max_width, max_height);
if target_w == w && target_h == h {
return (image, false);
}
(
image.resize(target_w, target_h, image::imageops::FilterType::Lanczos3),
true,
)
}
fn fit_within(w: u32, h: u32, max_width: Option<u32>, max_height: Option<u32>) -> (u32, u32) {
let mut scale = 1.0_f64;
if let Some(mw) = max_width.filter(|v| *v > 0) {
scale = scale.min(mw as f64 / w as f64);
}
if let Some(mh) = max_height.filter(|v| *v > 0) {
scale = scale.min(mh as f64 / h as f64);
}
if scale >= 1.0 {
return (w, h);
}
let nw = (w as f64 * scale).round().max(1.0) as u32;
let nh = (h as f64 * scale).round().max(1.0) as u32;
(nw, nh)
}
fn encode_png(
image: DynamicImage,
rate: u8,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
let encoder = PngEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err))?;
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
oxipng::optimize_from_memory(&out, &opts)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 优化失败").with_source(err))
}
fn encode_jpeg(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgb = image.to_rgb8();
let (w, h) = rgb.dimensions();
let mut out = Vec::new();
let quality = jpeg_quality_from_rate(rate);
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
encoder
.encode(rgb.as_raw(), w, h, ExtendedColorType::Rgb8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?;
Ok(out)
}
fn encode_webp(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h);
let bytes = if rate <= 10 {
encoder.encode_lossless()
} else {
encoder.encode(webp_quality_from_rate(rate))
};
Ok(bytes.to_vec())
}
fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let quality = avif_quality_from_rate(rate);
let raw = rgba.into_raw();
let pixels = raw.as_rgba();
let img = ravif::Img::new(pixels, w as usize, h as usize);
let encoder = ravif::Encoder::new().with_quality(quality);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
Ok(encoded.avif_file)
}
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
let speed = gif_speed_from_rate(rate);
{
let mut encoder = GifEncoder::new_with_speed(&mut out, speed);
encoder
.encode(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err))?;
}
Ok(out)
}
fn encode_bmp(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
let encoder = BmpEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err))?;
Ok(out)
}
fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Cursor::new(Vec::new());
let encoder = TiffEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err))?;
Ok(out.into_inner())
}
fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
let encoder = IcoEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err))?;
Ok(out)
}
fn extract_metadata(input: &[u8]) -> (Option<ImgBytes>, Option<ImgBytes>) {
let bytes = ImgBytes::copy_from_slice(input);
match DynImage::from_bytes(bytes) {
Ok(Some(img)) => (img.icc_profile(), img.exif()),
_ => (None, None),
}
}
fn apply_metadata(
output: Vec<u8>,
icc_profile: Option<ImgBytes>,
exif: Option<ImgBytes>,
) -> Result<Vec<u8>, AppError> {
if icc_profile.is_none() && exif.is_none() {
return Ok(output);
}
let out_bytes = ImgBytes::from(output);
let dyn_img = DynImage::from_bytes(out_bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err))?;
let Some(mut img) = dyn_img else {
return Ok(out_bytes.to_vec());
};
img.set_icc_profile(icc_profile);
img.set_exif(exif);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
Ok(buf)
}
fn strip_metadata(input: &[u8]) -> Result<Vec<u8>, AppError> {
let bytes = ImgBytes::copy_from_slice(input);
let dyn_img = DynImage::from_bytes(bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err))?;
let Some(mut img) = dyn_img else {
return Ok(bytes.to_vec());
};
img.set_icc_profile(None);
img.set_exif(None);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
Ok(buf)
}
fn effective_rate(rate: Option<u8>, level: CompressionLevel) -> u8 {
match rate {
Some(value) => value.clamp(1, 100),
None => match level {
CompressionLevel::Low => 25,
CompressionLevel::Medium => 55,
CompressionLevel::High => 80,
},
}
}
fn png_preset_from_rate(rate: u8) -> u8 {
(((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8
}
fn jpeg_quality_from_rate(rate: u8) -> u8 {
quality_from_rate(rate, 35, 95)
}
fn webp_quality_from_rate(rate: u8) -> f32 {
quality_from_rate(rate, 40, 92) as f32
}
fn avif_quality_from_rate(rate: u8) -> f32 {
quality_from_rate(rate, 35, 90) as f32
}
fn quality_from_rate(rate: u8, min_quality: u8, max_quality: u8) -> u8 {
let min_q = min_quality as i32;
let max_q = max_quality as i32;
let rate = rate.clamp(1, 100) as i32;
let span = max_q - min_q;
let q = max_q - ((rate - 1) * span / 99);
q.clamp(min_q, max_q) as u8
}
fn gif_speed_from_rate(rate: u8) -> i32 {
let rate = rate.clamp(1, 100) as i32;
1 + ((rate - 1) * 29 / 99)
}
fn is_animated_gif(input: &[u8]) -> Result<bool, AppError> {
let decoder = GifDecoder::new(Cursor::new(input))
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
let mut frames = decoder.into_frames().into_iter();
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
}
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
return Ok(true);
}
Ok(false)
}

341
src/services/idempotency.rs Normal file
View File

@@ -0,0 +1,341 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Copy)]
pub enum Scope {
User(Uuid),
ApiKey(Uuid),
}
#[derive(Debug)]
pub enum BeginResult {
Acquired { expires_at: DateTime<Utc> },
Replay { response_status: i32, response_body: JsonValue },
InProgress,
}
#[derive(Debug, FromRow)]
struct IdemRow {
request_hash: String,
response_status: i32,
response_body: Option<JsonValue>,
expires_at: DateTime<Utc>,
}
pub fn sha256_hex(parts: &[&[u8]]) -> String {
let mut hasher = Sha256::new();
for p in parts {
hasher.update(p);
hasher.update([0u8]); // separator
}
hex::encode(hasher.finalize())
}
pub async fn begin(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
ttl_hours: i64,
) -> Result<BeginResult, AppError> {
if idempotency_key.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 不能为空"));
}
if idempotency_key.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 过长"));
}
if request_hash.len() != 64 {
return Err(AppError::new(ErrorCode::InvalidRequest, "request_hash 不合法"));
}
let now = Utc::now();
let expires_at = now + Duration::hours(ttl_hours.max(1));
cleanup_expired_for_key(state, scope, idempotency_key, now).await?;
let inserted = match scope {
Scope::User(user_id) => {
sqlx::query(
r#"
INSERT INTO idempotency_keys (
user_id, idempotency_key, request_hash,
response_status, response_body,
expires_at
) VALUES (
$1, $2, $3,
0, NULL,
$4
)
ON CONFLICT DO NOTHING
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(expires_at)
.execute(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query(
r#"
INSERT INTO idempotency_keys (
api_key_id, idempotency_key, request_hash,
response_status, response_body,
expires_at
) VALUES (
$1, $2, $3,
0, NULL,
$4
)
ON CONFLICT DO NOTHING
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(expires_at)
.execute(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等记录失败").with_source(err))?;
if inserted.rows_affected() > 0 {
return Ok(BeginResult::Acquired { expires_at });
}
let row = get_row(state, scope, idempotency_key, now).await?;
let Some(row) = row else {
return Ok(BeginResult::Acquired { expires_at });
};
if row.request_hash != request_hash {
return Err(AppError::new(
ErrorCode::IdempotencyConflict,
"同一个 Idempotency-Key 的请求参数不一致",
));
}
if row.response_status == 0 || row.response_body.is_none() {
return Ok(BeginResult::InProgress);
}
Ok(BeginResult::Replay {
response_status: row.response_status,
response_body: row.response_body.unwrap_or(JsonValue::Null),
})
}
pub async fn wait_for_replay(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
max_wait_ms: u64,
) -> Result<Option<(i32, JsonValue)>, AppError> {
let started = tokio::time::Instant::now();
let now = Utc::now();
loop {
let row = get_row(state, scope, idempotency_key, now).await?;
let Some(row) = row else { return Ok(None) };
if row.request_hash != request_hash {
return Err(AppError::new(
ErrorCode::IdempotencyConflict,
"同一个 Idempotency-Key 的请求参数不一致",
));
}
if row.response_status != 0 {
return Ok(Some((
row.response_status,
row.response_body.unwrap_or(JsonValue::Null),
)));
}
if started.elapsed().as_millis() as u64 >= max_wait_ms {
return Ok(None);
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
pub async fn complete(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
response_status: i32,
response_body: JsonValue,
) -> Result<(), AppError> {
let updated = match scope {
Scope::User(user_id) => {
sqlx::query(
r#"
UPDATE idempotency_keys
SET response_status = $4,
response_body = $5
WHERE user_id = $1
AND idempotency_key = $2
AND request_hash = $3
AND response_status = 0
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(response_status)
.bind(response_body)
.execute(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query(
r#"
UPDATE idempotency_keys
SET response_status = $4,
response_body = $5
WHERE api_key_id = $1
AND idempotency_key = $2
AND request_hash = $3
AND response_status = 0
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(response_status)
.bind(response_body)
.execute(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等结果失败").with_source(err))?;
if updated.rows_affected() == 0 {
tracing::warn!("idempotency record not updated (maybe already completed?)");
}
Ok(())
}
pub async fn abort(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
) -> Result<(), AppError> {
match scope {
Scope::User(user_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0",
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.execute(&state.db)
.await;
}
Scope::ApiKey(api_key_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0",
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.execute(&state.db)
.await;
}
}
Ok(())
}
async fn cleanup_expired_for_key(
state: &AppState,
scope: Scope,
idempotency_key: &str,
now: DateTime<Utc>,
) -> Result<(), AppError> {
match scope {
Scope::User(user_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND expires_at < $3",
)
.bind(user_id)
.bind(idempotency_key)
.bind(now)
.execute(&state.db)
.await;
}
Scope::ApiKey(api_key_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND expires_at < $3",
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(now)
.execute(&state.db)
.await;
}
}
Ok(())
}
async fn get_row(
state: &AppState,
scope: Scope,
idempotency_key: &str,
now: DateTime<Utc>,
) -> Result<Option<IdemRow>, AppError> {
let row = match scope {
Scope::User(user_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
FROM idempotency_keys
WHERE user_id = $1
AND idempotency_key = $2
AND expires_at > $3
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(now)
.fetch_optional(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
FROM idempotency_keys
WHERE api_key_id = $1
AND idempotency_key = $2
AND expires_at > $3
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(now)
.fetch_optional(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "查询幂等记录失败").with_source(err))?;
Ok(row)
}

344
src/services/mail.rs Normal file
View File

@@ -0,0 +1,344 @@
use crate::config::Config;
use crate::error::{AppError, ErrorCode};
use crate::services::settings;
use crate::state::AppState;
use chrono::Datelike;
use lettre::message::{header::ContentType, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
#[derive(Clone)]
pub struct Mailer {
enabled: bool,
log_links_when_disabled: bool,
from: String,
from_name: String,
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
}
#[derive(Debug, Clone)]
pub struct MailSettings {
pub enabled: bool,
pub log_links_when_disabled: bool,
pub provider: String,
pub from: String,
pub from_name: String,
pub password: String,
pub smtp_host: Option<String>,
pub smtp_port: Option<u16>,
pub smtp_encryption: Option<String>,
}
impl MailSettings {
pub fn from_env(config: &Config) -> Self {
Self {
enabled: config.mail_enabled,
log_links_when_disabled: config.mail_log_links_when_disabled,
provider: config.mail_provider.clone(),
from: config.mail_from.clone(),
from_name: config.mail_from_name.clone(),
password: config.mail_password.clone(),
smtp_host: config.mail_smtp_host.clone(),
smtp_port: config.mail_smtp_port,
smtp_encryption: config.mail_smtp_encryption.clone(),
}
}
}
impl Mailer {
pub fn new(config: &Config) -> Result<Self, AppError> {
Self::from_settings(MailSettings::from_env(config))
}
pub fn from_settings(settings: MailSettings) -> Result<Self, AppError> {
if !settings.enabled {
return Ok(Self {
enabled: false,
log_links_when_disabled: settings.log_links_when_disabled,
from: settings.from,
from_name: settings.from_name,
transport: None,
});
}
if settings.password.trim().is_empty() {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"邮件服务已启用但未配置授权码/密码",
));
}
let smtp = SmtpConfig::from_settings(&settings)?;
let creds = Credentials::new(settings.from.clone(), settings.password.clone());
let tls_params = if smtp.encryption == SmtpEncryption::None {
None
} else {
Some(
TlsParameters::new(smtp.host.clone())
.map_err(|err| AppError::new(ErrorCode::Internal, "SMTP TLS 参数错误").with_source(err))?,
)
};
let tls = match (smtp.encryption, tls_params) {
(SmtpEncryption::Ssl, Some(params)) => Tls::Wrapper(params),
(SmtpEncryption::StartTls, Some(params)) => Tls::Required(params),
(SmtpEncryption::None, _) => Tls::None,
_ => Tls::None,
};
let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
.port(smtp.port)
.tls(tls)
.credentials(creds)
.build();
Ok(Self {
enabled: true,
log_links_when_disabled: false,
from: settings.from,
from_name: settings.from_name,
transport: Some(transport),
})
}
pub async fn send_verification_email(
&self,
to: &str,
username: &str,
verification_url: &str,
) -> Result<(), AppError> {
if !self.enabled {
if self.log_links_when_disabled {
tracing::info!(
to = %to,
verification_url = %verification_url,
"MAIL_ENABLED=false, verification email link"
);
} else {
tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending verification email");
}
return Ok(());
}
let year = chrono::Utc::now().year().to_string();
let html = render_template(
include_str!("../../templates/email_verification.html"),
&[
("{{username}}", username),
("{{verification_url}}", verification_url),
("{{year}}", &year),
],
);
let text = render_template(
include_str!("../../templates/email_verification.txt"),
&[
("{{username}}", username),
("{{verification_url}}", verification_url),
("{{year}}", &year),
],
);
self.send_email(to, "验证您的 ImageForge 账号", &text, &html)
.await
}
pub async fn send_password_reset_email(
&self,
to: &str,
username: &str,
reset_url: &str,
) -> Result<(), AppError> {
if !self.enabled {
if self.log_links_when_disabled {
tracing::info!(to = %to, reset_url = %reset_url, "MAIL_ENABLED=false, password reset link");
} else {
tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending password reset email");
}
return Ok(());
}
let year = chrono::Utc::now().year().to_string();
let html = render_template(
include_str!("../../templates/password_reset.html"),
&[
("{{username}}", username),
("{{reset_url}}", reset_url),
("{{year}}", &year),
],
);
let text = render_template(
include_str!("../../templates/password_reset.txt"),
&[
("{{username}}", username),
("{{reset_url}}", reset_url),
("{{year}}", &year),
],
);
self.send_email(to, "重置您的 ImageForge 密码", &text, &html)
.await
}
async fn send_email(
&self,
to: &str,
subject: &str,
text_body: &str,
html_body: &str,
) -> Result<(), AppError> {
if !self.enabled {
tracing::info!(to = %to, subject = %subject, "MAIL_ENABLED=false, skip sending email");
return Ok(());
}
let Some(transport) = &self.transport else {
return Err(AppError::new(ErrorCode::Internal, "邮件服务未初始化"));
};
let from = format!("{} <{}>", self.from_name, self.from);
let email = Message::builder()
.from(from.parse().map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "MAIL_FROM/MAIL_FROM_NAME 格式错误")
.with_source(err)
})?)
.to(to.parse().map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "收件人邮箱格式错误").with_source(err)
})?)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text_body.to_string()))
.singlepart(SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())),
)
.map_err(|err| AppError::new(ErrorCode::Internal, "构建邮件失败").with_source(err))?;
transport
.send(email)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "邮件发送失败").with_source(err))?;
Ok(())
}
}
#[derive(Debug, Clone)]
struct SmtpConfig {
host: String,
port: u16,
encryption: SmtpEncryption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SmtpEncryption {
Ssl,
StartTls,
None,
}
impl SmtpConfig {
fn from_settings(settings: &MailSettings) -> Result<Self, AppError> {
if settings.provider.eq_ignore_ascii_case("custom") {
let host = settings.smtp_host.clone().ok_or_else(|| {
AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置 host")
})?;
let port = settings
.smtp_port
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置端口"))?;
let encryption = parse_encryption(settings.smtp_encryption.as_deref().unwrap_or("ssl"))?;
return Ok(Self { host, port, encryption });
}
let provider = settings.provider.to_ascii_lowercase();
let (host, port, encryption) = match provider.as_str() {
"qq" => ("smtp.qq.com", 465, SmtpEncryption::Ssl),
"163" => ("smtp.163.com", 465, SmtpEncryption::Ssl),
"aliyun_enterprise" => ("smtp.qiye.aliyun.com", 465, SmtpEncryption::Ssl),
"tencent_enterprise" => ("smtp.exmail.qq.com", 465, SmtpEncryption::Ssl),
"gmail" => ("smtp.gmail.com", 587, SmtpEncryption::StartTls),
"outlook" => ("smtp.office365.com", 587, SmtpEncryption::StartTls),
other => {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("未知 MAIL_PROVIDER: {other}"),
))
}
};
Ok(Self {
host: host.to_string(),
port,
encryption,
})
}
}
fn parse_encryption(value: &str) -> Result<SmtpEncryption, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"ssl" => Ok(SmtpEncryption::Ssl),
"starttls" => Ok(SmtpEncryption::StartTls),
"none" => Ok(SmtpEncryption::None),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"MAIL_SMTP_ENCRYPTION 仅支持 ssl/starttls/none",
)),
}
}
fn render_template(template: &str, vars: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in vars {
out = out.replace(key, value);
}
out
}
pub async fn send_verification_email(
state: &AppState,
to: &str,
username: &str,
verification_url: &str,
) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
mailer
.send_verification_email(to, username, verification_url)
.await
}
pub async fn send_password_reset_email(
state: &AppState,
to: &str,
username: &str,
reset_url: &str,
) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
mailer.send_password_reset_email(to, username, reset_url).await
}
pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
let year = chrono::Utc::now().year().to_string();
let html = format!(
"<h2>ImageForge 邮件测试</h2><p>这是一封测试邮件。</p><p>{}</p>",
year
);
let text = format!("ImageForge 邮件测试\n\n这是一封测试邮件。\n{}\n", year);
mailer
.send_email(to, "ImageForge 邮件测试", &text, &html)
.await
}
async fn resolve_mailer(state: &AppState) -> Result<Mailer, AppError> {
if let Some(settings) = settings::load_mail_settings(state).await? {
return Mailer::from_settings(settings);
}
Ok(state.mailer.as_ref().clone())
}

7
src/services/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod mail;
pub mod billing;
pub mod quota;
pub mod compress;
pub mod idempotency;
pub mod settings;
pub mod bootstrap;

73
src/services/quota.rs Normal file
View File

@@ -0,0 +1,73 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{Duration, Utc};
use std::net::IpAddr;
pub async fn consume_anonymous_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
units: u32,
) -> Result<(), AppError> {
if units == 0 {
return Ok(());
}
let date = utc8_date();
let session_key = format!("anon_quota:{session_id}:{date}");
let ip_key = format!("anon_quota_ip:{ip}:{date}");
let mut conn = state.redis.clone();
let limit = state.config.anon_daily_units as i64;
let ttl_seconds = 48 * 60 * 60;
let inc = units as i64;
let script = redis::Script::new(
r#"
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local inc = tonumber(ARGV[3])
local v1 = tonumber(redis.call('GET', KEYS[1]) or '0')
local v2 = tonumber(redis.call('GET', KEYS[2]) or '0')
if v1 + inc > limit or v2 + inc > limit then
return -1
end
v1 = redis.call('INCRBY', KEYS[1], inc)
v2 = redis.call('INCRBY', KEYS[2], inc)
if v1 == inc then redis.call('EXPIRE', KEYS[1], ttl) end
if v2 == inc then redis.call('EXPIRE', KEYS[2], ttl) end
return v1
"#,
);
let new_value: i64 = script
.key(session_key)
.key(ip_key)
.arg(limit)
.arg(ttl_seconds)
.arg(inc)
.invoke_async(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "匿名配额检查失败").with_source(err))?;
if new_value < 0 {
return Err(AppError::new(
ErrorCode::QuotaExceeded,
"匿名试用次数已用完(每日 10 次)",
));
}
Ok(())
}
fn utc8_date() -> String {
let now = Utc::now() + Duration::hours(8);
now.format("%Y-%m-%d").to_string()
}

227
src/services/settings.rs Normal file
View File

@@ -0,0 +1,227 @@
use crate::error::{AppError, ErrorCode};
use crate::services::mail::MailSettings;
use crate::state::AppState;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailCustomSmtp {
pub host: String,
pub port: u16,
pub encryption: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailConfigStored {
pub enabled: bool,
pub provider: String,
pub from: String,
pub from_name: String,
pub password_encrypted: Option<String>,
pub custom_smtp: Option<MailCustomSmtp>,
pub log_links_when_disabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeConfigStored {
pub secret_key_encrypted: Option<String>,
pub webhook_secret_encrypted: Option<String>,
pub secret_key_prefix: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StripeSecrets {
pub secret_key: String,
pub webhook_secret: Option<String>,
pub secret_key_prefix: Option<String>,
}
pub async fn load_system_config<T: DeserializeOwned>(
state: &AppState,
key: &str,
) -> Result<Option<T>, AppError> {
let value: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM system_config WHERE key = $1")
.bind(key)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err))?;
let Some(value) = value else {
return Ok(None);
};
let parsed = serde_json::from_value::<T>(value)
.map_err(|err| AppError::new(ErrorCode::Internal, "解析系统配置失败").with_source(err))?;
Ok(Some(parsed))
}
pub async fn upsert_system_config(
state: &AppState,
key: &str,
value: serde_json::Value,
description: Option<&str>,
updated_by: Option<uuid::Uuid>,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO system_config (key, value, description, updated_at, updated_by)
VALUES ($1, $2, $3, NOW(), $4)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = COALESCE(EXCLUDED.description, system_config.description),
updated_at = NOW(),
updated_by = $4
"#,
)
.bind(key)
.bind(value)
.bind(description)
.bind(updated_by)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新系统配置失败").with_source(err))?;
Ok(())
}
pub async fn load_mail_settings(state: &AppState) -> Result<Option<MailSettings>, AppError> {
let Some(cfg) = load_system_config::<MailConfigStored>(state, "mail").await? else {
return Ok(None);
};
let password = match cfg.password_encrypted.as_deref() {
Some(value) => decrypt_secret(state, value)?,
None => String::new(),
};
Ok(Some(MailSettings {
enabled: cfg.enabled,
log_links_when_disabled: cfg
.log_links_when_disabled
.unwrap_or(state.config.mail_log_links_when_disabled),
provider: cfg.provider,
from: cfg.from,
from_name: cfg.from_name,
password,
smtp_host: cfg.custom_smtp.as_ref().map(|v| v.host.clone()),
smtp_port: cfg.custom_smtp.as_ref().map(|v| v.port),
smtp_encryption: cfg.custom_smtp.as_ref().map(|v| v.encryption.clone()),
}))
}
pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecrets>, AppError> {
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
return Ok(None);
};
let secret_key = match cfg.secret_key_encrypted.as_deref() {
Some(value) => decrypt_secret(state, value)?,
None => String::new(),
};
if secret_key.is_empty() {
return Ok(None);
}
let webhook_secret = match cfg.webhook_secret_encrypted.as_deref() {
Some(value) if !value.is_empty() => Some(decrypt_secret(state, value)?),
_ => None,
};
Ok(Some(StripeSecrets {
secret_key,
webhook_secret,
secret_key_prefix: cfg.secret_key_prefix,
}))
}
pub fn encrypt_secret(state: &AppState, plain: &str) -> Result<String, AppError> {
let key = derive_key(&state.config.api_key_pepper);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|err| AppError::new(ErrorCode::Internal, "加密密钥初始化失败").with_source(err))?;
let mut nonce_bytes = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plain.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "加密失败").with_source(err))?;
let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(URL_SAFE_NO_PAD.encode(out))
}
pub fn decrypt_secret(state: &AppState, encoded: &str) -> Result<String, AppError> {
let key = derive_key(&state.config.api_key_pepper);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|err| AppError::new(ErrorCode::Internal, "解密密钥初始化失败").with_source(err))?;
let decoded = URL_SAFE_NO_PAD
.decode(encoded.as_bytes())
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文格式错误").with_source(err))?;
if decoded.len() < 12 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密文长度错误"));
}
let (nonce_bytes, cipher_bytes) = decoded.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let plain = cipher
.decrypt(nonce, cipher_bytes)
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文解密失败").with_source(err))?;
String::from_utf8(plain)
.map_err(|err| AppError::new(ErrorCode::Internal, "解密文本编码错误").with_source(err))
}
fn derive_key(secret: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
pub async fn get_stripe_secret(state: &AppState) -> Result<String, AppError> {
if let Some(cfg) = load_stripe_secrets(state).await? {
if !cfg.secret_key.trim().is_empty() {
return Ok(cfg.secret_key);
}
}
state
.config
.stripe_secret_key
.clone()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Secret Key"))
}
pub async fn get_stripe_webhook_secret(state: &AppState) -> Result<String, AppError> {
if let Some(cfg) = load_stripe_secrets(state).await? {
if let Some(secret) = cfg.webhook_secret {
if !secret.trim().is_empty() {
return Ok(secret);
}
}
}
state
.config
.stripe_webhook_secret
.clone()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret"))
}

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