diff --git a/server/internal/codes/service_sqlite_test.go b/server/internal/codes/service_sqlite_test.go index c80a037..45bfd49 100644 --- a/server/internal/codes/service_sqlite_test.go +++ b/server/internal/codes/service_sqlite_test.go @@ -3,9 +3,11 @@ package codes_test import ( "context" "database/sql" + "errors" "testing" "time" + libcodes "github.com/wangjia/codes" "github.com/wangjia/pangolin/server/internal/codes" ) @@ -113,6 +115,50 @@ func TestRedeemCreatesSubscription(t *testing.T) { } } +// TestWebhookMintsIntoLibTables verifies Store.MintOne (webhook 铸码核心): +// lands in the shared lib's new tables, entitlement is a duration payload, +// and a duplicate code_hash leaves no orphan batch row (the old +// two-statement webhook implementation leaked one batch per dupe delivery). +func TestWebhookMintsIntoLibTables(t *testing.T) { + ctx := context.Background() + db := openMigratedSQLite(t) + store := codes.NewStore(db) + + plain, err := codes.GenerateCode() + if err != nil { + t.Fatal(err) + } + canonical := mustCanonical(t, plain) + ent, err := libcodesEnt("pro", 30) + if err != nil { + t.Fatal(err) + } + if err := store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n1"); err != nil { + t.Fatalf("MintOne: %v", err) + } + var batches, ccount int + if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 { + t.Fatalf("batches = %d (err=%v)", batches, err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE code_hash=?`, codes.Hash(canonical)).Scan(&ccount); err != nil || ccount != 1 { + t.Fatalf("codes = %d (err=%v)", ccount, err) + } + + // 同 hash 再灌:ErrDuplicate 且 batch 数不变(无孤儿——旧实现的 I2 缺陷在此修复)。 + err = store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n2") + if !errors.Is(err, codes.ErrDuplicate) { + t.Fatalf("err = %v, want ErrDuplicate", err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 { + t.Fatalf("batches after dup = %d, want 1 (no orphan)", batches) + } +} + +// libcodesEnt: 测试侧小工具,避免测试文件 import 库包名与宿主包名混淆。 +func libcodesEnt(plan string, days int) (libcodes.Entitlement, error) { + return libcodes.NewDurationEntitlement(plan, days) +} + func mustCanonical(t *testing.T, code string) string { t.Helper() c, err := codes.Canonicalize(code) diff --git a/server/internal/codes/store.go b/server/internal/codes/store.go index 9af5b24..2d6c36e 100644 --- a/server/internal/codes/store.go +++ b/server/internal/codes/store.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "strings" "time" libcodes "github.com/wangjia/codes" @@ -84,46 +83,6 @@ func NewStore(db *sql.DB) *Store { // Lib exposes the shared library store (used by Service / webhook shim). func (s *Store) Lib() *libcodes.Store { return s.lib } -// -------------------------------------------------------------------------- -// Batch and code creation (used by Service.CreateBatch and webhook) -// -------------------------------------------------------------------------- - -// CreateBatch inserts a new code_batches row and returns its ID. -func (s *Store) CreateBatch(ctx context.Context, channel BatchChannel, createdBy, note string) (int64, error) { - var notePtr *string - if note != "" { - notePtr = ¬e - } - res, err := s.db.ExecContext(ctx, - `INSERT INTO code_batches (channel, created_by, note, created_at) - VALUES (?, ?, ?, ?)`, - string(channel), createdBy, notePtr, time.Now().UTC()) - if err != nil { - return 0, fmt.Errorf("store.CreateBatch: %w", err) - } - id, err := res.LastInsertId() - if err != nil { - return 0, fmt.Errorf("store.CreateBatch last id: %w", err) - } - return id, nil -} - -// CreateCode inserts a single codes row. It returns ErrDuplicate if a row -// with the same code_hash already exists (UNIQUE constraint violation). -func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, durationDays int, batchID int64) error { - _, err := s.db.ExecContext(ctx, - `INSERT INTO codes (code_hash, plan_id, duration_days, batch_id, status) - VALUES (?, ?, ?, ?, 'unused')`, - codeHash, planID, durationDays, batchID) - if err != nil { - if isDuplicateKey(err) { - return ErrDuplicate - } - return fmt.Errorf("store.CreateCode: %w", err) - } - return nil -} - // -------------------------------------------------------------------------- // Subscription helpers (used inside redeem transaction) // -------------------------------------------------------------------------- @@ -255,17 +214,31 @@ func (s *Store) WriteAuditLog(ctx context.Context, tx *sql.Tx, actor, action, ta } // -------------------------------------------------------------------------- -// Webhook-specific lookup +// Webhook ingestion (single externally-generated code per call) // -------------------------------------------------------------------------- -// CodeExistsByHash returns true if a codes row with the given hash already exists. -func (s *Store) CodeExistsByHash(ctx context.Context, hash string) (bool, error) { - var count int - err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count) +// MintOne writes one externally-generated code (card-store webhook ingestion) +// as one batch + one code row in a single transaction. A duplicate code_hash +// returns ErrDuplicate with NOTHING committed — no orphan batch (the old +// two-statement implementation leaked one batch row per duplicate delivery). +func (s *Store) MintOne(ctx context.Context, codeHash string, ent libcodes.Entitlement, channel BatchChannel, createdBy, note string) error { + tx, err := s.lib.BeginTx(ctx) if err != nil { - return false, fmt.Errorf("store.CodeExistsByHash: %w", err) + return fmt.Errorf("store.MintOne: begin: %w", err) } - return count > 0, nil + defer tx.Rollback() + + batchID, err := s.lib.CreateBatchTx(ctx, tx, string(channel), ent, createdBy, note) + if err != nil { + return fmt.Errorf("store.MintOne: batch: %w", err) + } + if err := s.lib.CreateCodeTx(ctx, tx, codeHash, batchID, ent); err != nil { + return err // ErrDuplicate passes through; defer 回滚保证无孤儿 + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("store.MintOne: commit: %w", err) + } + return nil } // BeginTx starts a new transaction at Read Committed isolation level. @@ -276,19 +249,3 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) } -// -------------------------------------------------------------------------- -// helper -// -------------------------------------------------------------------------- - -// isDuplicateKey returns true if err is a MySQL duplicate-key error (1062). -func isDuplicateKey(err error) bool { - if err == nil { - return false - } - // go-sql-driver wraps MySQL errors; the error number is accessible via - // the mysql.MySQLError type. We do a string match to avoid importing - // the mysql package here. - msg := err.Error() - return strings.Contains(msg, "Duplicate entry") || - strings.Contains(msg, "1062") -} diff --git a/server/internal/codes/webhook.go b/server/internal/codes/webhook.go index 38c97a9..29a88ab 100644 --- a/server/internal/codes/webhook.go +++ b/server/internal/codes/webhook.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "time" "github.com/redis/go-redis/v9" + libcodes "github.com/wangjia/codes" "github.com/wangjia/pangolin/server/internal/apierr" ) @@ -132,25 +134,22 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } hash := Hash(canonical) - // --- Idempotent code insertion --- + // --- Idempotent code insertion (via shared lib primitives) --- ctx := r.Context() - planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))) + // 未知 plan 先拒(行为不变,旧 webhook.go:138-142)。 + if _, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))); err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + ent, err := libcodes.NewDurationEntitlement(strings.ToLower(payload.Plan), payload.DurationDays) if err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } - // Create batch (one per webhook call; the store may aggregate externally). - batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note) - if err != nil { - apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) - return - } - - err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID) - if err == ErrDuplicate { - // Same code_hash already exists – idempotent, do not error. + err = h.store.MintOne(ctx, hash, ent, ChannelStore, "webhook", payload.Note) + if errors.Is(err, ErrDuplicate) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"})