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

1574 lines
71 KiB
Markdown

# 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 审计页丢兑换记录,破坏行为等价 |
## 已知行为差异(有意为之,验收时说明)
1. **防爆破 Redis key 变化**:`redeem:fail:<uid>``redeem:fail:user:<uid>`(redisx.RateLimiter 以 redeemerRef 为 key)。部署瞬间在途失败计数清零,无安全影响。
2. **幂等重放现在会清零失败计数**(GuardedRedeem 对 Idempotent 成功也 Reset;旧代码幂等路径提前 return 不清零,service.go:162-171)。合法用户重放清计数,无害。
3. **MarkRedeemed 有 CAS 校验了**(库 store.go:171-200 检查 RowsAffected;旧实现 store.go:159-168 不检查,竞态输家会静默双发订阅)。严格更安全。
4. **CreateBatch 全成或全无**(库 Mint 单事务;旧实现 batch 行先落库、后续失败留孤儿 batch,service.go:342-345)。严格更干净。webhook 同理(旧 I2:重复码留孤儿 batch,webhook.go:145-158;新壳单事务无孤儿)。
5. **依赖版本抬升**:`modernc.org/sqlite` 1.18.1→1.38.2、`go-redis/v9` 9.20.1→9.21.0(MVS 取库要求的高版本)。全量测试守门(Task 9)。
## Global Constraints
- **分支**:所有 commit 落在 `feat/codes-lib-integration`(从 `main` 切出)。**终点是本地验证通过,不 merge / 不 push 部署 / 不动 pangolin1**。
- **公共 API 冻结**:`internal/codes` 对外的类型与构造函数签名**一个不改**——`cmd/server/main.go:296-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:<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:363` `POST /v1/webhook/store/codes`;HMAC 仅对 body,`X-Pangolin-Signature: sha256=<hex>`,webhook.go:172-195;时间窗 ±tolerance webhook.go:199-218;nonce Redis SETNX **先存后铸**,webhook.go:103-113;payload `{code,plan,duration_days,note}`)。**仓内无调用方代码**(grep `X-Pangolin-Signature` 仅测试文件命中)→ 调用方为外部发卡店。
- admin 面:`internal/codes/admin_support.go:25-70 ListBatches`(code_batches LEFT JOIN codes 聚合计数)、`:74-85 VoidBatch`;经 `internal/admin/services.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":"<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`:
```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=<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`:
```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:<id>` 格式(新旧幂等判定衔接)。
- [ ] webhook 协议逐字节兼容:旧 `webhook_test.go` 未改一行且全绿。
- [ ] 行为差异 1-5(文首清单)都已在交付说明里向用户列明,没有偷偷夹带第 6 条。
- [ ] 遗留项登记:① legacy_* 表 retire(验收后 cleanup 计划);② CI runner 主机 go 模块解析配置(用户执行);③ webhook 切 pay-contract(需外部协调);④ 本计划的 HTML 阅读版 + `docs/index.html` 登记(定稿后按仓规矩补)。
## 风险清单(执行者注意)
1. **依赖抬升回归**(最可能踩):`modernc.org/sqlite` 1.18.1→1.38.2 跨 20 个小版本,时间解析/锁行为若有细微变化会在 `internal/store` 既有测试暴露——Task 0 Step 3 特意在任何代码改动前先全量测试,炸了先修/上报,不带病往下走。
2. **模块解析是环境性的**:`github.com/wangjia/codes` 直连 GitHub 必失败(已实测),本机靠 insteadOf+GOPRIVATE,CI 靠 runner 主机同款配置+缓存预热——**推分支前 CI 会红是预期**,别误判为代码问题。
3. **表名 `codes` 一名两用有时间窗**:000020 之前的 SQL 指旧表、之后指库的新表;任何绕过本计划直查 `codes` 表的旁路代码(当前 grep 无,除 internal/codes 与两个测试)若在执行期间被并行分支引入,会静默读错表——终验 Step 3 的 diff 复核兜底。
4. **mysql 显式插 id 回填**:AUTO_INCREMENT 会自动越过回填的最大 id(mysql 8 行为),但若未来改用 MariaDB/其他分支需复验;sqlite AUTOINCREMENT 序列同样自动抬升,已由回填测试覆盖。