# Rust 后端 - 改进计划

## 命名规范

### 核心原则

**前后端通过 JSON 通信，字段命名必须统一。**

| 层级 | 命名风格 | 示例 | 说明 |
|------|---------|------|------|
| 前端 TypeScript | camelCase | `isFavorite` | 前端内部使用 |
| API 请求/响应 | camelCase | `isFavorite` | 前后端交互 |
| Rust 结构体 | snake_case | `is_favorite` | Rust 内部 |
| 数据库 | snake_case | `is_favorite` | 数据库字段 |

### 后端 Rust serde 配置

Rust 后端必须使用 `#[serde(rename = "camelCase")]` 确保序列化时使用 camelCase：

```rust
#[derive(Serialize, Deserialize)]
pub struct WeatherData {
    #[serde(rename = "id")]
    pub id: i32,
    #[serde(rename = "isFavorite")]
    pub is_favorite: bool,
    #[serde(rename = "inspectionType")]
    pub inspection_type: String,
}
```

### 前端 TypeScript 接口定义

```typescript
interface WeatherData {
  id: number;
  isFavorite: boolean;       // ✅ camelCase
  inspectionType: string;    // ✅ camelCase
}
```

### 数据库迁移注意

如果字段名使用 snake_case，JSON 序列化时需要转换：
- Rust → JSON：`is_favorite` → `isFavorite`（serde 自动处理）
- JSON → Rust：`isFavorite` → `is_favorite`（serde 自动处理）

### 已统一字段（2026-04-17）

| 数据库字段 | JSON 键 | 说明 |
|-----------|---------|------|
| is_favorite | isFavorite | 是否收藏 |
| inspection_type | inspectionType | 检测类型 |
| assignment_number | assignmentNumber | 任务编号 |
| paid_expires_at | paidExpiresAt | 付费到期时间 |
| is_paid_active | isPaidActive | 付费是否活跃 |

---

## 服务器资源评估

### 当前服务器配置

| 项目 | 规格 |
|------|------|
| CPU | AMD EPYC 7K62 48-Core |
| 内存 | 2 GB |
| 磁盘 | 30 GB (已用 17GB) |

### 当前资源占用

| 容器/服务 | 内存占用 | 说明 |
|----------|---------|------|
| PostgreSQL | 85.5 MB | 数据库 |
| rustweb (后端) | 844 KB | Rust 后端服务 |
| Gitea | 221.6 MB | 代码仓库 |
| Bitwarden | 251.1 MB | 密码管理 |
| OpenResty | 27.5 MB | 反向代理 |

### 业务数据分析

| 指标 | 数值 |
|------|------|
| 用户数 | 2,358 |
| 天气数据 | 6,563 条 |
| 数据库大小 | 12 MB |
| 日活用户（预估） | ~100-500 |

### 资源需求评估

#### 小规模（< 1000 用户）

| 资源 | 推荐配置 | 说明 |
|------|---------|------|
| 内存 | 512 MB - 1 GB | Rust + PostgreSQL 足够 |
| CPU | 1 核 | 单核即可处理大部分请求 |
| 磁盘 | 10 GB | 数据库增长缓慢 |

#### 中等规模（1000 - 10000 用户）

| 资源 | 推荐配置 | 说明 |
|------|---------|------|
| 内存 | 1 - 2 GB | 需要缓存和连接池 |
| CPU | 2 - 4 核 | 并发处理 |
| 磁盘 | 20 GB | 数据库 + 日志 |

#### 大规模（> 10000 用户）

| 资源 | 推荐配置 | 说明 |
|------|---------|------|
| 内存 | 2 - 4 GB | 多连接 + 缓存 |
| CPU | 4 - 8 核 | 高并发 |
| 磁盘 | 50 GB+ | SSD 推荐 |

### Rust 后端特性

| 特性 | 影响 |
|------|------|
| 内存效率 | 静态编译，无 GC，内存占用极低（实测 < 1MB） |
| CPU 效率 | 高并发处理能力，单核可处理数千 QPS |
| 启动速度 | 毫秒级启动 |
| 依赖 | 仅 28 行 Cargo.toml |

### 瓶颈分析

| 组件 | 当前瓶颈 | 建议 |
|------|---------|------|
| PostgreSQL | 内存 85MB | 用户增长到 10k+ 可考虑 2GB |
| Rust 后端 | 无 | 目前的连接池配置足够 |
| 磁盘 I/O | HDD | 用户 10k+ 建议升级 SSD |
| 网络带宽 | - | 取决于日活和每次请求大小 |

### 推荐配置

| 用户规模 | 升级方案 |
|---------|---------|
| < 5000 | 保持现状 |
| 5000-10000 | PostgreSQL 内存调整到 512MB |
| > 10000 | 升级到 4GB+ 服务器，SSD |

### 优化建议

```toml
# PostgreSQL 连接池配置（当前使用默认）
# 建议根据用户量调整
sqlx = { version = "0.8.6", default-pool-size = 10 }  # 默认 5
```

**结论**：当前 2GB 服务器对 Rust 后端来说资源非常充裕，预计可支撑 2-3 万用户。主要瓶颈可能在 PostgreSQL 的连接管理和磁盘 I/O。

---

## 二、生产环境数据库迁移

### 环境信息

| 环境 | 数据库名 | 数据量 |
|------|---------|--------|
| 测试 | `milkydata_dev` | users: 1, weather_data: 4, payment_orders: 6 |
| 生产 | `milkydata` | users: 2358, weather_data: 6563, payment_orders: 0 |

### 连接方式

```bash
ssh root@1panel-server
docker exec -it 1Panel-postgresql-FtMo psql -U milky -d milkydata_dev  # 测试环境
docker exec -it 1Panel-postgresql-FtMo psql -U milky -d milkydata     # 生产环境
```

### 当前 schema 差异

| 表 | 测试环境 | 生产环境 | 差异 |
|---|---------|---------|------|
| users | ✅ | ✅ | 无 |
| payment_orders | ✅ | ✅ | 无 |
| weather_data | ✅ | ✅ | **生产缺少 `is_favorite` 列** |

### 迁移步骤

#### ⚠️ 重要：执行前必须备份

```bash
ssh root@1panel-server
# 备份生产数据库
docker exec 1Panel-postgresql-FtMo pg_dump -U milky milkydata > /tmp/milkydata_backup_$(date +%Y%m%d_%H%M%S).sql

# 验证备份成功
ls -la /tmp/milkydata_backup_*.sql
```

#### 步骤 1：添加缺失列

生产环境执行（PostgreSQL 11+ 几乎无影响）：

```sql
-- 添加 is_favorite 列到 weather_data 表
ALTER TABLE weather_data ADD COLUMN is_favorite BOOLEAN NOT NULL DEFAULT false;
```

#### 步骤 2：验证

```sql
-- 检查列是否添加成功
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'weather_data' AND column_name = 'is_favorite';
```

#### 步骤 3：未来新增 memberships 表

当需要会员系统时，在生产环境执行：

