Back to Airi

Character Cards Cloud Sync — Phase 1 Design

docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md

0.11.016.8 KB
Original Source

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 facade,统一 character 调用入口
  • Phase 3:rename useAiriCardStoreuseCharacterStore,命名对齐 server
  • Settings 云同步(独立 spec,等本 spec 的 sync engine 落地后再接)
  • Marketplace 上架路径(user_characterscharacters 的 explicit publish transform)
  • 多设备并发实时编辑(per-field LWW / vector clock / 冲突 UI — α-full 范围)
  • Server-push(SSE / WS)同步推送 — 本 Phase 是被动 pull on focus / login

2. 现状

3 个并行 store 在管「角色卡」概念:

#Store数据形态存储用户路径
1useAiriCardStore (stores/modules/airi-card.ts)CCv3 jsonb + airi extensionuseLocalStorageManualReset<Map<string,AiriCard>>('airi-cards')事实上的运行时角色卡 — 三端 App.vue / chat / Stage / profile-switcher / artistry / sessions-drawer,调用点 25+
2useCharacterStore (stores/characters.ts)关系化 (character + i18n + capabilities + avatar + cover)server /characters API + @pinia/colada孤岛:仅 apps/stage-web/src/pages/settings/characters/
3useCharacterStore (stores/character/index.ts) ← 同名!facade of #1v2/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 完全不同。

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/ 改用统一的 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)默默丢卡
D3activeCardId 同步粒度per-userper-device 违反「养一个 AI 角色」产品直觉
D4server schema两张表(user_characters + 现存 characters单表 + visibility 字段:marketplace/private 权限边界易漏;多语言关系字段对私有卡冗余
D5Delete 语义soft delete(deletedAt tombstone)参与 LWWhard delete = 多设备 race 复活已删卡
D6同步触发watchDebounced 自动后台 sync (2s) + retry 队列手动按钮 = 用户感知不一致状态
D7多设备并发整卡 LWW by updatedAtper-field LWW / vector clock = α-full 范围,过度工程
D8CCv3 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 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>

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/

MethodPath用途Body
GET/user-characters列出当前用户全部卡 (含 tombstone)
PUT/user-characters/:clientIdupsert 一张卡(按 ownerId+clientId 唯一);LWW by updatedAt{ rawCard: AiriCard, updatedAt: string }
DELETE/user-characters/:clientIdsoft 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
  • 否则 → 拒绝(罕见 case:client 本地 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 到 server(debounced 2s)
  • firstSync(authedUserId):登录后调用一次,按 §7.1 算法
  • pullFromServer():focus / 启动调用,GET /user-characters + /user-characters/active

useAiriCardStore 内部:

  • addCard / updateCard / removeCard 写完 cards Map 后立即 enqueueUpsert/enqueueDelete(同步、无延迟)
  • flush() 由 watchDebounced(2s) 在 cardspendingOps 任一变化时触发
  • 新增 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 和 devtools/context-flow 的调用方。

9. 错误处理

场景处理
网络错误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
5xxretry 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

    • CRUD(PUT 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 端登录账号 Xweb 端看到 ARIA
V3多设备增量同步桌面编辑 ARIA personality → 等 watchDebounced(2s) flush → web 端切回 tab 触发 focus pullweb 端看到更新
V4多设备删除同步桌面 delete ARIA → 等 flush → web 端切回 tab 触发 focus pullARIA 不见
V5切账号确认 modal已登录 X 创建未同步本地卡 B → logout弹 modal「B 归 X / 丢弃 / 取消」
V6activeCard 多端切换桌面切到 ARIA → web 端 reloadweb 端 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)。需用户确认。
  • Q2activeClientId 为什么单独一张表而不是给 user_charactersisActive 字段?

    • 单独表:每用户至多一行,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 范围。