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:
@@ -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<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreatePaymentRequest>,
|
||||
) -> SaasResult<Json<PaymentResult>> {
|
||||
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<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(payment_id): Path<String>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
Path(method): Path<String>,
|
||||
body: axum::body::Bytes,
|
||||
) -> SaasResult<String> {
|
||||
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<MockPayQuery>,
|
||||
) -> axum::response::Html<String> {
|
||||
axum::response::Html(format!(r#"
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head><meta charset="utf-8"><title>Mock 支付</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20px; }}
|
||||
.card {{ background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
||||
.amount {{ font-size: 32px; font-weight: 700; color: #333; text-align: center; margin: 20px 0; }}
|
||||
.btn {{ display: block; width: 100%; padding: 12px; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; margin-top: 12px; }}
|
||||
.btn-pay {{ background: #1677ff; color: #fff; }}
|
||||
.btn-pay:hover {{ background: #0958d9; }}
|
||||
.btn-fail {{ background: #f5f5f5; color: #999; }}
|
||||
.subject {{ text-align: center; color: #666; font-size: 14px; }}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="subject">{subject}</div>
|
||||
<div class="amount">¥{amount_yuan}</div>
|
||||
<div style="text-align:center;color:#999;font-size:12px;margin-bottom:16px;">
|
||||
订单号: {trade_no}
|
||||
</div>
|
||||
<form action="/api/v1/billing/mock-pay/confirm" method="POST">
|
||||
<input type="hidden" name="trade_no" value="{trade_no}" />
|
||||
<button type="submit" name="action" value="success" class="btn btn-pay">确认支付 ¥{amount_yuan}</button>
|
||||
<button type="submit" name="action" value="fail" class="btn btn-fail">模拟失败</button>
|
||||
</form>
|
||||
</div>
|
||||
</body></html>
|
||||
"#,
|
||||
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<AppState>,
|
||||
Form(form): Form<MockPayConfirm>,
|
||||
) -> SaasResult<axum::response::Html<String>> {
|
||||
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#"
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head><meta charset="utf-8"><title>支付结果</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20px; text-align: center; }}
|
||||
.msg {{ font-size: 18px; color: #333; margin: 40px 0; }}
|
||||
</style></head>
|
||||
<body><div class="msg">{msg}</div></body>
|
||||
</html>
|
||||
"#)))
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
fn parse_alipay_callback(body: &str) -> (Option<String>, 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>, String) {
|
||||
// 微信回调是 JSON
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(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())
|
||||
}
|
||||
|
||||
@@ -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<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
@@ -12,4 +13,15 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
.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<crate::state::AppState> {
|
||||
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))
|
||||
}
|
||||
|
||||
266
crates/zclaw-saas/src/billing/payment.rs
Normal file
266
crates/zclaw-saas/src/billing/payment.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user