a62a2b1797
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>
141 lines
6.0 KiB
Go
141 lines
6.0 KiB
Go
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)
|
|
}
|
|
|
|
}
|