```sql
-- 1. 创建 memberships 表
CREATE TABLE memberships (
    id SERIAL PRIMARY KEY,
    user_id INTEGER UNIQUE REFERENCES users(id),
    is_paid BOOLEAN DEFAULT false,
    paid_expires_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_memberships_user_id ON memberships(user_id);

-- 2. 从 users 表同步现有数据
INSERT INTO memberships (user_id, is_paid, paid_expires_at)
SELECT id, is_paid, paid_expires_at FROM users
ON CONFLICT (user_id) DO NOTHING;

-- 3. 创建 invitation_codes 表
CREATE TABLE invitation_codes (
    id SERIAL PRIMARY KEY,
    code VARCHAR(32) UNIQUE NOT NULL,
    package_type VARCHAR(20) NOT NULL,
    paid_days INTEGER NOT NULL,
    source VARCHAR(20) DEFAULT 'invitation',
    used_by INTEGER REFERENCES users(id),
    used_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ,
    is_used BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
```

### 回滚方案

如果需要回滚：

```sql
-- 删除新增的列/表
ALTER TABLE weather_data DROP COLUMN is_favorite;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS invitation_codes;
```

---

## 一、代码质量与架构改进

### 1.1 代码组织问题
- [x] **所有路由处理器都在 main.rs 中** ✅
  - 改进：将 handler 函数按功能模块拆分到 `handlers/` 目录
  - 目录结构：
    - `handlers/auth.rs` - 登录相关
    - `handlers/weather.rs` - 天气数据 CRUD
    - `handlers/user.rs` - 用户相关
    - `handlers/admin.rs` - 管理员功能
    - `handlers/health.rs` - 健康检查
    - `handlers/static_files.rs` - 静态文件服务
  - 完成时间：2026-04-15
  - 附加：发现并修复静态文件被 JWT middleware 拦截的问题（路由顺序）

- [x] **config.rs 是死代码** ✅
  - 现状：AGENTS.md 中提到 config.rs 未被使用
  - 改进：保留文件，标注为待清理
  - 影响：减少代码混淆
  - 完成时间：2026-04-15

### 1.2 数据库操作优化
- [ ] **重复查询问题**
  - 位置：`insert_weather_data` 中多次查询用户信息
  - 改进：合并查询或使用缓存
  - 影响：减少数据库负载

- [ ] **缺少数据库索引**
  - 现状：未见索引定义
  - 改进：添加常用查询字段索引
    ```sql
    CREATE INDEX idx_weather_data_user_id ON weather_data(user_id);
    CREATE INDEX idx_weather_data_date ON weather_data(date);
    CREATE INDEX idx_users_openid ON users(openid);
    ```

### 1.3 错误处理改进
- [x] **错误响应格式不统一** ✅
  - 现状：部分返回 `ErrorResponse`，部分返回 JSON 字符串
  - 改进：统一使用 `ErrorResponse` 结构体
  - 彰响：前端解析更一致
  - 完成时间：2026-04-15

- [x] **错误信息暴露过多** ✅
  - 现状：部分错误直接返回数据库错误信息
  - 改进：区分用户友好错误和开发者错误
  - 影响：安全性提升
  - 完成时间：2026-04-15
  - 修改：移除所有 `errmsg: Some(e.to_string())`，错误详情只记录到日志

## 二、安全性改进

### 2.1 认证安全
- [ ] **缺少请求频率限制** ⚠️
  - 风险：API 可能被滥用或遭受暴力攻击
  - 改进：添加 Rate Limiting 中间件
  - 配置：
    - 登录接口：5 次/分钟
    - 数据上传：30 次/分钟
    - 查询接口：100 次/分钟
  - **状态**：actix-ratelimit 0.3.1 与 actix-web 4.x 不兼容（Transform trait 问题）
  - **备选方案**：
    1. `actix-web-lab` - 社区维护的中间件库
    2. 手动实现 - 使用 `std::collections::HashMap` 记录 IP 请求数
    3. Nginx/网关层限流

- [ ] **缺少 CSRF 防护**
  - 风险：跨站请求伪造攻击
  - 改进：添加 CSRF Token 验证
  - 实现：对于状态变更操作验证 Token

- [ ] **JWT Secret 管理**
  - 现状：明文存储在环境变量
  - 改进：使用密钥管理服务（如 AWS Secrets Manager）
  - 影响：提高密钥安全性

### 2.2 数据安全
- [ ] **数据库连接字符串明文存储**
  - 位置：`.env` 文件
  - 改进：使用连接字符串加密或密钥管理
  - 影响：防止凭证泄露

- [ ] **缺少敏感数据加密**
  - 现状：用户信息明文存储
  - 改进：对敏感字段加密存储
  - 字段：手机号、姓名等

- [ ] **缺少审计日志**
  - 需求：记录关键操作日志
  - 实现：添加审计日志表和中间件
  - 记录：登录、数据修改、管理员操作

### 2.3 输入验证
- [ ] **后端输入验证不完整**
  - 现状：部分依赖前端验证
  - 改进：添加完整的请求验证
  - 实现：使用 `validator` crate

## 三、性能优化

### 3.1 数据库性能
- [ ] **缺少连接池监控**
  - 现状：连接池配置固定
  - 改进：添加连接池监控和动态调整
  - 指标：连接数、等待时间、超时次数

- [ ] **缺少查询缓存**
  - 场景：用户信息、配置数据
  - 改进：添加 Redis 缓存层
  - 策略：用户信息缓存 5 分钟，配置数据缓存 1 小时

- [ ] **无读写分离支持**
  - 需求：高并发场景下的性能优化
  - 改进：支持主从数据库配置
  - 实现：使用 sqlx 的多数据源支持

### 3.2 API 性能
- [ ] **无响应压缩**
  - 现状：响应未压缩
  - 改进：添加 gzip 压缩中间件
  - 实现：使用 `actix-web` 的压缩功能

- [ ] **缺少 API 缓存**
  - 场景：天气数据列表查询
  - 改进：添加 HTTP 缓存头
  - 实现：`Cache-Control`、`ETag` 等

- [ ] **无 CDN 支持**
  - 场景：静态资源分发
  - 改进：配置 CDN 加速
  - 资源：CSS、JS、图片、字体

## 四、功能完整性改进

### 4.1 API 功能扩展
- [ ] **缺少批量操作接口**
  - 需求：批量删除、批量查询
  - 实现：添加批量操作端点
  - 接口：`POST /weather/batch-delete`、`POST /weather/batch-query`

- [ ] **无数据导出格式支持**
  - 需求：支持 CSV、Excel 导出
  - 实现：添加导出端点
  - 接口：`GET /weather/export?format=csv`

- [ ] **缺少搜索和过滤功能**
  - 需求：按日期、标题、地点搜索
  - 实现：添加搜索参数
  - 参数：`?keyword=xxx&startDate=xxx&endDate=xxx`

### 4.2 用户功能
- [ ] **缺少用户活动统计**
  - 需求：用户使用情况分析
  - 实现：添加统计接口
  - 数据：登录次数、数据上传量、最后活跃时间

- [ ] **无用户偏好设置**
  - 需求：保存用户偏好（如默认区域类型）
  - 实现：添加用户设置表
  - 接口：`GET/PUT /api/user/preferences`

