04 模块代码设计
本册面向工程师与 AI Agent,描述 Podcast 项目三端(服务端 / Web UI / 桌面端)的模块划分、分层职责、关键函数与架构纪律。所有论断均给出
file_path:line锚点佐证;读不到或代码中不存在的内容标注「未实现 / 待确认」。关联分册:API 契约详见
docs/API-DESIGN.md(被代码注释反复引用);数据库表与索引详见数据库分册;运维详见docs/SERVICE-OPS.md。
0. 总览
| 端 | 根目录 | 技术栈 | 模块入口 |
|---|---|---|---|
| 服务端 API | podcast-api/src | next@16.2.7 / react@19.2.4 / drizzle-orm@0.45 / postgres@3.4 / zod@4 / pino@10 / typescript@5(vitest@4 测试) | src/app/api/**/route.ts |
| Web UI | podcast-api/public/app | vanilla ES2022 SPA,无构建步骤 | public/app/index.html + public/app/app.js |
| 桌面端 | desktop/src | Qt6 + CEF(cefsimple 风格)+ CMake,仅 macOS | desktop/src/main.cpp |
依赖版本一手核验: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 / typescript ^5 / vitest ^4.1.8)。
1. 服务端分层架构
服务端是一个 Next.js App Router 项目,采用「route handler → service → db」三层 + 横切工具层的结构。数据流单向:HTTP 请求进入 route handler,经鉴权与 Zod 校验后调用 service,service 是唯一访问 db 的层,结果统一封装为 envelope 返回。
1.1 分层职责一览
| 层 | 目录 | 职责 | 关键文件 |
|---|---|---|---|
| Route handler | src/app/api/**/route.ts | HTTP 编排:鉴权 → Zod 校验 → 调 service → envelope(不含业务逻辑) | 13 个 route.ts |
| Service | src/lib/services/*.service.ts | 业务逻辑唯一住所,访问 db、计算派生字段、组装 DTO | 4 个 service |
| DB | src/lib/db/ | Drizzle schema + postgres-js 单例 | schema.ts / client.ts |
| API 工具 | src/lib/api/ | envelope / errors / auth / cursor / memo 横切工具 | 5 个文件 |
| Validations | src/lib/validations/ | Zod 请求 schema + 错误格式化 | podcasts.ts |
| Config | src/config/ | 环境变量 Zod 校验 | env.ts |
| Logger | src/lib/ | pino 单例 | logger.ts |
1.2 Route handler(鉴权 → Zod 校验 → 调 service → envelope 三段式)
所有 13 个 route handler 遵循同一骨架。以 GET /api/v1/podcasts/shows 为例(src/app/api/v1/podcasts/shows/route.ts):
| 步骤 | 代码锚点 | 说明 |
|---|---|---|
| 取 request id | shows/route.ts:19 | getRequestId(req):回显 X-Request-ID 或生成 UUID |
| 鉴权 | shows/route.ts:22-23 | authenticate(req),失败返回 fail(UNAUTHORIZED, 401)(code 1001) |
| Zod 校验 | shows/route.ts:25-27 | showsQuerySchema.safeParse(...),失败返回 fail(INVALID_PARAMS, 400)(code 1000) |
| cursor 解码/守卫 | shows/route.ts:29-41 | decodeCursor + isShowsCursor,并校验 cursor 与 sort 一致 |
| 调 service | shows/route.ts:43-53 | listShows({...}) |
| envelope + 日志 | shows/route.ts:54-55 | logger.debug(... durationMs ...) + ok(data, requestId) |
| 兜底 catch | shows/route.ts:56-59 | logger.error({err}) + fail(INTERNAL_ERROR, 500)(code 3001) |
所有 v1 route 均声明 export const dynamic = 'force-dynamic'(如 shows/route.ts:11),关闭静态化以保证每次请求都走鉴权与实时查询。
三段式 try/catch 模板(每个 handler 一致):先 getRequestId + startedAt = Date.now(),进入 try,依次鉴权/校验/调 service/返回 ok,catch 中 logger.error 后返回 fail(INTERNAL_ERROR, 'Internal server error', 500, requestId)。
13 个路由清单(src/app/api/):
| # | 方法 | 路径 | service 调用 | 锚点 |
|---|---|---|---|---|
| 1 | GET | /api/health | getHealth(now, startedAt) | health/route.ts:15-18 |
| 2 | GET | /api/v1/podcasts/languages | listLanguages() | podcasts/languages/route.ts |
| 3 | GET | /api/v1/podcasts/categories | listCategories(lang)(经 memo) | podcasts/categories/route.ts:29 |
| 4 | GET | /api/v1/podcasts/shows | listShows(params) | podcasts/shows/route.ts:43 |
| 5 | GET | /api/v1/podcasts/shows/{showId} | getShow(showId) | podcasts/shows/[showId]/route.ts |
| 6 | GET | /api/v1/podcasts/shows/{showId}/episodes | listEpisodes(showId, params) | podcasts/shows/[showId]/episodes/route.ts |
| 7 | GET | /api/v1/podcasts/episodes/{episodeId} | getEpisode(episodeId) | podcasts/episodes/[episodeId]/route.ts |
| 8 | GET | /api/v1/podcasts/episodes/{episodeId}/stream | getStream(episodeId) | podcasts/episodes/[episodeId]/stream/route.ts:24 |
| 9 | POST | /api/v1/podcasts/playback/progress | saveProgress(userId, input) | podcasts/playback/progress/route.ts:33 |
| 10 | GET | /api/v1/podcasts/playback/progress/{episodeId} | getProgress(userId, episodeId) | podcasts/playback/progress/[episodeId]/route.ts:24 |
| 11 | POST | /api/v1/podcasts/downloads | createTask(auth, input) | podcasts/downloads/route.ts:33 |
| 12 | GET | /api/v1/podcasts/downloads/{taskId} | getTask(userId, taskId) | podcasts/downloads/[taskId]/route.ts |
| 13 | GET | /api/v1/user/quota/podcasts | getQuota(auth) | user/quota/podcasts/route.ts:23 |
注:
/api/health是唯一不走 envelope/鉴权的 route,直接返回NextResponse.json(health)(health/route.ts:18),用作 liveness 探针。
service 结果 → HTTP 状态的映射(route 负责翻译 service 返回的 { ok:false, reason })。以 downloads 创建为例(downloads/route.ts:38-52):
| service reason | 业务 code 常量 | HTTP | 附带 data |
|---|---|---|---|
trial_quota | TRIAL_QUOTA_EXCEEDED (1003) | 403 | { trial_quota } |
daily_quota | DAILY_QUOTA_EXCEEDED (1004) | 403 | { daily_quota } |
not_found | NOT_FOUND (2001) | 404 | — |
not_downloadable | NOT_DOWNLOADABLE (2002) | 403 | — |
1.3 Service 层(业务逻辑唯一住所,4 个 service 职责)
业务逻辑只允许写在 service 层(health.service.ts:1 注释明确:"business logic lives only in services (architecture discipline §6.3)")。
| Service | 文件 | 职责 | 导出函数 |
|---|---|---|---|
| catalog | catalog.service.ts | 目录只读:语言/分类字典、shows 列表与详情、episodes 列表与详情、stream URL | listLanguages listCategories listShows getShow listEpisodes getEpisode getStream |
| downloads | downloads.service.ts | 下载任务 + 配额会计(试用 3 次终身、每日 300、服务器本地午夜边界) | createTask getTask getQuota |
| playback | playback.service.ts | 播放进度 UPSERT 保存 + 读取(含百分比计算) | saveProgress getProgress |
| health | health.service.ts | 健康快照(uptime / timestamp,依赖注入 now/startedAt) | getHealth |
catalog.service 关键设计点:
- 全文检索:
listShows在q存在时用websearch_to_tsquery('simple', q)对podcasts.tsv(数据库侧 GENERATED 列)做匹配,并强制按ts_rank相关性排序、为 item 附score(catalog.service.ts:371-388、448)。 - 游标分页:三种稳定排序(popular/name/recent)用 keyset 行值比较(任意页都是索引区间扫描,与深度无关,
catalog.service.ts:393-411);q相关性排序无稳定 keyset,退化为 offset(catalog.service.ts:455)。 - first-page total:仅首页(无 cursor)计算
total,且优先用预聚合 stats 表覆盖 lang/category/sub 过滤,落不到时才count(*)(catalog.service.ts:470-515)。 - episodes keyset NULLS LAST:
(publish_date, episode_uid)keyset,cursor 携带 null 标记使无日期行排在有日期行之后(catalog.service.ts:607-628)。 - 派生字段:
languageName/prettyCategoryName/formatDuration/deriveAudioFormat等纯函数(catalog.service.ts:192-230、748-760);getStream直接返回 CDN URL 不签名,expires_at = now + 7200s(D-5,catalog.service.ts:769-786)。
downloads.service 关键设计点:
- 配额常量
TRIAL_TOTAL = 3/DAILY_TOTAL = 300(downloads.service.ts:8-9);付费用户(isPaid)跳过试用限制。 - 检查顺序固定:试用配额 → 每日配额 → episode 存在 → audio_url 存在(
downloads.service.ts:95-108)。 - D-6:无服务端实际传输,任务直接以
status:'completed'+completed_at = now落库(downloads.service.ts:117-120);每日窗口以服务器本地午夜为界(localDayStart/nextLocalDayStart,downloads.service.ts:55-66)。
playback.service 关键设计点:saveProgress 用 onConflictDoUpdate 做 (user_id, episode_uid) UPSERT,且可选字段仅在提供时才覆盖(部分更新不抹除已存值,playback.service.ts:42-62);getProgress 计算 progress_percent 并钳制到 [0,100]、保留一位小数(playback.service.ts:83-85)。
1.4 DB 层(Drizzle schema + postgres-js 单例)
| 文件 | 职责 | 锚点 |
|---|---|---|
db/schema.ts | 8 张表的 Drizzle 定义(仅表,索引由 ETL 创建) | schema.ts:1 |
db/client.ts | postgres-js 客户端 + drizzle 实例单例 | client.ts:15-24 |
8 张表(与 scripts/etl/finalize.sql 镜像,schema.ts:1):
| 表 | drizzle 导出 | 主键 | 备注 |
|---|---|---|---|
| podcasts | podcasts | podcast_uid | 含 tsv GENERATED tsvector 列(name A>author B>description C,schema.ts:34-36)+ 反规范化 latest_episode_title / latest_publish_date |
| episodes | episodes | episode_uid | 外键 podcast_uid → podcasts |
| podcast_categories | podcastCategories | (podcast_uid, cat, sub) | sub '' 表示仅主分类 |
| playback_progress | playbackProgress | (user_id, episode_uid) | 由 playback.service UPSERT |
| download_tasks | downloadTasks | task_id | 配额会计 + D-6 立即完成 |
| language_stats | languageStats | lang | ETL 预聚合,'' 表示 lang NULL |
| category_stats | categoryStats | (lang, cat) | ETL 预聚合 |
| category_sub_stats | categorySubStats | (lang, cat, sub) | ETL 预聚合 |
预聚合 stats 表的意义:目录是只读快照,语言/分类计数在加载期算一次,API 读 stats 表代替每请求 count(DISTINCT) join(schema.ts:115-122)。
单例(client.ts):postgres(env.DATABASE_URL, { max: 10 }) 连接懒建(首查询时打开);通过挂到 globalThis.__podcastPgClient 复用,避免 Next.js dev HMR 重载时连接堆积(client.ts:8-17);db = drizzle(pg, { schema }) 是 ORM 查询唯一入口(client.ts:24)。pg(裸客户端)亦导出,供 ORM 之外的 raw SQL 需求。
关于
tsv列纪律:它是数据库侧GENERATED ALWAYS ... STORED,代码绝不写入,只通过 rawsql\`片段引用(schema.ts:33-36`)。
1.5 API 工具层(envelope / errors / auth / cursor / memo)
| 文件 | 导出 | 职责 | 锚点 |
|---|---|---|---|
api/envelope.ts | ok / fail | 统一响应封套 { code, message, data, request_id } | envelope.ts:11、envelope.ts:25 |
api/errors.ts | ERROR_CODES | 稳定业务错误码常量 | errors.ts:8-25 |
api/auth.ts | authenticate / getRequestId / AuthContext | Bearer 鉴权、request id 解析 | auth.ts:25、auth.ts:36 |
api/cursor.ts | encodeCursor / decodeCursor | base64url(JSON) 游标编解码(不签名) | cursor.ts:10、cursor.ts:20 |
api/memo.ts | memo / memoClear | 进程内 TTL 记忆化(热只读端点) | memo.ts:20、memo.ts:30 |
envelope:成功 ok(data, requestId, headers?) → { code:0, message:'OK', data, request_id },可附额外响应头(如快照读的 Cache-Control,envelope.ts:11-13);失败 fail(code, message, status, requestId, extraData?) → data 默认 null(envelope.ts:25-27)。
errors(业务码):
| 常量 | 值 | 语义 |
|---|---|---|
OK | 0 | 成功 |
INVALID_PARAMS | 1000 | 参数校验失败(扩展码,Zod 详情进 message) |
UNAUTHORIZED | 1001 | token 缺失或无效 |
TRIAL_QUOTA_EXCEEDED | 1003 | 试用配额耗尽(终身 3 集) |
DAILY_QUOTA_EXCEEDED | 1004 | 每日下载配额超限(300 集/天) |
NOT_FOUND | 2001 | show/episode/task 不存在 |
NOT_DOWNLOADABLE | 2002 | 无 audio_url,不可下载 |
INTERNAL_ERROR | 3001 | 内部错误 |
auth:token 来自 env.API_BEARER_TOKENS(逗号分隔白名单,auth.ts:11-17);user_id = token(确定性本地映射),paid- 前缀标记付费用户(auth.ts:48)。客户端 metadata 头 x-client-version / x-platform 仅记录日志,不做强制(D-1,auth.ts:37-40)。
cursor:base64url(JSON) keyset,不签名——只读目录上伪造游标至多返回空页(cursor.ts:1-2);解码失败返回 null(route 映射为 code 1000,cursor.ts:20-26)。
memo:单实例部署下的进程内 TTL 缓存,Map 存储(memo.ts:9);目前仅 categories route 使用(key=categories:<lang>,TTL 600000ms,categories/route.ts:29)并叠加 HTTP Cache-Control: private, max-age=300(categories/route.ts:31)。注释标注多实例时应换 Redis(D-7,memo.ts:2)。
1.6 Validations(Zod)
validations/podcasts.ts 集中定义请求 schema 与错误渲染:
| 导出 | 用途 | 锚点 |
|---|---|---|
categoriesQuerySchema | categories 查询(lang?) | podcasts.ts:22-24 |
showsQuerySchema | shows 查询(lang/category/sub/q/sort/时间窗/cursor/limit,含 sub requires category refine) | podcasts.ts:27-39 |
episodesQuerySchema | episodes 查询(sort/时间窗/cursor/limit) | podcasts.ts:42-48 |
progressBodySchema | 进度保存体(position/duration 受 INT4_MAX 约束) | podcasts.ts:54-59 |
downloadBodySchema | 下载体(format ∈ mp3/m4a/flac/wav;quality ∈ standard/high/lossless) | podcasts.ts:62-66 |
searchParamsToObject | URLSearchParams → 对象,丢弃空值(?cursor= 等同首页) | podcasts.ts:81-87 |
formatZodError | Zod 错误渲染为单行 message | podcasts.ts:95-99 |
约束细节:limit 默认 20、< 1 拒绝(code 1000)、> 50 钳到 50(podcasts.ts:11-16);日期参数为 YYYY-MM-DD 且经 Date.parse 校验(podcasts.ts:5-8)。
1.7 Config(env)与 Logger(pino)
config/env.ts:服务端环境变量的唯一类型化入口,启动即 envSchema.parse(process.env)(无效配置快速失败,env.ts:18)。
| 变量 | 默认 | 说明 |
|---|---|---|
NODE_ENV | development | development/test/production |
LOG_LEVEL | 可选 | fatal…trace |
DATABASE_URL | postgres://podcast:podcast@localhost:5432/podcast | PG 连接串 |
API_BEARER_TOKENS | dev-token | 逗号分隔 Bearer 白名单 |
logger.ts:pino 单例,dev 用 pino-pretty 彩色输出、prod 输出 JSON 供采集(logger.ts:12-24);base: undefined 丢弃 pid/hostname 噪声;时间戳 ISO。注释明确「业务代码禁止使用 console.*」(logger.ts:1)。
1.8 服务端架构纪律(强制约束)
| 纪律 | 出处 | 含义 |
|---|---|---|
| 组件/route 禁直连 db | health.service.ts:1 注释 | 业务逻辑只在 service;route 仅编排 |
| no-console(业务代码) | logger.ts:1 注释 | 一律用 logger;pino 是唯一日志入口 |
| service 是 db 唯一访问者 | 4 个 service 均 import { db },route 无 db import | 数据访问单向收敛 |
| env 单一来源 | env.ts | 不直接读 process.env(logger 除外,读 NODE_ENV/LOG_LEVEL 用于 transport 选择) |
| tsv 列只读 | schema.ts:33-36 | GENERATED 列禁写,只 raw sql 引用 |
| 错误码稳定 | errors.ts | code 常量集中,禁散落字面量 |
| 首页才算 total | catalog.service.ts:470、597 | 翻页不重复 count |
关于 Next.js 版本:
podcast-api/AGENTS.md警示「这不是你熟悉的 Next.js」——16.2.7 的 API/约定可能与训练数据不同,写代码前应读node_modules/next/dist/docs/。本项目 route handler 已体现两处新约定:ctx.params为 Promise(stream/route.ts:16await ctx.params)、export const dynamic = 'force-dynamic'。
2. Web UI 模块(vanilla ES2022 SPA,无构建)
Web UI 是同源服务于 /app/ 的零构建单页应用,与 /api/v1/* 并列(app.js:1-4)。仅两个文件:index.html(121 行,结构 + 控件)与 app.js(936 行,全部逻辑)。
2.1 配置常量
| 常量 | 值 | 用途 | 锚点 |
|---|---|---|---|
API_BASE | /api/v1 | API 前缀 | app.js:8 |
AUTH_TOKEN | dev-token | 固定 Bearer(与服务端默认白名单一致) | app.js:9 |
PAGE_SIZE | 20 | 列表分页大小 | app.js:10 |
SEARCH_DEBOUNCE_MS | 300 | 搜索去抖 | app.js:11 |
PROGRESS_INTERVAL_MS | 30000 | 播放中每 30s 保存进度 | app.js:12 |
RESUME_MIN_SECONDS / RESUME_MAX_PERCENT | 10 / 95 | 断点续播窗口(>10s 且 <95%,PRD AC03) | app.js:13-14 |
SPINNER_DELAY_MS | 150 | 延迟显示 spinner(快加载零闪烁) | app.js:16 |
PAGE_CACHE_TTL_MS / PAGE_CACHE_MAX | 300000 / 100 | 页缓存 TTL 与容量上限 | app.js:17-18 |
2.2 模块功能与关键函数清单
| 模块 | 关键函数 | 职责 | 锚点 |
|---|---|---|---|
| DOM 工具 | $ el debounce formatTime prettySlug coverImg | querySelector 简写、声明式建元素、去抖、时长格式化、slug 美化、封面 img 一次性回退占位图 | app.js:21-82 |
| API 客户端 | api(path, opts) / ApiError | fetch → 解封套 → 失败抛 ApiError;区分网络错误(-1)/异常响应(-2)/业务非 0 码 | app.js:85-122 |
| 页缓存 | cacheGet cachePut | 按完整 path(含 query/cursor/过滤器)缓存分页响应;Map 顺序近似 LRU,超 PAGE_CACHE_MAX 淘汰最旧 | app.js:128-140 |
| 延迟 spinner | deferredSpinner(selector) | 慢于 SPINNER_DELAY_MS 才显示 spinner,返回 hide/cancel | app.js:143-151 |
| 错误横幅 | showError(msg, retryFn) hideError | 顶部 #error-banner 显示消息 + 可选重试按钮(PRD 4.2.1) | app.js:154-166 |
| 状态 | homeState detailState player | 首页/详情/播放器三组集中状态 | app.js:169-200 |
| 首页数据 | buildShowsQuery resetShowsPagination applyShowsPage prefetchNextShows abortShowsPrefetch loadShows updateShowsPager | 构造 shows 查询、重置分页、应用页、预取下一页、加载(缓存优先 + 后台重验证 + 序号防陈旧) | app.js:205-301 |
| 首页渲染 | renderShows | 渲染卡片网格 + 空态 + total | app.js:304-329 |
| 首页过滤器 | loadLanguages loadCategories markActiveCategory initFiltersMeta initHomeEvents | 语言下拉/分类 chips(按会话缓存一次)、搜索/语言/排序/日期/翻页事件 | app.js:333-446 |
| 详情数据 | resetDetailState loadShowDetail buildEpisodesQuery applyEpisodesPage loadEpisodes updateEpisodesPager | show 详情 + episodes 列表(同样缓存优先 + 序号防陈旧 + showId 校验) | app.js:452-546 |
| 详情渲染 | renderShowHeaderSkeleton renderShowHeader renderEpisodes audioLinkAnchor | 骨架屏、show 头、episode 行、"Audio link"(首点击解析 stream_url) | app.js:549-674 |
| 播放器 | startEpisode playQueueIndex maybeOfferResume openPlayerBar closePlayer updatePlayerNav updatePlayingHighlight | 迷你播放器:队列快照、切轨、断点续播提示、底栏开合、上下曲、正在播放高亮 | app.js:680-788 |
| 进度保存 | postProgressFor startProgressTimer stopProgressTimer | 30s 定时 + 暂停/结束/切轨时 POST 进度 | app.js:791-818 |
| 播放器事件 | initPlayerEvents | 播放/暂停/seek/loadedmetadata/timeupdate/ended/error 绑定 | app.js:821-893 |
| 路由 | handleRoute | hash 路由:#/show/<id> ↔ 首页两视图切换 | app.js:896-916 |
| 初始化 | init | 绑定全局事件 + hashchange 监听 + 首次 handleRoute(DOMContentLoaded 触发) | app.js:919-936 |
2.3 关键机制说明
- 视图路由(hash):
handleRoute用正则/^#\/show\/([^/]+)$/匹配(app.js:897);命中显示详情视图、仅在showId变化时重载(app.js:902-906);否则回首页,首页数据懒加载一次(homeState.loaded,app.js:911-914)。 - 页缓存 + 下一页预取:
loadShows命中缓存即「瞬时翻页、无 spinner、pager 保持可用」(app.js:269-275);陈旧(>5min)则后台重验证,失败保留旧页(app.js:289-291);prefetchNextShows用单个AbortController(showsPrefetch)预取下一页写入缓存,使 Next 秒翻(app.js:235-262)。 - cursor 栈:
homeState.cursorStack/detailState.cursorStack保存历史游标使「上一页」可用;Next 时push(cursor)、Prev 时pop()(app.js:434-445、663-673)。pager 启用态由updateShowsPager/updateEpisodesPager根据栈长度与nextCursor控制(app.js:298-301、510-513)。 - 序号防陈旧:
showsSeq/episodesSeq/showSeq自增,去抖搜索可能重叠时丢弃过期响应(app.js:203、286、449-450、537)。 - 迷你播放器 + 断点续播:
startEpisode把当前 episodes 列表快照为播放队列(app.js:680-682);playQueueIndex切轨前先保存上一集进度(app.js:688-690),解析 stream_url 后查进度(查进度失败不阻塞播放,app.js:706-710),ended自动续播或提示队列结束(PRD AC10,app.js:879-888);maybeOfferResume按 >10s 且 <95% 提示续播(PRD AC03,app.js:728-741)。 - 错误横幅 + 重试:
api抛ApiError(含 code),各加载函数catch后showError(err.message, retryFn),#error-retry点击执行errorRetryFn(app.js:920-924)。 - 延迟 spinner:缓存/快加载(<150ms)零闪烁,仅慢请求才显示(
app.js:143-151、282、533)。
鉴权说明:Web UI 用硬编码
AUTH_TOKEN = 'dev-token'(app.js:9),与服务端API_BEARER_TOKENS默认值一致,因此本地直接可用;该 token 无paid-前缀,故被服务端视为试用用户(受 3 次终身下载配额限制)。
3. 桌面端模块(Qt6 + CEF + CMake)
桌面端是 macOS-only 的原生壳:Qt6 QMainWindow 内嵌一个 windowed CEF 浏览器,加载 Web UI(同一套 /app/ 页面)。源码 11 个文件,构建产物为 cefsimple 风格的 app bundle。
3.1 文件与职责
| 文件 | 角色 | 设计模式/要点 |
|---|---|---|
main.cpp | 浏览器进程入口 | 启动编排:框架加载 → Qt 先显窗 → CefInitialize → 建浏览器 → 退出握手 |
cef_app.h/.cc | PodcastCefApp(CefApp + CefBrowserProcessHandler) | 命令行开关、外部消息泵桥接、relaunch 聚焦 |
cef_client.h/.cc | PodcastCefClient(CefClient + LifeSpan/Load/Display) | 浏览器生命周期、加载错误回退页、标题回调、关闭握手 |
qt_window.h/.cpp | QtMainWindow(QMainWindow) | 内嵌浏览器、菜单(Reload/Quit/DevTools)、closeEvent 握手 |
browser_embed.h/.mm | Objective-C++ glue | NSView frame 调整 + isHandlingSendEvent 协议补丁 |
process_helper_mac.cc | Helper 进程入口 | CefExecuteProcess(每个 sub-process 类型一份 bundle) |
perf_trace.h | 性能打点 | [perf] <name> +<ms> 到 stderr,供测试消费 |
CMakeLists.txt | 构建 | CEF + Qt6 + 5 个 Helper bundle + 代码签名 |
3.2 进程模型(主进程 + 5 Helper bundle)
macOS 上 CEF 要求每种 sub-process 类型有独立的 Helper .app bundle。本项目主 app 内嵌 5 个 Helper(CMakeLists.txt:9-16 注释枚举):
PodcastDesktop.app/Contents
├── MacOS/PodcastDesktop (Qt + CEF 浏览器进程)
└── Frameworks
├── Chromium Embedded Framework.framework
├── PodcastDesktop Helper.app (默认/无后缀)
├── PodcastDesktop Helper (Alerts).app
├── PodcastDesktop Helper (GPU).app
├── PodcastDesktop Helper (Plugin).app
└── PodcastDesktop Helper (Renderer).app
- 主进程不调用
CefExecuteProcess()——所有 sub-process 由 Helper bundle 运行(main.cpp:156-157)。 - 所有 Helper 共享同一份
process_helper_mac.cc:LoadInHelper()+CefExecuteProcess(main_args, nullptr, nullptr),无需 CefApp 实现(process_helper_mac.cc:15-23)。 - 5 个 bundle 由 CMake 在
CEF_HELPER_APP_SUFFIXES上循环生成,仅 bundle/可执行名与CFBundleIdentifier后缀不同(CMakeLists.txt:144-192)。
3.3 main 启动流程(框架加载 → Qt 先显窗 → CefInitialize → 建浏览器)
main()(main.cpp:141-272)的时序,以 perf_trace::Mark 标记每个里程碑:
| 阶段 | 锚点 | 动作 | perf mark |
|---|---|---|---|
| 1. 框架加载 | main.cpp:147-154 | CefScopedLibraryLoader::LoadInMain(),失败提示需从 bundle 内启动 | framework_loaded |
| 2. Qt 就绪 | main.cpp:161-174 | 建 QApplication(NSApplication 须先于 CefInitialize)、设运行时 Dock 图标 | qt_ready |
| 3. 先显窗 | main.cpp:182-194 | 在慢 CefInitialize 之前 window.show() + processEvents 让其先绘制一帧(暗背景防白闪) | window_shown |
| 4. 泵接线 | main.cpp:197-205 | SetSchedulePumpCallback / SetRelaunchCallback(须先于 CefInitialize) | — |
| 5. CEF 初始化 | main.cpp:207-228 | CefSettings(no_sandbox / external_message_pump / windowed)+ cache_path + CefInitialize | cef_initialized |
| 6. 建浏览器 | main.cpp:232 | window.createBrowserIfNeeded()(CEF 就绪后才建,不再挂在 showEvent) | browser_created |
| 7. 事件循环 | main.cpp:253 | qt_app.exec() | event_loop_exited |
| 8. 退出泵兜底 | main.cpp:260-266 | 若循环先于 OnBeforeClose 退出,以 1ms 步继续泵(最多 3s)直到 browser 关闭 | — |
| 9. 关闭 | main.cpp:269-270 | window/client 析构后 CefShutdown() | shutdown_complete |
先显窗是核心感知优化:CefInitialize 阻塞主线程,故先把窗口画出来再初始化 CEF(main.cpp:180-194)。
CefSettings 关键项(main.cpp:208-221):no_sandbox=1、multi_threaded_message_loop=0(macOS 要求 CEF UI 线程 = 主应用线程)、external_message_pump=1、windowless_rendering_enabled=0(仅 windowed)、cache_path = ~/Library/Caches/PodcastDesktop。
3.4 CEF 外部消息泵集成(OnScheduleMessagePumpWork → Qt 精确定时器)
external message pump 模式:CEF 通过 OnScheduleMessagePumpWork(delay_ms) 告知下一次 CefDoMessageLoopWork() 的期望延迟(可能来自任意线程),由 Qt 侧 CefPumpScheduler 用单次精确定时器执行。
| 环节 | 锚点 | 说明 |
|---|---|---|
| CEF 回调 | cef_app.cc:38-44 | OnScheduleMessagePumpWork 转发到 schedule_pump_callback_ |
| 桥接回调 | main.cpp:198-199 | SetSchedulePumpCallback 把请求转给 CefPumpScheduler::Schedule |
| 跨线程编排 | main.cpp:122-133 | Schedule 用 QMetaObject::invokeMethod(..., Qt::QueuedConnection) 编排到 Qt 主线程,延迟 clamp(0,33)(~30Hz 上限) |
| 执行 + 自重排 | main.cpp:106-119 | 单次 Qt::PreciseTimer:timeout 调 CefDoMessageLoopWork(),若无新调度则以 33ms 自重排为 ~30Hz 下限 |
设计动因:取代旧的固定 10ms 轮询定时器——旧方案把每个多步 CEF 操作(启动、导航、关闭握手)节流到 10ms/步(
main.cpp:98-103)。自重排是为防 CEF 不在每次 DoWork 后重排时泵停滞导致导航/IPC 冻结(main.cpp:111-118)。
3.5 窗口/浏览器关闭握手(closeEvent → CloseBrowser → DoClose → OnBeforeClose)
这是 cefclient 经典握手,避免「关闭按钮假死」:
| 步 | 锚点 | 动作 |
|---|---|---|
| 1 | qt_window.cpp:88-108 closeEvent | 首次关闭:close_pending_=true、client_->CloseBrowser(false)、event->ignore()(mark close_requested) |
| 2 | cef_client.cc:118-122 CloseBrowser | browser_->GetHost()->CloseBrowser(force_close) |
| 3 | cef_client.cc:53-66 DoClose | unload handler 跑完后置 closing_=true、返回 false 让 CEF 把 OS 关闭请求转回顶层窗口(mark do_close) |
| 4 | qt_window.cpp:94-98 closeEvent(二次) | 见 IsClosing() 为真 → event->accept() 放行第二次关闭,原生视图拆除 |
| 5 | cef_client.cc:68-77 OnBeforeClose | browser_=nullptr、触发 browser_closed_callback_(mark browser_destroyed) |
| 6 | qt_window.cpp:68-71 回调 | browser_closed_=true + 零延迟定时器再 close() 完成窗口关闭 |
要点:DoClose 返回 false 是必须的——否则默认关闭请求会循环回一个持续 ignore 的 closeEvent,窗口永远关不掉(cef_client.cc:56-60)。--auto-close-ms=N 测试钩子走完全相同的 QWidget::close → closeEvent → 握手 路径(main.cpp:234-246)。
3.6 isHandlingSendEvent 协议补丁
CEF 期望 NSApp 遵守 CefAppProtocol,会在原生窗口操作(DevTools 窗口、关闭拆除)前后调用 isHandlingSendEvent / setHandlingSendEvent:。Qt 的 QNSApplication 未实现,否则会 "unrecognized selector" 崩溃。补丁用 NSApplication 的 category 补上两个方法,QNSApplication(子类)继承之;状态是 static(NSApp 唯一实例),见 browser_embed.mm:10-30。
browser_embed.mm 另一职责是 BrowserEmbedSetFrame:把 CEF 的 NSView 设 frame + autoresizing mask 跟随窗口实时缩放(browser_embed.mm:32-46),由 QtMainWindow::resizeBrowser 经 browser_container_ 的 resize 事件过滤器调用(qt_window.cpp:110-139)。
3.7 服务地址五级解析
ResolveStartupUrl(main.cpp:59-96)按优先级取首个非空:
| 级 | 来源 | 锚点 |
|---|---|---|
| 1 | --url=<value> 命令行参数 | main.cpp:60-69 |
| 2 | PODCAST_SERVER_URL 环境变量 | main.cpp:71-76 |
| 3 | ~/Library/Application Support/Podcast Desktop/server_url.txt(用户覆盖) | main.cpp:78-86 |
| 4 | <bundle>/Contents/Resources/server_url.txt(打包期烘焙) | main.cpp:88-93 |
| 5 | 编译内置默认 http://localhost:3000/app/index.html | main.cpp:36、main.cpp:95 |
ReadUrlFile 取文件首个非空非 # 注释行(main.cpp:39-51)。加载失败时 OnLoadError 渲染一个 data: URL 回退页("Server not running on localhost:3000",提示 Cmd+R 重试,cef_client.cc:18-41、79-99);回退页上 Reload 会导航回 app URL 而非 reload(qt_window.cpp:158-170)。
3.8 DevTools 入口、应用图标、性能打点
- DevTools:菜单 Develop → Open DevTools(快捷键 Cmd+Option+I,
qt_window.cpp:53-58);openDevTools空CefWindowInfo让 CEF 建独立顶层 DevTools 窗口,浏览器未就绪时 300ms 重试(qt_window.cpp:141-156)。启动参数--devtools在浏览器存在后 1200ms 自动开(main.cpp:247-250)。 - 应用图标:bundle 声明
CFBundleIconFile,但 LaunchServices 可能缓存旧的无图标项,故运行时再QApplication::setWindowIcon(AppIcon.icns),运行进程的运行时图标总是胜出(main.cpp:166-174);图标随Contents/Resources打包(CMakeLists.txt:103-106)。 - 性能打点:
perf_trace::Mark(name)输出[perf] <name> +<ms>到 stderr(自进程启动的毫秒数),供tests/perf-timing.mjs消费,单次 mark 成本是一次时钟读 + 一次 fprintf(perf_trace.h:4-5、26-33)。全流程 mark 列表见 §3.3 / §3.5。
3.9 构建(CMake)
CMakeLists.txt 要点:仅 macOS(:22-24)、C++17、默认 Release(CEF 二进制分发仅 Release,:29-32);CEF_ROOT = ../third_party/cef、USE_SANDBOX OFF(:38-46);Qt6 从 Homebrew 路径找 Core/Gui/Widgets(:52-56);主 target 启用 AUTOMOC(:126)。主 target 刻意不用 CEF 完整告警/标志集(-Werror/-fno-exceptions/-fno-rtti),因为要编 Qt 头文件,只取 CEF defines + include path(:111-118)。POST_BUILD 拷贝 CEF framework 与 5 个 Helper bundle 进 Contents/Frameworks,并做 ad-hoc 代码签名(最内层 bundle 先、主 app 最后,:194-220)。
3.10 桌面端设计模式总结
| 模式 | 体现 |
|---|---|
| 依赖注入(回调) | SetSchedulePumpCallback / SetRelaunchCallback / SetBrowserClosedCallback / SetTitleChangedCallback:CEF 层不直接耦合 Qt,通过 std::function 回调(cef_app.h:21-35、cef_client.h:23-39) |
| 外部消息泵 | OnScheduleMessagePumpWork → Qt PreciseTimer(§3.4) |
| 关闭状态机 | closeEvent ↔ DoClose/OnBeforeClose 两次握手(§3.5) |
| 单一线程不变量 | multi_threaded_message_loop=0,CEF UI 线程 == Qt 主线程,故回调可直接碰 Qt 对象(cef_client.h:14-17、qt_window.cpp:60-64) |
| 单实例聚焦 | OnAlreadyRunningAppRelaunch → relaunch 回调 surface 既有窗口(cef_app.cc:46-55、main.cpp:200-205) |
4. 跨端约定:API 契约如何贯穿三端
三端共享同一份契约(docs/API-DESIGN.md),由服务端权威实现,Web UI 与桌面端消费。
4.1 契约要素与三端对齐
| 契约要素 | 服务端(权威) | Web UI | 桌面端 |
|---|---|---|---|
统一封套 {code,message,data,request_id} | envelope.ts:11-27 产出 | api() 解封套、code!==0 抛错(app.js:115-121) | 经 Web UI 间接消费(壳只加载页面,不直接调 API) |
| 业务错误码 | errors.ts:8-25 定义 | ApiError.code 透传(app.js:85-91) | 同上 |
| Bearer 鉴权 | auth.ts 校验白名单,user_id=token、paid- 标付费 | Authorization: Bearer dev-token(app.js:9、95) | 同上 |
cursor 分页 {items,next_cursor,total} | cursor.ts + catalog.service keyset | cursorStack + 预取(app.js:434-445、235-262) | 同上 |
| 端点路径 | 13 个 route(§1.2) | API_BASE='/api/v1' + 相对路径(app.js:8) | 加载 /app/index.html |
| 服务地址 | localhost:3000 默认部署 | 同源(/api/v1 相对) | 五级解析(§3.7),默认 http://localhost:3000/app/index.html |
4.2 契约贯穿的关键事实
- 同源部署:Web UI 静态服务于
/app/,与/api/v1/*同源(app.js:1-2),故前端用相对路径、无跨域。桌面端默认加载http://localhost:3000/app/index.html(main.cpp:36),即把同一份 Web UI 装进原生壳。 - token 与配额语义一致:前端固定
dev-token(app.js:9)= 服务端默认白名单首项(env.ts:10),无paid-前缀 → 服务端按试用用户处理,受TRIAL_TOTAL=3终身配额约束(downloads.service.ts:8、95-97)。 - 错误处理闭环:服务端
fail(code,...)→ 前端api()把code!==0转为ApiError(message, code)→showError(msg, retryFn)渲染横幅 + 重试(app.js:118-119、156-161)。 - 分页语义对齐:服务端「仅首页返回
total、?cursor=等同首页」(catalog.service.ts:470、podcasts.ts:81-87);前端「仅首页(无 cursor)记total」(app.js:231、506),两侧契约一致。 - 断点续播契约:前端续播窗口(>10s 且 <95%,
app.js:13-14)消费服务端getProgress的position_seconds/progress_percent(playback.service.ts:83-94);保存走POST /playback/progressUPSERT。
4.3 单向数据流(端到端)
Web UI / 桌面壳
│ fetch Authorization: Bearer <token>, X-Request-ID?
▼
route handler ── 鉴权(auth) → 校验(zod) → cursor 解码 ──┐
│ │ 失败 → fail(code,status)
▼ 成功 ▼
service(业务逻辑唯一住所) ──→ db(drizzle/postgres-js 单例)
│
▼
ok(data, request_id) → 封套 → 前端解封套渲染
附录 A:本册引用的关键锚点速查
| 主题 | 锚点 |
|---|---|
| envelope ok/fail | podcast-api/src/lib/api/envelope.ts:11、:25 |
| 错误码常量 | podcast-api/src/lib/api/errors.ts:8 |
| Bearer 鉴权 | podcast-api/src/lib/api/auth.ts:36 |
| cursor 编解码 | podcast-api/src/lib/api/cursor.ts:10、:20 |
| memo TTL 缓存 | podcast-api/src/lib/api/memo.ts:20 |
| catalog.service listShows | podcast-api/src/lib/services/catalog.service.ts:356 |
| downloads.service createTask | podcast-api/src/lib/services/downloads.service.ts:91 |
| playback.service saveProgress | podcast-api/src/lib/services/playback.service.ts:33 |
| db 单例 | podcast-api/src/lib/db/client.ts:15 |
| 8 表 schema | podcast-api/src/lib/db/schema.ts:19 起 |
| env 校验 | podcast-api/src/config/env.ts:4 |
| logger pino | podcast-api/src/lib/logger.ts:12 |
| Zod schema | podcast-api/src/lib/validations/podcasts.ts:27 |
| shows route 三段式 | podcast-api/src/app/api/v1/podcasts/shows/route.ts:18 |
| Web UI api() | podcast-api/public/app/app.js:94 |
| Web UI loadShows | podcast-api/public/app/app.js:264 |
| Web UI handleRoute | podcast-api/public/app/app.js:896 |
| main 启动流程 | desktop/src/main.cpp:141 |
| CefPumpScheduler | desktop/src/main.cpp:104 |
| 关闭握手 closeEvent | desktop/src/qt_window.cpp:88 |
| DoClose | desktop/src/cef_client.cc:53 |
| 协议补丁 | desktop/src/browser_embed.mm:18 |
| Helper 入口 | desktop/src/process_helper_mac.cc:12 |
| 五级 URL 解析 | desktop/src/main.cpp:59 |
| CMake 构建 | desktop/CMakeLists.txt:1 |