P0: 从config/production.toml和config/development.toml移除wechat_secret/jwt_secret
这些密钥现在仅通过.env(EnvironmentFile)加载,不再进入Git历史
config.rs: 新增直接环境变量名回退(JWT_SECRET而非仅APP_JWT_SECRET)
P1: - 移除config.rs中的server_ports字段(死代码,未被任何代码使用)
- 简化main.rs端口fallback: 生产环境仅4433,开发环境仅8080/3000
- .env.example版本号0.2.3→0.3.0
Docs: - AGENTS.md测试域名xmclassmate.top/dev→dev.xmclassmate.top
23 KiB
AGENTS.md - Rust 后端
项目概述
微信小程序天气数据采集后端。用户通过微信认证,上传天气观测数据,并通过 REST API 管理数据。
技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Rust | edition 2024 | 主力语言(Rust 1.85+ 稳定) |
| actix-web | 4.11 | Web 框架 |
| sqlx | 0.8.6 | PostgreSQL 连接 |
| serde | 1.0 | 序列化 |
| jsonwebtoken | 9.3 | JWT 认证 |
| reqwest | 0.12 | HTTP 客户端 |
| chrono | 0.4 | 时间处理 |
项目结构
src/
├── main.rs # 入口文件,服务器配置,路由注册
├── auth.rs # JWT 中间件,令牌生成/验证
├── db.rs # 数据库操作(通过 sqlx 执行原始 SQL)
├── models.rs # 数据结构(Claims、User、WeatherData 等)
├── config.rs # 配置加载(支持多环境:config/*.toml)
├── error.rs # 错误处理
├── alipay.rs # 支付宝签名模块(RSA2 sign/verify、URL encode)
├── rate_limiter.rs # 滑动窗口 Rate Limiter(5次/分钟/IP)
└── handlers/ # 路由处理器模块
├── mod.rs # 模块导出
├── meta.rs # 根路径状态页 (/), 健康检查
├── auth.rs # 登录相关 (login, refresh-token, mock-login)
├── weather.rs # 天气数据 CRUD
├── user.rs # 用户相关
├── admin.rs # 管理员功能
├── payment.rs # 支付相关(支付宝网页支付)
├── favorites.rs # 收藏功能
├── health.rs # 健康检查 (/health)
└── static_files.rs # 静态文件服务
config/ ├── default.toml # 默认配置(所有环境的共同默认值) ├── development.toml # 开发/测试环境配置 └── production.toml # 生产环境配置
migrations/ # 数据库迁移 SQL tests/ # 集成测试 static/ # 静态文件 │ ├── css/ │ │ ├── report.css # PDF 报表样式 │ │ └── style.css # 通用样式 │ ├── js/ │ │ ├── afterbody.js # PDF 生成模块(html2pdf) │ │ └── ... │ └── katex/ # KaTeX 数学公式 deploy.sh # 部署脚本 set-paid-user.sh # 设置用户付费状态 expire-paid-user.sh # 使用户付费时间过期 .env.example # 环境变量模板
---
## 服务状态页
### 根路径 `/`
访问根路径返回服务状态 HTML 页面,显示:
- 服务名称
- 数据库连接状态
- **版本号**(从 `Cargo.toml` 编译时嵌入)
### 健康检查 `/health`
返回 JSON 格式健康状态:
```json
{
"status": "ok",
"database": "connected",
"version": "0.3.0"
}
PDF 报表生成
功能位置
static/js/afterbody.js - 使用 html2pdf 库生成 PDF 报表
依赖
html2pdf- HTML 转 PDF 库jspdf- PDF 生成库html2canvas- HTML 转图片库
静态文件依赖
| 文件 | 用途 |
|---|---|
/static/css/style.css |
PDF 报表样式 |
/static/js/afterbody.js |
PDF 生成逻辑 |
生成流程
- 前端调用后端获取天气数据
- 后端返回
weatherDataJSON - 前端加载
afterbody.js,将数据注入window.weatherData afterbody.js构建 HTML 并调用html2pdf生成 PDF
注意事项
- ⚠️ 生成 PDF 前需确保
/static/css/report.css存在 - 建议在 PDF 生成前检测静态文件可用性
配置管理
配置文件方式(推荐)
通过 APP_ENV 环境变量选择配置文件:
APP_ENV=development cargo run # 使用 config/development.toml
APP_ENV=production cargo run # 使用 config/production.toml
直接环境变量方式
最高优先级,可覆盖配置文件:
APP_DATABASE_URL=postgres://user:pass@host:5432/dbname
APP_JWT_SECRET=your_secret_key
APP_WECHAT_APPID=wx...
配置文件优先级
环境变量 > production.toml > development.toml > default.toml
版本管理
版本号统一在 Cargo.toml 中管理:
[package]
name = "rust-backend"
version = "0.3.0" # 唯一版本定义
不再使用 app_version 字段(已移除)。
版本控制
本项目使用 jj (Jujutsu) 替代 git 进行版本控制。提交变更时务必使用 jj 而非原生 git。
| 操作 | 命令 | 说明 |
|---|---|---|
| 查看状态 | jj status |
当前工作目录变更 |
| 提交 | jj commit -m "msg" |
创建新变更 |
| 推送 | jj git push |
推送到远程 |
| 拉取 | jj git fetch |
从远程拉取 |
| 查看日志 | jj log |
变更历史 |
git remote:gitea-server:milky/asd-backend.git
数据库
连接信息
| 环境 | 连接字符串 |
|---|---|
| 测试 | postgres://milkydata:password@127.0.0.1:5432/milkydata_dev |
| 生产 | postgres://milkydata:password@127.0.0.1:5432/milkydata |
⚠️ Docker PostgreSQL 监听在
127.0.0.1而非localhost
连接数据库
# 查看容器
docker ps | grep postgres
# 连接数据库(容器内)
docker exec -it postgres_container psql -U postgres -d milkydata_dev
表结构
users 表
id INTEGER PRIMARY KEY
openid VARCHAR UNIQUE(微信用户 ID)
name VARCHAR(默认为 openid 前 8 个字符)
type INTEGER(硬编码为 2)
is_paid BOOLEAN DEFAULT false
is_admin BOOLEAN DEFAULT false
paid_expires_at TIMESTAMPTZ DEFAULT NULL
weather_data 表
id INTEGER PRIMARY KEY
user_id INTEGER(外键,关联 users 表)
-- 30+ 个天气测量字段列
refresh_tokens 表
id SERIAL PRIMARY KEY
user_id INTEGER(外键,关联 users 表)
token VARCHAR(Refresh Token 字符串)
expires_at TIMESTAMPTZ(过期时间)
created_at TIMESTAMPTZ(创建时间)
payment_orders 表
id SERIAL PRIMARY KEY
order_no VARCHAR UNIQUE(订单号,格式:ASD{timestamp}{random})
user_id INTEGER(外键,关联 users 表)
package_type VARCHAR(套餐类型:monthly/yearly/permanent)
amount INTEGER(金额,单位:分)
status VARCHAR(订单状态:pending/paid/cancelled/expired)
paid_at TIMESTAMPTZ(支付时间,可空)
created_at TIMESTAMPTZ(创建时间)
认证流程
- 登录:
POST /api/login传入微信 code → 调用微信 API → UPSERT 用户 → 返回双 Token - 双 Token 机制:
token: access_token(24小时),用于 API 认证refresh_token: 7天有效期,用于续期 access_token
- JWT 声明:
{exp, iat, user_id, openid, user_type} - 中间件:
jwt_middleware提取 Bearer 令牌,验证后将 Claims 插入请求扩展 - 处理器访问:通过
claims: web::ReqData<Claims>参数获取 - Token 刷新:
POST /api/refresh-token用 refresh_token 换取新的 access_token 和 refresh_token
公开接口
| 接口 | 说明 |
|---|---|
POST /api/login |
微信登录,返回双 Token |
POST /api/refresh-token |
刷新 access_token |
API 接口
公开接口
| 接口 | 说明 |
|---|---|
GET / |
服务状态页(显示版本、数据库连接状态) |
GET /health |
健康检查 |
POST /api/login |
微信登录,返回双 Token |
GET /api/mock-login |
Mock 登录(沙箱测试用,受 MOCK_LOGIN_ENABLED 环境变量控制) |
POST /api/refresh-token |
刷新 access_token |
GET /weather/details |
获取天气详情(支持 JWT 或 temp_token) |
GET /static/{tail:*} |
静态文件 |
受保护接口(需要 JWT)
| 接口 | 说明 |
|---|---|
POST /api/post-weather-data |
上传天气数据(带配额检查) |
GET /api/user/profile |
获取当前用户信息 |
PUT /api/user/profile |
保存用户信息 |
GET /weather |
分页列出用户的天气数据 |
POST /api/generate-temp-token/{resource_id} |
生成 10 分钟分享令牌 |
DELETE /weather/delete/{id} |
删除天气记录 |
GET /api/favorites |
获取收藏列表 |
POST /api/favorites/{id} |
添加收藏 |
DELETE /api/favorites/{id} |
删除收藏 |
GET /api/user/quota |
获取用户配额 |
管理员接口(需要 JWT + is_admin)
| 接口 | 说明 |
|---|---|
PUT /api/admin/users/{id}/payment |
更新用户支付状态 |
GET /api/admin/users/{id} |
获取用户信息 |
登录码接口(网页授权)
| 接口 | 说明 |
|---|---|
POST /api/web-login/auto-confirm |
小程序一键确认:微信 code → openid → 创建用户 → JWT → 返回 payment_url |
POST /api/web-login/code |
小程序用微信 code 换取 display_code(旧方案) |
POST /api/web-login/confirm |
小程序确认登录(需 JWT + code,旧方案) |
GET /payment/generate-code |
网页端生成登录码(已废弃,不建议使用) |
GET /payment/login-status |
网页端轮询登录状态 |
支付接口
| 接口 | 说明 |
|---|---|
GET /payment |
套餐选择页(无需认证,外部浏览器访问) |
GET /payment/page |
支付页面(需 JWT,支持 URL 参数 jwt 或 Cookie) |
POST /api/payment/create-order |
创建订单(需 JWT) |
GET /payment/pay |
唤起支付宝支付(需 JWT + order_no) |
POST /payment/notify |
支付宝异步回调通知(需 RSA 签名验证,无认证) |
GET /payment/success |
支付成功页(需 order_no) |
POST /api/payment/mock-confirm |
模拟支付确认(沙箱测试用) |
POST /api/payment/sync-order |
根据订单号强制同步会员状态(幂等) |
GET /api/payment/orders |
获取当前用户的订单记录 |
支付宝配置(可选)
不配置则使用模拟支付;配置后启用真实支付宝支付:
# .env 或 config/*.toml
ALIPAY_APP_ID=your_alipay_app_id
ALIPAY_PRIVATE_KEY=your_private_key_content
ALIPAY_ALIPAY_PUBLIC_KEY=alipay_public_key_content
ALIPAY_GATEWAY=https://openapi-sandbox.dl.alipaydev.com/gateway.do # 沙箱
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do # 正式
正式环境必须使用 HTTPS(服务已支持 TLS)
代码模式
处理器模式
#[post("/api/endpoint")]
async fn handler(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
body: web::Json<RequestBody>,
) -> impl Responder {
match db::function(pool.get_ref(), claims.user_id).await {
Ok(data) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": data
})),
Err(e) => HttpResponse::Ok().json(serde_json::json!({
"success": false,
"errcode": 500,
"errmsg": e
}))
}
}
数据库函数模式
pub async fn function_name(pool: &PgPool, param: i32) -> Result<Type, String> {
match sqlx::query_as::<_, Type>("SELECT ...")
.bind(param)
.fetch_optional(pool)
.await
{
Ok(Some(row)) => Ok(row),
Ok(None) => Err("Not found".to_string()),
Err(e) => Err(format!("Query failed: {}", e)),
}
}
错误响应格式
{"success": false, "errcode": 403, "errmsg": "Error message"}
⚠️ 常见坑
1. config crate 路径问题
File::with_name() 使用当前工作目录,而非 CARGO_MANIFEST_DIR。
// ❌ 错误
let config = Config::builder()
.add_source(File::with_name("config"))
.build();
// ✅ 正确:使用 CARGO_MANIFEST_DIR
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let config_path = Path::new(manifest_dir).join("config");
2. TOML 配置结构
保持扁平结构,不使用 section headers。
# ❌ 错误:section headers 导致合并冲突
[development]
database_url = "..."
# ✅ 正确:扁平结构
database_url = "..."
3. serde skip 与查询冲突
skip_deserializing 会导致 SELECT 时字段缺失错误。
// ❌ 错误:skip_deserializing 影响查询
#[serde(rename = "isFavorite", skip_deserializing)]
pub is_favorite: bool,
// ✅ 正确:使用 default
#[serde(rename = "isFavorite", default)]
pub is_favorite: bool,
4. JWT 中不能添加 is_admin
禁止在 JWT Claims 中添加 is_admin,必须查询数据库验证。
5. 配额决策不能信任 JWT
必须查询数据库检查 is_paid_active,而非信任 JWT 中的声明。
6. SQL 保留关键字
SQL 中使用 desc 等保留关键字时必须加双引号:
-- ❌ 错误
ORDER BY desc
-- ✅ 正确
ORDER BY "desc"
7. actix-ratelimit 不兼容
actix-ratelimit 0.3.1 与 actix-web 4.x 不兼容。
备选方案:actix-web-lab、手动 HashMap 实现、Nginx 层限流。
支付系统
支付流程(登录码授权模式)
小程序内无法直接接入支付宝支付,采用外部浏览器中转方案:
小程序(mine/升级页) → outter页面 → 外部浏览器 → /payment → 支付宝
- 用户在小程序 mine 页面点击升级入口(未付费用户)
- 小程序调用
POST /api/web-login/auto-confirm(微信 code → openid → 创建用户 → JWT → payment_url) - 小程序跳转
outter页面,URL 指向/payment?jwt=xxx - outter 页面提示用户在外部浏览器打开
- 用户在手机浏览器打开
/payment?jwt=xxx - JWT 在 URL 参数中,网页自动通过 JWT 登录,获取用户信息和付费状态
- 未付费用户显示套餐选择页(包月/包年),已付费用户显示会员信息
- 用户选择套餐,点击「去支付」→ 跳转到
/payment/page?package=xxx&jwt=xxx - 后端生成订单,渲染支付宝支付表单(表单自动提交到沙箱/正式环境)
- 支付宝沙箱/正式环境展示支付页面
- 支付完成后,支付宝异步通知
/payment/notify - 跳转成功页
/payment/success?order_no=xxx(同步确认订单,幂等)
登录码相关接口
| 接口 | 说明 |
|---|---|
POST /api/web-login/code |
小程序用微信 code 换取 display_code |
POST /api/web-login/confirm |
小程序确认登录(需 JWT + code) |
GET /payment/generate-code |
网页端生成登录码(已废弃,不建议使用) |
GET /payment/login-status |
网页端轮询登录状态(返回 token 表示已确认) |
Mock 登录(沙箱测试)
沙箱环境下无法获取真实微信 code,使用 Mock 登录获取测试 JWT:
# 获取 mock JWT(需服务器设置 MOCK_LOGIN_ENABLED=true)
curl http://127.0.0.1:8080/api/mock-login
# 返回格式
{"success":true,"token":"eyJ0eX...","refresh_token":"MTAxOj...aW9u","user_id":101}
Mock 登录支持 user_id 参数指定已有用户,或自动创建新用户。环境变量 MOCK_LOGIN_ENABLED=false 时返回 403。
Mock 支付(沙箱测试)
当 .env 中未配置 ALIPAY_* 环境变量时,自动启用 Mock 支付模式:
流程:
用户访问 /payment/page → 显示 Mock 支付页面 → 点击"确认模拟支付" → 调用 /api/payment/mock-confirm → 激活会员
特点:
- 无需真实支付宝配置
- Mock 页面显示订单号和套餐信息
- 点击后通过
POST /api/payment/mock-confirm激活会员 - 有效期按套餐天数累加计算
启用真实支付:
在 .env 中配置 ALIPAY_APP_ID、ALIPAY_PRIVATE_KEY、ALIPAY_ALIPAY_PUBLIC_KEY、ALIPAY_GATEWAY,重启服务后自动禁用 Mock 模式。
累积计算逻辑
用户多次购买时,有效期会累加而非覆盖:
let base_time = std::cmp::max(current_expires, Utc::now());
let new_expires = base_time + days(pkg_days);
永久会员
永久会员的 expires_at 设为 2099-12-31 而非 NULL。
配额限制
- 未付费用户限制为
FREE_USER_DATA_LIMIT条记录 - 付费用户(活跃状态)无限制
部署
服务器信息
| 环境 | 域名 | 端口 | 远程目录 |
|---|---|---|---|
| 测试 | dev.xmclassmate.top | 8080 | /root/rust/rust_backend_dev |
| 生产 | xmclassmate.top | 4433 | /root/rust/rust_backend |
systemd 服务
| 环境 | systemd unit | 工作目录 | APP_ENV |
|---|---|---|---|
| 测试 | rust-backend-dev.service |
/root/rust/rust_backend_dev |
development |
| 生产 | rust-backend.service |
/root/rust/rust_backend |
(使用 .env) |
⚠️ systemd 配置使用
EnvironmentFile=加载.env文件(生产、开发环境均如此) 注意:EnvironmentFile不支持多行值,多行 PEM 密钥需 base64 编码或单行格式
部署命令
./deploy.sh development # 部署到测试服务器
./deploy.sh production # 部署到生产服务器
deploy.sh 选项:
| 选项 | 说明 |
|---|---|
--dry-run |
预览模式,不执行实际操作 |
--yes, -y |
跳过确认提示 |
--skip-tests |
跳过部署后测试 |
--rollback |
回滚到上一个备份版本 |
--backup-list |
列出可用备份 |
--help, -h |
显示帮助信息 |
示例:
./deploy.sh production --dry-run # 预览生产部署
./deploy.sh production --yes # 无需确认直接部署
./deploy.sh development --skip-tests # 跳过测试
./deploy.sh production --rollback # 回滚到上一个版本
./deploy.sh production --backup-list # 列出可用备份
deploy.sh 功能:依赖检查 → 编译 → 备份旧版本 → 清理旧备份(保留5个不同版本)→ 上传二进制/配置/迁移/脚本 → 重启服务 → 数据库备份 → 执行迁移 → 部署后测试 → 记录日志 → Webhook 通知
二进制备份:
- 命名格式:
{project}.backup.{timestamp}.{gitHash}.{md5前8位} - 示例:
rust-backend.backup.20260419_204600.a91948b.7f3e2d1c - 自动保留 5 个不同版本的备份(相同版本只保留最早的)
- 清理时按 MD5 去重,防止同一版本的多个备份占用空间
数据库备份:
- 位置:
${REMOTE_DIR}/backups/${DB_NAME}_*.dump - 保留数量:1个
- 格式:
pg_dump -Fc(自定义格式,可压缩)
数据库迁移:
- 位置:
${REMOTE_DIR}/migrations/*.sql - 自动检测:检查表是否存在,跳过已执行的迁移
- 迁移文件名格式:
{序号}_{描述}.sql
自动回滚:部署后测试失败时,自动回滚到上一个正常版本并重启服务。
- 回滚标记保存在
${REMOTE_DIR}/.last_deployed - 测试失败后自动执行,无需手动干预
- Webhook 通知会发送失败回滚消息
手动回滚(支持选择版本):
./deploy.sh production --rollback # 回滚时可以选择版本
./deploy.sh production --backup-list # 列出所有可用备份
回滚时会列出所有备份(按版本分组),用户可以选择回滚到哪个版本。
**Webhook 通知(可选):**
```bash
WEBHOOK_URL="https://example.com/webhook" ./deploy.sh production
部署日志位置:/root/rust/rust_backend/deploy.log
部署规范
重要:所有部署操作必须使用
deploy.sh,禁止手动操作服务器文件。
禁止事项:
- ❌ 直接 SSH 到服务器手动上传文件
- ❌ 直接
scp或rsync二进制文件到服务器 - ❌ 直接
systemctl restart服务而不通过脚本 - ❌ 直接修改服务器上的配置文件
deploy.sh 已包含的安全保障:
- ✅ 部署前自动备份旧版本
- ✅ 部署前自动备份数据库
- ✅ 自动检查依赖
- ✅ 自动测试
- ✅ 部署日志记录
首次部署准备
首次在服务器上部署时,需要手动创建 systemd 服务文件和环境配置:
1. 创建 systemd 服务文件:
开发环境服务:/etc/systemd/system/rust-backend-dev.service
生产环境服务:/etc/systemd/system/rust-backend.service
参考本地文件:rust-backend.service(生产)、rust-backend-dev.service(开发)
2. 创建 .env 环境配置:
开发环境:/root/rust/rust_backend_dev/.env
生产环境:/root/rust/rust_backend/.env
参考 .env.example 或询问运维获取。
3. 启用服务:
systemctl daemon-reload
systemctl enable rust-backend-dev.service # 开发环境
systemctl enable rust-backend.service # 生产环境
前端部署
前端部署由微信开发者工具单独完成,详见 ASD-fronted/AGENTS.md:
// ASD-fronted/miniprogram/config/env.ts
const CURRENT_ENV: 'development' | 'production' = 'production'; // 发布前切换
切换后通过微信开发者工具上传。
部署后测试
部署后验证是否成功,直接运行 test_deployment.sh:
# 设置测试域名
export TEST_DOMAIN="https://dev.xmclassmate.top" # 开发环境
export TEST_DOMAIN="https://xmclassmate.top" # 生产环境
# 运行测试
./test_deployment.sh
注意:deploy.sh 会自动执行此测试,但也可手动单独运行验证。
测试内容:
- 本地后端健康检查
- API 端点检查
- 静态文件检查
禁止事项
- ❌ 在 JWT Claims 中添加
is_admin(必须查数据库) - ❌ 信任 JWT 中的
is_paid来做配额决策 - ❌ 使用
as any类型错误抑制 - ❌ 添加审计日志
- ❌ 直接操作服务器文件(必须通过部署脚本)
必须事项
- ✅ 管理员接口必须通过数据库查询验证管理员身份
- ✅ 已认证处理器使用
web::ReqData<Claims> - ✅ 遵循现有的错误响应格式
- ✅ 通过数据库查询检查
is_paid_active - ✅ SQL 中使用保留关键字时加双引号
常见任务
添加新接口
- 在
main.rs中创建处理器函数 - 如需要,在
db.rs中添加数据库函数 - 在
create_server_config中注册 - 如需要,在
models.rs中添加请求/响应结构体
路由配置结构
App::new()
.app_data(web::Data::new(pool))
.app_data(web::Data::new(http_client))
.app_data(web::Data::new(app_state))
// 公开接口(无 middleware)
.service(login)
// 受保护接口(JWT middleware)
.service(
web::scope("")
.wrap(from_fn(jwt_middleware))
.service(post_weather_data)
)
// 静态文件和健康检查放最后
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
.service(health_check)
修改数据库
- 在
migrations/中创建迁移 SQL - 在 PostgreSQL 上手动执行迁移
- 更新
models.rs中的结构体 - 更新
db.rs中的数据库函数
开发命令
cargo build # 编译
APP_ENV=development cargo run # 开发环境运行
APP_ENV=production cargo run # 生产环境运行
cargo test # 运行测试
cargo clippy # 代码检查