feat(payment): Stripe Checkout payment module

- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
This commit is contained in:
boos
2026-06-10 19:54:27 +08:00
parent df97633300
commit d1f3f4e93d
8 changed files with 1310 additions and 0 deletions
+39
View File
@@ -13,6 +13,8 @@ pub struct AppConfig {
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -75,6 +77,23 @@ pub struct ComfyUIConfig {
pub agent_model: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PaymentConfig {
pub enabled: bool,
pub public_base_url: Option<String>,
pub product_name_prefix: String,
pub currency: String,
pub min_amount_usd: f64,
pub max_amount_usd: Option<f64>,
pub daily_limit_usd: Option<f64>,
pub order_timeout_minutes: i64,
pub max_pending_orders: i64,
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub success_path: String,
pub cancel_path: String,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend {
@@ -116,6 +135,26 @@ impl Default for ComfyUIConfig {
}
}
impl Default for PaymentConfig {
fn default() -> Self {
Self {
enabled: false,
public_base_url: None,
product_name_prefix: "BossAI 账户充值".to_string(),
currency: "usd".to_string(),
min_amount_usd: 1.0,
max_amount_usd: Some(100_000.0),
daily_limit_usd: None,
order_timeout_minutes: 30,
max_pending_orders: 3,
stripe_secret_key: None,
stripe_webhook_secret: None,
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
}
}
}
impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool {
matches!(
+155
View File
@@ -0,0 +1,155 @@
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
services::payment,
AppState,
};
use axum::{
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/payment/config", get(get_payment_config))
.route("/api/user/payment/orders/{public_id}", get(get_payment_order))
.route(
"/api/v1/payment/webhook/stripe",
post(stripe_webhook),
)
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
async fn get_payment_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({
"success": true,
"payment": payment::public_config(&state.config.payment),
"webhooks": {
"stripe": "/api/v1/payment/webhook/stripe",
},
})))
}
async fn get_payment_order(
State(state): State<AppState>,
headers: HeaderMap,
Path(public_id): Path<String>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let order = payment::fetch_payment_order_by_public_id(&state.db, user_id, public_id.trim()).await?;
Ok(Json(json!({
"success": true,
"order": order,
})))
}
async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<(StatusCode, Json<Value>)> {
let webhook_secret = state
.config
.payment
.stripe_webhook_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::ServiceUnavailable("Stripe webhook secret is not configured".into()))?;
let signature = headers
.get("stripe-signature")
.or_else(|| headers.get("Stripe-Signature"))
.and_then(|value| value.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Missing Stripe signature".into()))?;
if !payment::verify_stripe_signature(&body, signature, webhook_secret, 300) {
return Err(AppError::Unauthorized("Invalid Stripe signature".into()));
}
let event: Value = serde_json::from_slice(&body)
.map_err(|_| AppError::BadRequest("Invalid Stripe webhook JSON".into()))?;
let Some((event_type, session_id, payment_intent)) = payment::stripe_event_checkout_session(&event) else {
return Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true }))));
};
match event_type.as_str() {
"checkout.session.completed" => {
let completion = payment::complete_stripe_order_by_session(
&state.db,
&session_id,
payment_intent.as_deref(),
)
.await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"completed": true,
"order": completion.order,
"rechargeRecord": completion.recharge_record,
"creditedKeyId": completion.credited_key_id,
})),
))
}
"checkout.session.expired" => {
let order = payment::mark_stripe_order_failed_by_session(&state.db, &session_id).await?;
Ok((
StatusCode::OK,
Json(json!({
"received": true,
"failed": order.is_some(),
"order": order,
})),
))
}
_ => Ok((StatusCode::OK, Json(json!({ "received": true, "ignored": true })))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn stripe_signature_verification_matches_route_header_shape() {
let payload = br#"{"type":"checkout.session.completed"}"#;
let secret = "whsec_route_test";
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{}", String::from_utf8_lossy(payload)).as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let header = HeaderValue::from_str(&format!("t={timestamp},v1={signature}")).unwrap();
assert!(payment::verify_stripe_signature(
payload,
header.to_str().unwrap(),
secret,
300,
));
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod comfyui_process;
pub mod cron;
pub mod notification;
pub mod oauth;
pub mod payment;
pub mod register_captcha;
pub mod registration_limiter;
pub mod route_rebuild;
File diff suppressed because it is too large Load Diff