fix: serialize Stripe checkout provisioning
This commit is contained in:
@@ -40,6 +40,8 @@ STORAGE_PATH=./uploads
|
||||
BILLING_PROVIDER=stripe
|
||||
STRIPE_SECRET_KEY=sk_test_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
|
||||
|
||||
@@ -48,6 +48,7 @@ MAIL_ENABLED=false
|
||||
MAIL_LOG_LINKS_WHEN_DISABLED=false
|
||||
# STRIPE_SECRET_KEY=sk_live_replace_me
|
||||
# STRIPE_WEBHOOK_SECRET=whsec_replace_me
|
||||
# STRIPE_API_BASE_URL=https://api.stripe.com
|
||||
# MAIL_PROVIDER=custom
|
||||
# MAIL_FROM=noreply@example.com
|
||||
# MAIL_PASSWORD=replace-with-smtp-authorization-code
|
||||
|
||||
@@ -74,6 +74,7 @@ services:
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
|
||||
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||
STRIPE_API_BASE_URL: ${STRIPE_API_BASE_URL:-https://api.stripe.com}
|
||||
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
||||
MAIL_LOG_LINKS_WHEN_DISABLED: ${MAIL_LOG_LINKS_WHEN_DISABLED:-false}
|
||||
MAIL_PROVIDER: ${MAIL_PROVIDER:-qq}
|
||||
|
||||
@@ -592,7 +592,6 @@ Authorization: Bearer <token>
|
||||
POST /billing/checkout
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
Idempotency-Key: <key>
|
||||
```
|
||||
|
||||
请求体:
|
||||
@@ -605,6 +604,8 @@ Idempotency-Key: <key>
|
||||
{ "success": true, "data": { "checkout_url": "https://pay.example.com/..." } }
|
||||
```
|
||||
|
||||
Checkout 的 Customer 与 Session 幂等键由服务端按用户和待支付记录生成,客户端无需也不能决定该幂等边界。同一用户同一时间只允许一个未过期的 Checkout;已有未取消 Stripe 订阅时返回 `409 IDEMPOTENCY_CONFLICT`,套餐调整必须使用 Portal。
|
||||
|
||||
### 9.5 打开客户 Portal(管理支付方式/取消订阅)
|
||||
```http
|
||||
POST /billing/portal
|
||||
|
||||
59
migrations/017_billing_checkout_invariants.sql
Normal file
59
migrations/017_billing_checkout_invariants.sql
Normal 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
@@ -25,6 +25,7 @@ pub struct Config {
|
||||
|
||||
pub stripe_secret_key: Option<String>,
|
||||
pub stripe_webhook_secret: Option<String>,
|
||||
pub stripe_api_base_url: String,
|
||||
|
||||
pub storage_path: String,
|
||||
|
||||
@@ -99,6 +100,10 @@ impl Config {
|
||||
}
|
||||
let stripe_secret_key = env_string("STRIPE_SECRET_KEY");
|
||||
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());
|
||||
|
||||
@@ -140,6 +145,7 @@ impl Config {
|
||||
api_key_pepper,
|
||||
stripe_secret_key,
|
||||
stripe_webhook_secret,
|
||||
stripe_api_base_url,
|
||||
storage_path,
|
||||
allow_anonymous_upload,
|
||||
anon_max_file_size_mb,
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::state::AppState;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -27,15 +26,6 @@ struct IdemRow {
|
||||
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(
|
||||
state: &AppState,
|
||||
scope: Scope,
|
||||
|
||||
Reference in New Issue
Block a user