Compare commits

..

18 Commits

Author SHA1 Message Date
a037ea3064 feat: 邮件告警与每日报告优化(HTML+UTF-8、阈值实时告警、支付卡单检测、重试与状态机)
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
2026-08-21 09:02:59 +08:00
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
366d62f262 feat: 部署时自动校验支付宝签名一致性 + 验签失败升级为 error + 密钥核对脚本 2026-08-10 16:06:02 +08:00
49d740e27c fix: 移除订单自动确认,仅依赖支付宝回调验签;rsa2_verify 支持裸 base64 DER 公钥;.env 取消 git 跟踪 2026-08-10 15:49:27 +08:00
2ce97243ab fix: 全量代码审计修复 — 30项
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
P0 - Panic 风险修复:
  - payment.rs: unwrap() → let-else safe handling
  - payment.rs: get_jwt_secret() expect → Result/AppError
  - auth.rs: openid 切片添加 len >= 8 守卫
  - main.rs: 启动时 expect → unwrap_or_else 描述性 panic
  - main.rs: Directive::from_str 添加 fallback

P1 - 逻辑/安全修复:
  - payment.rs: urlencoding() + 解码 bug 修复 (移除 had_escape)
  - payment.rs: Mock 支付添加 check_mock_payment_allowed 检查
  - db.rs: 永久会员 NULL → 2099-12-31 一致化
  - user.rs: 维护模式添加安全说明注释
  - 自动清理 unused_variables 警告 (_is_mobile)

P2 - 错误吞没修复:
  - main.rs: 3 处定时任务 let _ = → if let Err = tracing::error!
  - db.rs + admin.rs: 7 处通知/审计日志 let _ = → tracing::warn!
  - auth.rs: refresh token 保存 add warn 日志

P3 - 死代码清理:
  - models.rs: 移除 TokenResponse (dead)
  - models.rs: 移除 AppState 中 5 个未使用字段 (env var 直接读取)
  - error.rs: 移除 3 个 dead ErrorResponse 方法
  - rate_limiter.rs: extract_client_ip_from_header → #[cfg(test)]
  - models.rs: 注释 typo fix (user_ytpe → user_type)
  - db.rs: RefreshToken 添加 deserialization 注释

Shell 脚本修复:
  - deploy.sh: run_migrations 移到 restart_service 之前
  - test.sh: 移除 EXIT trap 覆盖; heredoc 引号修复; 维护模式添加 restart
  - common.sh: mock_key 添加 sed 转义 (防 / & 注入)

验证: cargo check 0 warnings, 8 tests passed
2026-07-23 12:40:47 +08:00
217e7f8a55 fix: deploy.sh/test.sh 审计修复
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
deploy.sh:
- 修复重复的 header 注释行

lib/common.sh:
- view_logs/tail_logs/status: 从日志文件切换为 journalctl(上次改
  日志输出到 stdout 后,日志文件已不存在)
- init_env 模板: User=root → User=rust-backend(匹配最小权限原则)

lib/test.sh:
- test_notify_url_domain: 添加自定义 Host 头(TEST_DOMAIN),模拟
  真实外部请求场景。之前测试走 127.0.0.1 直连,Host 头为 localhost
  导致 notify_url 校验永远通过但实际配置可能是错误的
2026-07-23 12:25:55 +08:00
0b0757939b fix: 使用请求 Host 头动态构建 notify_url + 迁移文档/测试
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
根因:base_url 默认硬编码为 dev.xmclassmate.top,导致支付宝通知回调
(notify_url)指向开发服务器,RSA2 签名验证失败,订单延迟 12 分钟。

修复:
- 新增 get_base_url():优先用 APP_BASE_URL 环境变量,否则从请求
  Host 头获取当前域名,确保 notify_url 始终指向正确的服务器
- 生产 .env 已设置 APP_BASE_URL=https://xmclassmate.top
- .env.example 新增 APP_BASE_URL 文档

迁移文档:
- SERVER-MIGRATION-PLAN.md 新增阶段 3 关键配置章节
- 12 项迁移检查清单 + 历史问题复盘

部署测试:
- test.sh 新增 test_notify_url_domain:校验支付表单 notify_url
  域名与 BASE_URL 一致,迁移后自动捕获配置错误
2026-07-23 12:09:52 +08:00
65157475cb fix: 统一使用 alipay.trade.page.pay + 支付全链路测试
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
- 因支付宝产品仅开通电脑网站支付,移除 mobile/desktop 切换
- 统一使用 alipay.trade.page.pay + FAST_INSTANT_TRADE_PAY
- 该接口在手机浏览器中也能跳转支付宝或显示移动端页面
- 新增 tests/payment_e2e_test.sh — 全链路支付测试脚本
2026-07-22 20:39:57 +08:00
3135f790bb fix: 移除 deploy 模板中的 PAYMENT_MAINTENANCE_MODE
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
生产环境已配置支付宝正式密钥,关闭支付维护模式。
- 从 blue/green service 模板移除 Environment=PAYMENT_MAINTENANCE_MODE=true
- 服务器 .env 已更新为生产密钥
- 生产网关: openapi.alipay.com
2026-07-22 20:06:11 +08:00
6b0871f77d feat: 根据 User-Agent 自动切换手机/电脑网站支付
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
- 新增 is_mobile_ua() 检测手机浏览器(mobile/android/iphone/ipad/harmonyos)
- build_alipay_form_html 新增 is_mobile 参数
- 手机端: alipay.trade.wap.pay + QUICK_WAP_PAY
- 桌面端: alipay.trade.page.pay + FAST_INSTANT_TRADE_PAY
- 同时应用于 payment_page 和 alipay_pay_page 两个入口
2026-07-20 15:22:42 +08:00
a7ffd387d9 feat: 添加访客登录 API (POST /api/guest-login) 支持网站独立支付
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
- 新增 web_guest_login handler: 创建访客用户(免微信 code),返回 JWT
- 路由注册在公共区(无需 JWT 认证)
- /payment 页面新增「访客登录」按钮,直接选择套餐支付
- 保留微信小程序登录作为备选
- 已部署到 dev.xmclassmate.top 验证通过
2026-07-20 15:08:30 +08:00
2c86219c82 feat: Alipay 安全加固 + 切换手机网站支付 (wap.pay)
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
- Phase 1: 移除 Git 中 ALIPAY 密钥,改用 EnvironmentFile
  所有 4 个 deploy 模板删除 ALIPAY_* 内联 Environment,改为指向 .env
  check_service_file() 不再要求 ALIPAY_APP_ID 在 Environment 中
  init_env() 生成模板添加 EnvironmentFile 行

- Phase 2: 切换到手机网站支付 (alipay.trade.wap.pay)
  payment.rs: product_code QUICK_WAP_PAY, method alipay.trade.wap.pay
  用户从手机浏览器访问时自动唤起支付宝 APP

- Phase 3: 服务器 .env 文件创建
  所有环境 .env 文件已通过 SSH 创建(600 权限)
  dev 使用沙箱网关 (openapi-sandbox.dl.alipaydev.com)
  prod 使用正式网关 (openapi.alipay.com)

- 生产部署完成(blue→green 双环境同步)
  密钥安全: 不再存在于任何 Git 跟踪文件中
2026-07-20 14:52:39 +08:00
4c5c879583 fix: static_files 添加 txt 的 Content-Type 映射 2026-07-20 09:36:00 +08:00
70bb63be8d feat: 添加 robots.txt(屏蔽爬虫索引 API/支付) 2026-07-20 09:33:36 +08:00
9fc5c2429e feat: daily-report.sh 每日健康报告邮件 + logwatch 替换 2026-07-20 09:28:49 +08:00
1a359f8d84 fix: 日志输出从文件改为 stdout(journald 可见),紧凑格式,去掉 tracing-appender 2026-07-20 09:24:12 +08:00
d9c9d1dca0 fix: test_nginx_proxy_temp 改用 remote() 执行 2026-07-19 09:58:57 +08:00
941a8e5350 fix: insert_weather_data 配额查询使用已重命名的 membership_expires_at 列 2026-07-19 09:52:35 +08:00
31 changed files with 1636 additions and 363 deletions

19
.env
View File

@@ -1,19 +0,0 @@
# .env
DATABASE_URL=postgres://milkydata:44n6FdB8CdDAk5rk@154.37.213.24:5432/milkydata
WECHAT_APPID="wx5b00eb90621802f7"
WECHAT_SECRET="494efc513faa310bfba588bda2849bfd"
JWT_SECRET="your_super_secret_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
SSH_SERVER=root@1panel-server
# 支付宝生产环境配置
# 支付宝生产环境配置
ALIPAY_APP_ID=2021006148681106
ALIPAY_PRIVATE_KEY="MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCImX4Zj18whXYi6l29caq+ShjqT7JwWDbOoz/hEqZqPxQNu6KF59OswcUIv3It4CxJ0/kXsiC/pc6mrN9GedOAPIaz6YydgII/nwHl4OdPPxW8jeomBuiSmcbPcZ4AJTi1YTxrj//8YDIQInVc5/I4HPg+osN4qkqy+xrJ4vGrmyy+ClwHh3OxglGDLhbprH4WCgxKjwTDfR1A84w+pbrST1YaWI7sqqPhVxuYQQPjaLOpXMgrJu5ctnlFVTC27mY2wSIrxajB/6w1SQ+Z+Z149frk47PBOwL+xfKT4gwy3yj/t7WZStzH0gEhG7mXjc6qIjZ8uH5OxUZ+f6gMDpKBAgMBAAECggEAYnI87y7k65NEEOXXqTDCXWIJktTdRRpOZZ5B63QFe4z6ZutTti3d4CPGXQecwnNNd+fNHcbC01wsEvOD9sbWJxV3/zHSpbB4LmHqfEIacoKEp9+fYMiZ1Q3+uoKn3HL268GoSdz+IGUzr20mKE8Bwm8eegXT2qLgMx3FsLpecc3WXmwgMDI/alknO81+xZenLH1Y6+XdsoPVWsS3OY3gnzfYz40n1LaLCQiklBu6AUjSQupSUJ53rGtYpZnp+ImYwBVcYeFL0mZZVId/lzDI2K8eaM/RGVZYsU1vkP6CeSJzWhpr6ftSRRl3VJQou0ytLLX/YeUoXIhsfZ+x3cPKAQKBgQC9ub1z+T/+0lHZwm360otNHQNeEq3FYjnx1x7WOWTXCsAqVZizfmpUYSHoNZeNlyLpbXVsYp22xJmLBArB9LBMvjY1z2iOQJW7F46O6cY38AcgWC41YalLcrk2y4jny6rJZRMjvmDOM67SH01pzZQ6eGJpzSZpTj2Jm/tBsH7gGwKBgQC4UPK5a32ov9YuyzfQXcW341JFlTK2k2D1ewilYSZifpTDqdVpH1gSzGpfoXI7K6Z26xkx1n5rOIe+SxOKaNBj/5PJuEzmMVvUWkFU0IutoqVc2R6h4Rb+9q7llzAavFZ5Ssj1u9Q5Pjhc5Jr8zM6xXk55/4CuqYuBMCcte4fZkwKBgF/kVSEcxX+iBcSuRLAVYlrjYyRVBIdTEj5toCRedd1Kjb3X7YorRsrHHUAkdikWTor0y/iwD06lJ4Xf0SAe8EyZhwTDg/ECK6uYSHFrLshvBOsQJqrMZajAn2RWkye1BOktsPLL8lnGPYHuWE41ncm/tt4K1nM7HFdFA0U8U6QpAoGAe0gRgboG+6cMoJHmjCUciUmwDsCjggEDuk4H024umXSnFlRzXPPNGg3GHJ7E5BTnK+VGHdKlDhxqlvpqYIqI0EnzVNpD/TgIfvm7dvpiYspGJttGpOoEUpc3FnPQhiFxCYHe9pYidgNK+b8id3KZ0YMNpIYkUS8wE0HdAov8qqcCgYEAtRjXzj4JZ5we6aOqv4bcr7xLw4a7wzS99K5EcrbYRxvibUhelooQdgdY40FmdtfuY/4rpHC3R9/13aqcD/WSTFOdOsZAcfTi738y13h3dCy3FZisLYM7rgjUSnQnEGlyR0TYE4Yda490rpYJBjyQIE3EcIlWPerm8vh1wx1Rh90="
ALIPAY_ALIPAY_PUBLIC_KEY="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkhLqABUN1E2PCmOznQp+53T3x4j27I96hdatl6lWbCBPwyXnZVW4uYZ4kV+34+i2wCzEtMpUiNQaIsQXx2qD8x0d8szsj7pEZuY+xUUJTzm95fYO2nq4aaQ38T/kxdYrW12is8vwY83rlghdlkvVP2XZCuYoFq9UVieTu6LTDk6v91Mt2xr1Zw1Oj+yg8VpBFXkcS7ORJjiufWzCEoxzlb2KK2AGOLhdc8BJsBfrQ0JAqFB6b0uteUF21DDdPN/sblD8GoRAGuSpDhyQLG+0fDNdcIMyHpsq7bIDNjgYGS8RbKAtb6WyV6vriIemZ0fMvzN5Mpqv2jwCe/31ar//PwIDAQAB"
ALIPAY_GATEWAY="https://openapi.alipay.com/gateway.do"
APP_BASE_URL="https://dev.xmclassmate.top"

22
Cargo.lock generated
View File

@@ -544,15 +544,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -2358,7 +2349,6 @@ dependencies = [
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
] ]
@@ -3304,18 +3294,6 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.30" version = "0.1.30"

View File

@@ -9,7 +9,6 @@ chrono = {version = "0.4.41", features=["serde"]}
dotenvy = "0.15.7" dotenvy = "0.15.7"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
sentry = "0.48" sentry = "0.48"
sentry-actix = "0.48" sentry-actix = "0.48"
sentry-tracing = "0.48" sentry-tracing = "0.48"

View File

@@ -280,18 +280,20 @@ DROP TABLE IF EXISTS invitation_codes;
- 完成时间2026-04-15 - 完成时间2026-04-15
### 1.2 数据库操作优化 ### 1.2 数据库操作优化
- [ ] **重复查询问题** - [x] **重复查询问题**
- 位置:`insert_weather_data` 中多次查询用户信息 - 位置:`insert_weather_data` 中多次查询用户信息
- 改进:合并查询或使用缓存 - 改进:合并查询或使用缓存
- 影响:减少数据库负载 - 状态:`get_user_quota` 已合并为单次查询(见 `db.rs` 顶部注释2026-08-11 复核无遗留 N+1
- [ ] **缺少数据库索引** - [x] **缺少数据库索引**
- 现状:未见索引定义 - 状态:`migrations/006_add_performance_indexes.sql` 已添加
- 改进:添加常用查询字段索引
```sql ```sql
CREATE INDEX idx_weather_data_user_id ON weather_data(user_id); 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_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_users_openid ON users(openid);
CREATE INDEX idx_payment_orders_status ON payment_orders(status);
``` ```
### 1.3 错误处理改进 ### 1.3 错误处理改进
@@ -422,13 +424,13 @@ DROP TABLE IF EXISTS invitation_codes;
## 五、开发体验改进 ## 五、开发体验改进
### 5.1 测试覆盖 ### 5.1 测试覆盖
- [ ] **仅有一个基础编译测试** - [~] **基础测试不足**2026-08-11 已补充单元测试)
- 现状:`tests/integration_test.rs` 只有 `assert_eq!(2 + 2, 4)` - 现状:原仅有 `tests/integration_test.rs` 的编译测试和套餐一致性测试
- 改进:添加完整的测试套件 - 已补充(`cargo test` 共 36 用例):
- 类型: - `src/auth.rs`JWT 生成/验证回环、篡改拒绝、错误密钥拒绝、refresh token 随机性、临时 token 有效期
- 单元测试:工具函数、数据结构 - `src/handlers/payment.rs`套餐定义、URL 解码(**修复了 UTF-8 乱码与吞字符两个真实 bug**、支付宝表单解析UTF-8/GBK、RSA2 签名/验签回环PEM + Base64 DER 密钥)、移动端 UA 检测、订单号唯一性
- 集成测试API 端点 - `src/rate_limiter.rs`限流阈值、按客户端隔离、IP 提取(已有)
- 数据库测试CRUD 操作 - 待做API 集成测试(需要测试数据库)
- [ ] **缺少 API 测试** - [ ] **缺少 API 测试**
- 需求:验证 API 行为 - 需求:验证 API 行为
@@ -1369,6 +1371,9 @@ async onRedeemCode() {
## 十四、公告系统 ## 十四、公告系统
> **状态**:✅ 已实现2026-05`handlers/notifications.rs` + 迁移 `011_add_notifications.sql`,前端 `pkg-extra/notifications` 页面)
> 下方为原始设计文档,保留供参考。
### 功能概述 ### 功能概述
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。 公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。

View File

@@ -2,8 +2,6 @@
# =========================================== # ===========================================
# Rust Backend Deployment Script # Rust Backend Deployment Script
# =========================================== # ===========================================
# Rust Backend Deployment Script
# ===========================================
# Usage: ./deploy.sh [development|production] [options] # Usage: ./deploy.sh [development|production] [options]
# #
# Options: # Options:
@@ -185,8 +183,8 @@ upload_script "清理脚本" scripts/cleanup_refresh_tokens.sh scripts/cleanup_r
upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh
deploy_service_file deploy_service_file
run_migrations # Moved BEFORE restart
restart_service restart_service
run_migrations
if run_tests; then if run_tests; then
log_to_file "DEPLOY success" log_to_file "DEPLOY success"

View File

