diff --git a/server/internal/pay/store.go b/server/internal/pay/store.go index 699b493..130c312 100644 --- a/server/internal/pay/store.go +++ b/server/internal/pay/store.go @@ -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 diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go index 5759b50..945849d 100644 --- a/server/internal/pay/webhook.go +++ b/server/internal/pay/webhook.go @@ -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 } diff --git a/server/internal/pay/webhook_promo_sqlite_test.go b/server/internal/pay/webhook_promo_sqlite_test.go new file mode 100644 index 0000000..d43f49f --- /dev/null +++ b/server/internal/pay/webhook_promo_sqlite_test.go @@ -0,0 +1,140 @@ +package pay + +import ( + "context" + "net/http" + "strings" + "testing" + "time" +) + +// -------------------------------------------------------------------------- +// C2 · promo 限购 TOCTOU: CreateOrder's HasPaidPurchase check is a bare, +// unlocked SELECT at order-creation time — concurrent/multi-pending orders +// for the same promo SKU can both reach 'created' before either settles. +// webhook.go's settle must re-check inside the locked transaction, before +// granting, and swallow the duplicate (skip the grant, still ACK so pay +// doesn't retry forever) rather than double-granting +31 days per order. +// -------------------------------------------------------------------------- + +// seedPromoDuplicateOrders inserts two 'created' orders for the SAME user + +// promo SKU (pro_month_promo) — simulating the TOCTOU window where both +// orders were created before either was paid. +func seedPromoDuplicateOrders(t *testing.T, st *Store) { + t.Helper() + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-1", "alipay", 600, "CNY"); err != nil { + t.Fatalf("insert promo order 1: %v", err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-2", "wxpay", 600, "CNY"); err != nil { + t.Fatalf("insert promo order 2: %v", err) + } +} + +func TestWebhook_PromoDuplicateSettleSkipsGrantButAcks(t *testing.T) { + h, db, st := newWebhookRig(t) + seedPromoDuplicateOrders(t, st) + + // First promo order settles normally: paid + granted. + w1 := deliver(t, h, succeededPayload("pay-promo-1", "pro_month_promo")) + if w1.Code != http.StatusOK || !strings.Contains(w1.Body.String(), "SUCCESS") { + t.Fatalf("第一单应正常开通: %d %q", w1.Code, w1.Body.String()) + } + var expiresAfterFirst time.Time + if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterFirst); err != nil { + t.Fatalf("第一单未开通订阅: %v", err) + } + + // Second promo order for the SAME user+SKU settles (TOCTOU duplicate): + // must still ACK 200 SUCCESS (else pay retries this webhook forever), + // but must NOT grant a second +31 days. + w2 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) + if w2.Code != http.StatusOK || !strings.Contains(w2.Body.String(), "SUCCESS") { + t.Fatalf("重复 promo 单应仍 ACK(否则 pay 会无限重投): %d %q", w2.Code, w2.Body.String()) + } + + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("不得为重复 promo 单二次开通: subscriptions rows = %d, want 1", n) + } + var expiresAfterSecond time.Time + if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterSecond); err != nil { + t.Fatal(err) + } + if !expiresAfterFirst.Equal(expiresAfterSecond) { + t.Errorf("到期被重复叠加: %v → %v(应保持不变,promo 限购一次)", expiresAfterFirst, expiresAfterSecond) + } + + // The duplicate order's own ledger row must not become a second 'paid' + // row for the same (user_id, sku) — that's exactly what migration 000027's + // partial unique index forbids on sqlite, and what the settle-side + // re-check must avoid ever attempting. + row2, err := st.GetForUser(context.Background(), 1, "pay-promo-2") + if err != nil { + t.Fatalf("重复单台账未找到: %v", err) + } + if row2.Status == "paid" { + t.Errorf("重复 promo 单不应被标记为二条 paid(会撞 (user_id,sku) 唯一索引): status = %q", row2.Status) + } + if row2.SubID.Valid { + t.Errorf("重复 promo 单不应挂 sub_id: %+v", row2.SubID) + } + + // Redelivery of the SAME duplicate webhook (new nonce, same out_trade_no) + // must remain idempotent — no further side effects, still ACK. + w3 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) + if w3.Code != http.StatusOK || !strings.Contains(w3.Body.String(), "SUCCESS") { + t.Fatalf("重复单再次重投应仍 ACK: %d %q", w3.Code, w3.Body.String()) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("重投重复单不得二次开通: subscriptions rows = %d, want 1", n) + } +} + +// -------------------------------------------------------------------------- +// Defense-in-depth: migration 000027's sqlite partial unique index directly +// forbids two 'paid' rows for the same (user_id, sku='pro_month_promo'), +// independent of the application-layer settle check above. This guards +// against any other write path (bug, manual SQL, future code) accidentally +// double-marking a promo purchase 'paid'. +// -------------------------------------------------------------------------- + +func TestPayPurchases_PromoPaidUniqueIndex_SQLite(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1, "uuid-1") + now := time.Now().UTC() + + if _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-1', 'alipay', 'paid', 600, 'CNY', ?, ?)`, + now, now); err != nil { + t.Fatalf("first paid promo row should insert cleanly: %v", err) + } + + _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-2', 'wxpay', 'paid', 600, 'CNY', ?, ?)`, + now, now) + if err == nil { + t.Fatal("第二条同 user+promo-sku 的 paid 行应被部分唯一索引拒绝,却插入成功") + } + if !strings.Contains(err.Error(), "UNIQUE") { + t.Errorf("err = %v, want a UNIQUE constraint violation", err) + } + + // A non-promo SKU (or a 'created'/'canceled' status row) must be + // unaffected by the partial index — sanity check it isn't over-broad. + if _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month', 'ux-3', 'alipay', 'paid', 4990000, 'USDT', ?, ?)`, + now, now); err != nil { + t.Errorf("非 promo SKU 的 paid 行不应受此索引影响: %v", err) + } + +} diff --git a/server/internal/store/codes_lib_migrate_test.go b/server/internal/store/codes_lib_migrate_test.go index 2a775f6..529c2ae 100644 --- a/server/internal/store/codes_lib_migrate_test.go +++ b/server/internal/store/codes_lib_migrate_test.go @@ -104,11 +104,15 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { // the reviewer's repro hit: the lib's `codes` table collides with the // name 000022's down script renames legacy_codes back to. m := newSQLiteStepper(t, db) - // 000026 (notices), 000025 (user_device_limit_override), 000024 - // (invite_rewards) and 000023 (pay_purchases/source-enum) now sit on top - // of 000022 (codes_lib_legacy_rename) and are unrelated to this - // collision — step them back down first so we land exactly on the - // 000022 boundary the test targets. + // 000027 (pay_promo_paid_unique), 000026 (notices), 000025 + // (user_device_limit_override), 000024 (invite_rewards) and 000023 + // (pay_purchases/source-enum) now sit on top of 000022 + // (codes_lib_legacy_rename) and are unrelated to this collision — step + // them back down first so we land exactly on the 000022 boundary the + // test targets. + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000027 down: %v", err) + } if err := m.Steps(-1); err != nil { t.Fatalf("step 000026 down: %v", err) } diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 4f4adb3..ab79d56 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 26 { - t.Errorf("version = %d, want 26", v) + if v != 27 { + t.Errorf("version = %d, want 27", v) } // 2. Core tables exist. diff --git a/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql b/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql new file mode 100644 index 0000000..5173dab --- /dev/null +++ b/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql @@ -0,0 +1 @@ +-- no-op(与 up 对称,无 DDL 可回滚) diff --git a/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql b/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql new file mode 100644 index 0000000..1c2bd51 --- /dev/null +++ b/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql @@ -0,0 +1,7 @@ +-- no-op(MySQL 8):MySQL 不支持部分唯一索引(WHERE 子句),无法照搬 sqlite 版 +-- 的 (user_id, sku) WHERE status='paid' AND sku='pro_month_promo' 约束——建全表 +-- 唯一索引 (user_id, sku, status) 会连带拦掉 created/canceled 状态下的正常 +-- 复购/重试流程,不可行。mysql 侧的 promo 限购 TOCTOU 防线仅落在应用层: +-- webhook.go settle 在锁行开通前对 item.Promo 的 SKU 复查一次 HasPaidPurchase, +-- 命中则跳过发放、吞掉重复单(详见该函数注释)。此文件仅占位对齐 sqlite 的 +-- 000027 编号,不执行任何 DDL。 diff --git a/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql b/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql new file mode 100644 index 0000000..dd63831 --- /dev/null +++ b/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS ux_pay_promo_paid; diff --git a/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql b/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql new file mode 100644 index 0000000..b95d496 --- /dev/null +++ b/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql @@ -0,0 +1,9 @@ +-- C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只是裸 +-- SELECT(无锁),并发/多挂起单可绕过「每账号限购一次」。这里加部分唯一索引兜底: +-- 同一 user_id 的 pro_month_promo 至多一条 status='paid'。sqlite 支持部分索引 +-- (WHERE 子句),精确限定 sku='pro_month_promo'(目前唯一的 Promo=true 档位), +-- 不影响其余 SKU 正常复购。webhook.go settle 侧另有应用层复查作为第一道防线 +-- (mysql 侧 000027 是 no-op,不支持部分索引——见 mysql 版本注释)。 +CREATE UNIQUE INDEX ux_pay_promo_paid + ON pay_purchases (user_id, sku) + WHERE status = 'paid' AND sku = 'pro_month_promo';