- auth.rs 新增 JWT 生成/验证回环、篡改拒绝、refresh token 随机性、临时 token 有效期测试 - payment.rs 新增套餐定义、URL 解码、支付宝表单解析(UTF-8/GBK)、RSA2 签名验签、UA 检测测试 - 修复 urlencoding 两个真实 bug:UTF-8 多字节序列乱码、不完整转义吞字符 - 移除 db.rs 悬空 doc 注释,修复 auth.rs 冗余引用(clippy 0 警告) - cargo test 从 2 个增至 36 个全过 - 同步 IMPROVEMENTS.md(索引/重复查询/测试覆盖/公告系统状态)
1787 lines
51 KiB
Markdown
1787 lines
51 KiB
Markdown
# 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 数据库操作优化
|
||
- [x] **重复查询问题** ✅
|
||
- 位置:`insert_weather_data` 中多次查询用户信息
|
||
- 改进:合并查询或使用缓存
|
||
- 状态:`get_user_quota` 已合并为单次查询(见 `db.rs` 顶部注释);2026-08-11 复核无遗留 N+1
|
||
|
||
- [x] **缺少数据库索引** ✅
|
||
- 状态:`migrations/006_add_performance_indexes.sql` 已添加
|
||
```sql
|
||
CREATE INDEX idx_weather_data_user_id ON weather_data(user_id);
|
||
CREATE INDEX idx_weather_data_date ON weather_data(date DESC);
|
||
CREATE INDEX idx_weather_data_user_favorite ON weather_data(user_id, is_favorite);
|
||
CREATE INDEX idx_weather_data_user_date ON weather_data(user_id, date DESC);
|
||
CREATE INDEX idx_users_openid ON users(openid);
|
||
CREATE INDEX idx_payment_orders_status ON payment_orders(status);
|
||
```
|
||
|
||
### 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 测试覆盖
|
||
- [~] **基础测试不足**(2026-08-11 已补充单元测试)
|
||
- 现状:原仅有 `tests/integration_test.rs` 的编译测试和套餐一致性测试
|
||
- 已补充(`cargo test` 共 36 用例):
|
||
- `src/auth.rs`:JWT 生成/验证回环、篡改拒绝、错误密钥拒绝、refresh token 随机性、临时 token 有效期
|
||
- `src/handlers/payment.rs`:套餐定义、URL 解码(**修复了 UTF-8 乱码与吞字符两个真实 bug**)、支付宝表单解析(UTF-8/GBK)、RSA2 签名/验签回环(PEM + Base64 DER 密钥)、移动端 UA 检测、订单号唯一性
|
||
- `src/rate_limiter.rs`:限流阈值、按客户端隔离、IP 提取(已有)
|
||
- 待做:API 集成测试(需要测试数据库)
|
||
|
||
- [ ] **缺少 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 | 前端邀请码入口 | 低 |
|
||
|
||
---
|
||
|
||
## 十四、公告系统
|
||
|
||
> **状态**:✅ 已实现(2026-05,`handlers/notifications.rs` + 迁移 `011_add_notifications.sql`,前端 `pkg-extra/notifications` 页面)
|
||
> 下方为原始设计文档,保留供参考。
|
||
|
||
### 功能概述
|
||
|
||
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。
|
||
|
||
### 数据库设计
|
||
|
||
#### 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
|