AGENTS.md: - 支付系统章节新增三种支付模式(微信/邀请码/管理员) - 添加累积计算逻辑说明 - 说明永久会员使用 2099-12-31 而非 NULL IMPROVEMENTS.md: - 重构 12.1-12.7 章节为新的付费系统设计 - 添加 memberships 表设计(冗余表提高查询性能) - 添加 invitation_codes 表设计 - 详细说明累积计算逻辑和代码示例 - 更新邀请码接口设计(增加 paid_days 字段) - 更新后端/前端实现要点 - 邀请码生成规则改为 Y/M/P 类型标识
32 KiB
Rust 后端 - 改进计划
一、代码质量与架构改进
1.1 代码组织问题
-
所有路由处理器都在 main.rs 中 ✅
- 改进:将 handler 函数按功能模块拆分到
handlers/目录 - 目录结构:
handlers/auth.rs- 登录相关handlers/weather.rs- 天气数据 CRUDhandlers/user.rs- 用户相关handlers/admin.rs- 管理员功能handlers/health.rs- 健康检查handlers/static_files.rs- 静态文件服务
- 完成时间:2026-04-15
- 附加:发现并修复静态文件被 JWT middleware 拦截的问题(路由顺序)
- 改进:将 handler 函数按功能模块拆分到
-
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 问题)
- 备选方案:
actix-web-lab- 社区维护的中间件库- 手动实现 - 使用
std::collections::HashMap记录 IP 请求数 - Nginx/网关层限流
-
缺少 CSRF 防护
- 风险:跨站请求伪造攻击
- 改进:添加 CSRF Token 验证
- 实现:对于状态变更操作验证 Token
-
JWT Secret 管理
- 现状:明文存储在环境变量
- 改进:使用密钥管理服务(如 AWS Secrets Manager)
- 影响:提高密钥安全性
2.2 数据安全
-
数据库连接字符串明文存储
- 位置:
.env文件 - 改进:使用连接字符串加密或密钥管理
- 影响:防止凭证泄露
- 位置:
-
缺少敏感数据加密
- 现状:用户信息明文存储
- 改进:对敏感字段加密存储
- 字段:手机号、姓名等
-
缺少审计日志
- 需求:记录关键操作日志
- 实现:添加审计日志表和中间件
- 记录:登录、数据修改、管理员操作
2.3 输入验证
- 后端输入验证不完整
- 现状:部分依赖前端验证
- 改进:添加完整的请求验证
- 实现:使用
validatorcrate
三、性能优化
3.1 数据库性能
-
缺少连接池监控
- 现状:连接池配置固定
- 改进:添加连接池监控和动态调整
- 指标:连接数、等待时间、超时次数
-
缺少查询缓存
- 场景:用户信息、配置数据
- 改进:添加 Redis 缓存层
- 策略:用户信息缓存 5 分钟,配置数据缓存 1 小时
-
无读写分离支持
- 需求:高并发场景下的性能优化
- 改进:支持主从数据库配置
- 实现:使用 sqlx 的多数据源支持
3.2 API 性能
-
无响应压缩
- 现状:响应未压缩
- 改进:添加 gzip 压缩中间件
- 实现:使用
actix-web的压缩功能
-
缺少 API 缓存
- 场景:天气数据列表查询
- 改进:添加 HTTP 缓存头
- 实现:
Cache-Control、ETag等
-
无 CDN 支持
- 场景:静态资源分发
- 改进:配置 CDN 加速
- 资源:CSS、JS、图片、字体
四、功能完整性改进
4.1 API 功能扩展
-
缺少批量操作接口
- 需求:批量删除、批量查询
- 实现:添加批量操作端点
- 接口:
POST /weather/batch-delete、POST /weather/batch-query
-
无数据导出格式支持
- 需求:支持 CSV、Excel 导出
- 实现:添加导出端点
- 接口:
GET /weather/export?format=csv
-
缺少搜索和过滤功能
- 需求:按日期、标题、地点搜索
- 实现:添加搜索参数
- 参数:
?keyword=xxx&startDate=xxx&endDate=xxx
4.2 用户功能
-
缺少用户活动统计
- 需求:用户使用情况分析
- 实现:添加统计接口
- 数据:登录次数、数据上传量、最后活跃时间
-
无用户偏好设置
- 需求:保存用户偏好(如默认区域类型)
- 实现:添加用户设置表
- 接口:
GET/PUT /api/user/preferences
五、开发体验改进
5.1 测试覆盖
-
仅有一个基础编译测试
- 现状:
tests/integration_test.rs只有assert_eq!(2 + 2, 4) - 改进:添加完整的测试套件
- 类型:
- 单元测试:工具函数、数据结构
- 集成测试:API 端点
- 数据库测试:CRUD 操作
- 现状:
-
缺少 API 测试
- 需求:验证 API 行为
- 实现:使用
actix-web的测试工具 - 覆盖:正常流程、异常情况、边界条件
5.2 文档完善
-
无 API 文档
- 需求:接口文档
- 实现:使用 OpenAPI/Swagger
- 工具:
utoipacrate
-
缺少部署文档
- 内容:环境要求、配置说明、部署步骤
- 形式:
DEPLOYMENT.md文件
-
无变更日志
- 内容:版本更新记录
- 形式:
CHANGELOG.md文件
5.3 开发工具
-
缺少代码质量工具
- 工具:
clippy、rustfmt - 配置:
.clippy.toml、rustfmt.toml - 集成:CI/CD 流程
- 工具:
-
无热重载开发
- 需求:开发时自动重载
- 实现:使用
cargo-watch - 命令:
cargo watch -x run
六、监控与运维
6.1 日志系统
-
日志格式不统一 ✅
- 现状:部分使用
log宏,部分使用println - 改进:统一使用结构化日志
- 实现:使用
tracingcrate - 完成时间: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 类型冲突)
- 备选方案:
actix-web-lab- 社区维护的中间件库- 手动实现 - 在代码中直接使用
std::sync::atomic收集请求计数 - Nginx 层收集 - 反向代理层已有 access log
6.2 告警机制
-
无异常告警
- 需求:系统异常时通知
- 实现:集成告警服务
- 渠道:邮件、钉钉、企业微信
-
缺少资源监控
- 需求:CPU、内存、磁盘监控
- 实现:使用系统监控工具
- 工具:Prometheus + Grafana
6.3 健康检查
- 缺少健康检查接口 ✅
- 需求:负载均衡器健康检查
- 实现:添加
/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 前端输入问题
- 数字输入框无法输入小数点 ✅
- 问题:用户在输入框输入 "1." 时,末尾小数点会丢失
- 原因:
bindinput时直接用parseFloat()转换,parseFloat("1.")返回1 - 修复:移除
bindinput时的数值转换,改为仅在blur时验证和更新 - 修改文件:
miniprogram/pkg-asd/asdmain/asdmain.ts - 影响范围:
onMeasuredWindSpeedInput- 实测风速输入onPointWindSpeedInput- 测点风速输入onPointHeightInput- 测点高度输入onWindSpeedInput- 风向风速数组输入onWindDirectionInput- 风向数组输入
- 完成时间:2026-04-15
八、技术债务
8.1 高优先级
-
拆分 main.rs 中的路由处理器
- 影响:代码可维护性
- 工作量:中等
-
添加请求频率限制
- 影响:安全性
- 工作量:小
-
统一错误响应格式
- 影响:前后端对接
- 工作量:小
-
添加数据库索引
- 影响:性能
- 工作量:小
8.2 中优先级
-
添加单元测试和集成测试
- 影响:代码质量
- 工作量:大
-
实现 API 文档(OpenAPI)
- 影响:开发体验
- 工作量:中等
-
添加结构化日志
- 影响:运维
- 工作量:中等
-
优化数据库查询
- 影响:性能
- 工作量:中等
8.3 低优先级
-
实现 Redis 缓存
- 影响:性能
- 工作量:大
-
容器化部署
- 影响:部署流程
- 工作量:中等
-
添加监控告警
- 影响:运维
- 工作量:大
-
实现 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 接口或检查日志 |
实施步骤
第一阶段(最小改动):
- 创建
.env.example配置模板,移除敏感信息 - 在
deploy.sh中添加APP_ENV参数支持 - 创建
.env.development本地开发配置(可选加入 .gitignore)
第二阶段(推荐):
- 添加
configcrate 依赖 - 创建
config/default.toml和config/production.toml - 重构
config.rs使用 config crate - 更新
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 前后端字段命名一致性
问题描述:
后端 Rust 使用 #[serde(rename = "camelCase")] 序列化 JSON 字段,前端 TypeScript 必须使用相同的 camelCase 命名才能正确解析。
受影响字段:
| Rust 字段 | JSON 键 | 前端错误写法 | 前端正确写法 |
|---|---|---|---|
is_favorite |
isFavorite |
is_favorite |
isFavorite |
inspection_type |
inspectionType |
inspection_type |
inspectionType |
assignment_number |
assignmentNumber |
assignment_number |
assignmentNumber |
问题后果:
- 前端使用 snake_case 命名,接口返回的 camelCase 字段会是
undefined - 详情页面显示
undefined而非正确值
验证方法:
# 搜索后端 serde rename 配置
grep -n 'rename = "' src/models.rs
经验教训:
- API 接口字段命名应在前后端团队间统一约定
- 或让后端提供 JSON Schema / OpenAPI 文档
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
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");
经验教训:
- 使用
configcrate 的路径相关函数时注意基准目录 - 直接使用
std::env::var("CARGO_MANIFEST_DIR")更可靠
13.4 TOML 配置文件结构
问题描述:
TOML 文件中的 [development] 等 section headers 与 config crate 的合并逻辑冲突。
错误写法:
[development]
database_url = "..."
正确写法:
database_url = "..."
environment = "development"
经验教训:
- 保持 TOML 文件扁平结构,不使用 section headers
- 简化配置加载逻辑
十四、付费功能系统
当前状态
已有基础设施:
- ✅ 数据库字段:
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 | 后台手动设置 |
核心设计原则
- 多订单支持:一个用户可以有多条支付记录
- 累积计算:后续购买应累加有效期,而非覆盖
- 冗余字段:
users表的is_paid和paid_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)), // 永久会员
}
}
/// 确认订单支付
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)
}
累积计算示例:
| 操作 | 原到期时间 | 购买套餐 | 新到期时间 |
|---|---|---|---|
| 首次购买 | - | 包月(30天) | 现在+30天 |
| 第二次购买 | 5/1 | 包年(365天) | MAX(5/1, 现在)+365天 |
| 第三次购买 | 明年5/1 | 包月(30天) | 明年5/1+30天 |
12.4 接口设计
微信支付流程
// 1. 创建订单
POST /api/payment/create-order
Request: { "package_type": "monthly" | "yearly" | "permanent" }
Response: {
"success": true,
"data": {
"order_id": "内部订单号",
"prepay_id": "微信预支付ID",
"package": "prepay_id=...",
"timestamp": "...",
"nonce_str": "...",
"pay_sign": "..."
}
}
// 2. 支付回调
POST /api/payment/callback
Request: {
"event_type": "TRANSACTION.SUCCESS",
"resource": {
"out_trade_no": "内部订单号",
"transaction_id": "微信订单号",
"trade_state": "SUCCESS"
}
}
Response: { "code": "SUCCESS" }
// 3. 邀请码兑换
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"
}
}
// 4. 管理员开通
PUT /api/admin/users/{id}/payment
Request: {
"is_paid": true,
"paid_expires_at": "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 注意事项
- 幂等性:支付回调需处理重复通知(微信可能多次推送)
- 永久会员:expires_at 设为 2099-12-31 而非 NULL
- 事务处理:确认订单和更新会员状态应在同一事务中
- 退款处理:需实现退款接口,更新 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:
{
"package_type": "yearly",
"count": 10,
"expires_in_days": 365
}
Response:
{
"success": true,
"data": {
"codes": [
{"code": "ASD2024Y01A2B3C", "paid_days": 365, "expires_at": "2027-04-17T00:00:00Z"},
{"code": "ASD2024Y04D5E6F", "paid_days": 365, "expires_at": "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