From 38ee5ccbc68b25e26d019e2c0a342c250eca8f1e Mon Sep 17 00:00:00 2001 From: milky0217 Date: Fri, 3 Jul 2026 21:59:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=80=E6=9C=89=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0ICP=E5=A4=87=E6=A1=88=E5=8F=B7=20=E6=B5=99ICP?= =?UTF-8?q?=E5=A4=872026052792=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy.sh | 3 + deploy/rust-backend-development.service | 2 +- docs/SERVER-MIGRATION-PLAN.md | 99 +++++++++++++++++++++++ scripts/generate-admin-token.sh | 100 ++++++++++++++++++++++++ src/handlers/auth.rs | 12 ++- src/handlers/health.rs | 69 +++++++++++++++- src/handlers/meta.rs | 1 + src/handlers/payment.rs | 11 +++ 8 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 docs/SERVER-MIGRATION-PLAN.md create mode 100755 scripts/generate-admin-token.sh diff --git a/deploy.sh b/deploy.sh index 6103a35..29c68e3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -19,6 +19,7 @@ # --init-env 初始化 systemd service 模板 # --deploy-service 上传 systemd service 文件到服务器 # --target blue|green 蓝绿部署目标(默认单实例) +# --remote-host IP 部署目标服务器(默认 1panel-server) # =========================================== set -euo pipefail @@ -56,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 "" @@ -64,6 +66,7 @@ while [[ $# -gt 0 ]]; do echo " --yes, -y 跳过确认" echo " --skip-tests 跳过部署后测试" echo " --target blue|green 蓝绿部署目标(默认单实例)" + echo " --remote-host IP 部署目标服务器(默认 1panel-server)" echo " --rollback 回滚到指定备份" echo " --backup-list 列出可用备份" echo " --logs [N] 查看后端日志(默认50行)" diff --git a/deploy/rust-backend-development.service b/deploy/rust-backend-development.service index 318ed1b..cdeb0f5 100644 --- a/deploy/rust-backend-development.service +++ b/deploy/rust-backend-development.service @@ -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 diff --git a/docs/SERVER-MIGRATION-PLAN.md b/docs/SERVER-MIGRATION-PLAN.md new file mode 100644 index 0000000..63c777a --- /dev/null +++ b/docs/SERVER-MIGRATION-PLAN.md @@ -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 周作为回退 +- 确认无问题后关闭旧服务器 diff --git a/scripts/generate-admin-token.sh b/scripts/generate-admin-token.sh new file mode 100755 index 0000000..f318d68 --- /dev/null +++ b/scripts/generate-admin-token.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# ============================================================= +# 管理员 JWT 生成工具 +# 用法: ./scripts/generate-admin-token.sh [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 [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 "" diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 101b7bc..cbf4b17 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -283,13 +283,19 @@ pub async fn mock_login( 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("用户不存在")); diff --git a/src/handlers/health.rs b/src/handlers/health.rs index 52ba9ef..c85c648 100644 --- a/src/handlers/health.rs +++ b/src/handlers/health.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hint: Option, } +#[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 { + 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) -> 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) -> impl Responder { HttpResponse::ServiceUnavailable().json(HealthResponse { status: "error".to_string(), database: "disconnected".to_string(), + ssl_cert, error: Some(error_msg), hint: Some(hint_msg), }) diff --git a/src/handlers/meta.rs b/src/handlers/meta.rs index 25ca36e..16f86cb 100644 --- a/src/handlers/meta.rs +++ b/src/handlers/meta.rs @@ -209,6 +209,7 @@ const HTML_TEMPLATE: &str = r#" diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 3a37c89..18b1192 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -143,6 +143,7 @@ function confirmMockPay() {{ }}); }} + "#, order_no, display_name, jwt, order_no) } @@ -234,6 +235,7 @@ fn build_alipay_form_html( {} + "##, config.gateway, form_fields @@ -467,6 +469,9 @@ pub async fn payment_index() -> Result { + + "##, green, white, order_no, orange, jwt_token @@ -1031,6 +1039,9 @@ fn format_error_html(msg: &str, order_no: &str) -> String {

{}

订单号: {}

返回重试

+ "##, msg, order_no