05 实施记录(Implementation Record)

受众:工程师与 AI Agent。本册按"环境 → 入库 → 性能 → 打包 → 签名公证 → 经验"的实际施工顺序记录播客目录项目从零到可分发的全过程。 平台:macOS (darwin 25.3.0 / arm64) + Homebrew。日期:2026-06-04(脚手架)/ 2026-06-10~11(数据入库、性能、桌面端打包)。 真实性铁律:每条结论均给出 file_path:line 锚点。读不到/未实现的标注"待确认",不臆造。


0. 一手事实速查(已核验)

维度事实锚点
数据规模293,781 播客 / 22,867,469 剧集 / 354,508 分类关联etl-work/etl-stats.json:2-7
脏行dirtyLines=0,episodesNoUid=0etl-work/etl-stats.json:3,7
transform 耗时17:34:20 → 17:35:40(≈80s 流式转换)etl-work/etl-stats.json:8-9
数据库表8 张(podcasts / episodes / podcast_categories / language_stats / category_stats / category_sub_stats / playback_progress / download_tasks)scripts/etl/finalize.sql + scripts/etl/perf-stats.sql
技术栈next@16.2.7 / react@19.2.4 / drizzle-orm@0.45 / postgres@3.4 / zod@4 / pino@10 / vitest@4 / typescript@5INSTALL-CHECKLIST.md:102-110
数据集源dataset.7z(1.4GB,6 shard JSONL,解压后 episodes.tsv 约 6.38GB)dataset.7z、etl-work/episodes.tsv

1. 环境搭建(缺什么装什么)

1.1 体检结论(脚手架阶段,纯 API 子集)

hephyai-web-new-project skill 脚手架时对宿主机做体检,已装的不动,缺的本次装。来源 INSTALL-CHECKLIST.md:23-32:

工具体检前处理版本锚点
Node.js已装无需动作v24.3.0INSTALL-CHECKLIST.md:25
npm已装无需动作11.6.4INSTALL-CHECKLIST.md:26
git已装无需动作2.50.1INSTALL-CHECKLIST.md:28
Homebrew已装安装器5.1.10INSTALL-CHECKLIST.md:29
Docker缺失本次安装29.5.3INSTALL-CHECKLIST.md:30
Docker Compose缺失本次安装5.1.4INSTALL-CHECKLIST.md:31
colima缺失本次安装0.10.2INSTALL-CHECKLIST.md:32

Docker 运行时采用无 GUI 轻量方案:colima + docker CLI + compose 插件(INSTALL-CHECKLIST.md:36-54):

brew install colima docker docker-compose
mkdir -p ~/.docker
# 让 docker CLI 找到 Homebrew 的 compose 插件
cat > ~/.docker/config.json <<'JSON'
{ "cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"] }
JSON
colima start -f       # 首次拉取 VM 镜像
docker ps && docker compose version && colima list

1.2 数据库/桌面端阶段的追加安装(2026-06-11)

来源 INSTALL-CHECKLIST.md:177-183。这一批是入库、桌面端打包阶段缺什么补什么:

工具/组件安装方式版本用途锚点
sevenzip (7zz)brew install sevenzip26.01解压 dataset.7z(1.4GB → ≈7.5GB JSONL)INSTALL-CHECKLIST.md:179
ninjabrew install ninja1.13.2桌面端 CMake 构建器INSTALL-CHECKLIST.md:180
Qt 6brew install qt(模块化 formulae)6.11.1(qtbase 等拆分包)桌面 GUI 框架;cmake 入口 /opt/homebrew/lib/cmake/Qt6INSTALL-CHECKLIST.md:181
CEF minimal手动下载 spotifycdn144.0.27 / Chromium 144(macosarm64)桌面内嵌浏览器,解压于 third_party/cef/INSTALL-CHECKLIST.md:182
PostgreSQL 16docker run postgres:16(colima 内)16播客目录主存储(容器 podcast-pg,卷 podcast-pgdata)INSTALL-CHECKLIST.md:183

CEF 落地已核验:third_party/cef/Release/Chromium Embedded Framework.framework 存在(flat 布局,见 §4/§5)。

1.3 ⚠️ colima 镜像撑满系统盘事故与外置盘符号链接处置

来源 INSTALL-CHECKLIST.md:185-188,必读事故记录:

