fix: serialize Stripe checkout provisioning

This commit is contained in:
237899745
2026-07-26 03:07:30 +08:00
parent 03d0e43d4d
commit 08000cc16e
8 changed files with 805 additions and 197 deletions

View File

@@ -40,6 +40,8 @@ STORAGE_PATH=./uploads
BILLING_PROVIDER=stripe BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_xxx STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx
# Keep the official endpoint in production; override only for isolated mocks.
STRIPE_API_BASE_URL=https://api.stripe.com
# 邮件服务(注册验证 + 密码重置) # 邮件服务(注册验证 + 密码重置)
MAIL_ENABLED=false MAIL_ENABLED=false

View File

@@ -48,6 +48,7 @@ MAIL_ENABLED=false
MAIL_LOG_LINKS_WHEN_DISABLED=false MAIL_LOG_LINKS_WHEN_DISABLED=false
# STRIPE_SECRET_KEY=sk_live_replace_me # STRIPE_SECRET_KEY=sk_live_replace_me
# STRIPE_WEBHOOK_SECRET=whsec_replace_me # STRIPE_WEBHOOK_SECRET=whsec_replace_me
# STRIPE_API_BASE_URL=https://api.stripe.com
# MAIL_PROVIDER=custom # MAIL_PROVIDER=custom
# MAIL_FROM=noreply@example.com # MAIL_FROM=noreply@example.com
# MAIL_PASSWORD=replace-with-smtp-authorization-code # MAIL_PASSWORD=replace-with-smtp-authorization-code

View File

@@ -74,6 +74,7 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}" STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}" STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
STRIPE_API_BASE_URL: ${STRIPE_API_BASE_URL:-https://api.stripe.com}
MAIL_ENABLED: ${MAIL_ENABLED:-false} MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_LOG_LINKS_WHEN_DISABLED: ${MAIL_LOG_LINKS_WHEN_DISABLED:-false} MAIL_LOG_LINKS_WHEN_DISABLED: ${MAIL_LOG_LINKS_WHEN_DISABLED:-false}
MAIL_PROVIDER: ${MAIL_PROVIDER:-qq} MAIL_PROVIDER: ${MAIL_PROVIDER:-qq}

View File

@@ -592,7 +592,6 @@ Authorization: Bearer <token>
POST /billing/checkout POST /billing/checkout
Authorization: Bearer <token> Authorization: Bearer <token>
Content-Type: application/json Content-Type: application/json
Idempotency-Key: <key>
``` ```
请求体: 请求体:
@@ -605,6 +604,8 @@ Idempotency-Key: <key>
{ "success": true, "data": { "checkout_url": "https://pay.example.com/..." } } { "success": true, "data": { "checkout_url": "https://pay.example.com/..." } }
``` ```
Checkout 的 Customer 与 Session 幂等键由服务端按用户和待支付记录生成,客户端无需也不能决定该幂等边界。同一用户同一时间只允许一个未过期的 Checkout已有未取消 Stripe 订阅时返回 `409 IDEMPOTENCY_CONFLICT`,套餐调整必须使用 Portal。
### 9.5 打开客户 Portal管理支付方式/取消订阅) ### 9.5 打开客户 Portal管理支付方式/取消订阅)
```http ```http
POST /billing/portal POST /billing/portal

View File

@@ -0,0 +1,59 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM users
WHERE billing_customer_id IS NOT NULL AND billing_customer_id <> ''
GROUP BY billing_customer_id
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'duplicate users.billing_customer_id values require Stripe reconciliation before migration 017';
END IF;
IF EXISTS (
SELECT 1
FROM subscriptions
WHERE provider = 'stripe' AND status <> 'canceled'
GROUP BY user_id
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'multiple open Stripe subscriptions per user require reconciliation before migration 017';
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_billing_customer_unique
ON users(billing_customer_id)
WHERE billing_customer_id IS NOT NULL AND billing_customer_id <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_open_stripe_unique
ON subscriptions(user_id)
WHERE provider = 'stripe' AND status <> 'canceled';
CREATE TABLE IF NOT EXISTS billing_checkout_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
stripe_customer_id VARCHAR(200),
stripe_session_id VARCHAR(200),
checkout_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ NOT NULL,
lease_owner UUID,
lease_until TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
CONSTRAINT billing_checkout_sessions_status_check
CHECK (status IN ('pending', 'completed', 'expired', 'failed', 'canceled')),
CONSTRAINT billing_checkout_sessions_stripe_session_unique
UNIQUE (stripe_session_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_checkout_sessions_user_pending
ON billing_checkout_sessions(user_id)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_billing_checkout_sessions_expiry
ON billing_checkout_sessions(expires_at)
WHERE status = 'pending';

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ pub struct Config {
pub stripe_secret_key: Option<String>, pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>, pub stripe_webhook_secret: Option<String>,
pub stripe_api_base_url: String,
pub storage_path: String, pub storage_path: String,
@@ -99,6 +100,10 @@ impl Config {
} }
let stripe_secret_key = env_string("STRIPE_SECRET_KEY"); let stripe_secret_key = env_string("STRIPE_SECRET_KEY");
let stripe_webhook_secret = env_string("STRIPE_WEBHOOK_SECRET"); let stripe_webhook_secret = env_string("STRIPE_WEBHOOK_SECRET");
let stripe_api_base_url = env_string("STRIPE_API_BASE_URL")
.unwrap_or_else(|| "https://api.stripe.com".to_string())
.trim_end_matches('/')
.to_string();
let storage_path = env_string("STORAGE_PATH").unwrap_or_else(|| "./uploads".to_string()); let storage_path = env_string("STORAGE_PATH").unwrap_or_else(|| "./uploads".to_string());
@@ -140,6 +145,7 @@ impl Config {
api_key_pepper, api_key_pepper,
stripe_secret_key, stripe_secret_key,
stripe_webhook_secret, stripe_webhook_secret,
stripe_api_base_url,
storage_path, storage_path,
allow_anonymous_upload, allow_anonymous_upload,
anon_max_file_size_mb, anon_max_file_size_mb,

View File

@@ -3,7 +3,6 @@ use crate::state::AppState;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use sqlx::FromRow; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
@@ -27,15 +26,6 @@ struct IdemRow {
response_body: Option<JsonValue>, response_body: Option<JsonValue>,
} }
pub fn sha256_hex(parts: &[&[u8]]) -> String {
let mut hasher = Sha256::new();
for p in parts {
hasher.update(p);
hasher.update([0u8]); // separator
}
hex::encode(hasher.finalize())
}
pub async fn begin( pub async fn begin(
state: &AppState, state: &AppState,
scope: Scope, scope: Scope,