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

2842 lines
94 KiB
Markdown
Raw Blame History

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