Files
asd-backend/AGENTS.md

25 KiB
Raw Blame History

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             # 错误处理
├── rate_limiter.rs      # 滑动窗口 Rate Limiter5次/分钟/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 生成逻辑

生成流程

  1. 前端调用后端获取天气数据
  2. 后端返回 weatherData JSON
  3. 前端加载 afterbody.js,将数据注入 window.weatherData
  4. 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 remotegitea-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 表)
hasspotcheckwindspeed BOOLEAN DEFAULT false(是否有抽测风速)
spotcheckcount      INTEGER DEFAULT 5(抽测次数,用户可自定义 0-10
winddirection       JSON[](风向数组,前 10 项主测 +  spotcheckcount 项抽测)
windspeed           JSON[](风速数组,同上)
-- 其他 30+ 个天气测量字段列

refresh_tokens 表

id                  SERIAL PRIMARY KEY
user_id             INTEGER(外键,关联 users 表)
token               VARCHARRefresh 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(创建时间)

认证流程

  1. 登录POST /api/login 传入微信 code → 调用微信 API → UPSERT 用户 → 返回双 Token
  2. 双 Token 机制
    • token: access_token24小时用于 API 认证
    • refresh_token: 7天有效期用于续期 access_token
  3. JWT 声明{exp, iat, user_id, openid, user_type}
  4. 中间件jwt_middleware 提取 Bearer 令牌,验证后将 Claims 插入请求扩展
  5. 处理器访问:通过 claims: web::ReqData<Claims> 参数获取
  6. 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 → 支付宝
  1. 用户在小程序 mine 页面点击升级入口(未付费用户)
  2. 小程序调用 POST /api/web-login/auto-confirm(微信 code → openid → 创建用户 → JWT → payment_url
  3. 小程序跳转 outter 页面URL 指向 /payment?jwt=xxx
  4. outter 页面提示用户在外部浏览器打开
  5. 用户在手机浏览器打开 /payment?jwt=xxx
  6. JWT 在 URL 参数中,网页自动通过 JWT 登录,获取用户信息和付费状态
  7. 未付费用户显示套餐选择页(包月/包年),已付费用户显示会员信息
  8. 用户选择套餐,点击「去支付」→ 跳转到 /payment/page?package=xxx&jwt=xxx
  9. 后端生成订单,渲染支付宝支付表单(表单自动提交到沙箱/正式环境)
  10. 支付宝沙箱/正式环境展示支付页面
  11. 支付完成后,支付宝异步通知 /payment/notify
  12. 跳转成功页 /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_IDALIPAY_PRIVATE_KEYALIPAY_ALIPAY_PUBLIC_KEYALIPAY_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 列出可用备份
--init-env 初始化远程 .env生成随机 JWT_SECRET + 推送模板到服务器)
--help, -h 显示帮助信息

示例:

./deploy.sh production --dry-run    # 预览生产部署
./deploy.sh production --yes        # 无需确认直接部署
./deploy.sh development --skip-tests  # 跳过测试
./deploy.sh production --init-env   # 初始化生产 .env
./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 到服务器手动上传文件
  • 直接 scprsync 二进制文件到服务器
  • 直接 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 环境配置(推荐使用 deploy.sh

# 生产环境 — 自动生成随机 JWT_SECRET推送 .env 模板到服务器
./deploy.sh production --init-env

# 开发环境
./deploy.sh development --init-env

--init-env 会:

  • 生成 256 位随机 JWT_SECRETopenssl rand -base64 32
  • 推送 .env 文件到服务器 ${REMOTE_DIR}/.env
  • 设置文件权限 600(仅 root 可读)
  • 如文件已存在,先备份再覆盖

执行后仍需 SSH 到服务器填写以下手动变量:

ssh root@1panel-server
vim /root/rust/rust_backend/.env

# 必须手动填写:
DATABASE_URL=postgres://user:pass@host:5432/dbname
WECHAT_APPID=wx...
WECHAT_SECRET=xxx

不建议手动创建 .env——因为 deploy.sh --init-env 会自动生成强随机 JWT_SECRET,手动创建容易忘记生成或使用弱密钥。

JWT_SECRET 管理

生命周期 操作 说明
生成 openssl rand -base64 32 首次 deploy.sh --init-env 自动完成
存储 服务器 .env,权限 600 不进入 Git不进入 TOML 配置
验证 deploy.sh check_env_file 部署前自动检查是否缺失
轮换 手动替换 → 重启服务 旧 token 失效,所有用户需重新登录

原则:

  • Dev 和 Prod 使用不同的 JWT_SECRET
  • JWT_SECRET 永不出现在 config/*.toml 或任何 Git 管理的文件中
  • 服务器 .env 文件不通过 deploy.sh 同步(不会上传到服务器),仅手动或通过 --init-env 创建

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 中使用保留关键字时加双引号

常见任务

添加新接口

  1. main.rs 中创建处理器函数
  2. 如需要,在 db.rs 中添加数据库函数
  3. create_server_config 中注册
  4. 如需要,在 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)

修改数据库

  1. migrations/ 中创建迁移 SQL
  2. 在 PostgreSQL 上手动执行迁移
  3. 更新 models.rs 中的结构体
  4. 更新 db.rs 中的数据库函数

开发命令

cargo build                    # 编译
APP_ENV=development cargo run  # 开发环境运行
APP_ENV=production cargo run   # 生产环境运行
cargo test                     # 运行测试
cargo clippy                  # 代码检查