项内容
现象系统盘仅 228GB 且本就接近满。首次入库时 colima VM 镜像(默认在 ~/.colima)膨胀至 26GB 撑满系统盘,PostgreSQL 崩溃,整机命令短暂不可用。
根因colima VM 磁盘镜像 + PostgreSQL 数据卷全部落在系统盘 ~/.colima,22.87M 行剧集入库时空间放大。
处置colima delete 后把 ~/.colima 符号链接到外置盘 /Volumes/External/develop/.colima,重建 VM(4C/6G/80G),数据库镜像此后落外置盘;并清理 Homebrew 缓存与本地 Time Machine 快照。
后果(强约束)今后 colima/docker 一切数据都在外置盘;外置盘必须先挂载,colima 才能启动。

性能测量基线就是这台重建后的 VM(4C/6G PostgreSQL 16),见 PERF-REPORT.md:4。


2. 数据入库实施

2.1 流水线总览(解压 → ETL → COPY → 索引)

入库分两条路径,复用同一套 schema.sql / finalize.sql / perf-stats.sql:

脚本用途是否含 transform锚点
run-etl.sh全量入库:JSONL → TSV → COPY → finalize是scripts/etl/run-etl.sh:12-29
reload.sh重载:复用已生成 TSV,跳过 transform否scripts/etl/reload.sh:11-22

四步流水线(run-etl.sh:12-29):

1/4 transform JSONL -> TSV   (node transform.mjs)
2/4 create staging schema    (schema.sql)
3/4 COPY into staging        (3 张 *_staging 表)
4/4 finalize                 (dedupe -> constraints -> indexes -> ANALYZE)
4b  perf stats               (预聚合统计表 + keyset 索引)

所有 psql 走容器:docker exec -i podcast-pg psql -U podcast -d podcast -v ON_ERROR_STOP=1 -q(run-etl.sh:10)。

2.2 解压与 transform(流式、不中断批次)

  • 解压:dataset.7z(1.4GB)→ 6 个 shard(shard0..shard5/podcasts.jsonl),由 transform.mjs:79 读取。
  • transform 策略(transform.mjs:81-150):逐行 JSON.parse,脏行只 skip+计数、绝不中止批次(架构 §8.1)。输出 PostgreSQL COPY text 格式 TSV,转义规则在 transform.mjs:17-30(\ → \\、tab → \t、LF → \n、NULL → \N)。
  • 时间戳/整数防御:toTimestamp(unix 秒 → ISO,越界/NaN 归 null,transform.mjs:33-40)、toInt(transform.mjs:42-46)。
  • 背压:TsvWriter 4MiB highWaterMark + drain await(transform.mjs:48-63),保证 22.87M 行不爆内存。

2.3 COPY 与 finalize(staging → 索引)

  • staging 表无约束、字段近 text(schema.sql:7-41),COPY 最快。
  • finalize 用 DISTINCT ON 去重;孤儿剧集/分类(父播客缺失)由 EXISTS 子查询过滤掉(finalize.sql:52-71)。
  • 索引在 bulk load 之后建(架构 §8.1 step 4,finalize.sql:97-119)——这是大表入库的关键顺序。
  • 派生最新剧集字段:latest_episode_title / latest_publish_date 走 idx_episodes_podcast_pub 的 DISTINCT ON 回填(finalize.sql:98-112)。

2.4 真实统计(已核验)

来源 etl-work/etl-stats.json:

指标值锚点
linesRead293,781etl-work/etl-stats.json:2
dirtyLines0etl-work/etl-stats.json:3
podcasts293,781etl-work/etl-stats.json:4
episodes22,867,469etl-work/etl-stats.json:5
categories354,508etl-work/etl-stats.json:6
episodesNoUid0etl-work/etl-stats.json:7

数据质量极高:293,781 行全部解析成功,0 脏行、0 无 UID 剧集。run-etl 末尾还会打印三表 count(*) 与放弃的 staging 表对账(run-etl.sh:31-35)。


3. 性能优化(三层,A/B 实测)

目标:app 端翻页毫秒级。逐层优化 DB → API → 客户端(PERF-REPORT.md:1-6)。测量环境:colima VM 4C/6G PostgreSQL 16(22.87M 剧集)+ Next.js 16 生产构建 + CEF 144。回归:381 测试全绿(PERF-REPORT.md:59)。

3.1 结果总览(基线 → 优化后)

