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:
- 新增 per-plan 存储与契约;
- 对现有每用户 snapshot 做一次性/惰性拆分,将原有
plans[] 原样迁移为 per-plan rows;
- 第一阶段 payload 继续兼容现有
PkPlan,只改变 revision 与同步粒度;
- Web / Mobile 使用同一契约与冲突规则后,再移除旧整包写入路径;
- 迁移期间不得丢失现有本地或云端方案;旧的
[本地自动恢复] 方案按普通已有方案迁移,不做自动删除。
Acceptance Criteria / 验收标准
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
Background / 背景
排课器的课表方案云同步已经由 #537 建立,并在 #571 / #573 后补充了 CAS、网络恢复和 Web 端自动恢复策略。
本次基于最新
dev对 Web、Mobile、/api/pk/plans与数据库实现重新排查后,确认目前仍有一个同步模型层面的多端冲突问题:plans + activePlanId + majorSelected + weekView共用一个updatedAt/baseUpdatedAtCAS。[本地自动恢复]方案 x”的策略;这能保数据,但不是同一方案的真正多端合并。反复发生真实冲突时,恢复方案仍会持续占用正常方案额度,最终可能触及MAX_PLANS = 10。activePlanId、weekView等设备 UI 状态也参与整包 revision,制造了与课表内容无关的冲突。需要区分一个已修复的历史问题:PR #668(commit
8f8bac85)已经修复了 Go wire 的null键与本地缺省键导致“同内容也被重复克隆”的问题。当前要解决的是真实多端修改下,整包快照 + 恢复副本模型仍然会产生结构性冲突和方案膨胀,不是重新修 #668。资源评估
本改造应以低服务器负载为约束,不引入重型同步基础设施。推荐方案在正常实现下预计比现有整包同步更省:
Target Users / 目标用户
User Story / 用户故事
作为在多台设备上使用排课器的学生,我希望同一个课表方案在不同设备上的修改能够按方案正确同步;当两端修改互不冲突时自动合并,当确实修改了同一字段且无法自动判断时再提示我处理,而不是不断生成新的“自动恢复方案”或让我在整份本地/云端数据之间二选一。
Scope / 范围
1. 将同步冲突粒度从“用户整包快照”降为“单个 plan”
建议服务端按
(user_id, plan_id)保存方案,每个方案独立维护整数 revision,例如:不要求第一阶段立即瘦身
PkPlanpayload;先只改变同步粒度,降低迁移风险。2. 使用整数 revision 做条件写,不再用时间戳承担 CAS 版本号
客户端写入时携带自己观察到的
baseRevision:baseRevision = 0/ create 语义。updated_at保留为显示时间,不参与版本一致性判断。3. 三方合并放在客户端完成
Web / Mobile 为每个已同步方案保留最近一次成功同步的 base snapshot:
客户端执行
base / local / remote三方合并:实现时应优先复用现有稳定标识(
planId、custom event id、课程/教学班标识),不引入 CRDT。4. 取消“正常方案列表中的自动恢复副本”作为常规冲突策略
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 对以下行为必须一致:
8. 改为 dirty / lifecycle 驱动同步
不新增常驻连接,取消干净状态的固定 10s 同步心跳:
offline -> online:立即处理 dirty plans;Out of Scope / 不做什么
本 Issue 第一阶段明确不做:
Migration / 迁移建议
建议渐进迁移,避免一次性推翻现有
/api/pk/plans:plans[]原样迁移为 per-plan rows;PkPlan,只改变 revision 与同步粒度;[本地自动恢复]方案按普通已有方案迁移,不做自动删除。Acceptance Criteria / 验收标准
activePlanId/weekView的本地变化不会推进课表方案 revision。plans.length;不存在因多端反复修改而逐步占满MAX_PLANS的路径。Supplementary Materials / 补充材料
相关历史:
本次排查在最新
dev上确认:apps/gooseforum/resource/src/site/composables/useScheduleSync.tsapps/gooseforum/resource/src/site/composables/useScheduleStore.tsapps/gooseforum/app/http/controllers/pk/plans.go、apps/gooseforum/app/models/forum/pk/schedule_snapshot_rep.goapps/mobile/packages/forum_app/lib/src/schedule/schedule_sync.dartpackages/api-contract/components/schemas.yaml现有 Web 云同步回归测试 47/47 通过,说明 #668 所覆盖的“同内容重复克隆”回归当前成立;本 Issue 处理的是测试通过后仍存在的同步粒度与冲突模型问题。
Code of Conduct