chore: move the server to an independent folder

This commit is contained in:
RainbowBird
2026-08-02 18:43:57 +08:00
parent 771ba3f8a7
commit 4ccde2c96e
293 changed files with 176 additions and 3020 deletions
@@ -1,300 +0,0 @@
# Character Cards Cloud Sync — Phase 1 Design
> Date: 2026-05-09
> Status: Design (pending approval)
> Owner: @RainbowBird
> Track: Phase 1 of A (大整合 — 三 store 合一 + 云同步)
## 1. Goal & Non-Goals
### Goal
让所有 stage 入口(stage-tamagotchi / stage-web / stage-pocket)的角色卡数据从纯本地 `localStorage` 升级为「**本地永远是 source of truth on device + 登录后云同步**」,并清理「三 store 并存」技术债的第一刀(废 `stores/characters.ts` 孤岛页)。
### Non-Goals(本 Phase 不做)
- **Phase 2**:删 [`stores/character/index.ts`](../../../packages/stage-ui/src/stores/character/index.ts) facade,统一 character 调用入口
- **Phase 3**rename `useAiriCardStore``useCharacterStore`,命名对齐 server
- Settings 云同步(独立 spec,等本 spec 的 sync engine 落地后再接)
- Marketplace 上架路径(`user_characters``characters` 的 explicit publish transform
- 多设备并发实时编辑(per-field LWW / vector clock / 冲突 UI — α-full 范围)
- Server-pushSSE / WS)同步推送 — 本 Phase 是被动 pull on focus / login
## 2. 现状
3 个并行 store 在管「角色卡」概念:
| # | Store | 数据形态 | 存储 | 用户路径 |
|---|-------|---------|------|---------|
| 1 | `useAiriCardStore` ([`stores/modules/airi-card.ts`](../../../packages/stage-ui/src/stores/modules/airi-card.ts)) | CCv3 jsonb + airi extension | `useLocalStorageManualReset<Map<string,AiriCard>>('airi-cards')` | **事实上的运行时角色卡** — 三端 App.vue / chat / Stage / profile-switcher / artistry / sessions-drawer,调用点 25+ |
| 2 | `useCharacterStore` ([`stores/characters.ts`](../../../packages/stage-ui/src/stores/characters.ts)) | 关系化 (character + i18n + capabilities + avatar + cover) | server `/characters` API + `@pinia/colada` | **孤岛**:仅 [`apps/stage-web/src/pages/settings/characters/`](../../../apps/stage-web/src/pages/settings/characters/) |
| 3 | `useCharacterStore` ([`stores/character/index.ts`](../../../packages/stage-ui/src/stores/character/index.ts)) ← 同名!| facade of #1 | — | [v2/index.vue](../../../packages/stage-pages/src/pages/v2/index.vue) / devtools/context-flow |
Server `characters` 表([`apps/server/src/schemas/characters.ts`](../../../apps/server/src/schemas/characters.ts))已经是 marketplace 形态(`likesCount` / `forksCount` / `priceCredit` / `character_i18n` 多语言 / `character_capabilities` / `avatar_model` / `character_covers`),跟 client AiriCard CCv3 schema 完全不同。
## 3. 终态(Phase 1 完成后)
- `useAiriCardStore` 内部存储从 `localStorage`-only 升级到「**localStorage 主路径 + sync engine 后台同步到 server**」
- 25+ 调用面 path`activeCard.x.y.z`)**完全不变**——只换内部存储和加 sync 层
- 现有 `stores/characters.ts` 孤岛被废:删 store/service/model 文件,[`apps/stage-web/src/pages/settings/characters/`](../../../apps/stage-web/src/pages/settings/characters/) 改用统一的 [`packages/stage-pages/src/pages/settings/airi-card/`](../../../packages/stage-pages/src/pages/settings/airi-card/) 页(stage-tamagotchi 已经在用)
- Server 新建 `user_characters` + `user_active_character`
- Server 现有 `/characters` 路由和 `characters` 表**保留不动**marketplace 用,未来 spec 接入)
## 4. 关键设计决策
| # | 决策 | 选择 | 拒绝理由 |
|---|------|------|---------|
| D1 | 范围 | A 拆 3 Phase,本次只做 Phase 1 | 不拆 = PR 太大风险高;只做 B(不合 store)= 留二次重构债 |
| D2 | 未登录态 | **α-min**:本地可写 + 登录后 union by clientId 上传 | β(强制登录)破坏离线 UX、炸老用户;γ(不 merge)默默丢卡 |
| D3 | `activeCardId` 同步粒度 | per-user | per-device 违反「养一个 AI 角色」产品直觉 |
| D4 | server schema | 两张表(`user_characters` + 现存 `characters` | 单表 + visibility 字段:marketplace/private 权限边界易漏;多语言关系字段对私有卡冗余 |
| D5 | Delete 语义 | soft delete`deletedAt` tombstone)参与 LWW | hard delete = 多设备 race 复活已删卡 |
| D6 | 同步触发 | `watchDebounced` 自动后台 sync (2s) + retry 队列 | 手动按钮 = 用户感知不一致状态 |
| D7 | 多设备并发 | 整卡 LWW by `updatedAt` | per-field LWW / vector clock = α-full 范围,过度工程 |
| D8 | CCv3 import/export | 复用现有 `addCard` 路径,import 后自动入 sync 队列 | — |
## 5. 数据模型
### 5.1 Server Schema
新建文件 `apps/server/src/schemas/user-characters.ts`
```ts
import type { AiriCard } from '@proj-airi/stage-ui/types/airi-card'
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
// NOTICE: bare ownerId is intentional — no FK to user.id. better-auth hard-deletes
// the user row; a cascade would wipe these soft-delete archive rows.
// See `apps/server/docs/ai-context/account-deletion.md`.
export const userCharacters = pgTable(
'user_characters',
{
id: text('id').primaryKey().$defaultFn(() => nanoid()),
ownerId: text('owner_id').notNull(),
// client 端 nanoid,跨设备稳定标识同一张卡。server 端 PUT 用它做 idempotency。
clientId: text('client_id').notNull(),
// 完整 CCv3 + airi extensionlossless 兜底。
rawCard: jsonb('raw_card').notNull().$type<AiriCard>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
table => ({
ownerClientUniq: uniqueIndex('user_characters_owner_client_uniq').on(table.ownerId, table.clientId),
ownerIdx: index('user_characters_owner_idx').on(table.ownerId),
}),
)
export type UserCharacter = InferSelectModel<typeof userCharacters>
export type NewUserCharacter = InferInsertModel<typeof userCharacters>
export const userActiveCharacter = pgTable(
'user_active_character',
{
ownerId: text('owner_id').primaryKey(),
activeClientId: text('active_client_id').notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type UserActiveCharacter = InferSelectModel<typeof userActiveCharacter>
```
### 5.2 Client Internal State
`useAiriCardStore` 现有 `cards: Map<string, AiriCard>` + `activeCardId: string` 不变。新增 internal
```ts
interface SyncOp { kind: 'upsert' | 'delete', clientId: string }
interface SyncState {
status: 'offline' | 'unauthenticated' | 'syncing' | 'synced' | 'error'
pendingOps: Map<string, SyncOp> // by clientId, 最后一笔操作覆盖前面
lastSyncedAt: number | null
lastError: string | null
}
```
`pendingOps` 持久化到 `localStorage` 一个独立 key`airi-cards-pending-ops`),App 重启后能继续 flush。
## 6. API 设计
新建 `apps/server/src/routes/user-characters/`
| Method | Path | 用途 | Body |
|--------|------|------|------|
| GET | `/user-characters` | 列出当前用户全部卡 (含 tombstone) | — |
| PUT | `/user-characters/:clientId` | upsert 一张卡(按 ownerId+clientId 唯一);LWW by `updatedAt` | `{ rawCard: AiriCard, updatedAt: string }` |
| DELETE | `/user-characters/:clientId` | soft delete (set `deletedAt = now()`) | — |
| GET | `/user-characters/active` | 取当前 activeClientId | — |
| PUT | `/user-characters/active` | 设置 activeClientId | `{ activeClientId: string }` |
所有路由走 `authGuard`,按 `ownerId = currentUser.id` 过滤。
PUT `/user-characters/:clientId` 的 LWW 逻辑:
- 不存在 → INSERT
- 存在且 `deletedAt IS NULL`
- `incoming.updatedAt > existing.updatedAt` → UPDATE
- `incoming.updatedAt <= existing.updatedAt` → 返回 200 + existing(不覆盖;客户端发现 server 比自己新会拉回来)
- 存在且 `deletedAt IS NOT NULL`tombstone):
- `incoming.updatedAt > existing.deletedAt`**复活**:清 `deletedAt` + UPDATE 内容(last operation wins,不论是 edit 还是 delete
- `incoming.updatedAt <= existing.deletedAt` → 返回 200 + tombstone(编辑发生在删除前,不复活;客户端拉回 tombstone 后会本地删除)
DELETE 走相同的 LWW,比较 `incoming.deletedAt` (= now()) 与 `existing.updatedAt`
- `incoming.deletedAt > existing.updatedAt` → 设置 tombstone
- 否则 → 拒绝(罕见 caseclient 本地 clock 漂移)
## 7. 同步流程(α-min
### 7.1 First-Sync(登录后首次)
```
GET /user-characters
┌─────────────────────────────────┐
│ │
▼ │
client.cards (Map<clientId, AiriCard>) ──┐ │
├── union ──▶│
server.user_characters (列表) ───────────┘ by │
clientId │
▼ │
┌───────────────────────────────────┘
├─ local-only 卡(server 没有同 clientId)─▶ PUT 上传
├─ server-only 卡 (deletedAt IS NULL) ─▶ 加入 local Map
├─ server-only 卡 (deletedAt IS NOT NULL) ─▶ 忽略(tombstone,不需要回放到 local
└─ 同 clientId 两边都有 ─▶ 比较 max(local.updatedAt) vs max(server.updatedAt, server.deletedAt)
├─ local 时间戳更新 ─▶ PUT 上传(server 接受 LWW,可能复活 tombstone
├─ server.updatedAt 更新 ─▶ 写入 local Map
└─ server.deletedAt 更新 ─▶ 从 local Map 删除
```
### 7.2 切账号(logout → login 不同账号)
logout 触发时:
1. 检查 `pendingOps` 非空 OR 本地有未同步过的卡(无 server 记录的 clientId
2. 非空 → 弹 modal:「未同步的 N 张卡 — [归当前账号 (上传后再 logout)] [丢弃] [取消]」
3. 用户选「归当前账号」→ 等 sync queue flush 完成 → logout
4. 用户选「丢弃」→ wipe local `cards` Map + `pendingOps` → logout
5. login 后走 First-Sync
### 7.3 后续 Reconcile(登录态正常运行)
- `cards` Map watchDebounced(2s) 触发 → diff 出变更 → enqueue → flush
- `activeCardId` 变更 → debounce 1s → PUT `/user-characters/active`
- 失败 → 指数 backoff (1s → 2s → 4s ... cap 30s) → 网络恢复后 flush
- 离线 → ops 留在 `pendingOps`(已持久化),上线后 flush
- 启动 / window focus → GET `/user-characters` + `/user-characters/active` 拉一次(被动 pull
## 8. Client 改造
### 8.1 `useAiriCardStore` 内部改造(调用面不变)
新增 `packages/stage-shared/src/sync/airi-cards-sync-engine.ts`(放 `stage-shared` 而非 `stage-ui`,理由:未来 settings sync 复用同一 engine — per AGENTS.md「shared logic in packages/」):
- `enqueueUpsert(clientId)` / `enqueueDelete(clientId)`:立即把 op 推进 `pendingOps`**不**触发网络请求
- `flush()`:把 `pendingOps` 批量 PUT/DELETE 到 serverdebounced 2s
- `firstSync(authedUserId)`:登录后调用一次,按 §7.1 算法
- `pullFromServer()`focus / 启动调用,GET `/user-characters` + `/user-characters/active`
`useAiriCardStore` 内部:
- `addCard` / `updateCard` / `removeCard` 写完 `cards` Map 后**立即** `enqueueUpsert/enqueueDelete`(同步、无延迟)
- `flush()` 由 watchDebounced(2s) 在 `cards``pendingOps` 任一变化时触发
- 新增 internal `_hydrateFromServer(serverCards)` 用于 first-sync
- `pendingOps` 持久化到独立 localStorage key`airi-cards-pending-ops`),App 启动时恢复,登录后第一件事是 flush
### 8.2 废 `stores/characters.ts` 孤岛
删除(不留 deprecation 包装层):
- `packages/stage-ui/src/stores/characters.ts`
- `packages/stage-ui/src/services/characters.ts`
- `packages/stage-ui/src/models/characters.ts`
- `packages/stage-ui/src/types/character.ts`(除非 server route 仍引用,需先确认)
- `apps/stage-web/src/pages/settings/characters/`(整个目录)
- `apps/stage-web/src/pages/settings/characters/components/`
stage-web 的 `/settings/characters` 路由改重定向到 `/settings/airi-card`(已经是 stage-tamagotchi 在用的统一页)。
### 8.3 `stores/character/index.ts` facade 不动
Phase 2 处理。本 Phase 不动 [v2/index.vue](../../../packages/stage-pages/src/pages/v2/index.vue) 和 devtools/context-flow 的调用方。
## 9. 错误处理
| 场景 | 处理 |
|------|------|
| 网络错误 | sync 进 retry 队列;UI 在 settings/airi-card 顶部小 banner 显示「云同步暂时挂了,本地仍可编辑」 |
| 401 认证失效 | 清空 sync 队列;触发 logout flow(不弹切账号 modal — 因为不是用户主动 logout) |
| 400 schema 校验失败 | server 返回 valibot issuesclient 把这张卡 mark `syncStatus=error`pendingOps 中移除(避免无限重试),devtools 暴露原始 error |
| 5xx | retry queue + 指数 backoff |
| 启动时 server 不可达 | 进 `offline` 状态,本地照常使用,恢复后 first-sync |
## 10. Migration
- 旧用户升级版本:`useAiriCardStore` 加载现有 `localStorage['airi-cards']` Map(不变)
- 用户登录 → first-sync 把整个 Map 上传
- 不需要写一次性 migration script
- 现有 `localStorage` key 保留:`airi-cards`, `airi-card-active-id`
## 11. 测试策略
### Unit (Vitest)
- `airi-cards-sync-engine.spec.ts`
- first-sync union 算法(all-local-only / all-server-only / mixed / 同 clientId LWW
- tombstone 抑制复活:local 修改 < server.deletedAt → server wins
- enqueue/flush 队列幂等
- `pendingOps` 持久化 + 启动恢复
- 切账号 modal 三个分支(归当前 / 丢弃 / 取消)
- `routes/user-characters/route.test.ts`
- CRUDPUT idempotent by clientId
- ownership 隔离(用户 A 看不到用户 B 的卡)
- LWW: 旧 updatedAt 的 PUT 不覆盖 server
- soft delete 行为
### Integration
- `useAiriCardStore` first-sync 端到端(mock fetch + memdb
- 401 触发的 silent logout flow
### Verification(端到端用户路径)
落到 `docs/ai/context/verifications/character-cards-cloud-sync-phase-1.md`。每条用户路径一个文件。Phase 1 必须通过的:
| # | 用户路径 | 验证命令/操作 | 预期 |
|---|---------|--------------|------|
| V1 | 未登录用户继续创建/编辑卡 | 启动 stage-tamagotchi 不登录 → 创建卡 ARIA → 重启 | ARIA 仍在 |
| V2 | 首次登录上传本地卡 | V1 之后登录账号 X → web 端登录账号 X | web 端看到 ARIA |
| V3 | 多设备增量同步 | 桌面编辑 ARIA personality → 等 watchDebounced(2s) flush → web 端切回 tab 触发 focus pull | web 端看到更新 |
| V4 | 多设备删除同步 | 桌面 delete ARIA → 等 flush → web 端切回 tab 触发 focus pull | ARIA 不见 |
| V5 | 切账号确认 modal | 已登录 X 创建未同步本地卡 B → logout | 弹 modal「B 归 X / 丢弃 / 取消」 |
| V6 | activeCard 多端切换 | 桌面切到 ARIA → web 端 reload | web 端 active 是 ARIA |
| V7 | 离线编辑 + 上线 flush | 离线创建/编辑卡 → 上线 5s | 云端可见 |
| V8 | 孤岛页清理 | 升级前在 stage-web/settings/characters 用过该页 | 升级后路由 redirect 到 /settings/airi-card,孤岛页不存在 |
## 12. Open Questions(写 plan 时再钉)
- **Q1**:现有 `stores/characters.ts` 孤岛页用户已创建的关系化数据(`character` + `character_i18n` + `character_capabilities`)怎么处理?
- 选项 A:写 transform script `relational → AiriCard CCv3` 一次性 migrate 进 `user_characters`
- 选项 B:冷处理 + 在迁移说明里告知「此页面已停用,原数据请重新创建」
- 倾向 B(孤岛页用户极少,transform 边界 case 多易出 bug)。需用户确认。
- **Q2**`activeClientId` 为什么单独一张表而不是给 `user_characters``isActive` 字段?
- 单独表:每用户至多一行,PK = ownerId,更新简单;不需要清旧 active
- 加字段:要保证「至多一行 isActive=true」需要 partial unique index + 切换时事务
- 倾向单独表。需用户确认。
- **Q3**(已在 §8.1 决定 sync engine 放 `packages/stage-shared/src/sync/`,理由:未来 settings sync 复用 + AGENTS.md 「shared logic in packages/」)
---
> **Next**approve 后调用 `superpowers:writing-plans` skill 生成实现计划。
> Phase 2 / 3 / settings-sync 不在本 spec 范围。
-220
View File
@@ -1,220 +0,0 @@
# Verification 自动化方案
设计稿,未实施。落到这里是为了让 verification 流程从「人工跑命令贴输出」走向「机器跑断言贴 evidence」,同时保留 AGENTS.md 里 Iron Law 的语义。
## TL;DR
1. **原因**:现有 5 份 verification 文档结构清晰,但执行步骤需要人工跑命令、人工贴输出、人工记录「最后验证」日期。一旦超过 30 天,AGENTS.md 规定默认 unverified,没有机制能识别这种过期。
2. **猜想**verification 文档继续作为 single source of truth,每份文档关联一份可执行 artifactartifact 跑通就是 evidence,跑通时间就是「最后验证」。
3. **决策**:分三层实施,集成测试覆盖 in-repo 可重现路径,live verifier 覆盖只能在已部署环境验证的路径,CI 守护过期时间。
## 背景
`apps/server/docs/ai-context/verifications/` 下 5 份文档,结构基本统一:
- `场景 / 用户路径`:写明用户敲 X,预期得到 Y
- `命令 / 步骤`:手工敲的 curl、SQL、UI 操作
- `预期 / 实际输出`:贴 response body、log 节选、screenshot 路径
- `Evidence`:commit SHA、行号引用、测试文件路径
- `Status``最后验证`:人工维护
其中 3 份文档(`flux-unbilled-exploit-fix``flux-unbilled-reconciliation``admin-flux-grants`)引用了已落库的 vitest 单测,剩下 2 份(`email-auth``account-deletion`)以手工 curl + 真实 Resend / 真实数据库为主。
## 拆解现状
把 5 份文档里的步骤按「证据来源」拆开,能看到三类:
1. **纯代码路径**,例如 partial-debit 的数值逻辑、ledger 行写入。这类已经被 vitest 单测覆盖,证据来源是 `expect()` 断言。
2. **跨外部边界的用户路径**,例如「N 个并发 LLM completion 触发 pre-flight 拒绝 + ledger 写入 + metric 上报」。这类需要 pg、redis、Hono app、Prometheus `/metrics` 端点同时在场,目前没有自动化覆盖。
3. **依赖部署环境的路径**,例如 Resend 真实投递、Stripe webhook 回调、Grafana panel 斜率、Better Auth 跨域 OIDC handoff。这类无论在 PR CI 还是本地都无法完整跑通,必须在 staging 或 prod 上验证。
第 1 类已经自动化,第 2、3 类是空缺。
## 提出猜想
verification 文档的「用户路径」描述天然适合作为测试用例标题。如果给每份文档加一份配套 artifact,artifact 类型按上面三类分发:
- 纯代码路径,归到 `*.test.ts`,已经这样做
- 跨边界的用户路径,归到 `*.integration.test.ts`testcontainers 起依赖
- 依赖部署环境的路径,归到 `*.verifier.ts`,针对 staging URL 跑,post-deploy 触发
每份文档头部加一段 frontmatter,机器读取后能回答三个问题:
1. 这份文档对应的 feature 是什么
2. 自动化 artifact 在哪里
3. 上次自动化跑通是什么时候
## 分节解答
### 一、frontmatter schema
```yaml
---
feature: flux-unbilled-exploit-fix
owner: rbxin2003@gmail.com
automated_by:
- kind: unit
path: apps/server/src/services/billing/tests/billing-service.test.ts
cases:
- 'rejects pre-flight when balance is below FLUX_PER_REQUEST'
- 'non-streaming completion drains partial balance and logs charged'
- kind: integration
path: apps/server/tests/verifications/flux-unbilled.integration.test.ts
- kind: live
path: apps/server/tests/verifications/flux-unbilled.verifier.ts
schedule: post-deploy
last_verified:
unit: 2026-05-15
integration: 2026-05-15
live: 2026-05-14
expires_after_days: 30
---
```
字段语义钉死:
- `feature`:文档 slug,与文件名同名
- `automated_by[].kind``unit` / `integration` / `live`,三选一
- `automated_by[].path`:可执行文件路径,CI 跑通后能写回 `last_verified`
- `last_verified.<kind>`YYYY-MM-DD,由 CI 自动写回,人不手动改
- `expires_after_days`:默认 30,与 AGENTS.md 一致
### 二、集成测试 harness
放在每个 app 下的 `tests/verifications/` 目录,例如 `apps/server/tests/verifications/`。harness 提供:
1. testcontainers 起 Postgres 16 + Redis 7,注入与 `.env.example` 同 schema 的环境变量
2. `createApp()` 直接 mount,不走真实端口,调用 `app.request(...)`
3. 三种断言入口:
- HTTP 响应,按现有 `app.test.ts` 范式
- DB 状态,通过 drizzle 查 `flux_transaction` / `user_flux`
- Metric 状态,scrape `/metrics` 文本,匹配 `airi_billing_flux_unbilled_total{...} <value>`
最小测试骨架:
```ts
describe('verification: flux-unbilled-exploit-fix', () => {
let ctx: VerificationContext
beforeAll(async () => {
ctx = await startVerificationContext()
})
afterAll(async () => {
await ctx.stop()
})
it('concurrent partial-balance requests yield one partial debit and N-1 pre-flight 402', async () => {
await ctx.seedUser({ id: 'u1', balance: 5 })
await ctx.setConfig({ FLUX_PER_REQUEST: 100 })
const responses = await Promise.all(
Array.from({ length: 5 }, () => ctx.app.request('/api/v1/openai/...')),
)
expect(responses.filter(r => r.status === 402)).toHaveLength(5)
const ledger = await ctx.db.query.fluxTransaction.findMany({ where: { userId: 'u1' } })
expect(ledger).toHaveLength(0)
const metrics = await ctx.scrapeMetrics()
expect(metrics).toMatchMetric('airi_billing_flux_unbilled_total', {
labels: { reason: 'partial_debit_drained' },
delta: 0,
})
})
})
```
`MatchMetric``scrapeMetrics` 这两个 helper 放在 `packages/server-runtime``apps/server/src/testing/`,由集成测试和 live verifier 共用。
### 三、live verifier
针对 staging / prod。形态选 vitest 也可以,选独立 CLI 也可以,差别在「是否需要被 CI 用 `--include` pattern 隔离」。建议直接沿用 vitest,给文件后缀 `.verifier.ts`,配 `vitest.config.ts``include` / `exclude` 把它们与 unit / integration 隔离。
live verifier 的断言对象不再是「mount 的 Hono app」,是「真实 URL」:
```ts
describe('live verifier: flux-unbilled-exploit-fix', () => {
it('panel-43 slope is below alert threshold over the last 5 minutes', async () => {
const slope = await prometheusQuery(
'increase(airi_billing_flux_unbilled_total[5m])',
{ url: process.env.PROM_URL! },
)
expect(slope).toBeLessThan(0.5)
})
})
```
需要凭据的项目(Prometheus、Resend、Stripe)通过 env 注入,与 `secrets-management` 规则一致,不写进文件。
### 四、CI 编排
三条 GitHub Actions workflow
1. **`verification-unit.yml`**PR 触发,跑全部 `*.test.ts`。现状已有,作为 baseline。
2. **`verification-integration.yml`**PR 触发,跑全部 `*.integration.test.ts`。预计单跑 60 至 180 秒(testcontainers 启动),用 matrix 拆分到多个 worker。仅在改动触及 `apps/server/**``packages/server-*/**` 时跑,其他改动 skip。
3. **`verification-live.yml`**post-deploy 触发(Railway deploy hook → GitHub repository_dispatch),针对 staging URL 跑全部 `*.verifier.ts`。跑通后自动 PR 一份更新 `last_verified.live` 的提交,或者直接 commit 回 main(按团队偏好选)。
第 2 类必要的 secrettestcontainers 自身不需要 secret,只需要 docker daemonGitHub Actions runner 默认带。第 3 类需要 `PROM_URL``PROM_TOKEN``STRIPE_TEST_KEY``RESEND_API_KEY` 等,放到 GitHub Actions secrets。
### 五、过期守护
新增 `scripts/verification-doctor.ts`,在 `verification-unit.yml` 末尾跑:
```ts
// 遍历所有 verification 文档
// 读 frontmatter.last_verified
// 与 frontmatter.expires_after_days 比较
// 超期 -> stderr 报告 + exit 1
```
CI 失败时输出形如:
```
✗ flux-unbilled-reconciliation: last_verified.integration = 2025-12-01 (expired 165 days)
✗ email-auth: last_verified.live = (none)
```
主分支跑过期检查也跑,跑失败不阻塞 main,只发到 Slack / Lark 通知频道,避免老文档过期把全员卡住。
## 回指前文
回到 TL;DR 的三条决策:
1. 「集成测试覆盖 in-repo 可重现路径」对应第二节,testcontainers + drizzle + metric scrape 是这一层的最小工具集。
2. 「live verifier 覆盖只能在已部署环境验证的路径」对应第三节,针对真实 URL 跑 Prometheus query、Stripe test mode、Resend dashboard API。
3. 「CI 守护过期时间」对应第五节,frontmatter 的 `last_verified` 由 CI 写回,doctor 脚本扫超期。
三层加起来,verification 文档从「人工 claim」变成「机器 claim + 人工 narrative」。
## 影响面
| 维度 | 影响 |
|---|---|
| 单测时间 | 不变 |
| PR CI 时间 | 新增 60 至 180 秒(取决于 testcontainers 并发 + matrix 拆分) |
| 本地开发 | 默认 `pnpm exec vitest run` 不跑 integration,要显式跑 `pnpm verify:integration` |
| docker 依赖 | 本地跑 integration 需要 docker daemon,已有 `docker-compose.otel.yml` 范式 |
| Secret 管理 | live verifier 需要 4 至 6 个 staging secret,放 GitHub Actions secrets |
| 文档维护 | verification 文档新增 frontmatter,原有 markdown 正文不变 |
| AGENTS.md | 加一段「如何写 verification artifact」,引用本文 |
## 可观测性 / eval
实施后用三个指标判断方案有效:
1. **集成测试覆盖率**5 份文档里有几份对应有 `*.integration.test.ts`,目标 100%
2. **live verifier 触发频率**post-deploy 一次必跑,跑失败的次数与生产 incident 的相关性
3. **doctor 报告超期数**:每周扫一次,超期数应当趋近 0
第 3 个指标如果长期不为 0,说明 verification 流程仍需要人工介入太多,要回头看 frontmatter 设计是否合适。
## 收束
这份方案保留 verification 文档的人工 narrativeroot cause、why、tradeoff),把可执行部分挪到代码,把过期检测交给 CI。实施分三步:
1. 先做 frontmatter schema 与 doctor 脚本,零代码改动,立即能识别已有 5 份文档的过期状态。
2. 再做 `flux-unbilled-exploit-fix` 的集成测试样板,跑通一个 case 形成模板。
3. 最后逐份补齐 integration 与 live verifier。
如果某一份文档(例如 `email-auth`)的 live 验证依赖 Resend 真实投递,确认收件状态需要轮询 Resend `/emails` API,这部分实现成本较高,可以推到第三步的尾巴上单独立项。