# AGENTS.md - Rust 后端

## 项目概述

微信小程序天气数据采集后端。用户通过微信认证，上传天气观测数据，并通过 REST API 管理数据。

---

## 技术栈

| 技术 | 版本 | 说明 |
|------|------|------|
| Rust | edition 2021 | 主力语言 |
| actix-web | 4.11 | Web 框架 |
| sqlx | 0.8.6 | PostgreSQL 连接 |
| serde | 1.0 | 序列化 |
| jsonwebtoken | 9.3 | JWT 认证 |
| reqwest | 0.12 | HTTP 客户端 |
| chrono | 0.4 | 时间处理 |

---

## 项目结构

```
src/
├── main.rs              # 入口文件，服务器配置，路由注册
├── auth.rs              # JWT 中间件，令牌生成/验证
├── db.rs                # 数据库操作（通过 sqlx 执行原始 SQL）
├── models.rs            # 数据结构（Claims、User、WeatherData 等）
├── config.rs            # 配置加载（支持多环境：config/*.toml）
├── error.rs             # 错误处理
├── alipay.rs            # 支付宝签名模块（RSA2 sign/verify、URL encode）
├── rate_limiter.rs      # 滑动窗口 Rate Limiter（5次/分钟/IP）
└── handlers/            # 路由处理器模块
    ├── mod.rs           # 模块导出
    ├── meta.rs         # 根路径状态页 (/), 健康检查
    ├── auth.rs          # 登录相关 (login, refresh-token, mock-login)
    ├── weather.rs       # 天气数据 CRUD
    ├── user.rs          # 用户相关
    ├── admin.rs         # 管理员功能
    ├── payment.rs       # 支付相关（支付宝网页支付）
    ├── favorites.rs     # 收藏功能
    ├── health.rs        # 健康检查 (/health)
    └── static_files.rs  # 静态文件服务
```
config/
├── default.toml         # 默认配置（所有环境的共同默认值）
├── development.toml    # 开发/测试环境配置
└── production.toml      # 生产环境配置

migrations/              # 数据库迁移 SQL
tests/                   # 集成测试
static/                  # 静态文件
│   ├── css/
│   │   ├── report.css      # PDF 报表样式
│   │   └── style.css       # 通用样式
│   ├── js/
│   │   ├── afterbody.js   # PDF 生成模块（html2pdf）
│   │   └── ...
│   └── katex/              # KaTeX 数学公式
deploy.sh               # 部署脚本
.env.example           # 环境变量模板
```

---

## 服务状态页

### 根路径 `/`

访问根路径返回服务状态 HTML 页面，显示：
- 服务名称
- 数据库连接状态
- **版本号**（从 `Cargo.toml` 编译时嵌入）

### 健康检查 `/health`

返回 JSON 格式健康状态：

```json
{
  "status": "ok",
  "database": "connected",
  "version": "0.3.0"
}
```

---

## PDF 报表生成

### 功能位置

`static/js/afterbody.js` - 使用 `html2pdf` 库生成 PDF 报表

### 依赖

- `html2pdf` - HTML 转 PDF 库
- `jspdf` - PDF 生成库
- `html2canvas` - HTML 转图片库

### 静态文件依赖

| 文件 | 用途 |
|------|------|
| `/static/css/style.css` | PDF 报表样式 |
| `/static/js/afterbody.js` | PDF 生成逻辑 |

### 生成流程

1. 前端调用后端获取天气数据
2. 后端返回 `weatherData` JSON
3. 前端加载 `afterbody.js`，将数据注入 `window.weatherData`
4. `afterbody.js` 构建 HTML 并调用 `html2pdf` 生成 PDF

### 注意事项

- ⚠️ 生成 PDF 前需确保 `/static/css/report.css` 存在
- 建议在 PDF 生成前检测静态文件可用性

---

## 配置管理

### 配置文件方式（推荐）

通过 `APP_ENV` 环境变量选择配置文件：

```bash
APP_ENV=development cargo run  # 使用 config/development.toml
APP_ENV=production cargo run   # 使用 config/production.toml
```

### 直接环境变量方式

最高优先级，可覆盖配置文件：

```env
APP_DATABASE_URL=postgres://user:pass@host:5432/dbname
APP_JWT_SECRET=your_secret_key
APP_WECHAT_APPID=wx...
```

### 配置文件优先级

`环境变量 > production.toml > development.toml > default.toml`

### 版本管理

**版本号统一在 `Cargo.toml` 中管理**：

```toml
[package]
name = "rust-backend"
version = "0.3.0"  # 唯一版本定义
edition = "2024"
```

**不再使用 `app_version` 字段**（已移除）。

---

## 数据库

### 连接信息

| 环境 | 连接字符串 |
|------|-----------|
| 测试 | `postgres://milkydata:password@127.0.0.1:5432/milkydata_dev` |
| 生产 | `postgres://milkydata:password@127.0.0.1:5432/milkydata` |

