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

怡途Claw APP 课程接口实战:难度筛选传「入门」查出 0 条,两套词表谁也没迁就谁

2026年8月16日21 min read
APP 课程接口实战

怡途Claw APP 端课程三接口(/api/app/courses、/{slug}、/categories)的难点不在调用而在过滤参数:难度有两套词表、分类比较连一个空格都容不下、分页其实是内存里 slice。这篇逐条对照 docs/app-api.md 和 src/lib/blog-service.ts,讲清每处差额的来龙去脉和客户端的最稳传参姿势。

一个三天没人发现的坏筛选器

你在 APP 的 AI 课堂模块里加了个难度筛选器,三个 tab:入门、进阶、实战。联调那天每个 tab 都有数据——因为你看的是 UI 的默认列表。三天后有人点了「实战」tab:空页面,0 条课程。后端没有报错,日志里只有一个再正常不过的 200。

这种坏法最阴险的地方在于:接口不崩、不报错,只是安静地告诉你「没有符合条件的课程」。你大概率先怀疑数据(「是不是还没录高级课?」),再怀疑自己传参(「是不是要 URL 编码?」),最后才会想到去翻服务端的比较逻辑。这篇文章把 /api/app/courses 系列三个接口里这一类「沉默差额」一次讲完,素材全部来自 docs/app-api.mdsrc/lib/blog-service.ts 的逐行对照。

先看清楚:三个接口,一张表

src/app/api/app/courses/ 下的三个 route 文件都薄得像纸——鉴权之后一行调 src/lib/blog-service.ts

  • GET /api/app/coursesgetPublishedCoursesPaginated(route 里 clamp pageSize 到 1~100)
  • GET /api/app/courses/{slug}getCourseBySlug
  • GET /api/app/courses/categoriesgetAllCourseCategories

真正值得读的是 blog-service.ts。课程和博客文章共用一张 posts 表,靠 post_type 字段区分('course' / 'blog'),课程接口的所有查询都带 post_type = 'course'。记住这个背景,后面第 4 个坑直接由此而来。

坑一:难度有两套词表,文档和数据库各说各话

这是四个坑里伤害最大的一个,也是开头那个「坏筛选器」的真身。

数据库里 23 篇课程的 difficulty 字段,存的全部是 初级 / 中级 / 高级。而 docs/app-api.md 早先宣传的查询值是 入门 / 进阶 / 实战。旧实现的 getPublishedCoursesPaginated 把难度条件直接拼进 SQL 做 difficulty = ? 等值比较——按文档传值,每一档都匹配 0 行。文档和库谁也没迁就谁,APP 的难度筛选器就这么安静地坏了。

这个 mismatch 还有一个网页端的连带惨案,src/lib/course-difficulty.ts 的头注释记了全程:UI 和 JSON-LD 的查找表用 入门 / 进阶 / 实战 做键,difficultyMap[course.difficulty] 对每一行都 miss,详情页的 || difficultyMap["入门"] 兜底把 23 篇课全部画成「入门」——包括高级课;而同一页的 LearningResource 结构化数据用的是原始值,照常输出「高级」。同一个页面,肉眼看到的徽章和喂给搜索引擎的结构化数据互相矛盾,这种不一致丢的是富摘要资格,不只是难看。

修复(master 上的 3408bfdc)做了两件事:建 course-difficulty.ts,把两套拼写归一到 入门 / 进阶 / 实战 三档;难度过滤从 SQL 挪到内存,比较前先过 normalizeDifficulty。文档也补了对照表,现在 初级入门 都能查。

客户端最稳的姿势:传 初级 / 中级 / 高级 这套拼写。它是数据库的实际存储值,修复前的构建能命中;修复后会被归一到正确档位,照样命中。一套传参,跨版本安全。

坑二:category 的等值比较,一个空格就 miss

category 参数旧实现同样是 SQL category = ? 等值比较——空格和大小写全敏感。库里存着 AI客服,你从某个来源拿到的是 AI 客服(中间多个空格),查出来就是 0 条。这不是假设:本站博客侧的分类接口真实出现过 AI 客服AI客服 两个变体并存,课程侧用的是同一族比较逻辑。

