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=0 | etl-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@5 | INSTALL-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.0 | INSTALL-CHECKLIST.md:25 |
| npm | 已装 | 无需动作 | 11.6.4 | INSTALL-CHECKLIST.md:26 |
| git | 已装 | 无需动作 | 2.50.1 | INSTALL-CHECKLIST.md:28 |
| Homebrew | 已装 | 安装器 | 5.1.10 | INSTALL-CHECKLIST.md:29 |
| Docker | 缺失 | 本次安装 | 29.5.3 | INSTALL-CHECKLIST.md:30 |
| Docker Compose | 缺失 | 本次安装 | 5.1.4 | INSTALL-CHECKLIST.md:31 |
| colima | 缺失 | 本次安装 | 0.10.2 | INSTALL-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 sevenzip | 26.01 | 解压 dataset.7z(1.4GB → ≈7.5GB JSONL) | INSTALL-CHECKLIST.md:179 |
| ninja | brew install ninja | 1.13.2 | 桌面端 CMake 构建器 | INSTALL-CHECKLIST.md:180 |
| Qt 6 | brew install qt(模块化 formulae) | 6.11.1(qtbase 等拆分包) | 桌面 GUI 框架;cmake 入口 /opt/homebrew/lib/cmake/Qt6 | INSTALL-CHECKLIST.md:181 |
| CEF minimal | 手动下载 spotifycdn | 144.0.27 / Chromium 144(macosarm64) | 桌面内嵌浏览器,解压于 third_party/cef/ | INSTALL-CHECKLIST.md:182 |
| PostgreSQL 16 | docker 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)。 - 背压:
TsvWriter4MiB 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:
| 指标 | 值 | 锚点 |
|---|---|---|
| linesRead | 293,781 | etl-work/etl-stats.json:2 |
| dirtyLines | 0 | etl-work/etl-stats.json:3 |
| podcasts | 293,781 | etl-work/etl-stats.json:4 |
| episodes | 22,867,469 | etl-work/etl-stats.json:5 |
| categories | 354,508 | etl-work/etl-stats.json:6 |
| episodesNoUid | 0 | etl-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:
| 层 | 指标 | 基线 | 优化后 | 提升 |
|---|---|---|---|---|
| API | categories | 1,730ms | 1.6-4ms(memo 命中 ≈1.6ms) | ≈450× |
| API | shows 深页(offset≈294k) | 250-310ms | 4-9ms(任意深度恒定) | ≈40× |
| API | shows 浅页 | 14-22ms | 4-8ms | ≈3× |
| API | languages | 10-19ms | 1.3-1.5ms | ≈10× |
| API | 100 页连翻(×50/页) | — | p50=6ms / p95=9ms / max=28ms | 深度无关 |
| 客户端 | Next 翻页(感知) | 网络往返+渲染 | 0.8-1.0ms | 预取即时渲染 |
| 客户端 | Prev 翻页(感知) | 网络往返+渲染 | 0.5-0.9ms | 页缓存即时渲染 |
| 客户端 | 分类切换(冷/热) | ≥1.7s | 8.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.077ms | perf-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 |
| 进程内 memo | memo(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" |
| 延迟 spinner | 150ms 阈值,快路径零闪烁,慢请求才显示加载态 |
净效果:翻页感知 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 copy | ditto 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 |
| macdeployqt | bundle Qt frameworks + plugins | package.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):
| 顺序 | 对象 | 选项 | 锚点 |
|---|---|---|---|
| 1 | CEF framework 内部 Libraries/*.dylib | --timestamp | package.sh:80-82 |
| 2 | CEF framework 本体 | --timestamp | package.sh:83 |
| 3 | *Helper*.app | --options runtime --entitlements(含 allow-jit 等) | package.sh:86-89 |
| 4 | Qt*.framework | --timestamp | package.sh:92-94 |
| 5 | Frameworks 顶层裸 dylib(libicu/libpcre2/libpng…) | --timestamp | package.sh:98-100 |
| 6 | PlugIns/*.dylib | --timestamp | package.sh:101-103 |
| 7 | 主 app(最后) | --options runtime --entitlements | package.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. 关键决策与经验沉淀
| # | 决策/经验 | 依据 | 锚点 |
|---|---|---|---|
| 1 | Docker 用 colima 无 GUI 方案 | 轻量、无授权、CLI 友好 | INSTALL-CHECKLIST.md:36-54 |
| 2 | colima/PostgreSQL 数据全部落外置盘 | 系统盘 228GB 撑满事故的硬约束;外置盘须先挂载再启 colima | INSTALL-CHECKLIST.md:185-188 |
| 3 | 目录是只读快照 → 预聚合统计表 + keyset 索引 | 计数装载期算一次,翻页恒定毫秒级 | perf-stats.sql:1-4、PERF-REPORT.md:24-34 |
| 4 | cursor 不透明契约 | 内部 union 编码可演进而不破坏客户端;测试只断言契约 | catalog.service.ts:33-37、PERF-REPORT.md:60 |
| 5 | 进程内 memo 而非 Redis | 单实例部署足够;接口已抽象,多实例再换 | memo.ts:1-2、PERF-REPORT.md:65 |
| 6 | CEF 用 flat 纯净拷贝 + 打包前 park 出去 | 避免 macdeployqt 造悬空符号链接致 codesign 失败 | package.sh:26-47 |
| 7 | inside-out 深度签名 + 顶层裸 dylib 补签 | 满足公证的 Developer ID + 安全时间戳要求 | package.sh:63-107 |
| 8 | 先装订 app 再建 DMG(两次公证) | 离线/内网 Gatekeeper 需要 app 自带装订票据 | package.sh:148-172 |
| 9 | HTTP 分发取代 IM 传输 | 规避沙盒应用附加的特殊 quarantine | package.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 表为准。