> ⚠️ Docker PostgreSQL 监听在 `127.0.0.1` 而非 `localhost`

### 连接数据库

```bash
# 查看容器
docker ps | grep postgres

# 连接数据库（容器内）
docker exec -it postgres_container psql -U postgres -d milkydata_dev
```

### 表结构

#### users 表

```sql
id                  INTEGER PRIMARY KEY
openid              VARCHAR UNIQUE（微信用户 ID）
name                VARCHAR（默认为 openid 前 8 个字符）
type                INTEGER（硬编码为 2）
is_paid             BOOLEAN DEFAULT false
is_admin            BOOLEAN DEFAULT false
paid_expires_at     TIMESTAMPTZ DEFAULT NULL
```

#### weather_data 表

```sql
id                  INTEGER PRIMARY KEY
user_id             INTEGER（外键，关联 users 表）
-- 30+ 个天气测量字段列
```

#### refresh_tokens 表

```sql
id                  SERIAL PRIMARY KEY
user_id             INTEGER（外键，关联 users 表）
token               VARCHAR（Refresh Token 字符串）
expires_at          TIMESTAMPTZ（过期时间）
created_at          TIMESTAMPTZ（创建时间）
```

#### payment_orders 表

```sql
id                  SERIAL PRIMARY KEY
order_no            VARCHAR UNIQUE（订单号，格式：ASD{timestamp}{random}）
user_id             INTEGER（外键，关联 users 表）
package_type        VARCHAR（套餐类型：monthly/quarterly/annual/permanent）
amount               DECIMAL(10,2)（金额，单位：元）
status              VARCHAR（订单状态：pending/paid/cancelled/expired）
paid_at             TIMESTAMPTZ（支付时间，可空）
created_at          TIMESTAMPTZ（创建时间）
```

---

## 认证流程

1. **登录**：`POST /api/login` 传入微信 code → 调用微信 API → UPSERT 用户 → 返回双 Token
2. **双 Token 机制**：
   - `token`: access_token（24小时），用于 API 认证
   - `refresh_token`: 7天有效期，用于续期 access_token
3. **JWT 声明**：`{exp, iat, user_id, openid, user_type}`
4. **中间件**：`jwt_middleware` 提取 Bearer 令牌，验证后将 Claims 插入请求扩展
5. **处理器访问**：通过 `claims: web::ReqData<Claims>` 参数获取
6. **Token 刷新**：`POST /api/refresh-token` 用 refresh_token 换取新的 access_token 和 refresh_token

### 公开接口

| 接口 | 说明 |
|------|------|
| `POST /api/login` | 微信登录，返回双 Token |
| `POST /api/refresh-token` | 刷新 access_token |

---

## API 接口

### 公开接口

| 接口 | 说明 |
|------|------|
| `GET /` | 服务状态页（显示版本、数据库连接状态） |
| `GET /health` | 健康检查 |
| `POST /api/login` | 微信登录，返回双 Token |
| `GET /api/mock-login` | Mock 登录（沙箱测试用，受 `MOCK_LOGIN_ENABLED` 环境变量控制） |
| `POST /api/refresh-token` | 刷新 access_token |
| `GET /weather/details` | 获取天气详情（支持 JWT 或 temp_token） |
| `GET /static/{tail:*}` | 静态文件 |

### 受保护接口（需要 JWT）

