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 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-02 00:41:35 +08:00
parent becfda3fbf
commit b1e3a27043
3 changed files with 459 additions and 4 deletions

View File

@@ -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<PaymentResult> {
// 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<String> = 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<serde_json::Value> {
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<String> {
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(),
))
}
}
}