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. 接入概览
  2. 统一响应 envelope
  3. 错误码全表
  4. 游标分页机制详解
  5. 端点详细规格(13 个)
  6. 字段映射表(API ↔ 源数据集)
  7. 与 PRD 的偏差登记 D-1 ~ D-7
  8. 给集成方的注意事项

1. 接入概览

1.1 Base URL

项值锚点
业务 API 前缀http://<内网IP>:3000/api/v1路由位于 src/app/api/v1/**
健康检查(不带 v1)http://<内网IP>:3000/api/healthsrc/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.2src/lib/api/auth.ts:42-48
Content-Type: application/jsonPOST 必需POST 端点用 req.json() 解析 body;非法 JSON → code 1000src/app/api/v1/podcasts/playback/progress/route.ts:24-29
X-Request-ID可选(推荐)回显到响应 request_id;未传则服务端生成 UUID。Header 名大小写不敏感,读取 x-request-idsrc/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"
}
字段类型含义锚点
codenumber业务错误码(0 = 成功,见 §3)。注意:与 HTTP 状态码是两套体系,需各自判断src/lib/api/envelope.ts:12,26
messagestring人类可读描述。成功固定 "OK";失败为错误描述(参数错误时含 Zod 详情)src/lib/api/envelope.ts:12,26
dataobject | array | null业务负载。成功时为端点数据;失败时默认 null,部分配额错误会带 extraData(见 §5.11)src/lib/api/envelope.ts:12,26
request_idstring请求追踪 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)。

  1. 若请求带 X-Request-ID(大小写不敏感),原样回显到 request_id。
  2. 否则服务端用 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(...) 调用中给出。

codeHTTP常量名含义触发条件客户端处理建议
0200OK成功正常返回读取 data
1000400INVALID_PARAMS参数校验失败(PRD 未定义,本实现扩展)Zod 校验不通过 / 游标解码失败 / 游标与 sort 不匹配 / POST body 非法 JSONmessage 含具体错误(Zod 路径+原因),修正参数后重试;不要重试同样参数
1001401UNAUTHORIZEDToken 无效或缺失Authorization 缺失、非 Bearer 格式、或 token 不在允许列表检查 Bearer token;prompt 用户重新登录/换 token
1003403TRIAL_QUOTA_EXCEEDED试用额度已耗尽(3 集累计)试用用户累计下载任务数 ≥ 3提示升级付费;data.trial_quota 含 {used,total}
1004403DAILY_QUOTA_EXCEEDED当日下载配额超限(300 集/日)当日下载任务数 ≥ 300(含付费用户)提示次日重试;data.daily_quota 含 {used,total}
2001404NOT_FOUND节目/单集/任务不存在show_id / episode_id / task_id 查不到,或任务不属于当前用户提示资源不存在;不重试
2002403NOT_DOWNLOADABLE内容不可下载/播放(无 audio_url)单集 audio_url 为空提示该集无音频源;不重试
3001500INTERNAL_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(首页省略或空串)+ limitsrc/lib/validations/podcasts.ts:18-19
limit 默认值20src/lib/validations/podcasts.ts:14
limit 上限50,超过自动钳制为 50(不报错)src/lib/validations/podcasts.ts:16
limit 非法值非整数 / < 1 → code 1000src/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)返回;翻页不含 totalsrc/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, DESCcatalog.service.ts:397-398,465
sort=name{ "s":"name", "v":"<name>", "id":"<podcast_uid>" }(name, podcast_uid) 行值比较 >ASC, ASCcatalog.service.ts:399-400,457
sort=recent{ "s":"recent", "v":"<ISO时间>"|null, "id":"<podcast_uid>" }(latest_publish_date, podcast_uid),NULLS LASTDESC, DESCcatalog.service.ts:401-407,458-463
q 存在(相关度){ "o":<offset:int≥0> }纯 OFFSET(ts_rank 无稳定 keyset)相关度 DESCcatalog.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。

规则:

  1. 游标解码失败或不符合 isShowsCursor → code 1000(shows/route.ts:32-33)。
  2. 若游标是 keyset(含 s 字段)且满足以下任一条件 → code 1000 "cursor: cursor does not match the requested sort"(shows/route.ts:37-38):
    • 本次请求带了 q(相关度模式不接受 keyset 游标),或
    • 游标的 s 与本次请求的 sort 不一致(如用 popular 游标却请求 sort=name)。
  3. 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):