| 接口 | 说明 |
|------|------|
| `POST /api/post-weather-data` | 上传天气数据（带配额检查） |
| `GET /api/user/profile` | 获取当前用户信息 |
| `PUT /api/user/profile` | 保存用户信息 |
| `GET /weather` | 分页列出用户的天气数据 |
| `POST /api/generate-temp-token/{resource_id}` | 生成 10 分钟分享令牌 |
| `DELETE /weather/delete/{id}` | 删除天气记录 |
| `GET /api/favorites` | 获取收藏列表 |
| `POST /api/favorites/{id}` | 添加收藏 |
| `DELETE /api/favorites/{id}` | 删除收藏 |
| `GET /api/user/quota` | 获取用户配额 |

### 管理员接口（需要 JWT + is_admin）

| 接口 | 说明 |
|------|------|
| `PUT /api/admin/users/{id}/payment` | 更新用户支付状态 |
| `GET /api/admin/users/{id}` | 获取用户信息 |

### 登录码接口（网页授权）

| 接口 | 说明 |
|------|------|
| `POST /api/web-login/code` | 小程序用微信 code 换取 display_code |
| `POST /api/web-login/confirm` | 小程序确认登录（需 JWT + code） |
| `GET /payment/generate-code` | 网页端生成登录码（已废弃，不建议使用） |
| `GET /payment/login-status` | 网页端轮询登录状态 |

### 支付接口

| 接口 | 说明 |
|------|------|
| `GET /payment` | 套餐选择页（无需认证，外部浏览器访问） |
| `GET /payment/page` | 支付页面（需 JWT，支持 URL 参数 `jwt` 或 Cookie） |
| `POST /api/payment/create-order` | 创建订单（需 JWT） |
| `GET /payment/pay` | 唤起支付宝支付（需 JWT + order_no） |
| `POST /payment/notify` | 支付宝异步回调通知（需 RSA 签名验证，无认证） |
| `GET /payment/success` | 支付成功页（需 order_no） |
| `POST /api/payment/mock-confirm` | 模拟支付确认（沙箱测试用） |

### 支付宝配置（可选）

不配置则使用模拟支付；配置后启用真实支付宝支付：

```env
# .env 或 config/*.toml
ALIPAY_APP_ID=your_alipay_app_id
ALIPAY_PRIVATE_KEY=your_private_key_content
ALIPAY_ALIPAY_PUBLIC_KEY=alipay_public_key_content
ALIPAY_GATEWAY=https://openapi-sandbox.dl.alipaydev.com/gateway.do  # 沙箱
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do                 # 正式
```

> 正式环境必须使用 HTTPS（服务已支持 TLS）

---

## 代码模式

### 处理器模式

```rust
#[post("/api/endpoint")]
async fn handler(
    pool: web::Data<PgPool>,
    claims: web::ReqData<Claims>,
    body: web::Json<RequestBody>,
) -> impl Responder {
    match db::function(pool.get_ref(), claims.user_id).await {
        Ok(data) => HttpResponse::Ok().json(serde_json::json!({
            "success": true,
            "data": data
        })),
        Err(e) => HttpResponse::Ok().json(serde_json::json!({
            "success": false,
            "errcode": 500,
            "errmsg": e
        }))
    }
}
```

### 数据库函数模式

```rust
pub async fn function_name(pool: &PgPool, param: i32) -> Result<Type, String> {
    match sqlx::query_as::<_, Type>("SELECT ...")
        .bind(param)
        .fetch_optional(pool)
        .await
    {
        Ok(Some(row)) => Ok(row),
        Ok(None) => Err("Not found".to_string()),
        Err(e) => Err(format!("Query failed: {}", e)),
    }
}
```

### 错误响应格式

```json
{"success": false, "errcode": 403, "errmsg": "Error message"}
```

---

## ⚠️ 常见坑

### 1. config crate 路径问题

`File::with_name()` 使用当前工作目录，而非 `CARGO_MANIFEST_DIR`。

```rust
// ❌ 错误
let config = Config::builder()
    .add_source(File::with_name("config"))
    .build();

// ✅ 正确：使用 CARGO_MANIFEST_DIR
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let config_path = Path::new(manifest_dir).join("config");
```

### 2. TOML 配置结构

**保持扁平结构，不使用 section headers**。

```toml
# ❌ 错误：section headers 导致合并冲突
[development]
database_url = "..."

# ✅ 正确：扁平结构
database_url = "..."
```

