Files
pangolin/docs/superpowers/plans/2026-07-10-pangolin-codes-lib-integration.md
T

71 KiB

pangolin · codes 共享库接入(激活码逻辑迁移)Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 - [ ] checkbox 追踪。

设计文档(全景蓝图): pay 仓 ~/code/pay/docs/pay-v2-unified-gateway-design.html §9(codes 兑换内核边界)/ §9.1(部署选型 A:共享库嵌入宿主进程,权益表与码表同库同事务)。上游计划: pay 仓 ~/code/pay/docs/superpowers/plans/2026-07-10-pay-v2-p7-codes-lib.md(P7,已完成——库已建成并 push 到 ssh://git@git.51yanmei.com:2222/wangjia/codes.git,HEAD=c772d525679441d0579df6e309deba2d60254ab5)。宿主约束: 本仓 CLAUDE.md「数据层:多数据库」节(DB_DRIVER=mysql|sqlite 双方言、迁移分两套、golang-migrate、禁 MySQL 专属构造)。

Goal: 把 pangolin server/internal/codes/ 的激活码逻辑(铸码/兑换状态机/webhook 灌码/防爆破锁)迁移到共享库 github.com/wangjia/codes 上,pangolin 作为该库的第一个真实宿主完成验证。迁移是验证优先的:全部工作在新分支 feat/codes-lib-integration 上进行,以本地验证(build + 全量测试 + 行为等价测试)收尾,不合并、不部署——合并等用户验收后另行处理。

Architecture: internal/codes保留原 import 路径与全部公共 API(NewStore/NewService/RedeemRequest/BatchRequest/NewRedeemHandler/NewWebhookHandler/CtxKeyUserID/PlanCode/BatchChannel/ExportCSV…),内部改为薄适配层:兑换走 codes.GuardedRedeem(宿主事务 + grant 回调在同一个 *sql.Tx 里复刻现有订阅叠加算法),铸码走 codes.Mint,防爆破走 codes/redisx.RateLimiter,webhook 保留现有 X-Pangolin-* HMAC 协议作为薄壳直调库的铸码原语。plan_id+duration_days 语义映射为 codes.Entitlement{Kind:duration, Payload:{plan:"<plans.code>", days:N}}——库保持通用,适配在 pangolin 侧。旧表更名封存 + 库自建新表 + Go 侧幂等回填,双方言(mysql/sqlite)全覆盖。


关键决策(已定,含依据)

决策点 结论 依据
模块引入方式 require github.com/wangjia/codes v0.0.0-20260710045055-c772d5256794(pin 到 gitea HEAD c772d52 的伪版本),不用 replace 用 replace 指 ~/code/codes 会让 CI(容器内无此路径)必挂,且掩盖「真实宿主拉真实版本」的验证目标。代价:github.com/wangjia/codes 实际托管在 gitea,需一次性 git insteadOf 精确映射 + GOPRIVATE(见 Task 0;已实测无此配置时 go list -m 直接失败)
schema 策略 方案 (a):旧表更名封存(codeslegacy_codescode_batcheslegacy_code_batches,pangolin 迁移 000020)→ 库自建新表(codes.ApplyMigrations,由 cmd/migrate up 在 golang-migrate 之后调)→ Go 侧幂等回填。旧表数据封存不删(retire 留到验收后的 cleanup) 库拥有自己的 schema(嵌入迁移文件,表名 codes/codes_batches/codes_audit_log)——库的 codes 表与 pangolin 现有 codes 表同名冲突,必须先更名让位。改库适配旧表不可行(库不暴露表名定制)
回填形态 Go 函数(internal/codes/backfill.go),非 SQL 迁移文件 回填要做 plan_id→plans.code 字符串映射 + JSON payload 构造 + redeemed_by BIGINT"user:<id>" 格式化,SQL 双方言各写一套 JSON 拼接极易漂移;Go 一份代码天然双方言、可单测、可幂等重跑
webhook 保留 pangolin 现有端点与 HMAC 协议(POST /v1/webhook/store/codes,X-Pangolin-Signature: sha256=<hex> 仅对 body 做 MAC),改为薄壳直调库的铸码原语(CreateBatchTx+CreateCodeTx 单事务)。不采用库的 NewWebhookHandler(pay-contract 式 system\nts\nnonce\nbody MAC) 调用方是外部发卡店(用 WEBHOOK_SECRET 按旧协议签名;仓内 grep X-Pangolin-Signature 只有测试命中,说明签名方实现在仓外)。验证分支上换签名协议 = 强迫外部调用方同步升级,是 breaking change,超出验证范围。后续若接 pay v2 生态再在新路径挂库 handler、与发卡店协调切换(标注于 Task 6)
admin 面(batch 列表/CSV/void) 留在宿主侧(P7 计划明确排除进库),ListBatches/VoidBatch SQL 改指新表,行为不变 admin/services.go 已经隔着 CodesService 接口消费,改动被封在 internal/codes
审计双写 grant 回调内继续写旧 audit_log(admin 审计页数据源);库自身在同事务写 codes_audit_log(增量,不冲突) admin/store.go:188,197audit_log——不双写则 admin 审计页丢兑换记录,破坏行为等价

已知行为差异(有意为之,验收时说明)

  1. 防爆破 Redis key 变化:redeem:fail:<uid>redeem:fail:user:<uid>(redisx.RateLimiter 以 redeemerRef 为 key)。部署瞬间在途失败计数清零,无安全影响。
  2. 幂等重放现在会清零失败计数(GuardedRedeem 对 Idempotent 成功也 Reset;旧代码幂等路径提前 return 不清零,service.go:162-171)。合法用户重放清计数,无害。
  3. MarkRedeemed 有 CAS 校验了(库 store.go:171-200 检查 RowsAffected;旧实现 store.go:159-168 不检查,竞态输家会静默双发订阅)。严格更安全。
  4. CreateBatch 全成或全无(库 Mint 单事务;旧实现 batch 行先落库、后续失败留孤儿 batch,service.go:342-345)。严格更干净。webhook 同理(旧 I2:重复码留孤儿 batch,webhook.go:145-158;新壳单事务无孤儿)。
  5. 依赖版本抬升:modernc.org/sqlite 1.18.1→1.38.2、go-redis/v9 9.20.1→9.21.0(MVS 取库要求的高版本)。全量测试守门(Task 9)。

Global Constraints

  • 分支:所有 commit 落在 feat/codes-lib-integration(从 main 切出)。终点是本地验证通过,不 merge / 不 push 部署 / 不动 pangolin1
  • 公共 API 冻结:internal/codes 对外的类型与构造函数签名一个不改——cmd/server/main.go:296-300cmd/codegen/main.go:81-87internal/admin/router.go:56-57internal/admin/services.go:53-110internal/auth/middleware.go:45internal/usage/handler.go:19 六处调用点零改动、原样编译
  • 双方言铁律(本仓 CLAUDE.md 数据层节):新增 SQL 一律 ? 占位、时间 Go 端算好传参、不用 NOW()/FIELD()/UTC_TIMESTAMP();pangolin 自己的迁移双方言各一套(server/migrations/{mysql,sqlite}/000020_*);行为测试优先 sqlite :memory:(免 docker、进普通 CI),mysql 走既有 -tags integration
  • 明文码不落库不进日志(两边库都遵守,迁移不得破坏)。
  • 每个 Task 结束:cd /Users/wangjia/code/pangolin/server && go build ./... && go vet ./... && go test ./... 全绿再 commit(遵循「每刀提交」惯例)。
  • 不改机器/CI 配置:Task 0 的 git 全局配置仅限本开发机一条精确映射;CI runner(nas)主机的同款配置是用户协调项(见 Task 0 Step 4 与 Self-Review),本计划不代做。
  • 范围外:客户端改动、部署、删除 legacy 表、webhook 协议切换到 pay-contract、jiu/dudu 接入。

Task 0: Preflight — 现状锚点 + 分支 + 模块接线

目的:把后续任务依赖的「现状事实」钉死(全部已在 2026-07-10 逐文件核实,执行者可抽查),建分支,打通 go get github.com/wangjia/codes

现状锚点(cite file:line,执行前抽查 2-3 条确认没漂移)

旧表(迁移对象)

  • server/migrations/mysql/000003_codes.up.sql / server/migrations/sqlite/000003_codes.up.sql:code_batches(channel ENUM/CHECK store|tg|line|manual, created_by, note, created_at)+ codes(code_hash CHAR(64) UNIQUE, plan_id FK plans, duration_days INT, batch_id FK, status ENUM, redeemed_by BIGINT(用户 id), redeemed_at;无 created_at、无 void_reason)。
  • 当前迁移最高号 000019(server/internal/store/sqlite_migrate_test.go:33 断言 version = 19)→ 本计划新增 000020
  • pangolin 已存哈希:codes.code_hash = SHA-256 hex(internal/codes/generator.go:33-37);库的 internal/idgen 正是从 pangolin internal/idgen 移植(同 16 字符 Crockford Base32 + 校验字符、同 HashCode)→ code_hash 原样 passthrough,零转换

旧实现(行为基准)

  • 兑换:server/internal/codes/service.go:116-226(Redeem:锁检查→canonicalize→ReadCommitted 事务→SELECT FOR UPDATE→幂等短路→MarkRedeemed→applySubscription→audit→commit→清失败计数)。
  • 时长叠加算法(grant 回调必须逐字复刻):service.go:235-287 applySubscription —— 同 plan 有活跃订阅 → 取该 plan 最晚到期的一条,expires_at = max(expires_at, now) + days(store.go:214-233 ExtendSubscription,Go 端算日期);无同 plan 活跃订阅 → 新建一行,expires_at = max(now, latestSamePlan) + days,source='code'(store.go:238-261 CreateSubscription)。
  • 防爆破:service.go:60-102(Redis key redeem:fail:<uid>,INCR+EXPIRE,val >= failMax 即锁;rdb==nil 时全部 no-op)。
  • 审计:service.go:199-208audit_log(actor=user:<id>,action=redeem,target=code_hash:<前16>...,meta={plan,duration_days,batch_id,sub_id}),失败不中断事务
  • webhook:server/internal/codes/webhook.go:72-167(挂载于 cmd/server/main.go:363 POST /v1/webhook/store/codes;HMAC 仅对 body,X-Pangolin-Signature: sha256=<hex>,webhook.go:172-195;时间窗 ±tolerance webhook.go:199-218;nonce Redis SETNX 先存后铸,webhook.go:103-113;payload {code,plan,duration_days,note})。仓内无调用方代码(grep X-Pangolin-Signature 仅测试文件命中)→ 调用方为外部发卡店。
  • admin 面:internal/codes/admin_support.go:25-70 ListBatches(code_batches LEFT JOIN codes 聚合计数)、:74-85 VoidBatch;经 internal/admin/services.goCodesService 接口消费。
  • CSV:internal/codes/export.go(纯内存转换,不碰表,零改动)。

装配点(必须原样编译)

  • cmd/server/main.go:296-300(NewStore/NewService/NewRedeemHandler/NewWebhookHandler)、:363(webhook 路由)。
  • cmd/codegen/main.go:81-87(NewStore + NewService(store, nil, …) + CreateBatch —— rdb 为 nil 必须继续可用)。
  • internal/admin/router.go:56-57internal/auth/middleware.go:45(codes.CtxKeyUserID)、internal/usage/handler.go:19

测试资产(守门物)

  • internal/codes/generator_test.go(无 tag,纯算法)——委托给库后必须原样通过。
  • internal/codes/webhook_test.go(无 tag,签名/时间戳/nonce 拒绝路径,不碰 DB)——薄壳保留协议后必须原样通过。
  • internal/codes/service_test.go(//go:build integration,mysql testcontainers)——applySchema(:95-)自建旧表 DDL,需改为「plans/subscriptions/users/audit_log DDL + codes.ApplyMigrations」(Task 8)。
  • internal/store/sqlite_migrate_test.go:33,38(version=19、表清单含 code_batches,codes)与 internal/store/sqlite_stores_test.go:193-260(TestSQLite_CodesRedeemFlow 直调旧 Store 方法)——Task 1 / Task 7 更新。
  • CI:.gitea/workflows/ci.yml:123-143(go build+vet+test 在 golang:1.25 容器,挂宿主缓存 $HOME/.cache/pangolin-ci/gomod)、:172-183(integration 直接跑在 runner 宿主机)→ 两处都要能解析新依赖(见 Step 4)。

库 API(已逐文件核实,任务代码按此签名写)

  • codes.ApplyMigrations(ctx, db *sql.DB, dialect codes.Dialect) error;codes.DialectMySQL/codes.DialectSQLite
  • codes.NewStore(db *sql.DB, dialect codes.Dialect) *codes.Store;Store 方法:BeginTx(ctx)CreateBatchTx(ctx, tx, channel string, ent Entitlement, createdBy, note string) (int64, error)CreateCodeTx(ctx, tx, codeHash string, batchID int64, ent Entitlement) errorFindByHashVoidWriteAudit
  • codes.Mint(ctx, store, MintRequest{Channel string; Entitlement; Count int; CreatedBy, Note string}) (*MintResult{BatchID; Codes []string; …}, error)
  • codes.Redeem[T any](ctx, store, tx, codeHash, redeemerRef string, grant GrantFunc[T]) (*RedeemResult[T]{Idempotent bool; Code; Grant T}, error);GrantFunc[T] func(ctx, tx *sql.Tx, code Code) (T, error);codes.GuardedRedeem[T](ctx, store, limiter RateLimiter, tx, hash, ref, grant)(limiter 传 nil → Noop)。
  • codes.NewDurationEntitlement(plan string, days int) (Entitlement, error);Entitlement.DecodeDuration() (DurationPayload{Plan string; Days int}, error)
  • 哨兵错误:codes.ErrDuplicate/ErrCodeNotFound/ErrCodeRedeemed/ErrCodeVoid/ErrInvalidCode/ErrLocked/ErrNotVoidable
  • codes.GenerateCode() (string, error) / codes.Canonicalize(string) (string, error) / codes.Hash(string) string
  • redisx.NewRateLimiter(rdb *redis.Client, prefix string, failMax int, lockDur time.Duration) *RateLimiter(实现 codes.RateLimiter;Allowedval < failMax)/ redisx.NewNonceChecker(rdb, prefix) *NonceChecker(SeenOrStore(ctx, nonce, ttl) (seen bool, err error),SETNX)。
  • 库表结构(migrations/{mysql,sqlite}/000001_codes.up.sql):codes_batches(id, channel, entitlement_kind, entitlement_payload, created_by, note, created_at);codes(id, code_hash UNIQUE, batch_id, entitlement_kind, entitlement_payload, status, redeemed_by VARCHAR/TEXT, redeemed_at, void_reason, created_at);codes_audit_log(id, actor, action, target, meta, at);自带追踪表 codes_schema_migrations

Steps

  • Step 1: 切分支
cd /Users/wangjia/code/pangolin
git checkout main && git pull
git checkout -b feat/codes-lib-integration
  • Step 2: 本机一次性 go 模块解析配置(精确映射单仓,不映射整个 github.com/wangjia/,避免误伤真 GitHub 仓库)
git config --global url."ssh://git@git.51yanmei.com:2222/wangjia/codes.git".insteadOf "https://github.com/wangjia/codes"
go env -w GOPRIVATE=github.com/wangjia/codes
# 验证可解析(应打印 v0.0.0-20260710045055-c772d5256794 或更新):
cd /Users/wangjia/code/pangolin/server && go list -m github.com/wangjia/codes@c772d525679441d0579df6e309deba2d60254ab5
  • Step 3: pin 依赖
cd /Users/wangjia/code/pangolin/server
go get github.com/wangjia/codes@c772d525679441d0579df6e309deba2d60254ab5
go mod tidy
go build ./... && go test ./...   # 依赖抬升(modernc.org/sqlite 1.38.2)后的全量回归,先于任何代码改动

go test 因 sqlite 抬升出现回归:停下修复/上报,不带病继续。

  • Step 4: CI 协调项(登记,不执行)——在 PR 描述/交付说明里写明:CI runner(nas)宿主机需要同款 Step 2 两条配置 + 一次 GOMODCACHE="$HOME/.cache/pangolin-ci/gomod" go mod download github.com/wangjia/codes(预热容器挂载的模块缓存;go.sum 已入库,容器内命中缓存后无需再联网)。未配置前,推分支的 CI go jobs 会失败——这是预期,验收时说明。

  • Step 5: commit

git add server/go.mod server/go.sum
git commit -m "feat(server): 接入 github.com/wangjia/codes 共享库依赖(pin c772d52)(#codes-lib)"

Task 1: 迁移 000020 — 旧码表更名封存(双方言)

Files:

  • Create: server/migrations/mysql/000020_codes_lib_legacy_rename.up.sql / .down.sql
  • Create: server/migrations/sqlite/000020_codes_lib_legacy_rename.up.sql / .down.sql
  • Modify: server/internal/store/sqlite_migrate_test.go

Interfaces: 无 Go 接口;schema 事件:codeslegacy_codescode_batcheslegacy_code_batches(为库的同名 codes 表让位)。

  • Step 1: 先改测试(失败先行) —— sqlite_migrate_test.go:
    • :33 附近 if v != 19if v != 20(错误消息同步)。
    • :38 表清单里 "code_batches", "codes""legacy_code_batches", "legacy_codes"
cd /Users/wangjia/code/pangolin/server && ./run_sqlite_test.sh   # 预期 FAIL(version=19、legacy 表不存在)
  • Step 2: 写迁移

server/migrations/mysql/000020_codes_lib_legacy_rename.up.sql:

-- 旧激活码表更名封存:共享库 github.com/wangjia/codes 拥有自己的 `codes` 表(同名冲突),
-- 新表由 codes.ApplyMigrations 建(cmd/migrate up 内),数据经 Go 侧幂等回填(internal/codes/backfill.go)。
-- legacy_* 只封存不删,retire 留到验收后的 cleanup。
RENAME TABLE codes TO legacy_codes;
RENAME TABLE code_batches TO legacy_code_batches;

.down.sql:

RENAME TABLE legacy_code_batches TO code_batches;
RENAME TABLE legacy_codes TO codes;

server/migrations/sqlite/000020_codes_lib_legacy_rename.up.sql:

-- 同 mysql 版注释;sqlite ≥3.25 的 RENAME 会自动更新其他表 FK 引用。
ALTER TABLE codes RENAME TO legacy_codes;
ALTER TABLE code_batches RENAME TO legacy_code_batches;

.down.sql:

ALTER TABLE legacy_code_batches RENAME TO code_batches;
ALTER TABLE legacy_codes RENAME TO codes;
  • Step 3: 验证 + commit
cd /Users/wangjia/code/pangolin/server && ./run_sqlite_test.sh && go build ./... && go test ./internal/store/
git add server/migrations server/internal/store/sqlite_migrate_test.go
git commit -m "feat(server): 迁移000020 旧码表更名封存 legacy_*(为 codes 库表让位)(#codes-lib)"

注意:TestSQLiteMigrateUpDown 同时验证 down 干净回滚——000020 down 是纯 RENAME 回去,天然可逆。库自建的 codes_* 新表不归 golang-migrate 管(down 不删它们),这是有意的:库拥有自己的 schema 生命周期。


Task 2: cmd/migrate 接线 — golang-migrate 之后跑库迁移

Files:

  • Modify: server/internal/store/migrate.go(新增 ApplyCodesLibMigrations)
  • Modify: server/cmd/migrate/main.go(up 分支追加调用)
  • Test: server/internal/store/codes_lib_migrate_test.go(新)

Interfaces:

  • Produces: func ApplyCodesLibMigrations(ctx context.Context, database *sql.DB, driver string) error

  • Step 1: 失败测试 server/internal/store/codes_lib_migrate_test.go:

package store_test

import (
	"context"
	"testing"

	"github.com/wangjia/pangolin/server/internal/config"
	"github.com/wangjia/pangolin/server/internal/store"
)

// TestApplyCodesLibMigrations verifies the shared codes library's tables are
// created on top of a fully-migrated pangolin sqlite DB (post-000020, the
// legacy tables are renamed away so the lib's `codes` table name is free).
func TestApplyCodesLibMigrations(t *testing.T) {
	ctx := context.Background()
	db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer db.Close()
	if err := store.MigrateUp(db, "sqlite"); err != nil {
		t.Fatalf("MigrateUp: %v", err)
	}

	if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil {
		t.Fatalf("ApplyCodesLibMigrations: %v", err)
	}
	// 幂等:重复调用不报错(库自带 codes_schema_migrations 追踪表)。
	if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil {
		t.Fatalf("ApplyCodesLibMigrations 2nd: %v", err)
	}

	for _, tbl := range []string{"codes", "codes_batches", "codes_audit_log", "codes_schema_migrations"} {
		var name string
		if err := db.QueryRow(
			`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl,
		).Scan(&name); err != nil {
			t.Errorf("lib table %q missing: %v", tbl, err)
		}
	}
}
cd /Users/wangjia/code/pangolin/server && go test ./internal/store/ -run TestApplyCodesLibMigrations   # FAIL:undefined
  • Step 2: 实现 —— server/internal/store/migrate.go 追加(import 加 "context"libcodes "github.com/wangjia/codes"):
// ApplyCodesLibMigrations creates/updates the shared codes library's own
// tables (codes / codes_batches / codes_audit_log, tracked by the lib's
// codes_schema_migrations). Runs AFTER pangolin's golang-migrate set — 000020
// must have renamed the legacy `codes` table away first. Idempotent.
func ApplyCodesLibMigrations(ctx context.Context, database *sql.DB, driver string) error {
	d := libcodes.DialectMySQL
	if db.Normalize(driver) == "sqlite" {
		d = libcodes.DialectSQLite
	}
	return libcodes.ApplyMigrations(ctx, database, d)
}

server/cmd/migrate/main.gocase "up":store.MigrateUp 成功后追加(import 加 "context"):

	case "up":
		if err := store.MigrateUp(db, driver); err != nil {
			log.Fatalf("migrate up: %v", err)
		}
		if err := store.ApplyCodesLibMigrations(context.Background(), db, driver); err != nil {
			log.Fatalf("migrate up (codes lib): %v", err)
		}
		log.Println("migrate: up — done")
  • Step 3: 验证 + commit
cd /Users/wangjia/code/pangolin/server && go build ./... && go test ./internal/store/ && bash ../scripts/e2e-smoke.sh   # e2e 冒烟里 migrate up 会真跑到新代码
git add server/internal/store/migrate.go server/internal/store/codes_lib_migrate_test.go server/cmd/migrate/main.go
git commit -m "feat(server): cmd/migrate up 接入 codes 库自建迁移(ApplyCodesLibMigrations)(#codes-lib)"

(若 e2e-smoke 本地跑不了 openssl/python 依赖,允许跳过、在 Task 9 终验时补;go test 必须绿。)


Task 3: 幂等回填 — legacy_* → 库新表

Files:

  • Create: server/internal/codes/backfill.go
  • Test: server/internal/codes/backfill_test.go(新,无 build tag,sqlite :memory:)
  • Modify: server/cmd/migrate/main.go(up 再追加回填调用)

Interfaces:

  • Produces: func BackfillLegacy(ctx context.Context, database *sql.DB) (int, error) —— 返回本次迁移的 code 行数;legacy 表不存在(全新库)→ (0, nil);重复跑 → (0, nil)

映射规格(硬性):

legacy 新表 规则
legacy_code_batches.id codes_batches.id 保留原 id(admin 面/审计里的 batch_id 引用不断裂;两方言都允许对 AUTO_INCREMENT/AUTOINCREMENT 列显式插 id,后续自增自动越过)
legacy_code_batches.channel codes_batches.channel 原样(新列是开放字符串)
—(legacy batch 无权益) codes_batches.entitlement_* 取该 batch 第一条 code 的 plan/days 合成 {"plan":"<plans.code>","days":N};空 batch 合成 {"plan":"unknown","days":0}(batch 级权益仅信息性,兑换只读 code 级)
legacy_codes.plan_id + duration_days codes.entitlement_kind='duration' + payload plan = JOIN plans.code 得到的字符串(free/pro/team)
legacy_codes.code_hash codes.code_hash passthrough(同一 idgen 血统,零转换);幂等键
legacy_codes.status codes.status 字符串同值 passthrough(unused/redeemed/void)
legacy_codes.redeemed_by BIGINT codes.redeemed_by TEXT 非 NULL → "user:<id>"(与新兑换路径的 redeemerRef 同格式,幂等判定才能衔接);NULL → NULL
—(legacy code 无 created_at) codes.created_at 取所属 batch 的 created_at
codes.void_reason NULL
  • Step 1: 失败测试 server/internal/codes/backfill_test.go:
package codes_test

import (
	"context"
	"database/sql"
	"testing"
	"time"

	"github.com/wangjia/pangolin/server/internal/codes"
	"github.com/wangjia/pangolin/server/internal/config"
	"github.com/wangjia/pangolin/server/internal/store"
)

func openMigratedSQLite(t *testing.T) *sql.DB {
	t.Helper()
	db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	if err := store.MigrateUp(db, "sqlite"); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil {
		t.Fatalf("lib migrate: %v", err)
	}
	return db
}

func TestBackfillLegacy(t *testing.T) {
	ctx := context.Background()
	db := openMigratedSQLite(t)

	// 造 legacy 数据(000020 之后 legacy 表空壳存在,直插即可)。plans 已 seed(000007)。
	var proID int64
	if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
		t.Fatalf("plan: %v", err)
	}
	batchAt := time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC)
	if _, err := db.Exec(
		`INSERT INTO legacy_code_batches (id, channel, created_by, note, created_at) VALUES (7, 'manual', 'admin:1', 'may batch', ?)`,
		batchAt); err != nil {
		t.Fatalf("seed batch: %v", err)
	}
	redeemedAt := time.Date(2026, 6, 2, 9, 30, 0, 0, time.UTC)
	seed := []struct {
		id         int64
		hash       string
		status     string
		redeemedBy any
		redeemedAt any
	}{
		{101, "hash-unused-000000000000000000000000000000000000000000000000000001", "unused", nil, nil},
		{102, "hash-redeemed-0000000000000000000000000000000000000000000000000002", "redeemed", int64(42), redeemedAt},
		{103, "hash-void-00000000000000000000000000000000000000000000000000000003", "void", nil, nil},
	}
	for _, c := range seed {
		if _, err := db.Exec(
			`INSERT INTO legacy_codes (id, code_hash, plan_id, duration_days, batch_id, status, redeemed_by, redeemed_at)
			 VALUES (?, ?, ?, 30, 7, ?, ?, ?)`,
			c.id, c.hash, proID, c.status, c.redeemedBy, c.redeemedAt); err != nil {
			t.Fatalf("seed code %d: %v", c.id, err)
		}
	}

	n, err := codes.BackfillLegacy(ctx, db)
	if err != nil {
		t.Fatalf("BackfillLegacy: %v", err)
	}
	if n != 3 {
		t.Fatalf("migrated = %d, want 3", n)
	}

	// batch:id 保留 + 权益合成自第一条 code。
	var kind, payload string
	if err := db.QueryRow(
		`SELECT entitlement_kind, entitlement_payload FROM codes_batches WHERE id=7`).Scan(&kind, &payload); err != nil {
		t.Fatalf("batch row: %v", err)
	}
	if kind != "duration" {
		t.Errorf("batch kind = %q", kind)
	}
	if payload != `{"plan":"pro","days":30}` {
		t.Errorf("batch payload = %s", payload)
	}

	// code:redeemed_by 映射 + created_at 取 batch 时间 + status passthrough。
	var status, rby, cpayload string
	var cat time.Time
	if err := db.QueryRow(
		`SELECT status, redeemed_by, entitlement_payload, created_at FROM codes WHERE code_hash=?`,
		seed[1].hash).Scan(&status, &rby, &cpayload, &cat); err != nil {
		t.Fatalf("code row: %v", err)
	}
	if status != "redeemed" || rby != "user:42" {
		t.Errorf("status=%q redeemed_by=%q", status, rby)
	}
	if cpayload != `{"plan":"pro","days":30}` {
		t.Errorf("code payload = %s", cpayload)
	}
	if !cat.Equal(batchAt) {
		t.Errorf("created_at = %v, want batch created_at %v", cat, batchAt)
	}

	// 幂等:重复跑迁 0 行、不重不错。
	n2, err := codes.BackfillLegacy(ctx, db)
	if err != nil || n2 != 0 {
		t.Fatalf("re-run: n=%d err=%v, want 0,nil", n2, err)
	}
	var cnt int
	if err := db.QueryRow(`SELECT COUNT(*) FROM codes`).Scan(&cnt); err != nil || cnt != 3 {
		t.Fatalf("codes count = %d (err=%v), want 3", cnt, err)
	}
}

func TestBackfillLegacyNoLegacyTables(t *testing.T) {
	// 全新库(未来 legacy 表 retire 后)也不报错。用一个没跑 pangolin 迁移、只有库表的 DB 模拟。
	ctx := context.Background()
	db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer db.Close()
	if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil {
		t.Fatalf("lib migrate: %v", err)
	}
	n, err := codes.BackfillLegacy(ctx, db)
	if err != nil || n != 0 {
		t.Fatalf("n=%d err=%v, want 0,nil", n, err)
	}
}
cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run TestBackfill   # FAIL:undefined BackfillLegacy
  • Step 2: 实现 server/internal/codes/backfill.go(要点:先读全量入内存、关游标,再写——同一事务单连接上边迭代 rows 边 Exec 在 mysql 驱动上会打架;逐行 SELECT COUNT 幂等判重,双方言零分叉):
package codes

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"time"

	dbx "github.com/wangjia/pangolin/server/internal/db"
)

// BackfillLegacy copies legacy_codes / legacy_code_batches rows (renamed away
// by migration 000020) into the shared library's codes / codes_batches
// tables. Idempotent: batches keyed by preserved id, codes keyed by the
// unique code_hash; rows already present are skipped. Returns the number of
// code rows migrated. If the legacy tables don't exist (fresh install after
// the eventual legacy cleanup), it is a no-op.
func BackfillLegacy(ctx context.Context, database *sql.DB) (int, error) {
	exists, err := legacyTablesExist(ctx, database)
	if err != nil {
		return 0, fmt.Errorf("codes.BackfillLegacy: probe legacy tables: %w", err)
	}
	if !exists {
		return 0, nil
	}

	type legacyBatch struct {
		id        int64
		channel   string
		createdBy string
		note      sql.NullString
		createdAt time.Time
	}
	type legacyCode struct {
		id         int64
		hash       string
		planCode   string
		days       int
		batchID    int64
		status     string
		redeemedBy sql.NullInt64
		redeemedAt sql.NullTime
	}

	// ── 读阶段:全量取出、关闭游标(不能在同事务连接上边迭代边写)──
	var batches []legacyBatch
	rows, err := database.QueryContext(ctx,
		`SELECT id, channel, created_by, note, created_at FROM legacy_code_batches ORDER BY id`)
	if err != nil {
		return 0, fmt.Errorf("codes.BackfillLegacy: read batches: %w", err)
	}
	for rows.Next() {
		var b legacyBatch
		if err := rows.Scan(&b.id, &b.channel, &b.createdBy, &b.note, &b.createdAt); err != nil {
			rows.Close()
			return 0, fmt.Errorf("codes.BackfillLegacy: scan batch: %w", err)
		}
		batches = append(batches, b)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return 0, err
	}
	rows.Close()

	var lcodes []legacyCode
	rows, err = database.QueryContext(ctx,
		`SELECT c.id, c.code_hash, p.code, c.duration_days, c.batch_id, c.status, c.redeemed_by, c.redeemed_at
		   FROM legacy_codes c JOIN plans p ON p.id = c.plan_id ORDER BY c.id`)
	if err != nil {
		return 0, fmt.Errorf("codes.BackfillLegacy: read codes: %w", err)
	}
	for rows.Next() {
		var c legacyCode
		if err := rows.Scan(&c.id, &c.hash, &c.planCode, &c.days, &c.batchID, &c.status, &c.redeemedBy, &c.redeemedAt); err != nil {
			rows.Close()
			return 0, fmt.Errorf("codes.BackfillLegacy: scan code: %w", err)
		}
		lcodes = append(lcodes, c)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return 0, err
	}
	rows.Close()

	// batch id → (payload, createdAt):batch 权益取其第一条 code 合成。
	batchAt := make(map[int64]time.Time, len(batches))
	batchPayload := make(map[int64]string, len(batches))
	for _, b := range batches {
		batchAt[b.id] = b.createdAt
	}
	for _, c := range lcodes {
		if _, ok := batchPayload[c.batchID]; !ok {
			batchPayload[c.batchID] = durationPayloadJSON(c.planCode, c.days)
		}
	}

	// ── 写阶段:单事务,全成或全无;逐行判重保证幂等。──
	tx, err := database.BeginTx(ctx, nil)
	if err != nil {
		return 0, fmt.Errorf("codes.BackfillLegacy: begin: %w", err)
	}
	defer tx.Rollback()

	for _, b := range batches {
		var n int
		if err := tx.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM codes_batches WHERE id = ?`, b.id).Scan(&n); err != nil {
			return 0, fmt.Errorf("codes.BackfillLegacy: probe batch %d: %w", b.id, err)
		}
		if n > 0 {
			continue
		}
		payload, ok := batchPayload[b.id]
		if !ok {
			payload = durationPayloadJSON("unknown", 0) // 空批次:信息性占位
		}
		var note any
		if b.note.Valid {
			note = b.note.String
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO codes_batches (id, channel, entitlement_kind, entitlement_payload, created_by, note, created_at)
			 VALUES (?, ?, 'duration', ?, ?, ?, ?)`,
			b.id, b.channel, payload, b.createdBy, note, b.createdAt.UTC()); err != nil {
			return 0, fmt.Errorf("codes.BackfillLegacy: insert batch %d: %w", b.id, err)
		}
	}

	migrated := 0
	for _, c := range lcodes {
		var n int
		if err := tx.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM codes WHERE code_hash = ?`, c.hash).Scan(&n); err != nil {
			return 0, fmt.Errorf("codes.BackfillLegacy: probe code %d: %w", c.id, err)
		}
		if n > 0 {
			continue
		}
		var redeemedBy, redeemedAt any
		if c.redeemedBy.Valid {
			redeemedBy = fmt.Sprintf("user:%d", c.redeemedBy.Int64)
		}
		if c.redeemedAt.Valid {
			redeemedAt = c.redeemedAt.Time.UTC()
		}
		createdAt, ok := batchAt[c.batchID]
		if !ok {
			createdAt = time.Now()
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO codes (id, code_hash, batch_id, entitlement_kind, entitlement_payload, status, redeemed_by, redeemed_at, void_reason, created_at)
			 VALUES (?, ?, ?, 'duration', ?, ?, ?, ?, NULL, ?)`,
			c.id, c.hash, c.batchID, durationPayloadJSON(c.planCode, c.days),
			c.status, redeemedBy, redeemedAt, createdAt.UTC()); err != nil {
			return 0, fmt.Errorf("codes.BackfillLegacy: insert code %d: %w", c.id, err)
		}
		migrated++
	}

	if err := tx.Commit(); err != nil {
		return 0, fmt.Errorf("codes.BackfillLegacy: commit: %w", err)
	}
	return migrated, nil
}

func durationPayloadJSON(plan string, days int) string {
	b, _ := json.Marshal(struct {
		Plan string `json:"plan"`
		Days int    `json:"days"`
	}{plan, days})
	return string(b)
}

// legacyTablesExist probes for legacy_codes per dialect (sqlite_master vs
// information_schema) — no error-swallowing "SELECT and see" hacks.
func legacyTablesExist(ctx context.Context, database *sql.DB) (bool, error) {
	var n int
	if dbx.DialectForDB(database).Name() == "sqlite" {
		err := database.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_codes'`).Scan(&n)
		return n > 0, err
	}
	err := database.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'legacy_codes'`).Scan(&n)
	return n > 0, err
}
  • Step 3: 接进 cmd/migrate —— up 分支在 ApplyCodesLibMigrations 之后:
		n, err := codes.BackfillLegacy(context.Background(), db)
		if err != nil {
			log.Fatalf("migrate up (codes backfill): %v", err)
		}
		if n > 0 {
			log.Printf("migrate: codes backfill — %d legacy codes migrated", n)
		}

(import 加 "github.com/wangjia/pangolin/server/internal/codes"。)

  • Step 4: 验证 + commit
cd /Users/wangjia/code/pangolin/server && go build ./... && go test ./internal/codes/ -run TestBackfill && go test ./...
git add server/internal/codes/backfill.go server/internal/codes/backfill_test.go server/cmd/migrate/main.go
git commit -m "feat(server): legacy 码表 → codes 库新表的幂等回填(Go 双方言)(#codes-lib)"

Task 4: Store 换芯 + CreateBatch 走库 Mint + 生成器委托

Files:

  • Modify: server/internal/codes/store.go(Store 挂库 store;删除被库取代的方法)
  • Modify: server/internal/codes/generator.go(委托库)
  • Modify: server/internal/codes/service.go(仅 CreateBatch 部分)
  • Test: server/internal/codes/service_sqlite_test.go(新,无 build tag)

Interfaces:

  • NewStore(db *sql.DB) *Store 签名不变;Store 内部新增字段 lib *libcodes.Store

  • Service.CreateBatch(ctx, BatchRequest) (*BatchResult, error) 签名不变,内部走 libcodes.Mint

  • GenerateCode/Canonicalize/Hash 委托 libcodes;var ErrDuplicate = libcodes.ErrDuplicate(别名同一哨兵,errors.Is 跨层成立)。

  • 本 Task 删除:Store.CreateBatch/Store.CreateCode/Store.CodeExistsByHash(webhook Task 6 会改用新原语;若删除导致 webhook.go 编译失败,把 webhook.go 里的调用点临时注释性重写放到 Task 6——不行,必须每步可编译:因此本 Task 先保留 Store.CreateBatch/CreateCode/CodeExistsByHash 原样(它们写 legacy 表,已无人生产调用但 webhook.go 还引用),Task 6 重写 webhook 后、Task 7 一并删除)。

  • Step 1: 失败测试 server/internal/codes/service_sqlite_test.go(第一批只测 CreateBatch):

package codes_test

import (
	"context"
	"testing"
	"time"

	"github.com/wangjia/pangolin/server/internal/codes"
)

// 依赖 openMigratedSQLite(完整迁移链:pangolin 000001-000020 → 库表)——
// 从 backfill_test.go 挪到共享 helper 文件 sqlite_helper_test.go(见 Step 1 末尾说明)。
func TestCreateBatchViaLib(t *testing.T) {
	ctx := context.Background()
	db := openMigratedSQLite(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, nil, 5, time.Hour) // rdb=nil:cmd/codegen 的真实用法

	res, err := svc.CreateBatch(ctx, codes.BatchRequest{
		PlanCode: codes.PlanPro, DurationDays: 30, Count: 5,
		Channel: codes.ChannelManual, Note: "t", CreatedBy: "admin:1",
	})
	if err != nil {
		t.Fatalf("CreateBatch: %v", err)
	}
	if len(res.Codes) != 5 || res.BatchID == 0 {
		t.Fatalf("res = %+v", res)
	}

	// 码落在库的新表里,明文不落库,权益为 duration payload。
	var cnt int
	if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE batch_id=?`, res.BatchID).Scan(&cnt); err != nil || cnt != 5 {
		t.Fatalf("new codes rows = %d (err=%v), want 5", cnt, err)
	}
	var kind, payload string
	if err := db.QueryRow(`SELECT entitlement_kind, entitlement_payload FROM codes_batches WHERE id=?`, res.BatchID).Scan(&kind, &payload); err != nil {
		t.Fatalf("batch: %v", err)
	}
	if kind != "duration" || payload != `{"plan":"pro","days":30}` {
		t.Fatalf("kind=%q payload=%s", kind, payload)
	}
	for _, plain := range res.Codes {
		var n int
		if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE code_hash=?`, codes.Hash(plain)).Scan(&n); err != nil || n != 1 {
			t.Fatalf("hash lookup for %q: n=%d err=%v", plain, n, err)
		}
	}

	// 未知 plan 仍报错(旧行为 service.go:336-339)。
	if _, err := svc.CreateBatch(ctx, codes.BatchRequest{
		PlanCode: "nope", DurationDays: 30, Count: 1, Channel: codes.ChannelManual, CreatedBy: "x",
	}); err == nil {
		t.Fatal("unknown plan should error")
	}
}

(把 openMigratedSQLite 从 backfill_test.go 挪到共享 helper 文件 server/internal/codes/sqlite_helper_test.go,backfill_test.go 与本文件共用,避免重名。)

cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run TestCreateBatchViaLib   # FAIL:落的是 legacy 表/编译错
  • Step 2: 实现

store.go 头部(import 加 libcodes "github.com/wangjia/codes"),Store 与 NewStore 改为:

type Store struct {
	db      *sql.DB
	dialect dbx.Dialect
	lib     *libcodes.Store
}

// NewStore creates a Store backed by the given connection pool (MySQL or SQLite).
func NewStore(db *sql.DB) *Store {
	d := dbx.DialectForDB(db)
	libD := libcodes.DialectMySQL
	if d.Name() == "sqlite" {
		libD = libcodes.DialectSQLite
	}
	return &Store{db: db, dialect: d, lib: libcodes.NewStore(db, libD)}
}

// Lib exposes the shared library store (used by Service / webhook shim).
func (s *Store) Lib() *libcodes.Store { return s.lib }

generator.go:三个函数体改为委托 libcodes.GenerateCode() / libcodes.Canonicalize(code) / libcodes.Hash(canonical);ErrDuplicatevar ErrDuplicate = libcodes.ErrDuplicate(删除 errors.New,pangolin internal/idgen 的 import 从本文件移除——不删 internal/idgen 包本身,别处可能引用,Task 9 清点)。

service.goCreateBatch(service.go:332-383)替换为:

// CreateBatch generates Count activation codes via the shared library's Mint
// (all-or-nothing: batch + code hashes commit in one tx) and returns the
// plaintext codes — the only time they ever appear.
func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) {
	// 未知 plan 先拒(行为等价:旧实现先查 GetPlanID)。
	if _, err := svc.store.GetPlanID(ctx, req.PlanCode); err != nil {
		return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err)
	}
	ent, err := libcodes.NewDurationEntitlement(string(req.PlanCode), req.DurationDays)
	if err != nil {
		return nil, fmt.Errorf("CreateBatch: %w", err)
	}
	res, err := libcodes.Mint(ctx, svc.store.Lib(), libcodes.MintRequest{
		Channel:     string(req.Channel),
		Entitlement: ent,
		Count:       req.Count,
		CreatedBy:   req.CreatedBy,
		Note:        req.Note,
	})
	if err != nil {
		return nil, fmt.Errorf("CreateBatch: %w", err)
	}
	return &BatchResult{
		BatchID:      res.BatchID,
		Codes:        res.Codes,
		PlanCode:     req.PlanCode,
		DurationDays: req.DurationDays,
		Channel:      req.Channel,
	}, nil
}

(service.go import 加 libcodes "github.com/wangjia/codes"。)

  • Step 3: 验证 + commit
cd /Users/wangjia/code/pangolin/server && go build ./... && go test ./internal/codes/ && go test ./...
git add server/internal/codes/
git commit -m "feat(server): CreateBatch/生成器切换到 codes 库(Mint 单事务全成或全无)(#codes-lib)"

generator_test.go 必须原样全绿(算法同源);若有失败即库移植有偏差,停下上报。


Task 5: Redeem 换芯 — GuardedRedeem + grant 回调复刻订阅叠加

Files:

  • Modify: server/internal/codes/service.go(Service 结构/NewService/Redeem/applySubscription/grant)
  • Modify: server/internal/codes/store.go(新增 GetPlanIDTx)
  • Test: server/internal/codes/service_sqlite_test.go(追加兑换用例)

Interfaces:

  • NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service 签名不变;内部 rdb!=nil → redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur),nil → nil(GuardedRedeem 自动 Noop——cmd/codegen 场景)。

  • Service.Redeem(ctx, RedeemRequest) (*RedeemResult, *apierr.Error) 签名与语义不变

  • Produces: func (s *Store) GetPlanIDTx(ctx context.Context, tx *sql.Tx, code PlanCode) (int64, error)

  • 错误映射表(必须逐条):lib ErrLockedapierr.ErrLocked;ErrCodeNotFoundapierr.ErrCodeNotFound;ErrCodeRedeemedapierr.ErrCodeRedeemed;ErrCodeVoidapierr.ErrCodeVoid;canonicalize 失败→apierr.ErrInvalidCode(+记一次失败);其余→apierr.ErrInternal

  • Step 1: 失败测试(追加到 service_sqlite_test.go;helper 文件加 seedUser):

sqlite_helper_test.go 追加:

func seedUser(t *testing.T, db *sql.DB, id int64) {
	t.Helper()
	if _, err := db.Exec(
		`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at)
		 VALUES (?, ?, ?, 'x', ?, 'active', ?)`,
		id, fmt.Sprintf("u-%d", id), fmt.Sprintf("u%d@example.com", id), fmt.Sprintf("dp-%d", id),
		time.Now().UTC()); err != nil {
		t.Fatalf("seed user: %v", err)
	}
}

(import "database/sql", "fmt", "time"。)

用例(与 mysql 集成测试 service_test.go:198-449 的断言同源,搬到 sqlite 常跑):

func newRedeemFixture(t *testing.T) (*sql.DB, *codes.Service, string) {
	t.Helper()
	db := openMigratedSQLite(t)
	seedUser(t, db, 1)
	seedUser(t, db, 2)
	store := codes.NewStore(db)
	svc := codes.NewService(store, nil, 5, time.Hour)
	res, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
		PlanCode: codes.PlanPro, DurationDays: 30, Count: 1,
		Channel: codes.ChannelManual, CreatedBy: "t",
	})
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	return db, svc, res.Codes[0]
}

func TestRedeemCreatesSubscription(t *testing.T) {
	ctx := context.Background()
	db, svc, plain := newRedeemFixture(t)

	r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}
	if r.Idempotent || r.PlanCode != codes.PlanPro || r.DurationDays != 30 || r.SubscriptionID == 0 {
		t.Fatalf("result = %+v", r)
	}
	wantMin := time.Now().UTC().AddDate(0, 0, 29)
	if r.ExpiresAt.Before(wantMin) {
		t.Errorf("expires %v, want ≥ ~30d out", r.ExpiresAt)
	}
	// 订阅真落库(source='code'),码翻转 + redeemed_by 是 "user:1"。
	var src string
	var exp time.Time
	if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id=?`, r.SubscriptionID).Scan(&src, &exp); err != nil || src != "code" {
		t.Fatalf("sub: src=%q err=%v", src, err)
	}
	var status, rby string
	if err := db.QueryRow(`SELECT status, redeemed_by FROM codes WHERE code_hash=?`, codes.Hash(mustCanonical(t, plain))).Scan(&status, &rby); err != nil {
		t.Fatalf("code: %v", err)
	}
	if status != "redeemed" || rby != "user:1" {
		t.Errorf("status=%q redeemed_by=%q", status, rby)
	}
	// 旧式审计行仍写 audit_log(admin 审计页数据源)。
	var audits int
	if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='redeem' AND actor='user:1'`).Scan(&audits); err != nil || audits != 1 {
		t.Errorf("audit_log redeem rows = %d (err=%v), want 1", audits, err)
	}
}

func mustCanonical(t *testing.T, code string) string {
	t.Helper()
	c, err := codes.Canonicalize(code)
	if err != nil {
		t.Fatalf("canonicalize: %v", err)
	}
	return c
}

func TestRedeemIdempotentSameUser(t *testing.T) {
	ctx := context.Background()
	_, svc, plain := newRedeemFixture(t)
	if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
		t.Fatalf("first: %v", apiErr)
	}
	r2, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
	if apiErr != nil {
		t.Fatalf("second: %v", apiErr)
	}
	if !r2.Idempotent || r2.PlanCode != codes.PlanPro || r2.DurationDays != 30 {
		t.Fatalf("r2 = %+v", r2)
	}
	if !r2.ExpiresAt.IsZero() {
		t.Errorf("idempotent replay must omit ExpiresAt (old contract), got %v", r2.ExpiresAt)
	}
}

func TestRedeemOtherUserRejected(t *testing.T) {
	ctx := context.Background()
	_, svc, plain := newRedeemFixture(t)
	if _, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain}); apiErr != nil {
		t.Fatalf("first: %v", apiErr)
	}
	_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 2, Code: plain})
	if apiErr == nil || apiErr.Code != "CODE_REDEEMED" {
		t.Fatalf("apiErr = %+v, want CODE_REDEEMED", apiErr)
	}
}

func TestRedeemSamePlanExtends(t *testing.T) {
	ctx := context.Background()
	db, svc, plain := newRedeemFixture(t)
	// 预置同 plan 活跃订阅,到期在未来 10 天 → 兑换后应为 +10+30 天(max(expires,now)+days)。
	var proID int64
	if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
		t.Fatalf("plan: %v", err)
	}
	base := time.Now().UTC().AddDate(0, 0, 10).Truncate(time.Second)
	var subID int64
	res, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
		proID, base, time.Now().UTC())
	if err != nil {
		t.Fatalf("seed sub: %v", err)
	}
	subID, _ = res.LastInsertId()

	r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}
	if r.SubscriptionID != subID {
		t.Fatalf("extended sub id = %d, want %d (extend, not create)", r.SubscriptionID, subID)
	}
	want := base.AddDate(0, 0, 30)
	if !r.ExpiresAt.Equal(want) {
		t.Errorf("expires = %v, want %v", r.ExpiresAt, want)
	}
	var cnt int
	if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 1 {
		t.Errorf("subs = %d, want 1 (extended in place)", cnt)
	}
}

func TestRedeemCrossPlanCreatesNew(t *testing.T) {
	ctx := context.Background()
	db, svc, plain := newRedeemFixture(t) // 码是 pro
	var teamID int64
	if err := db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID); err != nil {
		t.Fatalf("plan: %v", err)
	}
	if _, err := db.Exec(`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) VALUES (1, ?, ?, 'code', ?)`,
		teamID, time.Now().UTC().AddDate(0, 0, 90), time.Now().UTC()); err != nil {
		t.Fatalf("seed team sub: %v", err)
	}
	r, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}
	var cnt int
	if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&cnt); err != nil || cnt != 2 {
		t.Fatalf("subs = %d, want 2 (new pro sub beside team)", cnt)
	}
	wantMin := time.Now().UTC().AddDate(0, 0, 29)
	if r.ExpiresAt.Before(wantMin) {
		t.Errorf("new pro sub expires %v, want ~30d (NOT stacked on team's 90d)", r.ExpiresAt)
	}
}

func TestRedeemInvalidFormat(t *testing.T) {
	ctx := context.Background()
	_, svc, _ := newRedeemFixture(t)
	_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: "not-a-code"})
	if apiErr == nil || apiErr.Code != "INVALID_CODE" {
		t.Fatalf("apiErr = %+v, want INVALID_CODE", apiErr)
	}
}

func TestRedeemNotFound(t *testing.T) {
	ctx := context.Background()
	_, svc, _ := newRedeemFixture(t)
	// 结构合法但不存在的码:生成一个不入库的。
	plain, err := codes.GenerateCode()
	if err != nil {
		t.Fatal(err)
	}
	_, apiErr := svc.Redeem(ctx, codes.RedeemRequest{UserID: 1, Code: plain})
	if apiErr == nil || apiErr.Code != "CODE_NOT_FOUND" {
		t.Fatalf("apiErr = %+v, want CODE_NOT_FOUND", apiErr)
	}
}

(apierr.Error.Code 字段名以 internal/apierr/apierr.go:91-115 为准——ErrInvalidCode=INVALID_CODE、ErrCodeNotFound=CODE_NOT_FOUND、ErrCodeRedeemed=CODE_REDEEMED、ErrCodeVoid=CODE_VOID、ErrLocked 对应 handler.go:72 里的 ACCOUNT_LOCKED,写测试前打开确认常量拼写。)

cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run 'TestRedeem'   # FAIL:旧实现查 legacy 表(已改名)必炸
  • Step 2: 实现 —— service.go 重写 Redeem 相关(保留 RedeemRequest/RedeemResult 原型;删除 redisKeyFail/isLocked/recordFail/clearFail 四个手写 Redis 函数与 auditMeta 的 cr *CodeRow 依赖):
import (
	// 原有 imports 基础上:
	"errors"
	"strconv"

	libcodes "github.com/wangjia/codes"
	"github.com/wangjia/codes/redisx"
)

// Service handles activation-code redemption on top of the shared codes lib.
type Service struct {
	store   *Store
	limiter libcodes.RateLimiter // nil → GuardedRedeem 内部 Noop(cmd/codegen 传 rdb=nil 的场景)
}

// NewService creates a Service. failMax/lockDur mirror the old per-user
// redeem lockout (5 次失败锁 1 小时), now backed by codes/redisx.
func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
	var limiter libcodes.RateLimiter
	if rdb != nil {
		limiter = redisx.NewRateLimiter(rdb, "redeem:fail:", failMax, lockDur)
	}
	return &Service{store: store, limiter: limiter}
}

// grantOutcome is the host-side grant result threaded through Redeem[T].
type grantOutcome struct {
	subID     int64
	expiresAt time.Time
}

func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
	ref := "user:" + strconv.FormatInt(req.UserID, 10)

	// 1. 锁检查先于格式检查(行为等价:旧 service.go:118-124 顺序)。
	if svc.limiter != nil {
		allowed, err := svc.limiter.Allowed(ctx, ref)
		if err != nil {
			return nil, apierr.ErrInternal
		}
		if !allowed {
			return nil, apierr.ErrLocked
		}
	}

	// 2. Canonicalize;格式错也计一次失败(旧 service.go:127-131)。
	canonical, cerr := Canonicalize(req.Code)
	if cerr != nil {
		if svc.limiter != nil {
			_ = svc.limiter.RecordFailure(ctx, ref)
		}
		return nil, apierr.ErrInvalidCode
	}

	// 3. 宿主事务:码状态翻转 + 订阅授予 + 双审计,一个 tx 原子提交。
	tx, err := svc.store.BeginTx(ctx)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	res, err := libcodes.GuardedRedeem(ctx, svc.store.Lib(), svc.limiter, tx,
		Hash(canonical), ref, svc.grantSubscription(req.UserID))
	if err != nil {
		switch {
		case errors.Is(err, libcodes.ErrLocked):
			return nil, apierr.ErrLocked
		case errors.Is(err, libcodes.ErrCodeNotFound):
			return nil, apierr.ErrCodeNotFound
		case errors.Is(err, libcodes.ErrCodeRedeemed):
			return nil, apierr.ErrCodeRedeemed
		case errors.Is(err, libcodes.ErrCodeVoid):
			return nil, apierr.ErrCodeVoid
		default:
			return nil, apierr.ErrInternal
		}
	}

	dur, derr := res.Code.Entitlement.DecodeDuration()
	if derr != nil {
		return nil, apierr.ErrInternal
	}

	if res.Idempotent {
		// 只读路径:回滚(旧 service.go:162-171 同款),ExpiresAt 留空由 /v1/me 兜底。
		_ = tx.Rollback()
		committed = true
		return &RedeemResult{
			Idempotent:   true,
			PlanCode:     PlanCode(dur.Plan),
			DurationDays: dur.Days,
		}, nil
	}

	if err := tx.Commit(); err != nil {
		return nil, apierr.ErrInternal
	}
	committed = true

	return &RedeemResult{
		Idempotent:     false,
		PlanCode:       PlanCode(dur.Plan),
		DurationDays:   dur.Days,
		ExpiresAt:      res.Grant.expiresAt,
		SubscriptionID: res.Grant.subID,
	}, nil
}

// grantSubscription returns the GrantFunc executed by the lib INSIDE the same
// tx as the code flip. It reproduces the old applySubscription semantics
// byte-for-byte (service.go:235-287 pre-migration) and writes the legacy
// audit_log row the admin audit page reads (non-fatal, old behavior).
func (svc *Service) grantSubscription(userID int64) libcodes.GrantFunc[grantOutcome] {
	return func(ctx context.Context, tx *sql.Tx, code libcodes.Code) (grantOutcome, error) {
		dur, err := code.Entitlement.DecodeDuration()
		if err != nil {
			return grantOutcome{}, err
		}
		planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanCode(dur.Plan))
		if err != nil {
			return grantOutcome{}, err
		}
		subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, dur.Days)
		if err != nil {
			return grantOutcome{}, err
		}

		// 旧式审计(数据源:admin/store.go:188,197 读 audit_log);失败不中断。
		meta, _ := json.Marshal(map[string]any{
			"plan":          dur.Plan,
			"duration_days": dur.Days,
			"batch_id":      code.BatchID,
			"sub_id":        subID,
		})
		_ = svc.store.WriteAuditLog(ctx, tx,
			fmt.Sprintf("user:%d", userID), "redeem",
			"code_hash:"+code.CodeHash[:16]+"...", string(meta))

		return grantOutcome{subID: subID, expiresAt: expiresAt}, nil
	}
}

// applySubscription:同 plan 有活跃订阅 → 取最晚到期那条 max(expires,now)+days
// 原地延长;否则新建一行 max(now, latestSamePlan)+days。算法体与迁移前完全一致,
// 仅签名从 (*CodeRow, *apierr.Error) 改为 (planID, days, error)。
func (svc *Service) applySubscription(
	ctx context.Context, tx *sql.Tx, userID, planID int64, durationDays int,
) (int64, time.Time, error) {
	subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
	if err != nil {
		return 0, time.Time{}, err
	}
	var samePlanSub *SubscriptionRow
	var latestSamePlan time.Time
	for i := range subs {
		if subs[i].PlanID == planID {
			if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
				samePlanSub = &subs[i]
			}
			if subs[i].ExpiresAt.After(latestSamePlan) {
				latestSamePlan = subs[i].ExpiresAt
			}
		}
	}
	now := time.Now().UTC()
	if samePlanSub != nil {
		if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, durationDays); err != nil {
			return 0, time.Time{}, err
		}
		base := samePlanSub.ExpiresAt
		if now.After(base) {
			base = now
		}
		return samePlanSub.ID, base.AddDate(0, 0, durationDays), nil
	}
	newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, planID, durationDays, latestSamePlan)
	if err != nil {
		return 0, time.Time{}, err
	}
	base := now
	if latestSamePlan.After(now) {
		base = latestSamePlan
	}
	return newSubID, base.AddDate(0, 0, durationDays), nil
}

store.go 追加(GetPlanID 原样保留):

// GetPlanIDTx is GetPlanID inside a transaction (grant callback runs inside
// the redeem tx and must not touch the pool).
func (s *Store) GetPlanIDTx(ctx context.Context, tx *sql.Tx, code PlanCode) (int64, error) {
	var id int64
	if err := tx.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, string(code)).Scan(&id); err != nil {
		return 0, fmt.Errorf("store.GetPlanIDTx(%s): %w", code, err)
	}
	return id, nil
}

同时删除 store.goFindCodeByHashForUpdate/MarkRedeemed/CodeRow(已被库取代;sqlite_stores_test.go 会编译失败——在本 Task 顺手改:删掉 internal/store/sqlite_stores_test.goTestSQLite_CodesRedeemFlow(:193-260),它测的是被删的低层方法;等价覆盖由本 Task 的 Service 级 sqlite 用例接管,且更接近真实调用面)。Store.BeginTx 保留原实现(ReadCommitted,store.go:308-310——modernc sqlite 在既有测试下接受该选项,保持不动)。

  • Step 3: 验证 + commit
cd /Users/wangjia/code/pangolin/server && go build ./... && go vet ./... && go test ./...
git add server/internal/codes/ server/internal/store/sqlite_stores_test.go
git commit -m "feat(server): Redeem 换芯 GuardedRedeem——grant 回调同事务复刻订阅叠加+双审计(#codes-lib)"

Task 6: webhook 薄壳 — 保留 X-Pangolin HMAC 协议,铸码走库原语

Files:

  • Modify: server/internal/codes/webhook.go
  • Modify: server/internal/codes/store.go(新增 MintOne)
  • Test: 既有 server/internal/codes/webhook_test.go 原样通过;追加 1 个 sqlite 铸码落库用例到 service_sqlite_test.go

Interfaces:

  • NewWebhookHandler(store *Store, rdb *redis.Client, secret string, timestampTolerance, nonceTTL time.Duration) *WebhookHandler 签名不变(main.go:299 原样编译)。

  • 协议逐字节不变:X-Pangolin-Signature: sha256=<hex>(HMAC 仅对 body)、X-Pangolin-Timestamp(±tolerance)、X-Pangolin-Nonce(Redis SETNX 去重,key 前缀 webhook:nonce: 不变,先存后铸的旧顺序保留——duplicate_ignored 语义与 mysql 集成测试 TestWebhookNonceReplay 依赖它;库的 nonce 后置改良留给未来切 pay-contract handler 时一并采纳)、payload {code,plan,duration_days,note}、响应 created/already_exists/duplicate_ignored 三态。

  • Produces: func (s *Store) MintOne(ctx context.Context, codeHash string, ent libcodes.Entitlement, channel BatchChannel, createdBy, note string) error(单事务 batch+code,重复码回 ErrDuplicate不留孤儿 batch)。

  • Step 1: 失败测试 —— 追加到 service_sqlite_test.go:

func TestWebhookMintsIntoLibTables(t *testing.T) {
	// 直接测 Store.MintOne(webhook 铸码核心):落库新表 + 权益 payload + 重复码无孤儿 batch。
	ctx := context.Background()
	db := openMigratedSQLite(t)
	store := codes.NewStore(db)

	plain, err := codes.GenerateCode()
	if err != nil {
		t.Fatal(err)
	}
	canonical := mustCanonical(t, plain)
	ent, err := libcodesEnt("pro", 30)
	if err != nil {
		t.Fatal(err)
	}
	if err := store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n1"); err != nil {
		t.Fatalf("MintOne: %v", err)
	}
	var batches, ccount int
	if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 {
		t.Fatalf("batches = %d (err=%v)", batches, err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM codes WHERE code_hash=?`, codes.Hash(canonical)).Scan(&ccount); err != nil || ccount != 1 {
		t.Fatalf("codes = %d (err=%v)", ccount, err)
	}

	// 同 hash 再灌:ErrDuplicate 且 batch 数不变(无孤儿——旧实现的 I2 缺陷在此修复)。
	err = store.MintOne(ctx, codes.Hash(canonical), ent, codes.ChannelStore, "webhook", "n2")
	if !errors.Is(err, codes.ErrDuplicate) {
		t.Fatalf("err = %v, want ErrDuplicate", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM codes_batches`).Scan(&batches); err != nil || batches != 1 {
		t.Fatalf("batches after dup = %d, want 1 (no orphan)", batches)
	}
}

// libcodesEnt:测试侧小工具,避免测试文件 import 库包名与宿主包名混淆。
func libcodesEnt(plan string, days int) (libcodes.Entitlement, error) {
	return libcodes.NewDurationEntitlement(plan, days)
}

(测试文件 import 加 "errors"libcodes "github.com/wangjia/codes"。)

cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run TestWebhookMints   # FAIL:undefined MintOne
  • Step 2: 实现

store.go 追加:

// MintOne writes one externally-generated code (card-store webhook ingestion)
// as one batch + one code row in a single transaction. A duplicate code_hash
// returns ErrDuplicate with NOTHING committed — no orphan batch (the old
// two-statement implementation leaked one batch row per duplicate delivery).
func (s *Store) MintOne(ctx context.Context, codeHash string, ent libcodes.Entitlement, channel BatchChannel, createdBy, note string) error {
	tx, err := s.lib.BeginTx(ctx)
	if err != nil {
		return fmt.Errorf("store.MintOne: begin: %w", err)
	}
	defer tx.Rollback()

	batchID, err := s.lib.CreateBatchTx(ctx, tx, string(channel), ent, createdBy, note)
	if err != nil {
		return fmt.Errorf("store.MintOne: batch: %w", err)
	}
	if err := s.lib.CreateCodeTx(ctx, tx, codeHash, batchID, ent); err != nil {
		return err // ErrDuplicate passes through; defer 回滚保证无孤儿
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("store.MintOne: commit: %w", err)
	}
	return nil
}

webhook.go:结构体/构造函数/verifySignature/verifyTimestamp/checkAndStoreNonce/redisKeyNonce 全部原样不动;仅 ServeHTTP 的「插码段」(webhook.go:136-166)替换:

	// --- Idempotent code insertion (via shared lib primitives) ---
	ctx := r.Context()

	// 未知 plan 先拒(行为不变,旧 webhook.go:138-142)。
	if _, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))); err != nil {
		apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
		return
	}
	ent, err := libcodes.NewDurationEntitlement(strings.ToLower(payload.Plan), payload.DurationDays)
	if err != nil {
		apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
		return
	}

	err = h.store.MintOne(ctx, hash, ent, ChannelStore, "webhook", payload.Note)
	if errors.Is(err, ErrDuplicate) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.WriteHeader(http.StatusOK)
		_ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"})
		return
	}
	if err != nil {
		apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]string{"status": "created"})

(import 加 "errors"libcodes "github.com/wangjia/codes"。)

协调标注(未来项,不在本计划):切换到库的 NewWebhookHandler(pay-contract 式 system\nts\nnonce\nbody MAC,X-Pay-* 头)是 breaking change,需要与外部发卡店协调签名端升级;届时在新路径(如 /v1/webhook/codes)并行挂库 handler、双轨过渡。

  • Step 3: 验证 + commit
cd /Users/wangjia/code/pangolin/server && go build ./... && go test ./internal/codes/    # webhook_test.go 旧用例必须原样全绿
git add server/internal/codes/
git commit -m "feat(server): webhook 薄壳化——保留 X-Pangolin HMAC 协议,铸码走库单事务原语(#codes-lib)"

Task 7: admin 面改指新表 + 死代码清理

Files:

  • Modify: server/internal/codes/admin_support.go(ListBatches/VoidBatch 指向 codes_batches/codes)
  • Modify: server/internal/codes/store.go(删除遗留的 CreateBatch/CreateCode/CodeExistsByHash/isDuplicateKey)
  • Test: server/internal/codes/admin_support_sqlite_test.go(新)

Interfaces: ListBatches(ctx, limit, offset) ([]BatchInfo, int, error) / VoidBatch(ctx, batchID) (int64, error) 签名与 BatchInfo 字段不变(admin 的 CodesService 接口零改动)。

  • Step 1: 失败测试 admin_support_sqlite_test.go:
package codes_test

import (
	"context"
	"testing"
	"time"

	"github.com/wangjia/pangolin/server/internal/codes"
)

func TestListBatchesAndVoidBatchOnLibTables(t *testing.T) {
	ctx := context.Background()
	db := openMigratedSQLite(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, nil, 5, time.Hour)

	res, err := svc.CreateBatch(ctx, codes.BatchRequest{
		PlanCode: codes.PlanPro, DurationDays: 30, Count: 3,
		Channel: codes.ChannelManual, Note: "n", CreatedBy: "admin:1",
	})
	if err != nil {
		t.Fatalf("mint: %v", err)
	}

	infos, total, err := store.ListBatches(ctx, 50, 0)
	if err != nil {
		t.Fatalf("ListBatches: %v", err)
	}
	if total != 1 || len(infos) != 1 {
		t.Fatalf("total=%d len=%d", total, len(infos))
	}
	bi := infos[0]
	if bi.ID != res.BatchID || bi.Channel != codes.ChannelManual || bi.CreatedBy != "admin:1" ||
		bi.Note != "n" || bi.Total != 3 || bi.Unused != 3 || bi.Redeemed != 0 || bi.Void != 0 {
		t.Fatalf("BatchInfo = %+v", bi)
	}

	n, err := store.VoidBatch(ctx, res.BatchID)
	if err != nil || n != 3 {
		t.Fatalf("VoidBatch = %d, %v", n, err)
	}
	infos, _, err = store.ListBatches(ctx, 50, 0)
	if err != nil || infos[0].Void != 3 || infos[0].Unused != 0 {
		t.Fatalf("after void: %+v (err=%v)", infos[0], err)
	}
	// 二次 void:0 行(只动 unused)。
	if n, err := store.VoidBatch(ctx, res.BatchID); err != nil || n != 0 {
		t.Fatalf("re-void = %d, %v", n, err)
	}
}
cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run TestListBatches   # FAIL:查的还是 code_batches(已更名)
  • Step 2: 实现 —— admin_support.go 两处 SQL 换表名:

    • ListBatches:SELECT COUNT(*) FROM code_batchescodes_batches;主查询 FROM code_batches b LEFT JOIN codes c ON c.batch_id = b.idFROM codes_batches b LEFT JOIN codes c ON c.batch_id = b.id(聚合表达式 SUM(c.status='redeemed') 等在两方言下不变,保留)。
    • VoidBatch:UPDATE codes SET status = 'void', void_reason = 'batch_void' WHERE batch_id = ? AND status = 'unused'(新表有 void_reason 列,补上语义)。

    store.go 删除 CreateBatch/CreateCode/CodeExistsByHashisDuplicateKey(库内部自带判重);BatchRow 类型如无引用一并删。确认 grep -rn "store\.CreateCode\|store\.CreateBatch\|CodeExistsByHash" server --include='*.go' 零命中(webhook 已改走 MintOne)。

  • Step 3: 验证 + commit

cd /Users/wangjia/code/pangolin/server && go build ./... && go vet ./... && go test ./...
git add server/internal/codes/
git commit -m "feat(server): admin 批次列表/作废改指 codes 库新表,清理被取代的旧 store 方法(#codes-lib)"

Task 8: mysql 集成测试对齐(行为等价的另一半)

Files:

  • Modify: server/internal/codes/service_test.go(applySchema + 表名引用)

要点:该文件带 //go:build integration(testcontainers,mysql:8)。改动最小化——测试用例的断言不动,只把环境搭建对齐新 schema:

  • Step 1: applySchema(service_test.go:95-)里删除 code_batchescodes 两段 DDL(:105-125),保留 plans/users/subscriptions/audit_log 等其余 DDL,函数末尾追加:
	if err := libcodes.ApplyMigrations(context.Background(), db, libcodes.DialectMySQL); err != nil {
		return fmt.Errorf("apply codes lib migrations: %w", err)
	}

(import 加 libcodes "github.com/wangjia/codes"。)

  • Step 2: 全文件 grep 直查表名的 SQL:FROM code_batches/INTO code_batchescodes_batches;直查 codes 表的列断言若涉及 plan_id/duration_days(旧列,新表没有)→ 改为断言 entitlement_payload(如 TestCSVExportNoPlaintextInDB 检查明文不在库里:改查 SELECT COUNT(*) FROM codes WHERE code_hash=?,语义不变)。TestWebhookSameHashIdempotent 若断言了「重复灌码会多一个 batch」(旧 I2 行为)→ 改为断言 batch 数不变(新行为,无孤儿),并注明这是有意的行为改良。TestRedeemFailLock/TestRedeemConcurrentSingleWinner/TestSamePlanExtension 等的断言逻辑保持原样(公共 API 未变,应直接编译通过)。

  • Step 3: 验证 + commit(需要本机 docker;不可用则明确记录「待 Task 9 终验跑」)

cd /Users/wangjia/code/pangolin/server && go vet -tags integration ./... && go test -tags integration -count=1 -p 1 -run 'TestRedeem|TestSamePlan|TestCrossPlan|TestAudit|TestCSV|TestWebhook' ./internal/codes/
git add server/internal/codes/service_test.go
git commit -m "test(server): mysql 集成测试对齐 codes 库新表(断言不变,环境搭建换 ApplyMigrations)(#codes-lib)"

Task 9: 终验 — 全量本地验证 + 迁移彩排

  • Step 1: 全量测试矩阵
cd /Users/wangjia/code/pangolin/server
go build ./... && go vet ./...
go test ./...                       # 单测 + sqlite 真库(含新的 backfill/service/admin sqlite 用例)
./run_sqlite_test.sh                # 迁移 up/down + store 方言
go test -tags integration -count=1 -p 1 ./...   # mysql+redis testcontainers(需 docker;Task 8 若已跑可只跑失败项)
bash ../scripts/e2e-smoke.sh        # 进程级端到端:migrate up(含 ApplyCodesLibMigrations+Backfill)+ server 真起
  • Step 2: 迁移彩排(生产数据形状) —— 用 sqlite 文件库模拟「有存量数据的升级」:
cd /Users/wangjia/code/pangolin/server
rm -f /tmp/pg-rehearsal.db
# ① 装出一个 000019 时代的库:先 checkout main 的 migrations 跑 up?——不必:直接用本分支
#    migrate up 到底,再手工把 legacy 表填上模拟旧数据、清空库表、重跑 up 验证幂等即可:
DB_DRIVER=sqlite DB_DSN=/tmp/pg-rehearsal.db go run ./cmd/migrate up
sqlite3 /tmp/pg-rehearsal.db "INSERT INTO legacy_code_batches (id,channel,created_by,created_at) VALUES (1,'manual','rehearsal',datetime('now'));
INSERT INTO legacy_codes (id,code_hash,plan_id,duration_days,batch_id,status,redeemed_by) SELECT 1,'rehearsalhash0000000000000000000000000000000000000000000000000000',id,30,1,'redeemed',77 FROM plans WHERE code='pro';"
DB_DRIVER=sqlite DB_DSN=/tmp/pg-rehearsal.db go run ./cmd/migrate up    # 应打印 codes backfill — 1 legacy codes migrated
sqlite3 /tmp/pg-rehearsal.db "SELECT code_hash,status,redeemed_by FROM codes;"   # 期望 rehearsalhash…|redeemed|user:77
DB_DRIVER=sqlite DB_DSN=/tmp/pg-rehearsal.db go run ./cmd/migrate up    # 三跑:0 migrated,幂等
  • Step 3: 调用点零改动复核
cd /Users/wangjia/code/pangolin
git diff main --stat -- server/cmd/server/main.go server/cmd/codegen/main.go \
  server/internal/admin/ server/internal/auth/ server/internal/usage/
# 期望:全部 0 行改动(公共 API 冻结的证明)
  • Step 4: 收尾 commit(如有零散修正)+ 汇总——向用户报告:分支名、测试矩阵结果、两条协调项(CI runner 主机 go 模块配置;webhook 未来切 pay-contract 需发卡店协调)、已知行为差异清单(见文首)。到此为止,不 merge。

Self-Review Checklist(执行完逐条打勾)

  • git log main..feat/codes-lib-integration 每刀一 commit,信息可读。
  • go build ./... && go vet ./... && go test ./..../run_sqlite_test.sh 全绿;integration + e2e-smoke 至少各完整跑过一次。
  • 六个调用点文件 git diff main 为零(公共 API 冻结兑现)。
  • 明文码搜不到落库/日志路径:grep -rn "res.Codes\|result.Codes" server --include='*.go' 逐处目检仍只流向 CSV/HTTP 响应。
  • 回填三跑幂等(Task 9 Step 2 彩排通过);redeemed_by 全部 user:<id> 格式(新旧幂等判定衔接)。
  • webhook 协议逐字节兼容:旧 webhook_test.go 未改一行且全绿。
  • 行为差异 1-5(文首清单)都已在交付说明里向用户列明,没有偷偷夹带第 6 条。
  • 遗留项登记:① legacy_* 表 retire(验收后 cleanup 计划);② CI runner 主机 go 模块解析配置(用户执行);③ webhook 切 pay-contract(需外部协调);④ 本计划的 HTML 阅读版 + docs/index.html 登记(定稿后按仓规矩补)。

风险清单(执行者注意)

  1. 依赖抬升回归(最可能踩):modernc.org/sqlite 1.18.1→1.38.2 跨 20 个小版本,时间解析/锁行为若有细微变化会在 internal/store 既有测试暴露——Task 0 Step 3 特意在任何代码改动前先全量测试,炸了先修/上报,不带病往下走。
  2. 模块解析是环境性的:github.com/wangjia/codes 直连 GitHub 必失败(已实测),本机靠 insteadOf+GOPRIVATE,CI 靠 runner 主机同款配置+缓存预热——推分支前 CI 会红是预期,别误判为代码问题。
  3. 表名 codes 一名两用有时间窗:000020 之前的 SQL 指旧表、之后指库的新表;任何绕过本计划直查 codes 表的旁路代码(当前 grep 无,除 internal/codes 与两个测试)若在执行期间被并行分支引入,会静默读错表——终验 Step 3 的 diff 复核兜底。
  4. mysql 显式插 id 回填:AUTO_INCREMENT 会自动越过回填的最大 id(mysql 8 行为),但若未来改用 MariaDB/其他分支需复验;sqlite AUTOINCREMENT 序列同样自动抬升,已由回填测试覆盖。