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:
Generated
+3
@@ -399,6 +399,8 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"figment",
|
"figment",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lettre",
|
"lettre",
|
||||||
@@ -411,6 +413,7 @@ dependencies = [
|
|||||||
"rstest",
|
"rstest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ once_cell = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
||||||
|
|||||||
+19
@@ -29,6 +29,25 @@ proxy_log_retention_days = 30
|
|||||||
# Supports http://, https:// and socks5:// proxy URLs.
|
# Supports http://, https:// and socks5:// proxy URLs.
|
||||||
# template_proxy_url = "http://127.0.0.1:7890"
|
# template_proxy_url = "http://127.0.0.1:7890"
|
||||||
|
|
||||||
|
[payment]
|
||||||
|
enabled = false
|
||||||
|
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
|
||||||
|
# Example: "https://tybossai.com"
|
||||||
|
public_base_url = ""
|
||||||
|
product_name_prefix = "BossAI 账户充值"
|
||||||
|
currency = "usd"
|
||||||
|
min_amount_usd = 1.0
|
||||||
|
max_amount_usd = 100000.0
|
||||||
|
order_timeout_minutes = 30
|
||||||
|
max_pending_orders = 3
|
||||||
|
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
|
||||||
|
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
|
||||||
|
# Prefer environment variables for secrets:
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
stripe_secret_key = ""
|
||||||
|
stripe_webhook_secret = ""
|
||||||
|
|
||||||
[comfyui]
|
[comfyui]
|
||||||
enabled = false
|
enabled = false
|
||||||
python_bin = "python3"
|
python_bin = "python3"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
public_id TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
recharge_record_id INTEGER,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
payment_method TEXT NOT NULL DEFAULT 'stripe',
|
||||||
|
provider_session_id TEXT,
|
||||||
|
provider_payment_id TEXT,
|
||||||
|
amount_usd REAL NOT NULL,
|
||||||
|
amount_minor INTEGER NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'usd',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
checkout_url TEXT,
|
||||||
|
success_url TEXT,
|
||||||
|
cancel_url TEXT,
|
||||||
|
note TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
paid_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
|
||||||
|
ON payment_orders (public_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
|
||||||
|
ON payment_orders (provider, provider_session_id)
|
||||||
|
WHERE provider_session_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
|
||||||
|
ON payment_orders (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
|
||||||
|
ON payment_orders (status);
|
||||||
@@ -13,6 +13,8 @@ pub struct AppConfig {
|
|||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comfyui: ComfyUIConfig,
|
pub comfyui: ComfyUIConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payment: PaymentConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -75,6 +77,23 @@ pub struct ComfyUIConfig {
|
|||||||
pub agent_model: String,
|
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)]
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ComfyUIBackend {
|
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 {
|
impl ComfyUIConfig {
|
||||||
pub fn uses_native_backend(&self) -> bool {
|
pub fn uses_native_backend(&self) -> bool {
|
||||||
matches!(
|
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 cron;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
pub mod payment;
|
||||||
pub mod register_captcha;
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user