### 3. serde skip 与查询冲突

`skip_deserializing` 会导致 SELECT 时字段缺失错误。

```rust
// ❌ 错误：skip_deserializing 影响查询
#[serde(rename = "isFavorite", skip_deserializing)]
pub is_favorite: bool,

// ✅ 正确：使用 default
#[serde(rename = "isFavorite", default)]
pub is_favorite: bool,
```

### 4. JWT 中不能添加 is_admin

**禁止在 JWT Claims 中添加 `is_admin`**，必须查询数据库验证。

### 5. 配额决策不能信任 JWT

必须查询数据库检查 `is_paid_active`，而非信任 JWT 中的声明。

### 6. SQL 保留关键字

SQL 中使用 `desc` 等保留关键字时必须加双引号：

```sql
-- ❌ 错误
ORDER BY desc

-- ✅ 正确
ORDER BY "desc"
```

### 7. actix-ratelimit 不兼容

**actix-ratelimit 0.3.1 与 actix-web 4.x 不兼容**。

备选方案：actix-web-lab、手动 HashMap 实现、Nginx 层限流。

---

## 支付系统

### 支付流程（登录码授权模式）

小程序内无法直接接入支付宝支付，采用外部浏览器中转方案：

```
小程序(升级页) → outter页面 → 外部浏览器 → /payment?code=xxx → 支付宝
```

1. 用户在小程序升级页点击「输入登录码」
2. 小程序获取微信 code，换取 display_code
3. 小程序自动调用 `POST /api/web-login/confirm` 确认登录（无需用户操作）
4. 小程序跳转 `outter` 页面，URL 指向 `/payment?code=xxx`
5. outter 页面提示用户在外部浏览器打开
6. 用户在手机浏览器打开 `/payment?code=xxx`
7. 网页轮询 `/payment/login-status?code=xxx`，检测到已确认后自动登录
8. 显示套餐选择页
9. 用户选择套餐，点击「去支付」→ 跳转到 `/payment/page?package=xxx&jwt=xxx`
10. 后端调用 `alipay.trade.page.pay` API，返回支付跳转
11. 支付宝沙箱/正式环境展示支付页面
12. 支付完成后，支付宝异步通知 `/payment/notify`
13. 跳转成功页 `/payment/success?order_no=xxx`

### 登录码相关接口

| 接口 | 说明 |
|------|------|
| `POST /api/web-login/code` | 小程序用微信 code 换取 display_code |
| `POST /api/web-login/confirm` | 小程序确认登录（需 JWT + code） |
| `GET /payment/generate-code` | 网页端生成登录码（已废弃，不建议使用） |
| `GET /payment/login-status` | 网页端轮询登录状态（返回 token 表示已确认） |

### Mock 登录（沙箱测试）

沙箱环境下无法获取真实微信 code，使用 Mock 登录获取测试 JWT：

```bash
# 获取 mock JWT（需服务器设置 MOCK_LOGIN_ENABLED=true）
curl http://127.0.0.1:8080/api/mock-login

# 返回格式
{"success":true,"token":"eyJ0eX...","refresh_token":"MTAxOj...aW9u","user_id":101}
```

Mock 登录支持 `user_id` 参数指定已有用户，或自动创建新用户。环境变量 `MOCK_LOGIN_ENABLED=false` 时返回 403。

### Mock 支付（沙箱测试）

当 `.env` 中未配置 `ALIPAY_*` 环境变量时，自动启用 Mock 支付模式：

**流程**：
```
用户访问 /payment/page → 显示 Mock 支付页面 → 点击"确认模拟支付" → 调用 /api/payment/mock-confirm → 激活会员
```

**特点**：
- 无需真实支付宝配置
- Mock 页面显示订单号和套餐信息
- 点击后通过 `POST /api/payment/mock-confirm` 激活会员
- 有效期按套餐天数累加计算

**启用真实支付**：
在 `.env` 中配置 `ALIPAY_APP_ID`、`ALIPAY_PRIVATE_KEY`、`ALIPAY_ALIPAY_PUBLIC_KEY`、`ALIPAY_GATEWAY`，重启服务后自动禁用 Mock 模式。

### 支付模式