来源 PERF-REPORT.md:9-18:

层指标基线优化后提升
APIcategories1,730ms1.6-4ms(memo 命中 ≈1.6ms)≈450×
APIshows 深页(offset≈294k)250-310ms4-9ms(任意深度恒定)≈40×
APIshows 浅页14-22ms4-8ms≈3×
APIlanguages10-19ms1.3-1.5ms≈10×
API100 页连翻(×50/页)—p50=6ms / p95=9ms / max=28ms深度无关
客户端Next 翻页(感知)网络往返+渲染0.8-1.0ms预取即时渲染
客户端Prev 翻页(感知)网络往返+渲染0.5-0.9ms页缓存即时渲染
客户端分类切换(冷/热)≥1.7s8.5ms / 0.6ms—

感知延迟测法:CDP 注入 MutationObserver,从点击到网格首节点内容变化的 performance.now() 差值(PERF-REPORT.md:20)。

3.2 DB 层(scripts/etl/perf-stats.sql)

优化内容A/B锚点
预聚合统计表language_stats / category_stats / category_sub_stats,装载期算一次,取代每请求对 354k 行 join 做 count(DISTINCT)categories 1,730ms → 单行索引读perf-stats.sql:6-31、PERF-REPORT.md:24-28
keyset 复合索引idx_podcasts_popular_keyset(episode_count DESC, podcast_uid DESC)、idx_podcasts_name_keyset(name, podcast_uid)、idx_podcasts_recent_keyset(latest_publish_date DESC NULLS LAST, podcast_uid DESC)EXPLAIN:Index Cond: ROW(...) < ROW(...),0.077msperf-stats.sql:35-40、PERF-REPORT.md:29-34
淘汰被覆盖索引DROP idx_podcasts_episode_count、idx_podcasts_latest_pub(keyset 复合索引覆盖其用途)—perf-stats.sql:42-44

正确性细节:podcasts.lang 单值,故跨语言 SUM 与 DISTINCT 等价(精确非估算,perf-stats.sql:6、PERF-REPORT.md:28);category_sub_stats 排除 sub='' 链接(perf-stats.sql:29)。perf-stats 已接入 run-etl.sh:28 / reload.sh:21(步骤 4b),快照重建自动重算。

3.3 API 层(src/lib/services/catalog.service.ts + src/lib/api/memo.ts)

优化内容锚点
shows 全面 keyset 化cursor 升级为带排序标记的 union:{s:'popular',v,id} | {s:'name',v,id} | {s:'recent',v|null,id}(offset 仅留给 q 相关度排序);任意页=索引范围 seek,深度无关;recent 的 NULL 尾部单独处理catalog.service.ts:33-37,380-397、PERF-REPORT.md:39-42
首页 total 走统计表lang/category/sub 过滤组合全命中预聚合;仅 q/日期过滤回退 count(*)PERF-REPORT.md:43
进程内 memomemo(key, ttlMs, compute),TTL 10min;categories/languages ≈1.6ms。单实例部署,多实例换 Redis(接口已抽象)memo.ts:20-27、PERF-REPORT.md:44
HTTP 缓存头字典端点 Cache-Control: private, max-age=300(CEF/浏览器侧免请求)PERF-REPORT.md:45
生产构建部署next build && next start 取代 dev 模式,消除编译抖动PERF-REPORT.md:46

cursor 对客户端保持不透明契约(opaque),路由校验 cursor 与 sort 匹配(不匹配 → 错误码 1000,catalog.service.ts:139-149、PERF-REPORT.md:42)。

3.4 客户端层(public/app/app.js)

来源 PERF-REPORT.md:50-56:

优化内容
页缓存以完整查询串(含 cursor+filters)为键,TTL 5min、容量 100;命中同步渲染零闪烁,过期后台 revalidate
下一页预取渲染含 next_cursor 的页后后台预取下一页(单飞行 + AbortController 防竞态)→ Next 点击即时
Prev 即时cursor 栈不变,页缓存短路网络
字典会话缓存languages 取一次;categories 按 lang 键缓存;剧集页同模式(5min)
渲染replaceChildren 单次 DOM 替换;封面 loading="lazy" decoding="async"
延迟 spinner150ms 阈值,快路径零闪烁,慢请求才显示加载态

净效果:翻页感知 0.5-1.0ms(PERF-REPORT.md:16-17)。