## 五、开发体验改进

### 5.1 测试覆盖
- [ ] **仅有一个基础编译测试**
  - 现状：`tests/integration_test.rs` 只有 `assert_eq!(2 + 2, 4)`
  - 改进：添加完整的测试套件
  - 类型：
    - 单元测试：工具函数、数据结构
    - 集成测试：API 端点
    - 数据库测试：CRUD 操作

- [ ] **缺少 API 测试**
  - 需求：验证 API 行为
  - 实现：使用 `actix-web` 的测试工具
  - 覆盖：正常流程、异常情况、边界条件

### 5.2 文档完善
- [ ] **无 API 文档**
  - 需求：接口文档
  - 实现：使用 OpenAPI/Swagger
  - 工具：`utoipa` crate

- [ ] **缺少部署文档**
  - 内容：环境要求、配置说明、部署步骤
  - 形式：`DEPLOYMENT.md` 文件

- [ ] **无变更日志**
  - 内容：版本更新记录
  - 形式：`CHANGELOG.md` 文件

### 5.3 开发工具
- [ ] **缺少代码质量工具**
  - 工具：`clippy`、`rustfmt`
  - 配置：`.clippy.toml`、`rustfmt.toml`
  - 集成：CI/CD 流程

- [ ] **无热重载开发**
  - 需求：开发时自动重载
  - 实现：使用 `cargo-watch`
  - 命令：`cargo watch -x run`

## 六、监控与运维

### 6.1 日志系统
- [x] **日志格式不统一** ✅
  - 现状：部分使用 `log` 宏，部分使用 `println`
  - 改进：统一使用结构化日志
  - 实现：使用 `tracing` crate
  - 完成时间：2026-04-15
  - 修改文件：Cargo.toml, src/main.rs, src/config.rs

- [x] **无日志聚合** ✅
  - 需求：集中式日志管理
  - 实现：配置日志收集器（如 ELK Stack）
  - 格式：JSON 格式便于解析
  - 状态：✅ 已完成（JSON + 文件轮转，2026-04-15）

- [ ] **缺少性能指标收集** ⚠️
  - 需求：API 响应时间、错误率等
  - 实现：添加 Prometheus 指标
  - **状态**：actix-web-prom 与当前架构不兼容（ServiceFactory Response 类型冲突）
  - **备选方案**：
    1. `actix-web-lab` - 社区维护的中间件库
    2. 手动实现 - 在代码中直接使用 `std::sync::atomic` 收集请求计数
    3. Nginx 层收集 - 反向代理层已有 access log

### 6.2 告警机制
- [ ] **无异常告警**
  - 需求：系统异常时通知
  - 实现：集成告警服务
  - 渠道：邮件、钉钉、企业微信

- [ ] **缺少资源监控**
  - 需求：CPU、内存、磁盘监控
  - 实现：使用系统监控工具
  - 工具：Prometheus + Grafana

### 6.3 健康检查
- [x] **缺少健康检查接口** ✅
  - 需求：负载均衡器健康检查
  - 实现：添加 `/health` 端点
  - 检查：数据库连接、服务状态
  - 完成时间：2026-04-15

## 七、部署改进

### 7.1 部署流程
- [ ] **部署脚本功能简单**
  - 现状：仅支持基本的编译、上传、重启
  - 改进：添加回滚、备份、验证功能
  - 实现：增强 `deploy.sh` 脚本

- [ ] **无蓝绿部署或滚动更新**
  - 需求：零停机部署
  - 实现：使用 Docker + Kubernetes
  - 或者：Nginx 负载均衡 + 多实例

### 7.2 容器化
- [ ] **缺少 Docker 支持**
  - 需求：容器化部署
  - 实现：添加 `Dockerfile` 和 `docker-compose.yml`
  - 优势：环境一致性、易于扩展

### 7.3 CI/CD
- [ ] **无自动化流程**
  - 需求：自动测试、构建、部署
  - 实现：配置 GitHub Actions 或 Gitea Actions
  - 流程：代码提交 → 测试 → 构建 → 部署

## 九、Bug 修复记录

### 9.1 前端输入问题
- [x] **数字输入框无法输入小数点** ✅
  - 问题：用户在输入框输入 "1." 时，末尾小数点会丢失
  - 原因：`bindinput` 时直接用 `parseFloat()` 转换，`parseFloat("1.")` 返回 `1`
  - 修复：移除 `bindinput` 时的数值转换，改为仅在 `blur` 时验证和更新
  - 修改文件：`miniprogram/pkg-asd/asdmain/asdmain.ts`
  - 影响范围：
    - `onMeasuredWindSpeedInput` - 实测风速输入
    - `onPointWindSpeedInput` - 测点风速输入
    - `onPointHeightInput` - 测点高度输入
    - `onWindSpeedInput` - 风向风速数组输入
    - `onWindDirectionInput` - 风向数组输入
  - 完成时间：2026-04-15

## 八、技术债务

### 8.1 高优先级
1. [ ] **拆分 main.rs 中的路由处理器**
   - 影响：代码可维护性
   - 工作量：中等

2. [ ] **添加请求频率限制**
   - 影响：安全性
   - 工作量：小

3. [ ] **统一错误响应格式**
   - 影响：前后端对接
   - 工作量：小

4. [ ] **添加数据库索引**
   - 影响：性能
   - 工作量：小

### 8.2 中优先级
1. [ ] **添加单元测试和集成测试**
   - 影响：代码质量
   - 工作量：大

2. [ ] **实现 API 文档（OpenAPI）**
   - 影响：开发体验
   - 工作量：中等

3. [ ] **添加结构化日志**
   - 影响：运维
   - 工作量：中等

4. [ ] **优化数据库查询**
   - 影响：性能
   - 工作量：中等

### 8.3 低优先级
1. [ ] **实现 Redis 缓存**
   - 影响：性能
   - 工作量：大

2. [ ] **容器化部署**
   - 影响：部署流程
   - 工作量：中等

3. [ ] **添加监控告警**
   - 影响：运维
   - 工作量：大

4. [ ] **实现 CI/CD**
   - 影响：开发效率
   - 工作量：中等

## 十、缺少环境区分机制

### 问题描述

当前后端项目未区分生产环境和开发/测试环境，所有配置混在一个 `.env` 文件中。

**现状分析**：

| 配置项 | 当前做法 | 问题 |
|--------|----------|------|
| DATABASE_URL | 硬编码生产数据库地址 | 开发/测试时无法切换到本地或测试数据库 |
| JWT_SECRET | 混在 .env 中 | 开发环境使用弱密钥存在安全隐患 |
| RUST_LOG | 统一设置为 `info` | 开发时需要 `debug` 级别日志 |
| APP_VERSION | 在 .env 和 Cargo.toml 两处定义 | 版本不一致 |

**.env 当前内容**：
```bash
DATABASE_URL=postgres://milkydata:***@154.37.213.24:5432/milkydata
WECHAT_APPID="wx5b00eb90621802f7"
WECHAT_SECRET="494efc...9bfd"
JWT_SECRET="your_s..._key"
SSL_KEY_PATH=/etc/ssl/private/private.key
SSL_CERT_PATH=/etc/ssl/certs/full_chain.pem
RUST_LOG=info
APP_VERSION="0.2.0"
FREE_USER_DATA_LIMIT=20
```

