怡途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.ts、database/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,意味着两次都是单独事务。
后果按时间线分两种:
DELETE成功、INSERT失败(比如某个 slug 长度撞到VARCHAR(500)边界、比如短暂断网):用户原本的进度被清空,新进度没写进去。从用户视角:"我明明在用 APP,怎么进度全没了?"- 两个客户端并发 POST 同一
device_id(用户在手机和平板上同时操作):- A:
DELETE→ 准备INSERT ["a","b"] - B:
DELETE→ 准备INSERT ["a","c"] - 最后落表可能是 A 的 b、B 的 c、或者只剩 a,取决于两条 INSERT 之间的 commit 顺序。
- A:
补救方案在你客户端这边能做的不多,但有几条值得做:
- 客户端自己写一份本地权威缓存(Keychain/UserDefaults),把
device_id和slugs强同步;服务端是辅助真相,客户端是首要真相。 - 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 = 1POST第 37-77 行:注意DELETE后INSERT,无BEGIN/COMMIT
- 表结构:
database/schema.sql:88-96UNIQUE 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 没说的四个差额》。