a4ae5fcb64
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>
2842 lines
94 KiB
Markdown
2842 lines
94 KiB
Markdown
# pay v2 · P7 codes 共享库(激活码兑换内核)Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 `- [ ]` checkbox 追踪。
|
||
|
||
> **设计文档(全景蓝图):** `docs/pay-v2-unified-gateway-design.html` §9(codes 兑换内核)/ §9.1(部署选型 A,已定)/ §9.2(履约动作多态)/ §12(通用权益描述符,可扩展性验证)。本计划是 pay v2 八阶段中的 **P7**,承接 `2026-07-10-pay-v2-p1-core-model.md` 末尾"后续阶段"列出的 P7 条目。
|
||
|
||
**Goal:** 把 pangolin 现有 `server/internal/codes/`(码模型/状态机/生成器/兑换事务/webhook 灌码)抽成一个与 pay **不同部署、不同仓库**的独立 Go 共享库,供 pangolin(订阅)、jiu(门店 license)、未来 dudu(额度)等产品各自 `import` 嵌入。库本身**entitlement-agnostic**——码携带通用「权益描述符」而非硬编码 plan+天数,兑换的"最终开通落库"通过宿主注入的回调函数完成,兑换是**宿主本地事务**(码表与宿主业务表同库)。
|
||
|
||
**Architecture:** 移植 pangolin `internal/codes` 的 canonical 部分(哈希存储 / 状态机 unused-redeemed-void / Crockford Base32 生成器 / 锁+CAS+幂等兑换骨架 / 批次 / 审计),去掉 pangolin 专属的 `plan_id`+`duration_days`+`subscriptions` 硬编码,替换成通用 `Entitlement{Kind, Payload}` 信封;webhook 签名从 pangolin 的「仅对 body 做 HMAC」升级为 pay-contract 既有的「system+timestamp+nonce+body 一并入 MAC」多产品既定惯例。库只依赖 `database/sql`(host 传入已打开的 `*sql.DB`/`*sql.Tx`,mysql 或 sqlite 均可,库不 import 具体驱动),Redis 相关能力(限流/去重)隔离进可选子包,避免强加给不跑 Redis 的宿主。
|
||
|
||
## 部署与模块选型(已决策,依据设计文档 §9.1 方案 A)
|
||
|
||
| 选项 | 说明 | 结论 |
|
||
|---|---|---|
|
||
| pay 仓内 `pkg/codes` 子包 | 复用 pay 的 go.mod | ❌ 不采用——pay 依赖很重(gin/GORM/alipay SDK/wechatpay-go),codes 的宿主(pangolin/jiu)只想要码逻辑,不想被迫拉进整个支付网关的依赖树;codes 与 pay 收款管线正交(§9 已定"不并入 pay"),放进 pay 仓在逻辑和依赖两个维度都是错误信号。 |
|
||
| pangolin 仓内保留、jiu 抄一份 | 零迁移成本 | ❌ 不采用——违反"共享库"目标,retire 到两份漂移代码,与设计文档 §9.1"方案 A = 共享库嵌入各产品"的决策矛盾。 |
|
||
| **独立仓库 + 独立 Go module(方案 A 落地形态)** | 新仓 `~/code/codes`,module `github.com/wangjia/codes`,各产品 `go get`/`replace` 引入 | ✅ **已定**——依赖最小(核心包零第三方依赖,仅 stdlib;可选 Redis 能力隔离进 `codes/redisx` 子包,不 import 就不产生依赖);可被 pangolin/jiu/dudu 平等 import;版本可独立打 tag,不与任何单一产品的发布节奏绑定。 |
|
||
|
||
**落地约定**(照用户全局仓库管理惯例):新仓源码放 `~/code/codes`,`git init` 后 remote 指向 `ssh://git@git.51yanmei.com:2222/wangjia/codes.git`(需先在 Gitea web 建仓才能 push;若尚未建仓,先本地 commit,push 留到 Gitea 仓建好后)。**本计划只搭这个新仓库,不改 pangolin/jiu 现有代码**——pangolin 现有 `server/internal/codes/` 迁移到 import 本库是后续独立工作(brain todo,pay 定稿 + codes 库稳定后再动),本计划不做。
|
||
|
||
## Global Constraints
|
||
|
||
- **Module**:`github.com/wangjia/codes`,`go 1.23`(取 pangolin `1.25.10` / pay `1.26.1` 的下界,保证两者都能正常 `require` 本库)。核心包(根目录 `codes`)编译期**零第三方依赖**——只有 `internal/idgen` 用到的 stdlib(`crypto/rand`/`crypto/sha256`)和测试期用到 `modernc.org/sqlite`(纯 Go 免 CGO,仅测试文件 import,不进宿主生产依赖图)。`codes/redisx` 子包才 import `github.com/redis/go-redis/v9`,宿主不 import 这个子包就不会被拉进 Redis 依赖。
|
||
- **同库本地事务是唯一原子性保证**:`Redeem[T]` 接收宿主已开启的 `*sql.Tx`,码状态翻转与宿主的 `grant` 回调写同一个事务——这要求宿主的权益表与 codes 表**在同一个数据库连接/同一个 `*sql.DB` 之下**(设计文档 §9.1 方案 A 的前提)。若宿主权益数据在另一个物理库,本库不提供分布式事务,那是方案 B(独立服务 + saga)的范畴,不在本计划内。
|
||
- **明文码永不落库/永不进日志**:数据库与审计日志只存 `SHA-256(canonical_plaintext)`;明文只在 `Mint` 的返回值里出现一次(调用方——通常是 webhook 处理器或运营 CLI——自行负责安全投递)。
|
||
- **状态机三态**:`unused → redeemed`(经 `Redeem`)、`unused → void`(经 `VoidCode`),**没有其他合法转移**;`redeemed`/`void` 都是终态。
|
||
- **通用权益描述符**:`Entitlement{Kind, Payload}` 替代硬编码 `plan_id`+`duration_days`;`Kind=duration` 承载时长型(`{plan,days}`),`Kind=quota` 承载额度型(`{resource,amount}`,对应 dudu 秒数/未来流量包)。库只做信封校验(`Validate()`),**从不解释 `Payload` 内的业务字段**——那是宿主 `GrantFunc` 的职责。
|
||
- **幂等**:同一 `redeemerRef` 重复兑换同一码 → `Idempotent:true`、不重复执行 `grant`;不同 `redeemerRef` 兑换已兑换码 → `ErrCodeRedeemed`。`redeemerRef` 是不透明字符串(`"user:123"` / `"shop:9"`),库不关心其归属维度——这正是设计文档 §9.1 强调"归属维度(user/shop)…留给各产品"的落地方式。
|
||
- 每步 `go build ./...` 通过;测试 `go test ./...`(sqlite `:memory:` 免 docker;`redisx` 用 `github.com/alicebob/miniredis/v2` 免 docker——与 pangolin `server/go.mod` 现有测试依赖一致)。
|
||
- **本计划范围之外**(明确排除,避免范围蔓延):admin 批次列表/CSV 导出(pangolin `admin_support.go`/`export.go` 已有实现,宿主若需要可直接在自己的 admin 层调用 `Store` 的基础方法拼,不进本库);pangolin/jiu 迁移到 import 本库(单独任务);独立服务化方案 B、reseller 门户、优惠券变体(设计文档 §7/§9.1 标注的 later)。
|
||
|
||
---
|
||
|
||
### Task 1: 模块脚手架 + Entitlement 描述符 + 状态机 + 哨兵错误
|
||
|
||
**Files:**
|
||
- Create: `go.mod`(新仓根目录)
|
||
- Create: `entitlement.go`
|
||
- Create: `status.go`
|
||
- Create: `errors.go`
|
||
- Test: `entitlement_test.go`
|
||
- Test: `status_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `type EntitlementKind string` + 常量 `EntitlementDuration` / `EntitlementQuota`
|
||
- `type Entitlement struct{ Kind EntitlementKind; Payload json.RawMessage }`
|
||
- `type DurationPayload struct{ Plan string; Days int }` / `type QuotaPayload struct{ Resource string; Amount int64 }`
|
||
- `func NewDurationEntitlement(plan string, days int) (Entitlement, error)` / `func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error)`
|
||
- `func (e Entitlement) DecodeDuration() (DurationPayload, error)` / `func (e Entitlement) DecodeQuota() (QuotaPayload, error)`
|
||
- `func (e Entitlement) Validate() error`
|
||
- `type Status string` + 常量 `StatusUnused` / `StatusRedeemed` / `StatusVoid`;`func (s Status) Redeemable() bool` / `func (s Status) Voidable() bool`
|
||
- 哨兵错误:`ErrDuplicate` / `ErrCodeNotFound` / `ErrCodeRedeemed` / `ErrCodeVoid` / `ErrInvalidCode` / `ErrLocked` / `ErrNotVoidable`
|
||
|
||
- [ ] **Step 0: 建仓**
|
||
|
||
```bash
|
||
mkdir -p ~/code/codes && cd ~/code/codes
|
||
git init
|
||
go mod init github.com/wangjia/codes
|
||
git remote add origin ssh://git@git.51yanmei.com:2222/wangjia/codes.git
|
||
```
|
||
(Gitea 仓需先在 web 建好才能 push;未建好先只本地 commit。)
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
`entitlement_test.go`:
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"encoding/json"
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes"
|
||
)
|
||
|
||
func TestDurationEntitlementRoundTrip(t *testing.T) {
|
||
e, err := codes.NewDurationEntitlement("pro", 30)
|
||
if err != nil {
|
||
t.Fatalf("NewDurationEntitlement: %v", err)
|
||
}
|
||
if e.Kind != codes.EntitlementDuration {
|
||
t.Fatalf("kind = %q, want duration", e.Kind)
|
||
}
|
||
if err := e.Validate(); err != nil {
|
||
t.Fatalf("Validate: %v", err)
|
||
}
|
||
p, err := e.DecodeDuration()
|
||
if err != nil {
|
||
t.Fatalf("DecodeDuration: %v", err)
|
||
}
|
||
if p.Plan != "pro" || p.Days != 30 {
|
||
t.Fatalf("got %+v", p)
|
||
}
|
||
if _, err := e.DecodeQuota(); err == nil {
|
||
t.Fatal("DecodeQuota on a duration entitlement should error")
|
||
}
|
||
|
||
// 整个 Entitlement 可安全过 JSON(webhook payload 的编码方式)。
|
||
raw, err := json.Marshal(e)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
var back codes.Entitlement
|
||
if err := json.Unmarshal(raw, &back); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
p2, err := back.DecodeDuration()
|
||
if err != nil || p2 != p {
|
||
t.Fatalf("round-trip mismatch: %+v vs %+v (err=%v)", p2, p, err)
|
||
}
|
||
}
|
||
|
||
func TestQuotaEntitlementRoundTrip(t *testing.T) {
|
||
e, err := codes.NewQuotaEntitlement("data_gb", 100)
|
||
if err != nil {
|
||
t.Fatalf("NewQuotaEntitlement: %v", err)
|
||
}
|
||
q, err := e.DecodeQuota()
|
||
if err != nil {
|
||
t.Fatalf("DecodeQuota: %v", err)
|
||
}
|
||
if q.Resource != "data_gb" || q.Amount != 100 {
|
||
t.Fatalf("got %+v", q)
|
||
}
|
||
}
|
||
|
||
func TestEntitlementValidateRejectsEmpty(t *testing.T) {
|
||
if err := (codes.Entitlement{}).Validate(); err == nil {
|
||
t.Fatal("empty entitlement should fail Validate")
|
||
}
|
||
if err := (codes.Entitlement{Kind: codes.EntitlementDuration}).Validate(); err == nil {
|
||
t.Fatal("duration entitlement with empty payload should fail Validate")
|
||
}
|
||
}
|
||
```
|
||
|
||
`status_test.go`:
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes"
|
||
)
|
||
|
||
func TestStatusTransitions(t *testing.T) {
|
||
cases := []struct {
|
||
s codes.Status
|
||
redeemable, voidable bool
|
||
}{
|
||
{codes.StatusUnused, true, true},
|
||
{codes.StatusRedeemed, false, false},
|
||
{codes.StatusVoid, false, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := c.s.Redeemable(); got != c.redeemable {
|
||
t.Errorf("%s.Redeemable() = %v, want %v", c.s, got, c.redeemable)
|
||
}
|
||
if got := c.s.Voidable(); got != c.voidable {
|
||
t.Errorf("%s.Voidable() = %v, want %v", c.s, got, c.voidable)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `cd ~/code/codes && go test ./... -v`
|
||
Expected: 编译失败——`codes` 包内容不存在。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
`entitlement.go`:
|
||
```go
|
||
// Package codes implements a product-agnostic activation-code lifecycle:
|
||
// generation, hashed storage, a local-transaction redeem skeleton with a
|
||
// host-supplied grant callback, batches, and audit. It is deliberately not
|
||
// entitlement-aware — see Entitlement — and not payment-aware (see
|
||
// pay-v2-unified-gateway-design.html §9 for the boundary with pay).
|
||
//
|
||
// Security invariant: plaintext codes are NEVER written to storage or logs.
|
||
// Only SHA-256(canonical_plaintext) is persisted; plaintext appears exactly
|
||
// once, in Mint's return value.
|
||
package codes
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// EntitlementKind selects how Entitlement.Payload should be interpreted by
|
||
// the host's GrantFunc. codes treats Payload as opaque bytes beyond envelope
|
||
// validation — it never inspects business fields.
|
||
type EntitlementKind string
|
||
|
||
const (
|
||
EntitlementDuration EntitlementKind = "duration"
|
||
EntitlementQuota EntitlementKind = "quota"
|
||
)
|
||
|
||
// Entitlement is the generic "what does this code grant" descriptor. It
|
||
// replaces a hardcoded plan_id+duration_days pair so one library carries a
|
||
// subscription extension (pangolin/jiu) or a quota top-up (dudu seconds,
|
||
// future traffic packs) without a schema change (design doc §12).
|
||
type Entitlement struct {
|
||
Kind EntitlementKind `json:"kind"`
|
||
Payload json.RawMessage `json:"payload"`
|
||
}
|
||
|
||
// DurationPayload is the Kind=duration payload shape.
|
||
type DurationPayload struct {
|
||
Plan string `json:"plan"`
|
||
Days int `json:"days"`
|
||
}
|
||
|
||
// QuotaPayload is the Kind=quota payload shape.
|
||
type QuotaPayload struct {
|
||
Resource string `json:"resource"`
|
||
Amount int64 `json:"amount"`
|
||
}
|
||
|
||
func NewDurationEntitlement(plan string, days int) (Entitlement, error) {
|
||
b, err := json.Marshal(DurationPayload{Plan: plan, Days: days})
|
||
if err != nil {
|
||
return Entitlement{}, fmt.Errorf("codes: marshal duration payload: %w", err)
|
||
}
|
||
return Entitlement{Kind: EntitlementDuration, Payload: b}, nil
|
||
}
|
||
|
||
func NewQuotaEntitlement(resource string, amount int64) (Entitlement, error) {
|
||
b, err := json.Marshal(QuotaPayload{Resource: resource, Amount: amount})
|
||
if err != nil {
|
||
return Entitlement{}, fmt.Errorf("codes: marshal quota payload: %w", err)
|
||
}
|
||
return Entitlement{Kind: EntitlementQuota, Payload: b}, nil
|
||
}
|
||
|
||
func (e Entitlement) DecodeDuration() (DurationPayload, error) {
|
||
if e.Kind != EntitlementDuration {
|
||
return DurationPayload{}, fmt.Errorf("codes: entitlement kind %q is not duration", e.Kind)
|
||
}
|
||
var p DurationPayload
|
||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||
return DurationPayload{}, fmt.Errorf("codes: decode duration payload: %w", err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func (e Entitlement) DecodeQuota() (QuotaPayload, error) {
|
||
if e.Kind != EntitlementQuota {
|
||
return QuotaPayload{}, fmt.Errorf("codes: entitlement kind %q is not quota", e.Kind)
|
||
}
|
||
var p QuotaPayload
|
||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||
return QuotaPayload{}, fmt.Errorf("codes: decode quota payload: %w", err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// Validate checks the envelope only: Kind/Payload are non-empty, and for the
|
||
// two known kinds the payload decodes. Unknown kinds pass through opaquely
|
||
// so new entitlement shapes don't require a codes library release first.
|
||
func (e Entitlement) Validate() error {
|
||
if e.Kind == "" {
|
||
return fmt.Errorf("codes: entitlement kind is empty")
|
||
}
|
||
if len(e.Payload) == 0 {
|
||
return fmt.Errorf("codes: entitlement payload is empty")
|
||
}
|
||
switch e.Kind {
|
||
case EntitlementDuration:
|
||
_, err := e.DecodeDuration()
|
||
return err
|
||
case EntitlementQuota:
|
||
_, err := e.DecodeQuota()
|
||
return err
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
```
|
||
|
||
`status.go`:
|
||
```go
|
||
package codes
|
||
|
||
// Status is the code lifecycle state. The only legal transitions are
|
||
// unused→redeemed (via Redeem) and unused→void (via VoidCode); both other
|
||
// states are terminal.
|
||
type Status string
|
||
|
||
const (
|
||
StatusUnused Status = "unused"
|
||
StatusRedeemed Status = "redeemed"
|
||
StatusVoid Status = "void"
|
||
)
|
||
|
||
func (s Status) Redeemable() bool { return s == StatusUnused }
|
||
func (s Status) Voidable() bool { return s == StatusUnused }
|
||
```
|
||
|
||
`errors.go`:
|
||
```go
|
||
package codes
|
||
|
||
import "errors"
|
||
|
||
var (
|
||
ErrDuplicate = errors.New("codes: duplicate code hash")
|
||
ErrCodeNotFound = errors.New("codes: code not found")
|
||
ErrCodeRedeemed = errors.New("codes: code already redeemed")
|
||
ErrCodeVoid = errors.New("codes: code has been voided")
|
||
ErrInvalidCode = errors.New("codes: invalid code format")
|
||
ErrLocked = errors.New("codes: too many failed attempts, temporarily locked")
|
||
ErrNotVoidable = errors.New("codes: code is not in a voidable state")
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `cd ~/code/codes && go test ./... -v`
|
||
Expected: `TestDurationEntitlementRoundTrip` / `TestQuotaEntitlementRoundTrip` / `TestEntitlementValidateRejectsEmpty` / `TestStatusTransitions` 全 PASS。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd ~/code/codes
|
||
git add go.mod entitlement.go status.go errors.go entitlement_test.go status_test.go
|
||
git commit -m "feat: module scaffold + 通用权益描述符 Entitlement + 状态机 + 哨兵错误"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装
|
||
|
||
**Files:**
|
||
- Create: `internal/idgen/idgen.go`
|
||
- Test: `internal/idgen/idgen_test.go`
|
||
- Create: `codegen.go`(根包薄封装,对外 API)
|
||
- Test: `codegen_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces(`internal/idgen`,包内私有,不对宿主暴露):
|
||
- `func GenerateCode() (string, error)` — 15 随机 Crockford Base32 数据字符 + 1 位 mod-37 校验字符(16 字符),`crypto/rand`。
|
||
- `func CanonicalizeCode(code string) (string, error)` — 归一化(大写、I/L→1、O→0、去连字符/空格)+ 校验位验证。
|
||
- `func HashCode(canonical string) string` — SHA-256 hex。
|
||
- Produces(根包 `codes`,对外 API):
|
||
- `func GenerateCode() (string, error)` / `func Canonicalize(code string) (string, error)` / `func Hash(canonical string) string` — 薄封装,委托给 `internal/idgen`。
|
||
|
||
此任务**原样移植** `pangolin/server/internal/codes` 依赖的 `internal/idgen` 的 Crockford Base32 部分(去掉与本库无关的 UUID v7 生成),含其完整的校验位/防偏抽样算法与既有测试集,是激活码格式与安全性的 canonical 来源,不重新发明。
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
`internal/idgen/idgen_test.go`:
|
||
```go
|
||
package idgen_test
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes/internal/idgen"
|
||
)
|
||
|
||
func TestGenerateCodeFormat(t *testing.T) {
|
||
for i := 0; i < 1000; i++ {
|
||
code, err := idgen.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
if len(code) != 16 {
|
||
t.Errorf("code %q: length = %d, want 16", code, len(code))
|
||
}
|
||
canonical, err := idgen.CanonicalizeCode(code)
|
||
if err != nil {
|
||
t.Errorf("CanonicalizeCode(%q): %v", code, err)
|
||
}
|
||
if canonical != code {
|
||
t.Errorf("canonical form mismatch: got %q, want %q", canonical, code)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestGenerateCodeUniqueness(t *testing.T) {
|
||
const n = 5_000
|
||
seen := make(map[string]struct{}, n)
|
||
for i := 0; i < n; i++ {
|
||
code, err := idgen.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
h := idgen.HashCode(code)
|
||
if _, dup := seen[h]; dup {
|
||
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
|
||
}
|
||
seen[h] = struct{}{}
|
||
}
|
||
}
|
||
|
||
func TestCanonicalizeCodeNormalization(t *testing.T) {
|
||
base, err := idgen.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
lower := strings.ToLower(base)
|
||
canonical, err := idgen.CanonicalizeCode(lower)
|
||
if err != nil {
|
||
t.Errorf("CanonicalizeCode(lower) error: %v", err)
|
||
}
|
||
if canonical != base {
|
||
t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base)
|
||
}
|
||
|
||
if idx := strings.IndexByte(base, '1'); idx >= 0 && idx < 15 {
|
||
for _, sub := range []string{"I", "L", "i", "l"} {
|
||
variant := base[:idx] + sub + base[idx+1:]
|
||
c, err := idgen.CanonicalizeCode(variant)
|
||
if err != nil {
|
||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||
continue
|
||
}
|
||
if c != base {
|
||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||
}
|
||
}
|
||
}
|
||
if idx := strings.IndexByte(base, '0'); idx >= 0 && idx < 15 {
|
||
variant := base[:idx] + "O" + base[idx+1:]
|
||
c, err := idgen.CanonicalizeCode(variant)
|
||
if err != nil {
|
||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||
} else if c != base {
|
||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCheckCharDetectsSingleErrors(t *testing.T) {
|
||
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||
code, err := idgen.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
for pos := 0; pos < 15; pos++ {
|
||
original := rune(code[pos])
|
||
for _, replacement := range alpha {
|
||
if replacement == original {
|
||
continue
|
||
}
|
||
mutated := code[:pos] + string(replacement) + code[pos+1:]
|
||
if _, err := idgen.CanonicalizeCode(mutated); err == nil {
|
||
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
|
||
pos, original, replacement, code, mutated)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHashCodeConsistency(t *testing.T) {
|
||
code1, _ := idgen.GenerateCode()
|
||
code2, _ := idgen.GenerateCode()
|
||
for code1 == code2 {
|
||
code2, _ = idgen.GenerateCode()
|
||
}
|
||
h1a := idgen.HashCode(code1)
|
||
h1b := idgen.HashCode(code1)
|
||
h2 := idgen.HashCode(code2)
|
||
if h1a != h1b {
|
||
t.Error("HashCode is not deterministic")
|
||
}
|
||
if h1a == h2 {
|
||
t.Error("different codes produced the same digest")
|
||
}
|
||
if len(h1a) != 64 {
|
||
t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a))
|
||
}
|
||
}
|
||
|
||
func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) {
|
||
for _, c := range []string{"", "ABCDE", "ABCDEFGH12345678X"} {
|
||
if _, err := idgen.CanonicalizeCode(c); err == nil {
|
||
t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c))
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) {
|
||
base, _ := idgen.GenerateCode()
|
||
invalid := "!" + base[1:]
|
||
if _, err := idgen.CanonicalizeCode(invalid); err == nil {
|
||
t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid)
|
||
}
|
||
}
|
||
|
||
func TestHyphenStripping(t *testing.T) {
|
||
code, err := idgen.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
|
||
canonical, err := idgen.CanonicalizeCode(hyphenated)
|
||
if err != nil {
|
||
t.Errorf("CanonicalizeCode(hyphenated) error: %v", err)
|
||
}
|
||
if canonical != code {
|
||
t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `cd ~/code/codes && go test ./internal/idgen/ -v`
|
||
Expected: 编译失败——`internal/idgen` 包不存在。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
`internal/idgen/idgen.go`(移植自 `pangolin/server/internal/idgen/idgen.go`,去掉 UUID v7 部分——本库不需要):
|
||
```go
|
||
// Package idgen generates 16-character Crockford Base32 activation codes
|
||
// with a mod-37 check character. Ported from pangolin's
|
||
// server/internal/idgen (Crockford portion only — UUID v7 generation is not
|
||
// needed by this library).
|
||
package idgen
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U
|
||
// to prevent visual confusion with 1, 1, 0, and V respectively).
|
||
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||
|
||
// crockfordCheck is the extended 37-symbol check-character alphabet used for
|
||
// the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet;
|
||
// symbols 32–36 are *, ~, $, =, U.
|
||
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
|
||
|
||
// crockfordDecode maps every printable ASCII character to its Crockford
|
||
// numeric value (0–31), or -1 if invalid. Normalisation (I/i/l/L→1, O/o→0)
|
||
// is baked in at init time.
|
||
var crockfordDecode [128]int8
|
||
|
||
func init() {
|
||
for i := range crockfordDecode {
|
||
crockfordDecode[i] = -1
|
||
}
|
||
for i, ch := range crockfordAlphabet {
|
||
crockfordDecode[ch] = int8(i)
|
||
if ch >= 'A' && ch <= 'Z' {
|
||
crockfordDecode[ch-'A'+'a'] = int8(i)
|
||
}
|
||
}
|
||
crockfordDecode['I'] = crockfordDecode['1']
|
||
crockfordDecode['i'] = crockfordDecode['1']
|
||
crockfordDecode['l'] = crockfordDecode['1']
|
||
crockfordDecode['L'] = crockfordDecode['1']
|
||
crockfordDecode['O'] = crockfordDecode['0']
|
||
crockfordDecode['o'] = crockfordDecode['0']
|
||
}
|
||
|
||
// computeCheckValue computes the Crockford mod-37 check value (Horner's
|
||
// method) of the first 15 characters of s (already canonical). Returns -1
|
||
// on any invalid character.
|
||
func computeCheckValue(s string) int {
|
||
result := 0
|
||
for i := 0; i < 15; i++ {
|
||
ch := s[i]
|
||
if ch >= 128 {
|
||
return -1
|
||
}
|
||
v := int(crockfordDecode[ch])
|
||
if v < 0 {
|
||
return -1
|
||
}
|
||
result = (result*32 + v) % 37
|
||
}
|
||
return result
|
||
}
|
||
|
||
func validateCheckChar(canonical string) error {
|
||
if len(canonical) != 16 {
|
||
return errors.New("idgen: invalid length for check validation")
|
||
}
|
||
expected := computeCheckValue(canonical)
|
||
if expected < 0 {
|
||
return errors.New("idgen: invalid data characters in code")
|
||
}
|
||
want := rune(crockfordCheck[expected])
|
||
got := rune(canonical[15])
|
||
if got != want {
|
||
return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CanonicalizeCode converts an activation-code string into canonical form:
|
||
// uppercase, I/L→1 and O→0 substituted, hyphens/spaces stripped, check
|
||
// character validated. Returns an error on invalid characters, wrong length
|
||
// (after stripping), or a bad check character.
|
||
func CanonicalizeCode(code string) (string, error) {
|
||
code = strings.TrimSpace(code)
|
||
code = strings.ReplaceAll(code, "-", "")
|
||
code = strings.ReplaceAll(code, " ", "")
|
||
|
||
if len(code) != 16 {
|
||
return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code))
|
||
}
|
||
|
||
var buf [16]byte
|
||
for i := 0; i < 16; i++ {
|
||
ch := code[i]
|
||
if ch >= 128 {
|
||
return "", fmt.Errorf("idgen: non-ASCII character at position %d", i)
|
||
}
|
||
v := crockfordDecode[ch]
|
||
if v < 0 {
|
||
if i < 15 {
|
||
return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i)
|
||
}
|
||
buf[i] = []byte(strings.ToUpper(string(ch)))[0]
|
||
continue
|
||
}
|
||
buf[i] = crockfordAlphabet[v]
|
||
}
|
||
|
||
canonical := string(buf[:])
|
||
if err := validateCheckChar(canonical); err != nil {
|
||
return "", err
|
||
}
|
||
return canonical, nil
|
||
}
|
||
|
||
// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext
|
||
// code — the value that gets persisted; the plaintext itself never is.
|
||
func HashCode(canonical string) string {
|
||
sum := sha256.Sum256([]byte(canonical))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// GenerateCode generates one random activation code in canonical Crockford
|
||
// Base32 form: 15 random data characters + 1 mod-37 check character.
|
||
// crypto/rand with rejection sampling (accept range [0,224), 224=7×32) to
|
||
// avoid modular bias.
|
||
func GenerateCode() (string, error) {
|
||
const dataLen = 15
|
||
var buf [dataLen]byte
|
||
i := 0
|
||
for i < dataLen {
|
||
var tmp [dataLen * 2]byte
|
||
if _, err := rand.Read(tmp[:]); err != nil {
|
||
return "", fmt.Errorf("idgen: crypto/rand: %w", err)
|
||
}
|
||
for _, b := range tmp {
|
||
if b < 224 {
|
||
buf[i] = crockfordAlphabet[b%32]
|
||
i++
|
||
if i == dataLen {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data := string(buf[:])
|
||
checkVal := computeCheckValue(data + "0")
|
||
if checkVal < 0 {
|
||
return "", errors.New("idgen: internal check computation error")
|
||
}
|
||
return data + string(crockfordCheck[checkVal]), nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `cd ~/code/codes && go test ./internal/idgen/ -v`
|
||
Expected: 全 PASS。
|
||
|
||
- [ ] **Step 5: 根包薄封装 + 测试**
|
||
|
||
`codegen_test.go`:
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes"
|
||
)
|
||
|
||
func TestGenerateCanonicalizeHashRoundTrip(t *testing.T) {
|
||
code, err := codes.GenerateCode()
|
||
if err != nil {
|
||
t.Fatalf("GenerateCode: %v", err)
|
||
}
|
||
canonical, err := codes.Canonicalize(code)
|
||
if err != nil {
|
||
t.Fatalf("Canonicalize: %v", err)
|
||
}
|
||
if canonical != code {
|
||
t.Fatalf("canonical mismatch: %q vs %q", canonical, code)
|
||
}
|
||
h := codes.Hash(canonical)
|
||
if len(h) != 64 {
|
||
t.Fatalf("Hash length = %d, want 64", len(h))
|
||
}
|
||
}
|
||
```
|
||
|
||
`codegen.go`:
|
||
```go
|
||
package codes
|
||
|
||
import "github.com/wangjia/codes/internal/idgen"
|
||
|
||
// GenerateCode generates one random 16-char Crockford Base32 activation code.
|
||
func GenerateCode() (string, error) { return idgen.GenerateCode() }
|
||
|
||
// Canonicalize normalises and validates a user-entered code string.
|
||
func Canonicalize(code string) (string, error) { return idgen.CanonicalizeCode(code) }
|
||
|
||
// Hash returns the hex SHA-256 digest of a canonical code — the only form
|
||
// ever persisted.
|
||
func Hash(canonical string) string { return idgen.HashCode(canonical) }
|
||
```
|
||
|
||
- [ ] **Step 6: 跑测试确认通过 + 全量编译**
|
||
|
||
Run: `cd ~/code/codes && go build ./... && go test ./...`
|
||
Expected: 编译通过,`internal/idgen` 与根包均 `ok`。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd ~/code/codes
|
||
git add internal/idgen/ codegen.go codegen_test.go
|
||
git commit -m "feat: Crockford Base32 生成器(移植 pangolin idgen)+ 根包薄封装"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Dialect + 内嵌 migrations(mysql+sqlite)+ Store CRUD
|
||
|
||
**Files:**
|
||
- Create: `dialect.go`
|
||
- Test: `dialect_test.go`
|
||
- Create: `migrations/mysql/000001_codes.up.sql` / `000001_codes.down.sql`
|
||
- Create: `migrations/sqlite/000001_codes.up.sql` / `000001_codes.down.sql`
|
||
- Create: `migrations.go`
|
||
- Test: `migrations_test.go`
|
||
- Create: `store.go`
|
||
- Create: `testdb_test.go`(包内测试共用 helper)
|
||
- Test: `store_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `type Dialect string` + 常量 `DialectMySQL`/`DialectSQLite`;`func (d Dialect) LockForUpdate() string`
|
||
- `var migrationsFS embed.FS`(私有)+ `func ApplyMigrations(ctx context.Context, db *sql.DB, dialect Dialect) error`
|
||
- `type Code struct{ ID int64; CodeHash string; BatchID int64; Entitlement Entitlement; Status Status; RedeemedBy string; RedeemedAt *time.Time; VoidReason string; CreatedAt time.Time }`
|
||
- `type Batch struct{ ID int64; Channel string; Entitlement Entitlement; CreatedBy, Note string; CreatedAt time.Time }`
|
||
- `type Store struct{...}` · `func NewStore(db *sql.DB, dialect Dialect) *Store`
|
||
- `(*Store) CreateBatch(ctx, channel string, ent Entitlement, createdBy, note string) (int64, error)`
|
||
- `(*Store) CreateCode(ctx, codeHash string, batchID int64, ent Entitlement) error`(重复 hash → `ErrDuplicate`)
|
||
- `(*Store) FindByHash(ctx, codeHash string) (*Code, error)`
|
||
- `(*Store) FindByHashForUpdate(ctx, tx *sql.Tx, codeHash string) (*Code, error)`
|
||
- `(*Store) MarkRedeemed(ctx, tx *sql.Tx, id int64, redeemerRef string, at time.Time) error`
|
||
- `(*Store) Void(ctx, id int64, reason string) (bool, error)`
|
||
- `(*Store) WriteAudit(ctx, tx *sql.Tx, actor, action, target string, meta map[string]any) error`(`tx==nil` 时直接用 `s.db`)
|
||
|
||
- [ ] **Step 1: 写失败测试(Dialect,不需要 DB)**
|
||
|
||
`dialect_test.go`:
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes"
|
||
)
|
||
|
||
func TestDialectLockForUpdate(t *testing.T) {
|
||
if got := codes.DialectMySQL.LockForUpdate(); got != "FOR UPDATE" {
|
||
t.Errorf("mysql LockForUpdate = %q", got)
|
||
}
|
||
if got := codes.DialectSQLite.LockForUpdate(); got != "" {
|
||
t.Errorf("sqlite LockForUpdate = %q, want empty (single-writer engine)", got)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v`
|
||
Expected: 编译失败——`Dialect`/`DialectMySQL`/`DialectSQLite` 未定义。
|
||
|
||
- [ ] **Step 3: 写 dialect.go**
|
||
|
||
```go
|
||
package codes
|
||
|
||
// Dialect captures the one SQL construct this library needs that differs
|
||
// between engines: pessimistic row locking inside a transaction. SQLite is a
|
||
// single-writer engine — a BEGIN'd write transaction already serializes
|
||
// concurrent writers, so no explicit lock clause is needed there.
|
||
type Dialect string
|
||
|
||
const (
|
||
DialectMySQL Dialect = "mysql"
|
||
DialectSQLite Dialect = "sqlite"
|
||
)
|
||
|
||
func (d Dialect) LockForUpdate() string {
|
||
if d == DialectMySQL {
|
||
return "FOR UPDATE"
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `cd ~/code/codes && go test ./... -run TestDialectLockForUpdate -v` → PASS。
|
||
|
||
- [ ] **Step 5: 写 migration SQL(mysql + sqlite)**
|
||
|
||
`migrations/mysql/000001_codes.up.sql`:
|
||
```sql
|
||
CREATE TABLE codes_batches (
|
||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
channel VARCHAR(32) NOT NULL,
|
||
entitlement_kind VARCHAR(32) NOT NULL,
|
||
entitlement_payload JSON NOT NULL,
|
||
created_by VARCHAR(64) NOT NULL,
|
||
note VARCHAR(255) NULL,
|
||
created_at DATETIME NOT NULL
|
||
);
|
||
|
||
CREATE TABLE codes (
|
||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
code_hash CHAR(64) NOT NULL,
|
||
batch_id BIGINT UNSIGNED NOT NULL,
|
||
entitlement_kind VARCHAR(32) NOT NULL,
|
||
entitlement_payload JSON NOT NULL,
|
||
status VARCHAR(16) NOT NULL DEFAULT 'unused',
|
||
redeemed_by VARCHAR(128) NULL,
|
||
redeemed_at DATETIME NULL,
|
||
void_reason VARCHAR(255) NULL,
|
||
created_at DATETIME NOT NULL,
|
||
UNIQUE KEY uq_codes_code_hash (code_hash),
|
||
KEY idx_codes_status (status),
|
||
KEY idx_codes_batch (batch_id)
|
||
);
|
||
|
||
CREATE TABLE codes_audit_log (
|
||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
actor VARCHAR(128) NOT NULL,
|
||
action VARCHAR(32) NOT NULL,
|
||
target VARCHAR(128) NOT NULL,
|
||
meta JSON NULL,
|
||
at DATETIME NOT NULL
|
||
);
|
||
```
|
||
|
||
`migrations/mysql/000001_codes.down.sql`:
|
||
```sql
|
||
DROP TABLE codes_audit_log;
|
||
DROP TABLE codes;
|
||
DROP TABLE codes_batches;
|
||
```
|
||
|
||
`migrations/sqlite/000001_codes.up.sql`:
|
||
```sql
|
||
CREATE TABLE codes_batches (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
channel TEXT NOT NULL,
|
||
entitlement_kind TEXT NOT NULL,
|
||
entitlement_payload TEXT NOT NULL,
|
||
created_by TEXT NOT NULL,
|
||
note TEXT NULL,
|
||
created_at DATETIME NOT NULL
|
||
);
|
||
|
||
CREATE TABLE codes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
code_hash TEXT NOT NULL UNIQUE,
|
||
batch_id INTEGER NOT NULL,
|
||
entitlement_kind TEXT NOT NULL,
|
||
entitlement_payload TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'unused' CHECK (status IN ('unused','redeemed','void')),
|
||
redeemed_by TEXT NULL,
|
||
redeemed_at DATETIME NULL,
|
||
void_reason TEXT NULL,
|
||
created_at DATETIME NOT NULL
|
||
);
|
||
CREATE INDEX idx_codes_status ON codes (status);
|
||
CREATE INDEX idx_codes_batch ON codes (batch_id);
|
||
|
||
CREATE TABLE codes_audit_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
actor TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT NOT NULL,
|
||
meta TEXT NULL,
|
||
at DATETIME NOT NULL
|
||
);
|
||
```
|
||
|
||
`migrations/sqlite/000001_codes.down.sql`:
|
||
```sql
|
||
DROP TABLE codes_audit_log;
|
||
DROP TABLE codes;
|
||
DROP TABLE codes_batches;
|
||
```
|
||
|
||
- [ ] **Step 6: 写失败测试(ApplyMigrations)**
|
||
|
||
`migrations_test.go`:
|
||
```go
|
||
package codes_test
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"testing"
|
||
|
||
"github.com/wangjia/codes"
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
func TestApplyMigrationsSQLite(t *testing.T) {
|
||
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
||
if err != nil {
|
||
t.Fatalf("open: %v", err)
|
||
}
|
||
db.SetMaxOpenConns(1) // :memory:+shared cache: one connection = one schema
|
||
defer db.Close()
|
||
|
||
if err := codes.ApplyMigrations(context.Background(), db, codes.DialectSQLite); err != nil {
|
||
t.Fatalf("ApplyMigrations: %v", err)
|
||
}
|
||
|
||
for _, table := range []string{"codes_batches", "codes", "codes_audit_log"} {
|
||
var name string
|
||
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
|
||
if err != nil {
|
||
t.Errorf("table %s missing: %v", table, err)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 跑测试确认失败**
|
||
|
||
Run: `cd ~/code/codes && go get modernc.org/sqlite@latest && go test ./... -run TestApplyMigrationsSQLite -v`
|
||
Expected: 编译失败——`ApplyMigrations` 未定义。
|
||
|
||
- [ ] **Step 8: 写 migrations.go**
|
||
|
||
```go
|
||
package codes
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"embed"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
//go:embed migrations/mysql/*.sql migrations/sqlite/*.sql
|
||
var migrationsFS embed.FS
|
||
|
||
// ApplyMigrations executes every *.up.sql file for dialect, in lexical
|
||
// order, inside one transaction. It's a zero-dependency convenience for
|
||
// hosts that don't already run golang-migrate; hosts that do (e.g.
|
||
// pangolin) can instead point their own migrate runner at the embedded
|
||
// files under "migrations/<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: 全部任务的测试(Task1–8)一并跑绿。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd ~/code/codes
|
||
git add webhook.go webhook_test.go
|
||
git commit -m "feat: webhook 灌码(pay-contract 式通用 HMAC 签名 + 去重 + 通用权益负载 → Mint)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage(用户 P7 范围逐条核对):**
|
||
- 独立 Go module/包 + 选型理由 → 「部署与模块选型」章节 ✓(独立仓 `github.com/wangjia/codes`,非 pay 子包/非 pangolin 内嵌,理由:依赖隔离 + 设计文档 §9.1 方案 A)。
|
||
- 码模型 + 状态机 + 生成器(Crockford Base32+校验位,crypto/rand)+ 哈希存储(SHA-256,明文不落库) → Task1(状态机/错误)+ Task2(生成器,移植 pangolin idgen)+ Task3(Store,`code_hash` 唯一约束,明文只在 `Mint`/webhook 请求体经过内存,从不写库/写日志)✓。
|
||
- 兑换事务骨架 `Redeem(ctx, tx, codeHash, grantFn)` → Task5,签名对齐用户给出的形态(`Redeem[T any](ctx, store, tx, codeHash, redeemerRef, grant)`——多出的 `store`/`redeemerRef` 是必要的接收者与幂等键,`grant` 即 `grantFn`),锁(`FindByHashForUpdate`+dialect)、CAS(`MarkRedeemed` 的 `AND status='unused'`)、幂等(同 `redeemerRef` 短路)三者齐全,`TestRedeemGrantFailureRollsBackWholeTx` 专门验证"宿主本地事务"的原子性承诺 ✓。
|
||
- 批次 + 审计 → Task3(`CreateBatch`/`WriteAudit`)+ Task4(`Mint` 内的批次生成)✓。
|
||
- 发卡店 webhook 灌码(HMAC) → Task8,选用 pay-contract 既有多产品签名惯例(而非原样照抄 pangolin 的 body-only HMAC),偏离已在 Task8 开头注明理由 ✓。
|
||
- 通用权益描述符(不硬编码 plan+days,支持时长/额度) → Task1 `Entitlement{Kind,Payload}` + `DurationPayload`/`QuotaPayload`,对应设计文档 §12 的两种 entitlement 形态 ✓。
|
||
- Redis 限流(可选,标注) → Task6(接口 + 零依赖默认值)+ Task7(`redisx` 子包,唯一 import Redis 的地方,不引用就不产生依赖)✓,明确标注"可选"。
|
||
|
||
**未覆盖 / 有意排除(见 Global Constraints 末尾"本计划范围之外"):** admin 批次列表/CSV 导出(pangolin 已有实现,宿主可自行在 `Store` 基础方法上拼,不进本库以保持库精简);pangolin/jiu 迁移到 import 本库(独立后续任务,本计划只交付库本身);独立服务化方案 B、reseller 门户、优惠券变体(设计文档标注 later)。这些都不是用户列出的 P7 范围条目。
|
||
|
||
**Placeholder scan:** 全部 8 个任务均给出完整 Go 实现 + 完整测试,无 TODO/占位;唯一需要人工确认的是 Task1 Step 0 的 Gitea 建仓(网络操作,不属于代码)。
|
||
|
||
**Type consistency 关键点:**
|
||
- `Entitlement`(Task1)→ 贯穿 `Code.Entitlement`(Task3)、`MintRequest.Entitlement`/`MintResult.Entitlement`(Task4)、`GrantFunc[T]` 的 `code Code` 参数(Task5)、`MintPayload.Entitlement`(Task8),全程同一类型,无中途转换丢信息。
|
||
- `Status`(Task1)三态与 `store.go`/`redeem.go` 的 SQL `status` 列字符串值(`'unused'`/`'redeemed'`/`'void'`)逐字一致。
|
||
- `Redeem`/`GuardedRedeem` 的泛型签名 `[T any]` 在 Task5→Task6(`GuardedRedeem` 包一层)→ 测试(`grantDuration(days int) GrantFunc[time.Time]`)全程一致,未出现 `any` 断言噩梦。
|
||
- `Dialect`(Task3)贯穿 `Store`/`FindByHashForUpdate`/`ApplyMigrations`,mysql/sqlite 两分支的 SQL 均已给出(mysql 分支未在 CI 用真实 MySQL 验证——本计划测试全跑 sqlite `:memory:`,mysql 语法只做静态审阅;这与 pangolin 现有 `run_mysql_test.sh` 需要 docker 的取舍一致,MySQL 集成验证留给宿主接入时用真实 MySQL 跑一次 `ApplyMigrations`)。
|
||
|
||
**一处需要宿主在真正 import 时注意(非本库缺陷,写进这里防止遗忘)**:`Redeem`/`GuardedRedeem` 要求 `tx` 与 `store` 底层 `*sql.DB` 同源,且宿主的 `grant` 回调里的写操作必须使用**传入的同一个 `tx`**(而不是另开一个 `store.db.Exec(...)`)——否则会静默丢失"本地事务"的原子性保证,退化成两个独立事务。这一点在 `GrantFunc` 的类型签名(强制传入 `*sql.Tx` 而非允许拿到 `*sql.DB`)里已经做了预防,但仍建议后续给宿主的接入文档里用大写加粗强调一次。
|
||
|
||
---
|
||
|
||
## 后续阶段(接续 P1 末尾"后续阶段"列表)
|
||
|
||
- P2–P6:见 `2026-07-10-pay-v2-p1-core-model.md` 末尾(Provider 抽象/首批渠道/退款/多账户路由/对账 job)。
|
||
- **P7(本计划)**:codes 共享库,独立仓 `github.com/wangjia/codes`,不改动 pay/pangolin/jiu 任何现有代码。
|
||
- **P8(later)**:订阅/recurring(4 类 kind)、拒付 chargeback(见设计文档 §5.1)。
|
||
- **未编号后续(brain todo,不在 pay 的 P1–P8 序列里)**:pangolin `server/internal/codes/` 迁移为 import `github.com/wangjia/codes`(替换 `plan_id+duration_days` 为 `Entitlement`,`grantFn` 实现 pangolin 的订阅叠加算法);jiu 门店 license 接入同一个库;codes 优惠券变体(设计文档 §7)。均待本计划落地验证后再排期。
|