Compare commits
10 Commits
c00e391c9b
...
2385016f05
| Author | SHA1 | Date | |
|---|---|---|---|
| 2385016f05 | |||
| 178008496e | |||
| 6d066c8e82 | |||
| fb224c829f | |||
| b220368544 | |||
| 452728be32 | |||
| ac49ccb552 | |||
| 776d318330 | |||
| 43c349919d | |||
| e672f00b66 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2341,6 +2341,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"encoding_rs",
|
||||
"include_dir",
|
||||
"jsonwebtoken",
|
||||
"openssl",
|
||||
|
||||
@@ -27,6 +27,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"] }
|
||||
|
||||
53
deploy.sh
53
deploy.sh
@@ -2,6 +2,8 @@
|
||||
# ===========================================
|
||||
# Rust Backend Deployment Script
|
||||
# ===========================================
|
||||
# Rust Backend Deployment Script
|
||||
# ===========================================
|
||||
# Usage: ./deploy.sh [development|production] [options]
|
||||
#
|
||||
# Options:
|
||||
@@ -16,6 +18,8 @@
|
||||
# --post-logs 部署后显示日志
|
||||
# --init-env 初始化 systemd service 模板
|
||||
# --deploy-service 上传 systemd service 文件到服务器
|
||||
# --target blue|green 蓝绿部署目标(默认单实例)
|
||||
# --remote-host IP 部署目标服务器(默认 1panel-server)
|
||||
# ===========================================
|
||||
|
||||
set -euo pipefail
|
||||
@@ -53,6 +57,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 +65,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 部署目标服务器(默认 1panel-server)"
|
||||
echo " --rollback 回滚到指定备份"
|
||||
echo " --backup-list 列出可用备份"
|
||||
echo " --logs [N] 查看后端日志(默认50行)"
|
||||
@@ -79,11 +85,37 @@ 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" ;;
|
||||
# ---------- 蓝绿目标自动检测 + 配置 ----------
|
||||
# 优先级: 手动 --target > 自动检测
|
||||
detect_blue_green() {
|
||||
local proxy_conf; proxy_conf=$(get_proxy_conf)
|
||||
local active
|
||||
active=$(remote "docker exec ${NGINX_CONTAINER} 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,10 +123,16 @@ fi
|
||||
# ---------- 环境配置 ----------
|
||||
case "${APP_ENV}" in
|
||||
development)
|
||||
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"
|
||||
TEST_DOMAIN="https://dev.xmclassmate.top"
|
||||
BACKEND_PORT="8080"
|
||||
fi
|
||||
TEST_DOMAIN="https://dev.xmclassmate.top"
|
||||
DB_CONTAINER="1Panel-postgresql-FtMo"
|
||||
DB_NAME="milkydata_dev"
|
||||
DB_USER="milkydata" ;;
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
|
||||
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` |
|
||||
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 页面渲染 |
|
||||
99
docs/SERVER-MIGRATION-PLAN.md
Normal file
99
docs/SERVER-MIGRATION-PLAN.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 服务器迁移计划
|
||||
|
||||
## 新旧对比
|
||||
|
||||
| 项目 | 旧服务器 (1panel-server) | 新服务器 (47.109.203.92) |
|
||||
|------|------------------------|-------------------------|
|
||||
| 系统 | - | Debian 13.5 |
|
||||
| CPU | - | 2 核 |
|
||||
| 内存 | - | 1.6 GiB |
|
||||
| 磁盘 | - | 40G (可用 36G) |
|
||||
| 区域 | - | 成都 |
|
||||
| 角色 | 当前生产+开发 | 目标生产+开发 |
|
||||
|
||||
## 阶段 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 启动 OpenResty
|
||||
```bash
|
||||
docker run -d --name openresty \
|
||||
--network host \
|
||||
-v /www/sites:/www/sites \
|
||||
1panel/openresty:1.21.4.3-3-3-focal
|
||||
```
|
||||
|
||||
### 1.5 复制 SSL 证书 + nginx 配置
|
||||
```bash
|
||||
rsync -av root@1panel-server:/www/sites/ /www/sites/
|
||||
```
|
||||
|
||||
## 阶段 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
|
||||
|
||||
# 注册 systemd service(从 deploy/ 目录上传)
|
||||
```
|
||||
|
||||
### 2.2 部署 Rust 后端
|
||||
```bash
|
||||
# 先部署到 dev 验证
|
||||
cd ASD-backend/rust-backend
|
||||
deploy.sh development --remote-host 47.109.203.92
|
||||
|
||||
# 再部署 blue/green
|
||||
deploy.sh production --remote-host 47.109.203.92
|
||||
```
|
||||
|
||||
### 2.3 更新 deploy.sh
|
||||
将默认 REMOTE_HOST 改为新服务器 IP
|
||||
|
||||
## 阶段 3 — 切换
|
||||
|
||||
### 3.1 内部验证
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/health # dev
|
||||
curl -k https://127.0.0.1:4433/health # blue
|
||||
curl -k https://127.0.0.1:4434/health # green
|
||||
```
|
||||
|
||||
### 3.2 更新 DNS
|
||||
将域名指向新服务器 IP
|
||||
|
||||
### 3.3 验证公网访问
|
||||
```bash
|
||||
curl https://dev.xmclassmate.top/health
|
||||
curl https://xmclassmate.top/health
|
||||
```
|
||||
|
||||
## 阶段 4 — 清理(可选)
|
||||
|
||||
- 迁移 GlitchTip (Sentry)
|
||||
- 旧服务器保留 1 周作为回退
|
||||
- 确认无问题后关闭旧服务器
|
||||
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"; }
|
||||
@@ -102,6 +106,39 @@ check_service_file() {
|
||||
log_info "service 配置检查通过"
|
||||
}
|
||||
|
||||
# ---------- 蓝绿部署:检查目标是否为当前活动环境 ----------
|
||||
NGINX_CONTAINER="1Panel-openresty-ABu5"
|
||||
|
||||
get_proxy_conf() {
|
||||
if [ "${APP_ENV}" = "development" ]; then
|
||||
echo "/www/sites/dev.xmclassmate.top/proxy/root.conf"
|
||||
else
|
||||
echo "/www/sites/xmclassmate.top/proxy/root.conf"
|
||||
fi
|
||||
}
|
||||
|
||||
check_not_active() {
|
||||
[ -z "$BG_TARGET" ] && return 0 # 非蓝绿部署,跳过
|
||||
|
||||
local proxy_conf; proxy_conf=$(get_proxy_conf)
|
||||
local active_env
|
||||
active_env=$(remote "docker exec ${NGINX_CONTAINER} 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; }
|
||||
|
||||
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 ""
|
||||
@@ -77,6 +77,7 @@ pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result<String, Stri
|
||||
}
|
||||
|
||||
// 解析 refresh_token,返回 (user_id, expires_at)
|
||||
#[allow(dead_code)]
|
||||
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))?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
54
src/db.rs
54
src/db.rs
@@ -11,7 +11,7 @@ use crate::error::AppError;
|
||||
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());
|
||||
let is_active_member = user.is_member && user.membership_expires_at.is_none_or(|expires| expires > Utc::now());
|
||||
|
||||
// 维护模式:非会员使用更高的临时限额,防止资源滥用
|
||||
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
|
||||
@@ -578,14 +578,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 +650,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,10 +668,11 @@ 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,
|
||||
@@ -686,6 +697,12 @@ 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;
|
||||
@@ -783,7 +800,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 +879,14 @@ pub async fn set_weather_favorite(
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct RefreshToken {
|
||||
#[allow(dead_code)]
|
||||
pub id: i32,
|
||||
pub user_id: i32,
|
||||
#[allow(dead_code)]
|
||||
pub token: String,
|
||||
#[allow(dead_code)]
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
#[allow(dead_code)]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -909,6 +930,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,7 +1041,7 @@ 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,
|
||||
@@ -1116,6 +1138,22 @@ 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)
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct ErrorResponse<T = ()> {
|
||||
}
|
||||
|
||||
impl<T> ErrorResponse<T> {
|
||||
#[allow(dead_code)]
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
@@ -29,12 +30,14 @@ impl<T> ErrorResponse<T> {
|
||||
}
|
||||
|
||||
impl<T: Serialize> ErrorResponse<T> {
|
||||
#[allow(dead_code, clippy::wrong_self_convention)]
|
||||
pub fn to_json_response(self) -> HttpResponse {
|
||||
HttpResponse::Ok().json(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorResponse<()> {
|
||||
#[allow(dead_code)]
|
||||
pub fn to_error_response(&self, status: StatusCode) -> HttpResponse {
|
||||
let body = serde_json::json!({
|
||||
"success": false,
|
||||
|
||||
@@ -149,7 +149,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 {
|
||||
@@ -247,8 +271,7 @@ pub async fn mock_login(
|
||||
}
|
||||
|
||||
// 如果设置了 MOCK_LOGIN_KEY,验证请求头
|
||||
if let Ok(key) = std::env::var("MOCK_LOGIN_KEY") {
|
||||
if !key.is_empty() {
|
||||
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())
|
||||
@@ -257,17 +280,22 @@ pub async fn mock_login(
|
||||
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("用户不存在"));
|
||||
@@ -613,7 +641,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 +773,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),
|
||||
|
||||
@@ -710,7 +710,7 @@ pub async fn payment_page(
|
||||
let base_url = std::env::var("APP_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
|
||||
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 {
|
||||
let jwt_for_mock = token.clone();
|
||||
@@ -800,12 +800,86 @@ 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)
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.bytes().peekable();
|
||||
let mut had_escape = false;
|
||||
while let Some(b) = chars.next() {
|
||||
if b == b'%' {
|
||||
had_escape = true;
|
||||
let hi = chars.next().and_then(hex_val);
|
||||
let lo = chars.next().and_then(hex_val);
|
||||
if let (Some(h), Some(l)) = (hi, lo) {
|
||||
result.push((h << 4 | l) as char);
|
||||
} else {
|
||||
result.push('%');
|
||||
}
|
||||
} else if b == b'+' && had_escape {
|
||||
result.push(' ');
|
||||
} else {
|
||||
result.push(b as char);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -855,14 +929,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 +977,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 +989,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 +997,26 @@ 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>
|
||||
</body>
|
||||
</html>"##,
|
||||
green, white, order_no, orange
|
||||
green, white, order_no, orange, jwt_token
|
||||
);
|
||||
html
|
||||
}
|
||||
@@ -952,11 +1043,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 +1059,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();
|
||||
@@ -1079,8 +1171,7 @@ 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() {
|
||||
if let Ok(key) = std::env::var("MOCK_PAY_KEY") && !key.is_empty() {
|
||||
let header_key = req
|
||||
.headers()
|
||||
.get("X-Mock-Key")
|
||||
@@ -1090,7 +1181,6 @@ fn check_mock_payment_allowed(req: &HttpRequest) -> Result<(), AppError> {
|
||||
return Err(AppError::Forbidden("Mock 支付密钥错误".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1154,7 +1244,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 +1405,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",
|
||||
@@ -1380,7 +1471,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),
|
||||
|
||||
@@ -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() {
|
||||
if let Some(extra) = &body.extra
|
||||
&& let Some(obj) = extra.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
scope.set_extra(k, sentry::protocol::Value::from(v.clone()));
|
||||
}
|
||||
scope.set_extra(k, v.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn get_current_user_profile(
|
||||
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!({
|
||||
|
||||
@@ -275,6 +275,9 @@ async fn main() -> std::io::Result<()> {
|
||||
// 会员到期前 7 天提醒
|
||||
let _ = db::check_member_expiry_soon(&pool_clone).await;
|
||||
|
||||
// 清理超过 24 小时的过期待支付订单
|
||||
let _ = db::cleanup_expired_pending_orders(&pool_clone, None).await;
|
||||
|
||||
// 清理过期的 refresh_token
|
||||
let _ = db::cleanup_expired_refresh_tokens(&pool_clone).await;
|
||||
}
|
||||
@@ -321,6 +324,8 @@ 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))
|
||||
.backlog(1024)
|
||||
.bind_openssl(&addr, ssl_builder)
|
||||
{
|
||||
Ok(s) => {
|
||||
@@ -342,6 +347,8 @@ 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))
|
||||
.backlog(1024)
|
||||
.bind(&addr)
|
||||
{
|
||||
Ok(s) => {
|
||||
|
||||
@@ -52,12 +52,14 @@ impl LoginResponse {
|
||||
}
|
||||
|
||||
// 兼容旧的 TokenResponse
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct TokenResponse {
|
||||
pub success: bool,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TokenResponse {
|
||||
pub fn new(token: String) -> Self {
|
||||
Self {
|
||||
@@ -345,11 +347,16 @@ pub struct AppState {
|
||||
pub jwt_secret: String,
|
||||
pub wechat_appid: String,
|
||||
pub wechat_secret: String,
|
||||
#[allow(dead_code)]
|
||||
pub free_user_data_limit: i32,
|
||||
// ===== 支付宝配置 =====
|
||||
#[allow(dead_code)]
|
||||
pub alipay_app_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub alipay_private_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub alipay_alipay_public_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub alipay_gateway: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String {
|
||||
headers
|
||||
.get("X-Forwarded-For")
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user