P2(pay 仓,7 任务):Provider 抽象/render_type + 收款管线 + 统一入账 + webhook v2。 P7(新仓 ~/code/codes,8 任务):零依赖 codes 内核 + Redeem[T] 泛型事务骨架。 并行起草(均只依赖已完成 P1),登记 index。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 KiB
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(取 pangolin1.25.10/ pay1.26.1的下界,保证两者都能正常require本库)。核心包(根目录codes)编译期零第三方依赖——只有internal/idgen用到的 stdlib(crypto/rand/crypto/sha256)和测试期用到modernc.org/sqlite(纯 Go 免 CGO,仅测试文件 import,不进宿主生产依赖图)。codes/redisx子包才 importgithub.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——与 pangolinserver/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/EntitlementQuotatype 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() errortype Status string+ 常量StatusUnused/StatusRedeemed/StatusVoid;func (s Status) Redeemable() bool/func (s Status) Voidable() bool- 哨兵错误:
ErrDuplicate/ErrCodeNotFound/ErrCodeRedeemed/ErrCodeVoid/ErrInvalidCode/ErrLocked/ErrNotVoidable
-
Step 0: 建仓
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:
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:
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:
// 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:
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:
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
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:
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 部分——本库不需要):
// 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:
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:
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
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() stringvar migrationsFS embed.FS(私有)+func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) errortype 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:
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
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:
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:
DROP TABLE codes_audit_log;
DROP TABLE codes;
DROP TABLE codes_batches;
migrations/sqlite/000001_codes.up.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:
DROP TABLE codes_audit_log;
DROP TABLE codes;
DROP TABLE codes_batches;
- Step 6: 写失败测试(ApplyMigrations)
migrations_test.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
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/<mysql|sqlite>/" 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(供本任务及后续任务的包内测试共用):
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:
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
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
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:
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:
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
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:
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:
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
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:
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:
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:
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:
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
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:
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:
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:
// 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:
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
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) errortype 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:
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:
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
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的 SQLstatus列字符串值('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/迁移为 importgithub.com/wangjia/codes(替换plan_id+duration_days为Entitlement,grantFn实现 pangolin 的订阅叠加算法);jiu 门店 license 接入同一个库;codes 优惠券变体(设计文档 §7)。均待本计划落地验证后再排期。