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

@@ -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())
}