Skip to content

feat(schedule): 重构课表多端同步为 per-plan revision CAS #714

Description

@WALKERKILLER

Background / 背景

排课器的课表方案云同步已经由 #537 建立,并在 #571 / #573 后补充了 CAS、网络恢复和 Web 端自动恢复策略。

本次基于最新 dev 对 Web、Mobile、/api/pk/plans 与数据库实现重新排查后,确认目前仍有一个同步模型层面的多端冲突问题:

  • 当前服务端以“每用户一份完整快照”作为写入单元:plans + activePlanId + majorSelected + weekView 共用一个 updatedAt / baseUpdatedAt CAS。
  • 任一设备对任一方案或 UI 状态的修改都会推进整包版本;两个设备即使只修改不同方案,也会在同一个快照版本上竞争。
  • Web 在真实分歧时采用“云端为主 + 将本地方案克隆为 [本地自动恢复]方案 x”的策略;这能保数据,但不是同一方案的真正多端合并。反复发生真实冲突时,恢复方案仍会持续占用正常方案额度,最终可能触及 MAX_PLANS = 10。
  • Mobile 当前冲突策略与 Web 不一致:Mobile 会停下并要求用户在“使用云端 / 保留本地”之间选择;同一账号跨 Web / App 使用时行为不统一。
  • activePlanId、weekView 等设备 UI 状态也参与整包 revision,制造了与课表内容无关的冲突。

需要区分一个已修复的历史问题:PR #668(commit 8f8bac85)已经修复了 Go wire 的 null 键与本地缺省键导致“同内容也被重复克隆”的问题。当前要解决的是真实多端修改下,整包快照 + 恢复副本模型仍然会产生结构性冲突和方案膨胀,不是重新修 #668。

资源评估

本改造应以低服务器负载为约束,不引入重型同步基础设施。推荐方案在正常实现下预计比现有整包同步更省:

  • 单次修改只写一个方案,不再重写用户全部方案 JSON;
  • 三方合并在客户端完成,服务端只做读取和 revision 条件写;
  • 不使用 WebDAV Server、CRDT、OT、WebSocket、Redis 锁、消息队列、后台 merge worker 或完整版本历史;
  • 干净状态不做固定 10s 同步心跳,仅在本地 dirty、进页、重新聚焦(必要时)、网络恢复时触发同步。

Target Users / 目标用户

  • 同一 YourTJ 账号同时在多个浏览器、电脑、手机 App 上使用排课器的用户;
  • 网络不稳定、离线编辑后重新联网的用户;
  • Web / Mobile 排课器维护者,需共享统一的同步语义与契约。

User Story / 用户故事

作为在多台设备上使用排课器的学生,我希望同一个课表方案在不同设备上的修改能够按方案正确同步;当两端修改互不冲突时自动合并,当确实修改了同一字段且无法自动判断时再提示我处理,而不是不断生成新的“自动恢复方案”或让我在整份本地/云端数据之间二选一。

Scope / 范围

1. 将同步冲突粒度从“用户整包快照”降为“单个 plan”

建议服务端按 (user_id, plan_id) 保存方案,每个方案独立维护整数 revision,例如:

user_id
plan_id
revision      // BIGINT / integer,服务端递增
payload       // 现阶段可继续复用现有 PkPlan JSON,避免同时重构数据模型
updated_at    // 仅用于展示/诊断,不再承担同步正确性

不要求第一阶段立即瘦身 PkPlan payload;先只改变同步粒度,降低迁移风险。

2. 使用整数 revision 做条件写,不再用时间戳承担 CAS 版本号

客户端写入时携带自己观察到的 baseRevision:

UPDATE ... SET payload=?, revision=revision+1
WHERE user_id=? AND plan_id=? AND revision=?
  • 命中:保存成功并返回新 revision;
  • 未命中:返回冲突,并尽量在冲突响应中直接附带当前 remote plan + revision,避免客户端额外 GET;
  • 新方案仅允许 baseRevision = 0 / create 语义。

updated_at 保留为显示时间,不参与版本一致性判断。

3. 三方合并放在客户端完成

Web / Mobile 为每个已同步方案保留最近一次成功同步的 base snapshot:

base   = 上一次同步成功的方案
local  = 当前本地方案
remote = 409 返回的云端最新方案

客户端执行 base / local / remote 三方合并:

  • 仅 local 改动 → 保留 local;
  • 仅 remote 改动 → 采用 remote;
  • 两端修改不同课程 / 不同自定义事件 → 自动合并;
  • 同一课程教学班、同一事件或同一字段被两端改成不同值 → 才进入显式冲突处理。

实现时应优先复用现有稳定标识(planId、custom event id、课程/教学班标识),不引入 CRDT。

4. 取消“正常方案列表中的自动恢复副本”作为常规冲突策略

  • 可自动三方合并的冲突不创建新方案;
  • 无法自动合并时提供明确的局部冲突处理;
  • 若仍需保底副本,放到独立的 conflict draft/archive,而不是 plans 正常集合,不计入 MAX_PLANS,也不参与下一轮正常同步;
  • 同一冲突必须幂等,不能重复产生多个恢复草稿。

