# pay v2 · P7 codes 共享库(激活码兑换内核)Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 `- [ ]` checkbox 追踪。 > **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html` §9(codes 兑换内核)/ §9.1(部署选型 A,已定)/ §9.2(履约动作多态)/ §12(通用权益描述符,可扩展性验证)。本计划是 pay v2 八阶段中的 **P7**,承接 `2026-07-10-pay-v2-p1-core-model.md` 末尾"后续阶段"列出的 P7 条目。 **Goal:** 把 pangolin 现有 `server/internal/codes/`(码模型/状态机/生成器/兑换事务/webhook 灌码)抽成一个与 pay **不同部署、不同仓库**的独立 Go 共享库,供 pangolin(订阅)、jiu(门店 license)、未来 dudu(额度)等产品各自 `import` 嵌入。库本身**entitlement-agnostic**——码携带通用「权益描述符」而非硬编码 plan+天数,兑换的"最终开通落库"通过宿主注入的回调函数完成,兑换是**宿主本地事务**(码表与宿主业务表同库)。 **Architecture:** 移植 pangolin `internal/codes` 的 canonical 部分(哈希存储 / 状态机 unused-redeemed-void / Crockford Base32 生成器 / 锁+CAS+幂等兑换骨架 / 批次 / 审计),去掉 pangolin 专属的 `plan_id`+`duration_days`+`subscriptions` 硬编码,替换成通用 `Entitlement{Kind, Payload}` 信封;webhook 签名从 pangolin 的「仅对 body 做 HMAC」升级为 pay-contract 既有的「system+timestamp+nonce+body 一并入 MAC」多产品既定惯例。库只依赖 `database/sql`(host 传入已打开的 `*sql.DB`/`*sql.Tx`,mysql 或 sqlite 均可,库不 import 具体驱动),Redis 相关能力(限流/去重)隔离进可选子包,避免强加给不跑 Redis 的宿主。 ## 部署与模块选型(已决策,依据设计文档 §9.1 方案 A) | 选项 | 说明 | 结论 | |---|---|---| | pay 仓内 `pkg/codes` 子包 | 复用 pay 的 go.mod | ❌ 不采用——pay 依赖很重(gin/GORM/alipay SDK/wechatpay-go),codes 的宿主(pangolin/jiu)只想要码逻辑,不想被迫拉进整个支付网关的依赖树;codes 与 pay 收款管线正交(§9 已定"不并入 pay"),放进 pay 仓在逻辑和依赖两个维度都是错误信号。 | | pangolin 仓内保留、jiu 抄一份 | 零迁移成本 | ❌ 不采用——违反"共享库"目标,retire 到两份漂移代码,与设计文档 §9.1"方案 A = 共享库嵌入各产品"的决策矛盾。 | | **独立仓库 + 独立 Go module(方案 A 落地形态)** | 新仓 `~/code/codes`,module `github.com/wangjia/codes`,各产品 `go get`/`replace` 引入 | ✅ **已定**——依赖最小(核心包零第三方依赖,仅 stdlib;可选 Redis 能力隔离进 `codes/redisx` 子包,不 import 就不产生依赖);可被 pangolin/jiu/dudu 平等 import;版本可独立打 tag,不与任何单一产品的发布节奏绑定。 | **落地约定**(照用户全局仓库管理惯例):新仓源码放 `~/code/codes`,`git init` 后 remote 指向 `ssh://git@git.51yanmei.com:2222/wangjia/codes.git`(需先在 Gitea web 建仓才能 push;若尚未建仓,先本地 commit,push 留到 Gitea 仓建好后)。**本计划只搭这个新仓库,不改 pangolin/jiu 现有代码**——pangolin 现有 `server/internal/codes/` 迁移到 import 本库是后续独立工作(brain todo,pay 定稿 + codes 库稳定后再动),本计划不做。 ## Global Constraints - **Module**:`github.com/wangjia/codes`,`go 1.23`(取 pangolin `1.25.10` / pay `1.26.1` 的下界,保证两者都能正常 `require` 本库)。核心包(根目录 `codes`)编译期**零第三方依赖**——只有 `internal/idgen` 用到的 stdlib(`crypto/rand`/`crypto/sha256`)和测试期用到 `modernc.org/sqlite`(纯 Go 免 CGO,仅测试文件 import,不进宿主生产依赖图)。`codes/redisx` 子包才 import `github.com/redis/go-redis/v9`,宿主不 import 这个子包就不会被拉进 Redis 依赖。 - **同库本地事务是唯一原子性保证**:`Redeem[T]` 接收宿主已开启的 `*sql.Tx`,码状态翻转与宿主的 `grant` 回调写同一个事务——这要求宿主的权益表与 codes 表**在同一个数据库连接/同一个 `*sql.DB` 之下**(设计文档 §9.1 方案 A 的前提)。若宿主权益数据在另一个物理库,本库不提供分布式事务,那是方案 B(独立服务 + saga)的范畴,不在本计划内。 - **明文码永不落库/永不进日志**:数据库与审计日志只存 `SHA-256(canonical_plaintext)`;明文只在 `Mint` 的返回值里出现一次(调用方——通常是 webhook 处理器或运营 CLI——自行负责安全投递)。 - **状态机三态**:`unused → redeemed`(经 `Redeem`)、`unused → void`(经 `VoidCode`),**没有其他合法转移**;`redeemed`/`void` 都是终态。 - **通用权益描述符**:`Entitlement{Kind, Payload}` 替代硬编码 `plan_id`+`duration_days`;`Kind=duration` 承载时长型(`{plan,days}`),`Kind=quota` 承载额度型(`{resource,amount}`,对应 dudu 秒数/未来流量包)。库只做信封校验(`Validate()`),**从不解释 `Payload` 内的业务字段**——那是宿主 `GrantFunc` 的职责。 - **幂等**:同一 `redeemerRef` 重复兑换同一码 → `Idempotent:true`、不重复执行 `grant`;不同 `redeemerRef` 兑换已兑换码 → `ErrCodeRedeemed`。`redeemerRef` 是不透明字符串(`"user:123"` / `"shop:9"`),库不关心其归属维度——这正是设计文档 §9.1 强调"归属维度(user/shop)…留给各产品"的落地方式。 - 每步 `go build ./...` 通过;测试 `go test ./...`(sqlite `:memory:` 免 docker;`redisx` 用 `github.com/alicebob/miniredis/v2` 免 docker——与 pangolin `server/go.mod` 现有测试依赖一致)。 - **本计划范围之外**(明确排除,避免范围蔓延):admin 批次列表/CSV 导出(pangolin `admin_support.go`/`export.go` 已有实现,宿主若需要可直接在自己的 admin 层调用 `Store` 的基础方法拼,不进本库);pangolin/jiu 迁移到 import 本库(单独任务);独立服务化方案 B、reseller 门户、优惠券变体(设计文档 §7/§9.1 标注的 later)。 --- ### Task 1: 模块脚手架 + Entitlement 描述符 + 状态机 + 哨兵错误 **Files:** - Create: `go.mod`(新仓根目录) - Create: `entitlement.go` - Create: `status.go` - Create: `errors.go` - Test: `entitlement_test.go` - Test: `status_test.go` **Interfaces:** - Produces: - `type EntitlementKind string` + 常量 `EntitlementDuration` / `EntitlementQuota` - `type Entitlement struct{ Kind EntitlementKind; Payload json.RawMessage }` - `type DurationPayload struct{ Plan string; Days int }` / `type QuotaPayload struct{ Resource string; Amount int64 }` - `func NewDurationEntitlement(plan string, days int) (Entitlement, error)` / `func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error)` - `func (e Entitlement) DecodeDuration() (DurationPayload, error)` / `func (e Entitlement) DecodeQuota() (QuotaPayload, error)` - `func (e Entitlement) Validate() error` - `type Status string` + 常量 `StatusUnused` / `StatusRedeemed` / `StatusVoid`;`func (s Status) Redeemable() bool` / `func (s Status) Voidable() bool` - 哨兵错误:`ErrDuplicate` / `ErrCodeNotFound` / `ErrCodeRedeemed` / `ErrCodeVoid` / `ErrInvalidCode` / `ErrLocked` / `ErrNotVoidable` - [ ] **Step 0: 建仓** ```bash mkdir -p ~/code/codes && cd ~/code/codes git init go mod init github.com/wangjia/codes git remote add origin ssh://git@git.51yanmei.com:2222/wangjia/codes.git ``` (Gitea 仓需先在 web 建好才能 push;未建好先只本地 commit。) - [ ] **Step 1: 写失败测试** `entitlement_test.go`: ```go package codes_test import ( "encoding/json" "testing" "github.com/wangjia/codes" ) func TestDurationEntitlementRoundTrip(t *testing.T) { e, err := codes.NewDurationEntitlement("pro", 30) if err != nil { t.Fatalf("NewDurationEntitlement: %v", err) } if e.Kind != codes.EntitlementDuration { t.Fatalf("kind = %q, want duration", e.Kind) } if err := e.Validate(); err != nil { t.Fatalf("Validate: %v", err) } p, err := e.DecodeDuration() if err != nil { t.Fatalf("DecodeDuration: %v", err) } if p.Plan != "pro" || p.Days != 30 { t.Fatalf("got %+v", p) } if _, err := e.DecodeQuota(); err == nil { t.Fatal("DecodeQuota on a duration entitlement should error") } // 整个 Entitlement 可安全过 JSON(webhook payload 的编码方式)。 raw, err := json.Marshal(e) if err != nil { t.Fatalf("marshal: %v", err) } var back codes.Entitlement if err := json.Unmarshal(raw, &back); err != nil { t.Fatalf("unmarshal: %v", err) } p2, err := back.DecodeDuration() if err != nil || p2 != p { t.Fatalf("round-trip mismatch: %+v vs %+v (err=%v)", p2, p, err) } } func TestQuotaEntitlementRoundTrip(t *testing.T) { e, err := codes.NewQuotaEntitlement("data_gb", 100) if err != nil { t.Fatalf("NewQuotaEntitlement: %v", err) } q, err := e.DecodeQuota() if err != nil { t.Fatalf("DecodeQuota: %v", err) } if q.Resource != "data_gb" || q.Amount != 100 { t.Fatalf("got %+v", q) } } func TestEntitlementValidateRejectsEmpty(t *testing.T) { if err := (codes.Entitlement{}).Validate(); err == nil { t.Fatal("empty entitlement should fail Validate") } if err := (codes.Entitlement{Kind: codes.EntitlementDuration}).Validate(); err == nil { t.Fatal("duration entitlement with empty payload should fail Validate") } } ``` `status_test.go`: ```go package codes_test import ( "testing" "github.com/wangjia/codes" ) func TestStatusTransitions(t *testing.T) { cases := []struct { s codes.Status redeemable, voidable bool }{ {codes.StatusUnused, true, true}, {codes.StatusRedeemed, false, false}, {codes.StatusVoid, false, false}, } for _, c := range cases { if got := c.s.Redeemable(); got != c.redeemable { t.Errorf("%s.Redeemable() = %v, want %v", c.s, got, c.redeemable) } if got := c.s.Voidable(); got != c.voidable { t.Errorf("%s.Voidable() = %v, want %v", c.s, got, c.voidable) } } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test ./... -v` Expected: 编译失败——`codes` 包内容不存在。 - [ ] **Step 3: 写实现** `entitlement.go`: ```go // Package codes implements a product-agnostic activation-code lifecycle: // generation, hashed storage, a local-transaction redeem skeleton with a // host-supplied grant callback, batches, and audit. It is deliberately not // entitlement-aware — see Entitlement — and not payment-aware (see // pay-v2-unified-gateway-design.html §9 for the boundary with pay). // // Security invariant: plaintext codes are NEVER written to storage or logs. // Only SHA-256(canonical_plaintext) is persisted; plaintext appears exactly // once, in Mint's return value. package codes import ( "encoding/json" "fmt" ) // EntitlementKind selects how Entitlement.Payload should be interpreted by // the host's GrantFunc. codes treats Payload as opaque bytes beyond envelope // validation — it never inspects business fields. type EntitlementKind string const ( EntitlementDuration EntitlementKind = "duration" EntitlementQuota EntitlementKind = "quota" ) // Entitlement is the generic "what does this code grant" descriptor. It // replaces a hardcoded plan_id+duration_days pair so one library carries a // subscription extension (pangolin/jiu) or a quota top-up (dudu seconds, // future traffic packs) without a schema change (design doc §12). type Entitlement struct { Kind EntitlementKind `json:"kind"` Payload json.RawMessage `json:"payload"` } // DurationPayload is the Kind=duration payload shape. type DurationPayload struct { Plan string `json:"plan"` Days int `json:"days"` } // QuotaPayload is the Kind=quota payload shape. type QuotaPayload struct { Resource string `json:"resource"` Amount int64 `json:"amount"` } func NewDurationEntitlement(plan string, days int) (Entitlement, error) { b, err := json.Marshal(DurationPayload{Plan: plan, Days: days}) if err != nil { return Entitlement{}, fmt.Errorf("codes: marshal duration payload: %w", err) } return Entitlement{Kind: EntitlementDuration, Payload: b}, nil } func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error) { b, err := json.Marshal(QuotaPayload{Resource: resource, Amount: amount}) if err != nil { return Entitlement{}, fmt.Errorf("codes: marshal quota payload: %w", err) } return Entitlement{Kind: EntitlementQuota, Payload: b}, nil } func (e Entitlement) DecodeDuration() (DurationPayload, error) { if e.Kind != EntitlementDuration { return DurationPayload{}, fmt.Errorf("codes: entitlement kind %q is not duration", e.Kind) } var p DurationPayload if err := json.Unmarshal(e.Payload, &p); err != nil { return DurationPayload{}, fmt.Errorf("codes: decode duration payload: %w", err) } return p, nil } func (e Entitlement) DecodeQuota() (QuotaPayload, error) { if e.Kind != EntitlementQuota { return QuotaPayload{}, fmt.Errorf("codes: entitlement kind %q is not quota", e.Kind) } var p QuotaPayload if err := json.Unmarshal(e.Payload, &p); err != nil { return QuotaPayload{}, fmt.Errorf("codes: decode quota payload: %w", err) } return p, nil } // Validate checks the envelope only: Kind/Payload are non-empty, and for the // two known kinds the payload decodes. Unknown kinds pass through opaquely // so new entitlement shapes don't require a codes library release first. func (e Entitlement) Validate() error { if e.Kind == "" { return fmt.Errorf("codes: entitlement kind is empty") } if len(e.Payload) == 0 { return fmt.Errorf("codes: entitlement payload is empty") } switch e.Kind { case EntitlementDuration: _, err := e.DecodeDuration() return err case EntitlementQuota: _, err := e.DecodeQuota() return err default: return nil } } ``` `status.go`: ```go package codes // Status is the code lifecycle state. The only legal transitions are // unused→redeemed (via Redeem) and unused→void (via VoidCode); both other // states are terminal. type Status string const ( StatusUnused Status = "unused" StatusRedeemed Status = "redeemed" StatusVoid Status = "void" ) func (s Status) Redeemable() bool { return s == StatusUnused } func (s Status) Voidable() bool { return s == StatusUnused } ``` `errors.go`: ```go package codes import "errors" var ( ErrDuplicate = errors.New("codes: duplicate code hash") ErrCodeNotFound = errors.New("codes: code not found") ErrCodeRedeemed = errors.New("codes: code already redeemed") ErrCodeVoid = errors.New("codes: code has been voided") ErrInvalidCode = errors.New("codes: invalid code format") ErrLocked = errors.New("codes: too many failed attempts, temporarily locked") ErrNotVoidable = errors.New("codes: code is not in a voidable state") ) ``` - [ ] **Step 4: 跑测试确认通过** Run: `cd ~/code/codes && go test ./... -v` Expected: `TestDurationEntitlementRoundTrip` / `TestQuotaEntitlementRoundTrip` / `TestEntitlementValidateRejectsEmpty` / `TestStatusTransitions` 全 PASS。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add go.mod entitlement.go status.go errors.go entitlement_test.go status_test.go git commit -m "feat: module scaffold + 通用权益描述符 Entitlement + 状态机 + 哨兵错误" ``` --- ### Task 2: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装 **Files:** - Create: `internal/idgen/idgen.go` - Test: `internal/idgen/idgen_test.go` - Create: `codegen.go`(根包薄封装,对外 API) - Test: `codegen_test.go` **Interfaces:** - Produces(`internal/idgen`,包内私有,不对宿主暴露): - `func GenerateCode() (string, error)` — 15 随机 Crockford Base32 数据字符 + 1 位 mod-37 校验字符(16 字符),`crypto/rand`。 - `func CanonicalizeCode(code string) (string, error)` — 归一化(大写、I/L→1、O→0、去连字符/空格)+ 校验位验证。 - `func HashCode(canonical string) string` — SHA-256 hex。 - Produces(根包 `codes`,对外 API): - `func GenerateCode() (string, error)` / `func Canonicalize(code string) (string, error)` / `func Hash(canonical string) string` — 薄封装,委托给 `internal/idgen`。 此任务**原样移植** `pangolin/server/internal/codes` 依赖的 `internal/idgen` 的 Crockford Base32 部分(去掉与本库无关的 UUID v7 生成),含其完整的校验位/防偏抽样算法与既有测试集,是激活码格式与安全性的 canonical 来源,不重新发明。 - [ ] **Step 1: 写失败测试** `internal/idgen/idgen_test.go`: ```go package idgen_test import ( "strings" "testing" "github.com/wangjia/codes/internal/idgen" ) func TestGenerateCodeFormat(t *testing.T) { for i := 0; i < 1000; i++ { code, err := idgen.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } if len(code) != 16 { t.Errorf("code %q: length = %d, want 16", code, len(code)) } canonical, err := idgen.CanonicalizeCode(code) if err != nil { t.Errorf("CanonicalizeCode(%q): %v", code, err) } if canonical != code { t.Errorf("canonical form mismatch: got %q, want %q", canonical, code) } } } func TestGenerateCodeUniqueness(t *testing.T) { const n = 5_000 seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { code, err := idgen.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } h := idgen.HashCode(code) if _, dup := seen[h]; dup { t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h) } seen[h] = struct{}{} } } func TestCanonicalizeCodeNormalization(t *testing.T) { base, err := idgen.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } lower := strings.ToLower(base) canonical, err := idgen.CanonicalizeCode(lower) if err != nil { t.Errorf("CanonicalizeCode(lower) error: %v", err) } if canonical != base { t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base) } if idx := strings.IndexByte(base, '1'); idx >= 0 && idx < 15 { for _, sub := range []string{"I", "L", "i", "l"} { variant := base[:idx] + sub + base[idx+1:] c, err := idgen.CanonicalizeCode(variant) if err != nil { t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) continue } if c != base { t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) } } } if idx := strings.IndexByte(base, '0'); idx >= 0 && idx < 15 { variant := base[:idx] + "O" + base[idx+1:] c, err := idgen.CanonicalizeCode(variant) if err != nil { t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) } else if c != base { t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) } } } func TestCheckCharDetectsSingleErrors(t *testing.T) { const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" code, err := idgen.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } for pos := 0; pos < 15; pos++ { original := rune(code[pos]) for _, replacement := range alpha { if replacement == original { continue } mutated := code[:pos] + string(replacement) + code[pos+1:] if _, err := idgen.CanonicalizeCode(mutated); err == nil { t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q", pos, original, replacement, code, mutated) } } } } func TestHashCodeConsistency(t *testing.T) { code1, _ := idgen.GenerateCode() code2, _ := idgen.GenerateCode() for code1 == code2 { code2, _ = idgen.GenerateCode() } h1a := idgen.HashCode(code1) h1b := idgen.HashCode(code1) h2 := idgen.HashCode(code2) if h1a != h1b { t.Error("HashCode is not deterministic") } if h1a == h2 { t.Error("different codes produced the same digest") } if len(h1a) != 64 { t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a)) } } func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) { for _, c := range []string{"", "ABCDE", "ABCDEFGH12345678X"} { if _, err := idgen.CanonicalizeCode(c); err == nil { t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c)) } } } func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) { base, _ := idgen.GenerateCode() invalid := "!" + base[1:] if _, err := idgen.CanonicalizeCode(invalid); err == nil { t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid) } } func TestHyphenStripping(t *testing.T) { code, err := idgen.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:] canonical, err := idgen.CanonicalizeCode(hyphenated) if err != nil { t.Errorf("CanonicalizeCode(hyphenated) error: %v", err) } if canonical != code { t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code) } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test ./internal/idgen/ -v` Expected: 编译失败——`internal/idgen` 包不存在。 - [ ] **Step 3: 写实现** `internal/idgen/idgen.go`(移植自 `pangolin/server/internal/idgen/idgen.go`,去掉 UUID v7 部分——本库不需要): ```go // Package idgen generates 16-character Crockford Base32 activation codes // with a mod-37 check character. Ported from pangolin's // server/internal/idgen (Crockford portion only — UUID v7 generation is not // needed by this library). package idgen import ( "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" ) // crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U // to prevent visual confusion with 1, 1, 0, and V respectively). const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" // crockfordCheck is the extended 37-symbol check-character alphabet used for // the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet; // symbols 32–36 are *, ~, $, =, U. const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U" // crockfordDecode maps every printable ASCII character to its Crockford // numeric value (0–31), or -1 if invalid. Normalisation (I/i/l/L→1, O/o→0) // is baked in at init time. var crockfordDecode [128]int8 func init() { for i := range crockfordDecode { crockfordDecode[i] = -1 } for i, ch := range crockfordAlphabet { crockfordDecode[ch] = int8(i) if ch >= 'A' && ch <= 'Z' { crockfordDecode[ch-'A'+'a'] = int8(i) } } crockfordDecode['I'] = crockfordDecode['1'] crockfordDecode['i'] = crockfordDecode['1'] crockfordDecode['l'] = crockfordDecode['1'] crockfordDecode['L'] = crockfordDecode['1'] crockfordDecode['O'] = crockfordDecode['0'] crockfordDecode['o'] = crockfordDecode['0'] } // computeCheckValue computes the Crockford mod-37 check value (Horner's // method) of the first 15 characters of s (already canonical). Returns -1 // on any invalid character. func computeCheckValue(s string) int { result := 0 for i := 0; i < 15; i++ { ch := s[i] if ch >= 128 { return -1 } v := int(crockfordDecode[ch]) if v < 0 { return -1 } result = (result*32 + v) % 37 } return result } func validateCheckChar(canonical string) error { if len(canonical) != 16 { return errors.New("idgen: invalid length for check validation") } expected := computeCheckValue(canonical) if expected < 0 { return errors.New("idgen: invalid data characters in code") } want := rune(crockfordCheck[expected]) got := rune(canonical[15]) if got != want { return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got) } return nil } // CanonicalizeCode converts an activation-code string into canonical form: // uppercase, I/L→1 and O→0 substituted, hyphens/spaces stripped, check // character validated. Returns an error on invalid characters, wrong length // (after stripping), or a bad check character. func CanonicalizeCode(code string) (string, error) { code = strings.TrimSpace(code) code = strings.ReplaceAll(code, "-", "") code = strings.ReplaceAll(code, " ", "") if len(code) != 16 { return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code)) } var buf [16]byte for i := 0; i < 16; i++ { ch := code[i] if ch >= 128 { return "", fmt.Errorf("idgen: non-ASCII character at position %d", i) } v := crockfordDecode[ch] if v < 0 { if i < 15 { return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i) } buf[i] = []byte(strings.ToUpper(string(ch)))[0] continue } buf[i] = crockfordAlphabet[v] } canonical := string(buf[:]) if err := validateCheckChar(canonical); err != nil { return "", err } return canonical, nil } // HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext // code — the value that gets persisted; the plaintext itself never is. func HashCode(canonical string) string { sum := sha256.Sum256([]byte(canonical)) return hex.EncodeToString(sum[:]) } // GenerateCode generates one random activation code in canonical Crockford // Base32 form: 15 random data characters + 1 mod-37 check character. // crypto/rand with rejection sampling (accept range [0,224), 224=7×32) to // avoid modular bias. func GenerateCode() (string, error) { const dataLen = 15 var buf [dataLen]byte i := 0 for i < dataLen { var tmp [dataLen * 2]byte if _, err := rand.Read(tmp[:]); err != nil { return "", fmt.Errorf("idgen: crypto/rand: %w", err) } for _, b := range tmp { if b < 224 { buf[i] = crockfordAlphabet[b%32] i++ if i == dataLen { break } } } } data := string(buf[:]) checkVal := computeCheckValue(data + "0") if checkVal < 0 { return "", errors.New("idgen: internal check computation error") } return data + string(crockfordCheck[checkVal]), nil } ``` - [ ] **Step 4: 跑测试确认通过** Run: `cd ~/code/codes && go test ./internal/idgen/ -v` Expected: 全 PASS。 - [ ] **Step 5: 根包薄封装 + 测试** `codegen_test.go`: ```go package codes_test import ( "testing" "github.com/wangjia/codes" ) func TestGenerateCanonicalizeHashRoundTrip(t *testing.T) { code, err := codes.GenerateCode() if err != nil { t.Fatalf("GenerateCode: %v", err) } canonical, err := codes.Canonicalize(code) if err != nil { t.Fatalf("Canonicalize: %v", err) } if canonical != code { t.Fatalf("canonical mismatch: %q vs %q", canonical, code) } h := codes.Hash(canonical) if len(h) != 64 { t.Fatalf("Hash length = %d, want 64", len(h)) } } ``` `codegen.go`: ```go package codes import "github.com/wangjia/codes/internal/idgen" // GenerateCode generates one random 16-char Crockford Base32 activation code. func GenerateCode() (string, error) { return idgen.GenerateCode() } // Canonicalize normalises and validates a user-entered code string. func Canonicalize(code string) (string, error) { return idgen.CanonicalizeCode(code) } // Hash returns the hex SHA-256 digest of a canonical code — the only form // ever persisted. func Hash(canonical string) string { return idgen.HashCode(canonical) } ``` - [ ] **Step 6: 跑测试确认通过 + 全量编译** Run: `cd ~/code/codes && go build ./... && go test ./...` Expected: 编译通过,`internal/idgen` 与根包均 `ok`。 - [ ] **Step 7: Commit** ```bash cd ~/code/codes git add internal/idgen/ codegen.go codegen_test.go git commit -m "feat: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装" ``` --- ### Task 3: Dialect + 内嵌 migrations(mysql+sqlite)+ Store CRUD **Files:** - Create: `dialect.go` - Test: `dialect_test.go` - Create: `migrations/mysql/000001_codes.up.sql` / `000001_codes.down.sql` - Create: `migrations/sqlite/000001_codes.up.sql` / `000001_codes.down.sql` - Create: `migrations.go` - Test: `migrations_test.go` - Create: `store.go` - Create: `testdb_test.go`(包内测试共用 helper) - Test: `store_test.go` **Interfaces:** - Produces: - `type Dialect string` + 常量 `DialectMySQL`/`DialectSQLite`;`func (d Dialect) LockForUpdate() string` - `var migrationsFS embed.FS`(私有)+ `func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) error` - `type Code struct{ ID int64; CodeHash string; BatchID int64; Entitlement Entitlement; Status Status; RedeemedBy string; RedeemedAt *time.Time; VoidReason string; CreatedAt time.Time }` - `type Batch struct{ ID int64; Channel string; Entitlement Entitlement; CreatedBy, Note string; CreatedAt time.Time }` - `type Store struct{...}` · `func NewStore(db *sql.DB, dialect Dialect) *Store` - `(*Store) CreateBatch(ctx, channel string, ent Entitlement, createdBy, note string) (int64, error)` - `(*Store) CreateCode(ctx, codeHash string, batchID int64, ent Entitlement) error`(重复 hash → `ErrDuplicate`) - `(*Store) FindByHash(ctx, codeHash string) (*Code, error)` - `(*Store) FindByHashForUpdate(ctx, tx *sql.Tx, codeHash string) (*Code, error)` - `(*Store) MarkRedeemed(ctx, tx *sql.Tx, id int64, redeemerRef string, at time.Time) error` - `(*Store) Void(ctx, id int64, reason string) (bool, error)` - `(*Store) WriteAudit(ctx, tx *sql.Tx, actor, action, target string, meta map[string]any) error`(`tx==nil` 时直接用 `s.db`) - [ ] **Step 1: 写失败测试(Dialect,不需要 DB)** `dialect_test.go`: ```go package codes_test import ( "testing" "github.com/wangjia/codes" ) func TestDialectLockForUpdate(t *testing.T) { if got := codes.DialectMySQL.LockForUpdate(); got != "FOR UPDATE" { t.Errorf("mysql LockForUpdate = %q", got) } if got := codes.DialectSQLite.LockForUpdate(); got != "" { t.Errorf("sqlite LockForUpdate = %q, want empty (single-writer engine)", got) } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v` Expected: 编译失败——`Dialect`/`DialectMySQL`/`DialectSQLite` 未定义。 - [ ] **Step 3: 写 dialect.go** ```go package codes // Dialect captures the one SQL construct this library needs that differs // between engines: pessimistic row locking inside a transaction. SQLite is a // single-writer engine — a BEGIN'd write transaction already serializes // concurrent writers, so no explicit lock clause is needed there. type Dialect string const ( DialectMySQL Dialect = "mysql" DialectSQLite Dialect = "sqlite" ) func (d Dialect) LockForUpdate() string { if d == DialectMySQL { return "FOR UPDATE" } return "" } ``` - [ ] **Step 4: 跑测试确认通过** Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v` → PASS。 - [ ] **Step 5: 写 migration SQL(mysql + sqlite)** `migrations/mysql/000001_codes.up.sql`: ```sql CREATE TABLE codes_batches ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, channel VARCHAR(32) NOT NULL, entitlement_kind VARCHAR(32) NOT NULL, entitlement_payload JSON NOT NULL, created_by VARCHAR(64) NOT NULL, note VARCHAR(255) NULL, created_at DATETIME NOT NULL ); CREATE TABLE codes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code_hash CHAR(64) NOT NULL, batch_id BIGINT UNSIGNED NOT NULL, entitlement_kind VARCHAR(32) NOT NULL, entitlement_payload JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'unused', redeemed_by VARCHAR(128) NULL, redeemed_at DATETIME NULL, void_reason VARCHAR(255) NULL, created_at DATETIME NOT NULL, UNIQUE KEY uq_codes_code_hash (code_hash), KEY idx_codes_status (status), KEY idx_codes_batch (batch_id) ); CREATE TABLE codes_audit_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, actor VARCHAR(128) NOT NULL, action VARCHAR(32) NOT NULL, target VARCHAR(128) NOT NULL, meta JSON NULL, at DATETIME NOT NULL ); ``` `migrations/mysql/000001_codes.down.sql`: ```sql DROP TABLE codes_audit_log; DROP TABLE codes; DROP TABLE codes_batches; ``` `migrations/sqlite/000001_codes.up.sql`: ```sql CREATE TABLE codes_batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, entitlement_kind TEXT NOT NULL, entitlement_payload TEXT NOT NULL, created_by TEXT NOT NULL, note TEXT NULL, created_at DATETIME NOT NULL ); CREATE TABLE codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, code_hash TEXT NOT NULL UNIQUE, batch_id INTEGER NOT NULL, entitlement_kind TEXT NOT NULL, entitlement_payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unused' CHECK (status IN ('unused','redeemed','void')), redeemed_by TEXT NULL, redeemed_at DATETIME NULL, void_reason TEXT NULL, created_at DATETIME NOT NULL ); CREATE INDEX idx_codes_status ON codes (status); CREATE INDEX idx_codes_batch ON codes (batch_id); CREATE TABLE codes_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, meta TEXT NULL, at DATETIME NOT NULL ); ``` `migrations/sqlite/000001_codes.down.sql`: ```sql DROP TABLE codes_audit_log; DROP TABLE codes; DROP TABLE codes_batches; ``` - [ ] **Step 6: 写失败测试(ApplyMigrations)** `migrations_test.go`: ```go package codes_test import ( "context" "database/sql" "testing" "github.com/wangjia/codes" _ "modernc.org/sqlite" ) func TestApplyMigrationsSQLite(t *testing.T) { db, err := sql.Open("sqlite", "file::memory:?cache=shared") if err != nil { t.Fatalf("open: %v", err) } db.SetMaxOpenConns(1) // :memory:+shared cache: one connection = one schema defer db.Close() if err := codes.ApplyMigrations(context.Background(), db, codes.DialectSQLite); err != nil { t.Fatalf("ApplyMigrations: %v", err) } for _, table := range []string{"codes_batches", "codes", "codes_audit_log"} { var name string err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) if err != nil { t.Errorf("table %s missing: %v", table, err) } } } ``` - [ ] **Step 7: 跑测试确认失败** Run: `cd ~/code/codes && go get modernc.org/sqlite@latest && go test ./... -run TestApplyMigrationsSQLite -v` Expected: 编译失败——`ApplyMigrations` 未定义。 - [ ] **Step 8: 写 migrations.go** ```go package codes import ( "context" "database/sql" "embed" "fmt" "sort" "strings" ) //go:embed migrations/mysql/*.sql migrations/sqlite/*.sql var migrationsFS embed.FS // ApplyMigrations executes every *.up.sql file for dialect, in lexical // order, inside one transaction. It's a zero-dependency convenience for // hosts that don't already run golang-migrate; hosts that do (e.g. // pangolin) can instead point their own migrate runner at the embedded // files under "migrations//" via golang-migrate's iofs source. func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) error { dir := "migrations/" + string(dialect) entries, err := migrationsFS.ReadDir(dir) if err != nil { return fmt.Errorf("codes.ApplyMigrations: read %s: %w", dir, err) } var files []string for _, e := range entries { if strings.HasSuffix(e.Name(), ".up.sql") { files = append(files, e.Name()) } } sort.Strings(files) tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("codes.ApplyMigrations: begin: %w", err) } defer tx.Rollback() for _, f := range files { raw, err := migrationsFS.ReadFile(dir + "/" + f) if err != nil { return fmt.Errorf("codes.ApplyMigrations: read %s: %w", f, err) } for _, stmt := range splitStatements(string(raw)) { if _, err := tx.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("codes.ApplyMigrations: exec %s: %w", f, err) } } } if err := tx.Commit(); err != nil { return fmt.Errorf("codes.ApplyMigrations: commit: %w", err) } return nil } // splitStatements splits a .sql file's content into individual statements on // ";" terminators — sufficient for this package's DDL (no semicolons inside // string literals). func splitStatements(sqlText string) []string { parts := strings.Split(sqlText, ";") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } ``` - [ ] **Step 9: 跑测试确认通过** Run: `cd ~/code/codes && go test ./... -run TestApplyMigrationsSQLite -v` → PASS。 - [ ] **Step 10: 写失败测试(Store CRUD)** `testdb_test.go`(供本任务及后续任务的包内测试共用): ```go package codes import ( "context" "database/sql" "testing" _ "modernc.org/sqlite" ) // openTestDB opens an in-memory SQLite DB with the codes schema migrated. // Shared by every *_test.go in this package (all live in `package codes`, // not `codes_test`, so this helper is visible package-wide). func openTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", "file::memory:?cache=shared") if err != nil { t.Fatalf("open sqlite: %v", err) } db.SetMaxOpenConns(1) // :memory:+shared cache: one connection = one schema t.Cleanup(func() { _ = db.Close() }) if err := ApplyMigrations(context.Background(), db, DialectSQLite); err != nil { t.Fatalf("apply migrations: %v", err) } return db } ``` `store_test.go`: ```go package codes import ( "context" "testing" ) func mustDuration(t *testing.T, plan string, days int) Entitlement { t.Helper() e, err := NewDurationEntitlement(plan, days) if err != nil { t.Fatalf("NewDurationEntitlement: %v", err) } return e } func TestStoreCreateBatchAndCode(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) ctx := context.Background() ent := mustDuration(t, "pro", 30) batchID, err := s.CreateBatch(ctx, "manual", ent, "cli", "test batch") if err != nil { t.Fatalf("CreateBatch: %v", err) } if batchID == 0 { t.Fatal("batchID should be non-zero") } hash := Hash("ABCDEFGHJKMNPQR0") // any 16-char string is fine here; Store doesn't validate format if err := s.CreateCode(ctx, hash, batchID, ent); err != nil { t.Fatalf("CreateCode: %v", err) } got, err := s.FindByHash(ctx, hash) if err != nil { t.Fatalf("FindByHash: %v", err) } if got == nil { t.Fatal("FindByHash: not found") } if got.Status != StatusUnused || got.BatchID != batchID { t.Fatalf("got %+v", got) } p, err := got.Entitlement.DecodeDuration() if err != nil || p.Plan != "pro" || p.Days != 30 { t.Fatalf("entitlement round-trip failed: %+v (err=%v)", p, err) } // duplicate hash -> ErrDuplicate if err := s.CreateCode(ctx, hash, batchID, ent); err != ErrDuplicate { t.Fatalf("duplicate CreateCode err = %v, want ErrDuplicate", err) } // FindByHash on unknown hash -> nil, no error miss, err := s.FindByHash(ctx, "no-such-hash") if err != nil || miss != nil { t.Fatalf("FindByHash(unknown) = %+v, %v", miss, err) } } func TestStoreVoid(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) ctx := context.Background() ent := mustDuration(t, "pro", 30) batchID, _ := s.CreateBatch(ctx, "manual", ent, "cli", "") hash := Hash("VOIDTESTHASH0001") if err := s.CreateCode(ctx, hash, batchID, ent); err != nil { t.Fatalf("CreateCode: %v", err) } code, _ := s.FindByHash(ctx, hash) ok, err := s.Void(ctx, code.ID, "printing error") if err != nil || !ok { t.Fatalf("Void ok=%v err=%v", ok, err) } after, _ := s.FindByHash(ctx, hash) if after.Status != StatusVoid || after.VoidReason != "printing error" { t.Fatalf("got %+v", after) } // voiding an already-void code is a no-op false, not an error ok2, err := s.Void(ctx, code.ID, "again") if err != nil || ok2 { t.Fatalf("second Void ok=%v err=%v, want false,nil", ok2, err) } } func TestStoreWriteAudit(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) ctx := context.Background() if err := s.WriteAudit(ctx, nil, "cli", "mint", "batch:1", map[string]any{"count": 5}); err != nil { t.Fatalf("WriteAudit: %v", err) } var n int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codes_audit_log`).Scan(&n); err != nil { t.Fatalf("count: %v", err) } if n != 1 { t.Fatalf("audit rows = %d, want 1", n) } } ``` - [ ] **Step 11: 跑测试确认失败** Run: `cd ~/code/codes && go test . -run 'TestStoreCreateBatchAndCode|TestStoreVoid|TestStoreWriteAudit' -v` Expected: 编译失败——`Store`/`NewStore`/`Code`/`Batch` 未定义。 - [ ] **Step 12: 写 store.go** ```go package codes import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" ) // Code mirrors one `codes` row. Entitlement is decoded from the stored JSON // envelope; the caller interprets Payload via DecodeDuration/DecodeQuota. type Code struct { ID int64 CodeHash string BatchID int64 Entitlement Entitlement Status Status RedeemedBy string RedeemedAt *time.Time VoidReason string CreatedAt time.Time } // Batch mirrors one `codes_batches` row. type Batch struct { ID int64 Channel string Entitlement Entitlement CreatedBy string Note string CreatedAt time.Time } // Store wraps a *sql.DB (host-owned connection pool — mysql or sqlite) and // exposes every database operation this library needs. Methods taking a // *sql.Tx run inside that host-managed transaction; others use the pool // directly. type Store struct { db *sql.DB dialect Dialect } func NewStore(db *sql.DB, dialect Dialect) *Store { return &Store{db: db, dialect: dialect} } func (s *Store) CreateBatch(ctx context.Context, channel string, ent Entitlement, createdBy, note string) (int64, error) { if err := ent.Validate(); err != nil { return 0, err } res, err := s.db.ExecContext(ctx, `INSERT INTO codes_batches (channel, entitlement_kind, entitlement_payload, created_by, note, created_at) VALUES (?, ?, ?, ?, ?, ?)`, channel, string(ent.Kind), string(ent.Payload), createdBy, nullableString(note), time.Now().UTC()) if err != nil { return 0, fmt.Errorf("codes.Store.CreateBatch: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("codes.Store.CreateBatch last id: %w", err) } return id, nil } // CreateCode inserts one codes row. Returns ErrDuplicate on a code_hash // unique-constraint violation (mysql or sqlite) — the caller should retry // with a freshly generated code (see Mint). func (s *Store) CreateCode(ctx context.Context, codeHash string, batchID int64, ent Entitlement) error { if err := ent.Validate(); err != nil { return err } _, err := s.db.ExecContext(ctx, `INSERT INTO codes (code_hash, batch_id, entitlement_kind, entitlement_payload, status, created_at) VALUES (?, ?, ?, ?, 'unused', ?)`, codeHash, batchID, string(ent.Kind), string(ent.Payload), time.Now().UTC()) if err != nil { if isDuplicateKey(err) { return ErrDuplicate } return fmt.Errorf("codes.Store.CreateCode: %w", err) } return nil } const selectCodeSQL = `SELECT id, code_hash, batch_id, entitlement_kind, entitlement_payload, status, redeemed_by, redeemed_at, void_reason, created_at FROM codes` func (s *Store) FindByHash(ctx context.Context, codeHash string) (*Code, error) { row := s.db.QueryRowContext(ctx, selectCodeSQL+" WHERE code_hash = ?", codeHash) return scanCode(row) } // FindByHashForUpdate locks the row for tx's duration (mysql: SELECT…FOR // UPDATE; sqlite: no clause needed — the write transaction already // serializes). Call inside the tx passed to Redeem. func (s *Store) FindByHashForUpdate(ctx context.Context, tx *sql.Tx, codeHash string) (*Code, error) { q := selectCodeSQL + " WHERE code_hash = ? " + s.dialect.LockForUpdate() row := tx.QueryRowContext(ctx, q, codeHash) return scanCode(row) } func scanCode(row *sql.Row) (*Code, error) { var c Code var kind, payload string var redeemedBy, voidReason sql.NullString var redeemedAt sql.NullTime err := row.Scan(&c.ID, &c.CodeHash, &c.BatchID, &kind, &payload, &c.Status, &redeemedBy, &redeemedAt, &voidReason, &c.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("codes.Store: scan code: %w", err) } c.Entitlement = Entitlement{Kind: EntitlementKind(kind), Payload: json.RawMessage(payload)} c.RedeemedBy = redeemedBy.String if redeemedAt.Valid { t := redeemedAt.Time c.RedeemedAt = &t } c.VoidReason = voidReason.String return &c, nil } // MarkRedeemed flips a code to redeemed inside tx. Guarded by // "AND status='unused'" so a concurrent winner's write can't be clobbered — // belt-and-suspenders alongside the row lock taken by FindByHashForUpdate. func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, id int64, redeemerRef string, at time.Time) error { _, err := tx.ExecContext(ctx, `UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=? WHERE id=? AND status='unused'`, redeemerRef, at, id) if err != nil { return fmt.Errorf("codes.Store.MarkRedeemed: %w", err) } return nil } // Void marks an unused code void outside any host transaction (an // independent admin action, not part of a redeem flow). Returns false (no // error) if the code was not unused. func (s *Store) Void(ctx context.Context, id int64, reason string) (bool, error) { res, err := s.db.ExecContext(ctx, `UPDATE codes SET status='void', void_reason=? WHERE id=? AND status='unused'`, reason, id) if err != nil { return false, fmt.Errorf("codes.Store.Void: %w", err) } n, _ := res.RowsAffected() return n > 0, nil } // WriteAudit inserts an audit row. Pass tx to keep it inside a caller's // transaction (e.g. Redeem); pass nil to write standalone (e.g. Mint, Void). func (s *Store) WriteAudit(ctx context.Context, tx *sql.Tx, actor, action, target string, meta map[string]any) error { metaJSON := "null" if len(meta) > 0 { b, err := json.Marshal(meta) if err != nil { return fmt.Errorf("codes.Store.WriteAudit: marshal meta: %w", err) } metaJSON = string(b) } type execer interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } var ex execer = s.db if tx != nil { ex = tx } if _, err := ex.ExecContext(ctx, `INSERT INTO codes_audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, actor, action, target, metaJSON, time.Now().UTC()); err != nil { return fmt.Errorf("codes.Store.WriteAudit: %w", err) } return nil } func nullableString(s string) any { if s == "" { return nil } return s } // isDuplicateKey recognises unique-constraint violations across the two // supported engines without importing either driver package. func isDuplicateKey(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "1062") || strings.Contains(msg, "UNIQUE constraint failed") } ``` - [ ] **Step 13: 跑测试确认通过** Run: `cd ~/code/codes && go test . -run 'TestStoreCreateBatchAndCode|TestStoreVoid|TestStoreWriteAudit' -v` → PASS。 - [ ] **Step 14: 全量编译 + 测试 + go mod tidy** Run: `cd ~/code/codes && go mod tidy && go build ./... && go test ./...` Expected: 编译通过,全部测试 `ok`;`go.mod`/`go.sum` 新增 `modernc.org/sqlite`(仅测试用到)。 - [ ] **Step 15: Commit** ```bash cd ~/code/codes git add dialect.go dialect_test.go migrations/ migrations.go migrations_test.go store.go testdb_test.go store_test.go go.mod go.sum git commit -m "feat: Dialect + 内嵌 mysql/sqlite migrations + Store CRUD(哈希存储/状态机/审计)" ``` --- ### Task 4: Mint — 批次生成(碰撞重试) **Files:** - Create: `mint.go` - Test: `mint_test.go` **Interfaces:** - Consumes: `Store`(Task3)、`GenerateCode`/`Hash`(Task2)、`Entitlement`(Task1)。 - Produces: - `type MintRequest struct{ Channel string; Entitlement Entitlement; Count int; CreatedBy, Note string }` - `type MintResult struct{ BatchID int64; Codes []string; Channel string; Entitlement Entitlement }` - `func Mint(ctx context.Context, store *Store, req MintRequest) (*MintResult, error)` - [ ] **Step 1: 写失败测试** `mint_test.go`: ```go package codes import ( "context" "testing" ) func TestMintGeneratesRequestedCount(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "team", 90) res, err := Mint(context.Background(), s, MintRequest{ Channel: "store", Entitlement: ent, Count: 5, CreatedBy: "webhook:card-store", Note: "batch A", }) if err != nil { t.Fatalf("Mint: %v", err) } if res.BatchID == 0 { t.Fatal("BatchID should be non-zero") } if len(res.Codes) != 5 { t.Fatalf("len(Codes) = %d, want 5", len(res.Codes)) } seen := make(map[string]struct{}) for _, plain := range res.Codes { canonical, err := Canonicalize(plain) if err != nil || canonical != plain { t.Fatalf("code %q not canonical: %v", plain, err) } if _, dup := seen[plain]; dup { t.Fatalf("duplicate plaintext %q", plain) } seen[plain] = struct{}{} row, err := s.FindByHash(context.Background(), Hash(plain)) if err != nil || row == nil { t.Fatalf("FindByHash(%q): row=%v err=%v", plain, row, err) } if row.Status != StatusUnused || row.BatchID != res.BatchID { t.Fatalf("row = %+v", row) } p, err := row.Entitlement.DecodeDuration() if err != nil || p.Plan != "team" || p.Days != 90 { t.Fatalf("entitlement = %+v (err=%v)", p, err) } } } func TestMintRejectsInvalidRequest(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "pro", 30) if _, err := Mint(context.Background(), s, MintRequest{Channel: "store", Entitlement: ent, Count: 0}); err == nil { t.Fatal("Count=0 should error") } if _, err := Mint(context.Background(), s, MintRequest{Channel: "store", Count: 1}); err == nil { t.Fatal("empty Entitlement should error") } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test . -run TestMint -v` Expected: 编译失败——`Mint`/`MintRequest`/`MintResult` 未定义。 - [ ] **Step 3: 写实现** `mint.go`: ```go package codes import ( "context" "fmt" ) // MintRequest describes a batch of Count codes to generate, all sharing one // Entitlement (design doc §9.2: "购买激活码" = pay 收款 + codes mint 发码). type MintRequest struct { Channel string // open string (e.g. "store"/"tg"/"line"/"manual") — host may extend Entitlement Entitlement Count int CreatedBy string Note string } // MintResult carries the plaintext codes — the ONLY time they ever appear. // The caller (webhook handler, admin CLI, purchase-fulfillment handler) is // responsible for delivering them securely; codes never logs or persists // plaintext. type MintResult struct { BatchID int64 Codes []string Channel string Entitlement Entitlement } // Mint generates Count codes for one Entitlement, writes the batch + code // hashes via store, and returns the plaintext codes. Duplicate-hash retries // (birthday collision, astronomically unlikely for 75-bit codes) are handled // automatically up to maxRetries per slot. func Mint(ctx context.Context, store *Store, req MintRequest) (*MintResult, error) { const maxRetries = 10 if req.Count <= 0 { return nil, fmt.Errorf("codes.Mint: count must be > 0, got %d", req.Count) } if err := req.Entitlement.Validate(); err != nil { return nil, fmt.Errorf("codes.Mint: %w", err) } batchID, err := store.CreateBatch(ctx, req.Channel, req.Entitlement, req.CreatedBy, req.Note) if err != nil { return nil, fmt.Errorf("codes.Mint: %w", err) } plaintexts := make([]string, 0, req.Count) for i := 0; i < req.Count; i++ { var code string ok := false for attempt := 0; attempt < maxRetries; attempt++ { c, err := GenerateCode() if err != nil { return nil, fmt.Errorf("codes.Mint: generate: %w", err) } err = store.CreateCode(ctx, Hash(c), batchID, req.Entitlement) if err == ErrDuplicate { continue } if err != nil { return nil, fmt.Errorf("codes.Mint: %w", err) } code, ok = c, true break } if !ok { return nil, fmt.Errorf("codes.Mint: exceeded %d retries for slot %d", maxRetries, i) } plaintexts = append(plaintexts, code) } return &MintResult{BatchID: batchID, Codes: plaintexts, Channel: req.Channel, Entitlement: req.Entitlement}, nil } ``` - [ ] **Step 4: 跑测试确认通过 + 全量** Run: `cd ~/code/codes && go build ./... && go test ./...` Expected: 全 `ok`。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add mint.go mint_test.go git commit -m "feat: Mint 批次生成(碰撞重试,明文只出现一次)" ``` --- ### Task 5: Redeem[T] 兑换事务骨架 + VoidCode(本计划核心) **Files:** - Create: `redeem.go` - Test: `redeem_test.go` **Interfaces:** - Consumes: `Store`(Task3)、`Entitlement`/`Status`(Task1)。 - Produces: - `type GrantFunc[T any] func(ctx context.Context, tx *sql.Tx, code Code) (T, error)` - `type RedeemResult[T any] struct{ Idempotent bool; Code Code; Grant T }` - `func Redeem[T any](ctx context.Context, store *Store, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error)` - `func VoidCode(ctx context.Context, store *Store, codeHash, actor, reason string) error` **设计要点**(对应用户给的目标签名 `Redeem(ctx, tx, codeHash, grantFn)`):`tx` 由**宿主**开启并最终 `Commit`/`Rollback`——`Redeem` 本身不管理事务生命周期。这样宿主可以把自己的权益写入(`grant` 回调)和 `Redeem` 内部的码状态翻转放进**同一个事务**,只要两者在同一个 `*sql.DB` 下,提交/回滚就是原子的(设计文档 §9.1 方案 A 的落地)。`grant` 返回任意类型 `T`(Go 泛型),让宿主直接拿到强类型的开通结果(如 `pangolin.SubscriptionID` 或 `jiu.LicenseExpiry`),不用 `any` 断言。 - [ ] **Step 1: 写失败测试** `redeem_test.go`: ```go package codes import ( "context" "database/sql" "testing" "time" ) // setupHostSubs creates a minimal stand-in for a host's own entitlement // table (e.g. pangolin's `subscriptions`), living in the SAME db as codes — // exactly the deployment-A premise this test exercises. func setupHostSubs(t *testing.T, db *sql.DB) { t.Helper() _, err := db.Exec(`CREATE TABLE test_subs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_ref TEXT NOT NULL, expires_at DATETIME NOT NULL)`) if err != nil { t.Fatalf("create test_subs: %v", err) } } // grantDuration is a stand-in GrantFunc: apply "extend by days" against // test_subs, returning the new expiry. func grantDuration(days int) GrantFunc[time.Time] { return func(ctx context.Context, tx *sql.Tx, code Code) (time.Time, error) { p, err := code.Entitlement.DecodeDuration() if err != nil { return time.Time{}, err } newExpiry := time.Now().UTC().AddDate(0, 0, p.Days) if _, err := tx.ExecContext(ctx, `INSERT INTO test_subs (user_ref, expires_at) VALUES (?, ?)`, "placeholder", newExpiry); err != nil { return time.Time{}, err } _ = days return newExpiry, nil } } func mintOne(t *testing.T, s *Store, ent Entitlement) string { t.Helper() res, err := Mint(context.Background(), s, MintRequest{Channel: "manual", Entitlement: ent, Count: 1, CreatedBy: "test"}) if err != nil { t.Fatalf("Mint: %v", err) } return res.Codes[0] } func TestRedeemSuccessAndIdempotent(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "pro", 30) plain := mintOne(t, s, ent) hash := Hash(plain) ctx := context.Background() // First redeem: succeeds, grant runs once. tx, err := db.BeginTx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } res, err := Redeem(ctx, s, tx, hash, "user:1", grantDuration(30)) if err != nil { t.Fatalf("Redeem: %v", err) } if res.Idempotent { t.Fatal("first redeem should not be idempotent") } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } var subCount int db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) if subCount != 1 { t.Fatalf("test_subs rows = %d, want 1", subCount) } // Second redeem, same redeemerRef: idempotent, grant NOT re-invoked. tx2, _ := db.BeginTx(ctx, nil) res2, err := Redeem(ctx, s, tx2, hash, "user:1", grantDuration(30)) if err != nil { t.Fatalf("Redeem (idempotent): %v", err) } if !res2.Idempotent { t.Fatal("second redeem by same user should be idempotent") } tx2.Commit() db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) if subCount != 1 { t.Fatalf("test_subs rows after idempotent redeem = %d, want still 1 (grant must not re-run)", subCount) } } func TestRedeemDifferentUserFails(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "pro", 30) plain := mintOne(t, s, ent) hash := Hash(plain) ctx := context.Background() tx, _ := db.BeginTx(ctx, nil) if _, err := Redeem(ctx, s, tx, hash, "user:1", grantDuration(30)); err != nil { t.Fatalf("first redeem: %v", err) } tx.Commit() tx2, _ := db.BeginTx(ctx, nil) defer tx2.Rollback() if _, err := Redeem(ctx, s, tx2, hash, "user:2", grantDuration(30)); err != ErrCodeRedeemed { t.Fatalf("err = %v, want ErrCodeRedeemed", err) } } func TestRedeemGrantFailureRollsBackWholeTx(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "pro", 30) plain := mintOne(t, s, ent) hash := Hash(plain) ctx := context.Background() failingGrant := func(ctx context.Context, tx *sql.Tx, code Code) (time.Time, error) { return time.Time{}, sql.ErrTxDone // any error stands in for "host's grant failed" } tx, _ := db.BeginTx(ctx, nil) _, err := Redeem(ctx, s, tx, hash, "user:1", failingGrant) if err == nil { t.Fatal("expected grant error to propagate") } if rbErr := tx.Rollback(); rbErr != nil { t.Fatalf("rollback: %v", rbErr) } // Code must still be unused — the whole tx (code state + grant) rolled back atomically. row, _ := s.FindByHash(ctx, hash) if row.Status != StatusUnused { t.Fatalf("code status after rollback = %s, want unused", row.Status) } var subCount int db.QueryRow(`SELECT COUNT(*) FROM test_subs`).Scan(&subCount) if subCount != 0 { t.Fatalf("test_subs rows after rollback = %d, want 0", subCount) } } func TestRedeemNotFoundAndVoid(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) ent := mustDuration(t, "pro", 30) ctx := context.Background() tx, _ := db.BeginTx(ctx, nil) defer tx.Rollback() if _, err := Redeem(ctx, s, tx, "no-such-hash", "user:1", grantDuration(30)); err != ErrCodeNotFound { t.Fatalf("err = %v, want ErrCodeNotFound", err) } plain := mintOne(t, s, ent) hash := Hash(plain) if err := VoidCode(ctx, s, hash, "admin:1", "compromised batch"); err != nil { t.Fatalf("VoidCode: %v", err) } tx2, _ := db.BeginTx(ctx, nil) defer tx2.Rollback() if _, err := Redeem(ctx, s, tx2, hash, "user:1", grantDuration(30)); err != ErrCodeVoid { t.Fatalf("err = %v, want ErrCodeVoid", err) } // voiding an already-redeemed code is rejected plain2 := mintOne(t, s, ent) hash2 := Hash(plain2) tx3, _ := db.BeginTx(ctx, nil) if _, err := Redeem(ctx, s, tx3, hash2, "user:9", grantDuration(30)); err != nil { t.Fatalf("redeem plain2: %v", err) } tx3.Commit() if err := VoidCode(ctx, s, hash2, "admin:1", "too late"); err != ErrNotVoidable { t.Fatalf("err = %v, want ErrNotVoidable", err) } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test . -run TestRedeem -v` Expected: 编译失败——`Redeem`/`GrantFunc`/`VoidCode` 未定义。 - [ ] **Step 3: 写实现** `redeem.go`: ```go package codes import ( "context" "database/sql" "fmt" "time" ) // GrantFunc performs the host's local entitlement write (extend a pangolin // subscription row, bump a jiu license, …) inside the SAME *sql.Tx as the // code-state flip, so grant + redeem commit atomically as long as the // host's entitlement table lives in the same database as the codes tables // (design doc §9.1 — the entire premise of deployment option A). type GrantFunc[T any] func(ctx context.Context, tx *sql.Tx, code Code) (T, error) // RedeemResult is Redeem's outcome. type RedeemResult[T any] struct { // Idempotent is true when redeemerRef already redeemed this code; Grant // is the zero value in that case — grant is intentionally NOT re-run, so // the host must not assume Grant reflects current state on a replay. Idempotent bool Code Code Grant T } // Redeem executes one redemption attempt inside the caller-owned transaction // tx. The caller owns tx's lifecycle (BeginTx/Commit/Rollback) — this lets // the host fold its own entitlement writes (via grant) and any other // business writes into the exact same local transaction. // // Flow: SELECT…FOR UPDATE (locks the row for tx's duration) → state-machine // check → idempotency short-circuit → grant(ctx, tx, code) → MarkRedeemed → // audit log. Any returned error means the caller must roll back tx; Redeem // itself never commits or rolls back. func Redeem[T any](ctx context.Context, store *Store, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error) { var zero T code, err := store.FindByHashForUpdate(ctx, tx, codeHash) if err != nil { return nil, err } if code == nil { return nil, ErrCodeNotFound } switch code.Status { case StatusRedeemed: if code.RedeemedBy == redeemerRef { return &RedeemResult[T]{Idempotent: true, Code: *code, Grant: zero}, nil } return nil, ErrCodeRedeemed case StatusVoid: return nil, ErrCodeVoid } grantResult, err := grant(ctx, tx, *code) if err != nil { return nil, fmt.Errorf("codes.Redeem: grant: %w", err) } now := time.Now().UTC() if err := store.MarkRedeemed(ctx, tx, code.ID, redeemerRef, now); err != nil { return nil, err } if err := store.WriteAudit(ctx, tx, redeemerRef, "redeem", fmt.Sprintf("code:%d", code.ID), map[string]any{ "batch_id": code.BatchID, "kind": string(code.Entitlement.Kind), }); err != nil { return nil, err } code.Status = StatusRedeemed code.RedeemedBy = redeemerRef code.RedeemedAt = &now return &RedeemResult[T]{Idempotent: false, Code: *code, Grant: grantResult}, nil } // VoidCode marks an unused code void (printing error, compromised batch) so // it can never be redeemed. Standalone admin action — not part of a Redeem // flow, so it manages its own (non-transactional) writes. Returns // ErrCodeNotFound / ErrNotVoidable as appropriate. func VoidCode(ctx context.Context, store *Store, codeHash, actor, reason string) error { code, err := store.FindByHash(ctx, codeHash) if err != nil { return err } if code == nil { return ErrCodeNotFound } if !code.Status.Voidable() { return ErrNotVoidable } ok, err := store.Void(ctx, code.ID, reason) if err != nil { return err } if !ok { return ErrNotVoidable } return store.WriteAudit(ctx, nil, actor, "void", fmt.Sprintf("code:%d", code.ID), map[string]any{"reason": reason}) } ``` - [ ] **Step 4: 跑测试确认通过 + 全量** Run: `cd ~/code/codes && go build ./... && go test ./... -v` Expected:`TestRedeemSuccessAndIdempotent` / `TestRedeemDifferentUserFails` / `TestRedeemGrantFailureRollsBackWholeTx` / `TestRedeemNotFoundAndVoid` 全 PASS,其余任务测试保持绿。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add redeem.go redeem_test.go git commit -m "feat: Redeem[T] 兑换事务骨架(锁+CAS+幂等+宿主grant回调)+ VoidCode" ``` --- ### Task 6: RateLimiter + NonceChecker 接口(零依赖默认实现)+ GuardedRedeem **Files:** - Create: `ratelimit.go` - Create: `noncecheck.go` - Create: `guard.go` - Test: `guard_test.go` **Interfaces:** - Produces: - `type RateLimiter interface{ Allowed(ctx, key string) (bool, error); RecordFailure(ctx, key string) error; Reset(ctx, key string) error }` - `type NoopRateLimiter struct{}`(始终放行,零依赖默认值) - `type NonceChecker interface{ SeenOrStore(ctx, nonce string, ttl time.Duration) (bool, error) }` - `type InMemoryNonceChecker struct{...}` · `func NewInMemoryNonceChecker() *InMemoryNonceChecker`(单进程默认值,供 webhook/测试用) - `func GuardedRedeem[T any](ctx, store *Store, limiter RateLimiter, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error)` 这两个接口**只在这一步定义**,是为了让 Task 7 的 `redisx` 子包(Redis 限流 + 去重)和 Task 8 的 webhook 处理器都能实现/依赖同一份契约,而不必反向依赖子包。 - [ ] **Step 1: 写失败测试** `guard_test.go`: ```go package codes import ( "context" "database/sql" "errors" "sync" "testing" "time" ) // fakeLimiter is an in-memory RateLimiter test double counting failures. type fakeLimiter struct { mu sync.Mutex fails map[string]int failMax int resets int } func newFakeLimiter(failMax int) *fakeLimiter { return &fakeLimiter{fails: make(map[string]int), failMax: failMax} } func (f *fakeLimiter) Allowed(_ context.Context, key string) (bool, error) { f.mu.Lock() defer f.mu.Unlock() return f.fails[key] < f.failMax, nil } func (f *fakeLimiter) RecordFailure(_ context.Context, key string) error { f.mu.Lock() defer f.mu.Unlock() f.fails[key]++ return nil } func (f *fakeLimiter) Reset(_ context.Context, key string) error { f.mu.Lock() defer f.mu.Unlock() f.fails[key] = 0 f.resets++ return nil } func TestGuardedRedeemLocksOutAfterFailures(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) limiter := newFakeLimiter(2) ctx := context.Background() grantCalls := 0 grant := GrantFunc[int](func(ctx context.Context, tx *sql.Tx, code Code) (int, error) { grantCalls++ return 0, nil }) // two failed attempts against a nonexistent code hash for i := 0; i < 2; i++ { tx, _ := db.BeginTx(ctx, nil) _, err := GuardedRedeem(ctx, s, limiter, tx, "missing", "user:1", grant) tx.Rollback() if err != ErrCodeNotFound { t.Fatalf("attempt %d: err = %v, want ErrCodeNotFound", i, err) } } // third attempt: locked out before ever touching the DB/grant tx, _ := db.BeginTx(ctx, nil) defer tx.Rollback() _, err := GuardedRedeem(ctx, s, limiter, tx, "missing", "user:1", grant) if err != ErrLocked { t.Fatalf("err = %v, want ErrLocked", err) } if grantCalls != 0 { t.Fatalf("grant should never have been called, got %d calls", grantCalls) } } func TestGuardedRedeemResetsOnSuccess(t *testing.T) { db := openTestDB(t) setupHostSubs(t, db) s := NewStore(db, DialectSQLite) limiter := newFakeLimiter(5) ctx := context.Background() ent := mustDuration(t, "pro", 30) plain := mintOne(t, s, ent) hash := Hash(plain) tx, _ := db.BeginTx(ctx, nil) _, err := GuardedRedeem(ctx, s, limiter, tx, hash, "user:1", grantDuration(30)) if err != nil { t.Fatalf("GuardedRedeem: %v", err) } tx.Commit() if limiter.resets != 1 { t.Fatalf("resets = %d, want 1", limiter.resets) } } func TestNoopRateLimiterNeverBlocks(t *testing.T) { var l RateLimiter = NoopRateLimiter{} ok, err := l.Allowed(context.Background(), "anyone") if err != nil || !ok { t.Fatalf("NoopRateLimiter.Allowed = %v, %v", ok, err) } if err := l.RecordFailure(context.Background(), "anyone"); err != nil { t.Fatalf("RecordFailure: %v", err) } } func TestInMemoryNonceCheckerDedup(t *testing.T) { c := NewInMemoryNonceChecker() ctx := context.Background() dup1, err := c.SeenOrStore(ctx, "n1", time.Minute) if err != nil || dup1 { t.Fatalf("first SeenOrStore: dup=%v err=%v", dup1, err) } dup2, err := c.SeenOrStore(ctx, "n1", time.Minute) if err != nil || !dup2 { t.Fatalf("second SeenOrStore: dup=%v err=%v, want dup=true", dup2, err) } dup3, err := c.SeenOrStore(ctx, "n2", time.Minute) if err != nil || dup3 { t.Fatalf("different nonce: dup=%v err=%v", dup3, err) } _ = errors.New // silence unused import if trimmed later } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test . -run 'TestGuardedRedeem|TestNoopRateLimiter|TestInMemoryNonceChecker' -v` Expected: 编译失败——`RateLimiter`/`GuardedRedeem`/`NonceChecker` 未定义。 - [ ] **Step 3: 写实现** `ratelimit.go`: ```go package codes import "context" // RateLimiter guards redemption attempts against brute-force guessing. // NoopRateLimiter (the zero-dependency default) never blocks; production // hosts running Redis should use codes/redisx.RateLimiter instead — kept in // a separate subpackage so importing the core codes package never pulls in // a Redis client. type RateLimiter interface { // Allowed reports whether key (typically redeemerRef) may attempt a // redemption right now. Allowed(ctx context.Context, key string) (bool, error) // RecordFailure registers one failed attempt for key. RecordFailure(ctx context.Context, key string) error // Reset clears key's failure count (called after a successful redeem). Reset(ctx context.Context, key string) error } // NoopRateLimiter never blocks and never errors — the default when a host // doesn't want (or doesn't yet have) rate limiting. type NoopRateLimiter struct{} func (NoopRateLimiter) Allowed(context.Context, string) (bool, error) { return true, nil } func (NoopRateLimiter) RecordFailure(context.Context, string) error { return nil } func (NoopRateLimiter) Reset(context.Context, string) error { return nil } ``` `noncecheck.go`: ```go package codes import ( "context" "sync" "time" ) // NonceChecker deduplicates webhook deliveries. InMemoryNonceChecker is a // single-process default (fine for one webhook instance, or tests); // multi-instance deployments should use codes/redisx.NonceChecker instead. type NonceChecker interface { // SeenOrStore returns true if nonce was already seen (and does NOT // store it again); otherwise stores it with ttl and returns false. SeenOrStore(ctx context.Context, nonce string, ttl time.Duration) (bool, error) } // InMemoryNonceChecker is a sync.Map-backed NonceChecker with lazy TTL // sweep. Not safe across multiple process instances behind a load balancer // — use codes/redisx.NonceChecker there. type InMemoryNonceChecker struct { mu sync.Mutex seen map[string]time.Time } func NewInMemoryNonceChecker() *InMemoryNonceChecker { return &InMemoryNonceChecker{seen: make(map[string]time.Time)} } func (c *InMemoryNonceChecker) SeenOrStore(_ context.Context, nonce string, ttl time.Duration) (bool, error) { c.mu.Lock() defer c.mu.Unlock() now := time.Now() for n, exp := range c.seen { if now.After(exp) { delete(c.seen, n) } } if exp, ok := c.seen[nonce]; ok && now.Before(exp) { return true, nil } c.seen[nonce] = now.Add(ttl) return false, nil } ``` `guard.go`: ```go package codes import ( "context" "database/sql" ) // GuardedRedeem wraps Redeem with a RateLimiter fail-lock, mirroring // pangolin's per-user redeem lockout: ErrLocked is returned without // touching the DB (grant is never invoked) if key has exceeded its failure // budget; any Redeem error records one failure; success or an idempotent // replay resets the counter. func GuardedRedeem[T any](ctx context.Context, store *Store, limiter RateLimiter, tx *sql.Tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T], error) { if limiter == nil { limiter = NoopRateLimiter{} } allowed, err := limiter.Allowed(ctx, redeemerRef) if err != nil { return nil, err } if !allowed { return nil, ErrLocked } result, err := Redeem(ctx, store, tx, codeHash, redeemerRef, grant) if err != nil { if recErr := limiter.RecordFailure(ctx, redeemerRef); recErr != nil { return nil, recErr } return nil, err } if resetErr := limiter.Reset(ctx, redeemerRef); resetErr != nil { return nil, resetErr } return result, nil } ``` - [ ] **Step 4: 跑测试确认通过 + 全量** Run: `cd ~/code/codes && go build ./... && go test ./... -v` Expected: 全 PASS(注意 `guard_test.go` 里未用到的 `errors` import 若报错,直接删掉那行 `_ = errors.New` 占位与对应 import——写实现时不要引入不必要的 import)。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add ratelimit.go noncecheck.go guard.go guard_test.go git commit -m "feat: RateLimiter/NonceChecker 接口(零依赖默认实现)+ GuardedRedeem 失败锁定" ``` --- ### Task 7: redisx 子包(可选)— Redis 限流 + 去重(miniredis 测试,免 docker) **Files:** - Create: `redisx/ratelimit.go` - Create: `redisx/nonce.go` - Test: `redisx/ratelimit_test.go` - Test: `redisx/nonce_test.go` **Interfaces:** - Consumes: `codes.RateLimiter` / `codes.NonceChecker` 接口(Task6)。 - Produces: - `type redisx.RateLimiter struct{...}` · `func redisx.NewRateLimiter(rdb *redis.Client, prefix string, failMax int, lockDur time.Duration) *RateLimiter`(实现 `codes.RateLimiter`) - `type redisx.NonceChecker struct{...}` · `func redisx.NewNonceChecker(rdb *redis.Client, prefix string) *NonceChecker`(实现 `codes.NonceChecker`) **这是唯一 import Redis 客户端的地方**——不 import `codes/redisx` 的宿主完全不产生 Redis 依赖。逻辑移植自 pangolin `internal/codes/service.go` 的 `redisKeyFail`/`isLocked`/`recordFail`/`clearFail`(失败计数器 + TTL)与 `webhook.go` 的 `checkAndStoreNonce`(`SET NX` 原子去重)。 - [ ] **Step 1: 写失败测试(RateLimiter)** `redisx/ratelimit_test.go`: ```go package redisx_test import ( "context" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/wangjia/codes/redisx" ) func newTestRedis(t *testing.T) *redis.Client { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) return redis.NewClient(&redis.Options{Addr: mr.Addr()}) } func TestRateLimiterLocksAfterFailMax(t *testing.T) { rdb := newTestRedis(t) rl := redisx.NewRateLimiter(rdb, "test:fail:", 3, time.Hour) ctx := context.Background() for i := 0; i < 3; i++ { allowed, err := rl.Allowed(ctx, "user:1") if err != nil || !allowed { t.Fatalf("attempt %d: allowed=%v err=%v", i, allowed, err) } if err := rl.RecordFailure(ctx, "user:1"); err != nil { t.Fatalf("RecordFailure: %v", err) } } allowed, err := rl.Allowed(ctx, "user:1") if err != nil || allowed { t.Fatalf("after 3 failures: allowed=%v err=%v, want false", allowed, err) } // a different key is unaffected allowed2, _ := rl.Allowed(ctx, "user:2") if !allowed2 { t.Fatal("user:2 should be unaffected by user:1's failures") } } func TestRateLimiterReset(t *testing.T) { rdb := newTestRedis(t) rl := redisx.NewRateLimiter(rdb, "test:fail:", 2, time.Hour) ctx := context.Background() rl.RecordFailure(ctx, "user:1") rl.RecordFailure(ctx, "user:1") if allowed, _ := rl.Allowed(ctx, "user:1"); allowed { t.Fatal("should be locked before reset") } if err := rl.Reset(ctx, "user:1"); err != nil { t.Fatalf("Reset: %v", err) } if allowed, err := rl.Allowed(ctx, "user:1"); err != nil || !allowed { t.Fatalf("after reset: allowed=%v err=%v", allowed, err) } } ``` `redisx/nonce_test.go`: ```go package redisx_test import ( "context" "testing" "time" "github.com/wangjia/codes/redisx" ) func TestNonceCheckerDedup(t *testing.T) { rdb := newTestRedis(t) nc := redisx.NewNonceChecker(rdb, "test:nonce:") ctx := context.Background() dup1, err := nc.SeenOrStore(ctx, "n1", time.Minute) if err != nil || dup1 { t.Fatalf("first: dup=%v err=%v", dup1, err) } dup2, err := nc.SeenOrStore(ctx, "n1", time.Minute) if err != nil || !dup2 { t.Fatalf("second (replay): dup=%v err=%v, want true", dup2, err) } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go get github.com/redis/go-redis/v9@latest github.com/alicebob/miniredis/v2@latest && go test ./redisx/... -v` Expected: 编译失败——`redisx` 包不存在。 - [ ] **Step 3: 写实现** `redisx/ratelimit.go`: ```go // Package redisx provides Redis-backed implementations of codes.RateLimiter // and codes.NonceChecker. This is the ONLY package in the module that // imports a Redis client — hosts that don't import redisx never pull Redis // into their dependency graph. package redisx import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) // RateLimiter implements codes.RateLimiter with a per-key failure counter + // TTL, ported from pangolin's redeem lockout // (internal/codes/service.go: redisKeyFail/isLocked/recordFail/clearFail). type RateLimiter struct { rdb *redis.Client prefix string failMax int lockDur time.Duration } func NewRateLimiter(rdb *redis.Client, prefix string, failMax int, lockDur time.Duration) *RateLimiter { if failMax <= 0 { failMax = 5 } if lockDur <= 0 { lockDur = time.Hour } if prefix == "" { prefix = "codes:redeem:fail:" } return &RateLimiter{rdb: rdb, prefix: prefix, failMax: failMax, lockDur: lockDur} } func (r *RateLimiter) key(k string) string { return r.prefix + k } func (r *RateLimiter) Allowed(ctx context.Context, key string) (bool, error) { val, err := r.rdb.Get(ctx, r.key(key)).Int() if err == redis.Nil { return true, nil } if err != nil { return false, fmt.Errorf("redisx.RateLimiter.Allowed: %w", err) } return val < r.failMax, nil } func (r *RateLimiter) RecordFailure(ctx context.Context, key string) error { k := r.key(key) pipe := r.rdb.Pipeline() pipe.Incr(ctx, k) pipe.Expire(ctx, k, r.lockDur) if _, err := pipe.Exec(ctx); err != nil { return fmt.Errorf("redisx.RateLimiter.RecordFailure: %w", err) } return nil } func (r *RateLimiter) Reset(ctx context.Context, key string) error { if err := r.rdb.Del(ctx, r.key(key)).Err(); err != nil { return fmt.Errorf("redisx.RateLimiter.Reset: %w", err) } return nil } ``` `redisx/nonce.go`: ```go package redisx import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) // NonceChecker implements codes.NonceChecker via Redis SET NX — atomic // check-and-store, safe across multiple webhook-handler instances. Ported // from pangolin's webhook.go: checkAndStoreNonce. type NonceChecker struct { rdb *redis.Client prefix string } func NewNonceChecker(rdb *redis.Client, prefix string) *NonceChecker { if prefix == "" { prefix = "codes:webhook:nonce:" } return &NonceChecker{rdb: rdb, prefix: prefix} } func (c *NonceChecker) SeenOrStore(ctx context.Context, nonce string, ttl time.Duration) (bool, error) { set, err := c.rdb.SetNX(ctx, c.prefix+nonce, "1", ttl).Result() if err != nil { return false, fmt.Errorf("redisx.NonceChecker.SeenOrStore: %w", err) } return !set, nil // SetNX returns true when newly set (not a duplicate) } ``` - [ ] **Step 4: 跑测试确认通过 + 全量** Run: `cd ~/code/codes && go mod tidy && go build ./... && go test ./...` Expected: 全 `ok`;根包(不 import `redisx`)编译产物不含 Redis 客户端符号。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add redisx/ go.mod go.sum git commit -m "feat(redisx): 可选 Redis 限流+去重实现(miniredis 测试,免 docker;根包零 Redis 依赖)" ``` --- ### Task 8: webhook 灌码 — 通用 HMAC 签名 + 去重 + 通用权益负载 → Mint **Files:** - Create: `webhook.go` - Test: `webhook_test.go` **Interfaces:** - Consumes: `Store`/`Mint`(Task3/4)、`Canonicalize`/`Hash`(Task2)、`NonceChecker`/`InMemoryNonceChecker`(Task6)。 - Produces: - `func VerifyWebhookSignature(secret []byte, system, timestamp, nonce string, rawBody []byte, gotSig string) error` - `type MintPayload struct{ Code, Channel string; Entitlement Entitlement; Note string }` - `type WebhookHandler struct{...}` · `func NewWebhookHandler(store *Store, nonces NonceChecker, system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler`(实现 `http.Handler`) **签名选型说明**:pangolin 现有 webhook(`internal/codes/webhook.go`)只对 body 做 HMAC,timestamp/nonce 通过独立请求头校验、不进 MAC。本库改用 **pay-contract 既有的双向签名惯例**(`sign = base64(HMAC_SHA256(secret, system+"\n"+timestamp+"\n"+nonce+"\n"+rawBody))`,见 `~/code/pay-contract/README.md` §3)——把 system/timestamp/nonce 也纳入 MAC,防止头部被篡改而签名仍验证通过;同时让"codes 灌码"与"pay 回调"这两类跨产品 webhook 用同一套验签心智模型,降低多产品接入的认知负担。这是本任务对 pangolin 原实现的一处**有意偏离**,已在 Self-Review 中记录。 - [ ] **Step 1: 写失败测试** `webhook_test.go`: ```go package codes import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" ) func signRequest(t *testing.T, secret, system, ts, nonce string, body []byte) string { t.Helper() mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(system + "\n" + ts + "\n" + nonce + "\n")) mac.Write(body) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func newSignedRequest(t *testing.T, secret, system, nonce string, body []byte) *http.Request { t.Helper() ts := strconv.FormatInt(time.Now().Unix(), 10) sig := signRequest(t, secret, system, ts, nonce, body) req := httptest.NewRequest(http.MethodPost, "/webhook/codes", strings.NewReader(string(body))) req.Header.Set("X-Pay-Timestamp", ts) req.Header.Set("X-Pay-Nonce", nonce) req.Header.Set("X-Pay-Sign", sig) return req } func TestWebhookMintsCodeOnValidRequest(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) ent := mustDuration(t, "pro", 30) payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) req := newSignedRequest(t, "s3cret", "card-store", "nonce-1", payload) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } canonical, _ := Canonicalize("ABCDEFGHJKMNPQR0") row, err := s.FindByHash(context.Background(), Hash(canonical)) if err != nil || row == nil { t.Fatalf("code not stored: row=%v err=%v", row, err) } } func TestWebhookReplayedNonceIsIgnored(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) ent := mustDuration(t, "pro", 30) payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) req1 := newSignedRequest(t, "s3cret", "card-store", "nonce-dup", payload) rec1 := httptest.NewRecorder() h.ServeHTTP(rec1, req1) if rec1.Code != http.StatusCreated { t.Fatalf("first request status = %d", rec1.Code) } req2 := newSignedRequest(t, "s3cret", "card-store", "nonce-dup", payload) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("replayed nonce status = %d, want 200", rec2.Code) } if !strings.Contains(rec2.Body.String(), "duplicate_ignored") { t.Fatalf("body = %s", rec2.Body.String()) } } func TestWebhookRejectsBadSignature(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) ent := mustDuration(t, "pro", 30) payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) req := newSignedRequest(t, "WRONG-secret", "card-store", "nonce-2", payload) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } } func TestWebhookRejectsStaleTimestamp(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) ent := mustDuration(t, "pro", 30) payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store", Entitlement: ent}) staleTS := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10) sig := signRequest(t, "s3cret", "card-store", staleTS, "nonce-3", payload) req := httptest.NewRequest(http.MethodPost, "/webhook/codes", strings.NewReader(string(payload))) req.Header.Set("X-Pay-Timestamp", staleTS) req.Header.Set("X-Pay-Nonce", "nonce-3") req.Header.Set("X-Pay-Sign", sig) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } } func TestWebhookRejectsInvalidEntitlement(t *testing.T) { db := openTestDB(t) s := NewStore(db, DialectSQLite) h := NewWebhookHandler(s, nil, "card-store", "s3cret", 5*time.Minute, time.Hour) payload, _ := json.Marshal(MintPayload{Code: "ABCDEFGHJKMNPQR0", Channel: "store"}) // empty Entitlement req := newSignedRequest(t, "s3cret", "card-store", "nonce-4", payload) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } } ``` - [ ] **Step 2: 跑测试确认失败** Run: `cd ~/code/codes && go test . -run TestWebhook -v` Expected: 编译失败——`WebhookHandler`/`MintPayload`/`VerifyWebhookSignature` 未定义。 - [ ] **Step 3: 写实现** `webhook.go`: ```go package codes import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strconv" "time" ) // VerifyWebhookSignature checks the pay-contract HMAC convention: // // sign = base64(HMAC_SHA256(secret, system+"\n"+timestamp+"\n"+nonce+"\n"+rawBody)) // // shared with pay's own callback signing (~/code/pay-contract §3), so every // cross-product inbound webhook is verified the same way. Binding // system/timestamp/nonce into the MAC (unlike pangolin's original body-only // HMAC) means a tampered header invalidates the signature too. func VerifyWebhookSignature(secret []byte, system, timestamp, nonce string, rawBody []byte, gotSig string) error { mac := hmac.New(sha256.New, secret) mac.Write([]byte(system + "\n" + timestamp + "\n" + nonce + "\n")) mac.Write(rawBody) want := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(gotSig)) { return fmt.Errorf("codes: webhook signature mismatch") } return nil } func verifyTimestamp(tsStr string, tolerance time.Duration) error { ts, err := strconv.ParseInt(tsStr, 10, 64) if err != nil { return fmt.Errorf("codes: invalid webhook timestamp: %w", err) } diff := time.Since(time.Unix(ts, 0)) if diff < 0 { diff = -diff } if diff > tolerance { return fmt.Errorf("codes: webhook timestamp outside tolerance") } return nil } // MintPayload is the JSON body an external card store posts to mint one // code. Entitlement is the generic descriptor (entitlement.go) — the // webhook never hardcodes plan/days, so the same endpoint serves duration // and quota products alike. type MintPayload struct { Code string `json:"code"` Channel string `json:"channel"` Entitlement Entitlement `json:"entitlement"` Note string `json:"note,omitempty"` } // WebhookHandler implements POST /webhook/codes ingestion from a card // store. Headers follow pay-contract naming: X-Pay-Timestamp / X-Pay-Nonce / // X-Pay-Sign. type WebhookHandler struct { store *Store nonces NonceChecker secret []byte system string timestampTolerance time.Duration nonceTTL time.Duration createdBy string } // NewWebhookHandler creates a handler. Pass nonces=nil to use the // single-process InMemoryNonceChecker default; multi-instance deployments // should pass a codes/redisx.NonceChecker instead. func NewWebhookHandler(store *Store, nonces NonceChecker, system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler { if nonces == nil { nonces = NewInMemoryNonceChecker() } return &WebhookHandler{ store: store, nonces: nonces, system: system, secret: []byte(secret), timestampTolerance: tolerance, nonceTTL: nonceTTL, createdBy: "webhook:" + system, } } func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } ts := r.Header.Get("X-Pay-Timestamp") nonce := r.Header.Get("X-Pay-Nonce") sig := r.Header.Get("X-Pay-Sign") if ts == "" || nonce == "" || sig == "" { http.Error(w, "missing signature headers", http.StatusBadRequest) return } if err := VerifyWebhookSignature(h.secret, h.system, ts, nonce, body, sig); err != nil { http.Error(w, "signature mismatch", http.StatusUnauthorized) return } if err := verifyTimestamp(ts, h.timestampTolerance); err != nil { http.Error(w, "stale timestamp", http.StatusUnauthorized) return } ctx := r.Context() dup, err := h.nonces.SeenOrStore(ctx, nonce, h.nonceTTL) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if dup { writeJSON(w, http.StatusOK, map[string]string{"status": "duplicate_ignored"}) return } var payload MintPayload if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "bad payload", http.StatusBadRequest) return } if payload.Code == "" || payload.Channel == "" { http.Error(w, "missing code/channel", http.StatusBadRequest) return } if err := payload.Entitlement.Validate(); err != nil { http.Error(w, "invalid entitlement: "+err.Error(), http.StatusBadRequest) return } canonical, err := Canonicalize(payload.Code) if err != nil { http.Error(w, "invalid code format", http.StatusBadRequest) return } h.mintOrAck(ctx, w, canonical, payload) } // mintOrAck writes one code (one batch per webhook call, mirroring // pangolin's original behavior) and responds; ErrDuplicate is treated as an // idempotent no-op ack, not an error. func (h *WebhookHandler) mintOrAck(ctx context.Context, w http.ResponseWriter, canonical string, payload MintPayload) { batchID, err := h.store.CreateBatch(ctx, payload.Channel, payload.Entitlement, h.createdBy, payload.Note) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } err = h.store.CreateCode(ctx, Hash(canonical), batchID, payload.Entitlement) if err == ErrDuplicate { writeJSON(w, http.StatusOK, map[string]string{"status": "already_exists"}) return } if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, map[string]string{"status": "created"}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } ``` - [ ] **Step 4: 跑测试确认通过 + 全量** Run: `cd ~/code/codes && go build ./... && go test ./... -v` Expected: 全部任务的测试(Task1–8)一并跑绿。 - [ ] **Step 5: Commit** ```bash cd ~/code/codes git add webhook.go webhook_test.go git commit -m "feat: webhook 灌码(pay-contract 式通用 HMAC 签名 + 去重 + 通用权益负载 → Mint)" ``` --- ## Self-Review **Spec coverage(用户 P7 范围逐条核对):** - 独立 Go module/包 + 选型理由 → 「部署与模块选型」章节 ✓(独立仓 `github.com/wangjia/codes`,非 pay 子包/非 pangolin 内嵌,理由:依赖隔离 + 设计文档 §9.1 方案 A)。 - 码模型 + 状态机 + 生成器(Crockford Base32+校验位,crypto/rand)+ 哈希存储(SHA-256,明文不落库) → Task1(状态机/错误)+ Task2(生成器,移植 pangolin idgen)+ Task3(Store,`code_hash` 唯一约束,明文只在 `Mint`/webhook 请求体经过内存,从不写库/写日志)✓。 - 兑换事务骨架 `Redeem(ctx, tx, codeHash, grantFn)` → Task5,签名对齐用户给出的形态(`Redeem[T any](ctx, store, tx, codeHash, redeemerRef, grant)`——多出的 `store`/`redeemerRef` 是必要的接收者与幂等键,`grant` 即 `grantFn`),锁(`FindByHashForUpdate`+dialect)、CAS(`MarkRedeemed` 的 `AND status='unused'`)、幂等(同 `redeemerRef` 短路)三者齐全,`TestRedeemGrantFailureRollsBackWholeTx` 专门验证"宿主本地事务"的原子性承诺 ✓。 - 批次 + 审计 → Task3(`CreateBatch`/`WriteAudit`)+ Task4(`Mint` 内的批次生成)✓。 - 发卡店 webhook 灌码(HMAC) → Task8,选用 pay-contract 既有多产品签名惯例(而非原样照抄 pangolin 的 body-only HMAC),偏离已在 Task8 开头注明理由 ✓。 - 通用权益描述符(不硬编码 plan+days,支持时长/额度) → Task1 `Entitlement{Kind,Payload}` + `DurationPayload`/`QuotaPayload`,对应设计文档 §12 的两种 entitlement 形态 ✓。 - Redis 限流(可选,标注) → Task6(接口 + 零依赖默认值)+ Task7(`redisx` 子包,唯一 import Redis 的地方,不引用就不产生依赖)✓,明确标注"可选"。 **未覆盖 / 有意排除(见 Global Constraints 末尾"本计划范围之外"):** admin 批次列表/CSV 导出(pangolin 已有实现,宿主可自行在 `Store` 基础方法上拼,不进本库以保持库精简);pangolin/jiu 迁移到 import 本库(独立后续任务,本计划只交付库本身);独立服务化方案 B、reseller 门户、优惠券变体(设计文档标注 later)。这些都不是用户列出的 P7 范围条目。 **Placeholder scan:** 全部 8 个任务均给出完整 Go 实现 + 完整测试,无 TODO/占位;唯一需要人工确认的是 Task1 Step 0 的 Gitea 建仓(网络操作,不属于代码)。 **Type consistency 关键点:** - `Entitlement`(Task1)→ 贯穿 `Code.Entitlement`(Task3)、`MintRequest.Entitlement`/`MintResult.Entitlement`(Task4)、`GrantFunc[T]` 的 `code Code` 参数(Task5)、`MintPayload.Entitlement`(Task8),全程同一类型,无中途转换丢信息。 - `Status`(Task1)三态与 `store.go`/`redeem.go` 的 SQL `status` 列字符串值(`'unused'`/`'redeemed'`/`'void'`)逐字一致。 - `Redeem`/`GuardedRedeem` 的泛型签名 `[T any]` 在 Task5→Task6(`GuardedRedeem` 包一层)→ 测试(`grantDuration(days int) GrantFunc[time.Time]`)全程一致,未出现 `any` 断言噩梦。 - `Dialect`(Task3)贯穿 `Store`/`FindByHashForUpdate`/`ApplyMigrations`,mysql/sqlite 两分支的 SQL 均已给出(mysql 分支未在 CI 用真实 MySQL 验证——本计划测试全跑 sqlite `:memory:`,mysql 语法只做静态审阅;这与 pangolin 现有 `run_mysql_test.sh` 需要 docker 的取舍一致,MySQL 集成验证留给宿主接入时用真实 MySQL 跑一次 `ApplyMigrations`)。 **一处需要宿主在真正 import 时注意(非本库缺陷,写进这里防止遗忘)**:`Redeem`/`GuardedRedeem` 要求 `tx` 与 `store` 底层 `*sql.DB` 同源,且宿主的 `grant` 回调里的写操作必须使用**传入的同一个 `tx`**(而不是另开一个 `store.db.Exec(...)`)——否则会静默丢失"本地事务"的原子性保证,退化成两个独立事务。这一点在 `GrantFunc` 的类型签名(强制传入 `*sql.Tx` 而非允许拿到 `*sql.DB`)里已经做了预防,但仍建议后续给宿主的接入文档里用大写加粗强调一次。 --- ## 后续阶段(接续 P1 末尾"后续阶段"列表) - P2–P6:见 `2026-07-10-pay-v2-p1-core-model.md` 末尾(Provider 抽象/首批渠道/退款/多账户路由/对账 job)。 - **P7(本计划)**:codes 共享库,独立仓 `github.com/wangjia/codes`,不改动 pay/pangolin/jiu 任何现有代码。 - **P8(later)**:订阅/recurring(4 类 kind)、拒付 chargeback(见设计文档 §5.1)。 - **未编号后续(brain todo,不在 pay 的 P1–P8 序列里)**:pangolin `server/internal/codes/` 迁移为 import `github.com/wangjia/codes`(替换 `plan_id+duration_days` 为 `Entitlement`,`grantFn` 实现 pangolin 的订阅叠加算法);jiu 门店 license 接入同一个库;codes 优惠券变体(设计文档 §7)。均待本计划落地验证后再排期。