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

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:
wangjia
2026-07-13 15:53:56 +08:00
parent ec4b3e0f22
commit a62a2b1797
9 changed files with 232 additions and 11 deletions
+36
View File
@@ -171,6 +171,42 @@ func (s *Store) HasPaidPurchase(ctx context.Context, userID int64, sku string) (
return n > 0, nil
}
// HasPaidPurchaseExcludingTx 是 HasPaidPurchase 的事务内(锁行后)复查版本,供
// webhook.go settle 在开通前对 Promo SKU 再查一次——CreateOrder 那次下单时的
// 检查是裸 SELECT 无锁,并发/多挂起单可绕过(TOCTOU)。excludeID 排除本单自己
// (本单尚未 MarkPaidTx,通常不会自匹配,但显式排除更稳妥、也便于未来复用)。
func (s *Store) HasPaidPurchaseExcludingTx(ctx context.Context, tx *sql.Tx, userID int64, sku string, excludeID int64) (bool, error) {
var n int
err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pay_purchases WHERE user_id = ? AND sku = ? AND status = 'paid' AND id <> ?`,
userID, sku, excludeID).Scan(&n)
if err != nil {
return false, fmt.Errorf("pay.Store.HasPaidPurchaseExcludingTx: %w", err)
}
return n > 0, nil
}
// MarkDuplicatePromoTx 收口一笔在 TOCTOU 竞争中"输"掉的 Promo SKU 重复单——
// 另一笔同 user+SKU 的订单已先一步 settle 为 paid(见 webhook.go settle 的
// promo 复查)。刻意标记为 'canceled' 而非 'paid':(user_id, sku) WHERE
// status='paid' 是部分唯一索引(migration 000027,仅 sqlite)本就不允许同一
// user+promo-SKU 出现第二条 paid 行,这里若也写 paid 会在 sqlite 上直接撞
// 约束报错、把整个 webhook 打成 500 引发 pay 无限重投——与"吞掉重复单,不
// 再重投"的目标相反。仍落一次结算回执字段(amount/currency/channel/paid_at)
// 供人工核对"钱是否真收到过、为何没有二次开通",不同于用户主动取消未付
// 单的语义(MarkCanceled 的原生用途),但复用同一 status 取值。
func (s *Store) MarkDuplicatePromoTx(ctx context.Context, tx *sql.Tx, id int64, amountMinor int64, currency, channel string, paidAt time.Time) error {
_, err := tx.ExecContext(ctx,
`UPDATE pay_purchases SET status = 'canceled', amount_minor = ?, currency = ?,
channel = ?, paid_at = ?, updated_at = ?
WHERE id = ?`,
amountMinor, currency, channel, paidAt, time.Now().UTC(), id)
if err != nil {
return fmt.Errorf("pay.Store.MarkDuplicatePromoTx: %w", err)
}
return nil
}
// SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。
func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) {
var exp time.Time