diff --git a/docs/superpowers/plans/2026-07-10-pangolin-codes-lib-integration.md b/docs/superpowers/plans/2026-07-10-pangolin-codes-lib-integration.md new file mode 100644 index 0000000..28d51f6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pangolin-codes-lib-integration.md @@ -0,0 +1,1573 @@ +# 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:"", 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:"` 格式化,SQL 双方言各写一套 JSON 拼接极易漂移;Go 一份代码天然双方言、可单测、可幂等重跑 | +| **webhook** | **保留 pangolin 现有端点与 HMAC 协议**(`POST /v1/webhook/store/codes`,`X-Pangolin-Signature: sha256=` 仅对 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 审计页丢兑换记录,破坏行为等价 | + +## 已知行为差异(有意为之,验收时说明) + +1. **防爆破 Redis key 变化**:`redeem:fail:` → `redeem:fail:user:`(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-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/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:`,INCR+EXPIRE,`val >= failMax` 即锁;rdb==nil 时全部 no-op)。 +- 审计:`service.go:199-208` 写 `audit_log`(actor=`user:`,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=`,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.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: 切分支** + +```bash +cd /Users/wangjia/code/pangolin +git checkout main && git pull +git checkout -b feat/codes-lib-integration +``` + +- [ ] **Step 2: 本机一次性 go 模块解析配置**(精确映射单仓,不映射整个 `github.com/wangjia/`,避免误伤真 GitHub 仓库) + +```bash +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 依赖** + +```bash +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** + +```bash +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"`。 + +```bash +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`: +```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`: +```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`: +```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`: +```sql +ALTER TABLE legacy_code_batches RENAME TO code_batches; +ALTER TABLE legacy_codes RENAME TO codes; +``` + +- [ ] **Step 3: 验证 + commit** + +```bash +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`: + +```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) + } + } +} +``` + +```bash +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"`): + +```go +// 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"`): + +```go + 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** + +```bash +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":"","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:"`(与新兑换路径的 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`: + +```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) + } +} +``` + +```bash +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` 幂等判重,双方言零分叉): + +```go +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` 之后: + +```go + 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** + +```bash +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): + +```go +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 与本文件共用,避免重名。) + +```bash +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 改为: + +```go +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)替换为: + +```go +// 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** + +```bash +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` 追加: + +```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 常跑): + +```go +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`,写测试前打开确认常量拼写。) + +```bash +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` 依赖): + +```go +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` 原样保留): + +```go +// 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** + +```bash +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=`(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`: + +```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"`。) + +```bash +cd /Users/wangjia/code/pangolin/server && go test ./internal/codes/ -run TestWebhookMints # FAIL:undefined MintOne +``` + +- [ ] **Step 2: 实现** + +`store.go` 追加: + +```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)替换: + +```go + // --- 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** + +```bash +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`: + +```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) + } +} +``` + +```bash +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** + +```bash +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,函数末尾追加: + +```go + 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 终验跑」) + +```bash +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: 全量测试矩阵** + +```bash +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 文件库模拟「有存量数据的升级」: + +```bash +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: 调用点零改动复核** + +```bash +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:` 格式(新旧幂等判定衔接)。 +- [ ] 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 序列同样自动抬升,已由回填测试覆盖。