03 API 接口详细设计(供其他 Agent 集成)
适用范围:podcast-api 工程(Next.js Route Handlers,本地/内网部署)。 目标读者:客户端工程师、其他 AI Agent。读完本册即可独立完成端到端集成,无需阅读源码。 真实性说明:本册所有端点、字段、错误码、默认值均逐项核对源码,并以
file_path:line形式给出锚点佐证。读不到的内容标注「未实现/待确认」,不臆造。 技术栈(已核验,podcast-api/package.json):next@16.2.7 / react@19.2.4 / drizzle-orm@^0.45.2 / postgres@^3.4.9 / zod@^4.4.3 / pino@^10.3.1 / vitest@^4.1.8 / typescript@^5。
目录
1. 接入概览
1.1 Base URL
| 项 | 值 | 锚点 |
|---|---|---|
| 业务 API 前缀 | http://<内网IP>:3000/api/v1 | 路由位于 src/app/api/v1/** |
| 健康检查(不带 v1) | http://<内网IP>:3000/api/health | src/app/api/health/route.ts:15 |
说明:
- 端口
3000为 Next.js 默认监听端口;<内网IP>取部署主机的局域网地址。本地开发时即http://localhost:3000(podcast-api/docs/API-DESIGN.md:5)。 - 路径规则:文件系统路由。
src/app/api/v1/podcasts/shows/[showId]/route.ts对应GET /api/v1/podcasts/shows/{show_id}。动态段[showId]、[episodeId]、[taskId]即路径参数。 - 全部业务端点声明
export const dynamic = 'force-dynamic'(如src/app/api/v1/podcasts/shows/route.ts:11),即每次请求实时查库,无 Next.js 路由缓存。
1.2 鉴权
所有 /api/v1/** 端点强制鉴权(/api/health 不鉴权)。鉴权逻辑:src/lib/api/auth.ts:36-49。
| Header | 必填 | 说明 | 锚点 |
|---|---|---|---|
Authorization | 是 | 格式 Bearer <token>。token 必须在服务端允许列表内,否则 HTTP 401 / code 1001。匹配正则 /^Bearer\s+(.+)$/i(大小写不敏感,允许多个空格) | src/lib/api/auth.ts:43-47 |
token 允许列表来源:环境变量 API_BEARER_TOKENS(逗号分隔,自动 trim、去空),默认值 dev-token(src/config/env.ts:10,src/lib/api/auth.ts:11-17)。
用户身份与付费判定(src/lib/api/auth.ts:48):
user_id = token:token 即用户标识(本地单机部署的确定性映射,多 token 即多用户,便于配额隔离测试)。- 付费判定:token 以
paid-前缀开头 →isPaid = true(付费用户);否则为试用用户。付费用户在下载端点跳过试用额度(3 集)限制,但仍受日配额(300 集/日)限制(详见 §5.11 / §5.12)。
集成提示:开发联调直接用
Authorization: Bearer dev-token(试用用户);要测试付费路径,部署方需在API_BEARER_TOKENS中加入一个以paid-开头的 token(如paid-test),客户端用该 token 即视为付费。
1.3 必需 / 可选 Header
| Header | 必填 | 处理方式 | 锚点 |
|---|---|---|---|
Authorization: Bearer <token> | 必需 | 见 §1.2 | src/lib/api/auth.ts:42-48 |
Content-Type: application/json | POST 必需 | POST 端点用 req.json() 解析 body;非法 JSON → code 1000 | src/app/api/v1/podcasts/playback/progress/route.ts:24-29 |
X-Request-ID | 可选(推荐) | 回显到响应 request_id;未传则服务端生成 UUID。Header 名大小写不敏感,读取 x-request-id | src/lib/api/auth.ts:25-27 |
X-Client-Version | 可选 | 仅写日志(debug 级),不强校验(偏差 D-1) | src/lib/api/auth.ts:38-40 |
X-Platform | 可选 | 同上,仅写日志,不强校验(偏差 D-1) | src/lib/api/auth.ts:38-40 |
注意:PRD 将
X-Client-Version/X-Platform标为必填,本实现仅记录不强制(偏差 D-1,见 §7)。集成方传不传都不影响调用结果。
2. 统一响应 envelope
所有 /api/v1/** 端点(无论成功或失败)返回统一信封结构。定义:src/lib/api/envelope.ts。
{
"code": 0,
"message": "OK",
"data": {},
"request_id": "uuid"
}
| 字段 | 类型 | 含义 | 锚点 |
|---|---|---|---|
code | number | 业务错误码(0 = 成功,见 §3)。注意:与 HTTP 状态码是两套体系,需各自判断 | src/lib/api/envelope.ts:12,26 |
message | string | 人类可读描述。成功固定 "OK";失败为错误描述(参数错误时含 Zod 详情) | src/lib/api/envelope.ts:12,26 |
data | object | array | null | 业务负载。成功时为端点数据;失败时默认 null,部分配额错误会带 extraData(见 §5.11) | src/lib/api/envelope.ts:12,26 |
request_id | string | 请求追踪 ID,回显规则见下 | src/lib/api/envelope.ts:12,26 |
成功响应工厂 ok(data, requestId, headers?):HTTP 200、code:0、message:"OK"(src/lib/api/envelope.ts:11-13)。
失败响应工厂 fail(code, message, status, requestId, extraData?):HTTP 状态由 status 决定,data 默认 null(src/lib/api/envelope.ts:25-27)。
2.1 request_id 回显规则
来源:getRequestId(req)(src/lib/api/auth.ts:25-27)。
- 若请求带
X-Request-ID(大小写不敏感),原样回显到request_id。 - 否则服务端用
crypto.randomUUID()生成一个 UUID 作为request_id。
集成建议:客户端每次请求生成唯一
X-Request-ID并随响应一起记录日志,便于与服务端 pino 日志(按requestId关联,如src/app/api/v1/podcasts/shows/route.ts:54)做端到端排障。
2.2 缓存头(仅字典类端点)
languages 与 categories 两个端点的成功响应附带 Cache-Control: private, max-age=300(5 分钟客户端缓存),其余端点无缓存头(src/app/api/v1/podcasts/languages/route.ts:27、src/app/api/v1/podcasts/categories/route.ts:31)。
3. 错误码全表
错误码定义:src/lib/api/errors.ts:8-25。code 是业务码,HTTP 状态在各路由的 fail(...) 调用中给出。
| code | HTTP | 常量名 | 含义 | 触发条件 | 客户端处理建议 |
|---|---|---|---|---|---|
0 | 200 | OK | 成功 | 正常返回 | 读取 data |
1000 | 400 | INVALID_PARAMS | 参数校验失败(PRD 未定义,本实现扩展) | Zod 校验不通过 / 游标解码失败 / 游标与 sort 不匹配 / POST body 非法 JSON | message 含具体错误(Zod 路径+原因),修正参数后重试;不要重试同样参数 |
1001 | 401 | UNAUTHORIZED | Token 无效或缺失 | Authorization 缺失、非 Bearer 格式、或 token 不在允许列表 | 检查 Bearer token;prompt 用户重新登录/换 token |
1003 | 403 | TRIAL_QUOTA_EXCEEDED | 试用额度已耗尽(3 集累计) | 试用用户累计下载任务数 ≥ 3 | 提示升级付费;data.trial_quota 含 {used,total} |
1004 | 403 | DAILY_QUOTA_EXCEEDED | 当日下载配额超限(300 集/日) | 当日下载任务数 ≥ 300(含付费用户) | 提示次日重试;data.daily_quota 含 {used,total} |
2001 | 404 | NOT_FOUND | 节目/单集/任务不存在 | show_id / episode_id / task_id 查不到,或任务不属于当前用户 | 提示资源不存在;不重试 |
2002 | 403 | NOT_DOWNLOADABLE | 内容不可下载/播放(无 audio_url) | 单集 audio_url 为空 | 提示该集无音频源;不重试 |
3001 | 500 | INTERNAL_ERROR | 服务器内部错误 | 路由 try/catch 兜底(DB 异常等) | 可重试(指数退避);持续失败则上报 request_id |
关键规则:HTTP 状态码与业务 code 必须分别判断。例如 1003/1004/2002 的 HTTP 状态都是 403,要靠
code区分语义。客户端正确做法:先看 HTTP 是否 2xx;再读 envelope 的code做业务分支。
错误码用法验证锚点(每个码至少一处真实调用):
- 1000:
src/app/api/v1/podcasts/shows/route.ts:26,33,38、src/app/api/v1/podcasts/playback/progress/route.ts:28 - 1001:
src/app/api/v1/podcasts/languages/route.ts:22 - 1003:
src/app/api/v1/podcasts/downloads/route.ts:41 - 1004:
src/app/api/v1/podcasts/downloads/route.ts:45 - 2001:
src/app/api/v1/podcasts/shows/[showId]/route.ts:25、downloads/[taskId]/route.ts:25 - 2002:
src/app/api/v1/podcasts/episodes/[episodeId]/stream/route.ts:27、downloads/route.ts:51 - 3001:所有路由
catch块,如src/app/api/v1/podcasts/shows/route.ts:58
4. 游标分页机制详解
总约定(src/lib/validations/podcasts.ts:10-19、podcast-api/docs/API-DESIGN.md:36-40):
| 项 | 规则 | 锚点 |
|---|---|---|
| 请求参数 | cursor(首页省略或空串)+ limit | src/lib/validations/podcasts.ts:18-19 |
limit 默认值 | 20 | src/lib/validations/podcasts.ts:14 |
limit 上限 | 50,超过自动钳制为 50(不报错) | src/lib/validations/podcasts.ts:16 |
limit 非法值 | 非整数 / < 1 → code 1000 | src/lib/validations/podcasts.ts:11-15 |
| 空串游标 | ?cursor=(空串)等价于「无 cursor」(首页)。searchParamsToObject 丢弃空值 | src/lib/validations/podcasts.ts:81-87 |
| 响应结构 | { items, next_cursor, total? } | src/lib/services/catalog.service.ts:94-98 |
next_cursor 空串 | "" 表示最后一页(无更多数据) | src/lib/services/catalog.service.ts:451,469 |
total 返回时机 | 仅首页(无 cursor)返回;翻页不含 total | src/lib/services/catalog.service.ts:470-472、597-604,668 |
4.1 游标编码
游标是 base64url(JSON keyset) 的不透明字符串(src/lib/api/cursor.ts:10-12):
next_cursor = base64url( JSON.stringify(keyset对象) )
- 客户端应将
next_cursor视为不透明 token,原样回传,不要解析/构造。 - 服务端不签名(只读目录,伪造游标至多得到空页或被类型守卫拒绝)。
- 解码失败(base64/JSON 异常)→
decodeCursor返回null→ 路由返回 code 1000(src/lib/api/cursor.ts:20-26、src/app/api/v1/podcasts/shows/route.ts:32-34)。 - 解码成功但结构不符(类型守卫
isShowsCursor/isEpisodesCursor失败)→ code 1000。
4.2 shows 列表的游标形状(keyset + offset 混合)
定义:src/lib/services/catalog.service.ts:33-37(类型)+ 139-150(类型守卫)+ 451-467(编码)。
shows 列表有四种游标形状,取决于排序方式:
| 排序 | 游标形状(JSON) | keyset 字段 | 排序方向 | 锚点 |
|---|---|---|---|---|
sort=popular(默认) | { "s":"popular", "v":<episode_count:int>, "id":"<podcast_uid>" } | (episode_count, podcast_uid) 行值比较 < | DESC, DESC | catalog.service.ts:397-398,465 |
sort=name | { "s":"name", "v":"<name>", "id":"<podcast_uid>" } | (name, podcast_uid) 行值比较 > | ASC, ASC | catalog.service.ts:399-400,457 |
sort=recent | { "s":"recent", "v":"<ISO时间>"|null, "id":"<podcast_uid>" } | (latest_publish_date, podcast_uid),NULLS LAST | DESC, DESC | catalog.service.ts:401-407,458-463 |
q 存在(相关度) | { "o":<offset:int≥0> } | 纯 OFFSET(ts_rank 无稳定 keyset) | 相关度 DESC | catalog.service.ts:380-381,408-410,455 |
设计要点:
- 三种稳定排序(popular/name/recent)走 keyset,任意翻页都是索引区间扫描,翻页耗时恒定,与深度无关(
catalog.service.ts:28-31,390-392)。 - 只有
q关键词搜索用 OFFSET 游标(相关度 ts_rank 排序无法 keyset),深翻页有 OFFSET 成本(catalog.service.ts:378-381)。 recent排序的v为null时表示已进入「无发布日期」尾部,游标仅按podcast_uid推进(catalog.service.ts:401-407)。
4.3 shows 游标与 sort 绑定校验(重要)
keyset 游标绑定生成它的 sort。校验逻辑:src/app/api/v1/podcasts/shows/route.ts:30-40。
规则:
- 游标解码失败或不符合
isShowsCursor→ code 1000(shows/route.ts:32-33)。 - 若游标是 keyset(含
s字段)且满足以下任一条件 → code 1000"cursor: cursor does not match the requested sort"(shows/route.ts:37-38):- 本次请求带了
q(相关度模式不接受 keyset 游标),或 - 游标的
s与本次请求的sort不一致(如用popular游标却请求sort=name)。
- 本次请求带了
- OFFSET 游标(
{o},由q搜索产生)可在任意请求复用,但语义上应仅在同q下翻页。
集成铁律:翻页时必须保持 sort/q 等过滤条件与首页完全一致,只替换
cursor。改了排序就要从首页重新开始。
4.4 episodes 列表的游标形状(纯 keyset)
定义:src/lib/services/catalog.service.ts:39-43(类型)+ 158-163(守卫)+ 658-665(编码)。
游标形状(JSON):
{ "d": "<ISO时间>" | null, "id": "<episode_uid>" }
| 字段 | 含义 | 锚点 |
|---|---|---|
d | 上一页最后一行的 publish_date ISO 字符串;null 表示该行无发布日期(已进入 NULLS LAST 尾部) | catalog.service.ts:40,661-664 |
id | 上一页最后一行的 episode_uid(同日期 tie-break) | catalog.service.ts:41,662 |
keyset 走 (publish_date, episode_uid) 复合排序,NULLS LAST(无日期单集排在有日期之后):
sort=newest(默认):publish_date DESC NULLS LAST, episode_uid DESC(catalog.service.ts:626-627)。sort=oldest:publish_date ASC NULLS LAST, episode_uid ASC(catalog.service.ts:628)。- 游标推进:
d非 null 时先越过该日期、再处理 tie、最后兜底到无日期行;d为 null 时仅按episode_uid在无日期尾部推进(catalog.service.ts:608-624)。
episodes 游标不绑定 sort 字段名校验(路由仅做 isEpisodesCursor 结构校验,shows/[showId]/episodes/route.ts:30-36),但语义上仍应保持 sort 一致翻页。
episodes 的
total(首页返回)是「过滤集合的总数」,受published_after/published_before影响(catalog.service.ts:597-604)。
5. 端点详细规格(13 个)
端点清单(文件系统路由 → HTTP):
| # | 方法 | 路径 | 鉴权 | 章节 |
|---|---|---|---|---|
| 0 | GET | /api/health | 否 | §5.0 |
| 1 | GET | /api/v1/podcasts/languages | 是 | §5.1 |
| 2 | GET | /api/v1/podcasts/categories | 是 | §5.2 |
| 3 | GET | /api/v1/podcasts/shows | 是 | §5.3 |
| 4 | GET | /api/v1/podcasts/shows/{show_id} | 是 | §5.4 |
| 5 | GET | /api/v1/podcasts/shows/{show_id}/episodes | 是 | §5.5 |
| 6 | GET | /api/v1/podcasts/episodes/{episode_id} | 是 | §5.6 |
| 7 | GET | /api/v1/podcasts/episodes/{episode_id}/stream | 是 | §5.7 |
| 8 | POST | /api/v1/podcasts/playback/progress | 是 | §5.8 |
| 9 | GET | /api/v1/podcasts/playback/progress/{episode_id} | 是 | §5.9 |
| 10 | POST | /api/v1/podcasts/downloads | 是 | §5.10 |
| 11 | GET | /api/v1/podcasts/downloads/{task_id} | 是 | §5.11 |
| 12 | GET | /api/v1/user/quota/podcasts | 是 | §5.12 |
所有 curl 示例用
Bearer dev-token,<HOST>代表http://localhost:3000(或内网 IP:3000)。
5.0 GET /api/health
存活探针,不鉴权,不带 /api/v1 前缀,直接返回(非统一 envelope)。源码:src/app/api/health/route.ts:15-19、src/lib/services/health.service.ts:17-23。
响应(HTTP 200,裸 JSON,非 envelope):
{ "status": "ok", "uptimeSeconds": 1234, "timestamp": "2026-06-11T08:00:00.000Z" }
| 字段 | 类型 | 来源 |
|---|---|---|
status | "ok" | 固定(health.service.ts:19) |
uptimeSeconds | number | (now - 进程启动时刻)/1000 取整(health.service.ts:20,启动时刻 route.ts:8) |
timestamp | string(ISO) | 当前服务器时间 ISO(health.service.ts:21) |
curl:
curl -s http://localhost:3000/api/health
5.1 GET /api/v1/podcasts/languages
语言列表 + 各语言节目数。源码:src/app/api/v1/podcasts/languages/route.ts、src/lib/services/catalog.service.ts:252-267。
- 查询参数:无。
- 数据来源:
language_stats表(ETL 预聚合),按podcast_count DESC排序(catalog.service.ts:254-257)。 - 进程内 memo 缓存 600s + 响应头
Cache-Control: private, max-age=300(languages/route.ts:25,27,src/lib/api/memo.ts)。
响应 data(数组,首项恒为 all):
{
"code": 0,
"message": "OK",
"data": [
{ "id": "all", "name": "All", "count": 293781 },
{ "id": "en", "name": "English", "count": 290000 },
{ "id": "zh", "name": "中文", "count": 2000 },
{ "id": "unknown", "name": "Unknown", "count": 100 }
],
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id | string | 语言码;首项固定 "all";language_stats.lang 为 ''(NULL 语言)时映射为 "unknown" | catalog.service.ts:259-264 |
name | string | 显示名;all→"All",已知码经 LANGUAGE_NAMES 映射(en→English, zh→中文 等共 20 个),未知码原样返回,''→"Unknown" | catalog.service.ts:169-195,262-263 |
count | number | 该语言节目数;首项 all = 全部语言之和 | catalog.service.ts:258,261-264 |
数值随真实数据聚合(偏差 D-2),实际几乎全为
en;上方 count 为示例占位,非固定。
可能错误码:1001(鉴权失败)、3001(内部错误)。
curl:
curl -s http://localhost:3000/api/v1/podcasts/languages \
-H "Authorization: Bearer dev-token"
5.2 GET /api/v1/podcasts/categories
分类列表(含子类与各级节目数,可按语言过滤)。源码:src/app/api/v1/podcasts/categories/route.ts、src/lib/services/catalog.service.ts:280-326。
查询参数(src/lib/validations/podcasts.ts:22-24):
| 参数 | 必填 | 类型/约束 | 默认 | 说明 |
|---|---|---|---|---|
lang | 否 | string,min(1) | 无 | 语言过滤;传入则各级 count 限定到该语言 |
数据来源:category_stats(主类)+ category_sub_stats(子类)+ language_stats(总数),均 ETL 预聚合,按 count DESC 排序(catalog.service.ts:283-307)。memo 缓存 600s(key 含 lang)+ Cache-Control: private, max-age=300(categories/route.ts:29,31)。
响应 data(数组,首项 all,all.subs 为空数组):
{
"code": 0,
"message": "OK",
"data": [
{ "id": "all", "name": "All", "count": 293781, "subs": [] },
{
"id": "health--fitness",
"name": "Health & Fitness",
"count": 12000,
"subs": [
{ "id": "mental-health", "name": "Mental Health", "count": 3000 }
]
}
],
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id | string | 主类 slug(数据集真实 15 主类,Apple 分类法,如 health--fitness);首项固定 "all" | catalog.service.ts:317-322 |
name | string | 美化显示名(health--fitness→Health & Fitness,true-crime→True Crime,tv→TV,diy→DIY),由 prettyCategoryName 生成 | catalog.service.ts:198-212,320 |
count | number | 该主类节目数;all = 总数(按 lang 过滤后) | catalog.service.ts:291-292,316,321 |
subs | array | 子类列表 [{id,name,count}],all 为空数组 | catalog.service.ts:309-314,322 |
subs[].id | string | 子类 slug | catalog.service.ts:312 |
subs[].name | string | 子类美化名 | catalog.service.ts:312 |
subs[].count | number | 子类节目数 | catalog.service.ts:312 |
分类用数据集 15 主类而非 PRD 9 业务类(偏差 D-3)。具体类目随真实数据,上方为示例。
可能错误码:1000(lang 为空串等非法 —— 实际空串被 searchParamsToObject 丢弃,等价不传)、1001、3001。
curl:
# 全部分类
curl -s http://localhost:3000/api/v1/podcasts/categories \
-H "Authorization: Bearer dev-token"
# 仅英文分类
curl -s "http://localhost:3000/api/v1/podcasts/categories?lang=en" \
-H "Authorization: Bearer dev-token"
5.3 GET /api/v1/podcasts/shows
节目列表(核心检索端点:过滤 + 排序 + 全文搜索 + 时间搜索 + 游标分页)。源码:src/app/api/v1/podcasts/shows/route.ts、src/lib/services/catalog.service.ts:356-474。
查询参数(src/lib/validations/podcasts.ts:27-39):
| 参数 | 必填 | 类型/约束 | 默认 | 说明 | 锚点 |
|---|---|---|---|---|---|
lang | 否 | string min(1) | — | 语言过滤(podcasts.lang = lang) | catalog.service.ts:358 |
category | 否 | string min(1) | — | 主类过滤(EXISTS 子查询匹配 podcast_categories.cat) | catalog.service.ts:359-370 |
sub | 否 | string min(1) | — | 子类过滤;必须与 category 同传,否则 code 1000 "sub requires category" | validations/podcasts.ts:39,catalog.service.ts:361 |
q | 否 | string min(1) | — | 全文搜索(标题 A / 作者 B / 描述 C 加权 tsvector,websearch_to_tsquery('simple'))。存在时强制按相关度排序,item 附 score | catalog.service.ts:371-372,377 |
sort | 否 | enum popular|name|recent | popular | popular=episode_count DESC;name=A-Z;recent=latest_publish_date DESC NULLS LAST。q 存在时此参数被相关度覆盖 | validations/podcasts.ts:33,catalog.service.ts:382-388 |
updated_after | 否 | YYYY-MM-DD | — | 时间搜索:latest_publish_date >= 该日 00:00 UTC(含当日) | catalog.service.ts:373 |
updated_before | 否 | YYYY-MM-DD | — | 时间搜索:latest_publish_date < 次日 00:00 UTC(含当日,闭区间) | catalog.service.ts:374 |
cursor | 否 | base64url 不透明串 | — | 见 §4.2/§4.3;与 sort/q 绑定校验 | shows/route.ts:30-40 |
limit | 否 | int,[1,50] | 20 | >50 钳制为 50;非整数/<1 → 1000 | validations/podcasts.ts:11-16 |
日期格式校验:YYYY-MM-DD 正则 + 真实日历日校验,不符 → code 1000 "expected YYYY-MM-DD" / "invalid calendar date"(validations/podcasts.ts:5-8)。
响应 data(Page 结构):
{
"code": 0,
"message": "OK",
"data": {
"items": [
{
"id": "pod_abc123",
"title": "The Daily",
"author": "The New York Times",
"cover": "https://cdn.example.com/logo.jpg",
"color": "#1A1A1A",
"language_id": "en",
"categories": [ { "cat": "news", "sub": "daily-news" } ],
"episode_count": 1500,
"latest_episode_title": "Friday Briefing",
"latest_episode_date": "2026-06-10"
}
],
"next_cursor": "eyJzIjoicG9wdWxhciIsInYiOjE1MDAsImlkIjoicG9kX2FiYzEyMyJ9",
"total": 290000
},
"request_id": "..."
}
item 字段(catalog.service.ts:437-449):
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id | string | = podcasts.podcast_uid(数据集 podcastUID) | catalog.service.ts:438 |
title | string | = podcasts.name(数据集 podcastName) | catalog.service.ts:439 |
author | string | null | = podcasts.author | catalog.service.ts:440 |
cover | string | null | = podcasts.logo(数据集 logo) | catalog.service.ts:441 |
color | string | null | = podcasts.color(主题色) | catalog.service.ts:442 |
language_id | string | null | = podcasts.lang | catalog.service.ts:443 |
categories | array | [{cat,sub}],来自 podcast_categories(批量查询) | catalog.service.ts:332-344,444 |
episode_count | number | = podcasts.episode_count | catalog.service.ts:445 |
latest_episode_title | string | null | = podcasts.latest_episode_title(ETL 反范式化) | catalog.service.ts:446 |
latest_episode_date | string | null | = podcasts.latest_publish_date 转 YYYY-MM-DD(UTC) | catalog.service.ts:218-220,447 |
score | number | 仅当 q 存在时出现,= ts_rank 相关度分(无则该集为 0) | catalog.service.ts:425,448 |
total:仅首页(无 cursor)返回;纯 lang/category/sub 过滤时走预聚合 stats 表,含 q/日期过滤时走 count(*)(catalog.service.ts:470-472,482-515)。
可能错误码:
- 1000:
sub未带category;日期格式非法;limit非法;游标解码失败/不匹配 sort/q(见 §4.3)。 - 1001、3001。
curl 示例:
# 1) 默认(popular,limit 20,首页带 total)
curl -s "http://localhost:3000/api/v1/podcasts/shows" \
-H "Authorization: Bearer dev-token"
# 2) 语言+主类+子类过滤,按名称排序,limit 10
curl -s "http://localhost:3000/api/v1/podcasts/shows?lang=en&category=news&sub=daily-news&sort=name&limit=10" \
-H "Authorization: Bearer dev-token"
# 3) 全文搜索(自动相关度排序,item 带 score)
curl -s "http://localhost:3000/api/v1/podcasts/shows?q=technology%20news" \
-H "Authorization: Bearer dev-token"
# 4) 时间搜索(最近更新区间)
curl -s "http://localhost:3000/api/v1/podcasts/shows?updated_after=2026-01-01&updated_before=2026-06-01&sort=recent" \
-H "Authorization: Bearer dev-token"
# 5) 翻页(用上一页返回的 next_cursor,保持 sort 不变)
curl -s "http://localhost:3000/api/v1/podcasts/shows?sort=popular&cursor=eyJzIjoicG9wdWxhciIsInYiOjE1MDAsImlkIjoicG9kX2FiYzEyMyJ9" \
-H "Authorization: Bearer dev-token"
5.4 GET /api/v1/podcasts/shows/{show_id}
节目详情。源码:src/app/api/v1/podcasts/shows/[showId]/route.ts、src/lib/services/catalog.service.ts:527-568。
- 路径参数:
show_id=podcasts.podcast_uid。 - 查询参数:无。
- 不存在 → code 2001(
shows/[showId]/route.ts:25)。
响应 data:
{
"code": 0,
"message": "OK",
"data": {
"id": "pod_abc123",
"title": "The Daily",
"author": "The New York Times",
"cover": "https://cdn.example.com/logo.jpg",
"color": "#1A1A1A",
"language_id": "en",
"categories": [ { "cat": "news", "sub": "daily-news" } ],
"description": "This is what the news should sound like...",
"web": "https://www.nytimes.com/the-daily",
"episode_count": 1500,
"episode_count_claimed": 1490,
"latest_episode_title": "Friday Briefing",
"latest_episode_date": "2026-06-10"
},
"request_id": "..."
}
字段语义(catalog.service.ts:553-567,比列表多 description/web/episode_count_claimed):
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id / title / author / cover / color / language_id / categories / episode_count / latest_episode_title / latest_episode_date | 同 §5.3 列表 | 同 §5.3 | catalog.service.ts:554-566 |
description | string | null | = podcasts.description | catalog.service.ts:562 |
web | string | null | = podcasts.web(节目官网) | catalog.service.ts:563 |
episode_count_claimed | number | null | = podcasts.episode_count_claimed(数据源声明的集数,可能与实际 episode_count 不同) | catalog.service.ts:564 |
可能错误码:2001、1001、3001。
curl:
curl -s "http://localhost:3000/api/v1/podcasts/shows/pod_abc123" \
-H "Authorization: Bearer dev-token"
5.5 GET /api/v1/podcasts/shows/{show_id}/episodes
单集列表(keyset 游标分页)。源码:src/app/api/v1/podcasts/shows/[showId]/episodes/route.ts、src/lib/services/catalog.service.ts:583-670。
- 路径参数:
show_id。节目不存在 → code 2001(episodes/route.ts:46)。 - 查询参数(
src/lib/validations/podcasts.ts:42-48):
| 参数 | 必填 | 类型/约束 | 默认 | 说明 | 锚点 |
|---|---|---|---|---|---|
sort | 否 | enum newest|oldest | newest | newest=publish_date DESC NULLS LAST;oldest=ASC NULLS LAST | catalog.service.ts:591,626-628 |
published_after | 否 | YYYY-MM-DD | — | 时间搜索:publish_date >= 该日 00:00 UTC(含当日) | catalog.service.ts:593 |
published_before | 否 | YYYY-MM-DD | — | 时间搜索:publish_date < 次日 00:00 UTC(含当日) | catalog.service.ts:594 |
cursor | 否 | base64url(见 §4.4) | — | 结构校验 isEpisodesCursor,失败 → 1000 | episodes/route.ts:30-36 |
limit | 否 | int [1,50] | 20 | >50 钳制 50 | validations/podcasts.ts:11-16 |
响应 data(Page):
{
"code": 0,
"message": "OK",
"data": {
"items": [
{
"id": "ep_xyz789",
"show_id": "pod_abc123",
"title": "Friday Briefing",
"duration": "00:24:30",
"duration_seconds": 1470,
"publish_date": "2026-06-10",
"cover": "https://cdn.example.com/logo.jpg",
"is_downloadable": true
}
],
"next_cursor": "eyJkIjoiMjAyNi0wNi0xMFQwMDowMDowMC4wMDBaIiwiaWQiOiJlcF94eXo3ODkifQ",
"total": 1500
},
"request_id": "..."
}
item 字段(catalog.service.ts:647-656):
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id | string | = episodes.episode_uid | catalog.service.ts:648 |
show_id | string | = episodes.podcast_uid | catalog.service.ts:649 |
title | string | = episodes.name | catalog.service.ts:650 |
duration | string | null | HH:MM:SS(由秒换算;秒为 null 或 <0 时为 null) | catalog.service.ts:223-230,651 |
duration_seconds | number | null | = episodes.duration(原始秒) | catalog.service.ts:652 |
publish_date | string | null | = episodes.publish_date 转 YYYY-MM-DD(UTC;null 透传) | catalog.service.ts:653 |
cover | string | null | = 节目 logo(数据集单集无独立封面,复用节目 logo,偏差 D-4) | catalog.service.ts:654 |
is_downloadable | boolean | = audio_url 非空 | catalog.service.ts:655 |
偏差 D-4:单集不返回
episode_number/description(数据集无此字段,不臆造)。
total:首页返回,为「过滤后集合」总数(受 published_after/published_before 影响,catalog.service.ts:597-604)。
可能错误码:2001(节目不存在)、1000(日期/limit/游标非法)、1001、3001。
curl:
# 首页(newest)
curl -s "http://localhost:3000/api/v1/podcasts/shows/pod_abc123/episodes?limit=20" \
-H "Authorization: Bearer dev-token"
# 时间窗 + 从旧到新
curl -s "http://localhost:3000/api/v1/podcasts/shows/pod_abc123/episodes?sort=oldest&published_after=2026-01-01&published_before=2026-03-31" \
-H "Authorization: Bearer dev-token"
# 翻页
curl -s "http://localhost:3000/api/v1/podcasts/shows/pod_abc123/episodes?cursor=eyJkIjoiMjAyNi0wNi0xMFQwMDowMDowMC4wMDBaIiwiaWQiOiJlcF94eXo3ODkifQ" \
-H "Authorization: Bearer dev-token"
5.6 GET /api/v1/podcasts/episodes/{episode_id}
单集详情(含所属节目摘要)。源码:src/app/api/v1/podcasts/episodes/[episodeId]/route.ts、src/lib/services/catalog.service.ts:683-726。
- 路径参数:
episode_id=episodes.episode_uid。不存在 → code 2001(episodes/[episodeId]/route.ts:25)。 - 查询参数:无。
响应 data:
{
"code": 0,
"message": "OK",
"data": {
"id": "ep_xyz789",
"show_id": "pod_abc123",
"title": "Friday Briefing",
"duration": "00:24:30",
"duration_seconds": 1470,
"publish_date": "2026-06-10",
"cover": "https://cdn.example.com/logo.jpg",
"is_downloadable": true,
"media_type": "audio/mpeg",
"podcast": {
"id": "pod_abc123",
"title": "The Daily",
"author": "The New York Times",
"cover": "https://cdn.example.com/logo.jpg",
"color": "#1A1A1A",
"language_id": "en",
"episode_count": 1500
}
},
"request_id": "..."
}
字段语义(catalog.service.ts:706-725,在 §5.5 item 基础上加 media_type + podcast 摘要):
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
id/show_id/title/duration/duration_seconds/publish_date/cover/is_downloadable | 同 §5.5 item | 同 §5.5(cover 同样复用节目 logo) | catalog.service.ts:707-714 |
media_type | string | null | = episodes.media_type(如 audio/mpeg) | catalog.service.ts:715 |
podcast.id | string | = episodes.podcast_uid | catalog.service.ts:717 |
podcast.title | string | = podcasts.name | catalog.service.ts:718 |
podcast.author | string | null | = podcasts.author | catalog.service.ts:719 |
podcast.cover | string | null | = podcasts.logo | catalog.service.ts:720 |
podcast.color | string | null | = podcasts.color | catalog.service.ts:721 |
podcast.language_id | string | null | = podcasts.lang | catalog.service.ts:722 |
podcast.episode_count | number | = podcasts.episode_count | catalog.service.ts:723 |
可能错误码:2001、1001、3001。
curl:
curl -s "http://localhost:3000/api/v1/podcasts/episodes/ep_xyz789" \
-H "Authorization: Bearer dev-token"
5.7 GET /api/v1/podcasts/episodes/{episode_id}/stream
播放地址。源码:src/app/api/v1/podcasts/episodes/[episodeId]/stream/route.ts、src/lib/services/catalog.service.ts:769-786。
- 路径参数:
episode_id。查询参数:无。 - 单集不存在 → code 2001;存在但
audio_url为空 → code 2002(stream/route.ts:26-27)。
响应 data:
{
"code": 0,
"message": "OK",
"data": {
"stream_url": "https://cdn.external.com/audio/ep_xyz789.mp3",
"format": "mp3",
"bitrate": null,
"expires_at": "2026-06-11T10:00:00.000Z"
},
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
stream_url | string | = episodes.audio_url(外部 CDN 直连,无签名,偏差 D-5) | catalog.service.ts:780 |
format | string | 由 media_type 推导(audio/mpeg→mp3,audio/mp4→m4a 等),无法识别则从 URL 扩展名兜底,再不行默认 "mp3" | catalog.service.ts:732-760,781 |
bitrate | null | 固定 null(数据集无码率) | catalog.service.ts:782 |
expires_at | string(ISO) | now + 7200s(语义兼容 PRD;实际 URL 无时效签名,过期时间仅形式字段) | catalog.service.ts:783 |
可能错误码:2001(不存在)、2002(无 audio_url)、1001、3001。
curl:
curl -s "http://localhost:3000/api/v1/podcasts/episodes/ep_xyz789/stream" \
-H "Authorization: Bearer dev-token"
5.8 POST /api/v1/podcasts/playback/progress
保存播放进度(UPSERT)。源码:src/app/api/v1/podcasts/playback/progress/route.ts、src/lib/services/playback.service.ts:33-64。
请求 body(JSON,src/lib/validations/podcasts.ts:54-59):
| 字段 | 必填 | 类型/约束 | 说明 | 锚点 |
|---|---|---|---|---|
episode_id | 是 | string min(1) | 单集 UID;不存在 → code 2001 | validations/podcasts.ts:55,playback.service.ts:34-39 |
position_seconds | 是 | int [0, 2147483647] | 当前播放位置(秒)。超 int4 上限 → 1000 | validations/podcasts.ts:51,56 |
duration_seconds | 否 | int [1, 2147483647] | 总时长(秒)。仅当提供时才覆盖存量(部分更新不擦除) | validations/podcasts.ts:57,playback.service.ts:46,59-61 |
completed | 否 | boolean | 是否已听完。仅当提供时才覆盖 | validations/podcasts.ts:58,playback.service.ts:47 |
- body 非法 JSON → code 1000
"invalid JSON body"(progress/route.ts:28)。 - UPSERT 主键
(user_id, episode_uid),可选字段未传不擦除已存值(playback.service.ts:42-62)。
响应 data(成功固定):
{ "code": 0, "message": "OK", "data": { "saved": true }, "request_id": "..." }
可能错误码:1000(body/参数非法)、2001(episode 不存在)、1001、3001。
curl:
curl -s -X POST "http://localhost:3000/api/v1/podcasts/playback/progress" \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{"episode_id":"ep_xyz789","position_seconds":300,"duration_seconds":1470,"completed":false}'
5.9 GET /api/v1/podcasts/playback/progress/{episode_id}
读取播放进度(按当前用户 + episode)。源码:src/app/api/v1/podcasts/playback/progress/[episodeId]/route.ts、src/lib/services/playback.service.ts:75-96。
- 路径参数:
episode_id。 - 无记录时返回
data: null(HTTP 200,code 0),不是 404(playback.service.ts:81,progress/[episodeId]/route.ts:30)。
响应 data(有进度时):
{
"code": 0,
"message": "OK",
"data": {
"episode_id": "ep_xyz789",
"position_seconds": 300,
"duration_seconds": 1470,
"completed": false,
"progress_percent": 20.4,
"updated_at": "2026-06-11T08:00:00.000Z"
},
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
episode_id | string | = playback_progress.episode_uid | playback.service.ts:89 |
position_seconds | number | 已存位置(秒) | playback.service.ts:90 |
duration_seconds | number | null | 已存总时长(秒) | playback.service.ts:91 |
completed | boolean | 是否听完 | playback.service.ts:92 |
progress_percent | number | null | position/duration*100,钳制 [0,100],保留 1 位小数;duration 缺失/≤0 时为 null | playback.service.ts:83-86,93 |
updated_at | string(ISO) | 最后更新时间 ISO | playback.service.ts:94 |
无记录时:
{ "code": 0, "message": "OK", "data": null, "request_id": "..." }
可能错误码:1001、3001(注意:无进度不是错误,返回 data:null)。
curl:
curl -s "http://localhost:3000/api/v1/podcasts/playback/progress/ep_xyz789" \
-H "Authorization: Bearer dev-token"
5.10 POST /api/v1/podcasts/downloads
创建下载任务(含配额校验)。源码:src/app/api/v1/podcasts/downloads/route.ts、src/lib/services/downloads.service.ts:91-134。
请求 body(JSON,src/lib/validations/podcasts.ts:62-66):
| 字段 | 必填 | 类型/约束 | 说明 | 锚点 |
|---|---|---|---|---|
episode_id | 是 | string min(1) | 单集 UID | validations/podcasts.ts:63 |
format | 是 | enum mp3|m4a|flac|wav | 期望格式(仅落库记录,不影响实际下载 URL) | validations/podcasts.ts:64 |
quality | 是 | enum standard|high|lossless | 期望质量(仅落库记录) | validations/podcasts.ts:65 |
校验顺序(downloads.service.ts:91-108,按此顺序短路):
- 试用用户累计任务数 ≥ 3 → code 1003(付费用户跳过此检查,
downloads.service.ts:95-97)。 - 当日任务数 ≥ 300(含付费用户)→ code 1004(
downloads.service.ts:98-100)。 - episode 不存在 → code 2001(
downloads.service.ts:107)。 - episode 无
audio_url→ code 2002(downloads.service.ts:108)。
任务落库后立即标记
completed(无服务端搬运,客户端直连 CDN,偏差 D-6,downloads.service.ts:117,127)。任务 ID 形如task_<uuid>(downloads.service.ts:110)。
成功响应 data:
{
"code": 0,
"message": "OK",
"data": {
"task_id": "task_550e8400-e29b-41d4-a716-446655440000",
"episode_id": "ep_xyz789",
"status": "completed",
"download_url": "https://cdn.external.com/audio/ep_xyz789.mp3",
"url_expires_at": "2026-06-11T09:00:00.000Z",
"trial_quota": { "used": 1, "total": 3 },
"daily_quota": { "used": 1, "total": 300 }
},
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
task_id | string | task_<uuid> | downloads.service.ts:110,125 |
episode_id | string | 请求中的 episode_id | downloads.service.ts:126 |
status | string | 固定 "completed"(D-6) | downloads.service.ts:127 |
download_url | string | = episodes.audio_url(CDN 直连) | downloads.service.ts:128 |
url_expires_at | string(ISO) | now + 3600s(形式字段,URL 实际无签名) | downloads.service.ts:129 |
trial_quota | object | {used,total},used = 本次创建后累计数(lifetime+1),total=3 | downloads.service.ts:130 |
daily_quota | object | {used,total},used = 当日累计(daily+1),total=300 | downloads.service.ts:131 |
配额超限错误响应携带 data(downloads/route.ts:41-47):
- 1003:
data: { "trial_quota": { "used": 3, "total": 3 } } - 1004:
data: { "daily_quota": { "used": 300, "total": 300 } }
可能错误码:1000(body 非法)、1003、1004、2001、2002、1001、3001。
curl:
curl -s -X POST "http://localhost:3000/api/v1/podcasts/downloads" \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{"episode_id":"ep_xyz789","format":"mp3","quality":"standard"}'
5.11 GET /api/v1/podcasts/downloads/{task_id}
查询下载任务状态(仅限任务所有者)。源码:src/app/api/v1/podcasts/downloads/[taskId]/route.ts、src/lib/services/downloads.service.ts:144-160。
- 路径参数:
task_id。 - 任务不存在或不属于当前用户 → code 2001(按
user_id+task_id联合过滤,downloads.service.ts:147-150,downloads/[taskId]/route.ts:25)。
响应 data:
{
"code": 0,
"message": "OK",
"data": {
"task_id": "task_550e8400-e29b-41d4-a716-446655440000",
"episode_id": "ep_xyz789",
"status": "completed",
"format": "mp3",
"file_size_bytes": null,
"created_at": "2026-06-11T08:00:00.000Z",
"completed_at": "2026-06-11T08:00:00.000Z"
},
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
task_id | string | = download_tasks.task_id | downloads.service.ts:152 |
episode_id | string | = download_tasks.episode_uid | downloads.service.ts:153 |
status | string | = download_tasks.status(D-6 下恒为 "completed") | downloads.service.ts:154 |
format | string | = download_tasks.format | downloads.service.ts:155 |
file_size_bytes | number | null | = download_tasks.file_size_bytes(D-6 下恒为 null,无真实搬运) | downloads.service.ts:156 |
created_at | string(ISO) | 创建时间 | downloads.service.ts:157 |
completed_at | string | null | 完成时间(D-6 下 = created_at) | downloads.service.ts:158 |
可能错误码:2001、1001、3001。
curl:
curl -s "http://localhost:3000/api/v1/podcasts/downloads/task_550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer dev-token"
5.12 GET /api/v1/user/quota/podcasts
下载配额快照。源码:src/app/api/v1/user/quota/podcasts/route.ts、src/lib/services/downloads.service.ts:169-182。
- 查询参数:无。
- 数据基于当前用户的
download_tasks计数(lifetime + 当日,downloads.service.ts:69-79,171)。
响应 data:
{
"code": 0,
"message": "OK",
"data": {
"is_trial": true,
"trial_quota": { "used": 1, "total": 3, "remaining": 2 },
"daily_quota": { "used": 1, "total": 300, "remaining": 299, "reset_at": "2026-06-12T00:00:00.000Z" }
},
"request_id": "..."
}
| 字段 | 类型 | 语义与来源 | 锚点 |
|---|---|---|---|
is_trial | boolean | = !isPaid(token 无 paid- 前缀即试用) | downloads.service.ts:173 |
trial_quota.used | number | 历史下载任务总数(lifetime) | downloads.service.ts:174 |
trial_quota.total | number | 固定 3 | downloads.service.ts:8,174 |
trial_quota.remaining | number | max(0, 3 - used) | downloads.service.ts:174 |
daily_quota.used | number | 当日任务数(服务器本地零点起算) | downloads.service.ts:175-176 |
daily_quota.total | number | 固定 300 | downloads.service.ts:9,176 |
daily_quota.remaining | number | max(0, 300 - used) | downloads.service.ts:177 |
daily_quota.reset_at | string(ISO) | 次日服务器本地零点的 ISO 瞬时 | downloads.service.ts:54-66,178 |
说明:配额窗口的「日」边界用服务器本地时区零点(
setHours(0,0,0,0),downloads.service.ts:55-58),而 shows/episodes 的时间搜索用 UTC 日界(catalog.service.ts:233-240)。两套日界不互通,集成方注意。
可能错误码:1001、3001。
curl:
curl -s "http://localhost:3000/api/v1/user/quota/podcasts" \
-H "Authorization: Bearer dev-token"
6. 字段映射表(API ↔ 源数据集)
API 字段命名(snake_case)与数据库列(snake_case)/数据集原始字段(camelCase)的对应关系。Drizzle 列定义:src/lib/db/schema.ts。
6.1 节目(podcasts 表)
| API 字段 | DB 列 | 数据集原始字段 | 变换 | 锚点 |
|---|---|---|---|---|
id / show_id / podcast.id | podcast_uid | podcastUID | 原样 | schema.ts:20,catalog.service.ts:438 |
title / podcast.title | name | podcastName | 原样 | schema.ts:21,catalog.service.ts:439 |
author | author | author | 原样 | schema.ts:22 |
description | description | description | 原样 | schema.ts:23 |
language_id | lang | lang | 原样 | schema.ts:24 |
web | web | web | 原样 | schema.ts:25 |
cover / podcast.cover | logo | logo | 原样 | schema.ts:26,catalog.service.ts:441 |
color | color | color | 原样 | schema.ts:27 |
episode_count | episode_count | (ETL 实际统计) | 原样 | schema.ts:29 |
episode_count_claimed | episode_count_claimed | (数据源声明集数) | 原样 | schema.ts:30 |
latest_episode_title | latest_episode_title | (ETL 反范式化) | 原样 | schema.ts:38 |
latest_episode_date | latest_publish_date | (ETL 反范式化,timestamptz) | timestamptz → YYYY-MM-DD(UTC,取 ISO 前 10 位) | schema.ts:39,catalog.service.ts:218-220 |
6.2 单集(episodes 表)
| API 字段 | DB 列 | 数据集原始字段 | 变换 | 锚点 |
|---|---|---|---|---|
id / episode_id | episode_uid | (单集 UID) | 原样 | schema.ts:48 |
show_id | podcast_uid | podcastUID | 原样 | schema.ts:49 |
title | name | (单集标题) | 原样 | schema.ts:51 |
stream_url / download_url | audio_url | audioUrl | 原样(CDN 直连,无签名) | schema.ts:52,catalog.service.ts:780 |
media_type | media_type | mediaType | 原样 | schema.ts:53 |
publish_date | publish_date | (timestamptz) | timestamptz → YYYY-MM-DD(UTC) | schema.ts:54,catalog.service.ts:218-220 |
duration_seconds | duration | (秒,int) | 原样 | schema.ts:55 |
duration | duration | (秒,int) | 秒 → HH:MM:SS(零填充;null 或负数 → null) | catalog.service.ts:223-230 |
is_downloadable | (派生) | — | audio_url != null → boolean | catalog.service.ts:655 |
cover(单集) | — | — | 无单集封面,复用节目 logo(D-4) | catalog.service.ts:654 |
format(stream) | (派生自 media_type/URL) | — | audio/mpeg→mp3 等映射表,兜底 URL 扩展名,再兜底 mp3 | catalog.service.ts:732-760 |
6.3 变换规则速查
| 变换 | 规则 | 锚点 |
|---|---|---|
日期 timestamptz → YYYY-MM-DD | date.toISOString().slice(0,10)(UTC 日期;null 透传) | catalog.service.ts:218-220 |
时长 秒 → HH:MM:SS | Math.floor 拆 h/m/s,各 2 位零填充;秒为 null 或 <0 → null | catalog.service.ts:223-230 |
| 语言码 → 显示名 | LANGUAGE_NAMES 表(20 个,支持 xx-YY 取主码);未知原样;''→Unknown | catalog.service.ts:169-195,262-263 |
| 分类 slug → 显示名 | -- 拆为 &,- 拆为空格首字母大写;tv→TV、diy→DIY | catalog.service.ts:198-212 |
| 关系日期 → UTC 日界(时间搜索) | after: >= 该日00:00Z;before: < 次日00:00Z(闭区间) | catalog.service.ts:232-240,373-374,593-594 |
| 配额日界 | 服务器本地时区零点(与时间搜索 UTC 不同) | downloads.service.ts:55-58 |
数据库一手事实(已核验):8 张表(podcasts / episodes / podcast_categories / language_stats / category_stats / category_sub_stats / playback_progress / download_tasks)、17 个索引(由 ETL 创建,schema 仅声明主键)、293,781 播客 + 22,867,469 剧集 + 354,508 分类关联。
tsv是 GENERATED ALWAYS STORED 列,API 只读不写(schema.ts:34-36)。
7. 与 PRD 的偏差登记 D-1 ~ D-7
来源:podcast-api/docs/API-DESIGN.md:103-112,并逐条标注对集成方的影响。
| # | 偏差 | 理由 | 对集成方的影响 | 锚点 |
|---|---|---|---|---|
| D-1 | X-Client-Version/X-Platform 不强校验(仅日志) | 本地单机部署无版本治理需求 | 这两个 Header 传不传都不影响调用;不要依赖服务端做版本拦截 | src/lib/api/auth.ts:37-40 |
| D-2 | 语言列表来自真实数据聚合(实际几乎全 en),非 PRD 固定 5 种 | 数据一致性优先 | languages 返回值与 PRD 示例不同;客户端勿硬编码语言集,按返回渲染 | catalog.service.ts:252-267 |
| D-3 | 分类用数据集真实 15 主类(Apple 分类法,如 health--fitness),非 PRD 9 业务类 | 同上;契约形状不变 | categories 的 id 是 Apple slug;过滤 shows?category= 必须用真实 slug,不能用 PRD 业务名 | catalog.service.ts:280-326 |
| D-4 | 单集无 episode_number/description/独立封面 | 数据集无此字段,不臆造 | 单集详情/列表不返回这些字段;cover 复用节目 logo;UI 勿假设单集有独立封面/集号 | catalog.service.ts:654,647-656,706-714 |
| D-5 | stream_url/download_url 不做时效签名(外部 CDN 直连) | 本地部署无密钥设施 | expires_at/url_expires_at 仅形式字段,URL 实际长期有效或受 CDN 自身策略约束;客户端直连 CDN 播放/下载 | catalog.service.ts:780-783,downloads.service.ts:128-129 |
| D-6 | 下载任务即时 completed(服务端不代理音频) | 架构上不搬运音频 | POST downloads 立即返回 status:"completed";无需轮询 GET downloads/{task_id} 等待进度;file_size_bytes 恒 null;真正下载靠客户端用 download_url 直连 CDN | downloads.service.ts:117,127,156 |
| D-7 | 未启用 Redis 缓存层 | 单用户本地部署 QPS 极低 | 字典端点用进程内 memo(600s)+ HTTP Cache-Control(300s)替代;多实例部署下 memo 不共享,但只读目录无一致性风险 | src/lib/api/memo.ts,languages/route.ts:25 |
8. 给集成方的注意事项
-
force-dynamic 实时查询:所有业务端点
export const dynamic = 'force-dynamic'(如shows/route.ts:11),每次请求实时查库,无 Next.js 路由缓存。字典端点(languages/categories)另有进程内 memo(600s)+ 客户端Cache-Control(300s);客户端可据此本地缓存字典,减少请求。 -
stream_url / download_url 直连外部 CDN 且无签名(D-5):
stream_url、download_url即数据集audio_url,是外部 CDN 地址。客户端直接拿去播放/下载,不经过本服务代理。expires_at/url_expires_at是形式字段,不代表真实失效时间。CDN 自身可能有防盗链/地域限制,本服务不负责。 -
下载任务即时 completed,无需轮询(D-6):
POST /downloads同步返回status:"completed"和download_url。客户端拿到即可下载,不要写「轮询 task 等待 completed」的逻辑。GET /downloads/{task_id}仅用于事后查询历史任务记录。file_size_bytes恒为 null。 -
限流(Rate Limiting)未实装:源码中未发现任何按 IP/token 的请求频率限制(限流未实现/待确认)。仅有下载额度(试用 3 集 / 日 300 集)这类业务配额。集成方不要依赖服务端限流保护,自行做客户端节流。
-
HTTP 状态码 vs 业务 code 双判断:1003/1004/2002 的 HTTP 都是 403,必须读 envelope
code区分。403 不一定是鉴权问题(401 才是)。 -
翻页一致性:shows 的 keyset 游标与
sort强绑定,改 sort/加 q 会使旧游标失效(code 1000,见 §4.3)。翻页时务必固定全部过滤/排序参数,只换cursor。total只在首页返回。 -
付费用户开关:付费 = token 带
paid-前缀。要测付费路径需部署方在API_BEARER_TOKENS配置带前缀的 token。付费用户仅跳过试用额度,仍受日配额限制。 -
空串参数 = 不传:
?cursor=、?lang=等空串值被searchParamsToObject丢弃(validations/podcasts.ts:81-87),等价于不传该参数。 -
日界不统一:时间搜索(shows/episodes 的
*_after/*_before)用 UTC 日界;下载配额日界用服务器本地时区零点。跨时区集成时注意reset_at的时区语义。 -
/api/health例外:唯一不鉴权、不带/api/v1、不走统一 envelope 的端点,返回裸 JSON{status,uptimeSeconds,timestamp}。可用作探活,但不要用它判断业务可用性(它不查库)。 -
POST 必须带
Content-Type与合法 JSON:playback/downloads 的 POST 用req.json()解析,body 非 JSON → code 1000"invalid JSON body"(progress/route.ts:24-29)。 -
错误
message含 Zod 详情:1000 错误的message形如"sub: sub requires category"或"position_seconds: ..."(validations/podcasts.ts:95-99),可直接展示或用于调试,但不要据其文本做程序分支(应判code)。