fix(server/pay): promo 限购 settle 侧幂等复查 + 000027 部分唯一索引(TOCTOU)
ci-pangolin / Lint — shellcheck (pull_request) Successful in 11s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 41s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Failing after 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 11s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m44s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 11m55s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 11s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 41s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Failing after 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 11s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m44s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 11m55s
CreateOrder 下单时的 HasPaidPurchase 只是裸 SELECT 无锁,并发/多挂起单可绕过 promo SKU「每账号限购一次」。两层修: ① webhook.go settle 在锁行、开通前对 item.Promo 的 SKU 复查一次(排除本单), 命中说明另一笔同 user+SKU 订单已抢先 settle,跳过发放(不二次 +N 天)、 仍 ack(否则 pay 无限重投)。新增 store.HasPaidPurchaseExcludingTx / MarkDuplicatePromoTx——重复单标记 canceled 而非 paid,避免自撞下面的 唯一索引、也避免整笔 500 触发死循环重投。 ② migration 000027(sqlite):部分唯一索引 ux_pay_promo_paid ON pay_purchases(user_id, sku) WHERE status='paid' AND sku='pro_month_promo', 兜底防止任何路径把同一用户的 promo 单二次写成 paid。mysql 8 不支持部分 索引,000027 mysql 侧是 no-op 占位(仅对齐编号),该场景 mysql 只靠①的 应用层复查兜底——两库防线强度不同,已在迁移文件与代码注释中记录。 副作用:新增迁移把 sqlite 迁移顶点从 26 推到 27,同步更新 internal/store/sqlite_migrate_test.go 的版本断言,以及 internal/store/codes_lib_migrate_test.go 手动 Steps(-1) 序列(补一步跳过 000027,才能精确落在 000022 边界,这条测试是硬编码步数的)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,15 +178,38 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
|
||||
purchaseID, userID = row.ID, row.UserID
|
||||
}
|
||||
|
||||
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
|
||||
if perr != nil {
|
||||
paidAt = h.now().UTC()
|
||||
}
|
||||
|
||||
// C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只在
|
||||
// 下单一刻查、裸 SELECT 无锁——并发/多笔挂起单可绕过"每账号限购一次"。这里
|
||||
// 在锁行、开通前对 Promo SKU 再复查一次:命中说明另一笔同 user+SKU 的订单
|
||||
// 已抢先 settle 为 paid,本单跳过发放(不二次 +N 天),仍需 ACK 200,否则 pay
|
||||
// 会把 500 当失败无限重投。sqlite 侧另有 migration 000027 的部分唯一索引
|
||||
// (user_id, sku) WHERE status='paid' 兜底;mysql 不支持部分索引,本检查是
|
||||
// mysql 侧唯一防线(见该迁移文件注释)。
|
||||
if item.Promo {
|
||||
dup, err := h.store.HasPaidPurchaseExcludingTx(ctx, tx, userID, ev.ProductBizCode, purchaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dup {
|
||||
slog.Warn("pay webhook: promo 限购 TOCTOU 命中,跳过发放并吞掉重复单",
|
||||
"order_no", ev.OutTradeNo, "user_id", userID, "sku", ev.ProductBizCode)
|
||||
if err := h.store.MarkDuplicatePromoTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID,
|
||||
codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt)
|
||||
if perr != nil {
|
||||
paidAt = h.now().UTC()
|
||||
}
|
||||
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user