修复后的做法在 blog-service.ts 里:过滤挪出 SQL,两边都过 normalizeCategoryKey 再比。/categories 接口也补了对应处理——SELECT DISTINCT category 对拼写变体会返回两行(DISTINCT 只认字节级相等),现在过 dedupeCategoryVariants 去重。顺带一个实现细节:如果课程表为空,getAllCourseCategories 会兜底返回四个硬编码模块名(AI基础入门 等),所以「categories 有值」不代表库里有课,别拿它当数据健康检查用。

tag 参数是同款故事的第三集:旧 SQL 用 JSON_CONTAINS 匹配标签,大小写敏感,课程标了 Agent,你传 tag=agent 就是 0 条。现在改成两边 toLowerCase 后比较。

坑三:分页是全量拉回内存再 slice

getPublishedCoursesPaginatedblog-service.ts:240 起)会发现一个反直觉的事实:函数开头认认真真组装了 conditionsparams,但 page / category / difficulty / tag 四个参数一个都没进 SQL。实际查询是 SELECT * FROM posts WHERE status='published' AND post_type='course' 全量拉回,难度、分类、标签三道过滤在 Node 里跑,total 取过滤后长度,最后 slice(offset, offset + pageSize) 切页。

三个参数被故意挪出 SQL,各有正当理由(词表归一、空格归一、大小写归一——SQL 等值比较都做不到),代价是每次分页请求都承担全表课程量。23 篇课时毫无感知;如果课程量涨两个数量级,这个接口的第一优化点就是「归一化后的可比较列 + SQL 索引」,而不是加缓存。现在你知道边界在哪了。

另外注意 total 的语义:它是过滤后的总数,翻页 totalPages 基于它算,和「库里全部课程数」不是一回事。做「共 N 门课」的展示位时别拿错。

坑四:课程详情 404 的一个隐性来源

课程和博客共用一张表、共用 slug 空间。getCourseBySluggetPostBySlug 都按 slug 查询,靠 post_type 过滤各管各的——但这层过滤不是一开始就全的。master 上的 c587b4aa 记录了修复前的事实:getPostBySlug 曾不带 post_type 过滤,23 篇课程在 /blog/<slug> 下也返回 200,和 /learn/<slug> 形成双 URL,各自声明 canonical,同一份内容在搜索引擎里自己跟自己竞争。

对 API 消费者的启示很直接:拿博客文章的 slug 去调 /api/app/courses/{slug},404 是设计行为而不是 bug。slug 不是全局键,是 (post_type, slug) 的组合键。如果你的 APP 同时展示博客和课程,两边的 slug 列表要分开维护,别想着用一次列表接口加前缀推导。

修到哪了,怎么自己验证

上面四个坑的修复在仓库 master 上(3408bfdc 难度词表、c587b4aa slug 隔离、ac32d260 分类归一化补落点),但写这篇时生产环境还在跑修复前的构建。这反而是个演示「怎么验证你连的环境修没修」的机会,两条 curl 就够(鉴权用 Bearer token,见 docs/app-api.md 的鉴权章节):

# 坑一验证:同一个入门档,两种拼写
curl -s -H "Authorization: Bearer $KEY" \
  "https://www.yitukeji.cn/api/app/courses?difficulty=%E5%85%A5%E9%97%A8" | jq '.data.total'
curl -s -H "Authorization: Bearer $KEY" \
  "https://www.yitukeji.cn/api/app/courses?difficulty=%E5%88%9D%E7%BA%A7" | jq '.data.total'

修复前的构建:入门(URL 编码后 %E5%85%A5%E9%97%A8)返回 0,初级%E5%88%9D%E7%BA%A7)返回真实数量。修复后两者相等。这就是「文档承诺 vs 实际实现」最便宜的现场验证法——比读任何 release note 都可靠。

课程接口是 APP 端文档对照源码系列的第三篇(前两篇写了博客四接口和学习进度同步)。照例只写对照出的差额,文档写对的部分不重复。你如果在接这套接口时踩到新的沉默差额,欢迎带 curl 复现来聊。