#方法路径鉴权章节
0GET/api/health否§5.0
1GET/api/v1/podcasts/languages是§5.1
2GET/api/v1/podcasts/categories是§5.2
3GET/api/v1/podcasts/shows是§5.3
4GET/api/v1/podcasts/shows/{show_id}是§5.4
5GET/api/v1/podcasts/shows/{show_id}/episodes是§5.5
6GET/api/v1/podcasts/episodes/{episode_id}是§5.6
7GET/api/v1/podcasts/episodes/{episode_id}/stream是§5.7
8POST/api/v1/podcasts/playback/progress是§5.8
9GET/api/v1/podcasts/playback/progress/{episode_id}是§5.9
10POST/api/v1/podcasts/downloads是§5.10
11GET/api/v1/podcasts/downloads/{task_id}是§5.11
12GET/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)
uptimeSecondsnumber(now - 进程启动时刻)/1000 取整(health.service.ts:20,启动时刻 route.ts:8)
timestampstring(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": "..."
}
字段类型语义与来源锚点
idstring语言码;首项固定 "all";language_stats.lang 为 ''(NULL 语言)时映射为 "unknown"catalog.service.ts:259-264
namestring显示名;all→"All",已知码经 LANGUAGE_NAMES 映射(en→English, zh→中文 等共 20 个),未知码原样返回,''→"Unknown"catalog.service.ts:169-195,262-263
countnumber该语言节目数;首项 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": "..."
}
字段类型语义与来源锚点
idstring主类 slug(数据集真实 15 主类,Apple 分类法,如 health--fitness);首项固定 "all"catalog.service.ts:317-322
namestring美化显示名(health--fitness→Health & Fitness,true-crime→True Crime,tv→TV,diy→DIY),由 prettyCategoryName 生成catalog.service.ts:198-212,320
countnumber该主类节目数;all = 总数(按 lang 过滤后)catalog.service.ts:291-292,316,321
subsarray子类列表 [{id,name,count}],all 为空数组catalog.service.ts:309-314,322
subs[].idstring子类 slugcatalog.service.ts:312
subs[].namestring子类美化名catalog.service.ts:312
subs[].countnumber子类节目数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 附 scorecatalog.service.ts:371-372,377
sort否enum popular|name|recentpopularpopular=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 → 1000validations/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):

字段类型语义与来源锚点
idstring= podcasts.podcast_uid(数据集 podcastUID)catalog.service.ts:438
titlestring= podcasts.name(数据集 podcastName)catalog.service.ts:439
authorstring | null= podcasts.authorcatalog.service.ts:440
coverstring | null= podcasts.logo(数据集 logo)catalog.service.ts:441
colorstring | null= podcasts.color(主题色)catalog.service.ts:442
language_idstring | null= podcasts.langcatalog.service.ts:443
categoriesarray[{cat,sub}],来自 podcast_categories(批量查询)catalog.service.ts:332-344,444
episode_countnumber= podcasts.episode_countcatalog.service.ts:445
latest_episode_titlestring | null= podcasts.latest_episode_title(ETL 反范式化)catalog.service.ts:446
latest_episode_datestring | null= podcasts.latest_publish_date 转 YYYY-MM-DD(UTC)catalog.service.ts:218-220,447
scorenumber仅当 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.3catalog.service.ts:554-566
descriptionstring | null= podcasts.descriptioncatalog.service.ts:562
webstring | null= podcasts.web(节目官网)catalog.service.ts:563
episode_count_claimednumber | 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|oldestnewestnewest=publish_date DESC NULLS LAST;oldest=ASC NULLS LASTcatalog.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,失败 → 1000episodes/route.ts:30-36
limit否int [1,50]20>50 钳制 50validations/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):

字段类型语义与来源锚点
idstring= episodes.episode_uidcatalog.service.ts:648
show_idstring= episodes.podcast_uidcatalog.service.ts:649
titlestring= episodes.namecatalog.service.ts:650
durationstring | nullHH:MM:SS(由秒换算;秒为 null 或 <0 时为 null)catalog.service.ts:223-230,651
duration_secondsnumber | null= episodes.duration(原始秒)catalog.service.ts:652
publish_datestring | null= episodes.publish_date 转 YYYY-MM-DD(UTC;null 透传)catalog.service.ts:653
coverstring | null= 节目 logo(数据集单集无独立封面,复用节目 logo,偏差 D-4)catalog.service.ts:654
is_downloadableboolean= 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_typestring | null= episodes.media_type(如 audio/mpeg)catalog.service.ts:715
podcast.idstring= episodes.podcast_uidcatalog.service.ts:717
podcast.titlestring= podcasts.namecatalog.service.ts:718
podcast.authorstring | null= podcasts.authorcatalog.service.ts:719
podcast.coverstring | null= podcasts.logocatalog.service.ts:720
podcast.colorstring | null= podcasts.colorcatalog.service.ts:721
podcast.language_idstring | null= podcasts.langcatalog.service.ts:722
podcast.episode_countnumber= podcasts.episode_countcatalog.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_urlstring= episodes.audio_url(外部 CDN 直连,无签名,偏差 D-5)catalog.service.ts:780
formatstring由 media_type 推导(audio/mpeg→mp3,audio/mp4→m4a 等),无法识别则从 URL 扩展名兜底,再不行默认 "mp3"catalog.service.ts:732-760,781
bitratenull固定 null(数据集无码率)catalog.service.ts:782
expires_atstring(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 2001validations/podcasts.ts:55,playback.service.ts:34-39
position_seconds是int [0, 2147483647]当前播放位置(秒)。超 int4 上限 → 1000validations/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_idstring= playback_progress.episode_uidplayback.service.ts:89
position_secondsnumber已存位置(秒)playback.service.ts:90
duration_secondsnumber | null已存总时长(秒)playback.service.ts:91
completedboolean是否听完playback.service.ts:92
progress_percentnumber | nullposition/duration*100,钳制 [0,100],保留 1 位小数;duration 缺失/≤0 时为 nullplayback.service.ts:83-86,93
updated_atstring(ISO)最后更新时间 ISOplayback.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)单集 UIDvalidations/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,按此顺序短路):

  1. 试用用户累计任务数 ≥ 3 → code 1003(付费用户跳过此检查,downloads.service.ts:95-97)。
  2. 当日任务数 ≥ 300(含付费用户)→ code 1004(downloads.service.ts:98-100)。
  3. episode 不存在 → code 2001(downloads.service.ts:107)。
  4. 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_idstringtask_<uuid>downloads.service.ts:110,125
episode_idstring请求中的 episode_iddownloads.service.ts:126
statusstring固定 "completed"(D-6)downloads.service.ts:127
download_urlstring= episodes.audio_url(CDN 直连)downloads.service.ts:128
url_expires_atstring(ISO)now + 3600s(形式字段,URL 实际无签名)downloads.service.ts:129
trial_quotaobject{used,total},used = 本次创建后累计数(lifetime+1),total=3downloads.service.ts:130
daily_quotaobject{used,total},used = 当日累计(daily+1),total=300downloads.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_idstring= download_tasks.task_iddownloads.service.ts:152
episode_idstring= download_tasks.episode_uiddownloads.service.ts:153
statusstring= download_tasks.status(D-6 下恒为 "completed")downloads.service.ts:154
formatstring= download_tasks.formatdownloads.service.ts:155
file_size_bytesnumber | null= download_tasks.file_size_bytes(D-6 下恒为 null,无真实搬运)downloads.service.ts:156
created_atstring(ISO)创建时间downloads.service.ts:157
completed_atstring | 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_trialboolean= !isPaid(token 无 paid- 前缀即试用)downloads.service.ts:173
trial_quota.usednumber历史下载任务总数(lifetime)downloads.service.ts:174
trial_quota.totalnumber固定 3downloads.service.ts:8,174
trial_quota.remainingnumbermax(0, 3 - used)downloads.service.ts:174
daily_quota.usednumber当日任务数(服务器本地零点起算)downloads.service.ts:175-176
daily_quota.totalnumber固定 300downloads.service.ts:9,176
daily_quota.remainingnumbermax(0, 300 - used)downloads.service.ts:177
daily_quota.reset_atstring(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.idpodcast_uidpodcastUID原样schema.ts:20,catalog.service.ts:438
title / podcast.titlenamepodcastName原样schema.ts:21,catalog.service.ts:439
authorauthorauthor原样schema.ts:22
descriptiondescriptiondescription原样schema.ts:23
language_idlanglang原样schema.ts:24
webwebweb原样schema.ts:25
cover / podcast.coverlogologo原样schema.ts:26,catalog.service.ts:441
colorcolorcolor原样schema.ts:27
episode_countepisode_count(ETL 实际统计)原样schema.ts:29
episode_count_claimedepisode_count_claimed(数据源声明集数)原样schema.ts:30
latest_episode_titlelatest_episode_title(ETL 反范式化)原样schema.ts:38
latest_episode_datelatest_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_idepisode_uid(单集 UID)原样schema.ts:48
show_idpodcast_uidpodcastUID原样schema.ts:49
titlename(单集标题)原样schema.ts:51
stream_url / download_urlaudio_urlaudioUrl原样(CDN 直连,无签名)schema.ts:52,catalog.service.ts:780
media_typemedia_typemediaType原样schema.ts:53
publish_datepublish_date(timestamptz)timestamptz → YYYY-MM-DD(UTC)schema.ts:54,catalog.service.ts:218-220
duration_secondsduration(秒,int)原样schema.ts:55
durationduration(秒,int)秒 → HH:MM:SS(零填充;null 或负数 → null)catalog.service.ts:223-230
is_downloadable(派生)—audio_url != null → booleancatalog.service.ts:655
cover(单集)——无单集封面,复用节目 logo(D-4)catalog.service.ts:654
format(stream)(派生自 media_type/URL)—audio/mpeg→mp3 等映射表,兜底 URL 扩展名,再兜底 mp3catalog.service.ts:732-760

6.3 变换规则速查

变换规则锚点
日期 timestamptz → YYYY-MM-DDdate.toISOString().slice(0,10)(UTC 日期;null 透传)catalog.service.ts:218-220
时长 秒 → HH:MM:SSMath.floor 拆 h/m/s,各 2 位零填充;秒为 null 或 <0 → nullcatalog.service.ts:223-230
语言码 → 显示名LANGUAGE_NAMES 表(20 个,支持 xx-YY 取主码);未知原样;''→Unknowncatalog.service.ts:169-195,262-263
分类 slug → 显示名-- 拆为 &,- 拆为空格首字母大写;tv→TV、diy→DIYcatalog.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-1X-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-5stream_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 直连 CDNdownloads.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. 给集成方的注意事项

  1. force-dynamic 实时查询:所有业务端点 export const dynamic = 'force-dynamic'(如 shows/route.ts:11),每次请求实时查库,无 Next.js 路由缓存。字典端点(languages/categories)另有进程内 memo(600s)+ 客户端 Cache-Control(300s);客户端可据此本地缓存字典,减少请求。

  2. stream_url / download_url 直连外部 CDN 且无签名(D-5):stream_url、download_url 即数据集 audio_url,是外部 CDN 地址。客户端直接拿去播放/下载,不经过本服务代理。expires_at/url_expires_at 是形式字段,不代表真实失效时间。CDN 自身可能有防盗链/地域限制,本服务不负责。

  3. 下载任务即时 completed,无需轮询(D-6):POST /downloads 同步返回 status:"completed" 和 download_url。客户端拿到即可下载,不要写「轮询 task 等待 completed」的逻辑。GET /downloads/{task_id} 仅用于事后查询历史任务记录。file_size_bytes 恒为 null。

  4. 限流(Rate Limiting)未实装:源码中未发现任何按 IP/token 的请求频率限制(限流未实现/待确认)。仅有下载额度(试用 3 集 / 日 300 集)这类业务配额。集成方不要依赖服务端限流保护,自行做客户端节流。

  5. HTTP 状态码 vs 业务 code 双判断:1003/1004/2002 的 HTTP 都是 403,必须读 envelope code 区分。403 不一定是鉴权问题(401 才是)。

  6. 翻页一致性:shows 的 keyset 游标与 sort 强绑定,改 sort/加 q 会使旧游标失效(code 1000,见 §4.3)。翻页时务必固定全部过滤/排序参数,只换 cursor。total 只在首页返回。

  7. 付费用户开关:付费 = token 带 paid- 前缀。要测付费路径需部署方在 API_BEARER_TOKENS 配置带前缀的 token。付费用户仅跳过试用额度,仍受日配额限制。

  8. 空串参数 = 不传:?cursor=、?lang= 等空串值被 searchParamsToObject 丢弃(validations/podcasts.ts:81-87),等价于不传该参数。

  9. 日界不统一:时间搜索(shows/episodes 的 *_after/*_before)用 UTC 日界;下载配额日界用服务器本地时区零点。跨时区集成时注意 reset_at 的时区语义。

  10. /api/health 例外:唯一不鉴权、不带 /api/v1、不走统一 envelope 的端点,返回裸 JSON {status,uptimeSeconds,timestamp}。可用作探活,但不要用它判断业务可用性(它不查库)。

  11. POST 必须带 Content-Type 与合法 JSON:playback/downloads 的 POST 用 req.json() 解析,body 非 JSON → code 1000 "invalid JSON body"(progress/route.ts:24-29)。

  12. 错误 message 含 Zod 详情:1000 错误的 message 形如 "sub: sub requires category" 或 "position_seconds: ..."(validations/podcasts.ts:95-99),可直接展示或用于调试,但不要据其文本做程序分支(应判 code)。