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
+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,