3.5 正确性保障

  • 381 个既有测试全绿(含 60 档播客全字段一致性、keyset 全量翻页无重复/遗漏比对,PERF-REPORT.md:58-59)。
  • 唯一测试改动:shows.test.ts 把断言 cursor 内部编码 {o:20} 的实现细节改为不透明契约断言(cursor 本就声明 opaque,PERF-REPORT.md:60)。
  • 数据值零变化:all=293,781、news=38,112 与源数据对账一致(PERF-REPORT.md:61)。

4. 桌面端打包实施(desktop/package.sh)

打包流水线 7~8 步(package.sh:22-183):fresh copy → bake server_url → macdeployqt → deep codesign → verify → DMG(→ notarize → staple)。

4.1 把 Qt 打进 bundle + 纯净 CEF framework

步骤实施锚点
fresh copyditto build/Release/PodcastDesktop.app → dist/package.sh:22-24
替换纯净 CEF构建树的 CEF 副本会累积混合 Versions/ 结构,被 codesign --deep --strict 拒;改用二进制发行版的 flat-layout 纯净拷贝package.sh:26-31
bake server_url写 Contents/Resources/server_url.txt(默认内网 http://10.10.20.207:3000/app/index.html),用户可覆盖package.sh:33-36
macdeployqtbundle Qt frameworks + pluginspackage.sh:38-44

⚠️ 关键技巧(避免悬空符号链接):CEF 是 flat framework(无 Versions/ 布局),macdeployqt 会尝试规范化它并造出悬空自指符号链接。处置:运行 macdeployqt 前先把 CEF framework 与 *Helper*.app 挪到 dist/cef-park/ 暂存,跑完再挪回(package.sh:40-47)。 裁剪:删 PlugIns/iconengines、imageformats——UI 在 CEF 内渲染,shell 不用;iconengines 还会拉入 macdeployqt 无法从拆分 Homebrew Qt 解析的 QtSvg(package.sh:49-51)。

构建后双重校验(package.sh:53-61):① 主二进制不得残留 /opt/homebrew 链接;② bundle 内不得有任何悬空符号链接(否则 codesign/spctl 失败)。

4.2 深度签名(inside-out)

签名顺序由内向外(package.sh:63-107),证书 Developer ID Application: Beijing Goland Tech Co., Ltd. (5RCGYAV62A):

顺序对象选项锚点
1CEF framework 内部 Libraries/*.dylib--timestamppackage.sh:80-82
2CEF framework 本体--timestamppackage.sh:83
3*Helper*.app--options runtime --entitlements(含 allow-jit 等)package.sh:86-89
4Qt*.framework--timestamppackage.sh:92-94
5Frameworks 顶层裸 dylib(libicu/libpcre2/libpng…)--timestamppackage.sh:98-100
6PlugIns/*.dylib--timestamppackage.sh:101-103
7主 app(最后)--options runtime --entitlementspackage.sh:106-107

entitlements(package.sh:65-76)为 Chromium/CEF 硬化运行时所需:com.apple.security.cs.allow-jit、allow-unsigned-executable-memory、disable-library-validation。

4.3 DMG 图标

build_dmg()(package.sh:120-146):staging 目录 + /Applications 软链 + .VolumeIcon.icns;用 Swift NSWorkspace.setIcon 给挂载卷与 .dmg 文件本身打图标(resource-fork 元数据,须先于 codesign);UDRW → UDZO 压缩后 codesign --timestamp 签 DMG。图标源 resources/AppIcon.icns(已核验存在,404KB)。


5. 签名公证踩坑实录(根因 + 修复)

三条均来自打包脚本注释与公证脚本的实战记录,每条给根因与修复。

① 27 个 Qt 依赖裸 dylib 漏签被 Apple 拒

项内容
现象公证被 Apple 拒。
根因macdeployqt 复制进 Frameworks/ 顶层的 Qt Homebrew 传递依赖裸 dylib(libicu*、libpcre2、libpng…)带的是 ad-hoc 链接器签名;公证要求 Developer ID + 安全时间戳,否则拒收(package.sh:96-97)。
修复在 inside-out 签名中单独补签这批顶层裸 dylib:find Frameworks -maxdepth 1 -name "*.dylib" → codesign --force --sign $SIGN_ID --timestamp(package.sh:98-100)。PlugIns 下 dylib 同理补签(package.sh:101-103)。

② DMG 内 app 无装订票据 → 离线/沙盒来源机器 "can't be opened"

项内容
现象在离线 / Apple 被墙的内网机器上,DMG 内 app 报 "can't be opened"。
根因离线 Gatekeeper 需要装订(stapled)票据;未装订的 app 只在能连 Apple 在线时才过(package.sh:117-119)。若先建 DMG 再公证,DMG 内的 app 副本不带自己的离线票据。
修复改为先装订 app、再建 DMG:先 notarytool submit(app zip)→ stapler staple 进 app bundle → 再 build_dmg 从已装订 app 构建 → 对 DMG 单独再提交公证并装订(package.sh:148-164)。最后强校验 DMG 内 app 副本必须带装订票据,否则 FATAL 退出(package.sh:166-172)。

两次提交的原因:app zip 与 DMG 文件是两个独立公证记录,装订 DMG 需要它自己那一遍(package.sh:160-163)。

③ 微信等沙盒应用传输打特殊 quarantine → 改 HTTP 分发

项内容
现象经微信等沙盒应用传输的安装包,落地后带特殊 quarantine 标记,触发安全告警。
根因沙盒 IM 应用对传入文件附加额外 quarantine 来源属性。
修复改用 HTTP 分发(SERVER_URL 走内网 HTTP,package.sh:12;分发同样走拷回机制,notarize-on-server.sh:80)。

5.1 服务器侧公证脚本注意事项(notarize-on-server.sh)

  • 该脚本只做"上传公证 → 轮询 → 装订 → 验证",不重签(notarize-on-server.sh:1-10)。
  • ⚠️ 切勿用会重签的流程跑此 app:重签若丢失 JIT entitlements,Chromium 渲染进程在硬化运行时下会直接崩溃(notarize-on-server.sh:8-10)。
  • 预检 codesign --verify "$DMG",失败即回开发机重打包(notarize-on-server.sh:35-37)。
  • 凭据:keychain profile 或 APPLE_ID + APP_PASSWORD + TEAM_ID(团队 5RCGYAV62A,与公司 StreamFab 同证书,notarize-on-server.sh:31-50)。
  • 产物 <原名>_notarized.dmg,拷回即可分发(双击直开、无 Gatekeeper 警告,notarize-on-server.sh:73-80)。

6. 关键决策与经验沉淀

#决策/经验依据锚点
1Docker 用 colima 无 GUI 方案轻量、无授权、CLI 友好INSTALL-CHECKLIST.md:36-54
2colima/PostgreSQL 数据全部落外置盘系统盘 228GB 撑满事故的硬约束;外置盘须先挂载再启 colimaINSTALL-CHECKLIST.md:185-188
3目录是只读快照 → 预聚合统计表 + keyset 索引计数装载期算一次,翻页恒定毫秒级perf-stats.sql:1-4、PERF-REPORT.md:24-34
4cursor 不透明契约内部 union 编码可演进而不破坏客户端;测试只断言契约catalog.service.ts:33-37、PERF-REPORT.md:60
5进程内 memo 而非 Redis单实例部署足够;接口已抽象,多实例再换memo.ts:1-2、PERF-REPORT.md:65
6CEF 用 flat 纯净拷贝 + 打包前 park 出去避免 macdeployqt 造悬空符号链接致 codesign 失败package.sh:26-47
7inside-out 深度签名 + 顶层裸 dylib 补签满足公证的 Developer ID + 安全时间戳要求package.sh:63-107
8先装订 app 再建 DMG(两次公证)离线/内网 Gatekeeper 需要 app 自带装订票据package.sh:148-172
9HTTP 分发取代 IM 传输规避沙盒应用附加的特殊 quarantinepackage.sh:12、notarize-on-server.sh:80
10公证脚本绝不重签重签丢 JIT entitlements → Chromium 渲染进程崩溃notarize-on-server.sh:8-10

待确认 / 未实现

  • 性能"后续可选"项(Redis 分布式缓存、episodes 列表预取、q 搜索深分页 keyset)当前规模不需要,未实现(PERF-REPORT.md:63-67)。
  • 脚手架阶段版本偏差(zod 4 vs 规范 ^3.24 等)已记录待团队确认(INSTALL-CHECKLIST.md:154-171);本项目实际栈以 §0 表为准。