From f2d490edcee37eb06c4aebf9d13460c67b2d6ac3 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sun, 26 Jul 2026 10:54:01 +0800 Subject: [PATCH] fix(billing): enforce one effective subscription per user --- frontend/src/pages/admin/AdminBillingPage.vue | 2 +- ..._cross_provider_subscription_invariant.sql | 65 +++++ src/api/admin.rs | 244 ++++++++++++++++-- src/api/billing.rs | 29 ++- src/api/webhooks.rs | 48 +++- 5 files changed, 349 insertions(+), 39 deletions(-) create mode 100644 migrations/023_cross_provider_subscription_invariant.sql diff --git a/frontend/src/pages/admin/AdminBillingPage.vue b/frontend/src/pages/admin/AdminBillingPage.vue index 27a6782..8b799d3 100644 --- a/frontend/src/pages/admin/AdminBillingPage.vue +++ b/frontend/src/pages/admin/AdminBillingPage.vue @@ -212,7 +212,7 @@ onMounted(async () => { > {{ subBusy ? '提交中…' : '立即开通' }} - 会取消该用户当前有效订阅,并按月数顺延。 + 会替换当前本地套餐;存在未取消 Stripe 订阅时将拒绝操作。
diff --git a/migrations/023_cross_provider_subscription_invariant.sql b/migrations/023_cross_provider_subscription_invariant.sql new file mode 100644 index 0000000..14afae6 --- /dev/null +++ b/migrations/023_cross_provider_subscription_invariant.sql @@ -0,0 +1,65 @@ +WITH ranked AS ( + SELECT + id, + user_id, + provider, + status::text AS previous_status, + current_period_end, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY + CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END, + current_period_end DESC, + updated_at DESC, + id DESC + ) AS position + FROM subscriptions + WHERE status IN ('active', 'trialing', 'past_due') +), duplicates AS ( + SELECT * + FROM ranked + WHERE position > 1 +) +INSERT INTO audit_logs ( + user_id, action, resource_type, resource_id, details +) +SELECT + user_id, + 'migration_subscription_dedup', + 'subscription', + id, + jsonb_build_object( + 'migration', '023_cross_provider_subscription_invariant', + 'provider', provider, + 'previous_status', previous_status, + 'current_period_end', current_period_end, + 'reason', 'cross_provider_single_effective_subscription' + ) +FROM duplicates; + +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY + CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END, + current_period_end DESC, + updated_at DESC, + id DESC + ) AS position + FROM subscriptions + WHERE status IN ('active', 'trialing', 'past_due') +) +UPDATE subscriptions AS subscription +SET status = 'canceled', + cancel_at_period_end = false, + canceled_at = COALESCE(subscription.canceled_at, NOW()), + updated_at = NOW() +FROM ranked +WHERE ranked.position > 1 + AND subscription.id = ranked.id; + +CREATE UNIQUE INDEX idx_subscriptions_user_effective_unique + ON subscriptions(user_id) + WHERE status IN ('active', 'trialing', 'past_due'); diff --git a/src/api/admin.rs b/src/api/admin.rs index fc30b31..60e8ae7 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -959,28 +959,92 @@ async fn create_manual_subscription( return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用")); } + let (subscription_id, period_start, period_end) = persist_manual_subscription( + &state.db, + admin_id, + user_id, + plan.id, + months, + req.note.as_deref(), + ip, + ) + .await?; + + Ok(Json(Envelope { + success: true, + data: ManualSubscriptionResponse { + message: "套餐已开通".to_string(), + subscription_id, + user_id, + plan_id: plan.id, + plan_name: plan.name, + period_start, + period_end, + status: "active".to_string(), + }, + })) +} + +async fn persist_manual_subscription( + pool: &sqlx::PgPool, + admin_id: Uuid, + user_id: Uuid, + plan_id: Uuid, + months: i32, + note: Option<&str>, + ip: IpAddr, +) -> Result<(Uuid, DateTime, DateTime), AppError> { let period_start = Utc::now(); let period_end = add_months_utc8(period_start, months)?; - - let mut tx = state - .db + let mut tx = pool .begin() .await .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; - let _ = sqlx::query( + let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE") + .bind(user_id) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "锁定订阅用户失败").with_source(err))?; + + let has_open_stripe: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 + FROM subscriptions + WHERE user_id = $1 + AND provider = 'stripe' + AND status <> 'canceled' + ) + "#, + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "检查 Stripe 订阅失败").with_source(err))?; + if has_open_stripe { + return Err(AppError::new( + ErrorCode::Forbidden, + "用户存在未取消的 Stripe 订阅,不能直接替换为手工套餐", + )); + } + + sqlx::query( r#" UPDATE subscriptions SET status = 'canceled', cancel_at_period_end = false, canceled_at = NOW(), updated_at = NOW() - WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due') + WHERE user_id = $1 + AND provider <> 'stripe' + AND status IN ('active', 'trialing', 'past_due') "#, ) .bind(user_id) .execute(&mut *tx) - .await; + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "关闭原本地订阅失败").with_source(err))?; let subscription_id: Uuid = sqlx::query_scalar( r#" @@ -999,7 +1063,7 @@ async fn create_manual_subscription( "#, ) .bind(user_id) - .bind(plan.id) + .bind(plan_id) .bind(period_start) .bind(period_end) .fetch_one(&mut *tx) @@ -1031,9 +1095,9 @@ async fn create_manual_subscription( .bind(subscription_id) .bind(serde_json::json!({ "target_user_id": user_id, - "plan_id": plan.id, + "plan_id": plan_id, "months": months, - "note": req.note, + "note": note, })) .bind(ip.to_string()) .execute(&mut *tx) @@ -1044,19 +1108,7 @@ async fn create_manual_subscription( .await .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; - Ok(Json(Envelope { - success: true, - data: ManualSubscriptionResponse { - message: "套餐已开通".to_string(), - subscription_id, - user_id, - plan_id: plan.id, - plan_name: plan.name, - period_start, - period_end, - status: "active".to_string(), - }, - })) + Ok((subscription_id, period_start, period_end)) } fn add_months_utc8(start: DateTime, months: i32) -> Result, AppError> { @@ -1740,6 +1792,7 @@ async fn audit_config_action( #[cfg(test)] mod tests { use super::*; + use sqlx::postgres::PgPoolOptions; #[test] fn secret_masking_never_splits_utf8() { @@ -1747,4 +1800,151 @@ mod tests { assert_eq!(mask_secret("中文密钥测试内容"), "中文密钥测试内容"); assert_eq!(mask_secret("🔑🔑🔑🔑🔑🔑🔑🔑more"), "🔑🔑🔑🔑🔑🔑🔑🔑..."); } + + #[tokio::test] + #[ignore = "requires IMAGEFORGE_TEST_DATABASE_URL"] + async fn manual_subscriptions_are_serialized_and_cannot_replace_stripe() { + let database_url = std::env::var("IMAGEFORGE_TEST_DATABASE_URL") + .expect("IMAGEFORGE_TEST_DATABASE_URL is required"); + let pool = PgPoolOptions::new() + .max_connections(32) + .connect(&database_url) + .await + .expect("connect test database"); + sqlx::migrate!() + .run(&pool) + .await + .expect("apply test migrations"); + + let marker = Uuid::new_v4().simple().to_string(); + let admin_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO users (email, username, password_hash, role, email_verified_at) + VALUES ($1, $2, 'test', 'admin', NOW()) + RETURNING id + "#, + ) + .bind(format!("admin-{marker}@example.test")) + .bind(format!("admin-{marker}")) + .fetch_one(&pool) + .await + .expect("insert test admin"); + let user_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO users (email, username, password_hash, email_verified_at) + VALUES ($1, $2, 'test', NOW()) + RETURNING id + "#, + ) + .bind(format!("user-{marker}@example.test")) + .bind(format!("user-{marker}")) + .fetch_one(&pool) + .await + .expect("insert test user"); + let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'pro_monthly'") + .fetch_one(&pool) + .await + .expect("load test plan"); + let ip: IpAddr = "127.0.0.1".parse().unwrap(); + + let mut joins = Vec::new(); + for _ in 0..20 { + let pool = pool.clone(); + joins.push(tokio::spawn(async move { + persist_manual_subscription( + &pool, + admin_id, + user_id, + plan_id, + 1, + Some("concurrency-test"), + ip, + ) + .await + })); + } + for join in joins { + join.await + .expect("manual subscription task panicked") + .expect("manual subscription failed"); + } + + let effective_manual: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM subscriptions + WHERE user_id = $1 + AND provider = 'manual' + AND status IN ('active', 'trialing', 'past_due') + "#, + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count effective manual subscriptions"); + assert_eq!(effective_manual, 1); + + sqlx::query( + "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE user_id = $1", + ) + .bind(user_id) + .execute(&pool) + .await + .expect("cancel test manual subscription"); + sqlx::query( + r#" + INSERT INTO subscriptions ( + user_id, plan_id, status, current_period_start, current_period_end, + provider, provider_customer_id, provider_subscription_id + ) VALUES ( + $1, $2, 'active', NOW(), NOW() + INTERVAL '1 month', + 'stripe', $3, $4 + ) + "#, + ) + .bind(user_id) + .bind(plan_id) + .bind(format!("cus_{marker}")) + .bind(format!("sub_{marker}")) + .execute(&pool) + .await + .expect("insert Stripe subscription"); + + let error = persist_manual_subscription( + &pool, + admin_id, + user_id, + plan_id, + 1, + Some("must-not-replace-stripe"), + ip, + ) + .await + .expect_err("manual subscription replaced Stripe"); + assert_eq!(error.code, ErrorCode::Forbidden); + let effective_subscriptions: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM subscriptions + WHERE user_id = $1 + AND status IN ('active', 'trialing', 'past_due') + "#, + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count effective subscriptions"); + assert_eq!(effective_subscriptions, 1); + + sqlx::query("DELETE FROM audit_logs WHERE details->>'target_user_id' = $1") + .bind(user_id.to_string()) + .execute(&pool) + .await + .expect("clean test audit logs"); + sqlx::query("DELETE FROM users WHERE id = ANY($1)") + .bind(vec![user_id, admin_id]) + .execute(&pool) + .await + .expect("clean test users"); + } } diff --git a/src/api/billing.rs b/src/api/billing.rs index ee460ba..2964bc9 100644 --- a/src/api/billing.rs +++ b/src/api/billing.rs @@ -457,23 +457,30 @@ async fn create_checkout_for_user( .map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))? .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在"))?; - let has_open_subscription: bool = sqlx::query_scalar( + let open_subscription_provider: Option = sqlx::query_scalar( r#" - SELECT EXISTS( - SELECT 1 FROM subscriptions - WHERE user_id = $1 AND provider = 'stripe' AND status <> 'canceled' - ) + SELECT provider + FROM subscriptions + WHERE user_id = $1 + AND ( + (provider = 'stripe' AND status <> 'canceled') + OR status IN ('active', 'trialing', 'past_due') + ) + ORDER BY CASE WHEN provider = 'stripe' THEN 0 ELSE 1 END + LIMIT 1 "#, ) .bind(user_id) - .fetch_one(&mut *tx) + .fetch_optional(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅状态失败").with_source(err))?; - if has_open_subscription { - return Err(AppError::new( - ErrorCode::IdempotencyConflict, - "已有 Stripe 订阅,请通过账单门户升级、降级或续费", - )); + if let Some(provider) = open_subscription_provider { + let message = if provider == "stripe" { + "已有 Stripe 订阅,请通过账单门户升级、降级或续费" + } else { + "当前已有有效套餐,请在套餐结束后创建 Stripe 订阅" + }; + return Err(AppError::new(ErrorCode::IdempotencyConflict, message)); } sqlx::query( diff --git a/src/api/webhooks.rs b/src/api/webhooks.rs index 3173221..b7f28fd 100644 --- a/src/api/webhooks.rs +++ b/src/api/webhooks.rs @@ -642,12 +642,14 @@ async fn write_subscription( if subscription.status != "canceled" { let conflicting: Option = sqlx::query_scalar( r#" - SELECT provider_subscription_id + SELECT provider || ':' || COALESCE(provider_subscription_id, id::text) FROM subscriptions WHERE user_id = $1 - AND provider = 'stripe' - AND status <> 'canceled' - AND provider_subscription_id <> $2 + AND status IN ('active', 'trialing', 'past_due') + AND NOT ( + provider = 'stripe' + AND provider_subscription_id = $2 + ) FOR UPDATE "#, ) @@ -661,7 +663,7 @@ async fn write_subscription( if conflicting.is_some() { return Err(AppError::new( ErrorCode::StorageUnavailable, - "用户已有其他未取消 Stripe 订阅,事件等待人工对账", + "用户已有其他有效订阅,Stripe 事件等待人工对账", )); } } @@ -1910,6 +1912,42 @@ mod tests { .await .expect("cancel primary subscription"); + let manual_subscription_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO subscriptions ( + user_id, plan_id, status, current_period_start, current_period_end, provider + ) VALUES ($1, $2, 'active', NOW(), NOW() + INTERVAL '1 month', 'manual') + RETURNING id + "#, + ) + .bind(user_id) + .bind(plan_id) + .fetch_one(&pool) + .await + .expect("insert manual subscription before delayed Stripe event"); + let delayed_active_error = apply_test_event(&state, &secondary) + .await + .expect_err("delayed active Stripe event created cross-provider double entitlement"); + assert_eq!(delayed_active_error.code, ErrorCode::StorageUnavailable); + let effective_after_delay: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM subscriptions + WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due') + "#, + ) + .bind(user_id) + .fetch_one(&pool) + .await + .expect("count effective subscriptions after delayed Stripe event"); + assert_eq!(effective_after_delay, 1); + sqlx::query( + "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE id = $1", + ) + .bind(manual_subscription_id) + .execute(&pool) + .await + .expect("cancel delayed-event manual fixture"); + let concurrent_invoice_id = format!("inv_{marker}_concurrent"); let concurrent_invoice_number = format!("INV-{marker}-C"); let concurrent_invoice_events = [