Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
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):旧表更名封存(codes→legacy_codes、code_batches→legacy_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,197 读 audit_log——不双写则 admin 审计页丢兑换记录,破坏行为等价 |
已知行为差异(有意为之,验收时说明)
- 防爆破 Redis key 变化:
redeem:fail:<uid>→redeem:fail:user:<uid>(redisx.RateLimiter 以 redeemerRef 为 key)。部署瞬间在途失败计数清零,无安全影响。 - 幂等重放现在会清零失败计数(GuardedRedeem 对 Idempotent 成功也 Reset;旧代码幂等路径提前 return 不清零,service.go:162-171)。合法用户重放清计数,无害。
- MarkRedeemed 有 CAS 校验了(库 store.go:171-200 检查 RowsAffected;旧实现 store.go:159-168 不检查,竞态输家会静默双发订阅)。严格更安全。
- CreateBatch 全成或全无(库 Mint 单事务;旧实现 batch 行先落库、后续失败留孤儿 batch,service.go:342-345)。严格更干净。webhook 同理(旧 I2:重复码留孤儿 batch,webhook.go:145-158;新壳单事务无孤儿)。
- 依赖版本抬升:
modernc.org/sqlite1.18.1→1.38.2、go-redis/v99.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-300、cmd/codegen/main.go:81-87、internal/admin/router.go:56-57、internal/admin/services.go:53-110、internal/auth/middleware.go:45、internal/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/CHECKstore|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正是从 pangolininternal/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-287applySubscription—— 同 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 keyredeem:fail:<uid>,INCR+EXPIRE,val >= failMax即锁;rdb==nil 时全部 no-op)。 - 审计:
service.go:199-208写audit_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:363POST /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})。仓内无调用方代码(grepX-Pangolin-Signature仅测试文件命中)→ 调用方为外部发卡店。 - admin 面:
internal/codes/admin_support.go:25-70 ListBatches(code_batches LEFT JOIN codes 聚合计数)、:74-85 VoidBatch;经internal/admin/services.go的CodesService接口消费。 - 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-57、internal/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) error、FindByHash、Void、WriteAudit。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;Allowed为val < 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 事件:codes→legacy_codes、code_batches→legacy_code_batches(为库的同名 codes 表让位)。
- Step 1: 先改测试(失败先行) ——
sqlite_migrate_test.go::33附近if v != 19→if 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.go 的 case "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);ErrDuplicate 改 var ErrDuplicate = libcodes.ErrDuplicate(删除 errors.New,pangolin internal/idgen 的 import 从本文件移除——不删 internal/idgen 包本身,别处可能引用,Task 9 清点)。
service.go 的 CreateBatch(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
ErrLocked→apierr.ErrLocked;ErrCodeNotFound→apierr.ErrCodeNotFound;ErrCodeRedeemed→apierr.ErrCodeRedeemed;ErrCodeVoid→apierr.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.go 的 FindCodeByHashForUpdate/MarkRedeemed/CodeRow(已被库取代;sqlite_stores_test.go 会编译失败——在本 Task 顺手改:删掉 internal/store/sqlite_stores_test.go 的 TestSQLite_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\nbodyMAC,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_batches→codes_batches;主查询FROM code_batches b LEFT JOIN codes c ON c.batch_id = b.id→FROM 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/CodeExistsByHash和isDuplicateKey(库内部自带判重);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_batches与codes两段 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_batches→codes_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登记(定稿后按仓规矩补)。
风险清单(执行者注意)
- 依赖抬升回归(最可能踩):
modernc.org/sqlite1.18.1→1.38.2 跨 20 个小版本,时间解析/锁行为若有细微变化会在internal/store既有测试暴露——Task 0 Step 3 特意在任何代码改动前先全量测试,炸了先修/上报,不带病往下走。 - 模块解析是环境性的:
github.com/wangjia/codes直连 GitHub 必失败(已实测),本机靠 insteadOf+GOPRIVATE,CI 靠 runner 主机同款配置+缓存预热——推分支前 CI 会红是预期,别误判为代码问题。 - 表名
codes一名两用有时间窗:000020 之前的 SQL 指旧表、之后指库的新表;任何绕过本计划直查codes表的旁路代码(当前 grep 无,除 internal/codes 与两个测试)若在执行期间被并行分支引入,会静默读错表——终验 Step 3 的 diff 复核兜底。 - mysql 显式插 id 回填:AUTO_INCREMENT 会自动越过回填的最大 id(mysql 8 行为),但若未来改用 MariaDB/其他分支需复验;sqlite AUTOINCREMENT 序列同样自动抬升,已由回填测试覆盖。