diff --git a/deploy.sh b/deploy.sh index 85d2b6f..6103a35 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,6 +2,8 @@ # =========================================== # Rust Backend Deployment Script # =========================================== +# Rust Backend Deployment Script +# =========================================== # Usage: ./deploy.sh [development|production] [options] # # Options: @@ -16,6 +18,7 @@ # --post-logs 部署后显示日志 # --init-env 初始化 systemd service 模板 # --deploy-service 上传 systemd service 文件到服务器 +# --target blue|green 蓝绿部署目标(默认单实例) # =========================================== set -euo pipefail @@ -60,7 +63,7 @@ while [[ $# -gt 0 ]]; do echo " --dry-run 预览模式" echo " --yes, -y 跳过确认" echo " --skip-tests 跳过部署后测试" - echo " --target blue|green 蓝绿部署目标(仅 production)" + echo " --target blue|green 蓝绿部署目标(默认单实例)" echo " --rollback 回滚到指定备份" echo " --backup-list 列出可用备份" echo " --logs [N] 查看后端日志(默认50行)" @@ -79,11 +82,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 +120,16 @@ 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_NAME="milkydata_dev" DB_USER="milkydata" ;; diff --git a/docs/BANDWIDTH-BENCHMARK.md b/docs/BANDWIDTH-BENCHMARK.md new file mode 100644 index 0000000..46f2e76 --- /dev/null +++ b/docs/BANDWIDTH-BENCHMARK.md @@ -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` | diff --git a/docs/MAINTENANCE-MODE-RUNBOOK.md b/docs/MAINTENANCE-MODE-RUNBOOK.md new file mode 100644 index 0000000..a1acdd4 --- /dev/null +++ b/docs/MAINTENANCE-MODE-RUNBOOK.md @@ -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 页面渲染 | diff --git a/docs/bench/bandwidth_analysis.rs b/docs/bench/bandwidth_analysis.rs new file mode 100644 index 0000000..8692bb0 --- /dev/null +++ b/docs/bench/bandwidth_analysis.rs @@ -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) +} diff --git a/docs/bench/bandwidth_bench.sh b/docs/bench/bandwidth_bench.sh new file mode 100644 index 0000000..6c78042 --- /dev/null +++ b/docs/bench/bandwidth_bench.sh @@ -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 "" diff --git a/lib/common.sh b/lib/common.sh index cb29bdf..a92053f 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -108,13 +108,21 @@ check_service_file() { # ---------- 蓝绿部署:检查目标是否为当前活动环境 ---------- NGINX_CONTAINER="1Panel-openresty-ABu5" -ROOT_PROXY_CONF="/www/sites/xmclassmate.top/proxy/root.conf" + +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 ${ROOT_PROXY_CONF} 2>/dev/null" 2>/dev/null | grep -oP '127\.0\.0\.1:(4433|4434)' | head -1 || echo "") + 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 diff --git a/src/auth.rs b/src/auth.rs index 677d2c2..93b0495 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -77,6 +77,7 @@ pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result Result<(i32, i64), String> { let decoded = BASE64.decode(token) .map_err(|e| format!("Refresh token 格式错误: {}", e))?; diff --git a/src/config.rs b/src/config.rs index 7747b27..a0b3dfc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { diff --git a/src/db.rs b/src/db.rs index f6fe9ef..308789b 100644 --- a/src/db.rs +++ b/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 { // 配额检查:非付费用户数据条数限制 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()); @@ -800,7 +800,7 @@ pub async fn get_user_quota( ) -> Result<(i64, bool, Option>, 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)) } @@ -879,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, + #[allow(dead_code)] pub created_at: chrono::DateTime, } @@ -926,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"#; diff --git a/src/error.rs b/src/error.rs index 1f1ef01..762c815 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,6 +11,7 @@ pub struct ErrorResponse { } impl ErrorResponse { + #[allow(dead_code)] pub fn success(data: T) -> Self { Self { success: true, @@ -29,12 +30,14 @@ impl ErrorResponse { } impl ErrorResponse { + #[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, diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index d985a80..101b7bc 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -149,7 +149,31 @@ pub async fn refresh_token( pool: web::Data, req: web::Json, app_state: web::Data, + 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,15 +271,13 @@ 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("模拟登录密钥错误")); } } @@ -613,7 +635,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 +767,7 @@ pub async fn web_login_auto_confirm( let (is_active_member, membership_expires_at): (bool, Option) = 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), diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 8b621c8..3a37c89 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -849,8 +849,8 @@ fn urlencoding(s: &str) -> String { while let Some(b) = chars.next() { if b == b'%' { had_escape = true; - let hi = chars.next().and_then(|c| hex_val(c)); - let lo = chars.next().and_then(|c| hex_val(c)); + 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 { @@ -1171,16 +1171,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())); } } @@ -1246,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, @@ -1407,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, Option, Option)> = sqlx::query_as( "SELECT code, expires_at, token, user_id FROM web_login_codes WHERE code = $1", @@ -1472,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), diff --git a/src/handlers/sentry.rs b/src/handlers/sentry.rs index 3063c20..0f0a007 100644 --- a/src/handlers/sentry.rs +++ b/src/handlers/sentry.rs @@ -3,8 +3,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct SentryEventRequest { + #[allow(dead_code)] pub level: Option, pub message: String, + #[allow(dead_code)] pub stack: Option, pub page: Option, pub user_id: Option, @@ -28,11 +30,11 @@ pub async fn report_frontend_error(body: web::Json) -> 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()); } } }, diff --git a/src/handlers/user.rs b/src/handlers/user.rs index effb6d5..e15efa7 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -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!({ diff --git a/src/main.rs b/src/main.rs index 4cc8ea0..2bb78d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -324,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) => { @@ -345,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) => { diff --git a/src/models.rs b/src/models.rs index c89ae4d..ff090d3 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + #[allow(dead_code)] pub alipay_private_key: Option, + #[allow(dead_code)] pub alipay_alipay_public_key: Option, + #[allow(dead_code)] pub alipay_gateway: Option, } diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 9aa488e..2025e46 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -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") diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ea467ee..df62837 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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");