feat(server): CreateBatch/生成器切换到 codes 库(Mint 单事务全成或全无)(#codes-lib)

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 删除)。
This commit is contained in:
wangjia
2026-07-10 14:24:18 +08:00
parent 15f0d78a91
commit e128c96d22
6 changed files with 126 additions and 72 deletions
-17
View File
@@ -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)
+8 -8
View File
@@ -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
+18 -46
View File
@@ -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,
@@ -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")
}
}
@@ -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
}
+13 -1
View File
@@ -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)