怡途Claw APP 端的博客四个接口(list/detail/categories/tags)看着只是 GET 包装,但 blog-service.ts 在分页、分类过滤、标签聚合、质量过滤上各埋了一处和文档承诺不一致的差额。客户端上线前最好把这些差额捋一遍,否则列表里看到的文章和详情接口拿到的可能是两份数据。
APP 端要拉博客:列表、详情、分类、标签。docs/app-api.md 第二章把这四个接口的入参、响应都列了,看起来就是四个 GET 包装。但 blog-service.ts 在分页查询、分类聚合、标签归一、质量过滤上各埋了一处和文档不一致的小差额。客户端如果按文档承诺去接,列表里看到的文章和详情接口拿到的可能是两份数据。这一篇把差额一次讲完。
配套源文件:
src/lib/blog-service.ts、src/lib/blog-quality-filter.ts、src/app/api/app/blog/*/route.ts、docs/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);
}
paginatedQuery 在 src/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 了。
踩坑场景:
- 客户端 APP 博客首页展示 20 条
- 用户点第 3 条
- 详情接口返回 404(被 quality filter 在 detail 路径挡掉)
- 用户看到"文章不存在"——但其实列表接口确实返回过这条
接入方实测:
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 Agent、API、Claude Code、GPT……)。
于是这条链路断了:
# 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 前台为什么没这个问题:getPostsByTag(blog-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_SEARCH 配 LOWER();最省事的是维持 SQL 只过滤 status/post_type,tag 匹配挪到 JS 里跟 getPostsByTag 用同一套逻辑——代价是分页得在内存里做。
六、客户端对接清单
四个接口,四个差额。最少要做这四件事才能上线:
- 列表兜底:APP 端接 404 详情后不要立即报错,回退列表
- 分类客户端过滤:用一份已知的"博客白名单分类"在客户端再过滤一次
/categories返回 - 标签云归一:拉
/tags后一律自己toLowerCase()去重累加,不要假设它的大小写形态 - 别把
/tags的值直接当tag参数:列表接口大小写敏感,得用库内原始大小写,或者干脆客户端筛
否则一份看起来"全部按文档实现"的客户端,会在用户第三次点击时崩出 404,或者点标签云进去看到空列表——而服务端日志里两件事都不报错:一个是 quality filter 在 detail 路径上生效了,一个是 JSON_CONTAINS 老实返回了 0 行。这是接口契约不严格的典型代价:没有哪一层觉得自己错了,但两个端点拼起来就是不一致。