| 模式 | 来源 | 处理方式 |
|------|------|---------|
| 微信支付 | `payment_orders` | 收到微信回调后确认 |
| 邀请码 | `invitation_codes` | 核销后直接激活 |
| 管理员开通 | 直接 UPDATE | 后台手动设置 |

### 累积计算逻辑

用户多次购买时，有效期会累加而非覆盖：

```rust
let base_time = std::cmp::max(current_expires, Utc::now());
let new_expires = base_time + days(pkg_days);
```

### 永久会员

永久会员的 `expires_at` 设为 `2099-12-31` 而非 NULL。

### 配额限制

- 未付费用户限制为 `FREE_USER_DATA_LIMIT` 条记录
- 付费用户（活跃状态）无限制

---

## 部署

### 服务器信息

| 环境 | 域名 | 端口 | 远程目录 |
|------|------|------|---------|
| 测试 | xmclassmate.top/dev | 8080 | /root/rust/rust_backend_dev |
| 生产 | xmclassmate.top | 4433 | /root/rust/rust_backend |

### systemd 服务

| 环境 | systemd unit | 工作目录 | APP_ENV |
|------|-------------|---------|---------|
| 测试 | `rust-backend-dev.service` | `/root/rust/rust_backend_dev` | `development` |
| 生产 | `rust-backend.service` | `/root/rust/rust_backend` | (使用 .env) |

> ⚠️ systemd 配置使用 `EnvironmentFile=` 加载 `.env` 文件（生产、开发环境均如此）
> 注意：`EnvironmentFile` 不支持多行值，多行 PEM 密钥需 base64 编码或单行格式

### 部署命令

```bash
./deploy.sh development   # 部署到测试服务器
./deploy.sh production    # 部署到生产服务器
```

**deploy.sh 选项：**

| 选项 | 说明 |
|------|------|
| `--dry-run` | 预览模式，不执行实际操作 |
| `--yes, -y` | 跳过确认提示 |
| `--skip-tests` | 跳过部署后测试 |
| `--rollback` | 回滚到上一个备份版本 |
| `--backup-list` | 列出可用备份 |
| `--help, -h` | 显示帮助信息 |

**示例：**
```bash
./deploy.sh production --dry-run    # 预览生产部署
./deploy.sh production --yes        # 无需确认直接部署
./deploy.sh development --skip-tests  # 跳过测试
./deploy.sh production --rollback   # 回滚到上一个版本
./deploy.sh production --backup-list # 列出可用备份
```

**deploy.sh 功能**：依赖检查 → 编译 → 备份旧版本 → 清理旧备份（保留5个不同版本）→ 上传二进制/配置/迁移/脚本 → 重启服务 → **数据库备份** → **执行迁移** → 部署后测试 → 记录日志 → Webhook 通知

**二进制备份**：
- 命名格式：`{project}.backup.{timestamp}.{gitHash}.{md5前8位}`
- 示例：`rust-backend.backup.20260419_204600.a91948b.7f3e2d1c`
- 自动保留 5 个**不同版本**的备份（相同版本只保留最早的）
- 清理时按 MD5 去重，防止同一版本的多个备份占用空间

**数据库备份**：
- 位置：`${REMOTE_DIR}/backups/${DB_NAME}_*.dump`
- 保留数量：1个
- 格式：`pg_dump -Fc`（自定义格式，可压缩）

**数据库迁移**：
- 位置：`${REMOTE_DIR}/migrations/*.sql`
- 自动检测：检查表是否存在，跳过已执行的迁移
- 迁移文件名格式：`{序号}_{描述}.sql`

**自动回滚**：部署后测试失败时，自动回滚到上一个正常版本并重启服务。
- 回滚标记保存在 `${REMOTE_DIR}/.last_deployed`
- 测试失败后自动执行，无需手动干预
- Webhook 通知会发送失败回滚消息

**手动回滚（支持选择版本）：**
```bash
./deploy.sh production --rollback   # 回滚时可以选择版本
./deploy.sh production --backup-list # 列出所有可用备份
```

