Compare commits
40 Commits
c00e391c9b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a037ea3064 | |||
| 3cce81321f | |||
| 366d62f262 | |||
| 49d740e27c | |||
| 2ce97243ab | |||
| 217e7f8a55 | |||
| 0b0757939b | |||
| 65157475cb | |||
| 3135f790bb | |||
| 6b0871f77d | |||
| a7ffd387d9 | |||
| 2c86219c82 | |||
| 4c5c879583 | |||
| 70bb63be8d | |||
| 9fc5c2429e | |||
| 1a359f8d84 | |||
| d9c9d1dca0 | |||
| 941a8e5350 | |||
| 51752f2d4d | |||
| 797a1438ea | |||
| 79a07a882a | |||
| 5a5953f21f | |||
| 2908d8c86c | |||
| 36d384f8fa | |||
| 917d69c171 | |||
| ae64614342 | |||
| a47502f07e | |||
| fff3877f03 | |||
| 0fa9c9c71c | |||
| ba00fcb1c6 | |||
| 38ee5ccbc6 | |||
| 178008496e | |||
| 6d066c8e82 | |||
| fb224c829f | |||
| b220368544 | |||
| 452728be32 | |||
| ac49ccb552 | |||
| 776d318330 | |||
| 43c349919d | |||
| e672f00b66 |
19
.env
19
.env
@@ -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"
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -556,23 +556,36 @@ let new_expires = base_time + days(pkg_days);
|
||||
| 环境 | 域名 | 端口 | 远程目录 |
|
||||
|------|------|------|---------|
|
||||
| 测试 | dev.xmclassmate.top | 8080 | /root/rust/rust_backend_dev |
|
||||
| 生产 | xmclassmate.top | 4433 | /root/rust/rust_backend |
|
||||
| 生产(蓝) | xmclassmate.top | 4433 | /root/rust/rust_backend_blue |
|
||||
| 生产(绿) | xmclassmate.top | 4434 | /root/rust/rust_backend_green |
|
||||
|
||||
**服务器**:`47.109.203.92` (Aliyun, Debian 13)
|
||||
**SSH 用户**:`deploy`(有 sudo 权限)
|
||||
**SSH 别名**:`aliyun-server`(`~/.ssh/config` 中配置)
|
||||
**nginx**:原生 nginx(非 Docker/OpenResty),配置位于 `/etc/nginx/sites-available/`
|
||||
**PostgreSQL**:Docker 容器 `postgres:17.6-alpine`(host 网络模式)
|
||||
|
||||
### systemd 服务
|
||||
|
||||
| 环境 | systemd unit | 工作目录 | APP_ENV |
|
||||
|------|-------------|---------|---------|
|
||||
| 测试 | `rust-backend-dev.service` | `/root/rust/rust_backend_dev` | `development` |
|
||||
| 生产 | `rust-backend.service` | `/root/rust/rust_backend` | (使用 .env) |
|
||||
| 环境 | systemd unit | 工作目录 | SERVER_PORT |
|
||||
|------|-------------|---------|-------------|
|
||||
| 测试 | `rust-backend-dev.service` | `/root/rust/rust_backend_dev` | 8080 |
|
||||
| 生产蓝 | `rust-backend-blue.service` | `/root/rust/rust_backend_blue` | 4433 |
|
||||
| 生产绿 | `rust-backend-green.service` | `/root/rust/rust_backend_green` | 4434 |
|
||||
|
||||
> ⚠️ systemd 配置使用 `EnvironmentFile=` 加载 `.env` 文件(生产、开发环境均如此)
|
||||
> 生产采用蓝绿部署:nginx 通过 `proxy_pass` 将流量路由到活跃环境(默认 :4433 = 蓝色)。
|
||||
> `deploy.sh` 自动检测活跃环境并部署到空闲环境,部署成功后自动切换 nginx 代理。
|
||||
|
||||
> ⚠️ systemd 配置使用 `Environment=` 直接加载环境变量(非 `EnvironmentFile`)。
|
||||
> 注意:`EnvironmentFile` 不支持多行值,多行 PEM 密钥需 base64 编码或单行格式
|
||||
|
||||
### 部署命令
|
||||
|
||||
```bash
|
||||
./deploy.sh development # 部署到测试服务器
|
||||
./deploy.sh production # 部署到生产服务器
|
||||
./deploy.sh development # 部署到测试服务器
|
||||
./deploy.sh production # 生产蓝绿部署(自动检测目标)
|
||||
./deploy.sh production --target blue # 指定部署到蓝色
|
||||
./deploy.sh production --target green # 指定部署到绿色
|
||||
```
|
||||
|
||||
**deploy.sh 选项:**
|
||||
@@ -584,6 +597,8 @@ let new_expires = base_time + days(pkg_days);
|
||||
| `--skip-tests` | 跳过部署后测试 |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--backup-list` | 列出可用备份 |
|
||||
| `--target blue\|green` | 蓝绿部署目标 |
|
||||
| `--remote-host IP` | 部署目标服务器(默认 `aliyun-server`) |
|
||||
| `--init-env` | 初始化远程 .env(生成随机 JWT_SECRET + 推送模板到服务器) |
|
||||
| `--help, -h` | 显示帮助信息 |
|
||||
|
||||
|
||||
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -544,15 +544,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -2341,6 +2332,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"encoding_rs",
|
||||
"include_dir",
|
||||
"jsonwebtoken",
|
||||
"openssl",
|
||||
@@ -2357,7 +2349,6 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3303,18 +3294,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
|
||||
@@ -9,7 +9,6 @@ chrono = {version = "0.4.41", features=["serde"]}
|
||||
dotenvy = "0.15.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tracing-appender = "0.2"
|
||||
sentry = "0.48"
|
||||
sentry-actix = "0.48"
|
||||
sentry-tracing = "0.48"
|
||||
@@ -27,6 +26,7 @@ base64 = "0.22"
|
||||
rsa = { version = "0.9", features = ["pem", "sha2"] }
|
||||
pkcs8 = "0.10"
|
||||
sha2 = "0.10"
|
||||
encoding_rs = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -280,18 +280,20 @@ DROP TABLE IF EXISTS invitation_codes;
|
||||
- 完成时间:2026-04-15
|
||||
|
||||
### 1.2 数据库操作优化
|
||||
- [ ] **重复查询问题**
|
||||
- [x] **重复查询问题** ✅
|
||||
- 位置:`insert_weather_data` 中多次查询用户信息
|
||||
- 改进:合并查询或使用缓存
|
||||
- 影响:减少数据库负载
|
||||
- 状态:`get_user_quota` 已合并为单次查询(见 `db.rs` 顶部注释);2026-08-11 复核无遗留 N+1
|
||||
|
||||
- [ ] **缺少数据库索引**
|
||||
- 现状:未见索引定义
|
||||
- 改进:添加常用查询字段索引
|
||||
- [x] **缺少数据库索引** ✅
|
||||
- 状态:`migrations/006_add_performance_indexes.sql` 已添加
|
||||
```sql
|
||||
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_payment_orders_status ON payment_orders(status);
|
||||
```
|
||||
|
||||
### 1.3 错误处理改进
|
||||
@@ -422,13 +424,13 @@ DROP TABLE IF EXISTS invitation_codes;
|
||||
## 五、开发体验改进
|
||||
|
||||
### 5.1 测试覆盖
|
||||
- [ ] **仅有一个基础编译测试**
|
||||
- 现状:`tests/integration_test.rs` 只有 `assert_eq!(2 + 2, 4)`
|
||||
- 改进:添加完整的测试套件
|
||||
- 类型:
|
||||
- 单元测试:工具函数、数据结构
|
||||
- 集成测试:API 端点
|
||||
- 数据库测试:CRUD 操作
|
||||
- [~] **基础测试不足**(2026-08-11 已补充单元测试)
|
||||
- 现状:原仅有 `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 检测、订单号唯一性
|
||||
- `src/rate_limiter.rs`:限流阈值、按客户端隔离、IP 提取(已有)
|
||||
- 待做:API 集成测试(需要测试数据库)
|
||||
|
||||
- [ ] **缺少 API 测试**
|
||||
- 需求:验证 API 行为
|
||||
@@ -1369,6 +1371,9 @@ async onRedeemCode() {
|
||||
|
||||
## 十四、公告系统
|
||||
|
||||
> **状态**:✅ 已实现(2026-05,`handlers/notifications.rs` + 迁移 `011_add_notifications.sql`,前端 `pkg-extra/notifications` 页面)
|
||||
> 下方为原始设计文档,保留供参考。
|
||||
|
||||
### 功能概述
|
||||
|
||||
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。
|
||||
|
||||
67
deploy.sh
67
deploy.sh
@@ -16,6 +16,8 @@
|
||||
# --post-logs 部署后显示日志
|
||||
# --init-env 初始化 systemd service 模板
|
||||
# --deploy-service 上传 systemd service 文件到服务器
|
||||
# --target blue|green 蓝绿部署目标(默认单实例)
|
||||
# --remote-host IP 部署目标服务器(默认 1panel-server)
|
||||
# ===========================================
|
||||
|
||||
set -euo pipefail
|
||||
@@ -27,7 +29,7 @@ source "${SCRIPT_DIR}/lib/test.sh"
|
||||
# ---------- 默认值 ----------
|
||||
PROJECT_NAME="rust-backend"
|
||||
REMOTE_USER="root"
|
||||
REMOTE_HOST="1panel-server"
|
||||
REMOTE_HOST="aliyun-server"
|
||||
APP_ENV="production"
|
||||
|
||||
DRY_RUN=false; SKIP_CONFIRM=false; SKIP_TESTS=false
|
||||
@@ -53,6 +55,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--init-env) INIT_ENV=true ;;
|
||||
--deploy-service) DEPLOY_SERVICE=true ;;
|
||||
--target) BG_TARGET="${2:-}"; shift ;;
|
||||
--remote-host) REMOTE_HOST="${2:-}"; shift ;;
|
||||
--help|-h)
|
||||
echo "用法: $0 [development|production] [options]"
|
||||
echo ""
|
||||
@@ -60,7 +63,8 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --dry-run 预览模式"
|
||||
echo " --yes, -y 跳过确认"
|
||||
echo " --skip-tests 跳过部署后测试"
|
||||
echo " --target blue|green 蓝绿部署目标(仅 production)"
|
||||
echo " --target blue|green 蓝绿部署目标"
|
||||
echo " --remote-host IP 部署目标服务器(默认 aliyun-server)"
|
||||
echo " --rollback 回滚到指定备份"
|
||||
echo " --backup-list 列出可用备份"
|
||||
echo " --logs [N] 查看后端日志(默认50行)"
|
||||
@@ -79,11 +83,40 @@ while [[ $# -gt 0 ]]; do
|
||||
esac; shift
|
||||
done
|
||||
|
||||
# ---------- 蓝绿目标检测 ----------
|
||||
if [ "${APP_ENV}" = "production" ] && [ -n "$BG_TARGET" ]; then
|
||||
case "$BG_TARGET" in
|
||||
blue) BG_PORT="4433"; BG_DIR="rust_backend_blue"; BG_SVC="rust-backend-blue.service" ;;
|
||||
green) BG_PORT="4434"; BG_DIR="rust_backend_green"; BG_SVC="rust-backend-green.service" ;;
|
||||
# 提前建立 SSH 连接(蓝绿检测需要 remote())
|
||||
ssh_connect
|
||||
|
||||
# ---------- 蓝绿目标自动检测 + 配置 ----------
|
||||
# 优先级: 手动 --target > 自动检测
|
||||
detect_blue_green() {
|
||||
local proxy_conf; proxy_conf=$(get_proxy_conf)
|
||||
local active
|
||||
active=$(remote "sudo cat ${proxy_conf} 2>/dev/null" 2>/dev/null | grep -oP '127\.0\.0\.1:\d+' | head -1 || echo "")
|
||||
|
||||
case "${APP_ENV}:${active}" in
|
||||
*:4433) echo "blue" ;;
|
||||
*:4434) echo "green" ;;
|
||||
*:8083) echo "blue" ;;
|
||||
*:8084) echo "green" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -z "$BG_TARGET" ] && [ "${APP_ENV}" = "production" ]; then
|
||||
# 自动检测:部署到待命环境(仅生产环境支持蓝绿)
|
||||
active_target=$(detect_blue_green)
|
||||
if [ -n "$active_target" ]; then
|
||||
BG_TARGET=$([ "$active_target" = "blue" ] && echo "green" || echo "blue")
|
||||
log_info "自动检测: 当前活动 ${active_target},部署到 ${BG_TARGET}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$BG_TARGET" ]; then
|
||||
case "${APP_ENV}:${BG_TARGET}" in
|
||||
production:blue) BG_PORT="4433"; BG_DIR="rust_backend_blue"; BG_SVC="rust-backend-blue.service" ;;
|
||||
production:green) BG_PORT="4434"; BG_DIR="rust_backend_green"; BG_SVC="rust-backend-green.service" ;;
|
||||
development:blue) BG_PORT="8083"; BG_DIR="rust_backend_dev_blue"; BG_SVC="rust-backend-dev-blue.service" ;;
|
||||
development:green)BG_PORT="8084"; BG_DIR="rust_backend_dev_green";BG_SVC="rust-backend-dev-green.service" ;;
|
||||
*) echo "错误: --target 只能是 blue 或 green"; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
@@ -91,11 +124,17 @@ fi
|
||||
# ---------- 环境配置 ----------
|
||||
case "${APP_ENV}" in
|
||||
development)
|
||||
REMOTE_DIR="/root/rust/rust_backend_dev"
|
||||
SERVICE_NAME="rust-backend-dev.service"
|
||||
if [ -n "$BG_TARGET" ]; then
|
||||
REMOTE_DIR="/root/rust/${BG_DIR}"
|
||||
SERVICE_NAME="${BG_SVC}"
|
||||
BACKEND_PORT="${BG_PORT}"
|
||||
else
|
||||
REMOTE_DIR="/root/rust/rust_backend_dev"
|
||||
SERVICE_NAME="rust-backend-dev.service"
|
||||
BACKEND_PORT="8080"
|
||||
fi
|
||||
TEST_DOMAIN="https://dev.xmclassmate.top"
|
||||
BACKEND_PORT="8080"
|
||||
DB_CONTAINER="1Panel-postgresql-FtMo"
|
||||
DB_CONTAINER="postgres"
|
||||
DB_NAME="milkydata_dev"
|
||||
DB_USER="milkydata" ;;
|
||||
production)
|
||||
@@ -109,14 +148,13 @@ case "${APP_ENV}" in
|
||||
BACKEND_PORT="4433"
|
||||
fi
|
||||
TEST_DOMAIN="https://xmclassmate.top"
|
||||
DB_CONTAINER="1Panel-postgresql-FtMo"
|
||||
DB_CONTAINER="postgres"
|
||||
DB_NAME="milkydata"
|
||||
DB_USER="milkydata" ;;
|
||||
esac
|
||||
|
||||
# ---------- 快捷命令 ----------
|
||||
GIT_VERSION=$(get_git_info)
|
||||
ssh_connect
|
||||
|
||||
if [ "$LIST_BACKUPS" = true ]; then list_backups; exit 0; fi
|
||||
if [ "$INIT_ENV" = true ]; then init_env; exit 0; fi
|
||||
@@ -129,6 +167,7 @@ if [ "$ROLLBACK" = true ]; then rollback; exit 0; fi
|
||||
# ---------- 主部署流程 ----------
|
||||
show_deploy_info
|
||||
confirm_deploy
|
||||
check_not_active
|
||||
check_dependencies
|
||||
check_service_file
|
||||
check_ssl_expiry
|
||||
@@ -144,8 +183,8 @@ upload_script "清理脚本" scripts/cleanup_refresh_tokens.sh scripts/cleanup_r
|
||||
upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh
|
||||
|
||||
deploy_service_file
|
||||
run_migrations # Moved BEFORE restart
|
||||
restart_service
|
||||
run_migrations
|
||||
|
||||
if run_tests; then
|
||||
log_to_file "DEPLOY success"
|
||||
|
||||
@@ -5,8 +5,8 @@ Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
User=rust-backend
|
||||
Group=rust-backend
|
||||
WorkingDirectory=/root/rust/rust_backend_blue
|
||||
ExecStart=/root/rust/rust_backend_blue/rust-backend
|
||||
Restart=always
|
||||
@@ -21,12 +21,9 @@ Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo
|
||||
Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem
|
||||
Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem
|
||||
Environment=RUST_LOG=info
|
||||
Environment=APP_VERSION=0.2.7
|
||||
Environment=APP_VERSION=0.3.0
|
||||
Environment=FREE_USER_DATA_LIMIT=20
|
||||
Environment=ALIPAY_APP_ID=2021006148681106
|
||||
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
|
||||
EnvironmentFile=/root/rust/rust_backend_blue/.env
|
||||
Environment=APP_BASE_URL=https://xmclassmate.top
|
||||
Environment=SERVER_PORT=4433
|
||||
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1
|
||||
|
||||
@@ -5,8 +5,8 @@ Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
User=rust-backend
|
||||
Group=rust-backend
|
||||
WorkingDirectory=/root/rust/rust_backend_dev
|
||||
ExecStart=/root/rust/rust_backend_dev/rust-backend
|
||||
Restart=always
|
||||
@@ -17,20 +17,15 @@ StandardError=journal
|
||||
Environment=DATABASE_URL=postgres://milkydata:44n6FdB8CdDAk5rk@127.0.0.1:5432/milkydata_dev
|
||||
Environment=WECHAT_APPID=wx5b00eb90621802f7
|
||||
Environment=WECHAT_SECRET=494efc513faa310bfba588bda2849bfd
|
||||
Environment=JWT_SECRET=your_super_secret_key
|
||||
Environment=JWT_SECRET=U8ZQKiyRN3IdkM705Rr6uLRpZ56w3z8Fw7aA6ib9oUk=
|
||||
Environment=SSL_KEY_PATH=/etc/ssl/private/private.key
|
||||
Environment=SSL_CERT_PATH=/etc/ssl/certs/full_chain.pem
|
||||
Environment=RUST_LOG=info,rust_backend=debug
|
||||
Environment=APP_VERSION=0.2.0
|
||||
Environment=FREE_USER_DATA_LIMIT=50
|
||||
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1
|
||||
Environment=APP_BASE_URL=https://xmclassmate.top
|
||||
Environment=ALIPAY_APP_ID=2021006148681106
|
||||
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=
|
||||
Environment=APP_BASE_URL=https://dev.xmclassmate.top
|
||||
EnvironmentFile=/root/rust/rust_backend_dev/.env
|
||||
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
@@ -5,8 +5,8 @@ Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
User=rust-backend
|
||||
Group=rust-backend
|
||||
WorkingDirectory=/root/rust/rust_backend_green
|
||||
ExecStart=/root/rust/rust_backend_green/rust-backend
|
||||
Restart=always
|
||||
@@ -21,12 +21,9 @@ Environment=JWT_SECRET=tribesman-pavestone-hatchling-wrangle-silly-embargo
|
||||
Environment=SSL_KEY_PATH=/etc/ssl/rust_backend/privkey.pem
|
||||
Environment=SSL_CERT_PATH=/etc/ssl/rust_backend/fullchain.pem
|
||||
Environment=RUST_LOG=info
|
||||
Environment=APP_VERSION=0.2.7
|
||||
Environment=APP_VERSION=0.3.0
|
||||
Environment=FREE_USER_DATA_LIMIT=20
|
||||
Environment=ALIPAY_APP_ID=2021006148681106
|
||||
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
|
||||
EnvironmentFile=/root/rust/rust_backend_green/.env
|
||||
Environment=APP_BASE_URL=https://xmclassmate.top
|
||||
Environment=SERVER_PORT=4434
|
||||
Environment=SENTRY_DSN=http://f02c7cce1dc746e2ad41b11397dab0db@127.0.0.1:9000/1
|
||||
|
||||
@@ -5,8 +5,8 @@ Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
User=rust-backend
|
||||
Group=rust-backend
|
||||
WorkingDirectory=/root/rust/rust_backend
|
||||
ExecStart=/root/rust/rust_backend/rust-backend
|
||||
Restart=always
|
||||
@@ -25,10 +25,7 @@ Environment=RUST_LOG=info
|
||||
Environment=APP_VERSION=0.3.0
|
||||
Environment=FREE_USER_DATA_LIMIT=20
|
||||
Environment=APP_BASE_URL=https://xmclassmate.top
|
||||
Environment=ALIPAY_APP_ID=your_alipay_app_id
|
||||
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
|
||||
EnvironmentFile=/root/rust/rust_backend/.env
|
||||
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
100
docs/BANDWIDTH-BENCHMARK.md
Normal file
100
docs/BANDWIDTH-BENCHMARK.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 带宽负载测试报告
|
||||
|
||||
> 验证 3Mbps 服务器带宽是否能支撑 1000 用户
|
||||
|
||||
## 测试工具
|
||||
|
||||
测试工具位于 `docs/bench/` 目录:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `bandwidth_bench.sh` | 一键运行完整基准测试(依赖 `oha`) |
|
||||
| `bandwidth_analysis.rs` | Rust 编写的带宽推算工具 |
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
cargo install oha --version "1.4.7"
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 一键运行所有基准测试
|
||||
bash docs/bench/bandwidth_bench.sh
|
||||
|
||||
# 编译并运行 Rust 分析工具
|
||||
rustc --edition 2024 docs/bench/bandwidth_analysis.rs -o /tmp/bandwidth_test
|
||||
/tmp/bandwidth_test
|
||||
```
|
||||
|
||||
## 实测数据(2026-07-03)
|
||||
|
||||
### API 端点响应大小
|
||||
|
||||
| 端点 | 响应大小 | HTTP 状态 |
|
||||
|------|----------|-----------|
|
||||
| `GET /health` | 38 B | 200 |
|
||||
| `GET /` | 6,409 B | 200 |
|
||||
| `GET /payment` | 14,499 B | 200 |
|
||||
| `POST /api/login` | ~400 B | 200 |
|
||||
| `GET /weather?page=1` | ~600 B | 200 |
|
||||
| `POST /api/post-weather-data` | ~120 B | 200 |
|
||||
| `GET /api/user/profile` | ~500 B | 200 |
|
||||
| 静态文件 (CSS/JS) | ~7-15 KB | 200 |
|
||||
|
||||
### 压力测试(oha)
|
||||
|
||||
| 端点 | 并发 | 吞吐量 | 带宽占用 | 平均延迟 |
|
||||
|------|------|--------|---------|---------|
|
||||
| `GET /health` | 100 | 490 req/s | 18 KB/s | 190 ms |
|
||||
| `GET /health` (Keep-Alive) | 100 | 516 req/s | 19 KB/s | 190 ms |
|
||||
| `GET /` (6KB HTML) | 50 | 161 req/s | 322 KB/s | 310 ms |
|
||||
| `GET /payment` (14KB) | 50 | 135 req/s | 1.9 MB/s 🔴 | 340 ms |
|
||||
| `GET /api/user/profile` (2KB) | 50 | 202 req/s | 440 KB/s | 250 ms |
|
||||
|
||||
> 所有测试成功率 100%,`oha` 在 `dev.xmclassmate.top` 上运行。
|
||||
> 🔴 表示超出 3Mbps (384 KB/s) 带宽限制。
|
||||
|
||||
## 带宽估算(3Mbps = 384 KB/s)
|
||||
|
||||
### 用户模型
|
||||
|
||||
- 注册用户: 1000
|
||||
- 同时在线 (10%): 100 人
|
||||
- 每个用户每小时 ~7 次 API 请求
|
||||
|
||||
### 日常负载
|
||||
|
||||
| 端点 | 单次大小 | 1000 用户/小时 | 带宽 |
|
||||
|------|---------|---------------|------|
|
||||
| GET /health | 38 B | 2,000 req | 0.02 KB/s |
|
||||
| GET / | 6.4 KB | 200 req | 0.36 KB/s |
|
||||
| POST /api/login | 400 B | 100 req | 0.01 KB/s |
|
||||
| GET /weather | 600 B | 3,000 req | 0.50 KB/s |
|
||||
| POST /api/post-weather | 120 B | 500 req | 0.02 KB/s |
|
||||
| **总计** | | | **~40 KB/s = 0.3 Mbps** |
|
||||
|
||||
### 结论
|
||||
|
||||
| 场景 | 带宽 | 占 3Mbps |
|
||||
|------|------|---------|
|
||||
| 日常负载 | ~40 KB/s | **~10%** |
|
||||
| 峰值 ×3 | ~120 KB/s | **~30%** |
|
||||
| 理论 API 极限 | 8000+ req/s | **100%** |
|
||||
|
||||
**3Mbps 对 1000 用户完全够用,余量 ~70-90%。**
|
||||
|
||||
### 唯一瓶颈
|
||||
|
||||
大 HTML 页面(`/payment` 14.5KB)在突发大量访问时可能占满带宽:
|
||||
- 26 个并发即可占满 3Mbps
|
||||
- 日常用户不走此页面(仅管理员/付费网页访问)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 路径 |
|
||||
|------|------|
|
||||
| 测试套件 | `docs/bench/bandwidth_bench.sh` |
|
||||
| 分析工具 | `docs/bench/bandwidth_analysis.rs` |
|
||||
| 本文档 | `docs/BANDWIDTH-BENCHMARK.md` |
|
||||
@@ -1,4 +1,9 @@
|
||||
# 蓝绿部署计划
|
||||
# 蓝绿部署(新服务器)
|
||||
|
||||
> **服务器**:47.109.203.92 (Aliyun, Debian 13)
|
||||
> **nginx**:原生 nginx(非 Docker/OpenResty)
|
||||
> **PostgreSQL**:Docker 容器 `postgres:17.6-alpine`
|
||||
> **部署用户**:`deploy`(有 sudo 权限)
|
||||
|
||||
## 域名与环境映射
|
||||
|
||||
@@ -28,7 +33,7 @@ xmclassmate.top 生产 :4433 / :4434 milkydata
|
||||
```
|
||||
dev.xmclassmate.top
|
||||
│
|
||||
OpenResty → :8080(单实例)
|
||||
nginx → :8080(单实例)
|
||||
│
|
||||
milkydata_dev
|
||||
```
|
||||
@@ -48,9 +53,9 @@ dev.xmclassmate.top
|
||||
```
|
||||
xmclassmate.top
|
||||
│
|
||||
OpenResty proxy
|
||||
nginx proxy(直接路由)
|
||||
/ \
|
||||
蓝色 :4433 绿色 :4434
|
||||
蓝色 :4433 绿色 :4434
|
||||
(active) (idle)
|
||||
\ /
|
||||
milkydata(共享)
|
||||
@@ -70,16 +75,18 @@ xmclassmate.top
|
||||
|
||||
两个 service 的 `DATABASE_URL` 都指向 `milkydata`,仅端口不同。
|
||||
|
||||
**OpenResty proxy 配置:**
|
||||
**nginx proxy 配置:**
|
||||
|
||||
```
|
||||
# /www/sites/xmclassmate.top/proxy/root.conf
|
||||
location ^~ / {
|
||||
proxy_pass http://127.0.0.1:4433; # deploy.sh 切换此端口
|
||||
proxy_pass https://127.0.0.1:4433; # deploy.sh 切换此端口
|
||||
}
|
||||
```
|
||||
|
||||
切换时 `sed` 替换端口,`nginx -s reload`。
|
||||
> 生产环境使用 HTTPS proxy(`proxy_pass https://...`),因为后端自身处理 TLS。
|
||||
|
||||
切换时 `sed` 替换端口,`sudo nginx -s reload`。
|
||||
|
||||
### 部署流程
|
||||
|
||||
@@ -110,9 +117,11 @@ location ^~ / {
|
||||
### 切换脚本
|
||||
|
||||
```bash
|
||||
./scripts/switch-env.sh --env prod --switch green
|
||||
./scripts/switch-env.sh --switch green
|
||||
# 或切回蓝色
|
||||
./scripts/switch-env.sh --env prod --switch blue
|
||||
./scripts/switch-env.sh --switch blue
|
||||
# 查看当前状态
|
||||
./scripts/switch-env.sh --status
|
||||
```
|
||||
|
||||
### 回滚
|
||||
@@ -203,11 +212,11 @@ cargo build --release && rsync ... systemctl restart ← ❌
|
||||
| # | 步骤 | 说明 | 状态 |
|
||||
|---|------|------|------|
|
||||
| 1 | 数据迁移 | 已完成 | ✅ |
|
||||
| 2 | 准备生产 service 文件(填入实际密钥) | 待完成 | ⬜ |
|
||||
| 3 | 部署新代码到 `:4433` | 待完成 | ⬜ |
|
||||
| 2 | 准备生产 service 文件 | 已完成 | ✅ |
|
||||
| 3 | 部署新代码到 `:4433` 和 `:4434` | 已完成 | ✅ |
|
||||
| 4 | `env.ts` 设 `CURRENT_ENV = 'production'` | 已完成 | ✅ |
|
||||
| 5 | 微信开发者工具上传小程序 | 待完成 | ⬜ |
|
||||
| 6 | 1Panel 确认 `proxy_pass` 指向 `:4433` | 已完成 | ✅ |
|
||||
| 5 | 微信开发者工具上传小程序 | 已完成 | ✅ |
|
||||
| 6 | 确认 nginx `proxy_pass` 指向 :4433 | 已完成 | ✅ |
|
||||
|
||||
## 实施状态
|
||||
|
||||
@@ -215,11 +224,11 @@ cargo build --release && rsync ... systemctl restart ← ❌
|
||||
|
||||
| 组件 | 状态 |
|
||||
|------|------|
|
||||
| Blue service (:4433) | ✅ 运行中,ConfigID `71ba8887` |
|
||||
| Green service (:4434) | ✅ 运行中,活动中 |
|
||||
| `deploy.sh --target blue\|green` | ✅ 已支持 |
|
||||
| `switch-env.sh --switch blue\|green` | ✅ 自动切换 |
|
||||
| OpenResty proxy | ✅ 指向活动中环境 |
|
||||
| Blue service (:4433) | ✅ 运行中,当前活跃 |
|
||||
| Green service (:4434) | ✅ 运行中,待命中 |
|
||||
| `deploy.sh production --target blue/green` | ✅ 已支持 |
|
||||
| `switch-env.sh --switch blue/green` | ✅ 自动切换 nginx proxy |
|
||||
| nginx proxy | ✅ 指向活动中环境 |
|
||||
|
||||
### 下次部署流程
|
||||
|
||||
|
||||
167
docs/MAINTENANCE-MODE-RUNBOOK.md
Normal file
167
docs/MAINTENANCE-MODE-RUNBOOK.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 支付维护模式 Runbook
|
||||
|
||||
> 当支付宝支付失效时的应急操作手册。涵盖开启/关闭维护模式、切换到 Mock 支付、修复支付宝配置三种方案。
|
||||
|
||||
---
|
||||
|
||||
## 快速判断
|
||||
|
||||
| 症状 | 应急方案 |
|
||||
|------|---------|
|
||||
| 支付宝正在维护/报错,需要临时止血 | **维护模式**(方案 A) |
|
||||
| 只是测试环境或无所谓真实收款 | **Mock 支付**(方案 B) |
|
||||
| 配置变更导致的问题(密钥过期、域名变更) | **修复支付宝配置**(方案 C) |
|
||||
|
||||
---
|
||||
|
||||
## 方案 A:开启维护模式(止血)
|
||||
|
||||
所有支付接口返回 503 "支付系统维护中,请稍后再试"。
|
||||
|
||||
### 前置条件
|
||||
|
||||
SSH 免密码登录到 `root@1panel-server`。
|
||||
|
||||
```bash
|
||||
ssh root@1panel-server
|
||||
```
|
||||
|
||||
### ⚠️ 关键陷阱:生产服务器使用蓝绿部署
|
||||
|
||||
| Service | 端口 | 目录 | 是否承担生产流量 |
|
||||
|---------|------|------|----------------|
|
||||
| `rust-backend-green.service` | **4434** | `/root/rust/rust_backend_green/` | ✅ nginx 代理指向这里 |
|
||||
| `rust-backend-blue.service` | 4433 | `/root/rust/rust_backend_blue/` | ❌ 备用 |
|
||||
| `rust-backend.service` | 3000 | `/root/rust/rust_backend/` | ❌ 旧版二进制,不处理生产流量 |
|
||||
|
||||
**务必修改 green + blue 两个 service,不能只改 `rust-backend.service`**。
|
||||
|
||||
Nginx 配置位置:`/opt/1panel/apps/openresty/openresty/www/sites/xmclassmate.top/proxy/root.conf`
|
||||
|
||||
### 操作步骤
|
||||
|
||||
```bash
|
||||
# 1. 在 green(当前活跃)和 blue(备用)中都添加
|
||||
sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/rust-backend-green.service
|
||||
sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/rust-backend-blue.service
|
||||
|
||||
# 2. 重新加载并重启
|
||||
systemctl daemon-reload
|
||||
systemctl restart rust-backend-green.service
|
||||
systemctl restart rust-backend-blue.service
|
||||
|
||||
# 3. 验证
|
||||
curl -s -w "\nHTTP %{http_code}\n" https://xmclassmate.top/payment
|
||||
# 预期:HTTP 503 + HTML 中显示"支付系统维护中"
|
||||
```
|
||||
|
||||
### 恢复
|
||||
|
||||
```bash
|
||||
ssh root@1panel-server
|
||||
sed -i '/PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/rust-backend-green.service
|
||||
sed -i '/PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/rust-backend-blue.service
|
||||
systemctl daemon-reload
|
||||
systemctl restart rust-backend-green.service
|
||||
systemctl restart rust-backend-blue.service
|
||||
|
||||
# 验证
|
||||
curl -s -w "\nHTTP %{http_code}\n" https://xmclassmate.top/payment
|
||||
# 预期:HTTP 200 + 套餐选择页 HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案 B:切换到 Mock 支付(快速恢复可用)
|
||||
|
||||
### 原理
|
||||
|
||||
当代码检测到 `ALIPAY_*` 环境变量不存在时,自动启用 Mock 支付(无需配置 `MOCK_PAY_ENABLED=true`)。
|
||||
|
||||
### 操作步骤
|
||||
|
||||
```bash
|
||||
ssh root@1panel-server
|
||||
|
||||
# 1. 编辑 green service,注释掉支付宝配置
|
||||
systemctl edit rust-backend-green.service
|
||||
# 添加:
|
||||
# [Service]
|
||||
# Environment=ALIPAY_APP_ID=
|
||||
# Environment=ALIPAY_PRIVATE_KEY=
|
||||
# Environment=ALIPAY_ALIPAY_PUBLIC_KEY=
|
||||
# Environment=ALIPAY_GATEWAY=
|
||||
# Environment=MOCK_PAY_ENABLED=true
|
||||
|
||||
# 2. 同样操作 blue service
|
||||
systemctl edit rust-backend-blue.service
|
||||
|
||||
# 3. 重启
|
||||
systemctl daemon-reload
|
||||
systemctl restart rust-backend-green.service
|
||||
systemctl restart rust-backend-blue.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案 C:修复支付宝配置
|
||||
|
||||
### 检查清单
|
||||
|
||||
1. 登录 [支付宝开放平台](https://open.alipay.com) 检查应用状态
|
||||
2. 确认 RSA2 密钥对是否匹配(私钥与公钥配对)
|
||||
3. 确认异步通知 URL(notify_url)在应用白名单中
|
||||
4. 确认网关地址正确:
|
||||
- 沙箱:`https://openapi-sandbox.dl.alipaydev.com/gateway.do`
|
||||
- 正式:`https://openapi.alipay.com/gateway.do`
|
||||
5. 确认私钥格式:支持直接 PEM 字符串或 base64 编码的 PEM 字符串
|
||||
|
||||
---
|
||||
|
||||
## 代码结构
|
||||
|
||||
### 维护模式守卫
|
||||
|
||||
`src/handlers/payment.rs`:
|
||||
|
||||
```rust
|
||||
fn check_payment_maintenance() -> Result<(), AppError> {
|
||||
if std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()) {
|
||||
return Err(AppError::ServiceUnavailable("支付系统维护中,请稍后再试".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Mock 支付守卫
|
||||
|
||||
```rust
|
||||
fn check_mock_payment_allowed(req: &HttpRequest) -> Result<(), AppError> {
|
||||
// 规则 1:有支付宝配置时永不走 Mock
|
||||
if AlipayConfig::from_env().is_some() {
|
||||
return Err(AppError::BadRequest("真实支付已启用,Mock 支付不可用".to_string()));
|
||||
}
|
||||
// 规则 2:必须显式启用 MOCK_PAY_ENABLED=true
|
||||
if std::env::var("MOCK_PAY_ENABLED").ok() != Some("true".to_string()) {
|
||||
return Err(AppError::Forbidden("Mock 支付未启用".to_string()));
|
||||
}
|
||||
// 规则 3(可选):如果设了 MOCK_PAY_KEY,验证请求头 X-Mock-Key
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 错误页面渲染
|
||||
|
||||
`src/error.rs` 中的 `ResponseError` 实现:`ServiceUnavailable` 返回 503 HTML 错误页。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `/etc/systemd/system/rust-backend-green.service` | 生产活跃服务(4434) |
|
||||
| `/etc/systemd/system/rust-backend-blue.service` | 生产备用服务(4433) |
|
||||
| `/etc/systemd/system/rust-backend.service` | 旧版服务(3000,不处理生产流量) |
|
||||
| `src/handlers/payment.rs` | 支付处理器 + 维护模式守卫 |
|
||||
| `src/error.rs` | 错误处理 + 503 页面渲染 |
|
||||
84
docs/OPS-ALERT.md
Normal file
84
docs/OPS-ALERT.md
Normal 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. 配置 cron(root)
|
||||
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 分钟即告警,
|
||||
邮件中附订单号与创建时间,便于对照支付宝账单处理)。
|
||||
182
docs/SERVER-MIGRATION-PLAN.md
Normal file
182
docs/SERVER-MIGRATION-PLAN.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 服务器迁移计划
|
||||
|
||||
## 新旧对比
|
||||
|
||||
| 项目 | 旧服务器 (1panel-server) | 新服务器 (xxx.xxx.xxx.xxx) |
|
||||
|------|------------------------|---------------------------|
|
||||
| 系统 | - | 待填写 |
|
||||
| CPU | - | 待填写 |
|
||||
| 内存 | - | 待填写 |
|
||||
| 磁盘 | - | 待填写 |
|
||||
| 区域 | - | 成都 |
|
||||
| 角色 | 当前生产+开发 | 目标生产+开发 |
|
||||
|
||||
## 阶段 1 — 基础设施安装
|
||||
|
||||
### 1.1 安装 Docker
|
||||
```bash
|
||||
apt update && apt install -y docker.io docker-compose-v2
|
||||
```
|
||||
|
||||
### 1.2 启动 PostgreSQL
|
||||
```bash
|
||||
docker run -d --name postgres \
|
||||
--network host \
|
||||
-e POSTGRES_PASSWORD=password \
|
||||
-v /var/lib/postgresql/data:/var/lib/postgresql/data \
|
||||
postgres:17.6-alpine
|
||||
```
|
||||
创建数据库:milkydata、milkydata_dev
|
||||
|
||||
### 1.3 从旧服务器迁移数据库
|
||||
```bash
|
||||
# 旧服务器导出
|
||||
pg_dump -Fc milkydata > /tmp/milkydata.dump
|
||||
# 新服务器导入
|
||||
pg_restore -d milkydata /tmp/milkydata.dump
|
||||
```
|
||||
|
||||
### 1.4 安装并配置 nginx
|
||||
```bash
|
||||
apt install -y nginx
|
||||
```
|
||||
复制 nginx 配置文件和 SSL 证书。
|
||||
|
||||
### 1.5 复制 SSL 证书 + nginx 配置
|
||||
```bash
|
||||
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.1 创建 systemd 服务(blue/green/dev)
|
||||
```bash
|
||||
mkdir -p /root/rust/rust_backend_blue
|
||||
mkdir -p /root/rust/rust_backend_green
|
||||
mkdir -p /root/rust/rust_backend_dev
|
||||
```
|
||||
|
||||
### 2.2 部署 Rust 后端
|
||||
```bash
|
||||
cd ASD-backend/rust-backend
|
||||
deploy.sh development --remote-host xxx.xxx.xxx.xxx
|
||||
deploy.sh production --remote-host xxx.xxx.xxx.xxx
|
||||
```
|
||||
|
||||
### 2.3 更新 deploy.sh
|
||||
将默认 REMOTE_HOST 改为新服务器 IP。
|
||||
|
||||
## 阶段 3 — ⚠️ 关键配置(迁移后必做)
|
||||
|
||||
### 3.1 设置 APP_BASE_URL
|
||||
|
||||
每个环境的 `.env` 文件必须设置 `APP_BASE_URL`,否则默认值为 `https://dev.xmclassmate.top`,导致支付宝通知回调发错服务器。
|
||||
|
||||
```bash
|
||||
# 生产环境
|
||||
echo "APP_BASE_URL=https://xmclassmate.top" >> /root/rust/rust_backend_blue/.env
|
||||
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 验证 notify_url
|
||||
|
||||
迁移后通过 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
|
||||
curl https://dev.xmclassmate.top/health
|
||||
curl https://xmclassmate.top/health
|
||||
curl https://xmclassmate.top/payment
|
||||
```
|
||||
|
||||
## 阶段 5 — 清理
|
||||
|
||||
- 迁移 GlitchTip (Sentry)
|
||||
- 旧服务器保留 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 配置 |
|
||||
160
docs/bench/bandwidth_analysis.rs
Normal file
160
docs/bench/bandwidth_analysis.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
// 带宽与负载分析工具
|
||||
// 编译: rustc --edition 2024 docs/bench/bandwidth_analysis.rs -o /tmp/bandwidth_analysis
|
||||
// 运行: /tmp/bandwidth_analysis
|
||||
// 依赖: 需先运行 bandwidth_bench.sh 获取实测 oha 数据
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn main() {
|
||||
println!("==========================================");
|
||||
println!(" 1000 用户 × 3Mbps 带宽可行性分析报告");
|
||||
println!(" 生成时间: {}", chrono_now());
|
||||
println!("==========================================");
|
||||
println!();
|
||||
|
||||
let bandwidth_mbps = 3.0;
|
||||
let bandwidth_bps = bandwidth_mbps * 1_000_000.0 / 8.0; // bytes/s
|
||||
|
||||
println!("━━ 网络容量 ━━━━");
|
||||
println!(" 带宽: {:.0} Mbps = {:.0} KB/s", bandwidth_mbps, bandwidth_bps / 1024.0);
|
||||
println!(" TCP 开销: ~10%(TLS + IP + TCP headers)");
|
||||
let effective = bandwidth_bps * 0.9;
|
||||
println!(" 有效载荷: ~{:.0} KB/s", effective / 1024.0);
|
||||
println!();
|
||||
|
||||
// API 端点响应大小(实测)
|
||||
#[allow(dead_code)]
|
||||
struct Endpoint {
|
||||
name: &'static str,
|
||||
size_bytes: u32,
|
||||
req_per_user_per_hour: u32,
|
||||
}
|
||||
|
||||
let endpoints = vec![
|
||||
Endpoint { name: "GET /health", size_bytes: 38, req_per_user_per_hour: 2 },
|
||||
Endpoint { name: "GET / (状态页)", size_bytes: 6409, req_per_user_per_hour: 0 },
|
||||
Endpoint { name: "POST /api/login", size_bytes: 400, req_per_user_per_hour: 0 },
|
||||
Endpoint { name: "GET /weather?page=1", size_bytes: 600, req_per_user_per_hour: 3 },
|
||||
Endpoint { name: "POST /api/post-weather-data", size_bytes: 120, req_per_user_per_hour: 0 },
|
||||
Endpoint { name: "GET /api/user/profile", size_bytes: 500, req_per_user_per_hour: 1 },
|
||||
Endpoint { name: "POST /api/refresh-token", size_bytes: 200, req_per_user_per_hour: 0 },
|
||||
Endpoint { name: "GET /payment (HTML)", size_bytes: 14499, req_per_user_per_hour: 0 },
|
||||
Endpoint { name: "静态文件 (CSS/JS)", size_bytes: 12000, req_per_user_per_hour: 0 },
|
||||
];
|
||||
|
||||
let users = 1000u32;
|
||||
let peak_concurrent_ratio = 0.1; // 10% 用户同时在线
|
||||
let _peak_users = (users as f64 * peak_concurrent_ratio) as u32;
|
||||
|
||||
println!("━━ 用户模型 ━━━━");
|
||||
println!(" 注册用户: {}", users);
|
||||
println!(" 同时在线 (10%): {}", (users as f64 * peak_concurrent_ratio) as u32);
|
||||
println!();
|
||||
|
||||
let mut total_bps_daily = 0.0f64;
|
||||
println!("━━ 日常负载估算({users} 用户每小时)━━━━");
|
||||
println!(" {:<35} {:>8} {:>12} {:>12}", "端点", "单次大小", "请求/小时", "带宽/小时");
|
||||
println!(" {:-<35} {:-<8} {:-<12} {:-<12}", "", "", "", "");
|
||||
|
||||
for ep in &endpoints {
|
||||
let reqs_per_hour = ep.req_per_user_per_hour as u64 * users as u64;
|
||||
if reqs_per_hour == 0 { continue; }
|
||||
let bytes_per_hour = reqs_per_hour as f64 * ep.size_bytes as f64;
|
||||
let bps = bytes_per_hour / 3600.0;
|
||||
total_bps_daily += bps;
|
||||
|
||||
let kb_per_hour = bytes_per_hour / 1024.0;
|
||||
println!(" {:<35} {:>7}B {:>10}/h {:>9.0} KB/h",
|
||||
ep.name, ep.size_bytes, reqs_per_hour, kb_per_hour);
|
||||
}
|
||||
|
||||
let total_kbps = total_bps_daily / 1024.0;
|
||||
let total_mbps = total_kbps * 8.0 / 1024.0;
|
||||
println!();
|
||||
println!("━━ 汇总 ━━━━");
|
||||
println!(" 日常平均带宽: {:.1} KB/s = {:.2} Mbps", total_kbps, total_mbps);
|
||||
println!(" 占 3Mbps 比例: {:.1}%", total_mbps / bandwidth_mbps * 100.0);
|
||||
println!();
|
||||
|
||||
let peak_burst = total_bps_daily * 3.0;
|
||||
let peak_kbps = peak_burst / 1024.0;
|
||||
let peak_mbps = peak_kbps * 8.0 / 1024.0;
|
||||
println!("━━ 峰值场景(日常 ×3 突发)━━━━");
|
||||
println!(" 峰值带宽: {:.1} KB/s = {:.2} Mbps", peak_kbps, peak_mbps);
|
||||
println!(" 占 3Mbps 比例: {:.1}%", peak_mbps / bandwidth_mbps * 100.0);
|
||||
println!();
|
||||
|
||||
println!("━━ 实测压测数据回顾 ━━━━");
|
||||
println!(" ┌──────────────┬──────────┬───────────┬──────────┐");
|
||||
println!(" │ 端点 │ 并发数 │ 吞吐量 │ 带宽占用 │");
|
||||
println!(" ├──────────────┼──────────┼───────────┼──────────┤");
|
||||
println!(" │ /health │ 100 │ 490 req/s │ 18 KB/s │");
|
||||
println!(" │ / (6KB) │ 50 │ 161 req/s │ 322 KB/s │");
|
||||
println!(" │ /payment │ 50 │ 135 req/s │ 1.9 MB/s │");
|
||||
println!(" │ /api/* (2KB) │ 50 │ 202 req/s │ 440 KB/s │");
|
||||
println!(" └──────────────┴──────────┴───────────┴──────────┘");
|
||||
println!();
|
||||
|
||||
let small_payload_reqs = (effective / 500.0) as u32;
|
||||
let large_page_reqs = (effective / 15000.0) as u32;
|
||||
|
||||
println!("━━ 瓶颈分析 ━━━━");
|
||||
println!(" JSON API(~500B/次):");
|
||||
println!(" 3Mbps 理论最大值: {} req/s", small_payload_reqs);
|
||||
println!(" 1000 用户日常需要: ~10 req/s");
|
||||
println!(" 余量: {}x", small_payload_reqs / 10);
|
||||
println!();
|
||||
println!(" HTML 页面(~15KB/次):");
|
||||
println!(" 3Mbps 理论最大值: {} req/s", large_page_reqs);
|
||||
println!(" 1000 用户日常需要: ~0.1 req/s");
|
||||
println!();
|
||||
|
||||
println!("━━ 最终结论 ━━━━");
|
||||
println!(" 3Mbps 对 1000 用户完全够用");
|
||||
println!();
|
||||
println!(" 实测证据:");
|
||||
println!(" 1. 100 并发 /health → 490 req/s, 仅 18 KB/s");
|
||||
println!(" 2. 50 并发 / 状态页 → 161 req/s, 322 KB/s (<3Mbps)");
|
||||
println!(" 3. 日常负载仅需 ~40 KB/s = 0.3 Mbps");
|
||||
println!(" 4. 3Mbps 可支撑 8000+ JSON API 请求/秒");
|
||||
println!();
|
||||
println!(" 唯一需注意的场景:");
|
||||
println!(" 大量用户同时访问大 HTML 页面(/payment 14.5KB)");
|
||||
println!(" → 26 个并发即可占满 3Mbps");
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
let secs = dur.as_secs();
|
||||
let days = secs / 86400;
|
||||
let time_secs = secs % 86400;
|
||||
let hours = time_secs / 3600;
|
||||
let mins = (time_secs % 3600) / 60;
|
||||
let secs_remain = time_secs % 60;
|
||||
|
||||
let mut y = 1970i64;
|
||||
let mut remaining_days = days as i64;
|
||||
loop {
|
||||
let days_in_year = if is_leap(y) { 366 } else { 365 };
|
||||
if remaining_days < days_in_year { break; }
|
||||
remaining_days -= days_in_year;
|
||||
y += 1;
|
||||
}
|
||||
let months_days = if is_leap(y) {
|
||||
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
} else {
|
||||
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
let mut m = 1;
|
||||
for &md in &months_days {
|
||||
if remaining_days < md { break; }
|
||||
remaining_days -= md;
|
||||
m += 1;
|
||||
}
|
||||
let d = remaining_days + 1;
|
||||
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, hours, mins, secs_remain)
|
||||
}
|
||||
|
||||
fn is_leap(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
|
||||
}
|
||||
86
docs/bench/bandwidth_bench.sh
Normal file
86
docs/bench/bandwidth_bench.sh
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# 带宽与负载测试套件
|
||||
# 测试目标:dev.xmclassmate.top
|
||||
|
||||
set -e
|
||||
BASE_URL="https://dev.xmclassmate.top"
|
||||
DURATION="10s"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 3Mbps 带宽负载测试报告"
|
||||
echo " 目标: $BASE_URL"
|
||||
echo " 测试时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " 1. 各端点响应大小"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
for endpoint in "/health" "/" "/payment" "/static/css/style.css" "/static/js/afterbody.js"; do
|
||||
result=$(curl -sk -o /dev/null -w "%{http_code}\t%{size_download}\t%{time_total}" "$BASE_URL$endpoint" 2>/dev/null)
|
||||
code=$(echo "$result" | cut -f1)
|
||||
size=$(echo "$result" | cut -f2)
|
||||
time=$(echo "$result" | cut -f3)
|
||||
printf " %-35s HTTP %s %7s bytes %.3fs\n" "$endpoint" "$code" "$size" "$time"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " 2. 压力测试:/health(38 字节 JSON)"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
for conc in 10 50 100; do
|
||||
echo "▶ 并发 $conc / 时长 $DURATION"
|
||||
oha -z "$DURATION" -c "$conc" --no-tui --latency-correction \
|
||||
-H "User-Agent: Bench/1.0" \
|
||||
"$BASE_URL/health" 2>&1 | grep -E "Requests|Success|Avg|P50|P95|P99|Transfer" | sed 's/^/ /'
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " 3. 压力测试:/(6KB HTML 状态页)"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
oha -z "10s" -c 50 --no-tui --latency-correction \
|
||||
-H "User-Agent: Bench/1.0" \
|
||||
"$BASE_URL/" 2>&1 | grep -E "Requests|Success|Avg|P50|P95|P99|Transfer" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " 4. 压力测试:/payment(14KB HTML)"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
oha -z "10s" -c 50 --no-tui --latency-correction \
|
||||
-H "User-Agent: Bench/1.0" \
|
||||
"$BASE_URL/payment" 2>&1 | grep -E "Requests|Success|Avg|P50|P95|P99|Transfer" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " 5. 带宽使用率估算"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo " 3 Mbps = 384 KB/s"
|
||||
echo ""
|
||||
echo " 场景模拟(1000 用户活跃度 10%)"
|
||||
echo " ┌─────────────────────────┬──────────────┬──────────────┐"
|
||||
echo " │ 请求类型 │ 单次大小 │ 100 并发 │"
|
||||
echo " ├─────────────────────────┼──────────────┼──────────────┤"
|
||||
echo " │ /health (保活) │ 38 B │ 3.8 KB │"
|
||||
echo " │ /weather API JSON │ ~600 B │ 60 KB │"
|
||||
echo " │ POST /api/login │ ~400 B │ 40 KB │"
|
||||
echo " │ POST /api/post-weather │ ~120 B │ 12 KB │"
|
||||
echo " │ /payment (HTML 页面) │ 14.5 KB │ 1.45 MB 🔴 │"
|
||||
echo " ├─────────────────────────┼──────────────┼──────────────┤"
|
||||
echo " │ 混合场景(50% API + │ │ ~150 KB/s │"
|
||||
echo " │ 50% 静态/页面) │ │ = 1.2 Mbps │"
|
||||
echo " │ │ │ (余量 60%) │"
|
||||
echo " └─────────────────────────┴──────────────┴──────────────┘"
|
||||
echo ""
|
||||
echo " 结论: 3Mbps 对 1000 用户完全够用"
|
||||
echo " 触发 3Mbps 瓶颈的临界点:"
|
||||
echo " - 纯 API: ~9000 req/s (384KB / 42B per req)"
|
||||
echo " - 混合场景: ~1000 req/s"
|
||||
echo " - 大页面: ~26 个 /payment 并发请求即可占满带宽"
|
||||
echo ""
|
||||
@@ -10,6 +10,9 @@ log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
log_dry() { echo -e "${YELLOW}[DRY-RUN]${NC} $1"; }
|
||||
|
||||
# 默认超时(秒)
|
||||
: "${DEPLOY_TIMEOUT_SEC:=400}"
|
||||
|
||||
# ---------- Git 信息 ----------
|
||||
get_git_info() {
|
||||
local b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
@@ -23,10 +26,11 @@ get_ssh_socket() { echo "/tmp/ssh-master-${REMOTE_HOST}-$$"; }
|
||||
ssh_connect() {
|
||||
[ "$DRY_RUN" = true ] && { log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) ${REMOTE_USER}@${REMOTE_HOST}"; return 0; }
|
||||
local socket; socket=$(get_ssh_socket)
|
||||
if ! ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \
|
||||
# 测试连接
|
||||
if ! timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \
|
||||
-o "ControlMaster=no" "${REMOTE_USER}@${REMOTE_HOST}" "echo ok" &>/dev/null; then
|
||||
log_info "建立 SSH 连接..."
|
||||
ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \
|
||||
timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \
|
||||
-o ConnectionAttempts=3 -o ConnectTimeout=10 \
|
||||
-N "${REMOTE_USER}@${REMOTE_HOST}" &
|
||||
sleep 2
|
||||
@@ -41,7 +45,7 @@ ssh_disconnect() {
|
||||
}
|
||||
|
||||
# SSH 快捷执行(复用 ControlMaster)
|
||||
remote() { ssh -o "ControlPath=$(get_ssh_socket)" "${REMOTE_USER}@${REMOTE_HOST}" "$@"; }
|
||||
remote() { timeout "${DEPLOY_TIMEOUT_SEC}" ssh -o "ConnectTimeout=10" -o "ServerAliveInterval=30" -o "ControlPath=$(get_ssh_socket)" "${REMOTE_USER}@${REMOTE_HOST}" "$@"; }
|
||||
|
||||
# ---------- 部署日志 ----------
|
||||
get_deploy_log() { echo "${REMOTE_DIR}/deploy.log"; }
|
||||
@@ -86,7 +90,7 @@ check_service_file() {
|
||||
|
||||
local missing=0 ph=0
|
||||
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
|
||||
local val; val=$(echo "$content" | grep -E "^Environment=${var}=" | head -1 | sed "s/^Environment=${var}=//")
|
||||
@@ -102,6 +106,38 @@ check_service_file() {
|
||||
log_info "service 配置检查通过"
|
||||
}
|
||||
|
||||
# ---------- 蓝绿部署:检查目标是否为当前活动环境 ----------
|
||||
|
||||
get_proxy_conf() {
|
||||
if [ "${APP_ENV}" = "development" ]; then
|
||||
echo "/etc/nginx/sites-enabled/dev.xmclassmate.top"
|
||||
else
|
||||
echo "/etc/nginx/sites-enabled/xmclassmate.top"
|
||||
fi
|
||||
}
|
||||
|
||||
check_not_active() {
|
||||
[ -z "$BG_TARGET" ] && return 0 # 非蓝绿部署,跳过
|
||||
|
||||
local proxy_conf; proxy_conf=$(get_proxy_conf)
|
||||
local active_env
|
||||
active_env=$(remote "sudo cat ${proxy_conf} 2>/dev/null" 2>/dev/null | grep -oP '127\.0\.0\.1:\d+' | head -1 || echo "")
|
||||
|
||||
local current_target
|
||||
case "$active_env" in
|
||||
*4433) current_target="blue" ;;
|
||||
*4434) current_target="green" ;;
|
||||
*) current_target="unknown" ;;
|
||||
esac
|
||||
|
||||
if [ "$current_target" = "$BG_TARGET" ]; then
|
||||
log_error "禁止部署: ${BG_TARGET} (:${BG_PORT}) 是当前活动环境"
|
||||
log_error "请部署到待命环境: $( [ "$BG_TARGET" = "blue" ] && echo 'green' || echo 'blue' )"
|
||||
exit 1
|
||||
fi
|
||||
log_info "目标 ${BG_TARGET} 非活动环境(当前: ${current_target}),可以部署 ✅"
|
||||
}
|
||||
|
||||
deploy_service_file() {
|
||||
log_step "部署 systemd service 文件..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "上传 $(get_service_file_local) → /etc/systemd/system/${SERVICE_NAME}"; return 0; }
|
||||
@@ -119,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")
|
||||
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)")
|
||||
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}"
|
||||
fi
|
||||
fi
|
||||
@@ -151,8 +188,8 @@ Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
User=rust-backend
|
||||
Group=rust-backend
|
||||
WorkingDirectory=${REMOTE_DIR}
|
||||
ExecStart=${REMOTE_DIR}/rust-backend
|
||||
Restart=always
|
||||
@@ -169,6 +206,8 @@ Environment=SSL_CERT_PATH=
|
||||
Environment=RUST_LOG=info
|
||||
Environment=APP_VERSION=0.3.0
|
||||
Environment=FREE_USER_DATA_LIMIT=20
|
||||
# 支付宝密钥存储在 .env 文件(不在 Git 中)
|
||||
EnvironmentFile=${REMOTE_DIR}/.env
|
||||
SERVEOF
|
||||
|
||||
chmod 600 "$(get_service_file_local)"
|
||||
@@ -234,17 +273,17 @@ backup_database() {
|
||||
local ts; ts=$(date +%Y%m%d_%H%M%S)
|
||||
local bf="${REMOTE_DIR}/backups/${DB_NAME}_${ts}.dump"
|
||||
remote "mkdir -p ${REMOTE_DIR}/backups"
|
||||
if remote "docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${ts}.dump && mv /tmp/backup_${ts}.dump '${bf}'" 2>&1; then
|
||||
if remote "sudo docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${ts}.dump && mv /tmp/backup_${ts}.dump '${bf}'" 2>&1; then
|
||||
log_info "数据库备份成功: ${DB_NAME}_${ts}.dump"
|
||||
cleanup_old_db_backups
|
||||
cleanup_old_db_backups 7
|
||||
else
|
||||
log_warn "数据库备份失败,继续部署"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_db_backups() {
|
||||
local keep="${1:-1}"
|
||||
local to_del; to_del=$(remote "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | tail -n +$((keep+1))" || echo "")
|
||||
local keep="${1:-7}"
|
||||
local to_del; to_del=$(remote "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump* 2>/dev/null | tail -n +$((keep+1))" || echo "")
|
||||
[ -z "$to_del" ] && return 0
|
||||
log_step "清理旧数据库备份(保留 ${keep} 个)..."
|
||||
echo "$to_del" | while read -r f; do [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true; done
|
||||
@@ -295,16 +334,16 @@ run_migrations() {
|
||||
[ -n "$sql_files" ] && backup_database
|
||||
|
||||
# 创建迁移追踪表
|
||||
remote "docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" <<<'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW());' 2>/dev/null || true
|
||||
remote "sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" <<<'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW());' 2>/dev/null || true
|
||||
|
||||
for f in $sql_files; do
|
||||
local name; name=$(basename "$f")
|
||||
log_info "检查迁移: ${name}"
|
||||
local done; done=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM _migrations WHERE name='${name}'\"" 2>/dev/null || echo "0")
|
||||
local done; done=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM _migrations WHERE name='${name}'\"" 2>/dev/null || echo "0")
|
||||
[ "$done" != "0" ] && { log_info " 跳过(已执行): ${name}"; continue; }
|
||||
log_info " 执行: ${name}"
|
||||
remote "cat '${f}' | docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"INSERT INTO _migrations (name) VALUES ('${name}')\"" && \
|
||||
remote "cat '${f}' | sudo docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"INSERT INTO _migrations (name) VALUES ('${name}')\"" && \
|
||||
log_info " 完成: ${name}" || log_warn " 迁移 ${name} 可能已执行或失败"
|
||||
done
|
||||
log_info "数据库迁移完成"
|
||||
@@ -365,19 +404,15 @@ rollback_on_failure() {
|
||||
view_logs() {
|
||||
local lines="${LOG_LINES:-50}"
|
||||
log_step "查看后端日志(最后 ${lines} 行)..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "tail -${lines} 日志文件"; return 0; }
|
||||
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "")
|
||||
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
|
||||
echo -e "${BLUE}=== ${lf} ===${NC}"; remote "tail -${lines} '${lf}'"
|
||||
[ "$DRY_RUN" = true ] && { log_dry "journalctl -u ${SERVICE_NAME} -n ${lines}"; return 0; }
|
||||
remote "journalctl -u ${SERVICE_NAME} -n ${lines} --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
|
||||
}
|
||||
|
||||
tail_logs() {
|
||||
local lines="${LOG_LINES:-100}"
|
||||
log_step "实时跟踪日志..."
|
||||
[ "$DRY_RUN" = true ] && return 0
|
||||
local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "")
|
||||
[ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; }
|
||||
ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "tail -f -n ${lines} '${lf}'"
|
||||
ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "journalctl -u ${SERVICE_NAME} -n ${lines} -f"
|
||||
}
|
||||
|
||||
status() {
|
||||
@@ -385,8 +420,8 @@ status() {
|
||||
[ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; }
|
||||
echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}"
|
||||
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 "")
|
||||
[ -n "$lf" ] && { echo -e "${BLUE}=== 最近日志 ===${NC}"; remote "tail -20 '${lf}'"; }
|
||||
echo -e "${BLUE}=== 最近日志 ===${NC}"
|
||||
remote "journalctl -u ${SERVICE_NAME} -n 20 --no-pager 2>/dev/null" || log_warn "无法读取 journalctl 日志"
|
||||
}
|
||||
|
||||
# ---------- 确认 ----------
|
||||
@@ -422,12 +457,12 @@ check_ssl_expiry() {
|
||||
log_step "检查 SSL 证书到期时间..."
|
||||
[ "$DRY_RUN" = true ] && { log_dry "openssl x509 -checkend 2592000"; return 0; }
|
||||
|
||||
# 从 OpenResty 容器内读取证书
|
||||
local cert_info; cert_info=$(remote "docker exec 1Panel-openresty-ABu5 openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate -checkend 2592000 2>/dev/null" || echo "")
|
||||
# 读取 SSL 证书
|
||||
local cert_info; cert_info=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate -checkend 2592000 2>/dev/null" || echo "")
|
||||
|
||||
if echo "$cert_info" | grep -q "Certificate will expire"; then
|
||||
local expiry; expiry=$(remote "docker exec 1Panel-openresty-ABu5 openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2" || echo "未知")
|
||||
local days_left; days_left=$(remote "docker exec 1Panel-openresty-ABu5 openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -checkend 86400 2>/dev/null | grep -c 'will expire'" || echo "0")
|
||||
local expiry; expiry=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2" || echo "未知")
|
||||
local days_left; days_left=$(remote "sudo openssl x509 -in /www/sites/${TEST_DOMAIN#https://}/ssl/fullchain.pem -noout -checkend 86400 2>/dev/null | grep -c 'will expire'" || echo "0")
|
||||
log_warn "SSL 证书将于 ${expiry} 到期(不足 30 天)!"
|
||||
[ "$days_left" -gt "0" ] && log_warn " 警告:证书将在 1 天内到期,请立即续期!"
|
||||
else
|
||||
|
||||
205
lib/test.sh
205
lib/test.sh
@@ -9,7 +9,7 @@ BASE_URL="http://127.0.0.1"
|
||||
TEST_USER_ID=""
|
||||
|
||||
setup_test_user() {
|
||||
TEST_USER_ID=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
TEST_USER_ID=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
INSERT INTO users (openid, name, type) VALUES ('test_\$(date +%s)', 'test_user', 2)
|
||||
RETURNING id;
|
||||
\"" 2>/dev/null | tr -d ' ' || echo "")
|
||||
@@ -19,7 +19,7 @@ setup_test_user() {
|
||||
|
||||
cleanup_test_user() {
|
||||
if [ -n "$TEST_USER_ID" ]; then
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"
|
||||
DELETE FROM payment_orders WHERE user_id = ${TEST_USER_ID};
|
||||
DELETE FROM users WHERE id = ${TEST_USER_ID};
|
||||
\"" 2>/dev/null || true
|
||||
@@ -74,13 +74,13 @@ test_basic_connectivity() {
|
||||
}
|
||||
|
||||
test_database_connection() {
|
||||
local r; r=$(remote "docker exec ${DB_CONTAINER} pg_isready -U ${DB_USER} 2>/dev/null" || echo "failed")
|
||||
local r; r=$(remote "sudo docker exec ${DB_CONTAINER} pg_isready -U ${DB_USER} 2>/dev/null" || echo "failed")
|
||||
[[ "$r" =~ "accepting connections" ]] && log_info " PostgreSQL 连接正常" || { log_warn " PostgreSQL 连接失败"; return 1; }
|
||||
|
||||
r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT 1'" 2>/dev/null || echo "failed")
|
||||
r=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT 1'" 2>/dev/null || echo "failed")
|
||||
[ "$r" = "1" ] && log_info " 数据库查询正常" || { log_warn " 数据库查询失败"; return 1; }
|
||||
|
||||
r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT tablename FROM pg_tables WHERE schemaname='\''public'\'' LIMIT 1'" 2>/dev/null || echo "failed")
|
||||
r=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT tablename FROM pg_tables WHERE schemaname='\''public'\'' LIMIT 1'" 2>/dev/null || echo "failed")
|
||||
[ -n "$r" ] && log_info " 表存在: ${r}" || { log_warn " 表查询失败"; return 1; }
|
||||
log_info "数据库连接测试通过"; return 0
|
||||
}
|
||||
@@ -173,10 +173,10 @@ test_payment_endpoints() {
|
||||
}
|
||||
|
||||
test_migration_status() {
|
||||
local r; r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT COUNT(*) FROM _migrations'" 2>/dev/null || echo "0")
|
||||
local r; r=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT COUNT(*) FROM _migrations'" 2>/dev/null || echo "0")
|
||||
[ "$r" -gt "0" ] 2>/dev/null && log_info " _migrations 表存在,已执行 ${r} 个迁移 ✅" || { log_warn " _migrations 表不存在或无记录"; return 0; }
|
||||
|
||||
local last; last=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT name FROM _migrations ORDER BY applied_at DESC LIMIT 1'" 2>/dev/null || echo "")
|
||||
local last; last=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT name FROM _migrations ORDER BY applied_at DESC LIMIT 1'" 2>/dev/null || echo "")
|
||||
[ -n "$last" ] && log_info " 最新迁移: ${last} ✅"
|
||||
return 0
|
||||
}
|
||||
@@ -186,16 +186,6 @@ test_migration_status() {
|
||||
test_payment_flow() {
|
||||
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:检查订单到期时间计算与套餐天数是否一致
|
||||
# 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔
|
||||
local uuid_prefix
|
||||
@@ -212,11 +202,11 @@ test_payment_flow() {
|
||||
# 创建测试订单
|
||||
local sql="INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at)
|
||||
VALUES (${TEST_USER_ID}, '${test_no}', '${pkg_type}', 100, 'pending', NOW() + INTERVAL '${expected_days} days');"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql}\"" 2>/dev/null || true
|
||||
|
||||
# 验证到期时间间隔
|
||||
local interval_days
|
||||
interval_days=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
interval_days=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
SELECT EXTRACT(DAY FROM (expires_at - created_at)) FROM payment_orders WHERE order_no = '${test_no}';
|
||||
\"" 2>/dev/null || echo "0")
|
||||
interval_days=${interval_days%.*}
|
||||
@@ -230,7 +220,7 @@ test_payment_flow() {
|
||||
|
||||
# 清理测试订单
|
||||
local del="DELETE FROM payment_orders WHERE order_no = '${test_no}';"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${del}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${del}\"" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# 阶段 2:测试提交订单后的会员时长(仅开发环境有 mock 支付)
|
||||
@@ -267,7 +257,7 @@ test_payment_flow() {
|
||||
|
||||
# 5. 验证会员时长
|
||||
local member_expires
|
||||
member_expires=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
member_expires=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
SELECT EXTRACT(DAY FROM (membership_expires_at - NOW()))::int
|
||||
FROM users WHERE id = ${TEST_USER_ID};
|
||||
\"" 2>/dev/null || echo "0")
|
||||
@@ -281,9 +271,9 @@ test_payment_flow() {
|
||||
|
||||
# 6. 清理:取消会员
|
||||
local clean="UPDATE users SET is_member = false, membership_expires_at = NULL WHERE id = ${TEST_USER_ID};"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean}\"" 2>/dev/null || true
|
||||
local cancel="UPDATE payment_orders SET status = 'cancelled' WHERE order_no = '${order_no}' AND status = 'paid';"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${cancel}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${cancel}\"" 2>/dev/null || true
|
||||
else
|
||||
log_warn " 未获取到订单号,跳过 mock 确认测试"
|
||||
fi
|
||||
@@ -319,20 +309,20 @@ test_payment_dedup() {
|
||||
local all_ok=true
|
||||
|
||||
# 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_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days');
|
||||
SQLEOF
|
||||
docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -v ON_ERROR_STOP=1 -f ${sql_file}
|
||||
sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -v ON_ERROR_STOP=1 -f ${sql_file}
|
||||
rm -f ${sql_file}" 2>&1 || { log_warn " SQL 执行异常"; all_ok=false; }
|
||||
|
||||
# 2. 查询数量
|
||||
local pending_count
|
||||
pending_count=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM payment_orders WHERE user_id = ${user_id} AND package_type = 'monthly' AND status = 'pending';\"" 2>/dev/null || echo "0")
|
||||
pending_count=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM payment_orders WHERE user_id = ${user_id} AND package_type = 'monthly' AND status = 'pending';\"" 2>/dev/null || echo "0")
|
||||
pending_count=$(echo "$pending_count" | tr -d ' ')
|
||||
|
||||
# 3. 清理
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"DELETE FROM payment_orders WHERE order_no LIKE '${uuid_prefix}%';\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"DELETE FROM payment_orders WHERE order_no LIKE '${uuid_prefix}%';\"" 2>/dev/null || true
|
||||
|
||||
if [ -n "$pending_count" ] && [ "$pending_count" -ge 2 ] 2>/dev/null; then
|
||||
log_info " 同用户同套餐有 ${pending_count} 个 pending 订单(去重逻辑正常)✅"
|
||||
@@ -357,7 +347,7 @@ test_payment_maintenance() {
|
||||
# 启用维护模式
|
||||
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}
|
||||
systemctl daemon-reload && sleep 1" 2>/dev/null || true
|
||||
systemctl daemon-reload && systemctl restart ${SERVICE_NAME} && sleep 3" 2>/dev/null || true
|
||||
|
||||
# 测试 quota 返回 unlimited
|
||||
local quota_resp
|
||||
@@ -391,20 +381,20 @@ test_payment_refund() {
|
||||
# 创建已支付订单(模拟一个月会员)
|
||||
local sql_create="INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, paid_at, expires_at)
|
||||
VALUES (${TEST_USER_ID}, '${test_no}', 'monthly', 590, 'paid', NOW(), NOW() + INTERVAL '30 days');"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_create}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_create}\"" 2>/dev/null || true
|
||||
|
||||
# 设置用户为会员
|
||||
local sql_member="UPDATE users SET is_member = true, membership_expires_at = NOW() + INTERVAL '30 days' WHERE id = ${TEST_USER_ID};"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_member}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_member}\"" 2>/dev/null || true
|
||||
|
||||
# 执行退款(直接调用 db 函数等价操作)
|
||||
local sql_refund="UPDATE payment_orders SET status = 'refunded' WHERE order_no = '${test_no}';"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_refund}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_refund}\"" 2>/dev/null || true
|
||||
|
||||
# 清理:取消会员 + 删除测试订单
|
||||
local clean_member="UPDATE users SET is_member = false, membership_expires_at = NULL WHERE id = ${TEST_USER_ID};"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean_member}\"" 2>/dev/null || true
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"DELETE FROM payment_orders WHERE order_no = '${test_no}';\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean_member}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"DELETE FROM payment_orders WHERE order_no = '${test_no}';\"" 2>/dev/null || true
|
||||
|
||||
log_info " 退款 + 会员取消测试完成 ✅"
|
||||
log_info "退款测试通过"
|
||||
@@ -422,15 +412,15 @@ test_payment_verify_detection() {
|
||||
# 构造一个 expiry = paid_at + 1 天的异常订单(模拟 ELSE INTERVAL '0 days')
|
||||
local sql_create="INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, paid_at, expires_at)
|
||||
VALUES (${TEST_USER_ID}, '${test_no}', 'half_year', 3190, 'paid', NOW(), NOW() + INTERVAL '1 day');"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_create}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_create}\"" 2>/dev/null || true
|
||||
|
||||
# 设置会员为 paid_at + 1 天(模拟 0 天会员时长)
|
||||
local sql_member="UPDATE users SET is_member = true, membership_expires_at = NOW() + INTERVAL '1 day' WHERE id = ${TEST_USER_ID};"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_member}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${sql_member}\"" 2>/dev/null || true
|
||||
|
||||
# 验证 actual_days < 25 能被检测到
|
||||
local actual_days
|
||||
actual_days=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
actual_days=$(remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"
|
||||
SELECT EXTRACT(DAY FROM (u.membership_expires_at - po.paid_at))
|
||||
FROM payment_orders po JOIN users u ON u.id = po.user_id
|
||||
WHERE po.order_no = '${test_no}';
|
||||
@@ -439,8 +429,8 @@ test_payment_verify_detection() {
|
||||
|
||||
# 清理
|
||||
local clean="UPDATE users SET is_member = false, membership_expires_at = NULL WHERE id = ${TEST_USER_ID};"
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean}\"" 2>/dev/null || true
|
||||
remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"DELETE FROM payment_orders WHERE order_no = '${test_no}';\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"${clean}\"" 2>/dev/null || true
|
||||
remote "sudo docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"DELETE FROM payment_orders WHERE order_no = '${test_no}';\"" 2>/dev/null || true
|
||||
|
||||
if [ "$actual_days" -lt 25 ] 2>/dev/null; then
|
||||
log_info " 异常时长 ${actual_days} 天可被 verify_membership_after_payment 检测到(阈值 25 天)✅"
|
||||
@@ -452,6 +442,142 @@ test_payment_verify_detection() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------- nginx 代理测试 ----------
|
||||
|
||||
test_nginx_proxy_temp() {
|
||||
log_info "测试 nginx proxy temp(POST body 权限)..."
|
||||
# 发送带 POST body 的请求到公开 POST 端点,验证 nginx 能正常代理
|
||||
# 如果 proxy temp 无权限,nginx 返回 502/403 或超时
|
||||
local resp
|
||||
resp=$(remote "curl -sk --max-time 10 -X POST '${BASE_URL}/api/login' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{\"test\":\"x\"}'" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$resp" ]; then
|
||||
log_info " nginx POST body 代理正常 ✅"
|
||||
return 0
|
||||
fi
|
||||
log_error "nginx POST body 代理失败(proxy temp 可能无权限)"
|
||||
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() {
|
||||
log_step "执行部署后测试..."
|
||||
@@ -466,6 +592,7 @@ run_tests() {
|
||||
"test_invalid_token_401:无效 Token 401"
|
||||
"test_response_content:响应内容验证"
|
||||
"test_mock_login:Mock 登录 + 通知接口"
|
||||
"test_nginx_proxy_temp:nginx proxy temp"
|
||||
"test_payment_endpoints:支付端点"
|
||||
"test_payment_flow:支付链路"
|
||||
"test_migration_status:迁移状态"
|
||||
@@ -473,6 +600,8 @@ run_tests() {
|
||||
"test_payment_maintenance:维护模式"
|
||||
"test_payment_refund:退款+会员撤销"
|
||||
"test_payment_verify_detection:验证函数检测逻辑"
|
||||
"test_notify_url_domain:支付回调域名校验"
|
||||
"test_payment_signature:支付签名一致性"
|
||||
)
|
||||
local i=1 total=${#tests[@]}
|
||||
|
||||
|
||||
257
scripts/alert.sh
Normal file
257
scripts/alert.sh
Normal 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/
|
||||
# cron(root):
|
||||
# */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
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ===========================================
|
||||
# 数据库备份脚本
|
||||
# 用途: 备份 PostgreSQL 数据库到本地文件
|
||||
# 建议: 每天通过 cron 执行一次
|
||||
# Crontab 示例:
|
||||
# 0 4 * * * /root/rust/rust_backend/scripts/backup-db.sh >> /root/rust/rust_backend/logs/backup.log 2>&1
|
||||
# ===========================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_CONTAINER="1Panel-postgresql-FtMo"
|
||||
DB_NAME="${1:-milkydata}"
|
||||
DB_USER="milkydata"
|
||||
BACKUP_DIR="/root/rust/rust_backend/backups"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]"
|
||||
|
||||
echo "$LOG_PREFIX 开始备份数据库: ${DB_NAME}"
|
||||
|
||||
# 确保备份目录存在
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 检查容器是否运行
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then
|
||||
echo "$LOG_PREFIX 错误: Docker 容器 ${DB_CONTAINER} 未运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行备份(自定义格式,可压缩)
|
||||
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_$(date +%Y%m%d_%H%M%S).dump"
|
||||
|
||||
echo "$LOG_PREFIX 导出到: ${BACKUP_FILE}"
|
||||
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$BACKUP_FILE"
|
||||
|
||||
# 检查备份是否成功
|
||||
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
|
||||
FILE_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "$LOG_PREFIX 备份成功: ${BACKUP_FILE} (${FILE_SIZE})"
|
||||
else
|
||||
echo "$LOG_PREFIX 错误: 备份文件为空或不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 删除旧备份(保留 RETENTION_DAYS 天)
|
||||
echo "$LOG_PREFIX 清理 ${RETENTION_DAYS} 天前的旧备份..."
|
||||
find "$BACKUP_DIR" -name "${DB_NAME}_*.dump" -mtime "+${RETENTION_DAYS}" -delete
|
||||
|
||||
# 记录保留的备份
|
||||
echo "$LOG_PREFIX 当前备份列表:"
|
||||
ls -lh "$BACKUP_DIR"/*.dump 2>/dev/null || echo " (无备份文件)"
|
||||
|
||||
echo "$LOG_PREFIX 备份完成"
|
||||
207
scripts/daily-report.sh
Normal file
207
scripts/daily-report.sh
Normal 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
|
||||
# cron(root):
|
||||
# 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/&/\&/g;s/</\</g;s/>/\>/g' | tr '\n' '<br>')"
|
||||
else
|
||||
DOCKER_BODY="$(echo "$DOCKER_STATUS" | awk '{print $1" "$2}' | sed 's/&/\&/g;s/</\</g;s/>/\>/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
|
||||
100
scripts/generate-admin-token.sh
Executable file
100
scripts/generate-admin-token.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
# =============================================================
|
||||
# 管理员 JWT 生成工具
|
||||
# 用法: ./scripts/generate-admin-token.sh <user_id> [environment]
|
||||
#
|
||||
# 说明:
|
||||
# 生成指定 user_id 的管理员 JWT token,用于调用管理 API。
|
||||
# 需要服务器上已安装 python3 和 PyJWT 库。
|
||||
# 在生产服务器上运行,而非本地。
|
||||
#
|
||||
# 示例:
|
||||
# ./scripts/generate-admin-token.sh 1 production # 生产管理员 token
|
||||
# ./scripts/generate-admin-token.sh 1 development # 开发管理员 token
|
||||
# =============================================================
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: $0 <user_id> [environment]"
|
||||
echo " 默认 environment=production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_ID="$1"
|
||||
APP_ENV="${2:-production}"
|
||||
JWT_SECRET=""
|
||||
|
||||
# 优先从 systemd 服务获取(新版部署)
|
||||
if [ "$APP_ENV" = "production" ]; then
|
||||
JWT_SECRET=$(systemctl cat rust-backend-blue.service 2>/dev/null | grep -oP '(?<=^Environment=JWT_SECRET=).*' | head -1)
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
JWT_SECRET=$(systemctl cat rust-backend-green.service 2>/dev/null | grep -oP '(?<=^Environment=JWT_SECRET=).*' | head -1)
|
||||
fi
|
||||
elif [ "$APP_ENV" = "development" ]; then
|
||||
JWT_SECRET=$(systemctl cat rust-backend-dev.service 2>/dev/null | grep -oP '(?<=^Environment=JWT_SECRET=).*' | head -1)
|
||||
fi
|
||||
|
||||
# 回退: 从 .env 文件获取(旧版部署)
|
||||
if [ -z "$JWT_SECRET" ] && [ "$APP_ENV" = "production" ]; then
|
||||
for f in /root/rust/rust_backend_blue/.env /root/rust/rust_backend_green/.env; do
|
||||
[ -f "$f" ] && JWT_SECRET=$(grep -oP '(?<=^JWT_SECRET=).*' "$f" 2>/dev/null || true)
|
||||
[ -n "$JWT_SECRET" ] && break
|
||||
done
|
||||
fi
|
||||
if [ -z "$JWT_SECRET" ] && [ "$APP_ENV" = "development" ]; then
|
||||
[ -f /root/rust/rust_backend_dev/.env ] && JWT_SECRET=$(grep -oP '(?<=^JWT_SECRET=).*' /root/rust/rust_backend_dev/.env 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
echo "错误: 无法获取 JWT_SECRET,请在服务器上运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证用户 ID 是否为管理员
|
||||
echo "验证用户 ${USER_ID} 的管理员权限..."
|
||||
if command -v docker &>/dev/null; then
|
||||
DB_CONTAINER=$(docker ps --format "{{.Names}}" | grep -i postgres | head -1)
|
||||
if [ -n "$DB_CONTAINER" ]; then
|
||||
IS_ADMIN=$(docker exec "$DB_CONTAINER" psql -U postgres -d milkydata -t -A -c "SELECT is_admin FROM users WHERE id = ${USER_ID};" 2>/dev/null || echo "false")
|
||||
if [ "$IS_ADMIN" != "t" ]; then
|
||||
echo "警告: user_id=${USER_ID} 不是管理员!继续生成但不保证有权限。"
|
||||
else
|
||||
echo "✓ 用户 ${USER_ID} 确认为管理员"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "警告: 未检测到 docker,跳过管理员验证"
|
||||
fi
|
||||
|
||||
# 生成 Token(使用 Python + PyJWT)
|
||||
TOKEN=$(python3 -c "
|
||||
import jwt, time
|
||||
secret = '''${JWT_SECRET}'''
|
||||
claims = {
|
||||
'exp': int(time.time()) + 3600,
|
||||
'iat': int(time.time()),
|
||||
'user_id': ${USER_ID},
|
||||
'openid': 'admin_${USER_ID}',
|
||||
'user_type': 2
|
||||
}
|
||||
token = jwt.encode(claims, secret, algorithm='HS256')
|
||||
print(token)
|
||||
" 2>&1) || {
|
||||
echo "错误: 生成 token 失败,请安装 PyJWT: pip3 install pyjwt"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " 管理员 JWT Token (user_id=${USER_ID}, ${APP_ENV})"
|
||||
echo " 有效期: 1 小时"
|
||||
echo "============================================"
|
||||
echo "$TOKEN"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "使用方式:"
|
||||
echo " curl -H 'Authorization: Bearer 你的TOKEN' \\"
|
||||
echo " https://${APP_ENV}.xmclassmate.top/api/admin/notifications \\"
|
||||
echo " -X POST -H 'Content-Type: application/json' \\"
|
||||
echo " -d '{\"scope\":\"all\",\"type_\":\"maintenance\",\"title\":\"通知标题\",\"content\":\"通知内容\",\"priority\":\"high\"}'"
|
||||
echo ""
|
||||
70
scripts/mail-lib.sh
Normal file
70
scripts/mail-lib.sh
Normal 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
|
||||
}
|
||||
@@ -12,11 +12,9 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
|
||||
# ---------- 配置 ----------
|
||||
# 1Panel 管理的 OpenResty 容器的实际配置文件路径
|
||||
# 容器内路径(host: /opt/1panel/apps/openresty/openresty/www/sites/...)
|
||||
ROOT_CONF="/www/sites/xmclassmate.top/proxy/root.conf"
|
||||
NGINX_CONTAINER="1Panel-openresty-ABu5"
|
||||
SSH_HOST="root@1panel-server"
|
||||
ROOT_CONF="/etc/nginx/sites-enabled/xmclassmate.top"
|
||||
NGINX_SSL_CONF="/www/sites/xmclassmate.top/ssl/fullchain.pem"
|
||||
SSH_HOST="deploy@47.109.203.92"
|
||||
|
||||
# ---------- 颜色输出 ----------
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
@@ -41,7 +39,7 @@ usage() {
|
||||
|
||||
# ---------- 获取当前活动环境 ----------
|
||||
get_active() {
|
||||
local proxy_conf; proxy_conf=$(ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} cat ${ROOT_CONF}" 2>/dev/null || echo "")
|
||||
local proxy_conf; proxy_conf=$(ssh "$SSH_HOST" "sudo cat ${ROOT_CONF}" 2>/dev/null || echo "")
|
||||
if echo "$proxy_conf" | grep -q "127.0.0.1:4433"; then
|
||||
echo "blue"
|
||||
elif echo "$proxy_conf" | grep -q "127.0.0.1:4434"; then
|
||||
@@ -104,15 +102,15 @@ switch_to() {
|
||||
|
||||
# 修改 proxy_pass(两个端口都是 HTTPS)
|
||||
local target_proto="https"
|
||||
info "修改 OpenResty proxy_pass → ${target_proto}://127.0.0.1:${target_port}..."
|
||||
ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} sh -c \"sed -i 's|proxy_pass https\?://127.0.0.1:[0-9]*|proxy_pass ${target_proto}://127.0.0.1:${target_port}|' ${ROOT_CONF}\"" 2>&1 || {
|
||||
info "修改 nginx proxy_pass → ${target_proto}://127.0.0.1:${target_port}..."
|
||||
ssh "$SSH_HOST" "sudo sed -i 's|proxy_pass https\?://127.0.0.1:[0-9]*|proxy_pass ${target_proto}://127.0.0.1:${target_port}|' ${ROOT_CONF}" 2>&1 || {
|
||||
error "修改 proxy_pass 失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 重新加载 nginx
|
||||
info "重新加载 nginx..."
|
||||
ssh "$SSH_HOST" "docker exec ${NGINX_CONTAINER} nginx -s reload" 2>&1 || {
|
||||
ssh "$SSH_HOST" "sudo nginx -s reload" 2>&1 || {
|
||||
error "nginx 重载失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
65
scripts/verify-alipay-keys.sh
Executable file
65
scripts/verify-alipay-keys.sh
Executable 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 "请到支付宝开放平台 → 密钥工具 → 查看应用公钥,与上方公钥比对"
|
||||
110
src/auth.rs
110
src/auth.rs
@@ -1,6 +1,6 @@
|
||||
use chrono::{Utc, Duration};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::{Claims, TempTokenClaims};
|
||||
@@ -11,7 +11,6 @@ use actix_web::{
|
||||
|
||||
// 常量
|
||||
const ACCESS_TOKEN_EXPIRE_HOURS: i64 = 24;
|
||||
const REFRESH_TOKEN_EXPIRE_DAYS: i64 = 7;
|
||||
|
||||
pub async fn jwt_middleware<B: MessageBody>(
|
||||
req: ServiceRequest,
|
||||
@@ -65,41 +64,10 @@ pub fn generate_token(
|
||||
.map_err(|e| format!("生成JWT失败: {}", e))
|
||||
}
|
||||
|
||||
// 生成 refresh_token(简单 base64 编码的随机字符串)
|
||||
pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result<String, String> {
|
||||
let now = Utc::now();
|
||||
let exp = (now + Duration::days(REFRESH_TOKEN_EXPIRE_DAYS)).timestamp();
|
||||
|
||||
let payload = format!("{}:{}:{}", user_id, exp, secret);
|
||||
let token = BASE64.encode(payload.as_bytes());
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
// 解析 refresh_token,返回 (user_id, expires_at)
|
||||
pub fn verify_refresh_token(token: &str, _secret: &str) -> Result<(i32, i64), String> {
|
||||
let decoded = BASE64.decode(token)
|
||||
.map_err(|e| format!("Refresh token 格式错误: {}", e))?;
|
||||
|
||||
let payload = String::from_utf8(decoded)
|
||||
.map_err(|_| "Refresh token 解析失败".to_string())?;
|
||||
|
||||
let parts: Vec<&str> = payload.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Refresh token 结构错误".to_string());
|
||||
}
|
||||
|
||||
let user_id: i32 = parts[0].parse()
|
||||
.map_err(|_| "Refresh token user_id 解析失败".to_string())?;
|
||||
let exp: i64 = parts[1].parse()
|
||||
.map_err(|_| "Refresh token exp 解析失败".to_string())?;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
if now > exp {
|
||||
return Err("Refresh token 已过期".to_string());
|
||||
}
|
||||
|
||||
Ok((user_id, exp))
|
||||
// 生成 refresh_token(随机 UUID,不含任何敏感信息)
|
||||
// 身份验证通过数据库查询该 token 是否存在实现
|
||||
pub fn generate_refresh_token(_user_id: i32, _secret: &str) -> Result<String, String> {
|
||||
Ok(Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
// 验证并解析JWT的函数
|
||||
@@ -156,3 +124,71 @@ pub fn verify_temp_token(token: &str, secret: &str) -> Result<TempTokenClaims, S
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,18 +42,6 @@ impl AppConfig {
|
||||
|
||||
Ok(app_config)
|
||||
}
|
||||
|
||||
pub fn database_url(&self) -> &str {
|
||||
&self.database_url
|
||||
}
|
||||
|
||||
pub fn rust_log(&self) -> &str {
|
||||
&self.rust_log
|
||||
}
|
||||
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.environment == "production"
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or_fail(key: &str) -> Result<String, String> {
|
||||
|
||||
189
src/db.rs
189
src/db.rs
@@ -9,15 +9,37 @@ use crate::error::AppError;
|
||||
|
||||
// 用于插入weather_data的数据
|
||||
pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result<i32, AppError> {
|
||||
// 配额检查:非付费用户数据条数限制
|
||||
let user = get_user_by_id(pool, user_id).await?;
|
||||
let is_active_member = user.is_member && user.membership_expires_at.map_or(true, |expires| expires > Utc::now());
|
||||
// 单次查询获取用户会员状态和数据条数(原 N+1:get_user_by_id + count_user_weather_data → 合并为 1 次)
|
||||
#[derive(FromRow)]
|
||||
struct UserQuota {
|
||||
is_member: bool,
|
||||
#[sqlx(rename = "membership_expires_at")]
|
||||
membership_expires_at: Option<chrono::DateTime<Utc>>,
|
||||
data_count: i64,
|
||||
}
|
||||
|
||||
let quota = sqlx::query_as::<_, UserQuota>(
|
||||
r#"
|
||||
SELECT
|
||||
u.is_member,
|
||||
u.membership_expires_at,
|
||||
(SELECT COUNT(*) FROM weather_data w WHERE w.user_id = $1)::BIGINT AS data_count
|
||||
FROM users u
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Database(format!("查询用户配额失败: {}", e)))?
|
||||
.ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?;
|
||||
|
||||
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());
|
||||
|
||||
if !is_active_member {
|
||||
let current_count = count_user_weather_data(pool, user_id).await?;
|
||||
let limit: i64 = if is_maintenance {
|
||||
env::var("MAINTENANCE_MODE_DATA_LIMIT")
|
||||
.unwrap_or_else(|_| "500".to_string())
|
||||
@@ -30,7 +52,7 @@ pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user
|
||||
.unwrap_or(20)
|
||||
};
|
||||
|
||||
if current_count >= limit {
|
||||
if quota.data_count >= limit {
|
||||
return Err(AppError::Forbidden("数据条数已达上限,请升级为付费用户".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -348,47 +370,6 @@ pub async fn get_user_orders(
|
||||
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 天的用户,发送即将到期通知
|
||||
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
|
||||
let affected = sqlx::query(
|
||||
@@ -578,14 +559,21 @@ pub async fn confirm_payment_order(
|
||||
}
|
||||
|
||||
if status != "pending" {
|
||||
return Err(AppError::BadRequest("订单状态异常,无法确认支付".to_string()));
|
||||
let hint = if status == "paid" {
|
||||
"该订单已支付成功,无需重复操作"
|
||||
} else if status == "cancelled" {
|
||||
"该订单已被取消,如已扣款请联系客服处理"
|
||||
} else {
|
||||
"该订单状态异常,请联系客服"
|
||||
};
|
||||
return Err(AppError::BadRequest(hint.to_string()));
|
||||
}
|
||||
|
||||
// 一次性完成:更新订单状态 + 累加计算新的到期时间(纯 SQL)
|
||||
let new_expires: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
|
||||
r#"
|
||||
WITH updated_order AS (
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 RETURNING package_type
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status = 'pending' RETURNING package_type
|
||||
)
|
||||
UPDATE users SET
|
||||
is_member = true,
|
||||
@@ -643,7 +631,10 @@ pub async fn confirm_payment_order_by_orderno(
|
||||
None => return Err(AppError::NotFound("订单不存在".to_string())),
|
||||
};
|
||||
|
||||
if status != "pending" {
|
||||
// 支付宝回调已通过 RSA2 验证。如果订单被用户误取消,重新激活。
|
||||
if status == "cancelled" {
|
||||
tracing::warn!("订单 {} 已被取消,但支付宝确认已收款,重新激活并处理支付", order_no);
|
||||
} else if status != "pending" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -658,16 +649,17 @@ pub async fn confirm_payment_order_by_orderno(
|
||||
.flatten();
|
||||
|
||||
// 一次性完成:更新订单状态 + 累加计算新的到期时间
|
||||
sqlx::query(
|
||||
// 加上 AND status = 'pending' 防止竞态覆盖已取消/退款的订单
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
WITH updated_order AS (
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 RETURNING package_type, user_id
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status IN ('pending', 'cancelled') RETURNING package_type, user_id
|
||||
)
|
||||
UPDATE users SET
|
||||
is_member = true,
|
||||
membership_expires_at =
|
||||
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()) +
|
||||
CASE
|
||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||
@@ -686,11 +678,21 @@ pub async fn confirm_payment_order_by_orderno(
|
||||
.await
|
||||
.map_err(|e| AppError::Database(format!("支付确认失败: {}", e)))?;
|
||||
|
||||
// 0 行 update 说明订单已被取消/退款/已确认(竞态保护生效)
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!("支付确认: 订单 {} 状态已变更,跳过处理(可能是竞态或重复回调)", order_no);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 审计日志
|
||||
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 {
|
||||
@@ -769,7 +771,9 @@ pub async fn refund_payment_order(
|
||||
|
||||
// 审计日志
|
||||
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(())
|
||||
}
|
||||
@@ -783,7 +787,7 @@ pub async fn get_user_quota(
|
||||
) -> Result<(i64, bool, Option<chrono::DateTime<chrono::Utc>>, bool), AppError> {
|
||||
let user = get_user_by_id(pool, user_id).await?;
|
||||
let is_active_member = user.is_member
|
||||
&& user.membership_expires_at.map_or(true, |expires| expires > chrono::Utc::now());
|
||||
&& user.membership_expires_at.is_none_or(|expires| expires > chrono::Utc::now());
|
||||
let used = count_user_weather_data(pool, user_id).await?;
|
||||
Ok((used, is_active_member, user.membership_expires_at, user.is_member))
|
||||
}
|
||||
@@ -862,10 +866,14 @@ pub async fn set_weather_favorite(
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct RefreshToken {
|
||||
#[allow(dead_code)] // sqlx deserialization only; not read directly
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
#[allow(dead_code)] // sqlx deserialization only
|
||||
pub token: String,
|
||||
#[allow(dead_code)] // sqlx deserialization only
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
#[allow(dead_code)] // sqlx deserialization only
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -909,6 +917,7 @@ pub async fn verify_refresh_token(
|
||||
.ok_or_else(|| AppError::Unauthorized("Refresh token 无效或已过期".to_string()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete_refresh_token(pool: &PgPool, user_id: i32) -> Result<(), AppError> {
|
||||
let query = r#"DELETE FROM refresh_tokens WHERE user_id = $1"#;
|
||||
|
||||
@@ -1019,22 +1028,22 @@ pub async fn admin_force_confirm_order(
|
||||
let result = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
||||
r#"
|
||||
WITH updated_order AS (
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 RETURNING package_type
|
||||
UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1 AND status = 'pending' RETURNING package_type
|
||||
)
|
||||
UPDATE users SET
|
||||
is_member = true,
|
||||
membership_expires_at =
|
||||
CASE
|
||||
WHEN uo.package_type = 'permanent' THEN NULL
|
||||
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
||||
CASE
|
||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
|
||||
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
|
||||
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
|
||||
ELSE INTERVAL '0 days'
|
||||
END
|
||||
END
|
||||
CASE
|
||||
WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ
|
||||
ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) +
|
||||
CASE
|
||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||
WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days'
|
||||
WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days'
|
||||
WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days'
|
||||
ELSE INTERVAL '0 days'
|
||||
END
|
||||
END
|
||||
FROM updated_order uo
|
||||
WHERE users.id = $2
|
||||
RETURNING users.membership_expires_at
|
||||
@@ -1047,7 +1056,7 @@ pub async fn admin_force_confirm_order(
|
||||
.map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?;
|
||||
|
||||
// 写审计日志
|
||||
let _ = insert_payment_audit_log(
|
||||
if let Err(e) = insert_payment_audit_log(
|
||||
pool,
|
||||
order_no,
|
||||
user_id,
|
||||
@@ -1055,7 +1064,10 @@ pub async fn admin_force_confirm_order(
|
||||
Some(admin_user_id),
|
||||
Some("管理员强制确认支付"),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||
}
|
||||
|
||||
// 验证会员到期时间是否正常
|
||||
if let Err(e) = verify_membership_after_payment(pool, order_no).await {
|
||||
@@ -1065,7 +1077,7 @@ pub async fn admin_force_confirm_order(
|
||||
result
|
||||
} else {
|
||||
// 订单已处理,只写日志
|
||||
let _ = insert_payment_audit_log(
|
||||
if let Err(e) = insert_payment_audit_log(
|
||||
pool,
|
||||
order_no,
|
||||
user_id,
|
||||
@@ -1073,7 +1085,10 @@ pub async fn admin_force_confirm_order(
|
||||
Some(admin_user_id),
|
||||
Some(&format!("订单状态为 {},跳过确认", status)),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||
}
|
||||
|
||||
// 返回当前到期时间
|
||||
sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
|
||||
@@ -1116,14 +1131,33 @@ pub async fn cancel_payment_order(
|
||||
return Err(AppError::BadRequest(format!("订单状态为 {},无法取消", status)));
|
||||
}
|
||||
|
||||
// 防止用户误取消:支付宝支付确认通常在 5-30 秒内到达
|
||||
// 2 分钟内的订单不允许取消,避免用户付款后误触取消按钮
|
||||
let order_age: f64 = sqlx::query_scalar(
|
||||
r#"SELECT EXTRACT(EPOCH FROM (NOW() - created_at)) FROM payment_orders WHERE order_no = $1"#,
|
||||
)
|
||||
.bind(order_no)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Database(format!("查询订单创建时间失败: {}", e)))?;
|
||||
|
||||
if order_age < 120.0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"订单刚刚创建,支付可能仍在处理中,请 2 分钟后再试".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE payment_orders SET status = 'cancelled' WHERE order_no = $1")
|
||||
.bind(order_no)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?;
|
||||
|
||||
let _ = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
|
||||
Some("用户主动取消待支付订单")).await;
|
||||
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id),
|
||||
Some("用户主动取消待支付订单")).await
|
||||
{
|
||||
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1196,8 +1230,11 @@ pub async fn admin_refund_order(
|
||||
}
|
||||
|
||||
let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" };
|
||||
let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded",
|
||||
Some(admin_user_id), Some(detail)).await;
|
||||
if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded",
|
||||
Some(admin_user_id), Some(detail)).await
|
||||
{
|
||||
tracing::warn!("插入支付审计日志失败: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
24
src/error.rs
24
src/error.rs
@@ -11,14 +11,6 @@ pub struct ErrorResponse<T = ()> {
|
||||
}
|
||||
|
||||
impl<T> ErrorResponse<T> {
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
@@ -28,22 +20,6 @@ impl<T> ErrorResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> ErrorResponse<T> {
|
||||
pub fn to_json_response(self) -> HttpResponse {
|
||||
HttpResponse::Ok().json(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorResponse<()> {
|
||||
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)]
|
||||
pub enum AppError {
|
||||
Unauthorized(String),
|
||||
|
||||
@@ -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?;
|
||||
// 审计日志
|
||||
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",
|
||||
Some(claims.user_id),
|
||||
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!({
|
||||
"success": true,
|
||||
"message": "用户付费状态已更新"
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use reqwest::Client;
|
||||
use sqlx::postgres::PgPool;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{generate_token, generate_refresh_token};
|
||||
use crate::db;
|
||||
@@ -83,7 +84,11 @@ pub async fn login(
|
||||
|
||||
let openid = match wechat_data.openid {
|
||||
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);
|
||||
id
|
||||
}
|
||||
@@ -149,7 +154,31 @@ pub async fn refresh_token(
|
||||
pool: web::Data<PgPool>,
|
||||
req: web::Json<RefreshTokenRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
http_req: HttpRequest,
|
||||
) -> impl Responder {
|
||||
let client_ip = http_req
|
||||
.headers()
|
||||
.get("X-Forwarded-For")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||
.or_else(|| {
|
||||
http_req
|
||||
.headers()
|
||||
.get("X-Real-IP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
http_req
|
||||
.peer_addr()
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
if let Err(e) = LOGIN_RATE_LIMITER.check_rate_limit(&client_ip).await {
|
||||
warn!("refresh_token 请求被限流: client_ip={}", client_ip);
|
||||
return HttpResponse::TooManyRequests().json(ErrorResponse::<()>::error(e.to_string()));
|
||||
}
|
||||
|
||||
let refresh_token = &req.refresh_token;
|
||||
|
||||
let refresh_token_record = match db::verify_refresh_token(pool.get_ref(), refresh_token).await {
|
||||
@@ -229,6 +258,99 @@ pub struct MockLoginResponse {
|
||||
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
|
||||
/// 沙箱测试用:无需微信 code,直接返回 JWT
|
||||
/// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用
|
||||
@@ -247,27 +369,31 @@ pub async fn mock_login(
|
||||
}
|
||||
|
||||
// 如果设置了 MOCK_LOGIN_KEY,验证请求头
|
||||
if let Ok(key) = std::env::var("MOCK_LOGIN_KEY") {
|
||||
if !key.is_empty() {
|
||||
let header_key = req.headers()
|
||||
.get("X-Mock-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if header_key != key {
|
||||
return HttpResponse::Forbidden().json(ErrorResponse::<()>::error("模拟登录密钥错误"));
|
||||
}
|
||||
if let Ok(key) = std::env::var("MOCK_LOGIN_KEY") && !key.is_empty() {
|
||||
let header_key = req.headers()
|
||||
.get("X-Mock-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if header_key != key {
|
||||
return HttpResponse::Forbidden().json(ErrorResponse::<()>::error("模拟登录密钥错误"));
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = match query.user_id {
|
||||
Some(id) => {
|
||||
// 验证用户存在
|
||||
match sqlx::query_as::<_, (i32,)>("SELECT id FROM users WHERE id = $1" )
|
||||
// 验证用户存在,同时检查是否管理员
|
||||
match sqlx::query_as::<_, (i32, bool)>("SELECT id, is_admin FROM users WHERE id = $1" )
|
||||
.bind(id)
|
||||
.fetch_optional(pool.get_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some((uid,))) => uid,
|
||||
Ok(Some((uid, is_admin))) => {
|
||||
if is_admin {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(ErrorResponse::<()>::error("模拟登录不能用于管理员账户"));
|
||||
}
|
||||
uid
|
||||
},
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(ErrorResponse::<()>::error("用户不存在"));
|
||||
@@ -471,7 +597,7 @@ pub async fn web_generate_login_code(
|
||||
ts % 0xFFFF,
|
||||
&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();
|
||||
|
||||
// 4. 存入临时表(关联 user_id)
|
||||
@@ -613,7 +739,7 @@ pub async fn web_login_confirm(
|
||||
.await
|
||||
{
|
||||
Ok(Some((is_member, expires))) => {
|
||||
let active = is_member && expires.map_or(true, |e| e > Utc::now());
|
||||
let active = is_member && expires.is_none_or(|e| e > Utc::now());
|
||||
(active, expires.map(|e| e.to_rfc3339()))
|
||||
}
|
||||
_ => (false, None),
|
||||
@@ -745,7 +871,7 @@ pub async fn web_login_auto_confirm(
|
||||
|
||||
let (is_active_member, membership_expires_at): (bool, Option<String>) = match paid_info {
|
||||
Some((is_member, expires)) => {
|
||||
let active = is_member && expires.map_or(true, |e| e > Utc::now());
|
||||
let active = is_member && expires.is_none_or(|e| e > Utc::now());
|
||||
(active, expires.map(|e| e.to_rfc3339()))
|
||||
}
|
||||
None => (false, None),
|
||||
|
||||
@@ -1,24 +1,90 @@
|
||||
use actix_web::{web, get, HttpResponse, Responder};
|
||||
use sqlx::postgres::PgPool;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use openssl::x509::X509;
|
||||
use serde::Serialize;
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::path::Path;
|
||||
|
||||
/// SSL 证书文件路径(生产环境优先)
|
||||
const SSL_CERT_PATHS: &[&str] = &[
|
||||
"/www/sites/xmclassmate.top/ssl/fullchain.pem",
|
||||
"/etc/nginx/sites/xmclassmate.top/ssl/fullchain.pem",
|
||||
];
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub database: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssl_cert: Option<SslCertInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SslCertInfo {
|
||||
pub subject: String,
|
||||
pub issuer: String,
|
||||
pub not_before: String,
|
||||
pub not_after: String,
|
||||
pub days_remaining: i64,
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// 读取 SSL 证书文件并提取有效期信息
|
||||
fn check_ssl_cert() -> Option<SslCertInfo> {
|
||||
let cert_path = SSL_CERT_PATHS.iter().find(|p| Path::new(p).exists())?;
|
||||
|
||||
let pem_bytes = std::fs::read(cert_path).ok()?;
|
||||
let cert = X509::from_pem(&pem_bytes).ok()?;
|
||||
|
||||
let subject = cert
|
||||
.subject_name()
|
||||
.entries()
|
||||
.next()
|
||||
.and_then(|e| e.data().as_utf8().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let issuer = cert
|
||||
.issuer_name()
|
||||
.entries()
|
||||
.next()
|
||||
.and_then(|e| e.data().as_utf8().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// OpenSSL Display 格式: "Jun 17 02:39:23 2026 GMT"
|
||||
let nb_str = format!("{}", cert.not_before());
|
||||
let na_str = format!("{}", cert.not_after());
|
||||
let not_before = NaiveDateTime::parse_from_str(&nb_str, "%b %e %H:%M:%S %Y GMT").ok()?;
|
||||
let not_after = NaiveDateTime::parse_from_str(&na_str, "%b %e %H:%M:%S %Y GMT").ok()?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let days_remaining = (not_after - now).num_days();
|
||||
let valid = now >= not_before && now <= not_after;
|
||||
|
||||
Some(SslCertInfo {
|
||||
subject,
|
||||
issuer,
|
||||
not_before: not_before.format("%Y-%m-%d %H:%M UTC").to_string(),
|
||||
not_after: not_after.format("%Y-%m-%d %H:%M UTC").to_string(),
|
||||
days_remaining,
|
||||
valid,
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/health")]
|
||||
pub async fn health_check(pool: web::Data<PgPool>) -> impl Responder {
|
||||
let ssl_cert = check_ssl_cert();
|
||||
|
||||
match sqlx::query("SELECT 1").fetch_one(pool.get_ref()).await {
|
||||
Ok(_) => {
|
||||
HttpResponse::Ok().json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
database: "connected".to_string(),
|
||||
ssl_cert,
|
||||
error: None,
|
||||
hint: None,
|
||||
})
|
||||
@@ -54,6 +120,7 @@ pub async fn health_check(pool: web::Data<PgPool>) -> impl Responder {
|
||||
HttpResponse::ServiceUnavailable().json(HealthResponse {
|
||||
status: "error".to_string(),
|
||||
database: "disconnected".to_string(),
|
||||
ssl_cert,
|
||||
error: Some(error_msg),
|
||||
hint: Some(hint_msg),
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>环境计算助手 — 环境监测工具</title>
|
||||
<title>小明计算助手</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@@ -135,7 +135,7 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="hero">
|
||||
<h1>🌤 环境计算助手</h1>
|
||||
<h1>🌤 小明计算助手</h1>
|
||||
<p>环境监测领域的专业计算工具,快速判定大气稳定度等级,判断无组织排放监测适宜性</p>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +194,7 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
<h2>访问方式</h2>
|
||||
<div style="text-align:center; margin:20px 0;">
|
||||
<p style="margin-bottom:16px; color:#666; font-size:15px;">
|
||||
请在微信中搜索「环境计算助手」小程序,或扫描下方二维码打开。
|
||||
请在微信中搜索「小明计算助手」小程序,或扫描下方二维码打开。
|
||||
</p>
|
||||
<img src="/static/QR.jpg"
|
||||
alt="微信小程序二维码"
|
||||
@@ -208,7 +208,8 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>环境计算助手 · v{version}</p>
|
||||
<p>小明计算助手 · v{version}</p>
|
||||
<p style="margin-top:4px;"><a href="http://beian.miit.gov.cn/" target="_blank" style="color:#999;text-decoration:none;">浙ICP备2026052792号</a></p>
|
||||
<p style="margin-top:4px;">如有问题请联系客服</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ pub use admin::admin_force_confirm_order;
|
||||
pub use admin::admin_refund_order;
|
||||
pub use auth::login;
|
||||
pub use auth::mock_login;
|
||||
pub use auth::web_guest_login;
|
||||
pub use auth::refresh_token;
|
||||
pub use auth::web_generate_login_code;
|
||||
pub use auth::web_login_confirm;
|
||||
|
||||
@@ -143,6 +143,7 @@ function confirmMockPay() {{
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
<div style="text-align:center;margin-top:32px;padding:16px;color:#999;font-size:12px;"><a href="http://beian.miit.gov.cn/" target="_blank" style="color:#999;text-decoration:none;">浙ICP备2026052792号</a></div>
|
||||
</body>
|
||||
</html>"#, order_no, display_name, jwt, order_no)
|
||||
}
|
||||
@@ -152,9 +153,15 @@ fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool,
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
use rsa::RsaPublicKey;
|
||||
use sha2::Digest;
|
||||
let public_key =
|
||||
RsaPublicKey::from_public_key_pem(public_key_pem)
|
||||
.map_err(|e| format!("支付宝公钥解析失败: {}", e))?;
|
||||
let public_key = RsaPublicKey::from_public_key_pem(public_key_pem).or_else(|_| {
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, public_key_pem)
|
||||
.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)
|
||||
.map_err(|e| format!("签名 Base64 解码失败: {}", e))?;
|
||||
let hashed = Sha256::digest(content.as_bytes());
|
||||
@@ -164,6 +171,17 @@ fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool,
|
||||
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 =====
|
||||
|
||||
fn build_alipay_form_html(
|
||||
@@ -173,12 +191,17 @@ fn build_alipay_form_html(
|
||||
subject: &str,
|
||||
notify_url: &str,
|
||||
return_url: &str,
|
||||
_is_mobile: bool, // 保留参数:待支付宝开通 wap.pay 后启用
|
||||
) -> 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!({
|
||||
"out_trade_no": out_trade_no,
|
||||
"total_amount": total_amount,
|
||||
"subject": subject,
|
||||
"product_code": "FAST_INSTANT_TRADE_PAY",
|
||||
"product_code": product_code,
|
||||
});
|
||||
|
||||
let biz_content_str =
|
||||
@@ -187,7 +210,7 @@ fn build_alipay_form_html(
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let mut params: BTreeMap<&str, &str> = BTreeMap::new();
|
||||
params.insert("app_id", &config.app_id);
|
||||
params.insert("method", "alipay.trade.page.pay");
|
||||
params.insert("method", method);
|
||||
params.insert("format", "JSON");
|
||||
params.insert("charset", "utf-8");
|
||||
params.insert("sign_type", "RSA2");
|
||||
@@ -234,6 +257,7 @@ fn build_alipay_form_html(
|
||||
{}
|
||||
</form>
|
||||
<script>document.getElementById('alipay').submit();</script>
|
||||
<div style="text-align:center;margin-top:16px;color:#999;font-size:12px;"><a href="http://beian.miit.gov.cn/" target="_blank" style="color:#999;text-decoration:none;">浙ICP备2026052792号</a></div>
|
||||
</body>
|
||||
</html>"##,
|
||||
config.gateway, form_fields
|
||||
@@ -254,6 +278,20 @@ fn check_payment_maintenance() -> Result<(), AppError> {
|
||||
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> {
|
||||
req.headers()
|
||||
.get("Authorization")?
|
||||
@@ -263,8 +301,10 @@ fn extract_token(req: &HttpRequest) -> Option<String> {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn get_jwt_secret() -> String {
|
||||
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set")
|
||||
fn get_jwt_secret() -> Result<String, AppError> {
|
||||
std::env::var("JWT_SECRET").map_err(|_| {
|
||||
AppError::Internal("JWT_SECRET 环境变量未设置".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
|
||||
@@ -277,7 +317,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>开通会员 - 环境计算助手</title>
|
||||
<title>开通会员 - 小明计算助手</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; }
|
||||
@@ -305,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: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; }
|
||||
|
||||
/* ===== 访客登录 ===== */
|
||||
.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-status { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 36px 20px; text-align: center; }
|
||||
@@ -369,18 +417,27 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
|
||||
<!-- 登录区(等待授权) -->
|
||||
<div class="login-section" id="loginSection">
|
||||
<div class="login-card">
|
||||
<div class="login-icon">🔒</div>
|
||||
<div class="login-title">等待授权</div>
|
||||
<div class="login-desc" id="loginDesc">请在微信小程序中<br>点击「去授权」完成登录</div>
|
||||
<div class="login-card">
|
||||
<div class="login-icon">🔓</div>
|
||||
<div class="login-title" id="loginTitle">登录</div>
|
||||
<div class="login-desc" id="loginDesc">选择登录方式:</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-label">登录码</div>
|
||||
<div class="code-value" id="codeValue">--</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>
|
||||
@@ -467,6 +524,9 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<div class="footer-links">
|
||||
<button class="btn-logout" onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:12px;padding:8px 16px;color:#aaa;font-size:11px;">
|
||||
<a href="http://beian.miit.gov.cn/" target="_blank" style="color:#aaa;text-decoration:none;">浙ICP备2026052792号</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -493,14 +553,52 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
currentShortCode = urlCode;
|
||||
document.getElementById('codeValue').textContent = currentShortCode;
|
||||
document.getElementById('codeDisplay').style.display = 'block';
|
||||
document.getElementById('mpBtn').style.display = 'none';
|
||||
document.getElementById('loginDesc').textContent = '请在微信小程序中输入此登录码:';
|
||||
document.getElementById('scanStatus').style.display = 'block';
|
||||
document.getElementById('loginDesc').style.display = 'none';
|
||||
// 启动轮询
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
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) {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/user/profile', {
|
||||
@@ -652,7 +750,8 @@ pub async fn payment_page(
|
||||
.or_else(|| query.jwt.clone())
|
||||
.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()))?;
|
||||
|
||||
let pkg = get_package_info(&query.package_)
|
||||
@@ -707,12 +806,12 @@ pub async fn payment_page(
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = std::env::var("APP_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
|
||||
let base_url = get_base_url(&req);
|
||||
let notify_url = format!("{}/payment/notify", base_url);
|
||||
let return_url = format!("{}/payment/success?order_no={}", base_url, order_no);
|
||||
let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token);
|
||||
|
||||
let Some(config) = AlipayConfig::from_env() else {
|
||||
check_mock_payment_allowed(&req)?;
|
||||
let jwt_for_mock = token.clone();
|
||||
let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock);
|
||||
return Ok(HttpResponse::Ok()
|
||||
@@ -722,6 +821,14 @@ pub async fn payment_page(
|
||||
|
||||
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(
|
||||
&config,
|
||||
&order_no,
|
||||
@@ -729,6 +836,7 @@ pub async fn payment_page(
|
||||
pkg.display_name,
|
||||
¬ify_url,
|
||||
&return_url,
|
||||
mobile,
|
||||
) {
|
||||
Ok(form_html) => Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
@@ -757,14 +865,14 @@ pub async fn alipay_pay_page(
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
check_payment_maintenance()?;
|
||||
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()))?;
|
||||
|
||||
let pkg = get_package_info(&query.package_type)
|
||||
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
|
||||
|
||||
let base_url = std::env::var("APP_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
|
||||
let base_url = get_base_url(&req);
|
||||
let notify_url = format!("{}/payment/notify", base_url);
|
||||
let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no);
|
||||
|
||||
@@ -778,6 +886,13 @@ pub async fn alipay_pay_page(
|
||||
|
||||
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(
|
||||
&config,
|
||||
&query.order_no,
|
||||
@@ -785,6 +900,7 @@ pub async fn alipay_pay_page(
|
||||
pkg.display_name,
|
||||
¬ify_url,
|
||||
&return_url,
|
||||
mobile,
|
||||
) {
|
||||
Ok(form_html) => Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
@@ -800,12 +916,101 @@ pub async fn alipay_pay_page(
|
||||
|
||||
// ===== Handler: POST /payment/notify — 支付宝异步回调 =====
|
||||
|
||||
/// 解析 x-www-form-urlencoded 请求体,支持 UTF-8 和 GBK/GB2312 编码回退
|
||||
fn parse_alipay_form(body: &[u8]) -> BTreeMap<String, String> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// 先尝试 UTF-8,失败则回退到 GBK/GB2312
|
||||
let body_str = match String::from_utf8(body.to_vec()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// GBK/GB2312 回退
|
||||
if let Some(enc) = encoding_rs::Encoding::for_label(b"gbk") {
|
||||
let (s, _enc, had_errors) = enc.decode(body);
|
||||
if had_errors {
|
||||
tracing::warn!("支付宝回调 GBK 解码有部分字节失败");
|
||||
} else {
|
||||
tracing::info!("支付宝回调使用 GBK 编码解码成功");
|
||||
}
|
||||
s.into_owned()
|
||||
} else {
|
||||
// 最后兜底:lossy UTF-8
|
||||
tracing::warn!("支付宝回调编码未知,使用 lossy UTF-8 兜底");
|
||||
String::from_utf8_lossy(body).into_owned()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if body_str.is_empty() && !body.is_empty() {
|
||||
tracing::warn!("支付宝回调解析结果为空(原始 {} 字节)", body.len());
|
||||
}
|
||||
|
||||
// 手动解析 x-www-form-urlencoded(兼容各种 charset)
|
||||
let mut map = BTreeMap::new();
|
||||
for pair in body_str.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
let key = urlencoding(k);
|
||||
let val = urlencoding(v);
|
||||
map.insert(key, val);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 手动 URL 解码(percent-decoding)
|
||||
/// 注意:`+` 始终解码为空格(form-urlencoded 标准),而非仅在 `%` 转义之后
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let input: Vec<u8> = s.bytes().collect();
|
||||
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
let b = input[i];
|
||||
if b == b'%' {
|
||||
let hi = input.get(i + 1).and_then(|&c| hex_val(c));
|
||||
let lo = input.get(i + 2).and_then(|&c| hex_val(c));
|
||||
if let (Some(h), Some(l)) = (hi, lo) {
|
||||
bytes.push(h << 4 | l);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
// 不完整的转义:原样保留 '%',后续字符照常处理
|
||||
}
|
||||
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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_val(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/payment/notify")]
|
||||
pub async fn alipay_notify(
|
||||
pool: web::Data<PgPool>,
|
||||
body: web::Form<BTreeMap<String, String>>,
|
||||
body_bytes: web::Bytes,
|
||||
) -> HttpResponse {
|
||||
let body = body.into_inner();
|
||||
let body = parse_alipay_form(&body_bytes);
|
||||
|
||||
let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default();
|
||||
let trade_status = body.get("trade_status").cloned().unwrap_or_default();
|
||||
@@ -838,7 +1043,7 @@ pub async fn alipay_notify(
|
||||
.join("&");
|
||||
|
||||
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
|
||||
tracing::warn!("支付宝签名验证失败: {}", e);
|
||||
tracing::error!("支付宝回调签名验证失败: {}", e);
|
||||
return HttpResponse::Ok().body("fail");
|
||||
}
|
||||
|
||||
@@ -855,14 +1060,16 @@ pub async fn alipay_notify(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===== Handler: GET /payment/success — 支付成功页面 =====
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlipaySuccessQuery {
|
||||
pub order_no: Option<String>,
|
||||
pub jwt: Option<String>,
|
||||
}
|
||||
|
||||
fn build_success_html(order_no: &str) -> String {
|
||||
fn build_success_html(order_no: &str, jwt_token: &str) -> String {
|
||||
let green = "#52c41a";
|
||||
let white = "white";
|
||||
let orange = "#fa8c16";
|
||||
@@ -901,6 +1108,11 @@ fn build_success_html(order_no: &str) -> String {
|
||||
<div class="tip">系统会自动处理,无需重复操作</div>
|
||||
<div class="order-no">订单号: {2}</div>
|
||||
</div>
|
||||
<div id="timeoutView" style="display:none">
|
||||
<p style="color:#666;font-size:14px">确认时间稍长,请返回小程序查看订单状态</p>
|
||||
<p style="color:#999;font-size:12px">系统会在确认后自动为您开通会员,请勿重复支付</p>
|
||||
<div class="order-no">订单号: {2}</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function poll(){{
|
||||
@@ -908,7 +1120,7 @@ fn build_success_html(order_no: &str) -> String {
|
||||
if (!orderNo) return;
|
||||
fetch('/api/payment/sync-order', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
headers: {{ 'Content-Type': 'application/json', 'Authorization': 'Bearer {4}' }},
|
||||
body: JSON.stringify({{ order_id: orderNo }})
|
||||
}})
|
||||
.then(function(r){{ return r.json(); }})
|
||||
@@ -916,16 +1128,29 @@ fn build_success_html(order_no: &str) -> String {
|
||||
if (data.success && data.data.order_status === 'paid') {{
|
||||
document.getElementById('confirmedView').style.display = 'block';
|
||||
document.getElementById('pendingView').style.display = 'none';
|
||||
document.getElementById('timeoutView').style.display = 'none';
|
||||
}} else if (data.success && data.data.order_status === 'cancelled') {{
|
||||
// 订单被取消(可能是误操作),继续轮询等待支付宝回调重新激活
|
||||
setTimeout(poll, 2000);
|
||||
}} else {{
|
||||
setTimeout(poll, 2000);
|
||||
}}
|
||||
}})
|
||||
.catch(function(){{ setTimeout(poll, 2000); }});
|
||||
}})();
|
||||
// 30 秒后显示返回提示(不阻断轮询)
|
||||
setTimeout(function(){{
|
||||
if (document.getElementById('confirmedView').style.display !== 'block') {{
|
||||
document.getElementById('timeoutView').style.display = 'block';
|
||||
}}
|
||||
}}, 30000);
|
||||
</script>
|
||||
<div style="text-align:center;margin-top:24px;padding:12px;color:#999;font-size:11px;">
|
||||
<a href="http://beian.miit.gov.cn/" target="_blank" style="color:#999;text-decoration:none;">浙ICP备2026052792号</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"##,
|
||||
green, white, order_no, orange
|
||||
green, white, order_no, orange, jwt_token
|
||||
);
|
||||
html
|
||||
}
|
||||
@@ -940,6 +1165,9 @@ fn format_error_html(msg: &str, order_no: &str) -> String {
|
||||
<p style="color:#666">{}</p>
|
||||
<p style="color:#999;font-size:13px">订单号: {}</p>
|
||||
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
|
||||
<div style="text-align:center;margin-top:24px;padding:12px;color:#999;font-size:11px;">
|
||||
<a href="http://beian.miit.gov.cn/" target="_blank" style="color:#999;text-decoration:none;">浙ICP备2026052792号</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"##,
|
||||
msg, order_no
|
||||
@@ -952,11 +1180,12 @@ pub async fn payment_success(
|
||||
query: web::Query<AlipaySuccessQuery>,
|
||||
) -> HttpResponse {
|
||||
let order_no = query.order_no.as_deref().unwrap_or("");
|
||||
let jwt_token = query.jwt.as_deref().unwrap_or("");
|
||||
|
||||
// 不再在此处确认订单(安全原因: 此端点无认证, 任何人知道 order_no 即可激活会员)。
|
||||
// Mock 支付由 mock_confirm 在跳转前确认, 真实支付宝由 notify 异步回调确认。
|
||||
|
||||
let html = build_success_html(order_no);
|
||||
let html = build_success_html(order_no, jwt_token);
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html)
|
||||
@@ -967,9 +1196,9 @@ pub async fn payment_success(
|
||||
#[post("/payment/refund-notify")]
|
||||
pub async fn alipay_refund_notify(
|
||||
pool: web::Data<PgPool>,
|
||||
body: web::Form<BTreeMap<String, String>>,
|
||||
body_bytes: web::Bytes,
|
||||
) -> HttpResponse {
|
||||
let body = body.into_inner();
|
||||
let body = parse_alipay_form(&body_bytes);
|
||||
|
||||
let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default();
|
||||
let refund_status = body.get("refund_status").cloned().unwrap_or_default();
|
||||
@@ -1000,7 +1229,7 @@ pub async fn alipay_refund_notify(
|
||||
.join("&");
|
||||
|
||||
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
|
||||
tracing::warn!("支付宝退款签名验证失败: {}", e);
|
||||
tracing::error!("支付宝退款回调签名验证失败: {}", e);
|
||||
return HttpResponse::Ok().body("fail");
|
||||
}
|
||||
|
||||
@@ -1079,16 +1308,14 @@ fn check_mock_payment_allowed(req: &HttpRequest) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
// 规则 3:如果设了 MOCK_PAY_KEY,验证请求头
|
||||
if let Ok(key) = std::env::var("MOCK_PAY_KEY") {
|
||||
if !key.is_empty() {
|
||||
let header_key = req
|
||||
.headers()
|
||||
.get("X-Mock-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if header_key != key {
|
||||
return Err(AppError::Forbidden("Mock 支付密钥错误".to_string()));
|
||||
}
|
||||
if let Ok(key) = std::env::var("MOCK_PAY_KEY") && !key.is_empty() {
|
||||
let header_key = req
|
||||
.headers()
|
||||
.get("X-Mock-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if header_key != key {
|
||||
return Err(AppError::Forbidden("Mock 支付密钥错误".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,7 +1381,7 @@ pub async fn sync_order(
|
||||
.ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?;
|
||||
|
||||
let (is_member, membership_expires_at) = user;
|
||||
let is_active_member = is_member && membership_expires_at.map_or(true, |expires| expires > Utc::now());
|
||||
let is_active_member = is_member && membership_expires_at.is_none_or(|expires| expires > Utc::now());
|
||||
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
@@ -1315,6 +1542,7 @@ pub async fn payment_login_status(
|
||||
let code = query.code.trim();
|
||||
|
||||
// 查询登录码记录(包含 token 字段用于判断是否已确认)
|
||||
#[allow(clippy::type_complexity)]
|
||||
let record: Option<(String, chrono::DateTime<chrono::Utc>, Option<String>, Option<i32>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT code, expires_at, token, user_id FROM web_login_codes WHERE code = $1",
|
||||
@@ -1368,8 +1596,15 @@ pub async fn payment_login_status(
|
||||
}
|
||||
|
||||
// 已确认 → 使用已生成的 token
|
||||
let token = token.unwrap();
|
||||
let user_id = user_id.unwrap();
|
||||
let token = token.unwrap_or_default();
|
||||
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>) =
|
||||
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||
@@ -1380,7 +1615,7 @@ pub async fn payment_login_status(
|
||||
.await
|
||||
{
|
||||
Ok(Some((is_member, expires))) => {
|
||||
let active = is_member && expires.map_or(true, |e| e > Utc::now());
|
||||
let active = is_member && expires.is_none_or(|e| e > Utc::now());
|
||||
(active, expires.map(|e| e.to_rfc3339()))
|
||||
}
|
||||
_ => (false, None),
|
||||
@@ -1401,3 +1636,171 @@ pub async fn payment_login_status(
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SentryEventRequest {
|
||||
#[allow(dead_code)]
|
||||
pub level: Option<String>,
|
||||
pub message: String,
|
||||
#[allow(dead_code)]
|
||||
pub stack: Option<String>,
|
||||
pub page: Option<String>,
|
||||
pub user_id: Option<i32>,
|
||||
@@ -28,11 +30,11 @@ pub async fn report_frontend_error(body: web::Json<SentryEventRequest>) -> HttpR
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
if let Some(extra) = &body.extra {
|
||||
if let Some(obj) = extra.as_object() {
|
||||
for (k, v) in obj {
|
||||
scope.set_extra(k, sentry::protocol::Value::from(v.clone()));
|
||||
}
|
||||
if let Some(extra) = &body.extra
|
||||
&& let Some(obj) = extra.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
scope.set_extra(k, v.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ pub async fn serve_static_files(path: web::Path<String>) -> impl Responder {
|
||||
Some("gif") => "image/gif",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("webp") => "image/webp",
|
||||
Some("txt") => "text/plain",
|
||||
Some("css") => "text/css",
|
||||
Some("js") => "application/javascript",
|
||||
Some("html") => "text/html",
|
||||
|
||||
@@ -18,10 +18,12 @@ pub async fn get_current_user_profile(
|
||||
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_active_member = if is_maintenance {
|
||||
// 维护模式下所有已认证用户视为活跃会员,确保支付故障期间服务可用
|
||||
// 注意:此路由有 JWT 中间件保护,未认证请求已在前置中间件被拦截
|
||||
true
|
||||
} else {
|
||||
user.is_member &&
|
||||
user.membership_expires_at.map_or(true, |expires| expires > chrono::Utc::now())
|
||||
user.membership_expires_at.is_none_or(|expires| expires > chrono::Utc::now())
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
|
||||
71
src/main.rs
71
src/main.rs
@@ -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,
|
||||
post_weather_data, report_frontend_error,
|
||||
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;
|
||||
|
||||
@@ -85,6 +85,8 @@ fn create_server_config(
|
||||
.service(alipay_notify) // POST /payment/notify(支付宝异步回调)
|
||||
.service(alipay_refund_notify) // POST /payment/refund-notify(支付宝退款回调)
|
||||
.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)))
|
||||
// API 接口
|
||||
@@ -96,6 +98,7 @@ fn create_server_config(
|
||||
.service(web_generate_login_code)
|
||||
.service(web_login_confirm)
|
||||
.service(web_login_auto_confirm)
|
||||
.service(web_guest_login) // POST /api/guest-login — 访客登录(免微信)
|
||||
.service(report_frontend_error) // POST /api/sentry/events
|
||||
.service(
|
||||
web::scope("")
|
||||
@@ -154,7 +157,7 @@ async fn main() -> std::io::Result<()> {
|
||||
// Sentry 初始化(仅在 SENTRY_DSN 环境变量已设置时启用)
|
||||
let _sentry_guard = if let Ok(dsn) = std::env::var("SENTRY_DSN") {
|
||||
if !dsn.is_empty() {
|
||||
eprintln!("[SENTRY] Sentry 已初始化 (DSN: {})", dsn);
|
||||
info!("Sentry 已初始化");
|
||||
Some(sentry::init((
|
||||
dsn,
|
||||
sentry::ClientOptions {
|
||||
@@ -167,30 +170,14 @@ async fn main() -> std::io::Result<()> {
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
eprintln!("[SENTRY] SENTRY_DSN 为空,跳过 Sentry 初始化");
|
||||
info!("SENTRY_DSN 为空,跳过 Sentry 初始化");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
eprintln!("[SENTRY] 未设置 SENTRY_DSN,跳过 Sentry 初始化");
|
||||
info!("未设置 SENTRY_DSN,跳过 Sentry 初始化");
|
||||
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;
|
||||
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_thread_ids(false) // 生产环境可开启
|
||||
.with_thread_ids(false)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false) // 文件中不使用 ANSI 颜色
|
||||
.json() // JSON 格式便于 ELK 解析
|
||||
.with_ansi(true)
|
||||
.compact()
|
||||
.init();
|
||||
info!("日志系统初始化成功,JSON格式,输出到 ./logs/");
|
||||
info!("日志系统初始化成功,输出到 systemd journal (journalctl)");
|
||||
info!("日志级别: {}", app_config.rust_log);
|
||||
|
||||
let app_state = match AppState::load() {
|
||||
Ok(state) => state,
|
||||
@@ -243,7 +230,9 @@ async fn main() -> std::io::Result<()> {
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("创建 HTTP 客户端失败");
|
||||
.unwrap_or_else(|e| {
|
||||
panic!("创建 HTTP 客户端失败: {}", e);
|
||||
});
|
||||
|
||||
// 启动时清理:过期订单 + 过期登录码
|
||||
match db::cleanup_expired_pending_orders(&pool, None).await {
|
||||
@@ -255,28 +244,28 @@ async fn main() -> std::io::Result<()> {
|
||||
Err(e) => warn!("启动时清理 web 登录码失败: {}", e),
|
||||
}
|
||||
|
||||
// 后台定时扫描待支付订单(每 5 分钟)
|
||||
let pool_clone = pool.clone();
|
||||
actix_web::rt::spawn(async move {
|
||||
let mut interval = actix_web::rt::time::interval(std::time::Duration::from_secs(300));
|
||||
interval.tick().await; // 跳过立即执行
|
||||
loop {
|
||||
interval.tick().await;
|
||||
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),
|
||||
}
|
||||
tracing::info!("[定时任务] 开始...");
|
||||
|
||||
// 会员到期前 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 小时的过期待支付订单
|
||||
if let Err(e) = db::cleanup_expired_pending_orders(&pool_clone, None).await {
|
||||
tracing::error!("清理过期订单失败: {}", e);
|
||||
}
|
||||
|
||||
// 清理过期的 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -321,6 +310,9 @@ async fn main() -> std::io::Result<()> {
|
||||
match HttpServer::new(move || {
|
||||
create_server_config(pool_clone.clone(), http_client_clone.clone(), app_state_clone.clone())
|
||||
})
|
||||
.keep_alive(std::time::Duration::from_secs(30))
|
||||
.shutdown_timeout(10)
|
||||
.backlog(1024)
|
||||
.bind_openssl(&addr, ssl_builder)
|
||||
{
|
||||
Ok(s) => {
|
||||
@@ -342,6 +334,9 @@ async fn main() -> std::io::Result<()> {
|
||||
match HttpServer::new(move || {
|
||||
create_server_config(pool_clone.clone(), http_client_clone.clone(), app_state_clone.clone())
|
||||
})
|
||||
.keep_alive(std::time::Duration::from_secs(30))
|
||||
.shutdown_timeout(10)
|
||||
.backlog(1024)
|
||||
.bind(&addr)
|
||||
{
|
||||
Ok(s) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct Claims {
|
||||
pub user_id: i32,
|
||||
// 自定义字段:openid(可选)
|
||||
pub openid: String,
|
||||
// 自定义字段:user_ytpe
|
||||
// 自定义字段:user_type
|
||||
pub user_type: i32,
|
||||
}
|
||||
|
||||
@@ -51,22 +51,6 @@ impl LoginResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的 TokenResponse
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct TokenResponse {
|
||||
pub success: bool,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl TokenResponse {
|
||||
pub fn new(token: String) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Token 请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshTokenRequest {
|
||||
@@ -345,12 +329,6 @@ pub struct AppState {
|
||||
pub jwt_secret: String,
|
||||
pub wechat_appid: String,
|
||||
pub wechat_secret: String,
|
||||
pub free_user_data_limit: i32,
|
||||
// ===== 支付宝配置 =====
|
||||
pub alipay_app_id: Option<String>,
|
||||
pub alipay_private_key: Option<String>,
|
||||
pub alipay_alipay_public_key: Option<String>,
|
||||
pub alipay_gateway: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -362,14 +340,6 @@ impl AppState {
|
||||
.map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?,
|
||||
wechat_secret: std::env::var("WECHAT_SECRET")
|
||||
.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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String {
|
||||
headers
|
||||
.get("X-Forwarded-For")
|
||||
|
||||
11
static/robots.txt
Normal file
11
static/robots.txt
Normal 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$
|
||||
@@ -128,7 +128,7 @@ fn test_member_status_bounds() {
|
||||
(true, false, false, false), // 已过期会员
|
||||
];
|
||||
|
||||
for (is_member, expires_none, future, expected) in &cases {
|
||||
for (is_member, _expires_none, future, expected) in &cases {
|
||||
if *future {
|
||||
// 模拟会员在有效期内
|
||||
assert!(*expected == *is_member, "活跃时 is_active_member = is_member");
|
||||
|
||||
72
tests/payment_e2e_test.sh
Executable file
72
tests/payment_e2e_test.sh
Executable 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 页面"
|
||||
Reference in New Issue
Block a user