Implement compression quota refunds and admin manual subscription

This commit is contained in:
2025-12-19 23:28:32 +08:00
commit 11f48fd3dd
106 changed files with 27848 additions and 0 deletions

520
src/api/webhooks.rs Normal file
View 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(&currency)
.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(&currency)
.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",
}
}