From b1e3a270434038131cec90997f0d61d5b58436c5 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 2 Apr 2026 00:41:35 +0800 Subject: [PATCH] feat(saas): add payment integration with Alipay/WeChat mock support - payment.rs: create_payment, handle_payment_callback, query_payment_status - Mock pay page for development mode with HTML confirm/cancel flow - Payment callback handler with subscription auto-creation on success - Alipay form-urlencoded and WeChat JSON callback parsing - 7 new routes including callback and mock-pay endpoints Co-Authored-By: Claude Opus 4.6 --- crates/zclaw-saas/src/billing/handlers.rs | 181 ++++++++++++++- crates/zclaw-saas/src/billing/mod.rs | 16 +- crates/zclaw-saas/src/billing/payment.rs | 266 ++++++++++++++++++++++ 3 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 crates/zclaw-saas/src/billing/payment.rs diff --git a/crates/zclaw-saas/src/billing/handlers.rs b/crates/zclaw-saas/src/billing/handlers.rs index a997df5..bd525d2 100644 --- a/crates/zclaw-saas/src/billing/handlers.rs +++ b/crates/zclaw-saas/src/billing/handlers.rs @@ -1,12 +1,14 @@ //! 计费 HTTP 处理器 use axum::{ - extract::{Extension, Path, State}, + extract::{Extension, Form, Path, Query, State}, Json, }; +use axum::response::Html; +use serde::Deserialize; use crate::auth::types::AuthContext; -use crate::error::SaasResult; +use crate::error::{SaasError, SaasResult}; use crate::state::AppState; use super::service; use super::types::*; @@ -53,3 +55,178 @@ pub async fn get_usage( let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; Ok(Json(usage)) } + +/// POST /api/v1/billing/payments — 创建支付订单 +pub async fn create_payment( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + let result = super::payment::create_payment( + &state.db, + &ctx.account_id, + &req, + ).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); + + // 支付宝回调:form-urlencoded 格式 + // 微信回调:JSON 格式 + let (trade_no, status) = if method == "alipay" { + parse_alipay_callback(&body_str) + } else if method == "wechat" { + parse_wechat_callback(&body_str) + } else { + tracing::warn!("Unknown payment callback method: {}", method); + return Ok("fail".into()); + }; + + if let Some(trade_no) = trade_no { + super::payment::handle_payment_callback(&state.db, &trade_no, &status).await?; + } + + // 支付宝期望 "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 { + axum::response::Html(format!(r#" + + +Mock 支付 + + +
+
{subject}
+
¥{amount_yuan}
+
+ 订单号: {trade_no} +
+
+ + + +
+
+ + "#, + subject = params.subject, + trade_no = params.trade_no, + amount_yuan = params.amount as f64 / 100.0, + )) +} + +#[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).await { + tracing::error!("Mock payment callback failed: {}", e); + } + + let msg = if status == "success" { + "支付成功!您可以关闭此页面。" + } else { + "支付已取消。" + }; + + Ok(axum::response::Html(format!(r#" + + +支付结果 + +
{msg}
+ + "#))) +} + +// === 辅助函数 === + +fn parse_alipay_callback(body: &str) -> (Option, String) { + // 简化解析:支付宝回调是 form-urlencoded + for pair in body.split('&') { + if let Some((k, v)) = pair.split_once('=') { + if k == "out_trade_no" { + return (Some(urlencoding::decode(v).unwrap_or_default().to_string()), "TRADE_SUCCESS".into()); + } + } + } + (None, "unknown".into()) +} + +fn parse_wechat_callback(body: &str) -> (Option, String) { + // 微信回调是 JSON + if let Ok(v) = serde_json::from_str::(body) { + if let Some(event_type) = v.get("event_type").and_then(|t| t.as_str()) { + if event_type == "TRANSACTION.SUCCESS" { + let trade_no = v.pointer("/resource/out_trade_no") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return (trade_no, "SUCCESS".into()); + } + } + } + (None, "unknown".into()) +} diff --git a/crates/zclaw-saas/src/billing/mod.rs b/crates/zclaw-saas/src/billing/mod.rs index 63197b6..a66c71d 100644 --- a/crates/zclaw-saas/src/billing/mod.rs +++ b/crates/zclaw-saas/src/billing/mod.rs @@ -1,10 +1,11 @@ -//! 计费模块 — 计划管理、订阅、用量配额 +//! 计费模块 — 计划管理、订阅、用量配额、支付 pub mod types; pub mod service; pub mod handlers; +pub mod payment; -use axum::routing::get; +use axum::routing::{get, post}; pub fn routes() -> axum::Router { axum::Router::new() @@ -12,4 +13,15 @@ pub fn routes() -> axum::Router { .route("/api/v1/billing/plans/{id}", get(handlers::get_plan)) .route("/api/v1/billing/subscription", get(handlers::get_subscription)) .route("/api/v1/billing/usage", get(handlers::get_usage)) + .route("/api/v1/billing/payments", post(handlers::create_payment)) + .route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status)) + // 支付回调(无需 auth) + .route("/api/v1/billing/callback/{method}", post(handlers::payment_callback)) +} + +/// mock 支付页面路由(开发模式) +pub fn mock_routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/billing/mock-pay", get(handlers::mock_pay_page)) + .route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm)) } diff --git a/crates/zclaw-saas/src/billing/payment.rs b/crates/zclaw-saas/src/billing/payment.rs new file mode 100644 index 0000000..9953a41 --- /dev/null +++ b/crates/zclaw-saas/src/billing/payment.rs @@ -0,0 +1,266 @@ +//! 支付集成 — 支付宝/微信支付 +//! +//! 开发模式使用 mock 支付,生产模式调用真实支付 API。 +//! 真实集成需要: +//! - 支付宝:alipay-sdk-rust 或 HTTP 直连(支付宝开放平台 v3 API) +//! - 微信支付:wxpay-rust 或 wechat-pay-rs + +use sqlx::PgPool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +/// 创建支付订单 +/// +/// 返回支付链接/二维码 URL,前端跳转或展示 +pub async fn create_payment( + pool: &PgPool, + account_id: &str, + req: &CreatePaymentRequest, +) -> SaasResult { + // 1. 获取计划信息 + let plan = sqlx::query_as::<_, BillingPlan>( + "SELECT * FROM billing_plans WHERE id = $1 AND status = 'active'" + ) + .bind(&req.plan_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| SaasError::NotFound("计划不存在或已下架".into()))?; + + // 检查是否已有活跃订阅 + let existing = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM billing_subscriptions \ + WHERE account_id = $1 AND status IN ('trial', 'active') AND plan_id = $2" + ) + .bind(account_id) + .bind(&req.plan_id) + .fetch_one(pool) + .await?; + + if existing > 0 { + return Err(SaasError::InvalidInput("已订阅该计划".into())); + } + + // 2. 创建发票 + let invoice_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now(); + let due = now + chrono::Duration::days(1); + + sqlx::query( + "INSERT INTO billing_invoices \ + (id, account_id, plan_id, amount_cents, currency, description, status, due_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, $8, $8)" + ) + .bind(&invoice_id) + .bind(account_id) + .bind(&req.plan_id) + .bind(plan.price_cents) + .bind(&plan.currency) + .bind(format!("{} - {} ({})", plan.display_name, plan.interval, now.format("%Y-%m"))) + .bind(due.to_rfc3339()) + .bind(now.to_rfc3339()) + .execute(pool) + .await?; + + // 3. 创建支付记录 + let payment_id = uuid::Uuid::new_v4().to_string(); + let trade_no = format!("ZCLAW-{}-{}", chrono::Utc::now().format("%Y%m%d%H%M%S"), &payment_id[..8]); + + sqlx::query( + "INSERT INTO billing_payments \ + (id, invoice_id, account_id, amount_cents, currency, method, status, external_trade_no, metadata, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, '{}', $8, $8)" + ) + .bind(&payment_id) + .bind(&invoice_id) + .bind(account_id) + .bind(plan.price_cents) + .bind(&plan.currency) + .bind(format!("{:?}", req.payment_method).to_lowercase()) + .bind(&trade_no) + .bind(now.to_rfc3339()) + .execute(pool) + .await?; + + // 4. 生成支付链接 + let pay_url = generate_pay_url(req.payment_method, &trade_no, plan.price_cents, &plan.display_name)?; + + Ok(PaymentResult { + payment_id, + trade_no, + pay_url, + amount_cents: plan.price_cents, + }) +} + +/// 处理支付回调(支付宝/微信异步通知) +pub async fn handle_payment_callback( + pool: &PgPool, + trade_no: &str, + status: &str, +) -> SaasResult<()> { + // 1. 查找支付记录 + let payment: Option<(String, String, String, i32)> = sqlx::query_as::<_, (String, String, String, i32)>( + "SELECT id, invoice_id, account_id, amount_cents \ + FROM billing_payments WHERE external_trade_no = $1 AND status = 'pending'" + ) + .bind(trade_no) + .fetch_optional(pool) + .await?; + + let (payment_id, invoice_id, account_id, _amount) = match payment { + Some(p) => p, + None => { + tracing::warn!("Payment callback for unknown/expired trade: {}", trade_no); + return Ok(()); + } + }; + + let now = chrono::Utc::now().to_rfc3339(); + + if status == "success" || status == "TRADE_SUCCESS" || status == "SUCCESS" { + // 2. 更新支付状态 + sqlx::query( + "UPDATE billing_payments SET status = 'succeeded', paid_at = $1, updated_at = $1 WHERE id = $2" + ) + .bind(&now) + .bind(&payment_id) + .execute(pool) + .await?; + + // 3. 更新发票状态 + sqlx::query( + "UPDATE billing_invoices SET status = 'paid', paid_at = $1, updated_at = $1 WHERE id = $2" + ) + .bind(&now) + .bind(&invoice_id) + .execute(pool) + .await?; + + // 4. 获取发票关联的计划 + let plan_id: Option = sqlx::query_scalar( + "SELECT plan_id FROM billing_invoices WHERE id = $1" + ) + .bind(&invoice_id) + .fetch_optional(pool) + .await? + .flatten(); + + if let Some(plan_id) = plan_id { + // 5. 取消旧订阅 + sqlx::query( + "UPDATE billing_subscriptions SET status = 'canceled', canceled_at = $1, updated_at = $1 \ + WHERE account_id = $2 AND status IN ('trial', 'active')" + ) + .bind(&now) + .bind(&account_id) + .execute(pool) + .await?; + + // 6. 创建新订阅(30 天周期) + let sub_id = uuid::Uuid::new_v4().to_string(); + let period_end = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + let period_start = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO billing_subscriptions \ + (id, account_id, plan_id, status, current_period_start, current_period_end, created_at, updated_at) \ + VALUES ($1, $2, $3, 'active', $4, $5, $6, $6)" + ) + .bind(&sub_id) + .bind(&account_id) + .bind(&plan_id) + .bind(&period_start) + .bind(&period_end) + .bind(&now) + .execute(pool) + .await?; + + tracing::info!( + "Payment succeeded: account={}, plan={}, subscription={}", + account_id, plan_id, sub_id + ); + } + } else { + // 支付失败 + sqlx::query( + "UPDATE billing_payments SET status = 'failed', failure_reason = $1, updated_at = $2 WHERE id = $3" + ) + .bind(status) + .bind(&now) + .bind(&payment_id) + .execute(pool) + .await?; + + tracing::warn!("Payment failed: trade={}, status={}", trade_no, status); + } + + Ok(()) +} + +/// 查询支付状态 +pub async fn query_payment_status( + pool: &PgPool, + payment_id: &str, + account_id: &str, +) -> SaasResult { + let payment: (String, String, i32, String, String) = sqlx::query_as::<_, (String, String, i32, String, String)>( + "SELECT id, method, amount_cents, currency, status \ + FROM billing_payments WHERE id = $1 AND account_id = $2" + ) + .bind(payment_id) + .bind(account_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| SaasError::NotFound("支付记录不存在".into()))?; + + let (id, method, amount, currency, status) = payment; + Ok(serde_json::json!({ + "id": id, + "method": method, + "amount_cents": amount, + "currency": currency, + "status": status, + })) +} + +// === 内部函数 === + +/// 生成支付 URL(开发模式使用 mock) +fn generate_pay_url( + method: PaymentMethod, + trade_no: &str, + amount_cents: i32, + subject: &str, +) -> SaasResult { + let is_dev = std::env::var("ZCLAW_SAAS_DEV") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + if is_dev { + // 开发模式:返回 mock 支付页面 URL + let base = std::env::var("ZCLAW_SAAS_URL") + .unwrap_or_else(|_| "http://localhost:8080".into()); + return Ok(format!( + "{}/api/v1/billing/mock-pay?trade_no={}&amount={}&subject={}", + base, trade_no, amount_cents, + urlencoding::encode(subject), + )); + } + + match method { + PaymentMethod::Alipay => { + // TODO: 真实支付宝集成 + // 需要 ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY 等环境变量 + Err(SaasError::InvalidInput( + "支付宝支付集成尚未配置,请联系管理员".into(), + )) + } + PaymentMethod::Wechat => { + // TODO: 真实微信支付集成 + // 需要 WECHAT_PAY_MCH_ID, WECHAT_PAY_API_KEY 等 + Err(SaasError::InvalidInput( + "微信支付集成尚未配置,请联系管理员".into(), + )) + } + } +}