Implement compression quota refunds and admin manual subscription
This commit is contained in:
6
.cargo/config.toml
Normal file
6
.cargo/config.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[http]
|
||||
timeout = 600
|
||||
low-speed-limit = 1
|
||||
|
||||
[net]
|
||||
retry = 10
|
||||
65
.env.example
Normal file
65
.env.example
Normal 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
13
.gitignore
vendored
Normal 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
4997
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
Cargo.toml
Normal file
56
Cargo.toml
Normal 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
149
README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# ImageForge - Rust 图片压缩服务
|
||||
|
||||
一个基于 Rust 的高性能图片压缩服务,提供 Web 界面 + 对外 API(API Key)+ 计费能力,支持用户系统与管理员后台。
|
||||
|
||||
## 项目概述
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态)
|
||||
- **批量处理**:支持多图片同时上传和处理
|
||||
- **压缩率**: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
46
docker/Dockerfile
Normal 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"]
|
||||
|
||||
25
docker/docker-compose.dev.yml
Normal file
25
docker/docker-compose.dev.yml
Normal 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
53
docker/nginx.conf
Normal 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
685
docs/api.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# API 接口文档(v1)- ImageForge
|
||||
|
||||
面向两类使用者:
|
||||
- **网站(Web)**:上传/批量/历史/账单等(可能包含匿名试用)。
|
||||
- **对外 API(Developer 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 或 SSE(SSE 更易穿透代理)。当前先保留 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
334
docs/architecture.md
Normal 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
191
docs/billing.md
Normal 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
46
docs/confirm.md
Normal 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+8)00:00 重置;采用 Cookie + IP 双限制。
|
||||
|
||||
3) **批量任务遇到额度不足时的行为**
|
||||
- 当前写法:`POST /compress/batch` 若本周期剩余单位不足以覆盖上传文件数,直接返回 `402`,不创建任务。
|
||||
|
||||
4) **默认套餐参数(可改)**
|
||||
- Free:500 / 月,5MB 单文件,10/批量,保留 24h
|
||||
- Pro:10,000 / 订阅周期,20MB 单文件,50/批量,保留 7 天
|
||||
- Business:100,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
583
docs/database.md
Normal 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
711
docs/deployment.md
Normal 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
|
||||
|
||||
# 可选:MinIO(S3 兼容,本地开发更接近生产)
|
||||
# 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
471
docs/email.md
Normal 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 时效控制
|
||||
- 邮箱验证 Token:24 小时有效
|
||||
- 密码重置 Token:1 小时有效
|
||||
|
||||
### 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
158
docs/frontend.md
Normal 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/ico,GIF 仅静态)
|
||||
- 文件大小与数量(按匿名/登录/套餐提示不同上限)
|
||||
- 匿名试用:每日 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:避免 localStorage(XSS 风险),优先 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
99
docs/observability.md
Normal 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(建议起点)
|
||||
- API:P95 < 300ms(不含压缩直返)、错误率 < 0.5%
|
||||
- Worker:队列积压 < N(按规模定义),失败率 < 1%
|
||||
|
||||
### 4.2 告警
|
||||
可用性:
|
||||
- `http 5xx` 激增
|
||||
- `/health` 探活失败
|
||||
|
||||
队列健康:
|
||||
- pending/inflight 持续上升
|
||||
- 单任务耗时异常增长
|
||||
|
||||
计费正确性:
|
||||
- webhook 处理失败
|
||||
- 订阅状态异常(active->incomplete 回退等)
|
||||
|
||||
滥用风险:
|
||||
- 单 key/单 IP 用量突增
|
||||
- 格式探测失败率异常
|
||||
|
||||
162
docs/prd.md
Normal file
162
docs/prd.md
Normal 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 对外 API(Developer 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 + SDK(TS/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
114
docs/privacy.md
Normal 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
119
docs/security.md
Normal 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 权限与限制
|
||||
- 最小权限:permissions(compress/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 密集型:放到 Worker;API 只做编排与轻量校验。
|
||||
- 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
117
docs/terms.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 服务条款(示例模板)- ImageForge
|
||||
|
||||
> 提示:本文为通用模板,**不构成法律意见**。上线前建议由专业人士结合你的主体信息、所在地法律与实际业务流程进行审核与调整。
|
||||
|
||||
最后更新:2025-12-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 接受条款
|
||||
|
||||
欢迎使用 ImageForge(下称“本服务”)。当你访问或使用本服务(包括网站与 API)时,即表示你已阅读、理解并同意受本条款约束。如果你不同意本条款,请停止使用本服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 服务内容
|
||||
|
||||
本服务提供图片压缩与格式转换能力,包括但不限于:
|
||||
- 网站上传压缩与批量压缩(含匿名试用)
|
||||
- 开发者 API(API 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
134
docs/ui.md
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
2756
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
69
frontend/src/app/layouts/AdminLayout.vue
Normal file
69
frontend/src/app/layouts/AdminLayout.vue
Normal 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>
|
||||
91
frontend/src/app/layouts/DashboardLayout.vue
Normal file
91
frontend/src/app/layouts/DashboardLayout.vue
Normal 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>
|
||||
|
||||
86
frontend/src/app/layouts/PublicLayout.vue
Normal file
86
frontend/src/app/layouts/PublicLayout.vue
Normal 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>
|
||||
|
||||
93
frontend/src/app/router.ts
Normal file
93
frontend/src/app/router.ts
Normal 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
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal 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
20
frontend/src/main.ts
Normal 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')
|
||||
30
frontend/src/pages/DocsPage.vue
Normal file
30
frontend/src/pages/DocsPage.vue
Normal 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>
|
||||
75
frontend/src/pages/ForgotPasswordPage.vue
Normal file
75
frontend/src/pages/ForgotPasswordPage.vue
Normal 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>
|
||||
|
||||
422
frontend/src/pages/HomePage.vue
Normal file
422
frontend/src/pages/HomePage.vue
Normal 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 / ICO(GIF 仅静态)</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>
|
||||
93
frontend/src/pages/LoginPage.vue
Normal file
93
frontend/src/pages/LoginPage.vue
Normal 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>
|
||||
17
frontend/src/pages/NotFoundPage.vue
Normal file
17
frontend/src/pages/NotFoundPage.vue
Normal 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>
|
||||
|
||||
73
frontend/src/pages/PricingPage.vue
Normal file
73
frontend/src/pages/PricingPage.vue
Normal 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>
|
||||
|
||||
18
frontend/src/pages/PrivacyPage.vue
Normal file
18
frontend/src/pages/PrivacyPage.vue
Normal 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>
|
||||
|
||||
100
frontend/src/pages/RegisterPage.vue
Normal file
100
frontend/src/pages/RegisterPage.vue
Normal 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>
|
||||
|
||||
83
frontend/src/pages/ResetPasswordPage.vue
Normal file
83
frontend/src/pages/ResetPasswordPage.vue
Normal 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>
|
||||
|
||||
20
frontend/src/pages/TermsPage.vue
Normal file
20
frontend/src/pages/TermsPage.vue
Normal 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>
|
||||
|
||||
61
frontend/src/pages/VerifyEmailPage.vue
Normal file
61
frontend/src/pages/VerifyEmailPage.vue
Normal 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>
|
||||
354
frontend/src/pages/admin/AdminBillingPage.vue
Normal file
354
frontend/src/pages/admin/AdminBillingPage.vue
Normal 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>
|
||||
134
frontend/src/pages/admin/AdminConfigPage.vue
Normal file
134
frontend/src/pages/admin/AdminConfigPage.vue
Normal 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>
|
||||
70
frontend/src/pages/admin/AdminHomePage.vue
Normal file
70
frontend/src/pages/admin/AdminHomePage.vue
Normal 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>
|
||||
464
frontend/src/pages/admin/AdminIntegrationsPage.vue
Normal file
464
frontend/src/pages/admin/AdminIntegrationsPage.vue
Normal 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>
|
||||
204
frontend/src/pages/admin/AdminTasksPage.vue
Normal file
204
frontend/src/pages/admin/AdminTasksPage.vue
Normal 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>
|
||||
163
frontend/src/pages/admin/AdminUsersPage.vue
Normal file
163
frontend/src/pages/admin/AdminUsersPage.vue
Normal 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>
|
||||
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal file
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal 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>
|
||||
202
frontend/src/pages/dashboard/DashboardBillingPage.vue
Normal file
202
frontend/src/pages/dashboard/DashboardBillingPage.vue
Normal 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>
|
||||
344
frontend/src/pages/dashboard/DashboardHistoryPage.vue
Normal file
344
frontend/src/pages/dashboard/DashboardHistoryPage.vue
Normal 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>
|
||||
143
frontend/src/pages/dashboard/DashboardHomePage.vue
Normal file
143
frontend/src/pages/dashboard/DashboardHomePage.vue
Normal 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>
|
||||
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal file
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal 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>
|
||||
524
frontend/src/services/api.ts
Normal file
524
frontend/src/services/api.ts
Normal 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)
|
||||
}
|
||||
129
frontend/src/services/http.ts
Normal file
129
frontend/src/services/http.ts
Normal 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)
|
||||
}
|
||||
|
||||
66
frontend/src/stores/auth.ts
Normal file
66
frontend/src/stores/auth.ts
Normal 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
45
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
23
frontend/src/utils/format.ts
Normal file
23
frontend/src/utils/format.ts
Normal 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)
|
||||
}
|
||||
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal 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],
|
||||
}
|
||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
29
frontend/vite.config.ts
Normal 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
423
migrations/001_init.sql
Normal 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;
|
||||
|
||||
9
migrations/002_task_client_ip.sql
Normal file
9
migrations/002_task_client_ip.sql
Normal 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;
|
||||
|
||||
6
migrations/003_add_compression_rate.sql
Normal file
6
migrations/003_add_compression_rate.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN IF NOT EXISTS compression_rate SMALLINT;
|
||||
|
||||
COMMIT;
|
||||
11
migrations/004_add_bonus_units.sql
Normal file
11
migrations/004_add_bonus_units.sql
Normal 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
1553
src/api/admin.rs
Normal file
File diff suppressed because it is too large
Load Diff
596
src/api/auth.rs
Normal file
596
src/api/auth.rs
Normal 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
738
src/api/billing.rs
Normal 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
1218
src/api/compress.rs
Normal file
File diff suppressed because it is too large
Load Diff
229
src/api/context.rs
Normal file
229
src/api/context.rs
Normal 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
343
src/api/downloads.rs
Normal 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
8
src/api/envelope.rs
Normal 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
39
src/api/health.rs
Normal 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
66
src/api/mod.rs
Normal 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
6
src/api/response.rs
Normal 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
987
src/api/tasks.rs
Normal 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
912
src/api/user.rs
Normal 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
520
src/api/webhooks.rs
Normal 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(¤cy)
|
||||
.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(¤cy)
|
||||
.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
60
src/auth.rs
Normal 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
161
src/config.rs
Normal 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
136
src/error.rs
Normal 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
64
src/main.rs
Normal 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
135
src/services/billing.rs
Normal 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
204
src/services/bootstrap.rs
Normal 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
533
src/services/compress.rs
Normal 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
341
src/services/idempotency.rs
Normal 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
344
src/services/mail.rs
Normal 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
7
src/services/mod.rs
Normal 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
73
src/services/quota.rs
Normal 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
227
src/services/settings.rs
Normal 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
Reference in New Issue
Block a user