Implement compression quota refunds and admin manual subscription
This commit is contained in:
520
src/api/webhooks.rs
Normal file
520
src/api/webhooks.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/webhooks/stripe", post(stripe_webhook))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StripeEvent {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
data: StripeEventData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StripeEventData {
|
||||
object: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn stripe_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
|
||||
let secret = settings::get_stripe_webhook_secret(&state)
|
||||
.await
|
||||
.map_err(|err| err.with_source("stripe webhook secret not configured"))?;
|
||||
|
||||
let sig = headers
|
||||
.get("Stripe-Signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 Stripe-Signature"))?;
|
||||
|
||||
verify_stripe_signature(&body, sig, &secret)?;
|
||||
|
||||
let payload_str = std::str::from_utf8(&body)
|
||||
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "Webhook payload 非 UTF-8"))?;
|
||||
let event: StripeEvent = serde_json::from_str(payload_str)
|
||||
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "Webhook JSON 解析失败").with_source(err))?;
|
||||
|
||||
let inserted: Option<String> = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO webhook_events (provider, provider_event_id, event_type, payload)
|
||||
VALUES ('stripe', $1, $2, $3)
|
||||
ON CONFLICT (provider, provider_event_id) DO NOTHING
|
||||
RETURNING provider_event_id
|
||||
"#,
|
||||
)
|
||||
.bind(&event.id)
|
||||
.bind(&event.type_)
|
||||
.bind(&event.data.object)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 入库失败").with_source(err))?;
|
||||
|
||||
if inserted.is_none() {
|
||||
return Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: serde_json::json!({ "status": "duplicate" }),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Err(err) = process_stripe_event(&state, &event).await {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
|
||||
)
|
||||
.bind(&event.id)
|
||||
.bind(err.to_string())
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let _ = sqlx::query(
|
||||
"UPDATE webhook_events SET status = 'processed', processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
|
||||
)
|
||||
.bind(&event.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: serde_json::json!({ "status": "ok" }),
|
||||
}))
|
||||
}
|
||||
|
||||
fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Result<(), AppError> {
|
||||
let mut timestamp: Option<i64> = None;
|
||||
let mut signatures = Vec::<String>::new();
|
||||
|
||||
for part in sig_header.split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(v) = part.strip_prefix("t=") {
|
||||
timestamp = v.parse::<i64>().ok();
|
||||
} else if let Some(v) = part.strip_prefix("v1=") {
|
||||
signatures.push(v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(ts) = timestamp else {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t"));
|
||||
};
|
||||
if signatures.is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1"));
|
||||
}
|
||||
|
||||
// 5 minutes tolerance
|
||||
let now = Utc::now().timestamp();
|
||||
if (now - ts).abs() > 300 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 时间戳过期"));
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook secret 错误").with_source(err))?;
|
||||
mac.update(ts.to_string().as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(payload);
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
if signatures.iter().any(|sig| secure_eq(sig, &expected)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 验签失败"))
|
||||
}
|
||||
}
|
||||
|
||||
fn secure_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut out = 0u8;
|
||||
for (x, y) in a.as_bytes().iter().zip(b.as_bytes().iter()) {
|
||||
out |= x ^ y;
|
||||
}
|
||||
out == 0
|
||||
}
|
||||
|
||||
async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<(), AppError> {
|
||||
match event.type_.as_str() {
|
||||
"checkout.session.completed" => {
|
||||
map_checkout_session_completed(state, &event.data.object).await
|
||||
}
|
||||
"customer.subscription.created" | "customer.subscription.updated" => {
|
||||
upsert_subscription(state, &event.data.object).await
|
||||
}
|
||||
"customer.subscription.deleted" => cancel_subscription(state, &event.data.object).await,
|
||||
"invoice.paid" | "invoice.payment_failed" => upsert_invoice(state, &event.data.object).await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn map_checkout_session_completed(
|
||||
state: &AppState,
|
||||
object: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
let customer_id = object
|
||||
.get("customer")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.trim().is_empty());
|
||||
|
||||
let user_id = object
|
||||
.get("client_reference_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v| v.parse::<uuid::Uuid>().ok())
|
||||
.or_else(|| {
|
||||
object
|
||||
.pointer("/metadata/user_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v| v.parse::<uuid::Uuid>().ok())
|
||||
});
|
||||
|
||||
let Some(customer_id) = customer_id else {
|
||||
tracing::warn!("checkout.session.completed missing customer");
|
||||
return Ok(());
|
||||
};
|
||||
let Some(user_id) = user_id else {
|
||||
tracing::warn!(customer = %customer_id, "checkout.session.completed missing user_id");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET billing_customer_id = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND (billing_customer_id IS NULL OR billing_customer_id = '')
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(customer_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?;
|
||||
|
||||
if updated.rows_affected() == 0 {
|
||||
let existing: Option<String> = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT billing_customer_id FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
|
||||
.flatten();
|
||||
|
||||
if let Some(existing) = existing.filter(|v| !v.trim().is_empty()) {
|
||||
if existing != customer_id {
|
||||
tracing::warn!(
|
||||
user_id = %user_id,
|
||||
existing_customer = %existing,
|
||||
new_customer = %customer_id,
|
||||
"user already mapped to different stripe customer"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(user_id = %user_id, "user not found for checkout.session.completed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
|
||||
let provider_subscription_id = object
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
|
||||
let provider_customer_id = object
|
||||
.get("customer")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.customer 缺失"))?;
|
||||
|
||||
let status = object
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("incomplete");
|
||||
let mapped_status = map_subscription_status(status);
|
||||
|
||||
let cps = object
|
||||
.get("current_period_start")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let cpe = object
|
||||
.get("current_period_end")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let current_period_start = Utc.timestamp_opt(cps, 0).single().unwrap_or_else(Utc::now);
|
||||
let current_period_end = Utc.timestamp_opt(cpe, 0).single().unwrap_or_else(Utc::now);
|
||||
|
||||
let cancel_at_period_end = object
|
||||
.get("cancel_at_period_end")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let price_id = object
|
||||
.pointer("/items/data/0/price/id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| object.pointer("/items/data/0/plan/id").and_then(|v| v.as_str()))
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.price 缺失"))?;
|
||||
|
||||
let user_id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
|
||||
.bind(provider_customer_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let Some(user_id) = user_id else {
|
||||
tracing::warn!(customer = %provider_customer_id, "stripe customer not mapped to user");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let plan_id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM plans WHERE stripe_price_id = $1 LIMIT 1")
|
||||
.bind(price_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
|
||||
|
||||
let Some(plan_id) = plan_id else {
|
||||
tracing::warn!(price = %price_id, "stripe price not mapped to plan");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let updated: Option<uuid::Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE subscriptions
|
||||
SET user_id = $1,
|
||||
plan_id = $2,
|
||||
status = $3::subscription_status,
|
||||
current_period_start = $4,
|
||||
current_period_end = $5,
|
||||
cancel_at_period_end = $6,
|
||||
provider = 'stripe',
|
||||
provider_customer_id = $7,
|
||||
updated_at = NOW()
|
||||
WHERE provider = 'stripe' AND provider_subscription_id = $8
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(plan_id)
|
||||
.bind(mapped_status)
|
||||
.bind(current_period_start)
|
||||
.bind(current_period_end)
|
||||
.bind(cancel_at_period_end)
|
||||
.bind(provider_customer_id)
|
||||
.bind(provider_subscription_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新订阅失败").with_source(err))?;
|
||||
|
||||
if updated.is_none() {
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO subscriptions (
|
||||
user_id, plan_id, status,
|
||||
current_period_start, current_period_end,
|
||||
cancel_at_period_end,
|
||||
provider, provider_customer_id, provider_subscription_id
|
||||
) VALUES (
|
||||
$1, $2, $3::subscription_status,
|
||||
$4, $5,
|
||||
$6,
|
||||
'stripe', $7, $8
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(plan_id)
|
||||
.bind(mapped_status)
|
||||
.bind(current_period_start)
|
||||
.bind(current_period_end)
|
||||
.bind(cancel_at_period_end)
|
||||
.bind(provider_customer_id)
|
||||
.bind(provider_subscription_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
|
||||
let provider_subscription_id = object
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
UPDATE subscriptions
|
||||
SET status = 'canceled',
|
||||
cancel_at_period_end = false,
|
||||
canceled_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE provider = 'stripe' AND provider_subscription_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(provider_subscription_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
|
||||
let provider_invoice_id = object
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.id 缺失"))?;
|
||||
let provider_customer_id = object
|
||||
.get("customer")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.customer 缺失"))?;
|
||||
|
||||
let user_id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
|
||||
.bind(provider_customer_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let Some(user_id) = user_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open");
|
||||
let status = map_invoice_status(stripe_status);
|
||||
|
||||
let invoice_number = object
|
||||
.get("number")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| format!("stripe_{provider_invoice_id}"));
|
||||
|
||||
let currency = object
|
||||
.get("currency")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("cny")
|
||||
.to_uppercase();
|
||||
let total_amount_cents = object.get("total").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
|
||||
let hosted_invoice_url = object.get("hosted_invoice_url").and_then(|v| v.as_str()).map(|v| v.to_string());
|
||||
let pdf_url = object.get("invoice_pdf").and_then(|v| v.as_str()).map(|v| v.to_string());
|
||||
|
||||
let period_start = object.get("period_start").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
|
||||
let period_end = object.get("period_end").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
|
||||
|
||||
let paid_at = object
|
||||
.pointer("/status_transitions/paid_at")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|ts| Utc.timestamp_opt(ts, 0).single());
|
||||
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
UPDATE invoices
|
||||
SET status = $1::invoice_status,
|
||||
currency = $2,
|
||||
total_amount_cents = $3,
|
||||
hosted_invoice_url = $4,
|
||||
pdf_url = $5,
|
||||
period_start = $6,
|
||||
period_end = $7,
|
||||
paid_at = $8
|
||||
WHERE provider = 'stripe' AND provider_invoice_id = $9
|
||||
"#,
|
||||
)
|
||||
.bind(status)
|
||||
.bind(¤cy)
|
||||
.bind(total_amount_cents)
|
||||
.bind(hosted_invoice_url.as_deref())
|
||||
.bind(pdf_url.as_deref())
|
||||
.bind(period_start)
|
||||
.bind(period_end)
|
||||
.bind(paid_at)
|
||||
.bind(provider_invoice_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新发票失败").with_source(err))?;
|
||||
|
||||
if updated.rows_affected() == 0 {
|
||||
let invoice_number = truncate(invoice_number, 50);
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO invoices (
|
||||
user_id, invoice_number, status, currency, total_amount_cents,
|
||||
period_start, period_end,
|
||||
provider, provider_invoice_id, hosted_invoice_url, pdf_url,
|
||||
paid_at
|
||||
) VALUES (
|
||||
$1, $2, $3::invoice_status, $4, $5,
|
||||
$6, $7,
|
||||
'stripe', $8, $9, $10,
|
||||
$11
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(invoice_number)
|
||||
.bind(status)
|
||||
.bind(¤cy)
|
||||
.bind(total_amount_cents)
|
||||
.bind(period_start)
|
||||
.bind(period_end)
|
||||
.bind(provider_invoice_id)
|
||||
.bind(hosted_invoice_url.as_deref())
|
||||
.bind(pdf_url.as_deref())
|
||||
.bind(paid_at)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建发票失败").with_source(err))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(mut s: String, max: usize) -> String {
|
||||
if s.len() > max {
|
||||
s.truncate(max);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn map_subscription_status(status: &str) -> &'static str {
|
||||
match status {
|
||||
"trialing" => "trialing",
|
||||
"active" => "active",
|
||||
"past_due" => "past_due",
|
||||
"canceled" => "canceled",
|
||||
_ => "incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_invoice_status(status: &str) -> &'static str {
|
||||
match status {
|
||||
"draft" => "draft",
|
||||
"paid" => "paid",
|
||||
"void" => "void",
|
||||
"uncollectible" => "uncollectible",
|
||||
_ => "open",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user