diff --git a/deploy.sh b/deploy.sh index 5f31d20..1434171 100755 --- a/deploy.sh +++ b/deploy.sh @@ -183,8 +183,8 @@ upload_script "清理脚本" scripts/cleanup_refresh_tokens.sh scripts/cleanup_r upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh deploy_service_file +run_migrations # Moved BEFORE restart restart_service -run_migrations if run_tests; then log_to_file "DEPLOY success" diff --git a/lib/common.sh b/lib/common.sh index 4a93517..2fa5a36 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -155,7 +155,8 @@ deploy_service_file() { local has_mock; has_mock=$(remote "grep -c 'MOCK_LOGIN_ENABLED' /etc/systemd/system/${SERVICE_NAME} 2>/dev/null || echo 0") if [ "$has_mock" = "0" ]; then local mock_key; mock_key=$(openssl rand -base64 12 2>/dev/null | tr -d '\n' || echo "dev-mock-$(date +%s)") - remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${mock_key}' /etc/systemd/system/${SERVICE_NAME}" + local escaped_key; escaped_key=$(echo "$mock_key" | sed 's/[\/&]/\\&/g') + remote "sed -i '/^\[Service\]/a Environment=MOCK_LOGIN_ENABLED=true\nEnvironment=MOCK_LOGIN_KEY=${escaped_key}' /etc/systemd/system/${SERVICE_NAME}" log_info "自动配置 MOCK_LOGIN_ENABLED=true, MOCK_LOGIN_KEY=${mock_key}" fi fi diff --git a/lib/test.sh b/lib/test.sh index 106eff1..b38297f 100644 --- a/lib/test.sh +++ b/lib/test.sh @@ -186,16 +186,6 @@ test_migration_status() { test_payment_flow() { log_info "开始支付链路测试..." - # 清理 trap:测试失败时清理 - local test_cleanup_done=false - cleanup_payment_test() { - if [ "$test_cleanup_done" = false ]; then - cleanup_test_user - test_cleanup_done=true - fi - } - trap cleanup_payment_test EXIT - # 阶段 1:检查订单到期时间计算与套餐天数是否一致 # 通过 SQL 直接创建测试订单(避免 JWT 依赖),验证 order.expires_at 间隔 local uuid_prefix @@ -319,7 +309,7 @@ test_payment_dedup() { local all_ok=true # 1. 在服务器上生成 SQL 文件(避免 stdin 管道问题) - remote "cat > ${sql_file} << 'SQLEOF' + remote "cat > ${sql_file} << SQLEOF INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no1}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days'); INSERT INTO payment_orders (user_id, order_no, package_type, amount, status, expires_at) VALUES (${user_id}, '${test_no2}', 'monthly', 590, 'pending', NOW() + INTERVAL '30 days'); SQLEOF @@ -357,7 +347,7 @@ test_payment_maintenance() { # 启用维护模式 remote "sed -i '/^Environment=PAYMENT_MAINTENANCE_MODE/d' /etc/systemd/system/${SERVICE_NAME} sed -i '/^\[Service\]/a Environment=PAYMENT_MAINTENANCE_MODE=true' /etc/systemd/system/${SERVICE_NAME} - systemctl daemon-reload && sleep 1" 2>/dev/null || true + systemctl daemon-reload && systemctl restart ${SERVICE_NAME} && sleep 3" 2>/dev/null || true # 测试 quota 返回 unlimited local quota_resp diff --git a/src/db.rs b/src/db.rs index a5c09f7..8dadeba 100644 --- a/src/db.rs +++ b/src/db.rs @@ -700,7 +700,7 @@ pub async fn confirm_payment_order_by_orderno( is_member = true, membership_expires_at = CASE - WHEN uo.package_type = 'permanent' THEN NULL + WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) + CASE WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' @@ -727,9 +727,13 @@ pub async fn confirm_payment_order_by_orderno( // 审计日志 if let Some(uid) = user_id { - let _ = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await; + if let Err(e) = insert_payment_audit_log(pool, order_no, uid, "paid", None, None).await { + tracing::warn!("插入支付审计日志失败: {}", e); + } // 发送支付成功通知 - let _ = insert_payment_notification(pool, uid, order_no).await; + if let Err(e) = insert_payment_notification(pool, uid, order_no).await { + tracing::warn!("插入支付通知失败: {}", e); + } // 验证会员到期时间是否正常 if let Err(e) = verify_membership_after_payment(pool, order_no).await { @@ -808,7 +812,9 @@ pub async fn refund_payment_order( // 审计日志 let detail = if other_active.0 == 0 { "退款,会员已撤销" } else { "退款,有其他有效订单,保留会员" }; - let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await; + if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded", None, Some(detail)).await { + tracing::warn!("插入支付审计日志失败: {}", e); + } Ok(()) } @@ -901,14 +907,14 @@ pub async fn set_weather_favorite( #[derive(Debug, FromRow)] pub struct RefreshToken { - #[allow(dead_code)] + #[allow(dead_code)] // sqlx deserialization only; not read directly pub id: i32, pub user_id: i32, - #[allow(dead_code)] + #[allow(dead_code)] // sqlx deserialization only pub token: String, - #[allow(dead_code)] + #[allow(dead_code)] // sqlx deserialization only pub expires_at: chrono::DateTime, - #[allow(dead_code)] + #[allow(dead_code)] // sqlx deserialization only pub created_at: chrono::DateTime, } @@ -1068,17 +1074,17 @@ pub async fn admin_force_confirm_order( UPDATE users SET is_member = true, membership_expires_at = - CASE - WHEN uo.package_type = 'permanent' THEN NULL - ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) + - CASE - WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' - WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days' - WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days' - WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days' - ELSE INTERVAL '0 days' - END - END + CASE + WHEN uo.package_type = 'permanent' THEN '2099-12-31'::TIMESTAMPTZ + ELSE GREATEST(COALESCE(users.membership_expires_at, NOW()), NOW()) + + CASE + WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' + WHEN uo.package_type = 'quarterly' THEN INTERVAL '90 days' + WHEN uo.package_type = 'half_year' THEN INTERVAL '182 days' + WHEN uo.package_type = 'yearly' THEN INTERVAL '365 days' + ELSE INTERVAL '0 days' + END + END FROM updated_order uo WHERE users.id = $2 RETURNING users.membership_expires_at @@ -1091,7 +1097,7 @@ pub async fn admin_force_confirm_order( .map_err(|e| AppError::Database(format!("管理员确认订单失败: {}", e)))?; // 写审计日志 - let _ = insert_payment_audit_log( + if let Err(e) = insert_payment_audit_log( pool, order_no, user_id, @@ -1099,7 +1105,10 @@ pub async fn admin_force_confirm_order( Some(admin_user_id), Some("管理员强制确认支付"), ) - .await; + .await + { + tracing::warn!("插入支付审计日志失败: {}", e); + } // 验证会员到期时间是否正常 if let Err(e) = verify_membership_after_payment(pool, order_no).await { @@ -1109,7 +1118,7 @@ pub async fn admin_force_confirm_order( result } else { // 订单已处理,只写日志 - let _ = insert_payment_audit_log( + if let Err(e) = insert_payment_audit_log( pool, order_no, user_id, @@ -1117,7 +1126,10 @@ pub async fn admin_force_confirm_order( Some(admin_user_id), Some(&format!("订单状态为 {},跳过确认", status)), ) - .await; + .await + { + tracing::warn!("插入支付审计日志失败: {}", e); + } // 返回当前到期时间 sqlx::query_scalar::<_, Option>>( @@ -1182,8 +1194,11 @@ pub async fn cancel_payment_order( .await .map_err(|e| AppError::Database(format!("取消订单失败: {}", e)))?; - let _ = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id), - Some("用户主动取消待支付订单")).await; + if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "cancelled", Some(user_id), + Some("用户主动取消待支付订单")).await + { + tracing::warn!("插入支付审计日志失败: {}", e); + } Ok(()) } @@ -1256,8 +1271,11 @@ pub async fn admin_refund_order( } let detail = if status == "paid" { "管理员手动退款(已支付订单)" } else { "管理员取消订单(待支付订单)" }; - let _ = insert_payment_audit_log(pool, order_no, user_id, "refunded", - Some(admin_user_id), Some(detail)).await; + if let Err(e) = insert_payment_audit_log(pool, order_no, user_id, "refunded", + Some(admin_user_id), Some(detail)).await + { + tracing::warn!("插入支付审计日志失败: {}", e); + } Ok(()) } diff --git a/src/error.rs b/src/error.rs index 762c815..8eb0f46 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,15 +11,6 @@ pub struct ErrorResponse { } impl ErrorResponse { - #[allow(dead_code)] - pub fn success(data: T) -> Self { - Self { - success: true, - data: Some(data), - error: None, - } - } - pub fn error(msg: impl Into) -> Self { Self { success: false, @@ -29,24 +20,6 @@ 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, - "error": self.error.clone().unwrap_or_else(|| "Unknown error".to_string()) - }); - HttpResponse::build(status).json(body) - } -} - #[derive(Debug)] pub enum AppError { Unauthorized(String), diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index 672b271..d516854 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -59,11 +59,13 @@ pub async fn admin_update_user_payment( // 更新用户付费状态 db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_member, membership_expires_at).await?; // 审计日志 - let _ = db::insert_payment_audit_log( + if let Err(e) = db::insert_payment_audit_log( pool.get_ref(), "ADMIN_MANUAL", target_user_id, "admin_revoke", Some(claims.user_id), Some(&format!("管理员手动更新付费状态: is_member={}, expires_at={:?}", body.is_member, body.membership_expires_at)), - ).await; + ).await { + tracing::warn!("插入支付审计日志失败: {}", e); + } Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "message": "用户付费状态已更新" diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 36ef280..aa88c49 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -84,7 +84,11 @@ pub async fn login( let openid = match wechat_data.openid { Some(id) => { - let masked_openid = format!("{}***{}", &id[0..4], &id[id.len() - 4..]); + let masked_openid = if id.len() >= 8 { + format!("{}***{}", &id[0..4], &id[id.len() - 4..]) + } else { + id.to_string() + }; debug!("获取到用户openid: {}", masked_openid); id } diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index cf41fb6..f63af6b 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -185,7 +185,7 @@ fn build_alipay_form_html( subject: &str, notify_url: &str, return_url: &str, - is_mobile: bool, // true=手机网站支付, false=电脑网站支付 + _is_mobile: bool, // 保留参数:待支付宝开通 wap.pay 后启用 ) -> Result { // 注意:当前支付宝产品仅开通了 alipay.trade.page.pay(电脑网站支付) // 该接口在手机浏览器中也能正常唤起支付宝 APP 或显示移动端页面 @@ -295,8 +295,10 @@ fn extract_token(req: &HttpRequest) -> Option { .map(|s| s.to_string()) } -fn get_jwt_secret() -> String { - std::env::var("JWT_SECRET").expect("JWT_SECRET must be set") +fn get_jwt_secret() -> Result { + std::env::var("JWT_SECRET").map_err(|_| { + AppError::Internal("JWT_SECRET 环境变量未设置".to_string()) + }) } // ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) ===== @@ -742,7 +744,8 @@ pub async fn payment_page( .or_else(|| query.jwt.clone()) .ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; - let claims = crate::auth::verify_token(&token, &get_jwt_secret()) + let secret = get_jwt_secret()?; + let claims = crate::auth::verify_token(&token, &secret) .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; let pkg = get_package_info(&query.package_) @@ -802,6 +805,7 @@ pub async fn payment_page( let return_url = format!("{}/payment/success?order_no={}&jwt={}", base_url, order_no, token); let Some(config) = AlipayConfig::from_env() else { + check_mock_payment_allowed(&req)?; let jwt_for_mock = token.clone(); let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock); return Ok(HttpResponse::Ok() @@ -855,7 +859,8 @@ pub async fn alipay_pay_page( ) -> Result { check_payment_maintenance()?; let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; - crate::auth::verify_token(&token, &get_jwt_secret()) + let secret = get_jwt_secret()?; + crate::auth::verify_token(&token, &secret) .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; let pkg = get_package_info(&query.package_type) @@ -947,13 +952,12 @@ fn parse_alipay_form(body: &[u8]) -> BTreeMap { } /// 手动 URL 解码(percent-decoding) +/// 注意:`+` 始终解码为空格(form-urlencoded 标准),而非仅在 `%` 转义之后 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) { @@ -961,7 +965,7 @@ fn urlencoding(s: &str) -> String { } else { result.push('%'); } - } else if b == b'+' && had_escape { + } else if b == b'+' { result.push(' '); } else { result.push(b as char); @@ -1570,8 +1574,15 @@ pub async fn payment_login_status( } // 已确认 → 使用已生成的 token - let token = token.unwrap(); - let user_id = user_id.unwrap(); + let token = token.unwrap_or_default(); + let Some(user_id) = user_id else { + return Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": false, "confirmed": false, + "token": None::, + "is_active_member": false, + "membership_expires_at": None::, + }))); + }; let (is_active_member, membership_expires_at): (bool, Option) = match sqlx::query_as::<_, (bool, Option>)>( diff --git a/src/handlers/user.rs b/src/handlers/user.rs index e15efa7..bd2f16f 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -18,6 +18,8 @@ pub async fn get_current_user_profile( let user = db::get_user_by_id(pool.get_ref(), user_id).await?; let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()); let is_active_member = if is_maintenance { + // 维护模式下所有已认证用户视为活跃会员,确保支付故障期间服务可用 + // 注意:此路由有 JWT 中间件保护,未认证请求已在前置中间件被拦截 true } else { user.is_member && diff --git a/src/main.rs b/src/main.rs index f59ef7d..eef6c93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -230,7 +230,9 @@ async fn main() -> std::io::Result<()> { .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build() - .expect("创建 HTTP 客户端失败"); + .unwrap_or_else(|e| { + panic!("创建 HTTP 客户端失败: {}", e); + }); // 启动时清理:过期订单 + 过期登录码 match db::cleanup_expired_pending_orders(&pool, None).await { @@ -260,13 +262,19 @@ async fn main() -> std::io::Result<()> { } // 会员到期前 7 天提醒 - let _ = db::check_member_expiry_soon(&pool_clone).await; + if let Err(e) = db::check_member_expiry_soon(&pool_clone).await { + tracing::error!("检查会员到期失败: {}", e); + } // 清理超过 24 小时的过期待支付订单 - let _ = db::cleanup_expired_pending_orders(&pool_clone, None).await; + if let Err(e) = db::cleanup_expired_pending_orders(&pool_clone, None).await { + tracing::error!("清理过期订单失败: {}", e); + } // 清理过期的 refresh_token - let _ = db::cleanup_expired_refresh_tokens(&pool_clone).await; + if let Err(e) = db::cleanup_expired_refresh_tokens(&pool_clone).await { + tracing::error!("清理过期 refresh token 失败: {}", e); + } } }); diff --git a/src/models.rs b/src/models.rs index ff090d3..9497180 100644 --- a/src/models.rs +++ b/src/models.rs @@ -20,7 +20,7 @@ pub struct Claims { pub user_id: i32, // 自定义字段:openid(可选) pub openid: String, - // 自定义字段:user_ytpe + // 自定义字段:user_type pub user_type: i32, } @@ -51,24 +51,6 @@ impl LoginResponse { } } -// 兼容旧的 TokenResponse -#[allow(dead_code)] -#[derive(Debug, Serialize, Clone)] -pub struct TokenResponse { - pub success: bool, - pub token: String, -} - -#[allow(dead_code)] -impl TokenResponse { - pub fn new(token: String) -> Self { - Self { - success: true, - token, - } - } -} - // Refresh Token 请求 #[derive(Debug, Deserialize)] pub struct RefreshTokenRequest { @@ -347,17 +329,6 @@ 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, } impl AppState { @@ -369,14 +340,6 @@ impl AppState { .map_err(|_| "环境变量WECHAT_APPID未设置".to_string())?, wechat_secret: std::env::var("WECHAT_SECRET") .map_err(|_| "环境变量WECHAT_SECRET未设置".to_string())?, - free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT") - .map(|v| v.parse().unwrap_or(20)) - .unwrap_or(20), - // 支付宝配置(可选,未配置时使用模拟支付) - alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(), - alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(), - alipay_alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok(), - alipay_gateway: std::env::var("ALIPAY_GATEWAY").ok(), }) } } diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 2025e46..910c416 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -40,7 +40,7 @@ impl RateLimiter { } } -#[allow(dead_code)] +#[cfg(test)] pub fn extract_client_ip_from_header(headers: &actix_web::http::header::HeaderMap) -> String { headers .get("X-Forwarded-For")