回滚时会列出所有备份（按版本分组），用户可以选择回滚到哪个版本。
```

**Webhook 通知（可选）：**
```bash
WEBHOOK_URL="https://example.com/webhook" ./deploy.sh production
```

**部署日志位置**：`/root/rust/rust_backend/deploy.log`

### 部署规范

> **重要**：所有部署操作**必须使用 `deploy.sh`**，禁止手动操作服务器文件。

**禁止事项：**
- ❌ 直接 SSH 到服务器手动上传文件
- ❌ 直接 `scp` 或 `rsync` 二进制文件到服务器
- ❌ 直接 `systemctl restart` 服务而不通过脚本
- ❌ 直接修改服务器上的配置文件

**deploy.sh 已包含的安全保障：**
- ✅ 部署前自动备份旧版本
- ✅ 部署前自动备份数据库
- ✅ 自动检查依赖
- ✅ 自动测试
- ✅ 部署日志记录

### 首次部署准备

首次在服务器上部署时，需要手动创建 systemd 服务文件和环境配置：

**1. 创建 systemd 服务文件：**

开发环境服务：`/etc/systemd/system/rust-backend-dev.service`

生产环境服务：`/etc/systemd/system/rust-backend.service`

参考本地文件：`rust-backend.service`（生产）、`rust-backend-dev.service`（开发）

**2. 创建 .env 环境配置：**

开发环境：`/root/rust/rust_backend_dev/.env`
生产环境：`/root/rust/rust_backend/.env`

参考 `.env.example` 或询问运维获取。

**3. 启用服务：**

```bash
systemctl daemon-reload
systemctl enable rust-backend-dev.service  # 开发环境
systemctl enable rust-backend.service       # 生产环境
```

### 前端部署

前端部署由微信开发者工具单独完成，详见 `ASD-fronted/AGENTS.md`：

```typescript
// ASD-fronted/miniprogram/config/env.ts
const CURRENT_ENV: 'development' | 'production' = 'production';  // 发布前切换
```

切换后通过微信开发者工具上传。

### 部署后测试

部署后验证是否成功，**直接运行** `test_deployment.sh`：

```bash
# 设置测试域名
export TEST_DOMAIN="https://xmclassmate.top/dev"   # 开发环境
export TEST_DOMAIN="https://xmclassmate.top"      # 生产环境

# 运行测试
./test_deployment.sh
```

**注意**：`deploy.sh` 会自动执行此测试，但也可手动单独运行验证。

测试内容：
- 本地后端健康检查
- API 端点检查
- 静态文件检查

---

## 禁止事项

- ❌ 在 JWT Claims 中添加 `is_admin`（必须查数据库）
- ❌ 信任 JWT 中的 `is_paid` 来做配额决策
- ❌ 使用 `as any` 类型错误抑制
- ❌ 添加审计日志
- ❌ **直接操作服务器文件**（必须通过部署脚本）

## 必须事项

- ✅ 管理员接口必须通过数据库查询验证管理员身份
- ✅ 已认证处理器使用 `web::ReqData<Claims>`
- ✅ 遵循现有的错误响应格式
- ✅ 通过数据库查询检查 `is_paid_active`
- ✅ SQL 中使用保留关键字时加双引号

---

## 常见任务

### 添加新接口

1. 在 `main.rs` 中创建处理器函数
2. 如需要，在 `db.rs` 中添加数据库函数
3. 在 `create_server_config` 中注册
4. 如需要，在 `models.rs` 中添加请求/响应结构体

### 路由配置结构

```rust
App::new()
    .app_data(web::Data::new(pool))
    .app_data(web::Data::new(http_client))
    .app_data(web::Data::new(app_state))
    // 公开接口（无 middleware）
    .service(login)
    // 受保护接口（JWT middleware）
    .service(
        web::scope("")
            .wrap(from_fn(jwt_middleware))
            .service(post_weather_data)
    )
    // 静态文件和健康检查放最后
    .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
    .service(health_check)
```

### 修改数据库

1. 在 `migrations/` 中创建迁移 SQL
2. 在 PostgreSQL 上手动执行迁移
3. 更新 `models.rs` 中的结构体
4. 更新 `db.rs` 中的数据库函数

---

## 开发命令

```bash
cargo build                    # 编译
APP_ENV=development cargo run  # 开发环境运行
APP_ENV=production cargo run   # 生产环境运行
cargo test                     # 运行测试
cargo clippy                  # 代码检查
```