### 影响

- 开发时连接生产数据库，有误操作风险
- 测试时无法使用独立的测试数据
- 切换环境需要手动修改配置，容易出错
- 生产配置泄露到代码仓库（.env 通常被 gitignore，但部署时容易混淆）

### 解决方案

#### 方案一：使用 config crate（推荐）

**添加依赖**：
```toml
# Cargo.toml
[dependencies]
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
```

**目录结构**：
```
rust-backend/
├── config/
│   ├── default.toml    # 默认配置（开发）
│   ├── development.toml
│   └── production.toml
├── .env                # 本地敏感配置（加入 .gitignore）
└── .env.example        # 配置模板（提交到仓库）
```

**default.toml（开发/测试默认）**：
```toml
database_url = "postgres://milkydata:password@localhost:5432/milkydata_dev"
wechat_appid = "wx_test_appid"
wechat_secret = "test_secret"
jwt_secret = "dev-only-secret-change-in-production"
ssl_key_path = ""
ssl_cert_path = ""
rust_log = "debug"
app_version = "0.2.3"
environment = "development"
free_user_data_limit = 100
server_host = "0.0.0.0"
server_port = 8080
```

**production.toml（生产环境）**：
```toml
database_url = "postgres://milkydata:***@154.37.213.24:5432/milkydata"
wechat_appid = "wx5b00eb90621802f7"
wechat_secret = "***"
jwt_secret = "***"
ssl_key_path = "/etc/ssl/private/private.key"
ssl_cert_path = "/etc/ssl/certs/full_chain.pem"
rust_log = "info"
app_version = "0.2.3"
environment = "production"
free_user_data_limit = 20
server_host = "0.0.0.0"
server_port = 8080
```

**配置加载逻辑**：

```rust
// src/config.rs
use config::{Config, ConfigError, File};
use serde::Deserialize;

#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
    pub database_url: String,
    pub wechat_appid: String,
    pub wechat_secret: String,
    pub jwt_secret: String,
    pub ssl_key_path: String,
    pub ssl_cert_path: String,
    pub rust_log: String,
    pub app_version: String,
    pub environment: String,
    pub free_user_data_limit: i32,
    pub server_host: String,
    pub server_port: u16,
}

impl AppConfig {
    pub fn load() -> Result<Self, ConfigError> {
        // 从环境变量读取当前环境，默认为 development
        let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());

        let config = Config::builder()
            // 1. 先加载默认配置
            .add_source(File::with_name("config/default"))
            // 2. 再加载当前环境配置（覆盖默认值）
            .add_source(File::with_name(&format!("config/{}", env)).required(false))
            // 3. 最后从环境变量加载（最高优先级）
            .add_source(config::Environment::with_prefix("APP"))
            .build()?;

        config.try_deserialize()
    }
}
```

#### 方案二：简化方案（最小改动）

保持现有 `.env` 结构不变，通过 `APP_ENV` 环境变量和 `.env.development` / `.env.production` 文件区分：

**`.env.example`（提交到仓库的配置模板）**：
```bash
# 必填配置
DATABASE_URL=postgres://user:pass@host:port/dbname
JWT_SECRET=your-secret-key
WECHAT_APPID=your-wechat-appid
WECHAT_SECRET=your-wechat-secret

# 可选配置（带默认值）
APP_ENV=development
RUST_LOG=info
APP_VERSION=0.2.3
FREE_USER_DATA_LIMIT=20
SSL_KEY_PATH=
SSL_CERT_PATH=
```

**部署脚本增强**：

```bash
#!/bin/bash
# deploy.sh

# 接收环境参数
ENV=${1:-production}

# 根据环境加载不同配置
if [ "$ENV" = "development" ]; then
    source .env.development
elif [ "$ENV" = "production" ]; then
    source .env.production
fi

# 构建和部署...
```

### 环境切换操作指南

| 场景 | 操作方法 |
|------|----------|
| 本地开发 | `APP_ENV=development cargo run`，连接本地数据库 |
| 测试服务器部署 | `APP_ENV=development ./deploy.sh` |
| 生产环境部署 | `APP_ENV=production ./deploy.sh` 或默认 `./deploy.sh` |
| 查看当前环境 | 启动后访问 `/health` 接口或检查日志 |

### 实施步骤

**第一阶段（最小改动）**：
1. 创建 `.env.example` 配置模板，移除敏感信息
2. 在 `deploy.sh` 中添加 `APP_ENV` 参数支持
3. 创建 `.env.development` 本地开发配置（可选加入 .gitignore）

**第二阶段（推荐）**：
1. 添加 `config` crate 依赖
2. 创建 `config/default.toml` 和 `config/production.toml`
3. 重构 `config.rs` 使用 config crate
4. 更新 `deploy.sh` 使用新的配置加载方式

### 相关改进项

- 本改进与"前后端版本统一管理"（改进路线图 P1）可合并实施
- 本改进与"拆分 main.rs 路由处理器"（改进路线图 P1）有协同效应

### 实施结果

✅ **已完成** (2026-04-17)

**实际实施方案**：采用简化方案 + TOML 配置文件

| 改动项 | 说明 |
|--------|------|
| `config.rs` | 重写为使用 `toml` crate 直接读取配置文件 |
| `config/*.toml` | 创建 `default.toml`、`development.toml`、`production.toml` |
| `Cargo.toml` | 添加 `toml = "0.8"` 依赖，移除 `config = "0.14"` |
| `deploy.sh` | 重写支持 `development`/`production` 环境参数 |
| systemd service | 创建 `rust-backend-dev.service` 测试服务 |

**关键修复**：
- TOML 文件去掉 `[development]` 等 section 头（config crate 遗留语法）
- `database_url` 使用 `127.0.0.1` 而非 `localhost`（Docker PostgreSQL 监听地址）

**测试验证**：
- 测试服务运行在端口 8080
- Nginx 代理 `https://xmclassmate.top/dev/api/login` 正常工作

---

## 十三、本次对话经验总结

### 13.1 前后端字段命名一致性

**状态**：✅ 已修复 (2026-04-17)

**问题描述**：
后端 Rust 使用 `#[serde(rename = "camelCase")]` 序列化 JSON 字段，前端 TypeScript 必须使用相同的 camelCase 命名才能正确解析。

**受影响字段**：
| Rust 字段 | JSON 键 | 前端正确写法 |
|-----------|---------|-------------|
| `is_favorite` | `isFavorite` | `isFavorite` |
| `inspection_type` | `inspectionType` | `inspectionType` |
| `assignment_number` | `assignmentNumber` | `assignmentNumber` |

**修复内容**：
- 后端 `models.rs`：为所有字段添加 `#[serde(rename = "camelCase")]`
- 前端 TypeScript：统一使用 camelCase 接口定义
- 前端 `dataCollector.ts`：字段名从 snake_case 改为 camelCase

