fix: reconcile ambiguous Stripe event ordering
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -130,14 +130,18 @@ RETURNING used_units;
|
|||||||
- `payments.provider_payment_id` ↔ Stripe `payment_intent.id`(或 charge id,按实现选)
|
- `payments.provider_payment_id` ↔ Stripe `payment_intent.id`(或 charge id,按实现选)
|
||||||
|
|
||||||
### 4.2 Checkout / Portal
|
### 4.2 Checkout / Portal
|
||||||
- Checkout:后端创建 Stripe Checkout Session,前端跳转 `checkout_url`。
|
- Checkout:后端按用户行锁串行创建 Stripe Checkout Session,前端跳转 `checkout_url`。服务端使用稳定 Customer 幂等键和待支付记录 ID 对应的 Session 幂等键,不依赖客户端 `Idempotency-Key`。
|
||||||
|
- 每个用户只能映射一个非空 Stripe Customer,同时只能存在一个未过期待支付记录和一个未取消 Stripe 订阅。并发请求复用同一 Session;Customer 映射未持久化时禁止返回或创建 Session。
|
||||||
|
- 已有未取消 Stripe 订阅的用户不能再次进入订阅 Checkout,升级、降级、续费和取消统一走 Portal,避免多重周期扣费。
|
||||||
- Portal:后端创建 Stripe Billing Portal Session,前端跳转管理支付方式/取消订阅。
|
- Portal:后端创建 Stripe Billing Portal Session,前端跳转管理支付方式/取消订阅。
|
||||||
|
|
||||||
### 4.3 Stripe Webhook(商用必须)
|
### 4.3 Stripe Webhook(商用必须)
|
||||||
要求:
|
要求:
|
||||||
- **验签**:使用 `STRIPE_WEBHOOK_SECRET` 校验 `Stripe-Signature`。
|
- **验签**:使用 `STRIPE_WEBHOOK_SECRET` 校验 `Stripe-Signature`。
|
||||||
- **事件幂等**:按 `provider_event_id` 去重(落库 `webhook_events`)。
|
- **事件幂等**:按 `provider_event_id` 去重(落库 `webhook_events`)。
|
||||||
- **乱序容忍**:订阅对象按 `(event.created, 事件优先级, event.id)` 保存独立水位;`deleted` 即使先到也会保留 tombstone,旧 `created/updated` 不得恢复已取消订阅。
|
- **乱序容忍**:订阅对象按 `(event.created, 事件优先级)` 保存独立水位;`deleted` 即使先到也会保留 tombstone,旧 `created/updated` 不得恢复已取消订阅。
|
||||||
|
- **同秒歧义**:两个不同事件具有相同 `(event.created, 事件优先级)` 时,不能用不透明的 Event ID 排序,必须从 Stripe 拉取当前订阅快照并以快照响应时间推进水位。
|
||||||
|
- **迁移对账**:历史版本用本地 `subscriptions.updated_at` 播种的非终态水位会标记为待对账;API 后台任务持租约获取 Stripe 快照,成功后才清除标记。未映射 Customer 或 Price 的受管订阅事件返回失败并等待重试,不能标记为已处理。
|
||||||
- **并发一致性**:`subscriptions(provider, provider_subscription_id)` 唯一,订阅业务写入与 `webhook_events=processed` 在同一事务提交。
|
- **并发一致性**:`subscriptions(provider, provider_subscription_id)` 唯一,订阅业务写入与 `webhook_events=processed` 在同一事务提交。
|
||||||
- **可重放**:保存原始 payload(脱敏)用于排查。
|
- **可重放**:保存原始 payload(脱敏)用于排查。
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,13 @@ CREATE UNIQUE INDEX idx_webhook_events_unique ON webhook_events(provider, provid
|
|||||||
CREATE INDEX idx_webhook_events_status ON webhook_events(status);
|
CREATE INDEX idx_webhook_events_status ON webhook_events(status);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stripe 运行时还通过迁移维护三组一致性结构:
|
||||||
|
- `billing_checkout_sessions` 持久化每用户唯一的待支付 Session 及处理租约,防止并发创建多个 Customer/Session。
|
||||||
|
- `provider_object_event_watermarks` 以 Stripe `event.created` 和事件等级保存对象水位;同秒同等级的不同事件标记为歧义并触发权威快照,不能按 Event ID 字典序决定先后。
|
||||||
|
- `stripe_subscription_reconciliations` 保存历史非因果水位的租约化对账任务,允许多 API 实例用 `FOR UPDATE SKIP LOCKED` 安全消费。
|
||||||
|
|
||||||
|
数据库唯一索引同时保证非空 `users.billing_customer_id` 全局唯一、`subscriptions(provider, provider_subscription_id)` 唯一,以及每用户最多一条未取消 Stripe 订阅。部署这些索引前必须先清理存量冲突,具体检查见 `docs/deployment.md`。
|
||||||
|
|
||||||
### 4.8 tasks - 压缩任务
|
### 4.8 tasks - 压缩任务
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE tasks (
|
CREATE TABLE tasks (
|
||||||
|
|||||||
@@ -55,15 +55,48 @@ curl --fail http://127.0.0.1:8080/metrics
|
|||||||
|
|
||||||
### 更新与回滚
|
### 更新与回滚
|
||||||
|
|
||||||
更新代码后保留 `.env.production` 和命名卷,重新构建并滚动重建:
|
更新代码后保留 `.env.production` 和命名卷。包含迁移 `017`、`018` 的版本不能直接让旧、新 Worker 并行滚动:先备份数据库并停止旧 Worker,再构建新镜像。
|
||||||
|
|
||||||
|
迁移 `017` 会在发现重复 Customer 或同用户多条未取消 Stripe 订阅时主动失败。部署前先检查并人工对账,两个查询都必须返回 0 行:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT billing_customer_id, COUNT(*)
|
||||||
|
FROM users
|
||||||
|
WHERE billing_customer_id IS NOT NULL AND billing_customer_id <> ''
|
||||||
|
GROUP BY billing_customer_id
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT user_id, COUNT(*)
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE provider = 'stripe' AND status <> 'canceled'
|
||||||
|
GROUP BY user_id
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
|
docker compose --env-file .env.production -f docker/docker-compose.prod.yml stop worker
|
||||||
docker compose --env-file .env.production -f docker/docker-compose.prod.yml build api
|
docker compose --env-file .env.production -f docker/docker-compose.prod.yml build api
|
||||||
docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d
|
docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d postgres redis api
|
||||||
|
docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d worker
|
||||||
```
|
```
|
||||||
|
|
||||||
生产镜像应使用不可变的 `IMAGEFORGE_TAG`。回滚时把该值改回上一镜像标签,然后再次运行 `up -d`。
|
新 API 启动后会消费迁移 `018` 创建的 Stripe 对账队列。启动 Worker 前应确认 API 健康、`STRIPE_SECRET_KEY` 可用且服务器能访问 `STRIPE_API_BASE_URL`;对账可以后台继续,但必须监控失败项:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT status, COUNT(*)
|
||||||
|
FROM stripe_subscription_reconciliations
|
||||||
|
GROUP BY status;
|
||||||
|
|
||||||
|
SELECT provider_object_id, reconciliation_reason, updated_at
|
||||||
|
FROM provider_object_event_watermarks
|
||||||
|
WHERE provider = 'stripe' AND requires_reconciliation = true
|
||||||
|
ORDER BY updated_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
`failed` 会指数退避重试;持续失败通常表示 Stripe 凭据、网络、Customer/Price 映射不完整。上线验收要求 `pending/processing/failed` 最终归零,且 `requires_reconciliation=true` 为 0。生产镜像应使用不可变的 `IMAGEFORGE_TAG`。数据库迁移已应用后,不能只回滚旧二进制;应保留新 schema,并使用兼容该 schema 的修复镜像。
|
||||||
|
|
||||||
### 反向代理
|
### 反向代理
|
||||||
|
|
||||||
|
|||||||
57
migrations/018_stripe_watermark_reconciliation.sql
Normal file
57
migrations/018_stripe_watermark_reconciliation.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
ALTER TABLE provider_object_event_watermarks
|
||||||
|
ADD COLUMN IF NOT EXISTS requires_reconciliation BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS reconciliation_reason TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_snapshot_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stripe_subscription_reconciliations (
|
||||||
|
provider_subscription_id VARCHAR(200) PRIMARY KEY,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner UUID,
|
||||||
|
lease_until TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT stripe_subscription_reconciliations_status_check
|
||||||
|
CHECK (status IN ('pending', 'processing', 'completed', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
WITH corrected AS (
|
||||||
|
UPDATE provider_object_event_watermarks
|
||||||
|
SET last_event_created = 0,
|
||||||
|
last_event_rank = 0,
|
||||||
|
last_event_id = 'reconcile:migration',
|
||||||
|
requires_reconciliation = true,
|
||||||
|
reconciliation_reason = 'migration_016_non_causal_seed',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE provider = 'stripe'
|
||||||
|
AND object_type = 'subscription'
|
||||||
|
AND is_deleted = false
|
||||||
|
AND last_event_id LIKE 'migration:%'
|
||||||
|
RETURNING provider_object_id
|
||||||
|
)
|
||||||
|
INSERT INTO stripe_subscription_reconciliations (
|
||||||
|
provider_subscription_id, reason, status, next_attempt_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
provider_object_id,
|
||||||
|
'migration_016_non_causal_seed',
|
||||||
|
'pending',
|
||||||
|
NOW()
|
||||||
|
FROM corrected
|
||||||
|
ON CONFLICT (provider_subscription_id) DO UPDATE
|
||||||
|
SET reason = EXCLUDED.reason,
|
||||||
|
status = 'pending',
|
||||||
|
next_attempt_at = NOW(),
|
||||||
|
last_error = NULL,
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_until = NULL,
|
||||||
|
completed_at = NULL,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stripe_subscription_reconciliations_ready
|
||||||
|
ON stripe_subscription_reconciliations(next_attempt_at, updated_at)
|
||||||
|
WHERE status IN ('pending', 'failed');
|
||||||
@@ -44,7 +44,7 @@ pub async fn run(state: AppState) -> Result<(), AppError> {
|
|||||||
.nest("/api/v1", v1)
|
.nest("/api/v1", v1)
|
||||||
.fallback_service(static_service)
|
.fallback_service(static_service)
|
||||||
.layer(axum::middleware::from_fn(request_context::middleware))
|
.layer(axum::middleware::from_fn(request_context::middleware))
|
||||||
.with_state(state);
|
.with_state(state.clone());
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
@@ -52,11 +52,14 @@ pub async fn run(state: AppState) -> Result<(), AppError> {
|
|||||||
|
|
||||||
tracing::info!(addr = %addr, "API server listening");
|
tracing::info!(addr = %addr, "API server listening");
|
||||||
|
|
||||||
axum::serve(
|
let reconciliation_task = tokio::spawn(webhooks::reconciliation_loop(state.clone()));
|
||||||
|
let serve_result = axum::serve(
|
||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
|
reconciliation_task.abort();
|
||||||
|
serve_result
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "HTTP 服务异常退出").with_source(err))
|
.map_err(|err| AppError::new(ErrorCode::Internal, "HTTP 服务异常退出").with_source(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1183
src/api/webhooks.rs
1183
src/api/webhooks.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user