@@ -21,13 +21,9 @@ Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo
Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem
Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem
Environment=RUST_LOG=info Environment=RUST_LOG=info
Environment=PAYMENT_MAINTENANCE_MODE=true
Environment=APP_VERSION=0.3.0 Environment=APP_VERSION=0.3.0
Environment=FREE_USER_DATA_LIMIT=20 Environment=FREE_USER_DATA_LIMIT=20
Environment=ALIPAY_APP_ID=2021006148681106 EnvironmentFile=/root/rust/rust_backend_blue/.env
Environment=ALIPAY_ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkhLqABUN1E2PCmOznQp+53T3x4j27I96hdatl6lWbCBPwyXnZVW4uYZ4kV+34+i2wCzEtMpUiNQaIsQXx2qD8x0d8szsj7pEZuY+xUUJTzm95fYO2nq4aaQ38T/kxdYrW12is8vwY83rlghdlkvVP2XZCuYoFq9UVieTu6LTDk6v91Mt2xr1Zw1Oj+yg8VpBFXkcS7ORJjiufWzCEoxzlb2KK2AGOLhdc8BJsBfrQ0JAqFB6b0uteUF21DDdPN/sblD8GoRAGuSpDhyQLG+0fDNdcIMyHpsq7bIDNjgYGS8RbKAtb6WyV6vriIemZ0fMvzN5Mpqv2jwCe/31ar//PwIDAQAB
Environment=ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCImX4Zj18whXYi6l29caq+ShjqT7JwWDbOoz/hEqZqPxQNu6KF59OswcUIv3It4CxJ0/kXsiC/pc6mrN9GedOAPIaz6YydgII/nwHl4OdPPxW8jeomBuiSmcbPcZ4AJTi1YTxrj//8YDIQInVc5/I4HPg+osN4qkqy+xrJ4vGrmyy+ClwHh3OxglGDLhbprH4WCgxKjwTDfR1A84w+pbrST1YaWI7sqqPhVxuYQQPjaLOpXMgrJu5ctnlFVTC27mY2wSIrxajB/6w1SQ+Z+Z149frk47PBOwL+xfKT4gwy3yj/t7WZStzH0gEhG7mXjc6qIjZ8uH5OxUZ+f6gMDpKBAgMBAAECggEAYnI87y7k65NEEOXXqTDCXWIJktTdRRpOZZ5B63QFe4z6ZutTti3d4CPGXQecwnNNd+fNHcbC01wsEvOD9sbWJxV3/zHSpbB4LmHqfEIacoKEp9+fYMiZ1Q3+uoKn3HL268GoSdz+IGUzr20mKE8Bwm8eegXT2qLgMx3FsLpecc3WXmwgMDI/alknO81+xZenLH1Y6+XdsoPVWsS3OY3gnzfYz40n1LaLCQiklBu6AUjSQupSUJ53rGtYpZnp+ImYwBVcYeFL0mZZVId/lzDI2K8eaM/RGVZYsU1vkP6CeSJzWhpr6ftSRRl3VJQou0ytLLX/YeUoXIhsfZ+x3cPKAQKBgQC9ub1z+T/+0lHZwm360otNHQNeEq3FYjnx1x7WOWTXCsAqVZizfmpUYSHoNZeNlyLpbXVsYp22xJmLBArB9LBMvjY1z2iOQJW7F46O6cY38AcgWC41YalLcrk2y4jny6rJZRMjvmDOM67SH01pzZQ6eGJpzSZpTj2Jm/tBsH7gGwKBgQC4UPK5a32ov9YuyzfQXcW341JFlTK2k2D1ewilYSZifpTDqdVpH1gSzGpfoXI7K6Z26xkx1n5rOIe+SxOKaNBj/5PJuEzmMVvUWkFU0IutoqVc2R6h4Rb+9q7llzAavFZ5Ssj1u9Q5Pjhc5Jr8zM6xXk55/4CuqYuBMCcte4fZkwKBgF/kVSEcxX+iBcSuRLAVYlrjYyRVBIdTEj5toCRedd1Kjb3X7YorRsrHHUAkdikWTor0y/iwD06lJ4Xf0SAe8EyZhwTDg/ECK6uYSHFrLshvBOsQJqrMZajAn2RWkye1BOktsPLL8lnGPYHuWE41ncm/tt4K1nM7HFdFA0U8U6QpAoGAe0gRgboG+6cMoJHmjCUciUmwDsCjggEDuk4H024umXSnFlRzXPPNGg3GHJ7E5BTnK+VGHdKlDhxqlvpqYIqI0EnzVNpD/TgIfvm7dvpiYspGJttGpOoEUpc3FnPQhiFxCYHe9pYidgNK+b8id3KZ0YMNpIYkUS8wE0HdAov8qqcCgYEAtRjXzj4JZ5we6aOqv4bcr7xLw4a7wzS99K5EcrbYRxvibUhelooQdgdY40FmdtfuY/4rpHC3R9/13aqcD/WSTFOdOsZAcfTi738y13h3dCy3FZisLYM7rgjUSnQnEGlyR0TYE4Yda490rpYJBjyQIE3EcIlWPerm8vh1wx1Rh90=
Environment=ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
Environment=APP_BASE_URL=https://xmclassmate.top Environment=APP_BASE_URL=https://xmclassmate.top
Environment=SERVER_PORT=4433 Environment=SERVER_PORT=4433
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1 Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1

View File

@@ -24,13 +24,8 @@ Environment=RUST_LOG=info,rust_backend=debug
Environment=APP_VERSION=0.2.0 Environment=APP_VERSION=0.2.0
Environment=FREE_USER_DATA_LIMIT=50 Environment=FREE_USER_DATA_LIMIT=50
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1 Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1
Environment=APP_BASE_URL=https://xmclassmate.top Environment=APP_BASE_URL=https://dev.xmclassmate.top
Environment=ALIPAY_APP_ID=2021006148681106 EnvironmentFile=/root/rust/rust_backend_dev/.env
Environment=ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
Environment=ALIPAY_ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkhLqABUN1E2PCmOznQp+53T3x4j27I96hdatl6lWbCBPwyXnZVW4uYZ4kV+34+i2wCzEtMpUiNQaIsQXx2qD8x0d8szsj7pEZuY+xUUJTzm95fYO2nq4aaQ38T/kxdYrW12is8vwY83rlghdlkvVP2XZCuYoFq9UVieTu6LTDk6v91Mt2xr1Zw1Oj+yg8VpBFXkcS7ORJjiufWzCEoxzlb2KK2AGOLhdc8BJsBfrQ0JAqFB6b0uteUF21DDdPN/sblD8GoRAGuSpDhyQLG+0fDNdcIMyHpsq7bIDNjgYGS8RbKAtb6WyV6vriIemZ0fMvzN5Mpqv2jwCe/31ar//PwIDAQAB
Environment=MOCK_LOGIN_ENABLED=true
Environment=MOCK_LOGIN_KEY=d3v-t35t-k3y-2026
Environment=ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCImX4Zj18whXYi6l29caq+ShjqT7JwWDbOoz/hEqZqPxQNu6KF59OswcUIv3It4CxJ0/kXsiC/pc6mrN9GedOAPIaz6YydgII/nwHl4OdPPxW8jeomBuiSmcbPcZ4AJTi1YTxrj//8YDIQInVc5/I4HPg+osN4qkqy+xrJ4vGrmyy+ClwHh3OxglGDLhbprH4WCgxKjwTDfR1A84w+pbrST1YaWI7sqqPhVxuYQQPjaLOpXMgrJu5ctnlFVTC27mY2wSIrxajB/6w1SQ+Z+Z149frk47PBOwL+xfKT4gwy3yj/t7WZStzH0gEhG7mXjc6qIjZ8uH5OxUZ+f6gMDpKBAgMBAAECggEAYnI87y7k65NEEOXXqTDCXWIJktTdRRpOZZ5B63QFe4z6ZutTti3d4CPGXQecwnNNd+fNHcbC01wsEvOD9sbWJxV3/zHSpbB4LmHqfEIacoKEp9+fYMiZ1Q3+uoKn3HL268GoSdz+IGUzr20mKE8Bwm8eegXT2qLgMx3FsLpecc3WXmwgMDI/alknO81+xZenLH1Y6+XdsoPVWsS3OY3gnzfYz40n1LaLCQiklBu6AUjSQupSUJ53rGtYpZnp+ImYwBVcYeFL0mZZVId/lzDI2K8eaM/RGVZYsU1vkP6CeSJzWhpr6ftSRRl3VJQou0ytLLX/YeUoXIhsfZ+x3cPKAQKBgQC9ub1z+T/+0lHZwm360otNHQNeEq3FYjnx1x7WOWTXCsAqVZizfmpUYSHoNZeNlyLpbXVsYp22xJmLBArB9LBMvjY1z2iOQJW7F46O6cY38AcgWC41YalLcrk2y4jny6rJZRMjvmDOM67SH01pzZQ6eGJpzSZpTj2Jm/tBsH7gGwKBgQC4UPK5a32ov9YuyzfQXcW341JFlTK2k2D1ewilYSZifpTDqdVpH1gSzGpfoXI7K6Z26xkx1n5rOIe+SxOKaNBj/5PJuEzmMVvUWkFU0IutoqVc2R6h4Rb+9q7llzAavFZ5Ssj1u9Q5Pjhc5Jr8zM6xXk55/4CuqYuBMCcte4fZkwKBgF/kVSEcxX+iBcSuRLAVYlrjYyRVBIdTEj5toCRedd1Kjb3X7YorRsrHHUAkdikWTor0y/iwD06lJ4Xf0SAe8EyZhwTDg/ECK6uYSHFrLshvBOsQJqrMZajAn2RWkye1BOktsPLL8lnGPYHuWE41ncm/tt4K1nM7HFdFA0U8U6QpAoGAe0gRgboG+6cMoJHmjCUciUmwDsCjggEDuk4H024umXSnFlRzXPPNGg3GHJ7E5BTnK+VGHdKlDhxqlvpqYIqI0EnzVNpD/TgIfvm7dvpiYspGJttGpOoEUpc3FnPQhiFxCYHe9pYidgNK+b8id3KZ0YMNpIYkUS8wE0HdAov8qqcCgYEAtRjXzj4JZ5we6aOqv4bcr7xLw4a7wzS99K5EcrbYRxvibUhelooQdgdY40FmdtfuY/4rpHC3R9/13aqcD/WSTFOdOsZAcfTi738y13h3dCy3FZisLYM7rgjUSnQnEGlyR0TYE4Yda490rpYJBjyQIE3EcIlWPerm8vh1wx1Rh90=
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes

View File

@@ -21,13 +21,9 @@ Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo
Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem
Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem
Environment=RUST_LOG=info Environment=RUST_LOG=info
Environment=PAYMENT_MAINTENANCE_MODE=true
Environment=APP_VERSION=0.3.0 Environment=APP_VERSION=0.3.0
Environment=FREE_USER_DATA_LIMIT=20 Environment=FREE_USER_DATA_LIMIT=20
Environment=ALIPAY_APP_ID=2021006148681106 EnvironmentFile=/root/rust/rust_backend_green/.env
Environment=ALIPAY_ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkhLqABUN1E2PCmOznQp+53T3x4j27I96hdatl6lWbCBPwyXnZVW4uYZ4kV+34+i2wCzEtMpUiNQaIsQXx2qD8x0d8szsj7pEZuY+xUUJTzm95fYO2nq4aaQ38T/kxdYrW12is8vwY83rlghdlkvVP2XZCuYoFq9UVieTu6LTDk6v91Mt2xr1Zw1Oj+yg8VpBFXkcS7ORJjiufWzCEoxzlb2KK2AGOLhdc8BJsBfrQ0JAqFB6b0uteUF21DDdPN/sblD8GoRAGuSpDhyQLG+0fDNdcIMyHpsq7bIDNjgYGS8RbKAtb6WyV6vriIemZ0fMvzN5Mpqv2jwCe/31ar//PwIDAQAB
Environment=ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCImX4Zj18whXYi6l29caq+ShjqT7JwWDbOoz/hEqZqPxQNu6KF59OswcUIv3It4CxJ0/kXsiC/pc6mrN9GedOAPIaz6YydgII/nwHl4OdPPxW8jeomBuiSmcbPcZ4AJTi1YTxrj//8YDIQInVc5/I4HPg+osN4qkqy+xrJ4vGrmyy+ClwHh3OxglGDLhbprH4WCgxKjwTDfR1A84w+pbrST1YaWI7sqqPhVxuYQQPjaLOpXMgrJu5ctnlFVTC27mY2wSIrxajB/6w1SQ+Z+Z149frk47PBOwL+xfKT4gwy3yj/t7WZStzH0gEhG7mXjc6qIjZ8uH5OxUZ+f6gMDpKBAgMBAAECggEAYnI87y7k65NEEOXXqTDCXWIJktTdRRpOZZ5B63QFe4z6ZutTti3d4CPGXQecwnNNd+fNHcbC01wsEvOD9sbWJxV3/zHSpbB4LmHqfEIacoKEp9+fYMiZ1Q3+uoKn3HL268GoSdz+IGUzr20mKE8Bwm8eegXT2qLgMx3FsLpecc3WXmwgMDI/alknO81+xZenLH1Y6+XdsoPVWsS3OY3gnzfYz40n1LaLCQiklBu6AUjSQupSUJ53rGtYpZnp+ImYwBVcYeFL0mZZVId/lzDI2K8eaM/RGVZYsU1vkP6CeSJzWhpr6ftSRRl3VJQou0ytLLX/YeUoXIhsfZ+x3cPKAQKBgQC9ub1z+T/+0lHZwm360otNHQNeEq3FYjnx1x7WOWTXCsAqVZizfmpUYSHoNZeNlyLpbXVsYp22xJmLBArB9LBMvjY1z2iOQJW7F46O6cY38AcgWC41YalLcrk2y4jny6rJZRMjvmDOM67SH01pzZQ6eGJpzSZpTj2Jm/tBsH7gGwKBgQC4UPK5a32ov9YuyzfQXcW341JFlTK2k2D1ewilYSZifpTDqdVpH1gSzGpfoXI7K6Z26xkx1n5rOIe+SxOKaNBj/5PJuEzmMVvUWkFU0IutoqVc2R6h4Rb+9q7llzAavFZ5Ssj1u9Q5Pjhc5Jr8zM6xXk55/4CuqYuBMCcte4fZkwKBgF/kVSEcxX+iBcSuRLAVYlrjYyRVBIdTEj5toCRedd1Kjb3X7YorRsrHHUAkdikWTor0y/iwD06lJ4Xf0SAe8EyZhwTDg/ECK6uYSHFrLshvBOsQJqrMZajAn2RWkye1BOktsPLL8lnGPYHuWE41ncm/tt4K1nM7HFdFA0U8U6QpAoGAe0gRgboG+6cMoJHmjCUciUmwDsCjggEDuk4H024umXSnFlRzXPPNGg3GHJ7E5BTnK+VGHdKlDhxqlvpqYIqI0EnzVNpD/TgIfvm7dvpiYspGJttGpOoEUpc3FnPQhiFxCYHe9pYidgNK+b8id3KZ0YMNpIYkUS8wE0HdAov8qqcCgYEAtRjXzj4JZ5we6aOqv4bcr7xLw4a7wzS99K5EcrbYRxvibUhelooQdgdY40FmdtfuY/4rpHC3R9/13aqcD/WSTFOdOsZAcfTi738y13h3dCy3FZisLYM7rgjUSnQnEGlyR0TYE4Yda490rpYJBjyQIE3EcIlWPerm8vh1wx1Rh90=
Environment=ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
Environment=APP_BASE_URL=https://xmclassmate.top Environment=APP_BASE_URL=https://xmclassmate.top
Environment=SERVER_PORT=4434 Environment=SERVER_PORT=4434
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1 Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1

View File

@@ -25,10 +25,7 @@ Environment=RUST_LOG=info
Environment=APP_VERSION=0.3.0 Environment=APP_VERSION=0.3.0
Environment=FREE_USER_DATA_LIMIT=20 Environment=FREE_USER_DATA_LIMIT=20
Environment=APP_BASE_URL=https://xmclassmate.top Environment=APP_BASE_URL=https://xmclassmate.top
Environment=ALIPAY_APP_ID=your_alipay_app_id EnvironmentFile=/root/rust/rust_backend/.env
Environment=ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
Environment=ALIPAY_ALIPAY_PUBLIC_KEY=your_alipay_public_key
Environment=ALIPAY_PRIVATE_KEY=your_private_key_base64
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes

84
docs/OPS-ALERT.md Normal file
View File

@@ -0,0 +1,84 @@
# 服务器邮件告警与每日报告 — 部署指南
<!-- 更新时间2026-08-21 -->
## 概述
两封自动邮件,均通过 `msmtp` → 163 SMTP 发送(复用现有 `~/.msmtprc` 配置):
| 脚本 | 频率cron | 作用 |
| --- | --- | --- |
| `daily-report.sh` | 每日 08:00 | 服务器健康报告HTML系统/容器/SSL/服务/安全/备份 + **支付与会员统计** |
| `alert.sh` | 每 5 分钟 | **实时异常告警**:正常时静默,异常才发邮件(带防刷屏状态机) |
## 部署步骤
```bash
# 1. 上传三个文件到服务器root 用户)
scp scripts/mail-lib.sh scripts/daily-report.sh scripts/alert.sh root@<服务器>:/root/rust/scripts/
ssh root@<服务器> 'chmod +x /root/rust/scripts/*.sh && mkdir -p /root/rust/scripts/logs'
# 2. 配置 cronroot
ssh root@<服务器> 'crontab -e'
# 加入以下两行:
0 8 * * * /root/rust/scripts/daily-report.sh >> /root/rust/scripts/logs/daily-report.log 2>&1
*/5 * * * * /root/rust/scripts/alert.sh >> /root/rust/scripts/logs/alert.log 2>&1
```
## 验证
```bash
# 干跑告警(预览当前异常与将发送的邮件,不真正发送)
/root/rust/scripts/alert.sh --dry-run
# 手动发一封健康报告测试 msmtp 链路
/root/rust/scripts/daily-report.sh
# 查看邮件日志
tail -50 /root/rust/scripts/logs/mail.log
```
## alert.sh 检测项与阈值
| 检测项 | 警告 | 严重 |
| --- | --- | --- |
| 磁盘使用(/ | > 80% | > 90% |
| 内存使用 | > 80% | > 90% |
| 备份时效 | > 24h | > 48h或无备份 |
| 服务状态green/blue/nginx | — | 非 active |
| Docker 容器 | — | 存在非 Up 容器 / docker 不可用 |
| SSL 证书剩余 | < 30 天 | < 7 天 |
| 支付卡单pending > 30 分钟) | ≥ 1 笔(附订单号) | — |
| 数据库SELECT 1 | — | 不可用 |
### 防刷屏机制
状态文件 `.alert-state`(默认 `/root/rust/scripts/.alert-state`)记录每个检测项最近告警级别:
- 级别**上升** → 立即发邮件
- 级别**不变** → 冷却 `ALERT_COOLDOWN_HOURS`(默认 6 小时)后才再次提醒
- **恢复**正常 → 静默清除状态,不发邮件
## 可配置项(环境变量)
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `MAIL_TO` | `m943790568@163.com` | 收件人,逗号分隔多地址 |
| `MAIL_FROM` | `ASD 监控 <m943790568@163.com>` | 发件人显示名 |
| `MAIL_ACCOUNT` | `default` | msmtp 账户名 |
| `MAIL_LOG_DIR` | `/root/rust/scripts/logs` | 日志目录mail.log / alert.log |
| `MAIL_RETRY` | `3` | 发送失败重试次数 |
| `DB_CONTAINER` | `1Panel-postgresql-FtMo` | PostgreSQL 容器名1Panel 安装) |
| `BACKUP_DIR` | `/root/rust/backups/milkydata` | 备份目录 |
| `STUCK_ORDER_MINUTES` | `30` | pending 订单视为卡单的时长 |
| `ALERT_STATE_FILE` | `/root/rust/scripts/.alert-state` | 状态文件路径 |
| `ALERT_COOLDOWN_HOURS` | `6` | 同级别再次提醒的冷却时间 |
## 注意事项
- 脚本在**服务器本机**运行cron 以 root 执行),数据库通过 `docker exec <容器> psql` 访问,
容器名与 `check-members.sh` / `send-notification.sh` 保持一致;如容器名不同,设置 `DB_CONTAINER` 覆盖。
- 邮件为 HTML + UTF-8无需额外编码配置msmtp 发送失败自动重试 3 次并记录日志。
- 单次采集失败(如 journalctl 无权限)不会阻断整封邮件,对应项显示 N/A。
- `alert.sh` 的卡单检测可直接发现「支付卡在确认页面」类问题pending 超 30 分钟即告警,
邮件中附订单号与创建时间,便于对照支付宝账单处理)。

