docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md
Date: 2026-05-09 Status: Design (pending approval) Owner: @RainbowBird Track: Phase 1 of A (大整合 — 三 store 合一 + 云同步)
让所有 stage 入口(stage-tamagotchi / stage-web / stage-pocket)的角色卡数据从纯本地 localStorage 升级为「本地永远是 source of truth on device + 登录后云同步」,并清理「三 store 并存」技术债的第一刀(废 stores/characters.ts 孤岛页)。
stores/character/index.ts facade,统一 character 调用入口useAiriCardStore → useCharacterStore,命名对齐 serveruser_characters → characters 的 explicit publish transform)3 个并行 store 在管「角色卡」概念:
| # | Store | 数据形态 | 存储 | 用户路径 |
|---|---|---|---|---|
| 1 | useAiriCardStore (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) | 关系化 (character + i18n + capabilities + avatar + cover) | server /characters API + @pinia/colada | 孤岛:仅 apps/stage-web/src/pages/settings/characters/ |
| 3 | useCharacterStore (stores/character/index.ts) ← 同名! | facade of #1 | — | v2/index.vue / devtools/context-flow |
Server characters 表(apps/server/src/schemas/characters.ts)已经是 marketplace 形态(likesCount / forksCount / priceCredit / character_i18n 多语言 / character_capabilities / avatar_model / character_covers),跟 client AiriCard CCv3 schema 完全不同。
useAiriCardStore 内部存储从 localStorage-only 升级到「localStorage 主路径 + sync engine 后台同步到 server」activeCard.x.y.z)完全不变——只换内部存储和加 sync 层stores/characters.ts 孤岛被废:删 store/service/model 文件,apps/stage-web/src/pages/settings/characters/ 改用统一的 packages/stage-pages/src/pages/settings/airi-card/ 页(stage-tamagotchi 已经在用)user_characters + user_active_character 表/characters 路由和 characters 表保留不动(marketplace 用,未来 spec 接入)| # | 决策 | 选择 | 拒绝理由 |
|---|---|---|---|
| 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 队列 | — |
新建文件 apps/server/src/schemas/user-characters.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 extension,lossless 兜底。
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>
useAiriCardStore 现有 cards: Map<string, AiriCard> + activeCardId: string 不变。新增 internal:
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。
新建 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 逻辑:
deletedAt IS NULL:
incoming.updatedAt > existing.updatedAt → UPDATEincoming.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 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 删除
logout 触发时:
pendingOps 非空 OR 本地有未同步过的卡(无 server 记录的 clientId)cards Map + pendingOps → logoutcards Map watchDebounced(2s) 触发 → diff 出变更 → enqueue → flushactiveCardId 变更 → debounce 1s → PUT /user-characters/activependingOps(已持久化),上线后 flush/user-characters + /user-characters/active 拉一次(被动 pull)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 到 server(debounced 2s)firstSync(authedUserId):登录后调用一次,按 §7.1 算法pullFromServer():focus / 启动调用,GET /user-characters + /user-characters/activeuseAiriCardStore 内部:
addCard / updateCard / removeCard 写完 cards Map 后立即 enqueueUpsert/enqueueDelete(同步、无延迟)flush() 由 watchDebounced(2s) 在 cards 或 pendingOps 任一变化时触发_hydrateFromServer(serverCards) 用于 first-syncpendingOps 持久化到独立 localStorage key(airi-cards-pending-ops),App 启动时恢复,登录后第一件事是 flushstores/characters.ts 孤岛删除(不留 deprecation 包装层):
packages/stage-ui/src/stores/characters.tspackages/stage-ui/src/services/characters.tspackages/stage-ui/src/models/characters.tspackages/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 在用的统一页)。
stores/character/index.ts facade 不动Phase 2 处理。本 Phase 不动 v2/index.vue 和 devtools/context-flow 的调用方。
| 场景 | 处理 |
|---|---|
| 网络错误 | sync 进 retry 队列;UI 在 settings/airi-card 顶部小 banner 显示「云同步暂时挂了,本地仍可编辑」 |
| 401 认证失效 | 清空 sync 队列;触发 logout flow(不弹切账号 modal — 因为不是用户主动 logout) |
| 400 schema 校验失败 | server 返回 valibot issues;client 把这张卡 mark syncStatus=error,pendingOps 中移除(避免无限重试),devtools 暴露原始 error |
| 5xx | retry queue + 指数 backoff |
| 启动时 server 不可达 | 进 offline 状态,本地照常使用,恢复后 first-sync |
useAiriCardStore 加载现有 localStorage['airi-cards'] Map(不变)localStorage key 保留:airi-cards, airi-card-active-idairi-cards-sync-engine.spec.ts
pendingOps 持久化 + 启动恢复routes/user-characters/route.test.ts
useAiriCardStore first-sync 端到端(mock fetch + memdb)落到 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,孤岛页不存在 |
Q1:现有 stores/characters.ts 孤岛页用户已创建的关系化数据(character + character_i18n + character_capabilities)怎么处理?
relational → AiriCard CCv3 一次性 migrate 进 user_charactersQ2:activeClientId 为什么单独一张表而不是给 user_characters 加 isActive 字段?
Q3:(已在 §8.1 决定 sync engine 放 packages/stage-shared/src/sync/,理由:未来 settings sync 复用 + AGENTS.md 「shared logic in packages/」)
Next:approve 后调用
superpowers:writing-plansskill 生成实现计划。 Phase 2 / 3 / settings-sync 不在本 spec 范围。