Files
pay/docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md
T
wangjia a4ae5fcb64 docs: pay v2 P2(收款管线)+ P7(codes 共享库)实现计划
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>
2026-07-10 09:26:32 +08:00

94 KiB
Raw Blame History

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 兑换已兑换码 → ErrCodeRedeemedredeemerRef 是不透明字符串("user:123" / "shop:9"),库不关心其归属维度——这正是设计文档 §9.1 强调"归属维度(user/shop)…留给各产品"的落地方式。
  • 每步 go build ./... 通过;测试 go test ./...(sqlite :memory: 免 docker;redisxgithub.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: 建仓

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 031 match crockfordAlphabet;
// symbols 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"

// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), 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() 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:

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.SubscriptionIDjiu.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.goredisKeyFail/isLocked/recordFail/clearFail(失败计数器 + TTL)与 webhook.gocheckAndStoreNonce(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) 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:

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: 全部任务的测试(Task18)一并跑绿。

  • 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 是必要的接收者与幂等键,grantgrantFn),锁(FindByHashForUpdate+dialect)、CAS(MarkRedeemedAND 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 要求 txstore 底层 *sql.DB 同源,且宿主的 grant 回调里的写操作必须使用传入的同一个 tx(而不是另开一个 store.db.Exec(...))——否则会静默丢失"本地事务"的原子性保证,退化成两个独立事务。这一点在 GrantFunc 的类型签名(强制传入 *sql.Tx 而非允许拿到 *sql.DB)里已经做了预防,但仍建议后续给宿主的接入文档里用大写加粗强调一次。


后续阶段(接续 P1 末尾"后续阶段"列表)

  • P2P6:见 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 的 P1P8 序列里):pangolin server/internal/codes/ 迁移为 import github.com/wangjia/codes(替换 plan_id+duration_daysEntitlement,grantFn 实现 pangolin 的订阅叠加算法);jiu 门店 license 接入同一个库;codes 优惠券变体(设计文档 §7)。均待本计划落地验证后再排期。