**验证方法**：
```bash
# 搜索后端 serde rename 配置
grep -n 'rename = "' src/models.rs
```

**命名规范总结**（详见本文档开头「命名规范」章节）：
- 前端 TypeScript：camelCase
- API 请求/响应：camelCase
- Rust 结构体：snake_case
- 数据库字段：snake_case

**参考**：[LRN-20260417-015](file:///home/milky/Documents/ASD/.learnings/LEARNINGS.md#LRN-20260417-015)

---

### 13.2 serde 配置冲突

**问题描述**：
`#[serde(skip_deserializing)]` 用于 POST 请求体解析（避免 id 字段），但在 SELECT 查询时会阻止字段被填充。

**错误配置**：
```rust
#[serde(skip_deserializing)]  // POST 时跳过，但 SELECT 时也跳过了
pub is_favorite: Option<bool>,
```

**正确配置**：
```rust
#[serde(default)]  // 缺失字段使用默认值
pub is_favorite: Option<bool>,
```

**经验教训**：
- `skip_deserializing` 会导致数据库查询结果无法填充字段
- 对于需要同时支持上传和查询的字段，使用 `default`

**参考**：[LRN-20260417-016](file:///home/milky/Documents/ASD/.learnings/LEARNINGS.md#LRN-20260417-016)

---

### 13.3 config crate 路径解析

**问题描述**：
`File::with_name("config/default")` 查找文件相对于 `cargo run` 执行目录，而非 `CARGO_MANIFEST_DIR`。

**错误写法**：
```rust
config::File::with_name("config/default")  // 相对于 cwd
```

**正确写法**：
```rust
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
    .map(PathBuf::from)
    .expect("CARGO_MANIFEST_DIR not set");
let config_path = manifest_dir.join("config").join("default.toml");
```

**经验教训**：
- 使用 `config` crate 的路径相关函数时注意基准目录
- 直接使用 `std::env::var("CARGO_MANIFEST_DIR")` 更可靠

**参考**：[LRN-20260417-001](file:///home/milky/Documents/ASD/.learnings/LEARNINGS.md#LRN-20260417-001)

---

### 13.4 TOML 配置文件结构

**问题描述**：
TOML 文件中的 `[development]` 等 section headers 与 `config` crate 的合并逻辑冲突。

**错误写法**：
```toml
[development]
database_url = "..."
```

**正确写法**：
```toml
database_url = "..."
environment = "development"
```

**经验教训**：
- 保持 TOML 文件扁平结构，不使用 section headers
- 简化配置加载逻辑

**参考**：[LRN-20260417-002](file:///home/milky/Documents/ASD/.learnings/LEARNINGS.md#LRN-20260417-002)

---

## 十四、付费功能系统

### 当前状态

**已有基础设施：**
- ✅ 数据库字段：`is_paid` (boolean), `paid_expires_at` (timestamp)
- ✅ 配额检查逻辑：`db.rs:15-24` 非付费用户限制 20 条数据
- ✅ 管理员 API：`PUT /api/admin/users/{id}/payment` 手动设置付费状态
- ✅ 用户查询 API：`GET /api/user/profile` 返回 `is_paid`, `is_paid_active`, `paid_expires_at`

**缺失功能：**
- ❌ 支付下单接口
- ❌ 微信支付回调接口
- ❌ 前端付费 UI
- ❌ 购买记录表

### 12.1 支付模式设计

#### 三种支付模式

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

#### 核心设计原则

1. **多订单支持**：一个用户可以有多条支付记录
2. **累积计算**：后续购买应累加有效期，而非覆盖
3. **冗余字段**：`users` 表的 `is_paid` 和 `paid_expires_at` 用于快速查询

### 12.2 数据库变更

```sql
-- 1. payment_orders 表添加新字段
ALTER TABLE payment_orders ADD COLUMN wx_order_id VARCHAR(64);           -- 微信订单号
ALTER TABLE payment_orders ADD COLUMN paid_days INTEGER;                 -- 本次套餐天数
ALTER TABLE payment_orders ADD COLUMN source VARCHAR(20) DEFAULT 'wechat'; -- 支付来源：wechat/invitation/admin

-- 2. memberships 表（新增）- 会员状态冗余表，提高查询性能
CREATE TABLE memberships (
    id SERIAL PRIMARY KEY,
    user_id INTEGER UNIQUE REFERENCES users(id),
    is_paid BOOLEAN DEFAULT false,
    paid_expires_at TIMESTAMPTZ,  -- 累积过期时间
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 3. invitation_codes 表（新增）
CREATE TABLE invitation_codes (
    id SERIAL PRIMARY KEY,
    code VARCHAR(32) UNIQUE NOT NULL,
    package_type VARCHAR(20) NOT NULL,  -- monthly/yearly/permanent
    paid_days INTEGER NOT NULL,        -- 转换为天数
    source VARCHAR(20) DEFAULT 'invitation',
    used_by INTEGER REFERENCES users(id),
    used_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ,            -- 邀请码过期时间
    is_used BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
```

### 12.3 累积计算逻辑

**核心算法**：

```rust
/// 确认订单时计算新到期时间
fn calculate_new_expires(
    current_expires: Option<DateTime<Utc>>,
    pkg_days: Option<i64>,  // None 表示永久
) -> Option<DateTime<Utc>> {
    let now = Utc::now();
    let base_time = current_expires
        .map(|e| std::cmp::max(e, now))
        .unwrap_or(now);

    match pkg_days {
        Some(days) => Some(base_time + chrono::Duration::days(days)),
        None => Some(DateTime::<Utc>::from_timestamp(2099, 12, 31)),  // 永久会员
    }
}

/// 确认订单支付（Rust 内部使用 snake_case）
pub async fn confirm_payment_order(
    pool: &PgPool,
    order_no: &str,
    user_id: i32,
) -> Result<Option<DateTime<Utc>>, String> {
    // 1. 查询订单
    let order = get_order_by_no(pool, order_no).await?;

    // 2. 检查权限和状态
    if order.user_id != user_id {
        return Err("无权操作此订单".to_string());
    }
    if order.status != "pending" {
        return Err("订单状态异常".to_string());
    }

    // 3. 获取当前用户到期时间
    let membership = get_membership(pool, user_id).await?;
    let current_expires = membership.map(|m| m.paid_expires_at).flatten();

    // 4. 计算新到期时间（累积）
    let pkg = get_package_info(&order.package_type)?;
    let new_expires = calculate_new_expires(current_expires, pkg.days);

    // 5. 更新订单状态
    update_order_status(pool, order_no, "paid", new_expires).await?;

    // 6. 更新用户会员状态
    update_membership(pool, user_id, true, new_expires).await?;

    Ok(new_expires)
}
```

**Rust 结构体示例**（snake_case 内部字段 + serde rename）：

```rust
#[derive(Serialize, Deserialize)]
pub struct PaymentOrder {
    #[serde(rename = "id")]
    pub id: i32,
    #[serde(rename = "userId")]
    pub user_id: i32,
    #[serde(rename = "orderId")]
    pub order_no: String,
    #[serde(rename = "packageType")]
    pub package_type: String,
    #[serde(rename = "amount")]
    pub amount: i32,
    #[serde(rename = "status")]
    pub status: String,
    #[serde(rename = "paidAt")]
    pub paid_at: Option<DateTime<Utc>>,
    #[serde(rename = "expiresAt")]
    pub expires_at: Option<DateTime<Utc>>,
}
```

**累积计算示例**：

| 操作 | 原到期时间 | 购买套餐 | 新到期时间 |
|------|----------|---------|-----------|
| 首次购买 | - | 包月(30天) | 现在+30天 |
| 第二次购买 | 5/1 | 包年(365天) | MAX(5/1, 现在)+365天 |
| 第三次购买 | 明年5/1 | 包月(30天) | 明年5/1+30天 |

### 12.4 接口设计

**注意**：所有 API 请求/响应均使用 camelCase 命名。

#### 微信支付流程

```json
// 1. 创建订单
POST /api/payment/create-order
Request: { "packageType": "monthly" | "yearly" | "permanent" }
Response: {
    "success": true,
    "data": {
        "orderId": "内部订单号",
        "prepayId": "微信预支付ID",
        "package": "prepay_id=...",
        "timestamp": "...",
        "nonceStr": "...",
        "paySign": "..."
    }
}

// 2. 支付回调
POST /api/payment/callback
Request: {
    "eventType": "TRANSACTION.SUCCESS",
    "resource": {
        "outTradeNo": "内部订单号",
        "transactionId": "微信订单号",
        "tradeState": "SUCCESS"
    }
}
Response: { "code": "SUCCESS" }

// 3. 邀请码兑换
POST /api/redeem-invitation-code
Request: { "code": "ASD2024Y01A2B3C" }
Response: {
    "success": true,
    "data": {
        "packageType": "yearly",
        "paidDays": 365,
        "expiresAt": "2027-04-17T00:00:00Z"
    }
}

// 4. 管理员开通
PUT /api/admin/users/{id}/payment
Request: {
    "isPaid": true,
    "paidExpiresAt": "2026-12-31T23:59:59Z"
}
```

### 12.5 定价建议

| 套餐 | 价格 | 天数 | 说明 |
|------|------|------|------|
| 包月 | ¥9.9 | 30天 | 尝鲜用户 |
| 包年 | ¥59 | 365天 | 主流套餐 |
| 永久 | ¥199 | - | 设为2099-12-31 |

### 12.6 实施优先级

| 阶段 | 内容 | 复杂度 |
|------|------|--------|
| P1 | 添加 memberships 表 | 低 |
| P1 | 实现累积计算逻辑 | 中 |
| P1 | 微信支付回调集成 | 高 |
| P2 | invitation_codes 表 | 中 |
| P2 | 邀请码兑换接口 | 中 |
| P2 | 前端付费 UI | 中 |

### 12.7 注意事项

1. **幂等性**：支付回调需处理重复通知（微信可能多次推送）
2. **永久会员**：expires_at 设为 2099-12-31 而非 NULL
3. **事务处理**：确认订单和更新会员状态应在同一事务中
4. **退款处理**：需实现退款接口，更新 membership

---

## 方案二：邀请码开通会员

### 方案描述
用户输入邀请码即可开通会员，无需支付。适合不想接入微信支付但需要会员管理的场景。

### 数据库设计
```sql
-- 邀请码表
CREATE TABLE invitation_codes (
    id SERIAL PRIMARY KEY,
    code VARCHAR(32) UNIQUE NOT NULL,  -- 邀请码
    package_type VARCHAR(20) NOT NULL,  -- 套餐类型：monthly/yearly/permanent
    used_by INTEGER REFERENCES users(id),  -- 使用者
    used_at TIMESTAMPTZ,  -- 使用时间
    created_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ,  -- 邀请码过期时间（可选）
    is_used BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_invitation_codes_code ON invitation_codes(code);
```

### 接口设计

#### 1. 使用邀请码
```json
POST /api/redeem-invitation-code
Request:
{
    "code": "ASD2024Y01A2B3C"
}

Response:
{
    "success": true,
    "data": {
        "package_type": "yearly",
        "paid_days": 365,
        "expires_at": "2027-04-17T00:00:00Z"
    }
}

Error:
{
    "success": false,
    "errcode": 400,
    "errmsg": "邀请码无效或已使用"
}
```

#### 2. 生成邀请码（管理员）
```json
POST /api/admin/invitation-codes
Request:
{
    "packageType": "yearly",
    "count": 10,
    "expiresInDays": 365
}

Response:
{
    "success": true,
    "data": {
        "codes": [
            {"code": "ASD2024Y01A2B3C", "paidDays": 365, "expiresAt": "2027-04-17T00:00:00Z"},
            {"code": "ASD2024Y04D5E6F", "paidDays": 365, "expiresAt": "2027-04-17T00:00:00Z"}
        ]
    }
}
```

### 后端实现要点
```rust
/// 兑换邀请码
pub async fn redeem_invitation_code(
    pool: &PgPool,
    user_id: i32,
    code: &str,
) -> Result<InvitationRedeemResult, String> {
    // 1. 查询邀请码
    let invite = get_invitation_code(pool, code).await?;

    // 2. 检查是否已使用
    if invite.is_used {
        return Err("邀请码已使用".to_string());
    }

    // 3. 检查是否过期
    if let Some(expires) = invite.expires_at {
        if expires < Utc::now() {
            return Err("邀请码已过期".to_string());
        }
    }

    // 4. 获取当前用户会员状态
    let membership = get_membership(pool, user_id).await?;
    let current_expires = membership.map(|m| m.paid_expires_at).flatten();

    // 5. 计算新到期时间（累积）
    let base_time = current_expires
        .map(|e| std::cmp::max(e, Utc::now()))
        .unwrap_or_else(Utc::now);
    let new_expires = if invite.package_type == "permanent" {
        DateTime::<Utc>::from_timestamp(2099, 12, 31)
    } else {
        Some(base_time + chrono::Duration::days(invite.paid_days as i64))
    };

    // 6. 标记邀请码已使用
    use_invitation_code(pool, code, user_id).await?;

    // 7. 更新用户会员状态（累积）
    update_membership(pool, user_id, true, new_expires).await?;

    Ok(InvitationRedeemResult {
        package_type: invite.package_type,
        paid_days: invite.paid_days,
        expires_at: new_expires,
    })
}

/// 生成邀请码
pub async fn generate_invitation_codes(
    pool: &PgPool,
    package_type: &str,
    count: i32,
    expires_in_days: Option<i32>,
) -> Result<Vec<GeneratedCode>, String> {
    let mut codes = Vec::new();
    let paid_days = get_package_days(package_type)?;

    for _ in 0..count {
        let code = generate_random_code(package_type);
        let expires_at = expires_in_days
            .map(|d| Utc::now() + chrono::Duration::days(d as i64));

        create_invitation_code(pool, &code, package_type, paid_days, expires_at).await?;
        codes.push(GeneratedCode { code, paid_days, expires_at });
    }

    Ok(codes)
}
```

### 前端实现要点
```typescript
// 页面：pkg-extra/upgrade/upgrade
// 添加"使用邀请码"入口

async onRedeemCode() {
    const code = this.data.invitationCode.trim();
    if (!code) {
        wx.showToast({ title: '请输入邀请码', icon: 'none' });
        return;
    }

    const res = await request({
        url: `${baseUrl}/api/redeem-invitation-code`,
        method: 'POST',
        data: { code }
    });

    if (res.success) {
        wx.showModal({
            title: '开通成功',
            content: `恭喜！您已开通${res.data.paid_days}天会员`,
            showCancel: false
        });
        this.fetchUserProfile();
    }
}
```

### 邀请码生成规则建议
```
格式：ASD + 年份 + 类型标识 + 6位随机
类型标识：Y=年度, M=月度, P=永久
示例：
- ASD2024Y01A2B3C  （年度会员 365天）
- ASD2024M03X7Y9Z  （月度会员 30天）
- ASD2024P00A1B2C  （永久会员）
```

### 实施优先级

| 阶段 | 内容 | 复杂度 |
|------|------|--------|
| P1 | memberships 表迁移 | 低 |
| P1 | 累积计算逻辑实现 | 中 |
| P2 | invitation_codes 表 | 中 |
| P2 | 邀请码兑换/生成接口 | 中 |
| P2 | 前端邀请码入口 | 低 |

---

## 十四、公告系统

### 功能概述

公告系统用于向用户发送系统公告，支持管理员发布、编辑、删除公告，用户查看公告列表和详情。

### 数据库设计

#### announcements 表

```sql
CREATE TABLE announcements (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,           -- 公告标题
    content TEXT NOT NULL,                 -- 公告内容
    priority VARCHAR(20) DEFAULT 'normal', -- high/normal/low
    status VARCHAR(20) DEFAULT 'published',-- draft/published/archived
    published_at TIMESTAMPTZ,              -- 发布时间
    created_by INTEGER REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_announcements_published ON announcements(published_at DESC);
```

#### user_announcement_reads 表

```sql
CREATE TABLE user_announcement_reads (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    announcement_id INTEGER REFERENCES announcements(id),
    read_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(user_id, announcement_id)
);

CREATE INDEX idx_reads_user ON user_announcement_reads(user_id);
```

### 后端接口

#### 用户接口

```json
// GET /api/announcements
// 公告列表（分页）
Response: {
    "success": true,
    "data": {
        "list": [
            {
                "id": 1,
                "title": "系统维护通知",
                "priority": "high",
                "publishedAt": "2026-04-17T10:00:00Z",
                "isRead": false
            }
        ],
        "total": 10,
        "page": 1,
        "pageSize": 20
    }
}

// GET /api/announcements/{id}
// 公告详情
Response: {
    "success": true,
    "data": {
        "id": 1,
        "title": "系统维护通知",
        "content": "将于今晚10点进行系统维护...",
        "priority": "high",
        "publishedAt": "2026-04-17T10:00:00Z",
        "isRead": true
    }
}

// GET /api/announcements/unread-count
// 未读数量
Response: {
    "success": true,
    "data": { "count": 3 }
}
```

#### 管理员接口

```json
// POST /api/admin/announcements
// 发布公告
Request: {
    "title": "系统维护通知",
    "content": "将于今晚10点进行系统维护...",
    "priority": "high"
}

// PUT /api/admin/announcements/{id}
// 更新公告
Request: {
    "title": "系统维护通知（已更新）",
    "content": "新内容...",
    "priority": "normal"
}

// DELETE /api/admin/announcements/{id}
// 删除公告
```

### Rust 结构体示例

```rust
#[derive(Serialize, Deserialize)]
pub struct Announcement {
    #[serde(rename = "id")]
    pub id: i32,
    #[serde(rename = "title")]
    pub title: String,
    #[serde(rename = "content")]
    pub content: String,
    #[serde(rename = "priority")]
    pub priority: String,
    #[serde(rename = "status")]
    pub status: String,
    #[serde(rename = "publishedAt")]
    pub published_at: Option<DateTime<Utc>>,
    #[serde(rename = "isRead")]
    pub is_read: bool,
}
```

### 实施优先级

| 阶段 | 内容 | 复杂度 |
|------|------|--------|
| P1 | announcements 表 | 低 |
| P1 | 用户公告列表/详情接口 | 低 |
| P1 | 标记已读接口 | 低 |
| P2 | 管理员 CRUD 接口 | 中 |
| P2 | 未读数量接口 | 低 |

---

## 十一、检查清单

### 代码提交前检查
- [ ] 通过 `cargo clippy` 检查
- [ ] 通过 `cargo fmt` 格式化
- [ ] 单元测试通过
- [ ] 无硬编码的敏感信息
- [ ] 配置文件不包含实际密钥

### 部署前检查
- [ ] 数据库迁移脚本准备
- [ ] 环境变量配置检查
- [ ] 备份当前版本
- [ ] 健康检查接口正常
- [ ] 确认目标环境的配置正确

### 安全检查
- [ ] 输入验证完整
- [ ] 错误信息不暴露敏感数据
- [ ] 认证和授权正确
- [ ] 日志不记录敏感信息
- [ ] 生产环境使用强密钥

## 十二、部署测试与自动回退

### 12.1 测试步骤要求

每次部署必须执行以下测试步骤：

#### 部署前测试（本地）
```bash
# 1. 代码检查
cargo clippy 2>&1 | grep -E "error|warning" || echo "✅ Clippy passed"

# 2. 格式化检查
cargo fmt --check || cargo fmt

# 3. 编译测试
APP_ENV=development cargo build 2>&1 | tail -5

# 4. 单元测试
cargo test 2>&1 | tail -10
```

#### 部署后测试（远程服务器）
```bash
# 部署后自动执行 test_deployment.sh，测试内容：
# 1. 健康检查
curl -f http://127.0.0.1:8080/health || exit 1

# 2. 登录接口
curl -f -X POST https://xmclassmate.top/dev/api/login \
  -H "Content-Type: application/json" \
  -d '{"code":"test"}' || exit 1

# 3. 静态资源
curl -f https://xmclassmate.top/dev/assets/img/favicon.png || exit 1
```

### 12.2 测试失败自动回退机制

部署脚本必须实现测试失败时的自动回退：

```bash
#!/bin/bash
# deploy.sh 核心逻辑

ENV=${1:-production}
REMOTE_DIR="/root/rust/rust_backend"
SERVICE_NAME="rust-backend.service"
BACKUP_DIR="/root/rust/backups"

# 1. 部署前备份
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
if [ -f "$REMOTE_DIR/rust-backend" ]; then
    mkdir -p $BACKUP_DIR
    cp "$REMOTE_DIR/rust-backend" "$BACKUP_DIR/rust-backend-$TIMESTAMP"
    echo "✅ 备份已创建: rust-backend-$TIMESTAMP"
fi

# 2. 执行部署
rsync target/release/rust-backend root@1panel-server:$REMOTE_DIR/
ssh root@1panel-server "systemctl restart $SERVICE_NAME"
sleep 3

# 3. 执行测试
echo "🔍 执行部署后测试..."
if ssh root@1panel-server "bash $REMOTE_DIR/test_deployment.sh"; then
    echo "✅ 部署成功"
else
    echo "❌ 测试失败，执行回退..."

    # 回退到上一个版本
    LAST_BACKUP=$(ls -t $BACKUP_DIR/rust-backend-* | head -1)
    if [ -n "$LAST_BACKUP" ]; then
        rsync "$LAST_BACKUP" root@1panel-server:$REMOTE_DIR/rust-backend
        ssh root@1panel-server "systemctl restart $SERVICE_NAME"
        echo "✅ 已回退到: $(basename $LAST_BACKUP)"
    else
        echo "⚠️ 无可用备份，回退失败"
        exit 1
    fi
fi
```

### 12.3 回退触发条件

以下情况自动触发回退：

| 测试项 | 失败条件 | 影响 |
|--------|----------|------|
| 健康检查 | `curl -f` 返回非0 | 服务无法启动 |
| 登录接口 | HTTP 状态码 != 200 | 核心功能不可用 |
| 静态资源 | HTTP 状态码 >= 400 | 页面显示异常 |
| systemd 状态 | `active (running)` 以外 | 服务启动失败 |

### 12.4 回退操作限制

- **仅自动回退二进制文件**：`rust-backend` 可执行文件
- **配置不回退**：配置文件（`config/*.toml`）保持新版本
- **数据库不回退**：数据库变更需要手动处理
- **日志保留**：回退后旧版本日志仍保存在 `$REMOTE_DIR/logs/`

### 12.5 备份保留策略

```bash
# 保留最近 10 个备份
BACKUP_COUNT=$(ls $BACKUP_DIR/rust-backend-* 2>/dev/null | wc -l)
if [ $BACKUP_COUNT -gt 10 ]; then
    ls -t $BACKUP_DIR/rust-backend-* | tail -$((BACKUP_COUNT - 10)) | xargs rm -f
    echo "🗑️ 已清理旧备份，保留最近 10 个"
fi
```

---

## 十五、网页端登录码流程（2026-04-24）

### 功能概述

实现**无需微信登录**的网页端登录确认流程。用户在小程序生成登录码，在后端网页完成微信授权登录，再由小程序确认完成整个登录流程。

### 业务流程

```
小程序 → 后端 GET /payment/generate-code（生成登录码）
                     ↓
         用户在外部浏览器打开支付页
                     ↓
         后端 GET /payment/login-status?code=xxx（网页轮询）
                     ↓
         网页端微信扫码授权（openid 写入 DB）
                     ↓
         网页端 POST /api/web-login/confirm（确认登录，token 写入 DB）
                     ↓
         小程序轮询 GET /payment/login-status?code=xxx
                     ↓
         confirmed=true, token=xxx → 小程序保存 token 登录完成
```

### 新增接口

| 接口 | 方法 | 认证 | 说明 |
|------|------|------|------|
| `GET /payment/generate-code` | GET | 无 | 网页端生成登录码 |
| `GET /payment/login-status` | GET | 无 | 查询登录码状态（网页轮询）|
| `POST /api/web-login/confirm` | POST | JWT | 小程序确认登录 |

### 数据库

`web_login_codes` 表（迁移 `005_add_web_login_codes.sql`）：

```sql
CREATE TABLE web_login_codes (
    id SERIAL PRIMARY KEY,
    code VARCHAR(32) UNIQUE NOT NULL,  -- 登录码（如 ASD-XXXXXX）
    openid VARCHAR(128),               -- 微信 openid（授权后填充）
    token TEXT,                        -- JWT（确认后填充）
    expires_at TIMESTAMPTZ NOT NULL,  -- 过期时间（10分钟）
    user_id INTEGER,                   -- 关联用户 ID
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_web_login_codes_code ON web_login_codes(code);
CREATE INDEX idx_web_login_codes_expires ON web_login_codes(expires_at);
```

状态演变：
1. **初始**：`code` 有值，`openid`=NULL, `token`=NULL
2. **微信授权后**：`openid` 被填充
3. **确认后**：`token` 被填充，`code` 被删除

### 关键 commits

| Commit | 说明 |
|--------|------|
| `034aa66` | 实现网页端登录码流程（无需微信登录） |
| `c66d130` | 统一登录码参数名为 code（与前端保持一致）|
| `92fb4e3` | web_login_confirm 查询改为精确匹配 code |
| `3bcfd9a` | web_login_confirm 加日志排查 + 精确匹配 code |

### 已知问题

- [ ] `web_login_confirm` (POST /api/web-login/confirm) JSON 反序列化偶发失败，日志已添加诊断（commit `3bcfd9a`）

---

## 十六、支付宝网页支付接入（2026-04-23）

### 功能概述

接入支付宝沙箱环境，用户在外部浏览器完成支付，支付成功回调更新用户会员状态。

### 支付流程

```
小程序 → 后端 /payment/page → 支付宝网页支付 → 回调 /payment/notify → 更新用户状态
```

### 关键 commits

| Commit | 说明 |
|--------|------|
| `8c9552f` | 新增支付页面接口，为接入支付宝做准备 |
| `e828631` | 接入支付宝沙箱环境 (alipay.trade.page.pay) |
| `375f0af` | 更新支付系统和支付宝集成文档 |
| `502ae8b` | payment_page 支持 URL 参数传递 JWT |
| `af4ae27` | 跳转外部支付页不再携带 JWT，由网页端微信扫码登录 |

### 沙箱测试

- 沙箱网关：`https://openapi-sandbox.dl.alipaydev.com/gateway.do`
- 测试账号：https://open.alipay.com/develop/sandbox/app

---

## 十七、常见 Bug 与修复（2026-04）

### 1. gitignore 阻止 source 文件追踪

**问题**：`.gitignore` 配置 `*` 阻止了所有文件，包括源码文件。

**现象**：`git add` 后 `git commit` 无响应，`git status` 显示文件但 `git diff --cached` 为空。

**修复**：使用 `git add -f <file>` 强制添加被忽略的文件。

### 2. `unwrap_or(None)` 误用导致 SQL 错误被吞掉

**问题**：`fetch_optional` 返回 `Result<Option<T>, E>`，`unwrap_or(None)` 只处理 `Err`，不处理 `Ok(None)`。

```rust
// 错误：sqlx::Error 被 ok() 吞掉，但 record 仍是 Option<T>
// 导致 record = None，永远走不到查询逻辑
let record = sqlx::query_as(...).fetch_optional(pool).await.ok();

// 正确：用 .ok().flatten() 处理 Result<Option<T>>
let record = sqlx::query_as(...).fetch_optional(pool).await.ok().flatten();
```

### 3. `serde(skip_deserializing)` 导致 SELECT 结果无法填充字段

**问题**：`#[serde(skip_deserializing)]` 在 POST 请求体解析时跳过字段，但也阻止了数据库 SELECT 结果填充字段。

**修复**：对需要同时支持上传和查询的字段，使用 `#[serde(default)]`。

---

**最后更新**：2026-04-24
**维护者**：milky