View File

@@ -2,12 +2,12 @@
## 新旧对比 ## 新旧对比
| 项目 | 旧服务器 (1panel-server) | 新服务器 (47.109.203.92) | | 项目 | 旧服务器 (1panel-server) | 新服务器 (xxx.xxx.xxx.xxx) |
|------|------------------------|-------------------------| |------|------------------------|---------------------------|
| 系统 | - | Debian 13.5 | | 系统 | - | 待填写 |
| CPU | - | 2 核 | | CPU | - | 待填写 |
| 内存 | - | 1.6 GiB | | 内存 | - | 待填写 |
| 磁盘 | - | 40G (可用 36G) | | 磁盘 | - | 待填写 |
| 区域 | - | 成都 | | 区域 | - | 成都 |
| 角色 | 当前生产+开发 | 目标生产+开发 | | 角色 | 当前生产+开发 | 目标生产+开发 |
@@ -36,64 +36,147 @@ pg_dump -Fc milkydata > /tmp/milkydata.dump
pg_restore -d milkydata /tmp/milkydata.dump pg_restore -d milkydata /tmp/milkydata.dump
``` ```
### 1.4 启动 OpenResty ### 1.4 安装并配置 nginx
```bash ```bash
docker run -d --name openresty \ apt install -y nginx
--network host \
-v /www/sites:/www/sites \
1panel/openresty:1.21.4.3-3-3-focal
``` ```
复制 nginx 配置文件和 SSL 证书。
### 1.5 复制 SSL 证书 + nginx 配置 ### 1.5 复制 SSL 证书 + nginx 配置
```bash ```bash
rsync -av root@1panel-server:/www/sites/ /www/sites/ rsync -av root@old-server:/etc/nginx/sites-available/ /etc/nginx/sites-available/
rsync -av root@old-server:/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/
rsync -av root@old-server:/etc/ssl/ /etc/ssl/
``` ```
## 阶段 2 — 后端部署 ## 阶段 2 — 后端部署
### 2.1 创建 systemd 服务blue/green/dev ### 2.1 创建 systemd 服务blue/green/dev
```bash ```bash
# 创建目录
mkdir -p /root/rust/rust_backend_blue mkdir -p /root/rust/rust_backend_blue
mkdir -p /root/rust/rust_backend_green mkdir -p /root/rust/rust_backend_green
mkdir -p /root/rust/rust_backend_dev mkdir -p /root/rust/rust_backend_dev
# 注册 systemd service从 deploy/ 目录上传)
``` ```
### 2.2 部署 Rust 后端 ### 2.2 部署 Rust 后端
```bash ```bash
# 先部署到 dev 验证
cd ASD-backend/rust-backend cd ASD-backend/rust-backend
deploy.sh development --remote-host 47.109.203.92 deploy.sh development --remote-host xxx.xxx.xxx.xxx
deploy.sh production --remote-host xxx.xxx.xxx.xxx
# 再部署 blue/green
deploy.sh production --remote-host 47.109.203.92
``` ```
### 2.3 更新 deploy.sh ### 2.3 更新 deploy.sh
将默认 REMOTE_HOST 改为新服务器 IP 将默认 REMOTE_HOST 改为新服务器 IP
## 阶段 3 — 切换 ## 阶段 3 — ⚠️ 关键配置(迁移后必做)
### 3.1 设置 APP_BASE_URL
每个环境的 `.env` 文件必须设置 `APP_BASE_URL`,否则默认值为 `https://dev.xmclassmate.top`,导致支付宝通知回调发错服务器。
### 3.1 内部验证
```bash ```bash
curl http://127.0.0.1:8080/health # dev # 生产环境
curl -k https://127.0.0.1:4433/health # blue echo "APP_BASE_URL=https://xmclassmate.top" >> /root/rust/rust_backend_blue/.env
curl -k https://127.0.0.1:4434/health # green echo "APP_BASE_URL=https://xmclassmate.top" >> /root/rust/rust_backend_green/.env
# 开发环境
echo "APP_BASE_URL=https://dev.xmclassmate.top" >> /root/rust/rust_backend_dev/.env
``` ```
### 3.2 更新 DNS ### 3.2 验证 notify_url
将域名指向新服务器 IP
### 3.3 验证公网访问 迁移后通过 API 检查支付表单的 notify_url 是否正确:
```bash
# 获取访客 JWT
JWT=$(curl -s -X POST 'https://YOUR_DOMAIN/api/guest-login' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 检查支付表单的 notify_url
curl -s "https://YOUR_DOMAIN/payment/page?package=monthly&jwt=${JWT}" | grep -oP 'notify_url[^"]+"[^"]+' | head -1
```
预期输出:`notify_url" value="https://YOUR_DOMAIN/payment/notify"`
### 3.3 验证支付宝密钥匹配
```bash
# 确认密钥配置正确(生产用正式密钥,开发用沙箱密钥)
ssh root@NEW_SERVER 'grep "ALIPAY_APP_ID\|ALIPAY_GATEWAY" /root/rust/rust_backend_*/env'
```
| 环境 | APP_ID 前缀 | GATEWAY |
|------|------------|---------|
| 生产 | 202100... | `openapi.alipay.com` |
| 开发 | 902100...(沙箱) | `openapi-sandbox.dl.alipaydev.com` |
### 3.4 验证关键端点
```bash
# 支付页面可访问(非 503
curl -s -o /dev/null -w "%{http_code}" https://YOUR_DOMAIN/payment
# 访客登录正常
curl -s -X POST https://YOUR_DOMAIN/api/guest-login | python3 -c "import sys,json; print(json.load(sys.stdin).get('success','FAIL'))"
# 健康检查
curl -s https://YOUR_DOMAIN/health | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','FAIL'))"
```
### 3.5 迁移后支付全链路测试
```bash
bash tests/payment_e2e_test.sh
```
### 3.6 运行部署后测试
```bash
export TEST_DOMAIN="https://YOUR_DOMAIN"
./deploy.sh production --skip-tests=false
# 或手动运行
export BASE_URL="https://YOUR_DOMAIN"
bash lib/test.sh
```
## 阶段 4 — DNS 切换
### 4.1 更新 DNS 记录
将域名指向新服务器 IP。
### 4.2 验证公网访问
```bash ```bash
curl https://dev.xmclassmate.top/health curl https://dev.xmclassmate.top/health
curl https://xmclassmate.top/health curl https://xmclassmate.top/health
curl https://xmclassmate.top/payment
``` ```
## 阶段 4 — 清理(可选) ## 阶段 5 — 清理
- 迁移 GlitchTip (Sentry) - 迁移 GlitchTip (Sentry)
- 旧服务器保留 1 周作为回退 - 旧服务器保留 1 周作为回退
- 确认无问题后关闭旧服务器 - 确认无问题后关闭旧服务器
## 迁移检查清单
| # | 检查项 | 命令/方法 | 状态 |
|---|--------|----------|------|
| 1 | PostgreSQL 运行 | `docker ps \| grep postgres` | ⬜ |
| 2 | 数据库导入 | `pg_restore -d milkydata /tmp/xxx.dump` | ⬜ |
| 3 | nginx 配置正确 | `nginx -t` | ⬜ |
| 4 | SSL 证书有效 | `openssl s_client -connect DOMAIN:443` | ⬜ |
| 5 | 后端服务运行 | `systemctl status rust-backend-*` | ⬜ |
| 6 | APP_BASE_URL 已设置 | `grep APP_BASE_URL /root/rust/rust_backend_*/.env` | ⬜ |
| 7 | 支付宝密钥环境正确 | `grep ALIPAY_APP_ID /root/rust/rust_backend_*/.env` | ⬜ |
| 8 | notify_url 正确 | 见 3.2 验证 | ⬜ |
| 9 | 支付页面可访问 | `curl DOMAIN/payment` | ⬜ |
| 10 | 访客登录正常 | `curl -X POST DOMAIN/api/guest-login` | ⬜ |
| 11 | 全链路测试通过 | `bash tests/payment_e2e_test.sh` | ⬜ |
| 12 | 部署后测试通过 | `deploy.sh production` | ⬜ |
## 历史问题复盘
| 日期 | 问题 | 根因 | 预防 |
|------|------|------|------|
| 2026-07-23 | 支付回调丢失 | `APP_BASE_URL` 未设置,默认指向 `dev.xmclassmate.top`,开发服务器无法通过 RSA2 验证 | 迁移清单第 6、8 项确保 base_url 配置正确 |
| 2026-07-18 | 保存功能 500 | nginx AppArmor 缺少 `/var/lib/nginx/** rw` | 部署后测试 `test_nginx_proxy_temp` |
| 2026-07-13 | ICP 备案号未显示 | nginx 配置路径不匹配(`/www/sites/` vs `/etc/nginx/sites-enabled/` | 迁移清单第 3 项检查 nginx 配置 |

View File

@@ -90,7 +90,7 @@ check_service_file() {
local missing=0 ph=0 local missing=0 ph=0
local required_vars=("DATABASE_URL" "JWT_SECRET" "WECHAT_APPID" "WECHAT_SECRET") local required_vars=("DATABASE_URL" "JWT_SECRET" "WECHAT_APPID" "WECHAT_SECRET")
[ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH" "ALIPAY_APP_ID") [ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH")
for var in "${required_vars[@]}"; do for var in "${required_vars[@]}"; do
local val; val=$(echo "$content" | grep -E "^Environment=${var}=" | head -1 | sed "s/^Environment=${var}=//") local val; val=$(echo "$content" | grep -E "^Environment=${var}=" | head -1 | sed "s/^Environment=${var}=//")
@@ -155,7 +155,8 @@ deploy_service_file() {
local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0") local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0")
if [ "$has_mock" = "0" ]; then if [ "$has_mock" = "0" ]; then
local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)") local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)")
remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${mock_key}' /etc/systemd/system/${SERVICE_NAME}" local escaped_key; escaped_key=$(echo "$mock_key" | sed 's/[\/&]/\\&/g')
remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${escaped_key}' /etc/systemd/system/${SERVICE_NAME}"
log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}" log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}"
fi fi
fi fi
@@ -187,8 +188,8 @@ Wants=network.target
[Service] [Service]
Type=simple Type=simple
User=root User=rust-backend
Group=root Group=rust-backend
WorkingDirectory=${REMOTE_DIR} WorkingDirectory=${REMOTE_DIR}
ExecStart=${REMOTE_DIR}/rust-backend ExecStart=${REMOTE_DIR}/rust-backend
Restart=always Restart=always
@@ -205,6 +206,8 @@ Environment=SSL_CERT_PATH=
Environment=RUST_LOG=info Environment=RUST_LOG=info
Environment=APP_VERSION=0.3.0 Environment=APP_VERSION=0.3.0
Environment=FREE_USER_DATA_LIMIT=20 Environment=FREE_USER_DATA_LIMIT=20
# 支付宝密钥存储在 .env 文件(不在 Git 中)
EnvironmentFile=${REMOTE_DIR}/.env
SERVEOF SERVEOF
chmod 600 "$(get_service_file_local)" chmod 600 "$(get_service_file_local)"
@@ -401,19 +404,15 @@ rollback_on_failure() {
view_logs() { view_logs() {
local lines="${LOG_LINES:-50}" local lines="${LOG_LINES:-50}"
log_step "查看后端日志(最后 ${lines} 行)..." log_step "查看后端日志(最后 ${lines} 行)..."
[ "$DRY_RUN" = true ] && { log_dry "tail -${lines} 日志文件"; return 0; } [ "$DRY_RUN" = true ] && { log_dry "journalctl -u ${SERVICE_NAME} -n ${lines}"; return 0; }
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") remote "journalctl -u ${SERVICE_NAME} -n ${lines} --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
echo -e "${BLUE}=== ${lf} ===${NC}"; remote "tail -${lines} '${lf}'"
} }
tail_logs() { tail_logs() {
local lines="${LOG_LINES:-100}" local lines="${LOG_LINES:-100}"
log_step "实时跟踪日志..." log_step "实时跟踪日志..."
[ "$DRY_RUN" = true ] && return 0 [ "$DRY_RUN" = true ] && return 0
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "journalctl -u ${SERVICE_NAME} -n ${lines} -f"
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "tail -f -n ${lines} '${lf}'"
} }
status() { status() {
@@ -421,8 +420,8 @@ status() {
[ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; } [ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; }
echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}" echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}"
remote "systemctl status ${SERVICE_NAME} --no-pager" remote "systemctl status ${SERVICE_NAME} --no-pager"
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") echo -e "${BLUE}=== 最近日志 ===${NC}"
[ -n "$lf" ] && { echo -e "${BLUE}=== 最近日志 ===${NC}"; remote "tail -20 '${lf}'"; } remote "journalctl -u ${SERVICE_NAME} -n 20 --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
} }
# ---------- 确认 ---------- # ---------- 确认 ----------

View File

@@ -186,16 +186,6 @@ test_migration_status() {
test_payment_flow() { test_payment_flow() {
log_info "开始支付链路测试..." log_info "开始支付链路测试..."
# 清理 trap测试失败时清理
local test_cleanup_done=false
cleanup_payment_test() {
if [ "$test_cleanup_done" = false ]; then
cleanup_test_user
test_cleanup_done=true
fi
}
trap cleanup_payment_test EXIT
# 阶段 1检查订单到期时间计算与套餐天数是否一致 # 阶段 1检查订单到期时间计算与套餐天数是否一致
# 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔 # 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔
local uuid_prefix local uuid_prefix
@@ -319,7 +309,7 @@ test_payment_dedup() {
local all_ok=true local all_ok=true
# 1. 在服务器上生成 SQL 文件(避免 stdin 管道问题) # 1. 在服务器上生成 SQL 文件(避免 stdin 管道问题)
remote "cat > ${sql_file} << 'SQLEOF' remote "cat > ${sql_file} << SQLEOF
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no1}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days'); INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no1}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days'); INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
SQLEOF SQLEOF
@@ -357,7 +347,7 @@ test_payment_maintenance() {
# 启用维护模式 # 启用维护模式
remote "sed -i '/^Environment=PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/${SERVICE_NAME} remote "sed -i '/^Environment=PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/${SERVICE_NAME}
sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/${SERVICE_NAME} sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/${SERVICE_NAME}
systemctl daemon-reload && sleep 1" 2>/dev/null || true systemctl daemon-reload && systemctl restart ${SERVICE_NAME} && sleep 3" 2>/dev/null || true
# 测试 quota 返回 unlimited # 测试 quota 返回 unlimited
local quota_resp local quota_resp
@@ -456,15 +446,13 @@ test_payment_verify_detection() {
test_nginx_proxy_temp() { test_nginx_proxy_temp() {
log_info "测试 nginx proxy tempPOST body 权限)..." log_info "测试 nginx proxy tempPOST body 权限)..."
# 发送 POST 请求验证 nginx 能正代理带 body 的请求AppArmor proxy temp 权限检查) # 发送 POST body 的请求到公开 POST 端点,验证 nginx 能正代理
local body; body=$(python3 -c "print('x'*50000)" 2>/dev/null || printf 'x%.0s' {1..50000}) # 如果 proxy temp 无权限nginx 返回 502/403 或超时
local resp local resp
resp=$(curl -sk --max-time 10 -X POST "${BASE_URL}" \ resp=$(remote "curl -sk --max-time 10 -X POST '${BASE_URL}/api/login' \
-H "Content-Type: application/json" \ -H 'Content-Type: application/json' \
-d "{\"test_body_size\":${#body}}" 2>&1) || true -d '{\"test\":\"x\"}'" 2>/dev/null || true)
# 只要能返回响应(包括错误),就说明 nginx 能正常处理 POST body
# 如果 proxy temp 无权限nginx 会返回 502/403 或超时
if [ -n "$resp" ]; then if [ -n "$resp" ]; then
log_info " nginx POST body 代理正常 ✅" log_info " nginx POST body 代理正常 ✅"
return 0 return 0
@@ -473,6 +461,123 @@ test_nginx_proxy_temp() {
return 1 return 1
} }
# ---------- 支付 notify_url 校验 ----------
test_notify_url_domain() {
log_info "校验支付表单 notify_url 域名..."
# 用 TEST_DOMAIN 构造 Host 头,模拟外部请求(而非 localhost 直连)
local test_domain="${TEST_DOMAIN#https://}"
# 获取访客 JWT通过本地后端
local jwt
jwt=$(remote "curl -sk -X POST '${BASE_URL}/api/guest-login'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
if [ -z "$jwt" ]; then
log_error "获取 JWT 失败,无法校验 notify_url"
return 1
fi
# 用正确的 Host 头请求支付表单(模拟外部用户访问)
# 后端此时通过 Host 头构建 notify_url应等于 TEST_DOMAIN
local notify_url
notify_url=$(remote "curl -sk '${BASE_URL}/payment/page?package=monthly&jwt=${jwt}' -H 'Host: ${test_domain}'" 2>/dev/null | grep -oP 'notify_url" value="\K[^"]+' | head -1)
if [ -z "$notify_url" ]; then
log_error "无法提取 notify_url可能为 Mock 支付模式)"
return 0 # Mock 模式下不校验
fi
# 检查 notify_url 是否以 TEST_DOMAIN 开头
local expected_prefix="https://${test_domain}/payment/notify"
if [ "$notify_url" != "$expected_prefix" ]; then
log_error "notify_url 不匹配: 表单=${notify_url}"
log_error " 期望: ${expected_prefix}"
log_error "请检查 APP_BASE_URL 环境变量配置"
return 1
fi
log_info " notify_url 正确: ${notify_url}"
return 0
}
# ---------- 支付宝签名一致性校验 ----------
# 防止密钥不同步导致的 invalid-signature历史事故服务器私钥与控制台应用公钥不匹配
test_payment_signature() {
log_info "校验支付宝支付签名与本地密钥一致性..."
if [ ! -f "${SCRIPT_DIR}/.env" ] || ! grep -q "ALIPAY_PRIVATE_KEY" "${SCRIPT_DIR}/.env"; then
log_warn " 本地 .env 无 ALIPAY_PRIVATE_KEY跳过签名校验"
return 0
fi
local jwt
jwt=$(remote "curl -sk -X POST '${BASE_URL}/api/guest-login'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
if [ -z "$jwt" ]; then
log_error "获取 JWT 失败,无法校验支付签名"
return 1
fi
local form
form=$(remote "curl -sk --max-time 10 '${BASE_URL}/payment/page?package=monthly&jwt=${jwt}'" 2>/dev/null || true)
if ! echo "$form" | grep -q 'name="sign"'; then
log_warn " 支付表单未生成(可能为 Mock 支付模式),跳过签名校验"
return 0
fi
local result
result=$(echo "$form" | python3 -c "
import base64, re, html, sys
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
priv_b64 = None
for line in open('${SCRIPT_DIR}/.env'):
if line.startswith('ALIPAY_PRIVATE_KEY='):
priv_b64 = line.strip().split('=', 1)[1]
break
if not priv_b64:
print('FAIL: 本地 .env 无 ALIPAY_PRIVATE_KEY')
sys.exit(1)
try:
priv = serialization.load_der_private_key(base64.b64decode(priv_b64), password=None)
except Exception:
try:
priv = serialization.load_pem_private_key(priv_b64.encode(), password=None)
except Exception as e:
print(f'FAIL: 私钥解析失败: {e}')
sys.exit(1)
content = sys.stdin.read()
fields = {}
for m in re.finditer(r'name=\"([^\"]+)\" value=\"([^\"]*)\"', content):
fields[m.group(1)] = html.unescape(m.group(2))
if 'sign' not in fields:
print('FAIL: 表单无 sign 字段')
sys.exit(1)
sign = fields.pop('sign')
sign_source = '&'.join(f'{k}={v}' for k, v in sorted(fields.items()))
try:
our_sign = base64.b64encode(priv.sign(sign_source.encode('utf-8'), padding.PKCS1v15(), hashes.SHA256())).decode()
except Exception as e:
print(f'FAIL: 签名失败: {e}')
sys.exit(1)
if our_sign == sign:
print('PASS')
else:
print('FAIL: 服务器签名与本地私钥不一致(密钥不同步!检查服务器 .env 与控制台应用公钥)')
sys.exit(1)
" 2>/dev/null)
if [ "$result" = "PASS" ]; then
log_info " 服务器签名与本地私钥一致 ✅"
return 0
else
log_error " ${result:-签名校验脚本执行失败}"
return 1
fi
}
# ---------- 主测试入口 ---------- # ---------- 主测试入口 ----------
run_tests() { run_tests() {
log_step "执行部署后测试..." log_step "执行部署后测试..."
@@ -495,6 +600,8 @@ run_tests() {
"test_payment_maintenance:维护模式" "test_payment_maintenance:维护模式"
"test_payment_refund:退款+会员撤销" "test_payment_refund:退款+会员撤销"
"test_payment_verify_detection:验证函数检测逻辑" "test_payment_verify_detection:验证函数检测逻辑"
"test_notify_url_domain:支付回调域名校验"
"test_payment_signature:支付签名一致性"
) )
local i=1 total=${#tests[@]} local i=1 total=${#tests[@]}

257
scripts/alert.sh Normal file
View File

@@ -0,0 +1,257 @@
#!/bin/bash
# ===========================================
# alert.sh — 服务器异常实时告警(正常时静默,异常才发邮件)
#
# 检测项:
# 磁盘使用 >80% 警告 / >90% 严重
# 内存使用 >80% 警告 / >90% 严重
# 备份时效 >24h 警告 / >48h 严重(含备份缺失)
# 服务状态 green/blue/nginx 非 active → 严重
# Docker 容器非 Up → 严重
# SSL 剩余 <30 天警告 / <7 天严重
# 支付卡单 pending 且创建超过 30 分钟 → 警告(列出订单号)
# 数据库 不可用 → 严重(支付/会员功能受影响)
#
# 防刷屏状态机(.alert-state
# 级别上升 → 立即告警;级别不变 → 超过 ALERT_COOLDOWN_HOURS 再次提醒;
# 恢复正常 → 清除状态,不打扰。
#
# 部署:
# cp scripts/mail-lib.sh scripts/alert.sh → 服务器 /root/rust/scripts/
# cronroot:
# */5 * * * * /root/rust/scripts/alert.sh >> /root/rust/scripts/logs/alert.log 2>&1
#
# 用法:/root/rust/scripts/alert.sh [--dry-run]
# ===========================================
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=mail-lib.sh
source "${SCRIPT_DIR}/mail-lib.sh"
# ---------- 可配置项(环境变量可覆盖) ----------
: "${ALERT_STATE_FILE:=/root/rust/scripts/.alert-state}"
: "${ALERT_COOLDOWN_HOURS:=6}" # 同一级别再次提醒的冷却时间
: "${DB_CONTAINER:=1Panel-postgresql-FtMo}"
: "${DB_NAME:=milkydata}"
: "${DB_USER:=milkydata}"
: "${STUCK_ORDER_MINUTES:=30}" # pending 超过该时长视为卡单
: "${BACKUP_DIR:=/root/rust/backups/milkydata}" # 备份目录
DRY_RUN=false
[ "${1:-}" = "--dry-run" ] && DRY_RUN=true
# 日志
alert_log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "${MAIL_LOG_DIR}/alert.log" 2>/dev/null || true; }
[ "$DRY_RUN" = true ] && alert_log() { echo "[DRY-RUN] $*"; }
# ---------- 检测项 ----------
# 每项输出: 级别(0/1/2)|键名|标签|当前值|详情
# 级别: 0=正常 1=警告 2=严重
check_disk() {
local pct; pct=$(df -P / | awk 'NR==2{print $5}' | tr -d '%')
pct=${pct:-0}
local used total; used=$(df -h / | awk 'NR==2{print $3}'); total=$(df -h / | awk 'NR==2{print $2}')
local lvl=0
[ "$pct" -ge 90 ] && lvl=2
[ "$pct" -ge 80 ] && [ "$lvl" -eq 0 ] && lvl=1
echo "${lvl}|disk|磁盘使用|${used} / ${total} (${pct}%)|根分区已用 ${pct}%"
}
check_mem() {
local pct; pct=$(free | awk '/Mem/{printf "%.0f", $3/$2 * 100}')
pct=${pct:-0}
local used total; used=$(free -h | awk '/Mem/{print $3}'); total=$(free -h | awk '/Mem/{print $2}')
local lvl=0
[ "$pct" -ge 90 ] && lvl=2
[ "$pct" -ge 80 ] && [ "$lvl" -eq 0 ] && lvl=1
echo "${lvl}|mem|内存使用|${used} / ${total} (${pct}%)|内存已用 ${pct}%"
}
check_backup() {
local dir="$BACKUP_DIR"
local last; last=$(ls -1t "$dir" 2>/dev/null | head -1)
if [ -z "$last" ]; then
echo "2|backup|备份|无备份文件|目录 ${dir} 无任何备份"
return
fi
local age; age=$(( ($(date +%s) - $(stat -c %Y "$dir/$last" 2>/dev/null || echo 0)) / 3600 ))
local lvl=0
[ "$age" -ge 48 ] && lvl=2
[ "$age" -ge 24 ] && [ "$lvl" -eq 0 ] && lvl=1
echo "${lvl}|backup|备份时效|${age} 小时前(${last}|最新备份距今 ${age} 小时"
}
check_services() {
local bad=""
for svc in rust-backend-green rust-backend-blue nginx; do
local st; st=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
[ "$st" = "active" ] || bad="${bad}${svc}=${st} "
done
if [ -n "$bad" ]; then
echo "2|services|服务状态|异常服务: ${bad}|systemd 服务未运行"
else
echo "0|services|服务状态|全部 active|green/blue/nginx 运行正常"
fi
}
check_docker() {
local out; out=$(docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null)
if [ -z "$out" ]; then
if docker info >/dev/null 2>&1; then
echo "0|docker|Docker 容器|无运行容器|当前无容器在运行"
else
echo "2|docker|Docker|不可用|docker 命令执行失败"
fi
return
fi
local bad; bad=$(echo "$out" | grep -v " Up " || true)
if [ -n "$bad" ]; then
echo "2|docker|Docker 容器|$(echo "$out" | grep -c " Up ") 个正常|异常容器: $(echo "$bad" | awk '{print $1}' | tr '\n' ' ')"
else
echo "0|docker|Docker 容器|$(echo "$out" | grep -c " Up ") 个运行中|全部正常"
fi
}
check_ssl() {
local expiry; expiry=$(echo "" | openssl s_client -connect xmclassmate.top:443 -servername xmclassmate.top 2>&1 | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$expiry" ]; then
echo "1|ssl|SSL 证书|检测失败|无法获取证书到期时间"
return
fi
local days; days=$(( ($(date -d "$expiry" +%s 2>/dev/null || echo 0) - $(date +%s)) / 86400 ))
local lvl=0
[ "$days" -lt 7 ] && lvl=2
[ "$days" -lt 30 ] && [ "$lvl" -eq 0 ] && lvl=1
echo "${lvl}|ssl|SSL 证书|剩余 ${days} 天(${expiry}|证书将于 ${expiry} 到期"
}
# 支付卡单检测pending 超过 STUCK_ORDER_MINUTES
check_stuck_orders() {
local count orders
count=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -At -c \
"SELECT count(*) FROM payment_orders WHERE status='pending' AND created_at < NOW() - INTERVAL '${STUCK_ORDER_MINUTES} minutes'" 2>/dev/null)
if [ -z "$count" ]; then
return # 数据库不可用由 check_db 负责
fi
if [ "$count" -gt 0 ]; then
orders=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -At -c \
"SELECT order_no || ' (用户' || user_id || ', ' || to_char(created_at, 'MM-DD HH24:MI') || ')' FROM payment_orders WHERE status='pending' AND created_at < NOW() - INTERVAL '${STUCK_ORDER_MINUTES} minutes' ORDER BY created_at" 2>/dev/null | tr '\n' '; ')
echo "1|stuck_orders|支付卡单|${count} 笔 pending 超 ${STUCK_ORDER_MINUTES} 分钟|${orders}"
else
echo "0|stuck_orders|支付卡单|无|无卡单"
fi
}
check_db() {
local ok; ok=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -At -c "SELECT 1" 2>/dev/null)
if [ -z "$ok" ]; then
echo "2|db|数据库|不可用|docker exec psql 失败(容器 ${DB_CONTAINER}"
else
echo "0|db|数据库|正常|SELECT 1 成功"
fi
}
# ---------- 状态文件管理 ----------
# 格式: key lvl last_alert_epoch
load_state() { [ -f "$ALERT_STATE_FILE" ] && cat "$ALERT_STATE_FILE" || true; }
save_state() {
local tmp="${ALERT_STATE_FILE}.tmp"
{ for k in "${!NEW_STATE[@]}"; do echo "$k ${NEW_STATE[$k]}"; done; } > "$tmp"
mv "$tmp" "$ALERT_STATE_FILE" 2>/dev/null || true
}
# 读取状态:$1=key, $2=字段序号1=级别 2=时间);状态行格式为 "key lvl time"
get_state() {
local line; line=$(load_state | awk -v k="$1" '$1==k')
[ -n "$line" ] || { echo "-1"; return; }
echo "$line" | awk -v f="$2" '{print $(f+1)}'
}
# ---------- 主流程 ----------
NOW=$(date +%s)
COOLDOWN_SEC=$((ALERT_COOLDOWN_HOURS * 3600))
# 收集检测结果(格式: level|key|label|value|detail
RESULTS=""
for check in check_disk check_mem check_backup check_services check_docker check_ssl check_db check_stuck_orders; do
r=$($check 2>/dev/null)
[ -n "$r" ] && RESULTS="${RESULTS}${r}\n"
done
declare -A NEW_STATE
ALERTS="" # 需要发邮件的条目
WARN_COUNT=0
CRIT_COUNT=0
while IFS='|' read -r lvl key label value detail; do
[ -z "$key" ] && continue
[ "$lvl" -eq 0 ] && continue # 正常项不处理(下面统一清除状态)
old_lvl=$(get_state "$key" 1)
old_time=$(get_state "$key" 2)
[ "$old_time" = "-1" ] && old_time=0
should_alert=false
if [ "$lvl" -gt "$old_lvl" ]; then
should_alert=true # 级别上升 → 立即告警
elif [ "$lvl" -eq "$old_lvl" ] && [ $((NOW - old_time)) -ge "$COOLDOWN_SEC" ]; then
should_alert=true # 同级别冷却期后再次提醒
fi
NEW_STATE[$key]="${lvl} ${NOW}"
if [ "$should_alert" = true ]; then
[ "$lvl" -eq 2 ] && CRIT_COUNT=$((CRIT_COUNT + 1))
[ "$lvl" -eq 1 ] && WARN_COUNT=$((WARN_COUNT + 1))
ALERTS="${ALERTS}<tr><td style='padding:8px 10px;border-bottom:1px solid #e2e8f0'>$( [ "$lvl" -eq 2 ] && echo "<span style='color:#dc2626'>严重</span>" || echo "<span style='color:#d97706'>警告</span>" )</td><td style='padding:8px 10px;border-bottom:1px solid #e2e8f0'>${label}</td><td style='padding:8px 10px;border-bottom:1px solid #e2e8f0'>${value}</td><td style='padding:8px 10px;border-bottom:1px solid #e2e8f0;color:#475569'>${detail}</td></tr>\n"
alert_log "告警 [${key}] 级别=${lvl}: ${label} ${value}${detail}"
fi
done < <(printf "%b" "$RESULTS")
# 恢复检测:状态文件中存在但本次为 0/未出现的项 → 清除
while read -r key old_lvl _; do
[ -z "$key" ] && continue
cur_lvl=$(printf "%b" "$RESULTS" | awk -F'|' -v k="$key" '$2==k{print $1; exit}')
if [ -z "$cur_lvl" ] || [ "$cur_lvl" -eq 0 ]; then
unset 'NEW_STATE[$key]' 2>/dev/null || true
alert_log "恢复: ${key} 已恢复正常,状态已清除"
fi
done < <(load_state)
save_state
# ---------- 发送告警邮件 ----------
if [ -n "$ALERTS" ]; then
if [ "$CRIT_COUNT" -gt 0 ]; then
SUBJECT="[ASD 告警] ${CRIT_COUNT} 项严重 / ${WARN_COUNT} 项警告 — $(date '+%m-%d %H:%M')"
else
SUBJECT="[ASD 告警] ${WARN_COUNT} 项警告 — $(date '+%m-%d %H:%M')"
fi
BODY=$(cat <<EOF
<html><body style="font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;max-width:640px;margin:0 auto;padding:16px">
<h2 style="color:#0f172a;margin:0 0 4px">⚠️ ASD 服务器异常告警</h2>
<div style="color:#94a3b8;font-size:13px;margin-bottom:16px">$(date '+%Y-%m-%d %H:%M:%S') · $(hostname)</div>
<table style="border-collapse:collapse;width:100%;font-size:13px">
<tr><th style="padding:8px 10px;background:#f1f5f9;text-align:left;color:#64748b">级别</th><th style="padding:8px 10px;background:#f1f5f9;text-align:left;color:#64748b">项目</th><th style="padding:8px 10px;background:#f1f5f9;text-align:left;color:#64748b">当前值</th><th style="padding:8px 10px;background:#f1f5f9;text-align:left;color:#64748b">说明</th></tr>
$(printf "%b" "$ALERTS")
</table>
<div style="color:#94a3b8;font-size:12px;margin-top:16px">下次同类提醒将在 ${ALERT_COOLDOWN_HOURS} 小时后,恢复正常后不再打扰。</div>
</body></html>
EOF
)
if [ "$DRY_RUN" = true ]; then
echo "---- 将发送告警邮件 ----"; echo "主题: ${SUBJECT}"; echo "$BODY" | head -30
else
if mail_send "$SUBJECT" "$BODY"; then
alert_log "告警邮件已发送: ${SUBJECT}"
else
alert_log "告警邮件发送失败: ${SUBJECT}"
fi
fi
else
alert_log "本次检查无异常($(date '+%H:%M:%S')"
[ "$DRY_RUN" = true ] && echo "当前无异常,无需告警。"
fi

207
scripts/daily-report.sh Normal file
View File

@@ -0,0 +1,207 @@
#!/bin/bash
# ===========================================
# 每日健康报告 — HTML 摘要邮件(含支付/会员统计)
#
# 部署:
# cp scripts/mail-lib.sh scripts/daily-report.sh → 服务器 /root/rust/scripts/
# chmod +x /root/rust/scripts/daily-report.sh
# cronroot:
# 0 8 * * * /root/rust/scripts/daily-report.sh >> /root/rust/scripts/logs/daily-report.log 2>&1
#
# 说明:单项采集失败自动降级为 N/A不会阻断整封邮件
# 发送失败自动重试并记录 /root/rust/scripts/logs/mail.log。
# ===========================================
# 不用 set -e采集失败不应导致邮件发不出去
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=mail-lib.sh
source "${SCRIPT_DIR}/mail-lib.sh"
# ---------- 可配置项 ----------
: "${DB_CONTAINER:=1Panel-postgresql-FtMo}" # PostgreSQL 容器名
: "${DB_NAME:=milkydata}" # 数据库名
: "${DB_USER:=milkydata}" # 数据库用户
: "${BACKUP_DIR:=/root/rust/backups/milkydata}" # 备份目录
DATE=$(date "+%Y-%m-%d")
HOST=$(hostname)
SUBJECT="[ASD 服务器报告] ${DATE}${HOST}"
# ---------- 状态徽章 ----------
# badge <ok|warn|crit> <文本>
badge() {
case "$1" in
ok) echo "<span style='background:#16a34a;color:#fff;padding:1px 8px;border-radius:10px;font-size:12px'>$2</span>" ;;
warn) echo "<span style='background:#d97706;color:#fff;padding:1px 8px;border-radius:10px;font-size:12px'>$2</span>" ;;
crit) echo "<span style='background:#dc2626;color:#fff;padding:1px 8px;border-radius:10px;font-size:12px'>$2</span>" ;;
*) echo "<span style='background:#64748b;color:#fff;padding:1px 8px;border-radius:10px;font-size:12px'>$2</span>" ;;
esac
}
# 根据磁盘/内存百分比判定级别(参数: 数值,返回 ok/warn/crit/unknown
pct_level() {
local v="$1" w="${2:-80}" c="${3:-90}"
case "$v" in
''|*[!0-9]*) echo "unknown"; return ;;
esac
if [ "$v" -ge "$c" ]; then echo "crit"
elif [ "$v" -ge "$w" ]; then echo "warn"
else echo "ok"; fi
}
# ---------- 系统状态 ----------
UPTIME=$(uptime -p | sed 's/up //')
LOAD=$(uptime | awk -F'load average:' '{print $2}' | xargs)
LOAD1=${LOAD%%,*} # 1 分钟负载(可能是小数,取整数部分比较)
LOAD1_INT=${LOAD1%%.*}
DISK_USED=$(df -h / | awk 'NR==2{print $3}')
DISK_TOTAL=$(df -h / | awk 'NR==2{print $2}')
DISK_PCT_STR=$(df -h / | awk 'NR==2{print $5}')
DISK_PCT=${DISK_PCT_STR%\%}
MEM_USED=$(free -h | awk '/Mem/{print $3}')
MEM_TOTAL=$(free -h | awk '/Mem/{print $2}')
MEM_PCT=$(free | awk '/Mem/{printf "%.0f", $3/$2 * 100}')
CPU_TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk '{printf "%.1f°C", $1/1000}' || echo "N/A")
# ---------- Docker ----------
DOCKER_STATUS=$(docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null || echo "")
DOCKER_DOWN=$(echo "$DOCKER_STATUS" | grep -vc "Up " || true)
if [ -z "$DOCKER_STATUS" ]; then
DOCKER_BODY="<span style='color:#dc2626'>Docker 不可用或无运行容器</span>"
elif [ "$DOCKER_DOWN" -gt 0 ]; then
DOCKER_BODY="$(echo "$DOCKER_STATUS" | awk '{print $1": "$2}' | sed 's/^/ /' | sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g' | tr '\n' '<br>')"
else
DOCKER_BODY="$(echo "$DOCKER_STATUS" | awk '{print $1" "$2}' | sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g' | tr '\n' '<br>')"
fi
# ---------- SSL 证书 ----------
SSL_EXPIRY=$(echo "" | openssl s_client -connect xmclassmate.top:443 -servername xmclassmate.top 2>&1 | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || echo "检测失败")
if [ "$SSL_EXPIRY" != "检测失败" ]; then
SSL_DAYS=$(( ($(date -d "$SSL_EXPIRY" +%s 2>/dev/null) - $(date +%s)) / 86400 ))
else
SSL_DAYS=9999
fi
# ---------- 服务状态 ----------
GREEN_STATUS=$(systemctl is-active rust-backend-green.service 2>/dev/null || echo "unknown")
BLUE_STATUS=$(systemctl is-active rust-backend-blue.service 2>/dev/null || echo "unknown")
NGINX_STATUS=$(systemctl is-active nginx 2>/dev/null || echo "unknown")
# ---------- 备份 ----------
LAST_BACKUP=$(ls -1t "$BACKUP_DIR/" 2>/dev/null | head -1)
BACKUP_AGE_HOURS=""
BACKUP_AGE_TEXT="未知"
if [ -n "$LAST_BACKUP" ]; then
BACKUP_TIME=$(stat -c %Y "$BACKUP_DIR/$LAST_BACKUP" 2>/dev/null)
NOW=$(date +%s)
BACKUP_AGE_HOURS=$(( (NOW - BACKUP_TIME) / 3600 ))
BACKUP_AGE_TEXT="${BACKUP_AGE_HOURS} 小时前"
fi
# ---------- 安全 ----------
SSH_BANS=$(sudo fail2ban-client status sshd 2>/dev/null | grep "Currently banned" | awk '{print $NF}' || echo "0")
NGINX_BANS=$(sudo fail2ban-client status nginx-botsearch 2>/dev/null | grep "Currently banned" | awk '{print $NF}' || echo "0")
YESTERDAY_ERRORS=$(journalctl -u rust-backend-green.service --since "1 day ago" --priority err --no-pager 2>/dev/null | wc -l)
YESTERDAY_WARNS=$(journalctl -u rust-backend-green.service --since "1 day ago" --priority warning --no-pager 2>/dev/null | wc -l)
# ---------- 应用概况(昨日) ----------
ACTIVE_USERS=$(journalctl -u rust-backend-green.service --since "1 day ago" --no-pager 2>/dev/null | grep -c "user_id=\|登录成功\|上传成功" || echo "0")
API_COUNT=$(journalctl -u rust-backend-green.service --since "1 day ago" --no-pager 2>/dev/null | grep -c "INFO\|WARN\|ERROR" || echo "0")
# ---------- 支付与会员(数据库统计,不可用则 N/A ----------
# 服务器上通过 docker exec 访问 PostgreSQL与 check-members.sh 同模式)
DB_QUERY() {
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -At -c "$1" 2>/dev/null || echo ""
}
DB_OK=false
if [ -n "$(DB_QUERY 'SELECT 1')" ]; then DB_OK=true; fi
PAY_ORDERS_TODAY="N/A"; PAY_PAID_TODAY="N/A"; PAY_STUCK="N/A"
MEM_ACTIVE="N/A"; MEM_EXPIRE_7D="N/A"
if [ "$DB_OK" = true ]; then
PAY_ORDERS_TODAY=$(DB_QUERY "SELECT count(*) FROM payment_orders WHERE created_at::date = CURRENT_DATE")
PAY_PAID_TODAY=$(DB_QUERY "SELECT count(*) FROM payment_orders WHERE status='paid' AND paid_at::date = CURRENT_DATE")
PAY_STUCK=$(DB_QUERY "SELECT count(*) FROM payment_orders WHERE status='pending' AND created_at < NOW() - INTERVAL '30 minutes'")
MEM_ACTIVE=$(DB_QUERY "SELECT count(*) FROM users WHERE is_member AND (membership_expires_at IS NULL OR membership_expires_at > NOW())")
MEM_EXPIRE_7D=$(DB_QUERY "SELECT count(*) FROM users WHERE is_member AND membership_expires_at IS NOT NULL AND membership_expires_at BETWEEN NOW() AND NOW() + INTERVAL '7 days'")
fi
[ -z "$PAY_ORDERS_TODAY" ] && PAY_ORDERS_TODAY="N/A"
[ -z "$PAY_PAID_TODAY" ] && PAY_PAID_TODAY="N/A"
[ -z "$PAY_STUCK" ] && PAY_STUCK="N/A"
[ -z "$MEM_ACTIVE" ] && MEM_ACTIVE="N/A"
[ -z "$MEM_EXPIRE_7D" ] && MEM_EXPIRE_7D="N/A"
# ---------- 组装 HTML ----------
# 行生成器: row <指标> <当前值> <级别> <附加说明?>
row() {
echo "<tr><td style='padding:6px 10px;border-bottom:1px solid #e2e8f0;color:#475569'>$1</td><td style='padding:6px 10px;border-bottom:1px solid #e2e8f0'>$2</td><td style='padding:6px 10px;border-bottom:1px solid #e2e8f0;text-align:center'>$(badge "$3" "$4")</td></tr>"
}
section_title() {
echo "<tr><td colspan='3' style='padding:10px;background:#f8fafc;font-weight:bold;color:#0f172a;border-bottom:1px solid #e2e8f0'>$1</td></tr>"
}
STYLE="font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#fff;max-width:640px;margin:0 auto;padding:16px"
TH="padding:8px 10px;background:#f1f5f9;color:#64748b;text-align:left;font-size:12px;border-bottom:2px solid #e2e8f0"
BODY=$(cat <<EOF
<html><body style="${STYLE}">
<h2 style="color:#0f172a;margin:0 0 4px">ASD 服务器健康报告</h2>
<div style="color:#94a3b8;font-size:13px;margin-bottom:16px">${DATE} · ${HOST} · 发送于 $(date "+%H:%M")</div>
<table style="border-collapse:collapse;width:100%;font-size:13px">
<tr><th style="${TH}">指标</th><th style="${TH}">当前值</th><th style="${TH}" width="90">状态</th></tr>
$(section_title "🖥 系统状态")
$(row "运行时长" "$UPTIME" ok "OK")
$(row "系统负载" "$LOAD" "$([ "$LOAD1_INT" -ge 4 ] && echo crit || { [ "$LOAD1_INT" -ge 2 ] && echo warn || echo ok; })" "$([ "$LOAD1_INT" -ge 4 ] && echo "超载" || { [ "$LOAD1_INT" -ge 2 ] && echo "偏高" || echo "正常"; })")
$(row "磁盘使用" "${DISK_USED} / ${DISK_TOTAL} (${DISK_PCT_STR})" "$(pct_level "$DISK_PCT")" "$(pct_level "$DISK_PCT" | tr 'a-z' 'A-Z')")
$(row "内存使用" "${MEM_USED} / ${MEM_TOTAL} (${MEM_PCT}%)" "$(pct_level "$MEM_PCT" 80 90)" "$(pct_level "$MEM_PCT" 80 90 | tr 'a-z' 'A-Z')")
$(row "CPU 温度" "${CPU_TEMP}" ok "OK")
$(section_title "📦 容器与服务")
$(row "Docker 容器" "运行中 $(echo "$DOCKER_STATUS" | grep -c "Up " || echo 0) 个 / 异常 $(echo "$DOCKER_STATUS" | grep -vc "Up " || echo 0) 个" "$([ "$DOCKER_DOWN" -gt 0 ] && echo crit || echo ok)" "$([ "$DOCKER_DOWN" -gt 0 ] && echo "有异常" || echo "正常")")
$(row "后端 green" "$GREEN_STATUS" "$([ "$GREEN_STATUS" = "active" ] && echo ok || echo crit)" "$([ "$GREEN_STATUS" = "active" ] && echo "运行中" || echo "异常")")
$(row "后端 blue" "$BLUE_STATUS" "$([ "$BLUE_STATUS" = "active" ] && echo ok || echo crit)" "$([ "$BLUE_STATUS" = "active" ] && echo "运行中" || echo "异常")")
$(row "nginx" "$NGINX_STATUS" "$([ "$NGINX_STATUS" = "active" ] && echo ok || echo crit)" "$([ "$NGINX_STATUS" = "active" ] && echo "运行中" || echo "异常")")
<tr><td colspan="3" style="padding:6px 10px;color:#64748b;font-size:12px;background:#fbfdff">${DOCKER_BODY}</td></tr>
$(section_title "🔐 SSL 与安全")
$(row "SSL 证书" "${SSL_EXPIRY}(剩余 ${SSL_DAYS} 天)" "$([ "$SSL_DAYS" -lt 7 ] && echo crit || { [ "$SSL_DAYS" -lt 30 ] && echo warn || echo ok; })" "$([ "$SSL_DAYS" -lt 7 ] && echo "即将过期" || { [ "$SSL_DAYS" -lt 30 ] && echo "注意续期" || echo "正常"; })")
$(row "Fail2ban SSH" "${SSH_BANS} 个 IP 被封禁" ok "OK")
$(row "Fail2ban nginx" "${NGINX_BANS} 个 IP 被封禁" ok "OK")
$(row "昨日日志错误" "$YESTERDAY_ERRORS 条" "$([ "$YESTERDAY_ERRORS" -gt 50 ] && echo warn || echo ok)" "$([ "$YESTERDAY_ERRORS" -gt 50 ] && echo "偏多" || echo "正常")")
$(row "昨日日志警告" "$YESTERDAY_WARNS 条" "$([ "$YESTERDAY_WARNS" -gt 200 ] && echo warn || echo ok)" "$([ "$YESTERDAY_WARNS" -gt 200 ] && echo "偏多" || echo "正常")")
$(section_title "💾 备份")
$(row "最新备份" "${LAST_BACKUP:-无}" "$([ -n "$BACKUP_AGE_HOURS" ] && { [ "$BACKUP_AGE_HOURS" -ge 48 ] && echo crit || { [ "$BACKUP_AGE_HOURS" -ge 24 ] && echo warn || echo ok; }; } || echo crit)" "$([ -n "$BACKUP_AGE_HOURS" ] && { [ "$BACKUP_AGE_HOURS" -ge 48 ] && echo "严重过期" || { [ "$BACKUP_AGE_HOURS" -ge 24 ] && echo "⚠ 超24小时" || echo "${BACKUP_AGE_TEXT}"; }; } || echo "未知")")
$(row "备份时效" "${BACKUP_AGE_TEXT}" ok "OK")
$(section_title "📊 应用概况(昨日)")
$(row "日志事件数" "$API_COUNT 条" ok "OK")
$(row "活跃用户操作" "$ACTIVE_USERS 次" ok "OK")
$(section_title "💰 支付与会员")
$(row "今日新订单" "$PAY_ORDERS_TODAY 笔" ok "OK")
$(row "今日支付成功" "$PAY_PAID_TODAY 笔" ok "OK")
$(row "卡单pending>30分钟" "$PAY_STUCK 笔" "$([ "$PAY_STUCK" -gt 0 ] 2>/dev/null && echo crit || echo ok)" "$([ "$PAY_STUCK" -gt 0 ] 2>/dev/null && echo "需人工处理" || echo "无")")
$(row "活跃会员" "$MEM_ACTIVE 人" ok "OK")
$(row "7天内到期会员" "$MEM_EXPIRE_7D 人" "$([ "$MEM_EXPIRE_7D" -gt 0 ] 2>/dev/null && echo warn || echo ok)" "$([ "$MEM_EXPIRE_7D" -gt 0 ] 2>/dev/null && echo "可发续费提醒" || echo "无")")
$( [ "$DB_OK" = false ] && echo "<tr><td colspan='3' style='padding:6px 10px;color:#dc2626;font-size:12px;background:#fef2f2'>⚠ 数据库不可用,支付/会员统计缺失</td></tr>" )
</table>
<div style="color:#94a3b8;font-size:12px;margin-top:16px">ASD 自动监控 | $(date "+%Y-%m-%d %H:%M") | 异常项请关注 alert.sh 实时告警</div>
</body></html>
EOF
)
# ---------- 发送 ----------
if mail_send "$SUBJECT" "$BODY"; then
echo "✅ 健康报告已发送到 ${MAIL_TO}$(date '+%H:%M:%S')"
else
echo "❌ 健康报告发送失败,详见 ${MAIL_LOG_DIR}/mail.log"
exit 1
fi

70
scripts/mail-lib.sh Normal file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# ===========================================
# mail-lib.sh — 邮件发送共享库
# 供 daily-report.sh / alert.sh 复用:
# mail_send <主题> <HTML正文> (带重试 + 日志 + 轮转)
# ===========================================
# ---------- 可配置项(环境变量可覆盖) ----------
: "${MAIL_TO:=m943790568@163.com}" # 收件人,多个用逗号分隔
: "${MAIL_FROM:=ASD 监控 <m943790568@163.com>}" # 发件人显示名
: "${MAIL_ACCOUNT:=default}" # msmtp 账户(~/.msmtprc 中的 account
: "${MAIL_LOG_DIR:=/root/rust/scripts/logs}" # 日志目录
: "${MAIL_RETRY:=3}" # 发送重试次数
: "${MAIL_LOG_MAX_KB:=1024}" # mail.log 轮转阈值KB
mkdir -p "$MAIL_LOG_DIR" 2>/dev/null || true
# 写日志
mail_log() {
local level="$1"; shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${level}] $*" >> "${MAIL_LOG_DIR}/mail.log" 2>/dev/null || true
}
# 日志轮转:超过阈值保留末尾 200 行
mail_rotate_log() {
local f="${MAIL_LOG_DIR}/mail.log"
[ -f "$f" ] || return 0
local size_kb=$(( $(stat -c %s "$f" 2>/dev/null || echo 0) / 1024 ))
if [ "$size_kb" -gt "$MAIL_LOG_MAX_KB" ]; then
tail -n 200 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f" 2>/dev/null || true
mail_log INFO "mail.log 已轮转"
fi
}
# 发送 HTML 邮件UTF-8失败自动重试
# 用法: mail_send "主题" "HTML 正文"
mail_send() {
local subject="$1" body="$2"
mail_rotate_log
local recipients headers
recipients=$(echo "$MAIL_TO" | tr ',' ' ')
headers=$(cat <<EOF
From: ${MAIL_FROM}
To: ${MAIL_TO}
Subject: ${subject}
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 8bit
EOF
)
local attempt=1 ok=0
while [ "$attempt" -le "$MAIL_RETRY" ]; do
if { echo "$headers"; echo; echo "$body"; } | msmtp -a "$MAIL_ACCOUNT" $recipients 2>>"${MAIL_LOG_DIR}/mail.log"; then
ok=1
mail_log INFO "邮件发送成功: ${subject}${MAIL_TO}(第 ${attempt} 次尝试)"
break
fi
mail_log WARN "邮件发送失败(第 ${attempt}/${MAIL_RETRY} 次): ${subject}"
attempt=$((attempt + 1))
[ "$attempt" -le "$MAIL_RETRY" ] && sleep $((attempt * 5))
done
if [ "$ok" -ne 1 ]; then
mail_log ERROR "邮件最终发送失败: ${subject}"
return 1
fi
return 0
}

65
scripts/verify-alipay-keys.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# ===========================================
# 支付宝密钥一致性核对脚本
# 对比本地 .env 与服务器 green/blue .env 的应用私钥,
# 并从本地私钥导出应用公钥,供与支付宝控制台比对。
# 防止 invalid-signature密钥不同步问题复发。
# 用法: bash scripts/verify-alipay-keys.sh [--remote-host HOST]
# ===========================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
REMOTE_HOST="${2:-aliyun-server}"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
if [ ! -f "$ENV_FILE" ] || ! grep -q '^ALIPAY_PRIVATE_KEY=' "$ENV_FILE"; then
error "本地 ${ENV_FILE} 未找到 ALIPAY_PRIVATE_KEY"
exit 1
fi
LOCAL_KEY=$(grep '^ALIPAY_PRIVATE_KEY=' "$ENV_FILE" | head -1 | cut -d'=' -f2- | tr -d '\r\n ')
LOCAL_MD5=$(printf '%s' "$LOCAL_KEY" | md5sum | cut -d' ' -f1)
info "本地 .env 私钥 MD5: ${LOCAL_MD5}"
ALL_MATCH=true
for ENV in green blue; do
REMOTE_MD5=$(ssh "$REMOTE_HOST" "grep '^ALIPAY_PRIVATE_KEY=' /root/rust/rust_backend_${ENV}/.env | cut -c20- | tr -d '\n' | md5sum | cut -d' ' -f1" 2>/dev/null || echo "N/A")
if [ "$REMOTE_MD5" = "$LOCAL_MD5" ]; then
info "服务器 ${ENV} 私钥一致 ✅"
else
warn "服务器 ${ENV} 私钥不一致(本地=${LOCAL_MD5} 远端=${REMOTE_MD5}"
ALL_MATCH=false
fi
done
if [ "$ALL_MATCH" = true ]; then
info "本地与服务器密钥一致"
else
warn "存在不一致!请检查服务器 .env 或重新部署"
fi
echo ""
echo "========== 应用公钥(与控制台比对) =========="
python3 -c "
import base64
from cryptography.hazmat.primitives import serialization
key_b64 = '$LOCAL_KEY'
try:
priv = serialization.load_der_private_key(base64.b64decode(key_b64), password=None)
except Exception:
priv = serialization.load_pem_private_key(key_b64.encode(), password=None)
pub_der = priv.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
print(base64.b64encode(pub_der).decode())
"
echo ""
info "请到支付宝开放平台 → 密钥工具 → 查看应用公钥,与上方公钥比对"

View File

@@ -124,3 +124,71 @@ pub fn verify_temp_token(token: &str, secret: &str) -> Result<TempTokenClaims, S
Ok(decoded.claims) Ok(decoded.claims)
} }
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "unit-test-secret-0123456789";
#[test]
fn test_generate_and_verify_token_roundtrip() {
let token = generate_token(42, "openid_test_123", 2, TEST_SECRET).unwrap();
let claims = verify_token(&token, TEST_SECRET).unwrap();
assert_eq!(claims.user_id, 42);
assert_eq!(claims.openid, "openid_test_123");
assert_eq!(claims.user_type, 2);
assert!(claims.exp > claims.iat, "过期时间应晚于签发时间");
assert!(claims.exp > Utc::now().timestamp(), "令牌应未过期");
}
#[test]
fn test_verify_token_rejects_wrong_secret() {
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
assert!(verify_token(&token, "wrong-secret").is_err());
}
#[test]
fn test_verify_token_rejects_tampered_payload() {
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
// 篡改 token 的载荷部分(第 2 段),应验证失败
let mut parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
parts[1] = "eyJ1c2VyX2lkIjo5OTl9";
let tampered = parts.join(".");
assert!(verify_token(&tampered, TEST_SECRET).is_err());
}
#[test]
fn test_verify_token_rejects_garbage() {
assert!(verify_token("not-a-jwt", TEST_SECRET).is_err());
assert!(verify_token("", TEST_SECRET).is_err());
}
#[test]
fn test_generate_refresh_token_is_random_uuid() {
let a = generate_refresh_token(1, TEST_SECRET).unwrap();
let b = generate_refresh_token(1, TEST_SECRET).unwrap();
assert_ne!(a, b, "两次生成的 refresh token 不应相同");
// UUID v4 格式8-4-4-4-12 十六进制
let parts: Vec<&str> = a.split('-').collect();
assert_eq!(parts.len(), 5);
assert!(parts.iter().all(|p| !p.is_empty()));
}
#[test]
fn test_temp_token_roundtrip_and_expiry_window() {
let token = generate_temp_token("openid_share", 777, TEST_SECRET, 10).unwrap();
let claims = verify_temp_token(&token, TEST_SECRET).unwrap();
assert_eq!(claims.openid, "openid_share");
assert_eq!(claims.resource_id, 777);
// 10 分钟有效期exp - iat 约等于 600 秒(允许 ±2 秒误差)
let lifetime = claims.exp - claims.iat;
assert!((598..=602).contains(&lifetime), "临时 token 有效期应为 10 分钟,实际 {lifetime}s");
assert!(verify_temp_token(&token, "wrong-secret").is_err());
}
}

