Files
asd-backend/IMPROVEMENTS.md
Milky0217 2aeef3e9bf docs: 添加生产环境数据库迁移步骤
- 新增二、生产环境数据库迁移章节
- 记录测试/生产环境数据量对比
- 添加备份和迁移操作步骤
- 添加 memberships/invitation_codes 建表语句
- 添加回滚方案
2026-04-17 23:03:27 +08:00

1304 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_caseJSON 序列化时需要转换:
- 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 | 付费是否活跃 |
---
## 二、生产环境数据库迁移
### 环境信息
| 环境 | 数据库名 | 数据量 |
|------|---------|--------|
| 测试 | `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
```
**命名规范总结**(详见本文档开头「命名规范」章节):
- 前端 TypeScriptcamelCase
- 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 | 前端邀请码入口 | 低 |
---
## 十一、检查清单
### 代码提交前检查
- [ ] 通过 `cargo clippy` 检查
- [ ] 通过 `cargo fmt` 格式化
- [ ] 单元测试通过
- [ ] 无硬编码的敏感信息
- [ ] 配置文件不包含实际密钥
### 部署前检查
- [ ] 数据库迁移脚本准备
- [ ] 环境变量配置检查
- [ ] 备份当前版本
- [ ] 健康检查接口正常
- [ ] 确认目标环境的配置正确
### 安全检查
- [ ] 输入验证完整
- [ ] 错误信息不暴露敏感数据
- [ ] 认证和授权正确
- [ ] 日志不记录敏感信息
- [ ] 生产环境使用强密钥
---
**最后更新**2026-04-17
**维护者**milky