返回博客

怡途Claw APP 博客接口:列表、详情、分类、标签——docs/app-api.md 没说的四个差额

2026年8月9日阅读约 34 分钟
APP 博客接口实战

怡途Claw APP 端的博客四个接口(list/detail/categories/tags)看着只是 GET 包装,但 blog-service.ts 在分页、分类过滤、标签聚合、质量过滤上各埋了一处和文档承诺不一致的差额。客户端上线前最好把这些差额捋一遍,否则列表里看到的文章和详情接口拿到的可能是两份数据。

APP 端要拉博客:列表、详情、分类、标签。docs/app-api.md 第二章把这四个接口的入参、响应都列了,看起来就是四个 GET 包装。但 blog-service.ts 在分页查询、分类聚合、标签归一、质量过滤上各埋了一处和文档不一致的小差额。客户端如果按文档承诺去接,列表里看到的文章和详情接口拿到的可能是两份数据。这一篇把差额一次讲完。

配套源文件:src/lib/blog-service.tssrc/lib/blog-quality-filter.tssrc/app/api/app/blog/*/route.tsdocs/app-api.md:230-320。下文每一行论断都能在这些文件里查到。

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

四个接口,文档没骗人:

GET  /api/app/blog?page=1&pageSize=20&category=AI资讯&tag=AI%20Agent
GET  /api/app/blog/{slug}
GET  /api/app/blog/categories
GET  /api/app/blog/tags
  • 鉴权统一走 Authorization: Bearer <APP_API_KEY>,四个 route.ts 第一步都是 validateAppApiKey(request),失败返回 401。
  • pageSize 上限 100,超了 route.ts 第 12 行那个 Math.min(100, ...) 默默截到 100——不报错。
  • pageSize 非正或 NaN 时兜底 20,page 同理兜底 1。客户端传 pageSize=0 不会卡死。

下面开始拆差额。

二、差额一:/categories 不过滤 post_type=course

src/app/api/app/blog/categories/route.ts 第 11 行直接调 getAllCategories()。看实现(blog-service.ts:133-140):

export async function getAllCategories(): Promise<string[]> {
  const rows = await query<{ category: string }>(
    "SELECT DISTINCT category FROM posts WHERE status = 'published' ORDER BY category"
  );
  ...
}

SQL 里没有 post_type = 'blog'

后果:客户端拉到的"博客分类"里会混进课程分类。docs/app-api.md:285-300 给的示例是 11 项(含"AI基础入门"),但这只是数据库只有博客时碰巧的结果。一旦课程也加进 posts 表(事实上 getCourseBySlug 在 blog-service.ts:102 就这么查),博客分类接口返回里就会冒出"AI基础入门"+"提示工程 & 大模型"+"AI商业应用"这些纯课程分类——getAllCourseCategories 的 fallback 列表(blog-service.ts:147)刚好也是这几项,不是巧合。

接入方实测确认

curl -H "Authorization: Bearer $API_KEY" \
  "https://www.yitukeji.cn/api/app/blog/categories" | jq '.data'

当前返回里如果有"AI基础入门"+"提示工程 & 大模型",就说明课程已混进博客分类。

客户端建议

  • 不要在博客 tab 直接展示 /categories 全量返回
  • 要么在客户端再过滤一遍(剔除课程专有分类),要么和后端约定一个 /api/app/blog/categories 的"博客白名单"——后者需要在 blog-service.ts:133 的 SQL 加 AND post_type = 'blog'

三、差额二:/tags 的大小写形态取决于走了哪条数据源

src/app/api/app/blog/tags/route.ts 第 11 行调 getAllTagsWithCount()。看实现(blog-service.ts:151-166):

export async function getAllTagsWithCount() {
  const posts = await getPublishedPosts();
  ...
}

第 153 行那个 if (posts.some((p) => p.id !== 0)) 是在判断"数据库里有没有真行":数据库有数据就走上面的分支,按 tag.toLowerCase() 归一后累加计数——这一步是对的,同义大小写在这里已经合并了。

差额在 else 分支。数据库查不到 blog 行时,第 165 行 return getAllTags()。这个 getAllTags 不是同文件的函数,而是第 9 行从静态数据导入的:

import { blogPosts as hardcodedPosts, getAllTags } from "@/data/blog-posts";

它的实现(src/data/blog-posts.ts:1404-1414没有 toLowerCase

export function getAllTags(): { tag: string; count: number }[] {
  const tagCount: Record<string, number> = {};
  for (const post of getBlogPostList()) {
    for (const tag of post.tags) {
      tagCount[tag] = (tagCount[tag] || 0) + 1;   // 原样做 key
    }
  }
  ...
}

所以 /api/app/blog/tags 的返回格式会随数据源切换而变:数据库正常时给全小写("ai agent"),回退到静态数据时给原始大小写("AI Agent")。同一个接口、同一份文档,两种大小写形态。

客户端建议:不要假设 /tags 的大小写形态。取到后一律自己 .toLowerCase() 归一再去重累加,这样两条数据源路径下都成立。

四、差额三:getPublishedPostsPaginated 不走 quality filter,列表会和 sitemap/blog 页不一致

这是最影响一致性的差额。

blog-service.ts:214-236 的分页查询:

export async function getPublishedPostsPaginated(options) {
  const { page = 1, pageSize = 20, category, tag } = options;
  const conditions = ["status = 'published'", "post_type = 'blog'"];
  ...
  return paginatedQuery<BlogPost>(baseQuery, countQuery, params, page, pageSize);
}

paginatedQuerysrc/lib/db.ts:59-77——纯粹执行 SQL,没有调 filterPublishablePosts

对比看:getPublishedPosts(blog-service.ts:78-84)和 getPostBySlug(blog-service.ts:93-100)走 quality filter。

后果:客户端通过 /api/app/blog 拉到的列表里可能包含被 quality filter(src/lib/blog-quality-filter.ts:20-43)屏蔽的薄内容/AI 新闻稿——而这些文章不会出现在 sitemap、博客前台、tag/category 列表页。客户端点进去拿到详情(getPostBySlug 走 filter 返回 null),前端就 404 了。

踩坑场景

  1. 客户端 APP 博客首页展示 20 条
  2. 用户点第 3 条
  3. 详情接口返回 404(被 quality filter 在 detail 路径挡掉)
  4. 用户看到"文章不存在"——但其实列表接口确实返回过这条

接入方实测

curl -H "Authorization: Bearer $API_KEY" \
  "https://www.yitukeji.cn/api/app/blog?page=1&pageSize=20" | jq '.data.items[].slug' > /tmp/app-blog-slugs.txt
curl -s "https://www.yitukeji.cn/sitemap.xml" | grep -oE "<loc>[^<]+</loc>" | grep -oE "/blog/[^<]+" | sed 's|/blog/||' > /tmp/sitemap-blog-slugs.txt
diff /tmp/app-blog-slugs.txt /tmp/sitemap-blog-slugs.txt

两个集合的差集就是被 quality filter 挡掉的——APP 列表能看到但 sitemap/blog 前台看不到的"幽灵文章"。

客户端建议

  • APP 端接 404 后不要立即报"文章不存在",先回退到列表上一个有效条目
  • 后端修复建议:在 blog-service.ts:235 那行 paginatedQuery 之后,对 result.items 调一次 filterPublishablePosts(result.items),但 total 已经是 SQL count 出来的——要严格匹配需要在 SQL 里把 quality 条件写进去(参考 PROTECTED_SLUG_KEYWORDS 的 contains 逻辑),或者新增一个 getPublishedPostsPaginatedFiltered 包装函数

五、差额四:/tags 给小写,列表接口按大小写敏感匹配——直接串起来筛不出东西

这一条是四个差额里最容易在真机上撞到的,因为它踩的正是文档暗示的用法:先拉标签云,再用标签筛列表。

APP 列表接口的 tag 过滤(blog-service.ts:226-229)走 SQL:

if (tag) {
  conditions.push("JSON_CONTAINS(tags, ?)");
  params.push(JSON.stringify(tag));
}

JSON_CONTAINS 比较 JSON 字符串值时是大小写敏感的,而且这里把入参原样塞进去,没有做任何归一。

而上一节刚说过,数据库正常时 /tags 返回的是全小写"ai agent"。库里真实存的 tag 是 "AI Agent"src/data/blog-posts.ts 里能捞出一批带大写的:AI AgentAPIClaude CodeGPT……)。

于是这条链路断了:

# 1. 拉标签云,拿到小写 tag
curl -H "Authorization: Bearer $API_KEY" \
  "https://www.yitukeji.cn/api/app/blog/tags" | jq -r '.data[0].tag'
# → "ai agent"

# 2. 拿它去筛列表
curl -H "Authorization: Bearer $API_KEY" \
  "https://www.yitukeji.cn/api/app/blog?tag=ai%20agent" | jq '.data.total'
# → 0,尽管标签云说这个 tag 有 N 篇

标签云告诉你"ai agent 有 9 篇",点进去列表是空的。接口自己的两个端点对不上

对比一下 Web 前台为什么没这个问题:getPostsByTagblog-service.ts:119-131)没用 SQL 过滤,而是把行全查出来后在 JS 里两边都 toLowerCase() 再比:

const normalized = tag.toLowerCase();
const filtered = filterPublishablePosts(rows).filter((p) =>
  p.tags.some((t) => t.toLowerCase() === normalized)
);

两边都归一,所以大小写随便传都对。同一个"按标签筛文章"的语义,Web 路径大小写不敏感,APP 路径大小写敏感——差额就在这。

客户端绕法:不要直接把 /tags 的返回值当 tag 参数用。要么维护一份"小写 → 库内原始大小写"的映射表,用原始大小写去请求;要么不传 tag 参数,拉全量后在客户端按 toLowerCase() 自己筛。

后端修复建议:把 getPublishedPostsPaginated 的 tag 条件改成大小写不敏感,跟 Web 路径对齐。JSON 列上可以用生成列或 JSON_SEARCHLOWER();最省事的是维持 SQL 只过滤 status/post_type,tag 匹配挪到 JS 里跟 getPostsByTag 用同一套逻辑——代价是分页得在内存里做。

六、客户端对接清单

四个接口,四个差额。最少要做这四件事才能上线:

  1. 列表兜底:APP 端接 404 详情后不要立即报错,回退列表
  2. 分类客户端过滤:用一份已知的"博客白名单分类"在客户端再过滤一次 /categories 返回
  3. 标签云归一:拉 /tags 后一律自己 toLowerCase() 去重累加,不要假设它的大小写形态
  4. 别把 /tags 的值直接当 tag 参数:列表接口大小写敏感,得用库内原始大小写,或者干脆客户端筛

否则一份看起来"全部按文档实现"的客户端,会在用户第三次点击时崩出 404,或者点标签云进去看到空列表——而服务端日志里两件事都不报错:一个是 quality filter 在 detail 路径上生效了,一个是 JSON_CONTAINS 老实返回了 0 行。这是接口契约不严格的典型代价:没有哪一层觉得自己错了,但两个端点拼起来就是不一致。