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

37 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 付费是否活跃

二、生产环境数据库迁移

环境信息

环境 数据库名 数据量
测试 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 中多次查询用户信息
    • 改进:合并查询或使用缓存
    • 影响:减少数据库负载
  • 缺少数据库索引

    • 现状:未见索引定义
    • 改进:添加常用查询字段索引
      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 错误处理改进

  • 错误响应格式不统一

    • 现状:部分返回 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 测试覆盖

  • 仅有一个基础编译测试

    • 现状: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 开发工具

  • 缺少代码质量工具

    • 工具: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 前端邀请码入口

十一、检查清单

代码提交前检查

  • 通过 cargo clippy 检查
  • 通过 cargo fmt 格式化
  • 单元测试通过
  • 无硬编码的敏感信息
  • 配置文件不包含实际密钥

部署前检查

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

安全检查

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

最后更新2026-04-17 维护者milky