From e128c96d2230bf4f70db1b135cd6f475d25e90c0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 14:24:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20CreateBatch/=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8=E5=88=87=E6=8D=A2=E5=88=B0=20codes=20=E5=BA=93(Mint?= =?UTF-8?q?=20=E5=8D=95=E4=BA=8B=E5=8A=A1=E5=85=A8=E6=88=90=E6=88=96?= =?UTF-8?q?=E5=85=A8=E6=97=A0)(#codes-lib)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store 挂库 store(NewStore 内部构造 libcodes.Store,签名不变); generator.go 三函数委托 libcodes,ErrDuplicate 别名同一哨兵; Service.CreateBatch 走 libcodes.Mint(签名不变)。openMigratedSQLite 挪到共享 sqlite_helper_test.go,backfill_test.go/service_sqlite_test.go 共用。Store.CreateBatch/CreateCode/CodeExistsByHash 原样保留供 webhook.go(Task 6 改用新原语,Task 7 删除)。 --- server/internal/codes/backfill_test.go | 17 ------ server/internal/codes/generator.go | 16 ++--- server/internal/codes/service.go | 64 ++++++-------------- server/internal/codes/service_sqlite_test.go | 57 +++++++++++++++++ server/internal/codes/sqlite_helper_test.go | 30 +++++++++ server/internal/codes/store.go | 14 ++++- 6 files changed, 126 insertions(+), 72 deletions(-) create mode 100644 server/internal/codes/service_sqlite_test.go create mode 100644 server/internal/codes/sqlite_helper_test.go diff --git a/server/internal/codes/backfill_test.go b/server/internal/codes/backfill_test.go index 5879b3b..5382b80 100644 --- a/server/internal/codes/backfill_test.go +++ b/server/internal/codes/backfill_test.go @@ -2,7 +2,6 @@ package codes_test import ( "context" - "database/sql" "testing" "time" @@ -11,22 +10,6 @@ import ( "github.com/wangjia/pangolin/server/internal/store" ) -func openMigratedSQLite(t *testing.T) *sql.DB { - t.Helper() - db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - if err := store.MigrateUp(db, "sqlite"); err != nil { - t.Fatalf("migrate: %v", err) - } - if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { - t.Fatalf("lib migrate: %v", err) - } - return db -} - func TestBackfillLegacy(t *testing.T) { ctx := context.Background() db := openMigratedSQLite(t) diff --git a/server/internal/codes/generator.go b/server/internal/codes/generator.go index 5168fbc..5f03479 100644 --- a/server/internal/codes/generator.go +++ b/server/internal/codes/generator.go @@ -8,16 +8,14 @@ package codes import ( - "errors" - - "github.com/wangjia/pangolin/server/internal/idgen" + libcodes "github.com/wangjia/codes" ) // GenerateCode generates a single random activation code in canonical Crockford // Base32 form (15 random data chars + 1 check char = 16 chars total). -// It delegates to idgen.GenerateCode which uses crypto/rand. +// It delegates to the shared library, which uses crypto/rand. func GenerateCode() (string, error) { - return idgen.GenerateCode() + return libcodes.GenerateCode() } // Canonicalize converts an activation-code string into canonical form: @@ -27,15 +25,17 @@ func GenerateCode() (string, error) { // Crockford alphabet, if the length is not exactly 16, or if the check character // is incorrect. func Canonicalize(code string) (string, error) { - return idgen.CanonicalizeCode(code) + return libcodes.Canonicalize(code) } // Hash returns the hex-encoded SHA-256 of the canonical plaintext code. // This is the value stored in the database; the plaintext is never stored. func Hash(canonical string) string { - return idgen.HashCode(canonical) + return libcodes.Hash(canonical) } // ErrDuplicate is returned by the batch generator when a generated code // already exists in the database (hash collision). The caller should retry. -var ErrDuplicate = errors.New("codes: duplicate code hash") +// Aliased to the same sentinel as the shared library so errors.Is succeeds +// across both layers. +var ErrDuplicate = libcodes.ErrDuplicate diff --git a/server/internal/codes/service.go b/server/internal/codes/service.go index 19bdbfe..0e5b93d 100644 --- a/server/internal/codes/service.go +++ b/server/internal/codes/service.go @@ -8,6 +8,7 @@ import ( "time" "github.com/redis/go-redis/v9" + libcodes "github.com/wangjia/codes" "github.com/wangjia/pangolin/server/internal/apierr" ) @@ -322,60 +323,31 @@ type BatchResult struct { Channel BatchChannel } -// CreateBatch generates Count activation codes, writes the batch + code hashes -// to MySQL, and returns the plaintext codes. The plaintext codes are the ONLY -// time they ever appear; the caller is responsible for delivering them securely -// (e.g., streaming to CSV without logging). -// -// Duplicate-hash retries (birthday collision, astronomically unlikely) are -// handled automatically up to maxRetries per slot. +// CreateBatch generates Count activation codes via the shared library's Mint +// (all-or-nothing: batch + code hashes commit in one tx) and returns the +// plaintext codes — the only time they ever appear. func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) { - const maxRetries = 10 - - // Look up plan ID. - planID, err := svc.store.GetPlanID(ctx, req.PlanCode) - if err != nil { + // 未知 plan 先拒(行为等价:旧实现先查 GetPlanID)。 + if _, err := svc.store.GetPlanID(ctx, req.PlanCode); err != nil { return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err) } - - // Insert the batch record. - batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note) + ent, err := libcodes.NewDurationEntitlement(string(req.PlanCode), req.DurationDays) if err != nil { return nil, fmt.Errorf("CreateBatch: %w", err) } - - plaintexts := make([]string, 0, req.Count) - - for i := 0; i < req.Count; i++ { - var code string - var h string - ok := false - for attempt := 0; attempt < maxRetries; attempt++ { - c, err := GenerateCode() - if err != nil { - return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err) - } - h = Hash(c) - err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID) - if err == ErrDuplicate { - continue // collision – generate a fresh code - } - if err != nil { - return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err) - } - code = c - ok = true - break - } - if !ok { - return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i) - } - plaintexts = append(plaintexts, code) + res, err := libcodes.Mint(ctx, svc.store.Lib(), libcodes.MintRequest{ + Channel: string(req.Channel), + Entitlement: ent, + Count: req.Count, + CreatedBy: req.CreatedBy, + Note: req.Note, + }) + if err != nil { + return nil, fmt.Errorf("CreateBatch: %w", err) } - return &BatchResult{ - BatchID: batchID, - Codes: plaintexts, + BatchID: res.BatchID, + Codes: res.Codes, PlanCode: req.PlanCode, DurationDays: req.DurationDays, Channel: req.Channel, diff --git a/server/internal/codes/service_sqlite_test.go b/server/internal/codes/service_sqlite_test.go new file mode 100644 index 0000000..a783377 --- /dev/null +++ b/server/internal/codes/service_sqlite_test.go @@ -0,0 +1,57 @@ +package codes_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// TestCreateBatchViaLib verifies CreateBatch mints codes through the shared +// codes library (Mint): codes land in the lib's new `codes` table (no +// plaintext persisted), the batch's entitlement is a duration payload, and +// unknown plans are still rejected up front (old behaviour, service.go:336-339). +func TestCreateBatchViaLib(t *testing.T) { + ctx := context.Background() + db := openMigratedSQLite(t) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) // rdb=nil: cmd/codegen's real usage + + res, err := svc.CreateBatch(ctx, codes.BatchRequest{ + PlanCode: codes.PlanPro, DurationDays: 30, Count: 5, + Channel: codes.ChannelManual, Note: "t", CreatedBy: "admin:1", + }) + if err != nil { + t.Fatalf("CreateBatch: %v", err) + } + if len(res.Codes) != 5 || res.BatchID == 0 { + t.Fatalf("res = %+v", res) + } + + // 码落在库的新表里,明文不落库,权益为 duration payload。 + var cnt int + if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE batch_id=?`, res.BatchID).Scan(&cnt); err != nil || cnt != 5 { + t.Fatalf("new codes rows = %d (err=%v), want 5", cnt, err) + } + var kind, payload string + if err := db.QueryRow(`SELECT entitlement_kind, entitlement_payload FROM codes_batches WHERE id=?`, res.BatchID).Scan(&kind, &payload); err != nil { + t.Fatalf("batch: %v", err) + } + if kind != "duration" || payload != `{"plan":"pro","days":30}` { + t.Fatalf("kind=%q payload=%s", kind, payload) + } + for _, plain := range res.Codes { + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE code_hash=?`, codes.Hash(plain)).Scan(&n); err != nil || n != 1 { + t.Fatalf("hash lookup for %q: n=%d err=%v", plain, n, err) + } + } + + // 未知 plan 仍报错(旧行为 service.go:336-339)。 + if _, err := svc.CreateBatch(ctx, codes.BatchRequest{ + PlanCode: "nope", DurationDays: 30, Count: 1, Channel: codes.ChannelManual, CreatedBy: "x", + }); err == nil { + t.Fatal("unknown plan should error") + } +} diff --git a/server/internal/codes/sqlite_helper_test.go b/server/internal/codes/sqlite_helper_test.go new file mode 100644 index 0000000..eb8595a --- /dev/null +++ b/server/internal/codes/sqlite_helper_test.go @@ -0,0 +1,30 @@ +package codes_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// openMigratedSQLite opens an in-memory sqlite DB, runs pangolin's full +// golang-migrate chain (000001-000020) followed by the shared codes +// library's own migrations. Shared by backfill_test.go and +// service_sqlite_test.go. +func openMigratedSQLite(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("migrate: %v", err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatalf("lib migrate: %v", err) + } + return db +} diff --git a/server/internal/codes/store.go b/server/internal/codes/store.go index e0feaa3..42ff997 100644 --- a/server/internal/codes/store.go +++ b/server/internal/codes/store.go @@ -7,6 +7,7 @@ import ( "strings" "time" + libcodes "github.com/wangjia/codes" dbx "github.com/wangjia/pangolin/server/internal/db" ) @@ -80,10 +81,21 @@ type BatchRow struct { type Store struct { db *sql.DB dialect dbx.Dialect + lib *libcodes.Store } // NewStore creates a Store backed by the given connection pool (MySQL or SQLite). -func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} } +func NewStore(db *sql.DB) *Store { + d := dbx.DialectForDB(db) + libD := libcodes.DialectMySQL + if d.Name() == "sqlite" { + libD = libcodes.DialectSQLite + } + return &Store{db: db, dialect: d, lib: libcodes.NewStore(db, libD)} +} + +// 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)