128
src/db.rs
View File

@@ -11,17 +11,18 @@ use crate::error::AppError;
pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result<i32, AppError> { pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result<i32, AppError> {
// 单次查询获取用户会员状态和数据条数(原 N+1get_user_by_id + count_user_weather_data → 合并为 1 次) // 单次查询获取用户会员状态和数据条数(原 N+1get_user_by_id + count_user_weather_data → 合并为 1 次)
#[derive(FromRow)] #[derive(FromRow)]
struct UserQuota { struct UserQuota {
is_member: bool, is_member: bool,
paid_expires_at: Option<chrono::DateTime<Utc>>, #[sqlx(rename = "membership_expires_at")]
data_count: i64, membership_expires_at: Option<chrono::DateTime<Utc>>,
} data_count: i64,
}
let quota = sqlx::query_as::<_, UserQuota>( let quota = sqlx::query_as::<_, UserQuota>(
r#" r#"
SELECT SELECT
u.is_member, u.is_member,
u.paid_expires_at, u.membership_expires_at,
(SELECT COUNT(*) FROM weather_data w WHERE w.user_id = $1)::BIGINT AS data_count (SELECT COUNT(*) FROM weather_data w WHERE w.user_id = $1)::BIGINT AS data_count
FROM users u FROM users u
WHERE u.id = $1 WHERE u.id = $1
@@ -33,7 +34,7 @@ pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user
.map_err(|e| AppError::Database(format!("查询用户配额失败: {}", e)))? .map_err(|e| AppError::Database(format!("查询用户配额失败: {}", e)))?
.ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?; .ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?;
let is_active_member = quota.is_member && quota.paid_expires_at.is_none_or(|expires| expires > Utc::now()); let is_active_member = quota.is_member && quota.membership_expires_at.is_none_or(|expires| expires > Utc::now());
// 维护模式:非会员使用更高的临时限额,防止资源滥用 // 维护模式:非会员使用更高的临时限额,防止资源滥用
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()); let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
@@ -369,47 +370,6 @@ pub async fn get_user_orders(
Ok(rows) Ok(rows)
} }
/// 扫描超过 10 分钟的待支付订单,尝试自动确认(用于回调重试)
/// 返回成功确认的订单数量
pub async fn check_and_retry_pending_orders(pool: &PgPool) -> Result<u64, AppError> {
// 查找超过 10 分钟仍为 pending 的订单
let stuck_orders: Vec<(String,)> = sqlx::query_as(
r#"
SELECT order_no FROM payment_orders
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '10 minutes'
AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at ASC
"#,
)
.fetch_all(pool)
.await
.map_err(|e| AppError::Database(format!("查询待处理订单失败: {}", e)))?;
if stuck_orders.is_empty() {
return Ok(0);
}
tracing::info!(
"发现 {} 个待处理订单,尝试自动确认...",
stuck_orders.len()
);
let mut confirmed: u64 = 0;
for (order_no,) in &stuck_orders {
match confirm_payment_order_by_orderno(pool, order_no).await {
Ok(_) => {
confirmed += 1;
tracing::info!("定时任务自动确认订单: {}", order_no);
}
Err(e) => {
tracing::warn!("定时任务无法确认订单 {}: {}", order_no, e);
}
}
}
Ok(confirmed)
}
/// 检查会员到期前 7 天的用户,发送即将到期通知 /// 检查会员到期前 7 天的用户,发送即将到期通知
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> { pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
let affected = sqlx::query( let affected = sqlx::query(
@@ -699,7 +659,7 @@ pub async fn confirm_payment_order_by_orderno(
is_member = true, is_member = true,
membership_expires_at = membership_expires_at =
CASE CASE
WHEN uo.package_type = 'permanent' THEN NULL WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) + ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
CASE CASE
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
@@ -726,9 +686,13 @@ pub async fn confirm_payment_order_by_orderno(
// 审计日志 // 审计日志
if let Some(uid) = user_id { if let Some(uid) = user_id {
let _ = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await; if let Err(e) = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await {
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 发送支付成功通知 // 发送支付成功通知
let _ = insert_payment_notification(pool, uid, order_no).await; if let Err(e) = insert_payment_notification(pool, uid, order_no).await {
tracing::warn!("插入支付通知失败: {}", e);
}
// 验证会员到期时间是否正常 // 验证会员到期时间是否正常
if let Err(e) = verify_membership_after_payment(pool, order_no).await { if let Err(e) = verify_membership_after_payment(pool, order_no).await {
@@ -807,7 +771,9 @@ pub async fn refund_payment_order(
// 审计日志 // 审计日志
let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" }; let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" };
let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await; if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await {
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(()) Ok(())
} }
@@ -900,14 +866,14 @@ pub async fn set_weather_favorite(
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub struct RefreshToken { pub struct RefreshToken {
#[allow(dead_code)] #[allow(dead_code)] // sqlx deserialization only; not read directly
pub id: i32, pub id: i32,
pub user_id: i32, pub user_id: i32,
#[allow(dead_code)] #[allow(dead_code)] // sqlx deserialization only
pub token: String, pub token: String,
#[allow(dead_code)] #[allow(dead_code)] // sqlx deserialization only
pub expires_at: chrono::DateTime<chrono::Utc>, pub expires_at: chrono::DateTime<chrono::Utc>,
#[allow(dead_code)] #[allow(dead_code)] // sqlx deserialization only
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
} }
@@ -1067,17 +1033,17 @@ pub async fn admin_force_confirm_order(
UPDATE users SET UPDATE users SET
is_member = true, is_member = true,
membership_expires_at = membership_expires_at =
CASE CASE
WHEN uo.package_type = 'permanent' THEN NULL WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) + ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
CASE CASE
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days' WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days' WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days' WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
ELSE INTERVAL '0 days' ELSE INTERVAL '0 days'
END END
END END
FROM updated_order uo FROM updated_order uo
WHERE users.id = $2 WHERE users.id = $2
RETURNING users.membership_expires_at RETURNING users.membership_expires_at
@@ -1090,7 +1056,7 @@ pub async fn admin_force_confirm_order(
.map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?; .map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?;
// 写审计日志 // 写审计日志
let _ = insert_payment_audit_log( if let Err(e) = insert_payment_audit_log(
pool, pool,
order_no, order_no,
user_id, user_id,
@@ -1098,7 +1064,10 @@ pub async fn admin_force_confirm_order(
Some(admin_user_id), Some(admin_user_id),
Some("管理员强制确认支付"), Some("管理员强制确认支付"),
) )
.await; .await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 验证会员到期时间是否正常 // 验证会员到期时间是否正常
if let Err(e) = verify_membership_after_payment(pool, order_no).await { if let Err(e) = verify_membership_after_payment(pool, order_no).await {
@@ -1108,7 +1077,7 @@ pub async fn admin_force_confirm_order(
result result
} else { } else {
// 订单已处理,只写日志 // 订单已处理,只写日志
let _ = insert_payment_audit_log( if let Err(e) = insert_payment_audit_log(
pool, pool,
order_no, order_no,
user_id, user_id,
@@ -1116,7 +1085,10 @@ pub async fn admin_force_confirm_order(
Some(admin_user_id), Some(admin_user_id),
Some(&format!("订单状态为 {},跳过确认", status)), Some(&format!("订单状态为 {},跳过确认", status)),
) )
.await; .await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
// 返回当前到期时间 // 返回当前到期时间
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>( sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
@@ -1181,8 +1153,11 @@ pub async fn cancel_payment_order(
.await .await
.map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?; .map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?;
let _ = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id), if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
Some("用户主动取消待支付订单")).await; Some("用户主动取消待支付订单")).await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(()) Ok(())
} }
@@ -1255,8 +1230,11 @@ pub async fn admin_refund_order(
} }
let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" }; let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" };
let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded", if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded",
Some(admin_user_id), Some(detail)).await; Some(admin_user_id), Some(detail)).await
{
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(()) Ok(())
} }

View File

@@ -11,15 +11,6 @@ pub struct ErrorResponse<T = ()> {
} }
impl<T> ErrorResponse<T> { impl<T> ErrorResponse<T> {
#[allow(dead_code)]
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
}
}
pub fn error(msg: impl Into<String>) -> Self { pub fn error(msg: impl Into<String>) -> Self {
Self { Self {
success: false, success: false,
@@ -29,24 +20,6 @@ impl<T> ErrorResponse<T> {
} }
} }
impl<T: Serialize> ErrorResponse<T> {
#[allow(dead_code, clippy::wrong_self_convention)]
pub fn to_json_response(self) -> HttpResponse {
HttpResponse::Ok().json(self)
}
}
impl ErrorResponse<()> {
#[allow(dead_code)]
pub fn to_error_response(&self, status: StatusCode) -> HttpResponse {
let body = serde_json::json!({
"success": false,
"error": self.error.clone().unwrap_or_else(|| "Unknown error".to_string())
});
HttpResponse::build(status).json(body)
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {
Unauthorized(String), Unauthorized(String),

View File

@@ -59,11 +59,13 @@ pub async fn admin_update_user_payment(
// 更新用户付费状态 // 更新用户付费状态
db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_member, membership_expires_at).await?; db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_member, membership_expires_at).await?;
// 审计日志 // 审计日志
let _ = db::insert_payment_audit_log( if let Err(e) = db::insert_payment_audit_log(
pool.get_ref(), "ADMIN_MANUAL", target_user_id, "admin_revoke", pool.get_ref(), "ADMIN_MANUAL", target_user_id, "admin_revoke",
Some(claims.user_id), Some(claims.user_id),
Some(&format!("管理员手动更新付费状态: is_member={}, expires_at={:?}", body.is_member, body.membership_expires_at)), Some(&format!("管理员手动更新付费状态: is_member={}, expires_at={:?}", body.is_member, body.membership_expires_at)),
).await; ).await {
tracing::warn!("插入支付审计日志失败: {}", e);
}
Ok(HttpResponse::Ok().json(serde_json::json!({ Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true, "success": true,
"message": "用户付费状态已更新" "message": "用户付费状态已更新"

View File

@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use reqwest::Client; use reqwest::Client;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::auth::{generate_token, generate_refresh_token}; use crate::auth::{generate_token, generate_refresh_token};
use crate::db; use crate::db;
@@ -83,7 +84,11 @@ pub async fn login(
let openid = match wechat_data.openid { let openid = match wechat_data.openid {
Some(id) => { Some(id) => {
let masked_openid = format!("{}***{}", &id[0..4], &id[id.len() - 4..]); let masked_openid = if id.len() >= 8 {
format!("{}***{}", &id[0..4], &id[id.len() - 4..])
} else {
id.to_string()
};
debug!("获取到用户openid: {}", masked_openid); debug!("获取到用户openid: {}", masked_openid);
id id
} }
@@ -253,6 +258,99 @@ pub struct MockLoginResponse {
pub user_id: i32, pub user_id: i32,
} }
// ===== 访客登录(免微信,直接网站支付用) =====
#[derive(Debug, Serialize)]
pub struct GuestLoginResponse {
pub success: bool,
pub token: String,
pub refresh_token: String,
pub user_id: i32,
pub is_active_member: bool,
pub membership_expires_at: Option<String>,
}
/// POST /api/guest-login
/// 网站独立支付用:无需微信 code直接创建访客用户并返回 JWT
#[post("/api/guest-login")]
pub async fn web_guest_login(
pool: web::Data<PgPool>,
app_state: web::Data<AppState>,
) -> impl Responder {
let guest_openid = format!("web_guest_{}", Uuid::new_v4());
let guest_name = format!("访客_{}", &guest_openid[10..18]);
// 创建访客用户
let user_id = match sqlx::query_as::<_, (i32,)>(
r#"INSERT INTO users (openid, name, type) VALUES ($1, $2, 2)
ON CONFLICT (openid) DO UPDATE SET id = users.id RETURNING id"#,
)
.bind(&guest_openid)
.bind(&guest_name)
.fetch_one(pool.get_ref())
.await
{
Ok((id,)) => id,
Err(e) => {
error!("创建访客用户失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("创建用户失败"));
}
};
// 生成 JWT
let token = match generate_token(user_id, &guest_openid, 2, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("JWT 生成失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("生成令牌失败"));
}
};
// 生成 refresh_token
let refresh_token_str = match generate_refresh_token(user_id, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("refresh_token 生成失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("生成刷新令牌失败"));
}
};
let refresh_expires_at = Utc::now() + Duration::days(7);
if let Err(e) =
db::create_refresh_token(pool.get_ref(), user_id, &refresh_token_str, refresh_expires_at).await
{
warn!("保存 refresh_token 失败(继续): {}", e);
}
// 查询付费状态
let (is_active_member, membership_expires_at): (bool, Option<String>) =
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
"SELECT is_member, membership_expires_at FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
{
Ok(Some((is_member, expires))) => {
let active = is_member && expires.is_none_or(|e| e > Utc::now());
(active, expires.map(|e| e.to_rfc3339()))
}
_ => (false, None),
};
info!("[GUEST LOGIN] user_id={} 访客登录成功", user_id);
HttpResponse::Ok().json(GuestLoginResponse {
success: true,
token,
refresh_token: refresh_token_str,
user_id,
is_active_member,
membership_expires_at,
})
}
/// GET /api/mock-login /// GET /api/mock-login
/// 沙箱测试用:无需微信 code直接返回 JWT /// 沙箱测试用:无需微信 code直接返回 JWT
/// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用 /// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用
@@ -499,7 +597,7 @@ pub async fn web_generate_login_code(
ts % 0xFFFF, ts % 0xFFFF,
&random_part[..4] &random_part[..4]
); );
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase()); let display_code = format!("ASD-{}", short_code[..7].to_uppercase());
let code_for_db = display_code.clone(); let code_for_db = display_code.clone();
// 4. 存入临时表(关联 user_id // 4. 存入临时表(关联 user_id

View File

@@ -23,6 +23,7 @@ pub use admin::admin_force_confirm_order;
pub use admin::admin_refund_order; pub use admin::admin_refund_order;
pub use auth::login; pub use auth::login;
pub use auth::mock_login; pub use auth::mock_login;
pub use auth::web_guest_login;
pub use auth::refresh_token; pub use auth::refresh_token;
pub use auth::web_generate_login_code; pub use auth::web_generate_login_code;
pub use auth::web_login_confirm; pub use auth::web_login_confirm;

View File

@@ -153,9 +153,15 @@ fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool,
use rsa::pkcs8::DecodePublicKey; use rsa::pkcs8::DecodePublicKey;
use rsa::RsaPublicKey; use rsa::RsaPublicKey;
use sha2::Digest; use sha2::Digest;
let public_key = let public_key = RsaPublicKey::from_public_key_pem(public_key_pem).or_else(|_| {
RsaPublicKey::from_public_key_pem(public_key_pem) base64::Engine::decode(&base64::engine::general_purpose::STANDARD, public_key_pem)
.map_err(|e| format!("支付宝公钥解析失败: {}", e))?; .map_err(|e| format!("支付宝公钥 base64 解码失败: {}", e))
.and_then(|der| {
RsaPublicKey::from_public_key_der(&der)
.map_err(|e| format!("支付宝公钥 DER 解析失败: {}", e))
})
})
.map_err(|e| format!("支付宝公钥解析失败: {}", e))?;
let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sign) let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sign)
.map_err(|e| format!("签名 Base64 解码失败: {}", e))?; .map_err(|e| format!("签名 Base64 解码失败: {}", e))?;
let hashed = Sha256::digest(content.as_bytes()); let hashed = Sha256::digest(content.as_bytes());
@@ -165,6 +171,17 @@ fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool,
Ok(true) Ok(true)
} }
/// 检测是否为移动端浏览器
fn is_mobile_ua(user_agent: &str) -> bool {
let ua = user_agent.to_lowercase();
ua.contains("mobile")
|| ua.contains("android")
|| ua.contains("iphone")
|| ua.contains("ipad")
|| ua.contains("phone")
|| ua.contains("harmonyos")
}
// ===== Helper: 生成支付宝支付表单 HTML ===== // ===== Helper: 生成支付宝支付表单 HTML =====
fn build_alipay_form_html( fn build_alipay_form_html(
@@ -174,12 +191,17 @@ fn build_alipay_form_html(
subject: &str, subject: &str,
notify_url: &str, notify_url: &str,
return_url: &str, return_url: &str,
_is_mobile: bool, // 保留参数:待支付宝开通 wap.pay 后启用
) -> Result<String, String> { ) -> Result<String, String> {
// 注意:当前支付宝产品仅开通了 alipay.trade.page.pay电脑网站支付
// 该接口在手机浏览器中也能正常唤起支付宝 APP 或显示移动端页面
let (method, product_code) = ("alipay.trade.page.pay", "FAST_INSTANT_TRADE_PAY");
let biz_content = serde_json::json!({ let biz_content = serde_json::json!({
"out_trade_no": out_trade_no, "out_trade_no": out_trade_no,
"total_amount": total_amount, "total_amount": total_amount,
"subject": subject, "subject": subject,
"product_code": "FAST_INSTANT_TRADE_PAY", "product_code": product_code,
}); });
let biz_content_str = let biz_content_str =
@@ -188,7 +210,7 @@ fn build_alipay_form_html(
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut params: BTreeMap<&str, &str> = BTreeMap::new(); let mut params: BTreeMap<&str, &str> = BTreeMap::new();
params.insert("app_id", &config.app_id); params.insert("app_id", &config.app_id);
params.insert("method", "alipay.trade.page.pay"); params.insert("method", method);
params.insert("format", "JSON"); params.insert("format", "JSON");
params.insert("charset", "utf-8"); params.insert("charset", "utf-8");
params.insert("sign_type", "RSA2"); params.insert("sign_type", "RSA2");
@@ -256,6 +278,20 @@ fn check_payment_maintenance() -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// 构建基础 URL优先使用 APP_BASE_URL 环境变量,否则从请求 Host 头获取
/// 确保支付宝通知回调notify_url正确指向当前处理请求的服务器
fn get_base_url(req: &HttpRequest) -> String {
if let Ok(url) = std::env::var("APP_BASE_URL") {
return url;
}
let host = req
.headers()
.get("Host")
.and_then(|v| v.to_str().ok())
.unwrap_or("xmclassmate.top");
format!("https://{}", host)
}
fn extract_token(req: &HttpRequest) -> Option<String> { fn extract_token(req: &HttpRequest) -> Option<String> {
req.headers() req.headers()
.get("Authorization")? .get("Authorization")?
@@ -265,8 +301,10 @@ fn extract_token(req: &HttpRequest) -> Option<String> {
.map(|s| s.to_string()) .map(|s| s.to_string())
} }
fn get_jwt_secret() -> String { fn get_jwt_secret() -> Result<String, AppError> {
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set") std::env::var("JWT_SECRET").map_err(|_| {
AppError::Internal("JWT_SECRET 环境变量未设置".to_string())
})
} }
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) ===== // ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
@@ -307,9 +345,17 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
.btn-login { display: block; width: 100%; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; margin-bottom: 16px; } .btn-login { display: block; width: 100%; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; margin-bottom: 16px; }
.btn-login:disabled { background: #d9d9d9; cursor: not-allowed; } .btn-login:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; } .btn-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; }
.login-note { font-size: 12px; color: #bbb; margin-top: 12px; } .login-note { font-size: 12px; color: #bbb; margin-top: 12px; }
/* ===== 访客登录 ===== */
.guest-divider { display: flex; align-items: center; margin: 20px 0; color: #ccc; font-size: 13px; gap: 12px; }
.guest-divider::before, .guest-divider::after { content: ""; flex: 1; border-top: 1px solid #eee; }
.btn-guest { display: block; width: 100%; background: #fff; color: #333; border: 1px solid #d9d9d9; border-radius: 12px; padding: 14px; font-size: 15px; cursor: pointer; margin-bottom: 8px; }
.btn-guest:hover { border-color: #07c160; color: #07c160; }
.btn-guest:disabled { background: #f5f5f5; color: #ccc; cursor: not-allowed; border-color: #eee; }
.guest-hint { font-size: 12px; color: #bbb; text-align: center; margin-bottom: 4px; }
/* ===== 会员区域 ===== */ /* ===== 会员区域 ===== */
.paid-section { display: none; } .paid-section { display: none; }
.paid-status { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 36px 20px; text-align: center; } .paid-status { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 36px 20px; text-align: center; }
@@ -371,18 +417,27 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<!-- 登录区(等待授权) --> <!-- 登录区(等待授权) -->
<div class="login-section" id="loginSection"> <div class="login-section" id="loginSection">
<div class="login-card"> <div class="login-card">
<div class="login-icon">🔒</div> <div class="login-icon">🔓</div>
<div class="login-title">等待授权</div> <div class="login-title" id="loginTitle">登录</div>
<div class="login-desc" id="loginDesc">请在微信小程序中<br>点击「去授权」完成登录</div> <div class="login-desc" id="loginDesc">选择登录方式:</div>
<div class="error-msg" id="errorMsg"></div> <div class="error-msg" id="errorMsg"></div>
<!-- 访客登录(网站独立支付) -->
<button class="btn-guest" id="guestBtn" onclick="guestLogin()">📱 访客登录 - 直接选择套餐</button>
<div class="guest-hint">无需微信,自动创建体验账号</div>
<div class="guest-divider">或</div>
<div class="code-display" id="codeDisplay" style="display:none"> <div class="code-display" id="codeDisplay" style="display:none">
<div class="code-label">登录码</div> <div class="code-label">登录码</div>
<div class="code-value" id="codeValue">--</div> <div class="code-value" id="codeValue">--</div>
</div> </div>
<button class="btn-login" id="mpBtn" onclick="showMpLogin()">从微信小程序登录</button>
<div class="login-note">需要已安装「小明计算助手」小程序</div>
<div class="scan-status waiting" id="scanStatus" style="display:none"> <div class="scan-status waiting" id="scanStatus" style="display:none">
等待小程序授权确认... 等待小程序授权确认...
</div> </div>
@@ -498,14 +553,52 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
currentShortCode = urlCode; currentShortCode = urlCode;
document.getElementById('codeValue').textContent = currentShortCode; document.getElementById('codeValue').textContent = currentShortCode;
document.getElementById('codeDisplay').style.display = 'block'; document.getElementById('codeDisplay').style.display = 'block';
document.getElementById('mpBtn').style.display = 'none';
document.getElementById('loginDesc').textContent = '请在微信小程序中输入此登录码:';
document.getElementById('scanStatus').style.display = 'block'; document.getElementById('scanStatus').style.display = 'block';
document.getElementById('loginDesc').style.display = 'none';
// 启动轮询 // 启动轮询
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollLoginStatus, 2000); pollTimer = setInterval(pollLoginStatus, 2000);
} }
})(); })();
// ---- 访客登录(免微信) ----
async function guestLogin() {
var btn = document.getElementById('guestBtn');
btn.disabled = true;
btn.textContent = '登录中...';
try {
var resp = await fetch('/api/guest-login', { method: 'POST' });
var data = await resp.json();
if (data.success) {
jwt = data.token;
isPaidActive = data.is_active_member || false;
paidExpiresAt = data.membership_expires_at || null;
showLoggedIn();
} else {
showError('登录失败,请重试');
btn.disabled = false;
btn.textContent = '📱 访客登录 - 直接选择套餐';
}
} catch(e) {
showError('网络错误,请重试');
btn.disabled = false;
btn.textContent = '📱 访客登录 - 直接选择套餐';
}
}
// ---- 显示微信小程序登录 ----
function showMpLogin() {
document.getElementById('guestBtn').style.display = 'none';
document.querySelector('.guest-divider').style.display = 'none';
document.querySelector('.guest-hint').style.display = 'none';
document.getElementById('mpBtn').style.display = 'none';
document.getElementById('loginDesc').textContent = '请在微信小程序中\n点击「去授权」完成登录';
document.getElementById('codeDisplay').style.display = 'block';
document.getElementById('scanStatus').style.display = 'block';
document.getElementById('loginTitle').textContent = '等待授权';
}
async function checkPaidStatus(token) { async function checkPaidStatus(token) {
try { try {
const resp = await fetch(API_BASE + '/api/user/profile', { const resp = await fetch(API_BASE + '/api/user/profile', {
@@ -657,7 +750,8 @@ pub async fn payment_page(
.or_else(|| query.jwt.clone()) .or_else(|| query.jwt.clone())
.ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; .ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
let claims = crate::auth::verify_token(&token, &get_jwt_secret()) let secret = get_jwt_secret()?;
let claims = crate::auth::verify_token(&token, &secret)
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
let pkg = get_package_info(&query.package_) let pkg = get_package_info(&query.package_)
@@ -712,12 +806,12 @@ pub async fn payment_page(
} }
}; };
let base_url = std::env::var("APP_BASE_URL") let base_url = get_base_url(&req);
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
let notify_url = format!("{}/payment/notify", base_url); let notify_url = format!("{}/payment/notify", base_url);
let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token); let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token);
let Some(config) = AlipayConfig::from_env() else { let Some(config) = AlipayConfig::from_env() else {
check_mock_payment_allowed(&req)?;
let jwt_for_mock = token.clone(); let jwt_for_mock = token.clone();
let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock); let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock);
return Ok(HttpResponse::Ok() return Ok(HttpResponse::Ok()
@@ -727,6 +821,14 @@ pub async fn payment_page(
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
// 根据 User-Agent 选择手机网站支付或电脑网站支付
let mobile = req
.headers()
.get("User-Agent")
.and_then(|v| v.to_str().ok())
.map(is_mobile_ua)
.unwrap_or(false);
match build_alipay_form_html( match build_alipay_form_html(
&config, &config,
&order_no, &order_no,
@@ -734,6 +836,7 @@ pub async fn payment_page(
pkg.display_name, pkg.display_name,
&notify_url, &notify_url,
&return_url, &return_url,
mobile,
) { ) {
Ok(form_html) => Ok(HttpResponse::Ok() Ok(form_html) => Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8") .content_type("text/html; charset=utf-8")
@@ -762,14 +865,14 @@ pub async fn alipay_pay_page(
) -> Result<HttpResponse, AppError> { ) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?; check_payment_maintenance()?;
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
crate::auth::verify_token(&token, &get_jwt_secret()) let secret = get_jwt_secret()?;
crate::auth::verify_token(&token, &secret)
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
let pkg = get_package_info(&query.package_type) let pkg = get_package_info(&query.package_type)
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?; .ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
let base_url = std::env::var("APP_BASE_URL") let base_url = get_base_url(&req);
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
let notify_url = format!("{}/payment/notify", base_url); let notify_url = format!("{}/payment/notify", base_url);
let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no); let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no);
@@ -783,6 +886,13 @@ pub async fn alipay_pay_page(
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
let mobile = req
.headers()
.get("User-Agent")
.and_then(|v| v.to_str().ok())
.map(is_mobile_ua)
.unwrap_or(false);
match build_alipay_form_html( match build_alipay_form_html(
&config, &config,
&query.order_no, &query.order_no,
@@ -790,6 +900,7 @@ pub async fn alipay_pay_page(
pkg.display_name, pkg.display_name,
&notify_url, &notify_url,
&return_url, &return_url,
mobile,
) { ) {
Ok(form_html) => Ok(HttpResponse::Ok() Ok(form_html) => Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8") .content_type("text/html; charset=utf-8")
@@ -847,27 +958,42 @@ fn parse_alipay_form(body: &[u8]) -> BTreeMap<String, String> {
} }
/// 手动 URL 解码percent-decoding /// 手动 URL 解码percent-decoding
/// 注意:`+` 始终解码为空格form-urlencoded 标准),而非仅在 `%` 转义之后
fn urlencoding(s: &str) -> String { fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len()); let input: Vec<u8> = s.bytes().collect();
let mut chars = s.bytes().peekable(); let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
let mut had_escape = false; let mut i = 0;
while let Some(b) = chars.next() { while i < input.len() {
let b = input[i];
if b == b'%' { if b == b'%' {
had_escape = true; let hi = input.get(i + 1).and_then(|&c| hex_val(c));
let hi = chars.next().and_then(hex_val); let lo = input.get(i + 2).and_then(|&c| hex_val(c));
let lo = chars.next().and_then(hex_val);
if let (Some(h), Some(l)) = (hi, lo) { if let (Some(h), Some(l)) = (hi, lo) {
result.push((h << 4 | l) as char); bytes.push(h << 4 | l);
} else { i += 3;
result.push('%'); continue;
} }
} else if b == b'+' && had_escape { // 不完整的转义:原样保留 '%',后续字符照常处理
result.push(' ');
} else {
result.push(b as char);
} }
if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
i += 1;
}
// 按字节收集后统一解码:优先 UTF-8失败回退 GBK兼容支付宝回调编码
match String::from_utf8(bytes.clone()) {
Ok(s) => s,
Err(_) => match encoding_rs::Encoding::for_label(b"gbk") {
Some(enc) => {
let (s, _, _) = enc.decode(&bytes);
s.into_owned()
}
None => String::from_utf8_lossy(&bytes).into_owned(),
},
} }
result
} }
fn hex_val(c: u8) -> Option<u8> { fn hex_val(c: u8) -> Option<u8> {
@@ -917,7 +1043,7 @@ pub async fn alipay_notify(
.join("&"); .join("&");
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) { if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
tracing::warn!("支付宝签名验证失败: {}", e); tracing::error!("支付宝回调签名验证失败: {}", e);
return HttpResponse::Ok().body("fail"); return HttpResponse::Ok().body("fail");
} }
@@ -1103,7 +1229,7 @@ pub async fn alipay_refund_notify(
.join("&"); .join("&");
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) { if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
tracing::warn!("支付宝退款签名验证失败: {}", e); tracing::error!("支付宝退款回调签名验证失败: {}", e);
return HttpResponse::Ok().body("fail"); return HttpResponse::Ok().body("fail");
} }
@@ -1470,8 +1596,15 @@ pub async fn payment_login_status(
} }
// 已确认 → 使用已生成的 token // 已确认 → 使用已生成的 token
let token = token.unwrap(); let token = token.unwrap_or_default();
let user_id = user_id.unwrap(); let Some(user_id) = user_id else {
return Ok(HttpResponse::Ok().json(serde_json::json!({
"success": false, "confirmed": false,
"token": None::<String>,
"is_active_member": false,
"membership_expires_at": None::<String>,
})));
};
let (is_active_member, membership_expires_at): (bool, Option<String>) = let (is_active_member, membership_expires_at): (bool, Option<String>) =
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>( match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
@@ -1503,3 +1636,171 @@ pub async fn payment_login_status(
membership_expires_at, membership_expires_at,
})) }))
} }
#[cfg(test)]
mod tests {
use super::*;
// ===== 套餐定义 =====
#[test]
fn test_get_package_info_known_packages() {
for (pkg, expected_amount, expected_days) in [
("monthly", 590, Some(30)),
("quarterly", 1680, Some(90)),
("half_year", 3190, Some(182)),
("yearly", 6020, Some(365)),
] {
let info = get_package_info(pkg).unwrap_or_else(|| panic!("套餐 {pkg} 应存在"));
assert_eq!(info.amount, expected_amount, "套餐 {pkg} 金额");
assert_eq!(info.days, expected_days, "套餐 {pkg} 天数");
}
}
#[test]
fn test_get_package_info_unknown_package() {
assert!(get_package_info("weekly").is_none());
assert!(get_package_info("lifetime").is_none());
assert!(get_package_info("").is_none());
}
// ===== URL 解码 =====
#[test]
fn test_urlencoding_decodes_percent_escapes() {
assert_eq!(urlencoding("hello%20world"), "hello world");
assert_eq!(urlencoding("%E4%B8%AD%E6%96%87"), "中文");
assert_eq!(urlencoding("a%2Bb"), "a+b");
}
#[test]
fn test_urlencoding_plus_means_space() {
assert_eq!(urlencoding("a+b+c"), "a b c");
}
#[test]
fn test_urlencoding_handles_invalid_escapes() {
// 不完整的 % 序列按字面保留
assert_eq!(urlencoding("100%"), "100%");
assert_eq!(urlencoding("%2"), "%2");
assert_eq!(urlencoding("%zz"), "%zz");
}
#[test]
fn test_hex_val() {
assert_eq!(hex_val(b'0'), Some(0));
assert_eq!(hex_val(b'9'), Some(9));
assert_eq!(hex_val(b'a'), Some(10));
assert_eq!(hex_val(b'f'), Some(15));
assert_eq!(hex_val(b'A'), Some(10));
assert_eq!(hex_val(b'F'), Some(15));
assert_eq!(hex_val(b'g'), None);
assert_eq!(hex_val(b'-'), None);
}
#[test]
fn test_parse_alipay_form_utf8() {
let body = b"out_trade_no=ASD20260101&trade_status=TRADE_SUCCESS&total_amount=5.90";
let map = parse_alipay_form(body);
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD20260101"));
assert_eq!(map.get("trade_status").map(String::as_str), Some("TRADE_SUCCESS"));
assert_eq!(map.get("total_amount").map(String::as_str), Some("5.90"));
}
#[test]
fn test_parse_alipay_form_gbk_fallback() {
// 模拟支付宝回调使用 GBK 编码(含中文 subject
let raw = "out_trade_no=ASD123&subject=包月会员";
let (gbk_bytes, _, _) = encoding_rs::GBK.encode(raw);
let map = parse_alipay_form(&gbk_bytes);
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD123"));
assert_eq!(map.get("subject").map(String::as_str), Some("包月会员"));
}
#[test]
fn test_parse_alipay_form_empty() {
let map = parse_alipay_form(b"");
assert!(map.is_empty());
}
// ===== RSA2 签名 / 验签 =====
fn generate_test_key_pair() -> (String, String) {
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
use rsa::RsaPrivateKey;
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
let private_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
let public_pem = private_key
.to_public_key()
.to_public_key_pem(LineEnding::LF)
.unwrap()
.to_string();
(private_pem, public_pem)
}
#[test]
fn test_rsa2_sign_verify_roundtrip() {
let (private_pem, public_pem) = generate_test_key_pair();
let content = "out_trade_no=ASD123&trade_status=TRADE_SUCCESS";
let sign = rsa2_sign(content, &private_pem).unwrap();
assert!(!sign.is_empty());
assert!(rsa2_verify(content, &sign, &public_pem).unwrap());
}
#[test]
fn test_rsa2_verify_rejects_tampered_content() {
let (private_pem, public_pem) = generate_test_key_pair();
let sign = rsa2_sign("original content", &private_pem).unwrap();
// 调用处语义:`if let Err(e) = rsa2_verify(...)` 即拒绝,篡改内容必须返回 Err
assert!(rsa2_verify("tampered content", &sign, &public_pem).is_err());
}
#[test]
fn test_rsa2_sign_supports_base64_der_key() {
use rsa::pkcs8::EncodePrivateKey;
use rsa::RsaPrivateKey;
// 支付宝密钥工具导出的是 Base64(DER),不是 PEM 文本
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
let der = private_key.to_pkcs8_der().unwrap();
let b64_key = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, der.as_bytes());
let sign = rsa2_sign("hello", &b64_key).unwrap();
assert!(!sign.is_empty());
}
#[test]
fn test_rsa2_sign_rejects_invalid_key() {
assert!(rsa2_sign("content", "not-a-key").is_err());
}
// ===== 移动端 UA 检测 =====
#[test]
fn test_is_mobile_ua() {
assert!(is_mobile_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"));
assert!(is_mobile_ua("Mozilla/5.0 (Linux; Android 14; Pixel 8)"));
assert!(is_mobile_ua("HarmonyOS Browser"));
assert!(is_mobile_ua("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"));
assert!(!is_mobile_ua("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"));
assert!(!is_mobile_ua("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15"));
}
// ===== 订单号格式 =====
#[test]
fn test_order_no_format_is_unique() {
// create_order 内部生成格式ASD{timestamp}{random},这里验证生成逻辑的幂等约束:
// 同一时刻多次生成的订单号不应相同(随机后缀)
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
let no = format!("ASD{}{:04}", chrono::Utc::now().timestamp_millis(), rand::random::<u16>());
assert!(seen.insert(no.clone()), "订单号重复: {no}");
}
}
}

View File

@@ -17,6 +17,7 @@ pub async fn serve_static_files(path: web::Path<String>) -> impl Responder {
Some("gif") => "image/gif", Some("gif") => "image/gif",
Some("svg") => "image/svg+xml", Some("svg") => "image/svg+xml",
Some("webp") => "image/webp", Some("webp") => "image/webp",
Some("txt") => "text/plain",
Some("css") => "text/css", Some("css") => "text/css",
Some("js") => "application/javascript", Some("js") => "application/javascript",
Some("html") => "text/html", Some("html") => "text/html",

View File

@@ -18,6 +18,8 @@ pub async fn get_current_user_profile(
let user = db::get_user_by_id(pool.get_ref(), user_id).await?; let user = db::get_user_by_id(pool.get_ref(), user_id).await?;
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()); let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
let is_active_member = if is_maintenance { let is_active_member = if is_maintenance {
// 维护模式下所有已认证用户视为活跃会员,确保支付故障期间服务可用
// 注意:此路由有 JWT 中间件保护,未认证请求已在前置中间件被拦截
true true
} else { } else {
user.is_member && user.is_member &&

View File

@@ -25,7 +25,7 @@ use handlers::{
get_user_orders, health_check, list_notifications, login, mark_all_read, mark_notification_read, unread_count, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success, get_user_orders, health_check, list_notifications, login, mark_all_read, mark_notification_read, unread_count, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success,
post_weather_data, report_frontend_error, post_weather_data, report_frontend_error,
refresh_token, remove_favorite, root, save_user_profile, serve_static_files, refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
web_generate_login_code, web_login_confirm, web_login_auto_confirm, web_generate_login_code, web_guest_login, web_login_confirm, web_login_auto_confirm,
}; };
use models::AppState; use models::AppState;
@@ -85,6 +85,8 @@ fn create_server_config(
.service(alipay_notify) // POST /payment/notify支付宝异步回调 .service(alipay_notify) // POST /payment/notify支付宝异步回调
.service(alipay_refund_notify) // POST /payment/refund-notify支付宝退款回调 .service(alipay_refund_notify) // POST /payment/refund-notify支付宝退款回调
.service(payment_login_status) // GET /payment/login-status网页轮询 .service(payment_login_status) // GET /payment/login-status网页轮询
// robots.txt无需认证
.route("/robots.txt", web::get().to(|| async { serve_static_files(web::Path::from("robots.txt".to_string())).await }))
// 静态文件(无需认证) // 静态文件(无需认证)
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
// API 接口 // API 接口
@@ -96,6 +98,7 @@ fn create_server_config(
.service(web_generate_login_code) .service(web_generate_login_code)
.service(web_login_confirm) .service(web_login_confirm)
.service(web_login_auto_confirm) .service(web_login_auto_confirm)
.service(web_guest_login) // POST /api/guest-login — 访客登录(免微信)
.service(report_frontend_error) // POST /api/sentry/events .service(report_frontend_error) // POST /api/sentry/events
.service( .service(
web::scope("") web::scope("")
@@ -154,7 +157,7 @@ async fn main() -> std::io::Result<()> {
// Sentry 初始化(仅在 SENTRY_DSN 环境变量已设置时启用) // Sentry 初始化(仅在 SENTRY_DSN 环境变量已设置时启用)
let _sentry_guard = if let Ok(dsn) = std::env::var("SENTRY_DSN") { let _sentry_guard = if let Ok(dsn) = std::env::var("SENTRY_DSN") {
if !dsn.is_empty() { if !dsn.is_empty() {
eprintln!("[SENTRY] Sentry 已初始化 (DSN: {})", dsn); info!("Sentry 已初始化");
Some(sentry::init(( Some(sentry::init((
dsn, dsn,
sentry::ClientOptions { sentry::ClientOptions {
@@ -167,30 +170,14 @@ async fn main() -> std::io::Result<()> {
}, },
))) )))
} else { } else {
eprintln!("[SENTRY] SENTRY_DSN 为空,跳过 Sentry 初始化"); info!("SENTRY_DSN 为空,跳过 Sentry 初始化");
None None
} }
} else { } else {
eprintln!("[SENTRY] 未设置 SENTRY_DSN跳过 Sentry 初始化"); info!("未设置 SENTRY_DSN跳过 Sentry 初始化");
None None
}; };
// 初始化文件日志JSON 格式,带轮转)
let log_dir = std::path::Path::new("./logs");
std::fs::create_dir_all(log_dir).ok(); // 确保日志目录存在
let file_appender = tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("rust-backend")
.filename_suffix("log")
.build(log_dir)
.expect("无法创建日志文件");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// 保持文件 guard 存活(使用 Box 泄漏)
std::mem::forget(_guard);
use std::str::FromStr; use std::str::FromStr;
let default_directive = tracing_subscriber::filter::Directive::from_str("info").unwrap(); let default_directive = tracing_subscriber::filter::Directive::from_str("info").unwrap();
@@ -213,14 +200,14 @@ async fn main() -> std::io::Result<()> {
) )
) )
.with_target(true) .with_target(true)
.with_thread_ids(false) // 生产环境可开启 .with_thread_ids(false)
.with_file(true) .with_file(true)
.with_line_number(true) .with_line_number(true)
.with_writer(non_blocking) .with_ansi(true)
.with_ansi(false) // 文件中不使用 ANSI 颜色 .compact()
.json() // JSON 格式便于 ELK 解析
.init(); .init();
info!("日志系统初始化成功,JSON格式输出到 ./logs/"); info!("日志系统初始化成功,输出到 systemd journal (journalctl)");
info!("日志级别: {}", app_config.rust_log);
let app_state = match AppState::load() { let app_state = match AppState::load() {
Ok(state) => state, Ok(state) => state,
@@ -243,7 +230,9 @@ async fn main() -> std::io::Result<()> {
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(5))
.build() .build()
.expect("创建 HTTP 客户端失败"); .unwrap_or_else(|e| {
panic!("创建 HTTP 客户端失败: {}", e);
});
// 启动时清理:过期订单 + 过期登录码 // 启动时清理:过期订单 + 过期登录码
match db::cleanup_expired_pending_orders(&pool, None).await { match db::cleanup_expired_pending_orders(&pool, None).await {
@@ -255,31 +244,28 @@ async fn main() -> std::io::Result<()> {
Err(e) => warn!("启动时清理 web 登录码失败: {}", e), Err(e) => warn!("启动时清理 web 登录码失败: {}", e),
} }
// 后台定时扫描待支付订单(每 5 分钟)
let pool_clone = pool.clone(); let pool_clone = pool.clone();
actix_web::rt::spawn(async move { actix_web::rt::spawn(async move {
let mut interval = actix_web::rt::time::interval(std::time::Duration::from_secs(300)); let mut interval = actix_web::rt::time::interval(std::time::Duration::from_secs(300));
interval.tick().await; // 跳过立即执行 interval.tick().await; // 跳过立即执行
loop { loop {
interval.tick().await; interval.tick().await;
tracing::info!("[定时任务] 开始扫描待支付订单..."); tracing::info!("[定时任务] 开始...");
match db::check_and_retry_pending_orders(&pool_clone).await {
Ok(confirmed) => {
if confirmed > 0 {
tracing::info!("[定时任务] 自动确认了 {} 个待支付订单", confirmed);
}
}
Err(e) => tracing::warn!("[定时任务] 扫描待支付订单失败: {}", e),
}
// 会员到期前 7 天提醒 // 会员到期前 7 天提醒
let _ = db::check_member_expiry_soon(&pool_clone).await; if let Err(e) = db::check_member_expiry_soon(&pool_clone).await {
tracing::error!("检查会员到期失败: {}", e);
}
// 清理超过 24 小时的过期待支付订单 // 清理超过 24 小时的过期待支付订单
let _ = db::cleanup_expired_pending_orders(&pool_clone, None).await; if let Err(e) = db::cleanup_expired_pending_orders(&pool_clone, None).await {
tracing::error!("清理过期订单失败: {}", e);
}
// 清理过期的 refresh_token // 清理过期的 refresh_token
let _ = db::cleanup_expired_refresh_tokens(&pool_clone).await; if let Err(e) = db::cleanup_expired_refresh_tokens(&pool_clone).await {
tracing::error!("清理过期 refresh token 失败: {}", e);
}
} }
}); });

View File

@@ -20,7 +20,7 @@ pub struct Claims {
pub user_id: i32, pub user_id: i32,
// 自定义字段openid可选 // 自定义字段openid可选
pub openid: String, pub openid: String,
// 自定义字段user_ytpe // 自定义字段user_type
pub user_type: i32, pub user_type: i32,
} }
@@ -51,24 +51,6 @@ impl LoginResponse {
} }
} }
// 兼容旧的 TokenResponse
#[allow(dead_code)]
#[derive(Debug, Serialize, Clone)]
pub struct TokenResponse {
pub success: bool,
pub token: String,
}
#[allow(dead_code)]
impl TokenResponse {
pub fn new(token: String) -> Self {
Self {
success: true,
token,
}
}
}
// Refresh Token 请求 // Refresh Token 请求
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RefreshTokenRequest { pub struct RefreshTokenRequest {
@@ -347,17 +329,6 @@ pub struct AppState {
pub jwt_secret: String, pub jwt_secret: String,
pub wechat_appid: String, pub wechat_appid: String,
pub wechat_secret: String, pub wechat_secret: String,
#[allow(dead_code)]
pub free_user_data_limit: i32,
// ===== 支付宝配置 =====
#[allow(dead_code)]
pub alipay_app_id: Option<String>,
#[allow(dead_code)]
pub alipay_private_key: Option<String>,
#[allow(dead_code)]
pub alipay_alipay_public_key: Option<String>,
#[allow(dead_code)]
pub alipay_gateway: Option<String>,
} }
impl AppState { impl AppState {
@@ -369,14 +340,6 @@ impl AppState {
.map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?, .map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?,
wechat_secret: std::env::var("WECHAT_SECRET") wechat_secret: std::env::var("WECHAT_SECRET")
.map_err(|_| "环境变量WECHAT_SECRET未设置".to_string())?, .map_err(|_| "环境变量WECHAT_SECRET未设置".to_string())?,
free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT")
.map(|v| v.parse().unwrap_or(20))
.unwrap_or(20),
// 支付宝配置(可选,未配置时使用模拟支付)
alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(),
alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(),
alipay_alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok(),
alipay_gateway: std::env::var("ALIPAY_GATEWAY").ok(),
}) })
} }
} }

View File

@@ -40,7 +40,7 @@ impl RateLimiter {
} }
} }
#[allow(dead_code)] #[cfg(test)]
pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String { pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String {
headers headers
.get("X-Forwarded-For") .get("X-Forwarded-For")

11
static/robots.txt Normal file
View File

@@ -0,0 +1,11 @@
User-agent: *
Disallow: /payment
Disallow: /api
Disallow: /weather
Disallow: /static
# Allow root (status page)
Allow: /$
# Allow health check
Allow: /health$

72
tests/payment_e2e_test.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# ===== 全链路支付测试 =====
# 运行: bash tests/payment_e2e_test.sh
set -e
BASE_URL="https://xmclassmate.top"
echo "=========================================="
echo " 全链路支付测试"
echo " 环境: ${BASE_URL}"
echo "=========================================="
# Step 1: 健康检查
echo ""
echo "[1/5] 健康检查..."
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" ${BASE_URL}/health)
echo " GET /health → HTTP ${HEALTH}"
[ "$HEALTH" = "200" ] && echo " ✅ 通过" || { echo " ❌ 失败"; exit 1; }
# Step 2: 支付页面可访问
echo ""
echo "[2/5] 支付页面..."
PAY_CODE=$(curl -s -o /dev/null -w "%{http_code}" ${BASE_URL}/payment)
echo " GET /payment → HTTP ${PAY_CODE}"
[ "$PAY_CODE" = "200" ] && echo " ✅ 通过(维护模式已关闭)" || { echo " ❌ 失败返回503=维护中)"; exit 1; }
# Step 3: 访客登录
echo ""
echo "[3/5] 访客登录..."
LOGIN_RESULT=$(curl -s -X POST ${BASE_URL}/api/guest-login)
JWT=$(echo "$LOGIN_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['token'])" 2>/dev/null)
USER_ID=$(echo "$LOGIN_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['user_id'])" 2>/dev/null)
echo " POST /api/guest-login → user_id=${USER_ID}"
[ -n "$JWT" ] && echo " ✅ JWT 获取成功" || { echo " ❌ JWT 获取失败"; exit 1; }
# Step 4: 用户信息
echo ""
echo "[4/5] 用户信息..."
PROFILE=$(curl -s -H "Authorization: Bearer ${JWT}" ${BASE_URL}/api/user/profile)
echo " GET /api/user/profile → $(echo $PROFILE | python3 -c "import sys,json; print('is_member='+str(json.load(sys.stdin)['data']['is_member']))" 2>/dev/null)"
# Step 5: 生成支付表单
echo ""
echo "[5/5] 支付宝支付表单..."
RESULT=$(curl -s "${BASE_URL}/payment/page?package=monthly&jwt=${JWT}")
if echo "$RESULT" | grep -q "sign"; then
METHOD=$(echo "$RESULT" | grep -oP '(?<=name="method" value=")[^"]+')
APP_ID_CHECK=$(echo "$RESULT" | grep -oP '(?<=name="app_id" value=")[^"]+')
GATEWAY=$(echo "$RESULT" | grep -oP '(?<=action=")[^"]+')
echo " method: ${METHOD}"
echo " app_id: ${APP_ID_CHECK}"
echo " gateway: ${GATEWAY}"
echo " ✅ 支付表单生成成功"
elif echo "$RESULT" | grep -q "失败\|Error"; then
echo " ❌ 支付表单生成失败"
echo "$RESULT" | grep -oP '(?<=<p[^>]*>)[^<]+' | head -3
exit 1
else
echo " ❌ 未知响应"
echo "${RESULT:0:200}"
exit 1
fi
echo ""
echo "=========================================="
echo " 全链路测试通过 ✅"
echo "=========================================="
echo ""
echo "手动验证:"
echo "1. 打开 https://xmclassmate.top/payment"
echo "2. 点击「访客登录」→ 选择套餐 → 支付"
echo "3. 浏览器将跳转到支付宝 alipay.trade.page.pay 页面"