//! 计费 HTTP 处理器 use axum::{ extract::{Extension, Form, Path, Query, State}, Json, }; use serde::Deserialize; use crate::auth::types::AuthContext; use crate::error::{SaasError, SaasResult}; use crate::state::AppState; use super::service; use super::types::*; /// GET /api/v1/billing/plans — 列出所有活跃计划 pub async fn list_plans( State(state): State, ) -> SaasResult>> { let plans = service::list_plans(&state.db).await?; Ok(Json(plans)) } /// GET /api/v1/billing/plans/:id — 获取单个计划详情 pub async fn get_plan( State(state): State, Path(plan_id): Path, ) -> SaasResult> { let plan = service::get_plan(&state.db, &plan_id).await? .ok_or_else(|| crate::error::SaasError::NotFound("计划不存在".into()))?; Ok(Json(plan)) } /// GET /api/v1/billing/subscription — 获取当前订阅 pub async fn get_subscription( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { let plan = service::get_account_plan(&state.db, &ctx.account_id).await?; let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?; let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; Ok(Json(serde_json::json!({ "plan": plan, "subscription": sub, "usage": usage, }))) } /// GET /api/v1/billing/usage — 获取当月用量 pub async fn get_usage( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; Ok(Json(usage)) } /// POST /api/v1/billing/usage/increment — 客户端上报用量(Hand/Pipeline 执行后调用) /// /// 请求体: `{ "dimension": "hand_executions" | "pipeline_runs" | "relay_requests", "count": 1 }` /// 需要认证 — account_id 从 JWT 提取。 #[derive(Debug, Deserialize)] pub struct IncrementUsageRequest { /// 用量维度:hand_executions / pipeline_runs / relay_requests pub dimension: String, /// 递增数量,默认 1 #[serde(default = "default_count")] pub count: i32, } fn default_count() -> i32 { 1 } pub async fn increment_usage_dimension( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { // 验证维度白名单 if !["hand_executions", "pipeline_runs", "relay_requests"].contains(&req.dimension.as_str()) { return Err(SaasError::InvalidInput( format!("无效的用量维度: {},支持: hand_executions / pipeline_runs / relay_requests", req.dimension) )); } // 限制单次递增上限(防滥用) if req.count < 1 || req.count > 100 { return Err(SaasError::InvalidInput( format!("count 必须在 1~100 范围内,得到: {}", req.count) )); } // 单次原子更新,避免循环 N 次数据库查询 service::increment_dimension_by(&state.db, &ctx.account_id, &req.dimension, req.count).await?; // 返回更新后的用量 let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; Ok(Json(serde_json::json!({ "dimension": req.dimension, "incremented": req.count, "usage": usage, }))) } /// POST /api/v1/billing/payments — 创建支付订单 pub async fn create_payment( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { let config = state.config.read().await; let result = super::payment::create_payment( &state.db, &ctx.account_id, &req, &config.payment, ).await?; Ok(Json(result)) } /// GET /api/v1/billing/payments/:id — 查询支付状态 pub async fn get_payment_status( State(state): State, Extension(ctx): Extension, Path(payment_id): Path, ) -> SaasResult> { let status = super::payment::query_payment_status( &state.db, &payment_id, &ctx.account_id, ).await?; Ok(Json(status)) } /// POST /api/v1/billing/callback/:method — 支付回调(支付宝/微信异步通知) pub async fn payment_callback( State(state): State, Path(method): Path, body: axum::body::Bytes, ) -> SaasResult { tracing::info!("Payment callback received: method={}, body_len={}", method, body.len()); let body_str = String::from_utf8_lossy(&body); let config = state.config.read().await; let (trade_no, status, callback_amount) = if method == "alipay" { parse_alipay_callback(&body_str, &config.payment)? } else if method == "wechat" { parse_wechat_callback(&body_str, &config.payment)? } else { tracing::warn!("Unknown payment callback method: {}", method); return Ok("fail".into()); }; // trade_no 是必填字段,缺失说明回调格式异常 let trade_no = trade_no.ok_or_else(|| { tracing::warn!("Payment callback missing out_trade_no: method={}", method); SaasError::InvalidInput("回调缺少交易号".into()) })?; if let Err(e) = super::payment::handle_payment_callback(&state.db, &trade_no, &status, callback_amount).await { // 对外返回通用错误,不泄露内部细节 tracing::error!("Payment callback processing failed: method={}, error={}", method, e); return Ok("fail".into()); } // 支付宝期望 "success",微信期望 JSON if method == "alipay" { Ok("success".into()) } else { Ok(r#"{"code":"SUCCESS","message":"OK"}"#.into()) } } // === Mock 支付(开发模式) === #[derive(Debug, Deserialize)] pub struct MockPayQuery { trade_no: String, amount: i32, subject: String, } /// GET /api/v1/billing/mock-pay — 开发模式 Mock 支付页面 pub async fn mock_pay_page( Query(params): Query, ) -> axum::response::Html { // HTML 转义防止 XSS let safe_subject = html_escape(¶ms.subject); let safe_trade_no = html_escape(¶ms.trade_no); let amount_yuan = params.amount as f64 / 100.0; axum::response::Html(format!(r#" Mock 支付
{safe_subject}
¥{amount_yuan}
订单号: {safe_trade_no}
"#)) } #[derive(Debug, Deserialize)] pub struct MockPayConfirm { trade_no: String, action: String, } /// POST /api/v1/billing/mock-pay/confirm — Mock 支付确认 pub async fn mock_pay_confirm( State(state): State, Form(form): Form, ) -> SaasResult> { let status = if form.action == "success" { "success" } else { "failed" }; if let Err(e) = super::payment::handle_payment_callback(&state.db, &form.trade_no, status, None).await { tracing::error!("Mock payment callback failed: {}", e); } let msg = if status == "success" { "支付成功!您可以关闭此页面。" } else { "支付已取消。" }; Ok(axum::response::Html(format!(r#" 支付结果
{msg}
"#))) } // === 回调解析 === /// 解析支付宝回调并验签,返回 (trade_no, status, callback_amount_cents) fn parse_alipay_callback( body: &str, config: &crate::config::PaymentConfig, ) -> SaasResult<(Option, String, Option)> { // form-urlencoded → key=value 对 let mut params: Vec<(String, String)> = Vec::new(); for pair in body.split('&') { if let Some((k, v)) = pair.split_once('=') { params.push(( k.to_string(), urlencoding::decode(v).unwrap_or_default().to_string(), )); } } let mut trade_no = None; let mut callback_amount: Option = None; // 验签:生产环境强制,开发环境允许跳过 let is_dev = std::env::var("ZCLAW_SAAS_DEV") .map(|v| v == "true" || v == "1") .unwrap_or(false); if let Some(ref public_key) = config.alipay_public_key { match super::payment::verify_alipay_callback(¶ms, public_key) { Ok(true) => {} Ok(false) => { tracing::warn!("Alipay callback signature verification FAILED"); return Err(SaasError::InvalidInput("支付宝回调验签失败".into())); } Err(e) => { tracing::error!("Alipay callback verification error: {}", e); return Err(SaasError::InvalidInput("支付宝回调验签异常".into())); } } } else if !is_dev { tracing::error!("Alipay public key not configured in production — rejecting callback"); return Err(SaasError::InvalidInput("支付宝公钥未配置,无法验签".into())); } else { tracing::warn!("Alipay public key not configured (dev mode), skipping signature verification"); } // 提取 trade_no、trade_status 和 total_amount let mut trade_status = "unknown".to_string(); for (k, v) in ¶ms { match k.as_str() { "out_trade_no" => trade_no = Some(v.clone()), "trade_status" => trade_status = v.clone(), "total_amount" => { // 支付宝金额为元(字符串),转为分(整数) if let Ok(yuan) = v.parse::() { callback_amount = Some((yuan * 100.0).round() as i32); } } _ => {} } } // 支付宝成功状态映射 let status = if trade_status == "TRADE_SUCCESS" || trade_status == "TRADE_FINISHED" { "TRADE_SUCCESS" } else { &trade_status }; Ok((trade_no, status.to_string(), callback_amount)) } /// 解析微信支付回调,解密 resource 字段,返回 (trade_no, status, callback_amount_cents) fn parse_wechat_callback( body: &str, config: &crate::config::PaymentConfig, ) -> SaasResult<(Option, String, Option)> { let v: serde_json::Value = serde_json::from_str(body) .map_err(|e| SaasError::InvalidInput(format!("微信回调 JSON 解析失败: {}", e)))?; let event_type = v.get("event_type") .and_then(|t| t.as_str()) .unwrap_or(""); if event_type != "TRANSACTION.SUCCESS" { // 非支付成功事件(如退款等),忽略 return Ok((None, event_type.to_string(), None)); } // 解密 resource 字段 let resource = v.get("resource") .ok_or_else(|| SaasError::InvalidInput("微信回调缺少 resource 字段".into()))?; let ciphertext = resource.get("ciphertext") .and_then(|v| v.as_str()) .ok_or_else(|| SaasError::InvalidInput("微信回调 resource 缺少 ciphertext".into()))?; let nonce = resource.get("nonce") .and_then(|v| v.as_str()) .ok_or_else(|| SaasError::InvalidInput("微信回调 resource 缺少 nonce".into()))?; let associated_data = resource.get("associated_data") .and_then(|v| v.as_str()) .unwrap_or(""); let api_v3_key = config.wechat_api_v3_key.as_deref() .ok_or_else(|| SaasError::InvalidInput("微信 API v3 密钥未配置,无法解密回调".into()))?; let plaintext = super::payment::decrypt_wechat_resource( ciphertext, nonce, associated_data, api_v3_key, )?; let decrypted: serde_json::Value = serde_json::from_str(&plaintext) .map_err(|e| SaasError::Internal(format!("微信回调解密内容 JSON 解析失败: {}", e)))?; let trade_no = decrypted.get("out_trade_no") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let trade_state = decrypted.get("trade_state") .and_then(|v| v.as_str()) .unwrap_or("UNKNOWN"); // 微信金额已为分(整数) let callback_amount = decrypted.get("amount") .and_then(|a| a.get("total")) .and_then(|v| v.as_i64()) .map(|v| v as i32); Ok((trade_no, trade_state.to_string(), callback_amount)) } /// HTML 转义,防止 XSS 注入 fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }