01 系统架构设计
分册定位:本册是《播客目录展示服务》交付文档的架构总览,面向工程师与 AI Agent,描述实际落地的三端系统(数据底座 / 服务端 / 桌面客户端)。 真源对照:架构决策真源为
deepresearch_podcast-catalog-api-architecture_20260604_v4.md(下称 v4);实际部署见DELIVERY.md;依赖链与运维见docs/SERVICE-OPS.md。 真实性约定:所有"已落地"结论均给出file_path:line锚点;v4 设计但本项目未做/简化的,统一在 §7 标注,正文不臆造。 最后更新:2026-06-11。
0. 一页速览(TL;DR)
| 维度 | 实际落地结论 | 锚点 |
|---|---|---|
| 形态 | 只读播客目录 + macOS 桌面客户端浏览/搜索/播放,单机内网部署(服务端 IP 10.10.20.207) | DELIVERY.md:3、docs/SERVICE-OPS.md:2 |
| 数据底座 | PostgreSQL 16(容器 podcast-pg),8 张表 / 17 索引 | docs/SERVICE-OPS.md:23、scripts/etl/finalize.sql:11 |
| 数据规模 | 293,781 播客 + 22,867,469 剧集 + 354,508 分类关联 | 实测 podcast-pg 库(docs/SERVICE-OPS.md:23) |
| 服务端 | Next.js 16.2.7 App Router · 仅 Route Handlers · Node runtime;13 个路由 / 4 个 service | podcast-api/package.json:21、src/app/api/**/route.ts |
| 客户端 | Qt6 主窗口内嵌 CEF(Chromium Embedded Framework)浏览器,加载内嵌 Web UI(静态 HTML/CSS/JS) | desktop/README.md:1、podcast-api/public/app/index.html |
| 鉴权 | 内网固定 Bearer token 白名单(API_BEARER_TOKENS,默认 dev-token),userId = token | src/lib/api/auth.ts:36、src/config/env.ts:10 |
| 缓存 | 进程内 TTL memo(非 Redis),单实例部署 | src/lib/api/memo.ts:1 |
| 音频 | 不代理,直接返回外部 CDN audioUrl(v4 D-5) | src/lib/services/catalog.service.ts:769 |
与 v4 最大的落地差异:v4 推荐 k8s 多副本容器 + Redis 7 + ioredis + JWT/API Key 双层鉴权;本项目落地为 单机 colima 容器 + PostgreSQL 单库 + 进程内缓存 + 固定 token 内网鉴权。v4 中框架无关的契约(数据库 Schema、REST/keyset 分页、错误码、音频不代理)原样落地;v4 中为公网高并发设计的部分(Redis、k8s、JWT 轮换、CORS)按"内网单机"现实裁掉或降级。
1. 项目定位与全景
1.1 一句话定位
为公司内网提供一个只读的播客目录展示与播放服务:服务端把一份离线构建的播客数据集(dataset.7z)灌入数据库并以 REST API 暴露;macOS 桌面客户端通过 HTTP API 浏览分类、搜索播客、查看剧集、并直接从外部 CDN 播放音频。
1.2 数据形态(来自 v4,已核验)
- 数据是批量构建的只读快照(
buildId),偏只读、周期重建(deepresearch...v4.md:51)。 - 剧集是大表:
audioUrl指向外部 CDN(如traffic.libsyn.com),非自托管(deepresearch...v4.md:66)。 - 分类体系沿用 Apple Podcasts 同款分类法,主类用
--表示复合词(如health--fitness)(deepresearch...v4.md:71)。
1.3 部署形态(来自 DELIVERY,实际落地)
- 方案 B · 内网服务端模式:本机(
10.10.20.207)作为内网服务端,同事安装PodcastDesktop.dmg,app 通过 HTTP API 取数据并展示/播放(DELIVERY.md:3)。 - 客户端范围:仅 Apple Silicon (arm64) / macOS;Intel / Windows 不在本次交付范围(
DELIVERY.md:19)。
1.4 三端职责划分
| 端 | 技术 | 职责 | 不负责 |
|---|---|---|---|
| 数据底座 | PostgreSQL 16(容器) | 持久化只读目录 + 用户态(播放进度 / 下载任务);索引化筛选/搜索/分页 | 不直接对外,只绑 127.0.0.1 |
| 服务端 | Next.js 16 Route Handlers | REST API(/api/v1/podcasts/*)+ 静态 Web UI(/app/);鉴权、校验、日志、缓存 | 不代理音频;不签发 JWT |
| 客户端 | Qt6 + CEF + 内嵌 Web UI | 原生窗口壳 + Chromium 渲染 Web UI + 浏览器内音频解码播放 | 不直接连数据库;不做业务逻辑(逻辑在 Web UI/服务端) |
2. 三端总体架构图
┌──────────────────────────────────────────────────────────────────────────────┐
│ 桌面客户端 (PodcastDesktop.app, macOS arm64) │
│ ┌────────────────────────────┐ 嵌入 ┌──────────────────────────────────┐ │
│ │ Qt6 主窗口 (QMainWindow) │ ───────▶ │ CEF (Chromium Embedded Framework)│ │
│ │ desktop/src/qt_window.cpp │ │ + 5 个 Helper 进程 │ │
│ │ + 10ms QTimer 驱动消息泵 │ │ desktop/src/cef_client.cc │ │
│ └────────────────────────────┘ │ 加载 → http://10.10.20.207:3000 │ │
│ │ /app/index.html (Web UI) │ │
│ │ 音频在 Chromium 内解码播放 │ │
│ └──────────────────────────────────┘ │
└───────────────────────────────┬──────────────────────────────────────────────┘
│ HTTP (Authorization: Bearer <token>)
│ LAN: http://10.10.20.207:3000
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ 服务端 Next.js 16.2.7 (App Router · 仅 Route Handlers · Node runtime) │
│ 监听 *:3000(全部网卡,LAN 可达) 工作目录 podcast-api/ │
│ │
│ 静态 Web UI /app/* ──────────────▶ public/app/{index.html,app.js,styles.css}│
│ │
│ REST API /api/health │
│ /api/v1/podcasts/* (13 个 route.ts) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ route.ts: authenticate() → zod 校验 → service → envelope(ok/fail)│ │
│ │ lib/api/auth.ts lib/validations/* lib/api/envelope.ts │ │
│ └───────────────────────────────┬─────────────────────────────┘ │
│ 4 个 service (lib/services/*) │ 进程内 TTL memo (lib/api/memo.ts) │
│ catalog / playback / downloads / health │
│ │ Drizzle ORM + postgres-js (连接池 max:10)│
└─────────────────────────────────────────┼─────────────────────────────────────┘
│ DATABASE_URL → 127.0.0.1:5432
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ 数据底座 PostgreSQL 16 (容器 podcast-pg, 只绑 127.0.0.1:5432) │
│ 在 colima VM 内运行;VM 磁盘镜像位于外置盘 /Volumes/External/develop/.colima │
│ │
│ 8 张表:podcasts / episodes / podcast_categories │
│ playback_progress / download_tasks │
│ language_stats / category_stats / category_sub_stats (ETL 预聚合) │
│ 17 索引:GIN(tsv 全文) · GIN(name trgm) · keyset(popular/name/recent) · ... │
└──────────────────────────────────────────────────────────────────────────────┘
▲
│ 离线 ETL(约 20 分钟一次性入库)
dataset.7z → transform.mjs → COPY staging → finalize.sql → perf-stats.sql
音频:客户端拿 episode 的 audioUrl,由 Chromium 直连外部 CDN(默认不经服务端)
锚点:客户端结构 desktop/README.md:1、desktop/src/qt_window.cpp、desktop/src/cef_client.cc;服务端监听 docs/SERVICE-OPS.md:14;DB 只绑本地 docs/SERVICE-OPS.md:12、DELIVERY.md:36;依赖链 docs/SERVICE-OPS.md:9。
3. 技术栈选型与裁决
3.1 实际技术栈(来自 podcast-api/package.json)
| 类别 | 选型 | 版本 | 锚点 |
|---|---|---|---|
| Web 框架 | Next.js(App Router,仅 Route Handlers) | 16.2.7 | package.json:23 |
| UI 运行时 | React / React-DOM | 19.2.4 | package.json:25 |
| ORM | Drizzle ORM | ^0.45.2 | package.json:21 |
| DB 驱动 | postgres(postgres-js) | ^3.4.9 | package.json:24 |
| 校验 | Zod | ^4.4.3 | package.json:27 |
| 日志 | pino + pino-pretty(dev) | ^10.3.1 | package.json:22 |
| 测试 | Vitest + @vitest/coverage-v8 | ^4.1.8 | package.json:53 |
| 语言 | TypeScript | ^5 | package.json:50 |
| Node | engines | >=22 | package.json:7 |
注:v4 执行摘要写的是
next@16.2.6 / drizzle@0.38 / zod@3.24 / pino@9 / ioredis@5.10,实际落地版本更新且用 postgres-js 取代 node-postgres、用进程内 memo 取代 ioredis/Redis。版本以package.json为准。
3.2 逐项裁决
Next.js 16(App Router · 仅 Route Handlers)— 公司钦定栈
- 为什么:v4 §3.1 明确"公司钦定 Next.js 栈",覆盖早期 Go/Hono 备选(
deepresearch...v4.md:19)。官方支持纯 API 后端,route.ts嵌套实现/v1路径版本化。 - 落地证据:13 个
route.ts,全部为命名导出export async function GET/POST(...),统一export const dynamic = 'force-dynamic'(如src/app/api/v1/podcasts/shows/route.ts:11、src/app/api/health/route.ts:6)。 - runtime:全部跑默认 Node.js runtime(未声明
runtime='edge'),因为 postgres-js / pino 依赖 Node 核心模块——与 v4 §3.2 的运行时事实链一致(deepresearch...v4.md:109)。
Drizzle ORM + PostgreSQL 16
- 为什么 PG:v4 §2 论证"必须要数据库"——~2287 万剧集要按分类/语言/关键词做有索引筛选 + 分页,裸 JSONL 全表扫描不可行(
deepresearch...v4.md:83)。 - 为什么 Drizzle:官方有 PostgreSQL Full-Text Search 与 Cursor-based Pagination 指南。落地中全文检索用
sql\`片段表达websearch_to_tsquery/ts_rank(src/lib/services/catalog.service.ts:371、:377);keyset 用行值比较(col, id) < (v, id)(catalog.service.ts:398`)。 - schema 即 DDL 镜像:
src/lib/db/schema.ts注释明确"mirroring scripts/etl/finalize.sql",索引由 ETL 创建,API 从不在目录表 INSERT(schema.ts:1、schema.ts:13)。
postgres-js(取代 v4 的 node-postgres)
- 落地:
src/lib/db/client.ts:15用postgres(env.DATABASE_URL, { max: 10 })建常驻连接池,drizzle 绑定该 client(client.ts:24)。dev HMR 下复用全局单例避免连接堆积(client.ts:8)。 - 与 v4 差异:v4 文中以 node-postgres 为例论证"Workers 跨请求隔离不兼容",本项目在 Node 容器内不受此限,且换用更轻的 postgres-js。两者都是常驻连接池模型,结论一致(容器内直连、无需 Hyperdrive)。
Zod 4 / pino 10 / Vitest 4
- Zod:每端点入口校验,
searchParamsToObject(req.nextUrl)→schema.safeParse(...),失败映射业务码1000(src/app/api/v1/podcasts/shows/route.ts:25、src/lib/api/errors.ts:12)。 - pino:单例 logger,dev 用 pino-pretty、prod 输出 JSON 给采集层;注释强制"禁 console.*"(
src/lib/logger.ts:1、:12)。 - Vitest:
npm run test/test:coverage已配置(package.json:14)。
CEF(Chromium Embedded Framework)— 客户端壳
- 为什么 CEF 而非 Qt WebEngine:
desktop/README.md:6明确"音频播放发生在 Chromium 内——不使用 Qt WebEngine"。CEF 提供完整 Chromium 渲染 + 媒体解码,且发行版扁平布局与 Chrome/Electron 同款、公证兼容(DELIVERY.md:16)。 - 集成方式:Qt6 主窗口把 CEF 浏览器作为中央控件的子
NSView嵌入(SetAsChild),10msQTimer驱动CefDoMessageLoopWork();CEF 版本 144,5 个 Helper 变体(desktop/README.mdArchitecture 段)。
3.3 与 v4 "k8s 方案" 对照本项目"本机内网"落地的差异
| 维度 | v4 设计(为公网高并发) | 本项目落地(本机内网) | 取舍理由 |
|---|---|---|---|
| 部署 | k8s 容器 output: standalone,多副本 + HPA(deepresearch...v4.md:131) | 单机 colima VM 内 1 个 PG 容器 + 1 个 npm run start 进程(docs/SERVICE-OPS.md:9) | 内网同事级并发,单机够用 |
| 入口 | Ingress/Gateway TLS 终止 + 限流粗筛(deepresearch...v4.md:127) | 无网关,Next 直接监听 *:3000,HTTP 明文(docs/SERVICE-OPS.md:14) | 内网可信,省去 TLS/网关 |
| 缓存 | Redis 7 + ioredis 常驻单例(deepresearch...v4.md:101) | 进程内 Map + TTL(src/lib/api/memo.ts:9) | 单实例无需跨进程共享缓存 |
| 鉴权 | API Key + JWT(access 短期 + refresh 轮换)或复用 login token(deepresearch...v4.md:34) | 固定 Bearer token 白名单,userId=token(src/lib/api/auth.ts:48) | 内网固定 token,未做用户体系 |
| 搜索 | PG 全文检索起步,可升级 Meilisearch/Typesense(deepresearch...v4.md:271) | PG 全文检索(tsv + GIN),未引入独立搜索引擎 | 元数据级搜索,PG 够用 |
| DB 暴露 | 容器内直连,PG 可外部托管 | PG 容器只绑 127.0.0.1:5432,LAN 扫不到(DELIVERY.md:36) | 内网安全收敛 |
4. 部署拓扑
4.1 依赖链(启动顺序,来自 SERVICE-OPS)
外置盘挂载 (/Volumes/External) ← 一切的前提
└─ colima 虚拟机 (4C/6G, docker 运行时) ← VM 镜像在外置盘 .colima(经 ~/.colima 符号链接)
└─ podcast-pg 容器 (PostgreSQL 16) ← 只绑 127.0.0.1:5432,数据卷 podcast-pgdata
└─ Next.js 生产服务 (端口 3000) ← 监听全部网卡,LAN 可达
├─ REST API /api/v1/*
└─ Web UI /app/index.html ← 桌面端加载的页面
锚点:docs/SERVICE-OPS.md:9。为什么是这个顺序:DB 镜像在外置盘上的 colima VM 磁盘里,必须先有外置盘 → 再有 VM → 再有 DB 容器,最后 Next 才能连库(docs/SERVICE-OPS.md:18)。
4.2 拓扑要点表
| 组件 | 配置 | 锚点 |
|---|---|---|
| colima | Docker 运行时 VM(4C/6G),镜像 /Volumes/External/develop/.colima | docs/SERVICE-OPS.md:22 |
| podcast-pg | PostgreSQL 16,--restart unless-stopped(VM 在则自动拉起),只绑 127.0.0.1(已验证 LAN 扫不到 5432) | docs/SERVICE-OPS.md:23、DELIVERY.md:36 |
| Next.js | 生产构建 npm run start,工作目录 podcast-api/,日志 /tmp/podcast-prod.log | docs/SERVICE-OPS.md:24 |
| 监听 | API/UI 监听 *:3000,LAN 内 http://10.10.20.207:3000 可达(已从 LAN IP 验证 200) | DELIVERY.md:37 |
| 防火墙 | 开启 macOS 防火墙时需放行 node 的 3000 端口入站 | DELIVERY.md:39 |
4.3 一键启停
- 启动:
podcast-api/scripts/serve.sh(幂等)——colima → podcast-pg(等pg_isready)→ 无.next先npm run build→npm run start→ 回显本机与 LAN 健康检查(podcast-api/scripts/serve.sh:1,末行输出READY — clients connect to: http://10.10.20.207:3000/app/index.html)。 - 停止:
scripts/stop.sh(--vm连 VM 一起停,拔外置盘前必须)(docs/SERVICE-OPS.md:71)。
4.4 客户端服务地址解析优先级
桌面端按以下顺序解析服务端地址(DELIVERY.md:21):
| 序 | 来源 |
|---|---|
| 1 | --url=<完整地址> 命令行参数 |
| 2 | 环境变量 PODCAST_SERVER_URL |
| 3 | ~/Library/Application Support/Podcast Desktop/server_url.txt(用户机覆盖) |
| 4 | app 内置 Contents/Resources/server_url.txt(打包烧录,当前 = http://10.10.20.207:3000/app/index.html) |
| 5 | 兜底 localhost:3000 |
IP 变更只需重打包(
SERVER_URL=... ./package.sh)或让用户按 ③ 放一行文本文件,无需重装(DELIVERY.md:28)。
5. 端到端数据流
5.1 离线入库链(一次性,约 20 分钟)
dataset.7z
└─(解压)→ dataset/ JSONL
└─ scripts/etl/transform.mjs 逐行解析,脏行容错跳过并计数 → 三份 TSV
└─ COPY → *_staging 表
└─ scripts/etl/finalize.sql staging → 最终表(DISTINCT ON 去重、
│ 外键约束、孤儿行丢弃计数)→ 批量后建索引 → ANALYZE
└─ scripts/etl/perf-stats.sql 预聚合 language_stats / category_stats /
category_sub_stats + 3 个 keyset 索引
锚点:ETL 目录 podcast-api/scripts/etl/;入库步骤 finalize.sql:1("staging → final tables ... then indexes (after bulk load), then ANALYZE");预聚合表 perf-stats.sql:8;重建入口 reload.sh / run-etl.sh(docs/SERVICE-OPS.md:135)。
预聚合的工程意义:目录是只读快照,语言/分类计数在入库时算一次存进 *_stats 表,API 直接读,避免每请求 count(DISTINCT) 跨 join(src/lib/db/schema.ts:115、listLanguages 走 language_stats 见 catalog.service.ts:252)。
5.2 在线读取链(每请求)
桌面端 CEF / Web UI
└─ fetch(http://10.10.20.207:3000/api/v1/podcasts/shows?..., Authorization: Bearer <token>)
└─ route.ts: authenticate(req) 鉴权 → 失败 401 / code 1001
│ zod.safeParse(searchParams) 校验 → 失败 400 / code 1000
│ decodeCursor + 类型守卫 游标合法性
└─ catalog.service.listShows(...) Drizzle 构造 SQL(含 keyset / tsv)
└─ postgres-js 连接池 → PostgreSQL 16 走 GIN / keyset 索引
└─ envelope.ok(data, requestId) { code:0, message:"OK", data, request_id }
└─ Web UI 渲染列表/详情
└─ 播放:取 episode.audioUrl → Chromium 直连外部 CDN(不经服务端)
锚点:鉴权 src/lib/api/auth.ts:36;校验/游标 src/app/api/v1/podcasts/shows/route.ts:25;service catalog.service.ts:356;统一信封 src/lib/api/envelope.ts:11;音频直连 catalog.service.ts:769(D-5)。
5.3 响应信封与错误码(实际落地)
成功:{ code: 0, message: "OK", data, request_id }(envelope.ts:11)。错误码(src/lib/api/errors.ts:8):
| code | HTTP | 含义 |
|---|---|---|
| 0 | 200 | 成功 |
| 1000 | 400 | 参数校验失败(Zod 详情入 message) |
| 1001 | 401 | token 缺失或无效 |
| 1003 | 403 | 试用配额耗尽(终身 3 集) |
| 1004 | 403 | 当日下载配额超限(300 集/日) |
| 2001 | 404 | show/episode/task 未找到 |
| 2002 | 403 | 内容不可下载(无 audio_url) |
| 3001 | 500 | 服务端内部错误 |
5.4 API 路由全景(13 个,已核验)
| 路由 | 方法 | service | 锚点 |
|---|---|---|---|
/api/health | GET | health | src/app/api/health/route.ts |
/api/v1/podcasts/languages | GET | catalog | .../languages/route.ts |
/api/v1/podcasts/categories | GET | catalog | .../categories/route.ts |
/api/v1/podcasts/shows | GET | catalog(offset/keyset 混合) | .../shows/route.ts |
/api/v1/podcasts/shows/{showId} | GET | catalog | .../shows/[showId]/route.ts |
/api/v1/podcasts/shows/{showId}/episodes | GET | catalog(keyset) | .../shows/[showId]/episodes/route.ts |
/api/v1/podcasts/episodes/{episodeId} | GET | catalog | .../episodes/[episodeId]/route.ts |
/api/v1/podcasts/episodes/{episodeId}/stream | GET | catalog(D-5 直连 CDN) | .../episodes/[episodeId]/stream/route.ts |
/api/v1/podcasts/playback/progress | POST | playback(UPSERT) | .../playback/progress/route.ts |
/api/v1/podcasts/playback/progress/{episodeId} | GET | playback | .../playback/progress/[episodeId]/route.ts |
/api/v1/podcasts/downloads | POST | downloads(配额 + D-6 立即完成) | .../downloads/route.ts |
/api/v1/podcasts/downloads/{taskId} | GET | downloads | .../downloads/[taskId]/route.ts |
/api/v1/user/quota/podcasts | GET | downloads | .../user/quota/podcasts/route.ts |
注:API 基路径是
/api/v1/podcasts/...(业务前缀podcasts),与 v4 文中示例的/v1/...路径不同;以代码为准。
6. 关键架构决策与权衡表
| # | 决策点 | 选择 | 权衡 / 理由 | 锚点 |
|---|---|---|---|---|
| D1 | shows 列表分页 | 稳定排序用 keyset、q 相关性用 offset | 三种稳定排序(popular/name/recent)走复合索引行值比较,任意页恒定耗时;q 搜索按 ts_rank 排序无稳定 keyset,回退 offset | catalog.service.ts:33、:381、:398 |
| D2 | 剧集列表分页 | keyset on (publish_date, episode_uid) | 单档最高 1500 条且增量更新,keyset 翻页恒定、不错位;NULL 日期行用 null 标记排在末尾 | catalog.service.ts:583、:617 |
| D3 | 音频是否代理 | 不代理(D-5) | 直接返回外部 CDN audioUrl,stream_url 即原始 url;代理吃带宽增延迟,对"只展示"不值 | catalog.service.ts:769、deepresearch...v4.md:547 |
| D4 | 缓存层 | 进程内 TTL memo,非 Redis | 单实例部署,无需跨进程共享;注释明确"swap for Redis if scaled out" | src/lib/api/memo.ts:1 |
| D5 | 计数策略 | 首页才算 total + 预聚合 stats 表 | 仅无游标的首页返回 total;lang/category/sub 过滤命中预聚合表,避免深分页 count | catalog.service.ts:470、:482 |
| D6 | 下载任务 | 立即标记 completed(D-6) | 无服务端转存,客户端直连 CDN 下载,task 存为 completed + completed_at=now | src/lib/services/downloads.service.ts:117 |
| D7 | 部署形态 | 单机内网,非 k8s | 内网同事级并发,colima 单容器 + 单 Next 进程足够,省运维 | docs/SERVICE-OPS.md:9 |
| D8 | 鉴权 | 固定 token 白名单,userId=token | 内网可信,paid- 前缀标记付费用户;不适合公网暴露 | auth.ts:48、DELIVERY.md:63 |
7. 与设计文档 (v4) 的落地对照表
维度图例:✅ 已落地 / 🔸 简化落地 / ❌ 未做。
7.1 已落地(✅)
| v4 设计项 | 落地证据 |
|---|---|
| PostgreSQL 16 主存储 + tsvector/GIN 全文 + pg_trgm | scripts/etl/finalize.sql:27(生成列 tsv)、:116(GIN tsv)、:117(trgm) |
| 数据库三表 + 用户态表 | 8 张表全部存在:podcasts/episodes/podcast_categories + playback_progress/download_tasks + 3 张 stats(schema.ts) |
| 剧集 keyset 游标分页(by publishDate) | catalog.service.ts:583 |
| 目录 offset / 稳定排序 keyset | catalog.service.ts:381 |
REST + 路径版本化 /v1 | 实际为 /api/v1/podcasts/*(src/app/api/v1/...) |
| Zod 入口校验 / pino 结构化日志 / 禁 console.* | shows/route.ts:25、logger.ts:1 |
| Route Handlers 默认 Node runtime | 无 runtime='edge' 声明,全部 dynamic='force-dynamic' |
| 离线 ETL + 脏行容错 + 批量后建索引 | finalize.sql:1、transform.mjs、docs/SERVICE-OPS.md:135 |
| 音频不代理,返回外部 CDN url | catalog.service.ts:769(D-5) |
| 分页页大小上限 | 各 validations schema 限制 limit(src/lib/validations/podcasts.ts) |
7.2 简化落地(🔸)
| v4 设计项 | 落地差异 |
|---|---|
| Redis 7 + ioredis 热点缓存/限流 | 改为进程内 Map + TTL memo;未做限流(memo.ts:9) |
| 双层鉴权(API Key + JWT 轮换) | 改为固定 Bearer token 白名单,userId=token,无 JWT/refresh(auth.ts:36、env.ts:10) |
| k8s 多副本 + HPA + Ingress/TLS | 改为单机 colima 容器 + 单 Next 进程,HTTP 明文,无网关(docs/SERVICE-OPS.md:9) |
| node-postgres 连接池 | 改用 postgres-js(client.ts:15),同为常驻连接池 |
| 版本(next 16.2.6 / drizzle 0.38 / zod 3.24 / pino 9) | 实际 next 16.2.7 / drizzle 0.45 / zod 4 / pino 10(package.json) |
7.3 未做(❌)
| v4 设计项 | 状态 |
|---|---|
| 复用 Qt login token(SSO / JWKS / introspection,方案甲) | 未做。内网用固定 token;公网访问需走 v4 §7 JWT 方案(DELIVERY.md:63) |
| 浏览器 SPA + CORS 白名单 + httpOnly refresh cookie + CSRF | 未做。客户端为 CEF 桌面端,未上浏览器 SPA |
限流(按 Key + user_id,RateLimit-* 响应头) | 未做。代码中无限流中间件 |
| 自注册 / login / refresh / logout 鉴权端点 | 未做。无 /v1/auth/* 路由 |
| 收藏 / 订阅(favorites / subscriptions 表与接口) | 未做。schema 仅有 playback_progress / download_tasks;用户态聚焦"播放进度 + 下载配额" |
| Hyperdrive / 外部托管 PG / 快照原子切换 | 未做。单机自托管;重建走 reload.sh(约 20 分钟全量),非在线原子切换 |
| 独立搜索引擎(Meilisearch/Typesense) | 未做。停在 PG 全文检索(v4 推荐的起步阶段) |
8. 已知边界与待确认
| 项 | 说明 | 锚点 |
|---|---|---|
| 鉴权仅适合内网 | UI 内置 dev-token,不适合公网暴露 | DELIVERY.md:63 |
| 服务器睡眠会断服 | 需关闭"自动进入睡眠"或 caffeinate -s 配合 serve.sh | DELIVERY.md:64 |
| 音频/封面依赖公网 | 来自外部 CDN,客户端需可出公网(或后续加代理端点) | DELIVERY.md:65 |
| 公证状态 | DMG 已 Developer ID 签名,公证已配置(NOTARY_PROFILE=goland-notary);交付时标注"待公证" | DELIVERY.md:10、:43 |
| 全量语言分布 | v4 标注"未核验、不臆断纯英文";本项目 language_stats 已按真实数据预聚合,可查 | deepresearch...v4.md:76、catalog.service.ts:252 |
9. 相关分册与文件索引
| 资源 | 路径 | 用途 |
|---|---|---|
| 架构决策真源 | deepresearch_podcast-catalog-api-architecture_20260604_v4.md | 框架无关契约 + k8s 设计推演 |
| 交付说明 | DELIVERY.md | 客户端分发、服务地址解析、公证、已知边界 |
| 运维手册 | docs/SERVICE-OPS.md | 启动/停止/排障、依赖链 |
| API 设计 | podcast-api/docs/API-DESIGN.md | 端点契约 / 信封 / 错误码细节 |
| DB Schema | podcast-api/src/lib/db/schema.ts | 8 张表 Drizzle 定义 |
| ETL | podcast-api/scripts/etl/ | transform.mjs / finalize.sql / perf-stats.sql / reload.sh / run-etl.sh |
| 服务配置 | podcast-api/.env.local | DATABASE_URL、token 白名单 |
| 桌面端 | desktop/(README.md / CMakeLists.txt / src/) | Qt6 + CEF 客户端 |
| Web UI | podcast-api/public/app/(index.html / app.js / styles.css) | 桌面端加载的静态页面 |