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:
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user