5. 删除语义防止离线旧设备复活方案

第一阶段无需长期 tombstone 表:

  • 新建方案只能使用 baseRevision = 0;
  • 已同步方案始终使用 baseRevision > 0;
  • 若客户端提交 baseRevision > 0 但服务端该 planId 已不存在,则返回 deleted/conflict(409/410),不得当作新建;
  • planId 不复用。

这样可以直接物理删除方案,同时防止长期离线设备把已删除方案重新创建。

6. 将设备 UI 状态从方案内容 CAS 中拆出

建议:

  • activePlanId:仅本地;
  • weekView:仅本地;
  • majorSelected:不得继续与所有 plans 共用 revision。若确有跨设备同步价值,可后续作为独立轻量 preference 资源;否则先保留本地。

避免“电脑切换查看周次”导致“手机编辑课表方案”产生云端冲突。

7. 统一 Web / Mobile 同步状态机

Web 与 Mobile 对以下行为必须一致:

  • 首次云同步;
  • 本地 dirty;
  • 409;
  • 网络失败后的重试;
  • 远端删除;
  • 同一方案三方合并;
  • 无法自动合并时的用户提示;
  • 账号切换时本地数据归属。

8. 改为 dirty / lifecycle 驱动同步

不新增常驻连接,取消干净状态的固定 10s 同步心跳:

  • 本地方案修改:约 3s debounce 后上传该 dirty plan;
  • 进入排课页:拉取方案及 revisions;
  • 页面重新聚焦:仅在距离上次同步达到合理阈值时拉取;
  • offline -> online:立即处理 dirty plans;
  • 网络失败:仅 dirty 数据按退避策略重试。

Out of Scope / 不做什么

本 Issue 第一阶段明确不做:

  • 部署完整 WebDAV 服务;
  • CRDT / OT;
  • WebSocket 实时协作;
  • Redis / 分布式锁 / 消息队列;
  • 服务端三方 merge worker;
  • 每次修改的完整历史版本表;
  • 同步架构改造同时重做整个课程数据 schema;
  • 多人共同编辑同一份课表(本 Issue 仅解决同一账号多设备)。

Migration / 迁移建议

建议渐进迁移,避免一次性推翻现有 /api/pk/plans:

  1. 新增 per-plan 存储与契约;
  2. 对现有每用户 snapshot 做一次性/惰性拆分,将原有 plans[] 原样迁移为 per-plan rows;
  3. 第一阶段 payload 继续兼容现有 PkPlan,只改变 revision 与同步粒度;
  4. Web / Mobile 使用同一契约与冲突规则后,再移除旧整包写入路径;
  5. 迁移期间不得丢失现有本地或云端方案;旧的 [本地自动恢复] 方案按普通已有方案迁移,不做自动删除。

Acceptance Criteria / 验收标准

  • 两台设备从同一 revision 出发,分别修改不同方案,双方均可保存,不产生 409。
  • 两台设备修改同一方案但不同课程/不同事件时,可通过三方合并得到一份方案,不新增“自动恢复方案”。
  • 两台设备修改同一方案的同一字段为不同值时,只对实际冲突项提示用户处理,不要求整份“使用本地/使用云端”二选一。
  • Web 与 Mobile 对首次同步、dirty、409、网络恢复、远端删除的行为一致。
  • 已在另一设备删除的方案不会被长期离线设备重新“复活”。
  • activePlanId / weekView 的本地变化不会推进课表方案 revision。
  • 正常同步冲突不会增加 plans.length;不存在因多端反复修改而逐步占满 MAX_PLANS 的路径。
  • 服务端不承担三方合并计算,不引入 WebDAV / CRDT / WebSocket / Redis 锁 / MQ 等额外基础设施。
  • 单方案修改只上传/写入该 plan,不重写该用户其他方案。
  • 现有云端方案可无损迁移,且迁移前后的方案内容与用户归属一致。
  • 为 Web、Mobile、Backend/Contract 增加对应并发与迁移回归测试。

Supplementary Materials / 补充材料

相关历史:

本次排查在最新 dev 上确认:

  • Web:apps/gooseforum/resource/src/site/composables/useScheduleSync.ts
  • Web store:apps/gooseforum/resource/src/site/composables/useScheduleStore.ts
  • Backend:apps/gooseforum/app/http/controllers/pk/plans.go、apps/gooseforum/app/models/forum/pk/schedule_snapshot_rep.go
  • Mobile:apps/mobile/packages/forum_app/lib/src/schedule/schedule_sync.dart
  • Contract:packages/api-contract/components/schemas.yaml

现有 Web 云同步回归测试 47/47 通过,说明 #668 所覆盖的“同内容重复克隆”回归当前成立;本 Issue 处理的是测试通过后仍存在的同步粒度与冲突模型问题。

Code of Conduct

  • I agree to follow this project's Code of Conduct / 我同意遵守本项目的行为准则

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions