Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

跨设备同步学习进度时数据"凭空消失":怡途科技 APP 学习进度 API 的取舍与踩坑

2026年8月10日26 min read
学习进度同步 API 实战

怡途Claw APP 端的学习进度同步看着只有 GET / POST 两个接口,但全量替换语义 + device_id 标识 + 缺事务这三个决定一起决定了"为什么换台手机进度会丢、为什么网络抖一下历史就清空"。这一篇把 docs/app-api.md 第三章的承诺和 `src/app/api/app/progress/route.ts` 的实现之间那点没说清的差额,一次讲完。

APP 端做学习进度同步这条线,docs/app-api.md 第三章看起来很轻:两个接口,一个 GET 拉、一个 POST 推,传一个 device_id 加一个 slugs 数组就完事。但接口表的"看着简单"和"上线不炸"之间隔着一段距离,这一篇就专门补这段距离——哪些是文档没写但实现里有、哪些是文档承诺了但实现没兜住、哪些取舍在你自己的 APP 上其实有第二条路。

配套源文件:src/app/api/app/progress/route.tsdatabase/schema.sql:88-96。下文每一行论断都能在这两个文件里查到。

一、先把文档承诺的接口打一遍

接口本体确实就是两个,文档没骗人:

GET  /api/app/progress?device_id={deviceId}
POST /api/app/progress
     { "device_id": "abc123-uuid", "slugs": ["ai-intro-for-beginners", "ml-core-concepts"] }
  • GET:返回 { device_id, slugs }slugs 是这门设备上已完成的课程 slug 列表。
  • POST:传当前全量已完成的 slug 列表,服务端全量替换。传空数组 = 清空所有进度。
  • 鉴权走 Authorization: Bearer <APP_API_KEY>,由 validateAppApiKey(request) 把关。

看着像完事了。下面是文档没说但实现里有、且会真出问题的三处。

二、坑 1:device_id 不是账号,是设备

文档第三章末写了"未来可升级为用户账号体系"——这句话在当前实现里完全成立,因为 learning_progress 表的索引就是 UNIQUE KEY unique_device_course (device_id, course_slug),没有 user_id 字段:

CREATE TABLE IF NOT EXISTS learning_progress (
  id INT AUTO_INCREMENT PRIMARY KEY,
  device_id VARCHAR(255) NOT NULL,
  course_slug VARCHAR(500) NOT NULL,
  completed TINYINT NOT NULL DEFAULT 1,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY unique_device_course (device_id, course_slug)
);

带来的直接后果:

  • 用户重装 APP → 本地 UUID 重新生成 → 之前的所有进度查不到。
  • 用户换手机 → 进度不跟着走,除非你把他旧 device_id 在新设备写死。
  • 两台设备用同一个 device_id 同时上线 → 后写覆盖前写(见坑 3)。

所以你在 APP 端做"恢复进度"功能时,要假设 device_id 只是设备指纹——别拿它当登录凭据,别把它和用户手机号绑定当回写。

自己 APP 的取舍:如果你的产品场景强依赖跨设备保留进度(比如用户常年在平板和手机之间切),建议在客户端把 device_id 做一次"账号绑定"——让用户在新设备首次登录时手动粘贴旧 device_id,或者你做一次后端迁移接口把旧的 device_id 行改写到新 device_id。当前公开 API 都不提供这两条路,必须自己加。

三、坑 2:全量替换是设计选择,不是 bug

POST 的实现是DELETE FROM learning_progress WHERE device_id = ?,再 INSERT 全部新行:

// Transaction: clear old progress, insert new
await execute("DELETE FROM learning_progress WHERE device_id = ?", [deviceId]);

if (slugs.length > 0) {
  const placeholders = slugs.map(() => "(?, ?)").join(", ");
  const values: (string | number)[] = [];
  for (const slug of slugs) {
    values.push(deviceId, slug);
  }
  await execute(
    `INSERT INTO learning_progress (device_id, course_slug) VALUES ${placeholders}`,
    values
  );
}

"全量替换"在客户端写起来确实省事——你不用算 diff,直接把本地缓存的完整列表推上去就行。但代价是服务端把"你上次推过的全集"和"这次推的全集"之间的差算账的责任,丢给了客户端

  • 客户端只发增量("我刚把 X 标完成了")→ 服务端把 X 之外的都清空了。
  • 客户端网络超时,POST 失败 → 客户端重试,但中间如果有过一次成功 POST → 你不知道服务端现在到底有没有那行。

接入方正确做法是:把"全量"在客户端算清楚。具体建议是 APP 启动时只调一次 GET 拉基线,之后的本地变更记到一个内存集合(用 Set 存 slug 列表),每次变更后做一次 POST 推"当前已完成的完整列表"——不要试图去算"服务端少了哪几行"。

四、坑 3:DELETE 和 INSERT 不在事务里

注释里写着 // Transaction: clear old progress, insert new,但代码里没有 BEGIN ... COMMIT 包裹——await execute("DELETE ...")await execute("INSERT ...") 是两次独立调用,MySQL/InnoDB 默认 autocommit=1,意味着两次都是单独事务

后果按时间线分两种:

  1. DELETE 成功、INSERT 失败(比如某个 slug 长度撞到 VARCHAR(500) 边界、比如短暂断网):用户原本的进度被清空,新进度没写进去。从用户视角:"我明明在用 APP,怎么进度全没了?"
  2. 两个客户端并发 POST 同一 device_id(用户在手机和平板上同时操作):
    • A:DELETE → 准备 INSERT ["a","b"]
    • B:DELETE → 准备 INSERT ["a","c"]
    • 最后落表可能是 A 的 b、B 的 c、或者只剩 a,取决于两条 INSERT 之间的 commit 顺序

补救方案在你客户端这边能做的不多,但有几条值得做:

  • 客户端自己写一份本地权威缓存(Keychain/UserDefaults),把 device_idslugs 强同步;服务端是辅助真相,客户端是首要真相。
  • POST 失败做指数退避重试,但重试前先 GET 一次——如果 GET 回来的就是你"想要的全集",说明服务端已经拿到了,跳过重试;否则重推。
  • 如果你的产品允许"误操作撤销"(最近 30 秒内删除的进度能恢复),可以接一个增量版 POST /api/app/progress/diff,但当前公开 API 没有这个入口,得自己加。

五、文档里"使用建议"那一段的实操翻译

文档最后给了三条使用建议:

  • APP 启动时调用 GET 拉取进度
  • 用户标记完成/取消时调用 POST 全量同步
  • 如果想跨设备同步,用同一 device_id(未来可升级为用户账号体系)

按上面三个坑重写一下:

  • "启动时 GET 拉" → 拉之前先拿本地缓存同步渲染,GET 是后台校验,不是首屏阻塞。理由:网络抖动时 GET 5xx 你不能把首屏搞空白。
  • "用户标记完成/取消时 POST" → POST 之前先算全集再发,不要发"我刚加了这一门"。理由:全量替换语义下,发增量等于"清掉其他全部"。
  • "跨设备同步用同一 device_id" → 这一条当前实现做不到(坑 1)。产品上要让用户能换设备不丢进度,要么客户端做"换机迁移"流程,要么等平台出"device_id 合并"接口。

六、推荐对接流程(一图流)

APP 启动
  ├── 从本地(Keychain/UserDefaults)读 slugs 缓存,渲染课程页勾选状态
  └── 后台 GET /api/app/progress?device_id=...
        ├── 成功:用返回的 slugs 覆盖本地缓存,刷新 UI
        └── 失败:保持本地缓存,日志告警,不影响用户

用户勾选/取消一门课
  ├── 更新本地 Set
  └── POST /api/app/progress { device_id, slugs: <全集> }
        ├── 成功:日志记录
        ├── 5xx:本地保留,1s/3s/9s 指数退避重试 3 次
        └── 401:APP_API_KEY 失效,弹提示让用户重启 APP 拉新 key

用户切到另一台设备
  └── 引导用户走"换机迁移"流程(产品决策;当前 API 不支持)

七、这篇对应的源码锚点

  • 路由:src/app/api/app/progress/route.ts(全文 77 行)
    • GET 第 10-32 行:注意读时 WHERE completed = 1
    • POST 第 37-77 行:注意 DELETEINSERT,无 BEGIN/COMMIT
  • 表结构:database/schema.sql:88-96
    • UNIQUE KEY unique_device_course 决定了并发写是覆盖而不是相加
    • completed TINYINT DEFAULT 1 让不写 completed 列的 INSERT 不会"插入后读不到"
  • 鉴权:src/lib/app-auth.ts 里的 validateAppApiKey——8 行,逻辑是从 Authorization 头剥掉 Bearer 前缀后跟环境变量 APP_API_KEY全等字符串比较。两点值得注意:APP_API_KEY 没配时直接返回 false(不是放行),以及不带 Bearer 前缀直接传裸 key 也能过。

配套阅读:博客相关接口(/api/app/blog/api/app/blog/[slug]/api/app/blog/categories/api/app/blog/tags)的同款"文档 vs 源码"差额,见《怡途Claw APP 博客接口——docs/app-api.md 没说的四个差额》