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:
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user