fix: serialize Stripe checkout provisioning
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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_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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user