04 模块代码设计

本册面向工程师与 AI Agent,描述 Podcast 项目三端(服务端 / Web UI / 桌面端)的模块划分、分层职责、关键函数与架构纪律。所有论断均给出 file_path:line 锚点佐证;读不到或代码中不存在的内容标注「未实现 / 待确认」。

关联分册:API 契约详见 docs/API-DESIGN.md(被代码注释反复引用);数据库表与索引详见数据库分册;运维详见 docs/SERVICE-OPS.md。

0. 总览

端根目录技术栈模块入口
服务端 APIpodcast-api/srcnext@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 UIpodcast-api/public/appvanilla ES2022 SPA,无构建步骤public/app/index.html + public/app/app.js
桌面端desktop/srcQt6 + CEF(cefsimple 风格)+ CMake,仅 macOSdesktop/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 handlersrc/app/api/**/route.tsHTTP 编排:鉴权 → Zod 校验 → 调 service → envelope(不含业务逻辑)13 个 route.ts
Servicesrc/lib/services/*.service.ts业务逻辑唯一住所,访问 db、计算派生字段、组装 DTO4 个 service
DBsrc/lib/db/Drizzle schema + postgres-js 单例schema.ts / client.ts
API 工具src/lib/api/envelope / errors / auth / cursor / memo 横切工具5 个文件
Validationssrc/lib/validations/Zod 请求 schema + 错误格式化podcasts.ts
Configsrc/config/环境变量 Zod 校验env.ts
Loggersrc/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 idshows/route.ts:19getRequestId(req):回显 X-Request-ID 或生成 UUID
鉴权shows/route.ts:22-23authenticate(req),失败返回 fail(UNAUTHORIZED, 401)(code 1001)
Zod 校验shows/route.ts:25-27showsQuerySchema.safeParse(...),失败返回 fail(INVALID_PARAMS, 400)(code 1000)
cursor 解码/守卫shows/route.ts:29-41decodeCursor + isShowsCursor,并校验 cursor 与 sort 一致
调 serviceshows/route.ts:43-53listShows({...})
envelope + 日志shows/route.ts:54-55logger.debug(... durationMs ...) + ok(data, requestId)
兜底 catchshows/route.ts:56-59logger.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 调用锚点
1GET/api/healthgetHealth(now, startedAt)health/route.ts:15-18
2GET/api/v1/podcasts/languageslistLanguages()podcasts/languages/route.ts
3GET/api/v1/podcasts/categorieslistCategories(lang)(经 memo)podcasts/categories/route.ts:29
4GET/api/v1/podcasts/showslistShows(params)podcasts/shows/route.ts:43
5GET/api/v1/podcasts/shows/{showId}getShow(showId)podcasts/shows/[showId]/route.ts
6GET/api/v1/podcasts/shows/{showId}/episodeslistEpisodes(showId, params)podcasts/shows/[showId]/episodes/route.ts
7GET/api/v1/podcasts/episodes/{episodeId}getEpisode(episodeId)podcasts/episodes/[episodeId]/route.ts
8GET/api/v1/podcasts/episodes/{episodeId}/streamgetStream(episodeId)podcasts/episodes/[episodeId]/stream/route.ts:24
9POST/api/v1/podcasts/playback/progresssaveProgress(userId, input)podcasts/playback/progress/route.ts:33
10GET/api/v1/podcasts/playback/progress/{episodeId}getProgress(userId, episodeId)podcasts/playback/progress/[episodeId]/route.ts:24
11POST/api/v1/podcasts/downloadscreateTask(auth, input)podcasts/downloads/route.ts:33
12GET/api/v1/podcasts/downloads/{taskId}getTask(userId, taskId)podcasts/downloads/[taskId]/route.ts
13GET/api/v1/user/quota/podcastsgetQuota(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_quotaTRIAL_QUOTA_EXCEEDED (1003)403{ trial_quota }
daily_quotaDAILY_QUOTA_EXCEEDED (1004)403{ daily_quota }
not_foundNOT_FOUND (2001)404—
not_downloadableNOT_DOWNLOADABLE (2002)403—

1.3 Service 层(业务逻辑唯一住所,4 个 service 职责)

业务逻辑只允许写在 service 层(health.service.ts:1 注释明确:"business logic lives only in services (architecture discipline §6.3)")。

Service文件职责导出函数
catalogcatalog.service.ts目录只读:语言/分类字典、shows 列表与详情、episodes 列表与详情、stream URLlistLanguages listCategories listShows getShow listEpisodes getEpisode getStream
downloadsdownloads.service.ts下载任务 + 配额会计(试用 3 次终身、每日 300、服务器本地午夜边界)createTask getTask getQuota
playbackplayback.service.ts播放进度 UPSERT 保存 + 读取(含百分比计算)saveProgress getProgress
healthhealth.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.ts8 张表的 Drizzle 定义(仅表,索引由 ETL 创建)schema.ts:1
db/client.tspostgres-js 客户端 + drizzle 实例单例client.ts:15-24

8 张表(与 scripts/etl/finalize.sql 镜像,schema.ts:1):

表drizzle 导出主键备注
podcastspodcastspodcast_uid含 tsv GENERATED tsvector 列(name A>author B>description C,schema.ts:34-36)+ 反规范化 latest_episode_title / latest_publish_date
episodesepisodesepisode_uid外键 podcast_uid → podcasts
podcast_categoriespodcastCategories(podcast_uid, cat, sub)sub '' 表示仅主分类
playback_progressplaybackProgress(user_id, episode_uid)由 playback.service UPSERT
download_tasksdownloadTaskstask_id配额会计 + D-6 立即完成
language_statslanguageStatslangETL 预聚合,'' 表示 lang NULL
category_statscategoryStats(lang, cat)ETL 预聚合
category_sub_statscategorySubStats(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,代码绝不写入,只通过 raw sql\` 片段引用(schema.ts:33-36`)。

1.5 API 工具层(envelope / errors / auth / cursor / memo)

文件导出职责锚点
api/envelope.tsok / fail统一响应封套 { code, message, data, request_id }envelope.ts:11、envelope.ts:25
api/errors.tsERROR_CODES稳定业务错误码常量errors.ts:8-25
api/auth.tsauthenticate / getRequestId / AuthContextBearer 鉴权、request id 解析auth.ts:25、auth.ts:36
api/cursor.tsencodeCursor / decodeCursorbase64url(JSON) 游标编解码(不签名)cursor.ts:10、cursor.ts:20
api/memo.tsmemo / 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(业务码):

常量值语义
OK0成功
INVALID_PARAMS1000参数校验失败(扩展码,Zod 详情进 message)
UNAUTHORIZED1001token 缺失或无效
TRIAL_QUOTA_EXCEEDED1003试用配额耗尽(终身 3 集)
DAILY_QUOTA_EXCEEDED1004每日下载配额超限(300 集/天)
NOT_FOUND2001show/episode/task 不存在
NOT_DOWNLOADABLE2002无 audio_url,不可下载
INTERNAL_ERROR3001内部错误

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 与错误渲染:

导出用途锚点
categoriesQuerySchemacategories 查询(lang?)podcasts.ts:22-24
showsQuerySchemashows 查询(lang/category/sub/q/sort/时间窗/cursor/limit,含 sub requires category refine)podcasts.ts:27-39
episodesQuerySchemaepisodes 查询(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
searchParamsToObjectURLSearchParams → 对象,丢弃空值(?cursor= 等同首页)podcasts.ts:81-87
formatZodErrorZod 错误渲染为单行 messagepodcasts.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_ENVdevelopmentdevelopment/test/production
LOG_LEVEL可选fatal…trace
DATABASE_URLpostgres://podcast:podcast@localhost:5432/podcastPG 连接串
API_BEARER_TOKENSdev-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 禁直连 dbhealth.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-36GENERATED 列禁写,只 raw sql 引用
错误码稳定errors.tscode 常量集中,禁散落字面量
首页才算 totalcatalog.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:16 await 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/v1API 前缀app.js:8
AUTH_TOKENdev-token固定 Bearer(与服务端默认白名单一致)app.js:9
PAGE_SIZE20列表分页大小app.js:10
SEARCH_DEBOUNCE_MS300搜索去抖app.js:11
PROGRESS_INTERVAL_MS30000播放中每 30s 保存进度app.js:12
RESUME_MIN_SECONDS / RESUME_MAX_PERCENT10 / 95断点续播窗口(>10s 且 <95%,PRD AC03)app.js:13-14
SPINNER_DELAY_MS150延迟显示 spinner(快加载零闪烁)app.js:16
PAGE_CACHE_TTL_MS / PAGE_CACHE_MAX300000 / 100页缓存 TTL 与容量上限app.js:17-18

2.2 模块功能与关键函数清单

模块关键函数职责锚点
DOM 工具$ el debounce formatTime prettySlug coverImgquerySelector 简写、声明式建元素、去抖、时长格式化、slug 美化、封面 img 一次性回退占位图app.js:21-82
API 客户端api(path, opts) / ApiErrorfetch → 解封套 → 失败抛 ApiError;区分网络错误(-1)/异常响应(-2)/业务非 0 码app.js:85-122
页缓存cacheGet cachePut按完整 path(含 query/cursor/过滤器)缓存分页响应;Map 顺序近似 LRU,超 PAGE_CACHE_MAX 淘汰最旧app.js:128-140
延迟 spinnerdeferredSpinner(selector)慢于 SPINNER_DELAY_MS 才显示 spinner,返回 hide/cancelapp.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渲染卡片网格 + 空态 + totalapp.js:304-329
首页过滤器loadLanguages loadCategories markActiveCategory initFiltersMeta initHomeEvents语言下拉/分类 chips(按会话缓存一次)、搜索/语言/排序/日期/翻页事件app.js:333-446
详情数据resetDetailState loadShowDetail buildEpisodesQuery applyEpisodesPage loadEpisodes updateEpisodesPagershow 详情 + 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 stopProgressTimer30s 定时 + 暂停/结束/切轨时 POST 进度app.js:791-818
播放器事件initPlayerEvents播放/暂停/seek/loadedmetadata/timeupdate/ended/error 绑定app.js:821-893
路由handleRoutehash 路由:#/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/.ccPodcastCefApp(CefApp + CefBrowserProcessHandler)命令行开关、外部消息泵桥接、relaunch 聚焦
cef_client.h/.ccPodcastCefClient(CefClient + LifeSpan/Load/Display)浏览器生命周期、加载错误回退页、标题回调、关闭握手
qt_window.h/.cppQtMainWindow(QMainWindow)内嵌浏览器、菜单(Reload/Quit/DevTools)、closeEvent 握手
browser_embed.h/.mmObjective-C++ glueNSView frame 调整 + isHandlingSendEvent 协议补丁
process_helper_mac.ccHelper 进程入口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-154CefScopedLibraryLoader::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-205SetSchedulePumpCallback / SetRelaunchCallback(须先于 CefInitialize)—
5. CEF 初始化main.cpp:207-228CefSettings(no_sandbox / external_message_pump / windowed)+ cache_path + CefInitializecef_initialized
6. 建浏览器main.cpp:232window.createBrowserIfNeeded()(CEF 就绪后才建,不再挂在 showEvent)browser_created
7. 事件循环main.cpp:253qt_app.exec()event_loop_exited
8. 退出泵兜底main.cpp:260-266若循环先于 OnBeforeClose 退出,以 1ms 步继续泵(最多 3s)直到 browser 关闭—
9. 关闭main.cpp:269-270window/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-44OnScheduleMessagePumpWork 转发到 schedule_pump_callback_
桥接回调main.cpp:198-199SetSchedulePumpCallback 把请求转给 CefPumpScheduler::Schedule
跨线程编排main.cpp:122-133Schedule 用 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 经典握手,避免「关闭按钮假死」:

步锚点动作
1qt_window.cpp:88-108 closeEvent首次关闭:close_pending_=true、client_->CloseBrowser(false)、event->ignore()(mark close_requested)
2cef_client.cc:118-122 CloseBrowserbrowser_->GetHost()->CloseBrowser(force_close)
3cef_client.cc:53-66 DoCloseunload handler 跑完后置 closing_=true、返回 false 让 CEF 把 OS 关闭请求转回顶层窗口(mark do_close)
4qt_window.cpp:94-98 closeEvent(二次)见 IsClosing() 为真 → event->accept() 放行第二次关闭,原生视图拆除
5cef_client.cc:68-77 OnBeforeClosebrowser_=nullptr、触发 browser_closed_callback_(mark browser_destroyed)
6qt_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
2PODCAST_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.htmlmain.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 keysetcursorStack + 预取(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/progress UPSERT。

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/failpodcast-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 listShowspodcast-api/src/lib/services/catalog.service.ts:356
downloads.service createTaskpodcast-api/src/lib/services/downloads.service.ts:91
playback.service saveProgresspodcast-api/src/lib/services/playback.service.ts:33
db 单例podcast-api/src/lib/db/client.ts:15
8 表 schemapodcast-api/src/lib/db/schema.ts:19 起
env 校验podcast-api/src/config/env.ts:4
logger pinopodcast-api/src/lib/logger.ts:12
Zod schemapodcast-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 loadShowspodcast-api/public/app/app.js:264
Web UI handleRoutepodcast-api/public/app/app.js:896
main 启动流程desktop/src/main.cpp:141
CefPumpSchedulerdesktop/src/main.cpp:104
关闭握手 closeEventdesktop/src/qt_window.cpp:88
DoClosedesktop/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