Files
asd-backend/IMPROVEMENTS.md
milky0217 3cce81321f test: 补充单元测试并修复 urlencoding 解码 bug
- 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(索引/重复查询/测试覆盖/公告系统状态)
2026-08-14 13:09:55 +08:00

51 KiB
Raw Blame History

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

#[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 接口定义

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

数据库迁移注意

如果字段名使用 snake_caseJSON 序列化时需要转换:

  • Rust → JSONis_favoriteisFavoriteserde 自动处理)
  • JSON → RustisFavoriteis_favoriteserde 自动处理)

已统一字段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

优化建议

# 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

连接方式

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

迁移步骤

⚠️ 重要:执行前必须备份

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+ 几乎无影响):

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

步骤 2验证

-- 检查列是否添加成功
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 表

当需要会员系统时,在生产环境执行:

-- 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);

回滚方案

如果需要回滚:

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

一、代码质量与架构改进

1.1 代码组织问题

  • 所有路由处理器都在 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 拦截的问题(路由顺序)
  • config.rs 是死代码

    • 现状AGENTS.md 中提到 config.rs 未被使用
    • 改进:保留文件,标注为待清理
    • 影响:减少代码混淆
    • 完成时间2026-04-15

1.2 数据库操作优化

  • 重复查询问题

    • 位置:insert_weather_data 中多次查询用户信息
    • 改进:合并查询或使用缓存
    • 状态:get_user_quota 已合并为单次查询(见 db.rs 顶部注释2026-08-11 复核无遗留 N+1
  • 缺少数据库索引

    • 状态:migrations/006_add_performance_indexes.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 错误处理改进

  • 错误响应格式不统一

    • 现状:部分返回 ErrorResponse,部分返回 JSON 字符串
    • 改进:统一使用 ErrorResponse 结构体
    • 彰响:前端解析更一致
    • 完成时间2026-04-15
  • 错误信息暴露过多

    • 现状:部分错误直接返回数据库错误信息
    • 改进:区分用户友好错误和开发者错误
    • 影响:安全性提升
    • 完成时间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-ControlETag
  • 无 CDN 支持

    • 场景:静态资源分发
    • 改进:配置 CDN 加速
    • 资源CSS、JS、图片、字体

四、功能完整性改进

4.1 API 功能扩展

  • 缺少批量操作接口

    • 需求:批量删除、批量查询
    • 实现:添加批量操作端点
    • 接口:POST /weather/batch-deletePOST /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.rsJWT 生成/验证回环、篡改拒绝、错误密钥拒绝、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 开发工具

  • 缺少代码质量工具

    • 工具:clippyrustfmt
    • 配置:.clippy.tomlrustfmt.toml
    • 集成CI/CD 流程
  • 无热重载开发

    • 需求:开发时自动重载
    • 实现:使用 cargo-watch
    • 命令:cargo watch -x run

六、监控与运维

6.1 日志系统

  • 日志格式不统一

    • 现状:部分使用 log 宏,部分使用 println
    • 改进:统一使用结构化日志
    • 实现:使用 tracing crate
    • 完成时间2026-04-15
    • 修改文件Cargo.toml, src/main.rs, src/config.rs
  • 无日志聚合

    • 需求:集中式日志管理
    • 实现:配置日志收集器(如 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 健康检查

  • 缺少健康检查接口
    • 需求:负载均衡器健康检查
    • 实现:添加 /health 端点
    • 检查:数据库连接、服务状态
    • 完成时间2026-04-15

七、部署改进

7.1 部署流程

  • 部署脚本功能简单

    • 现状:仅支持基本的编译、上传、重启
    • 改进:添加回滚、备份、验证功能
    • 实现:增强 deploy.sh 脚本
  • 无蓝绿部署或滚动更新

    • 需求:零停机部署
    • 实现:使用 Docker + Kubernetes
    • 或者Nginx 负载均衡 + 多实例

7.2 容器化

  • 缺少 Docker 支持
    • 需求:容器化部署
    • 实现:添加 Dockerfiledocker-compose.yml
    • 优势:环境一致性、易于扩展

7.3 CI/CD

  • 无自动化流程
    • 需求:自动测试、构建、部署
    • 实现:配置 GitHub Actions 或 Gitea Actions
    • 流程:代码提交 → 测试 → 构建 → 部署

九、Bug 修复记录

9.1 前端输入问题

  • 数字输入框无法输入小数点
    • 问题:用户在输入框输入 "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 当前内容

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推荐

添加依赖

# 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开发/测试默认)

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生产环境

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

配置加载逻辑

// 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(提交到仓库的配置模板)

# 必填配置
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=

部署脚本增强

#!/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.tomlconfig/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.tomldevelopment.tomlproduction.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 而非 localhostDocker 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

验证方法

# 搜索后端 serde rename 配置
grep -n 'rename = "' src/models.rs

命名规范总结(详见本文档开头「命名规范」章节):

  • 前端 TypeScriptcamelCase
  • API 请求/响应camelCase
  • Rust 结构体snake_case
  • 数据库字段snake_case

参考LRN-20260417-015


13.2 serde 配置冲突

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

错误配置

#[serde(skip_deserializing)]  // POST 时跳过,但 SELECT 时也跳过了
pub is_favorite: Option<bool>,

正确配置

#[serde(default)]  // 缺失字段使用默认值
pub is_favorite: Option<bool>,

经验教训

  • skip_deserializing 会导致数据库查询结果无法填充字段
  • 对于需要同时支持上传和查询的字段,使用 default

参考LRN-20260417-016


13.3 config crate 路径解析

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

错误写法

config::File::with_name("config/default")  // 相对于 cwd

正确写法

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


13.4 TOML 配置文件结构

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

错误写法

[development]
database_url = "..."

正确写法

database_url = "..."
environment = "development"

经验教训

  • 保持 TOML 文件扁平结构,不使用 section headers
  • 简化配置加载逻辑

参考LRN-20260417-002


十四、付费功能系统

当前状态

已有基础设施:

  • 数据库字段:is_paid (boolean), paid_expires_at (timestamp)
  • 配额检查逻辑:db.rs:15-24 非付费用户限制 20 条数据
  • 管理员 APIPUT /api/admin/users/{id}/payment 手动设置付费状态
  • 用户查询 APIGET /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_paidpaid_expires_at 用于快速查询

12.2 数据库变更

-- 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 累积计算逻辑

核心算法

/// 确认订单时计算新到期时间
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

#[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 命名。

微信支付流程

// 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

方案二:邀请码开通会员

方案描述

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

数据库设计

-- 邀请码表
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. 使用邀请码

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. 生成邀请码(管理员)

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"}
        ]
    }
}

后端实现要点

/// 兑换邀请码
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)
}

前端实现要点

// 页面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-05handlers/notifications.rs + 迁移 011_add_notifications.sql,前端 pkg-extra/notifications 页面) 下方为原始设计文档,保留供参考。

功能概述

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

数据库设计

announcements 表

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 表

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);

后端接口

用户接口

// 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 }
}

管理员接口

// 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 结构体示例

#[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 测试步骤要求

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

部署前测试(本地)

# 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

部署后测试(远程服务器)

# 部署后自动执行 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 测试失败自动回退机制

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

#!/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 备份保留策略

# 保留最近 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

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由网页端微信扫码登录

沙箱测试


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

1. gitignore 阻止 source 文件追踪

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

现象git addgit 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)

// 错误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