07 运维与交付手册
适用范围:本机(Mac,Apple Silicon,内网 IP
10.10.20.207)作为播客目录的内网服务端;同事通过PodcastDesktop.app经http://10.10.20.207:3000访问。 受众:负责本机运维与客户端分发的工程师 / AI Agent。 一手事实锚点:docs/SERVICE-OPS.md、DELIVERY.md、podcast-api/scripts/{serve.sh,stop.sh}、desktop/{download-server.mjs,notarize-on-server.sh,package.sh}。 最后核验:2026-06-11。
本册可独立阅读。涉及数据库表结构、API 路由、ETL 重建等细节,引用其他分册;运维与交付的核心动作在本册内自包含。
0. 速查总表
| 动作 | 一键命令 | 锚点 |
|---|---|---|
| 启动全栈 | podcast-api/scripts/serve.sh | serve.sh:1 |
| 停止(保留 VM) | podcast-api/scripts/stop.sh | stop.sh:1 |
| 停止(含 VM,拔盘前) | podcast-api/scripts/stop.sh --vm | stop.sh:27 |
| 打包客户端 DMG(含公证) | cd desktop && NOTARY_PROFILE=goland-notary ./package.sh | package.sh:148 |
| 单独公证已签 DMG | desktop/notarize-on-server.sh <dmg> | notarize-on-server.sh:24 |
| 起内网下载页(8000 端口) | node desktop/download-server.mjs | download-server.mjs:15 |
| 健康检查 | curl -s http://localhost:3000/api/health | serve.sh:24 |
1. 服务架构与依赖链
1.1 依赖链(自底向上)
外置盘挂载 /Volumes/External ← 一切的前提
└─ colima VM (docker 运行时, 4C/6G) ← VM 镜像在外置盘 /Volumes/External/develop/.colima
└─ podcast-pg 容器 (PostgreSQL 16) ← 仅绑 127.0.0.1:5432,数据卷 podcast-pgdata
└─ Next.js 生产服务 (端口 3000, 监听 *) ← LAN 可达
├─ REST API /api/health + /api/v1/*
└─ Web UI /app/index.html ← 桌面端加载的页面
来源:docs/SERVICE-OPS.md:9-16,serve.sh:2-3(脚本头注释明确链路)。
1.2 为什么必须是这个顺序
| 层 | 为什么依赖下层 | 锚点 |
|---|---|---|
| 外置盘 → colima | VM 的磁盘镜像物理存放在外置盘 /Volumes/External/develop/.colima(经 ~/.colima 符号链接),盘不在就无目录可启 | SERVICE-OPS.md:18,22,133 |
| colima → podcast-pg | PostgreSQL 跑在 colima 提供的 docker 运行时里,无 VM 即无 docker | serve.sh:11-12 |
| podcast-pg → Next.js | Next 服务启动后要连库;库未 pg_isready 时 API 会回 3001(数据库未就绪) | serve.sh:18-21,SERVICE-OPS.md:138 |
一句话:数据库镜像在外置盘的 colima 虚拟机磁盘里,所以先盘、再 VM、再库、最后服务(SERVICE-OPS.md:18)。停止顺序严格相反(见 §3)。
1.3 组件清单
| 组件 | 关键参数 | 锚点 |
|---|---|---|
| colima VM | 4C/6G,镜像 /Volumes/External/develop/.colima | SERVICE-OPS.md:22 |
| podcast-pg | PostgreSQL 16,--restart unless-stopped(VM 在则自动拉起),仅绑 127.0.0.1:5432,数据卷 podcast-pgdata | SERVICE-OPS.md:12,23,36,DELIVERY.md:36 |
| 数据规模 | 293,781 播客 + 22,867,469 剧集(serve.sh 启动时回显 podcasts 行数) | SERVICE-OPS.md:23,serve.sh:21 |
| Next.js | 生产构建 npm run start,工作目录 podcast-api/,日志 /tmp/podcast-prod.log | SERVICE-OPS.md:24,serve.sh:28 |
| 技术栈 | next@16.2.7 / react@19.2.4 / drizzle-orm@0.45 / postgres@3.4 / zod@4 / pino@10 / vitest@4 / typescript@5 | 项目一手事实 |
数据库共 8 张表(
podcasts/episodes/podcast_categories/language_stats/category_stats/category_sub_stats/playback_progress/download_tasks)、17 个索引、354,508 条分类关联;API 暴露 13 个路由(/api/health+/api/v1/...),4 个 service(catalog / downloads / playback / health)。细节见对应分册。
2. 启动服务
2.1 一键启动(推荐)
/Volumes/External/develop/musicfab/podcast/podcast-api/scripts/serve.sh
脚本幂等(serve.sh:24 检测到 :3000 已健康则跳过启动),分 4 步(serve.sh:9-38):
| 步骤 | 动作 | 锚点 |
|---|---|---|
| 1/4 colima VM | colima status 失败则 colima start | serve.sh:9-10 |
| 2/4 PostgreSQL | 容器不在则 docker start podcast-pg;缺容器直接 FATAL 退出并提示用 etl/reload.sh 恢复;等待 pg_isready 最多 30 秒,回显 podcasts 行数 | serve.sh:12-21 |
| 3/4 Next.js | 已健康则跳过;无 .next 先 npm run build,再 nohup npm run start > /tmp/podcast-prod.log,轮询健康最多 30 秒 | serve.sh:23-32 |
| 4/4 可达性 | 取 en0/en1 的 LAN IP,分别回显 localhost 与 LAN 的健康/UI HTTP code | serve.sh:34-38 |
成功末行(serve.sh:38):
[serve] READY — clients connect to: http://<LAN_IP>:3000/app/index.html
2.2 手动逐步启动(理解原理 / 排障)
来源 SERVICE-OPS.md:44-63:
export PATH="/opt/homebrew/bin:$PATH"
# ① 确认外置盘已挂载(没有它一切免谈)
ls /Volumes/External/develop/.colima
# ② 启动 colima VM(约 10-30 秒)
colima start
# ③ 启动数据库容器(设了 unless-stopped,VM 起来后通常已自动拉起)
docker start podcast-pg
docker exec podcast-pg pg_isready -U podcast # 期望: accepting connections
# ④ 启动 Next.js 生产服务
cd /Volumes/External/develop/musicfab/podcast/podcast-api
nohup npm run start > /tmp/podcast-prod.log 2>&1 &
# ⑤ 验证
curl http://localhost:3000/api/health # 期望: {"status":"ok",...}
3. 停止服务
停止顺序与启动严格相反:Next 服务 → PG 容器 →(可选)colima VM(stop.sh:2-3)。
3.1 一键停止
# 停 Next + PG 容器(VM 保留,下次启动快)
/Volumes/External/develop/musicfab/podcast/podcast-api/scripts/stop.sh
# 连 VM 一起停(拔外置盘 / 关机前用这个)
/Volumes/External/develop/musicfab/podcast/podcast-api/scripts/stop.sh --vm
| 步骤 | 动作 | 锚点 |
|---|---|---|
| 1/3 Next | lsof -tiTCP:3000 找 PID,kill,2 秒后仍在则 kill -9 升级 | stop.sh:8-18 |
| 2/3 PG 容器 | docker stop podcast-pg(仅当在跑) | stop.sh:20-25 |
| 3/3 colima VM | 仅传 --vm 时执行 colima stop;否则保留 | stop.sh:27-32 |
3.2 手动逐步停止
来源 SERVICE-OPS.md:81-90:
kill $(lsof -tiTCP:3000 -sTCP:LISTEN) # ① 停 Next(按端口找进程)
docker stop podcast-pg # ② 停数据库容器
colima stop # ③(可选)停 VM —— 拔外置盘前必须执行
3.3 ⚠️ 拔外置盘前的强制约束
拔外置盘前必须先
colima stop(或stop.sh --vm)。 VM 磁盘镜像在外置盘上(/Volumes/External/develop/.colima),带电拔盘等于给运行中的数据库断电,可能损坏数据卷podcast-pgdata。来源SERVICE-OPS.md:92。
关机/拔盘前的标准顺序:stop.sh --vm → 确认 colima status 已停 → 再推出外置盘。
4. 状态检查速查命令
来源 SERVICE-OPS.md:96-104:
| 检查项 | 命令 | 期望 |
|---|---|---|
| VM 状态 | colima status | Running |
| 容器状态 | docker ps --format '{{.Names}} {{.Status}}' | podcast-pg Up |
| Next 是否监听 | lsof -iTCP:3000 -sTCP:LISTEN | 有 node 进程 |
| 服务健康 | curl -s http://localhost:3000/api/health | {"status":"ok",...} |
| 全链路(带库查询) | curl -s -H "Authorization: Bearer dev-token" http://localhost:3000/api/v1/podcasts/languages | head -c 120 | JSON 数据 |
| 数据库就绪 | docker exec podcast-pg pg_isready -U podcast | accepting connections |
| 数据行数 | docker exec podcast-pg psql -U podcast -d podcast -tAc 'SELECT count(*) FROM podcasts;' | 293781 |
| 服务日志(pino JSON) | tail -f /tmp/podcast-prod.log | — |
5. 开机自启(launchd 配方)
把 serve.sh 注册为 LaunchAgent,登录后自动拉起全栈(SERVICE-OPS.md:106-127):
cat > ~/Library/LaunchAgents/com.podcastfab.server.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.podcastfab.server</string>
<key>ProgramArguments</key><array>
<string>/Volumes/External/develop/musicfab/podcast/podcast-api/scripts/serve.sh</string>
</array>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/tmp/podcast-serve.log</string>
<key>StandardErrorPath</key><string>/tmp/podcast-serve.log</string>
</dict></plist>
EOF
launchctl load ~/Library/LaunchAgents/com.podcastfab.server.plist
| 字段 | 含义 |
|---|---|
RunAtLoad | 登录加载即运行 |
StandardOutPath / StandardErrorPath | LaunchAgent 自身日志 /tmp/podcast-serve.log(与服务日志 /tmp/podcast-prod.log 区分) |
注意事项(SERVICE-OPS.md:127):
- 外置盘需在登录前已挂载,否则
serve.sh第 1 步即失败。 - 防睡眠:接电源 + 系统设置关闭"自动进入睡眠";或配合
caffeinate -s。 - 卸载:
launchctl unload ~/Library/LaunchAgents/com.podcastfab.server.plist。
6. 客户端交付
6.1 交付物
| 物件 | 路径 | 说明 | 锚点 |
|---|---|---|---|
| 安装包 | desktop/dist/PodcastDesktop.dmg(约 177MB) | Developer ID 签名(Beijing Goland Tech Co., Ltd. / Team 5RCGYAV62A),公证后双击直开 | DELIVERY.md:10 |
| 打包脚本 | desktop/package.sh | 拷贝→烧录服务地址→macdeployqt→深度签名→DMG→(可选)公证,一条命令 | DELIVERY.md:11,package.sh:2-6 |
| 公证脚本 | desktop/notarize-on-server.sh | 对已签 DMG 单独执行公证+装订 | notarize-on-server.sh:1 |
| 下载服务 | desktop/download-server.mjs | 内网 HTTP 下载页(默认 8000 端口) | download-server.mjs:7,15 |
6.2 App 技术形态(已签名公证)
- 架构:仅 Apple Silicon (arm64) + macOS,Intel/Windows 不在交付范围(
DELIVERY.md:19)。 - 自包含:Qt 6.11 框架 + Cocoa 插件 + CEF framework 全打进 bundle,
@executable_path引用,零 Homebrew 依赖(DELIVERY.md:16,package.sh:54-60验证无/opt/homebrew残留)。 - 签名:inside-out 深度签名(CEF 框架库→CEF 框架→5 个 Helper→Qt 框架/裸 dylib→插件→主程序),硬化运行时 + CEF entitlements(
allow-jit/allow-unsigned-executable-memory/disable-library-validation)(package.sh:63-110)。codesign --verify --deep --strict✅(DELIVERY.md:17)。
6.3 服务地址五级解析(客户端)
客户端按优先级从高到低解析服务地址(DELIVERY.md:21-28):
| 级 | 来源 | 说明 |
|---|---|---|
| 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,package.sh:31-37烧录第 4 级),或让用户按第 3 级放一行文本文件(DELIVERY.md:28)。
6.4 内网 HTTP 下载链接(download-server.mjs,端口 8000)
独立于 API 服务的轻量下载服务,专供分发 DMG(download-server.mjs:1-7):
node /Volumes/External/develop/musicfab/podcast/desktop/download-server.mjs # 默认 8000
node /Volumes/External/develop/musicfab/podcast/desktop/download-server.mjs 9000 # 自定义端口
| 特性 | 说明 | 锚点 |
|---|---|---|
| 端口 | 默认 8000(process.argv[2] 可覆盖),与 API 的 3000 隔离,互不干扰 | download-server.mjs:15,7 |
| 绑定 | 0.0.0.0,LAN 可达;LAN IP 优先取 10.* 网段网卡 | download-server.mjs:20-32,123 |
| 来源文件 | desktop/dist/PodcastDesktop.dmg;缺文件启动时告警提示先 package.sh | download-server.mjs:17,41 |
路由 / | 暗色下载页(含安装步骤 + SHA-256) | download-server.mjs:84-89 |
路由 /PodcastDesktop.dmg | 强制下载(Content-Disposition: attachment),支持 HTTP Range 断点续传(206/416) | download-server.mjs:91-118 |
| 完整性 | 启动时一次性算 SHA-256,下载页展示供校验 | download-server.mjs:34-39,77 |
启动后控制台回显(download-server.mjs:124-127):
[download] 下载页: http://<IP>:8000/
[download] 直链: http://<IP>:8000/PodcastDesktop.dmg
[download] sha256: <hex>
下载页明确提示用户:用浏览器点按钮下载,勿用微信/钉钉转发(
download-server.mjs:72),原因见 §7。
6.5 公证流程
公司公证凭据(macapple@dvdfab.cn / Team 5RCGYAV62A,与 StreamFab 同套)已存入本机 keychain profile goland-notary(DELIVERY.md:43)。
方式 A — package.sh 一键(打包+公证+装订):
cd desktop && NOTARY_PROFILE=goland-notary ./package.sh
设了 NOTARY_PROFILE 时走 7/8→8/8 双提交流程(package.sh:148-174):
| 步 | 动作 | 锚点 |
|---|---|---|
| 1 | 把 app 打 zip 提交 notarytool submit --wait | package.sh:149-153 |
| 2 | stapler staple 票据装订进 app bundle(离线可过 Gatekeeper) | package.sh:154-156 |
| 3 | 从已装订的 app 构建 DMG | package.sh:158-159 |
| 4 | DMG 第二次提交公证(DMG 文件本身还没记录) | package.sh:160-163 |
| 5 | 校验 DMG 内 app 副本确实带票据,否则 FATAL | package.sh:166-173 |
为什么双提交:app 与 DMG 是两个独立产物,各自需要一次公证记录才能 staple;离线/被墙的内网机靠装订票据过 Gatekeeper(
package.sh:117-119,160-161)。
方式 B — notarize-on-server.sh(对已签 DMG 单独公证):
# 服务器已有 keychain profile
NOTARY_PROFILE=goland-notary desktop/notarize-on-server.sh PodcastDesktop.dmg
# 或直接给凭据(凭据已内置默认值)
APPLE_ID=xxx APP_PASSWORD=xxxx-xxxx-xxxx-xxxx TEAM_ID=5RCGYAV62A \
desktop/notarize-on-server.sh PodcastDesktop.dmg
流程(notarize-on-server.sh:35-80):0/4 预检签名(codesign --verify,不重签)→ 1/4 notarytool submit --wait → 2/4 stapler staple+validate → 3/4 重命名 <原名>_notarized.dmg → 4/4 spctl --assess 终验。
⚠️ 凭据安全:
notarize-on-server.sh内置公司公证凭据(notarize-on-server.sh:31-33),禁止外发 / 提交公共仓库(DELIVERY.md:49,notarize-on-server.sh:30)。 ⚠️ 切勿重签:此 app 在硬化运行时下依赖 JIT entitlements,任何重签流程若丢失allow-jit会导致 Chromium 渲染进程直接崩溃(notarize-on-server.sh:10-12)。
首次公证被拒的历史教训:27 个 macdeployqt 拷入的 Qt 依赖裸 dylib 未签 Developer ID 被 Apple 拒;package.sh 已补签该层(package.sh:95-100)。未来再被拒,用 xcrun notarytool log <id> --keychain-profile goland-notary 看逐文件报告(DELIVERY.md:51)。
首次设置 keychain profile(package.sh:179):
xcrun notarytool store-credentials <profile> --apple-id <id> --team-id 5RCGYAV62A --password <app-specific-password>
6.6 同事安装步骤(给用户)
来源 DELIVERY.md:53-59,download-server.mjs:71-76:
- 用浏览器从内网下载页(§6.4)下载
PodcastDesktop.dmg,勿用微信/钉钉转发。 - 双击挂载,把 PodcastDesktop 拖进 Applications。
- 首次打开:右键图标 → 打开 → 打开(公证完成的版本此步可消失)。
- 应用自动连
http://10.10.20.207:3000;连不上时确认同一内网、服务器已跑serve.sh,或在~/Library/Application Support/Podcast Desktop/server_url.txt写入正确地址。
7. 分发与 quarantine
7.1 不同传输方式的 quarantine 差异
macOS 对从网络/外部来源获得的文件打 com.apple.quarantine 扩展属性,Gatekeeper 据此拦截。不同传输方式行为不同:
| 传输方式 | quarantine 行为 | 推荐度 |
|---|---|---|
| 浏览器 HTTP 下载(download-server.mjs) | 打 quarantine,但 DMG 已公证装订,Gatekeeper 校验票据通过,正常可开 | ✅ 推荐 |
scp / 命令行拷贝 | 通常不打 quarantine(非"下载"语义),直接可开 | ✅ 可用 |
| AirDrop | 打 quarantine;已公证 DMG 仍可通过 | ⚠️ 可用 |
| 微信 / 钉钉等沙盒应用转发 | 沙盒应用把文件落在受限容器并打 quarantine,常导致 "can't be opened" | ❌ 避免 |
下载页已显式提示"请勿用微信/钉钉等转发——会被系统拦"(
download-server.mjs:72)。一手代码/文档未对 AirDrop/scp 的 quarantine 行为逐项实测说明,上表传输差异属 macOS 通用行为归纳,以"用浏览器从下载页获取已公证 DMG"为交付标准路径。
7.2 沙盒应用传输导致 "can't be opened" 的成因与修复
成因:微信/钉钉等沙盒应用接收文件时,文件被写入应用沙盒容器并附带 com.apple.quarantine(且来源标记为该沙盒 app)。用户从中拖出运行时,Gatekeeper 因 quarantine 来源不可信而拦截,提示应用"can't be opened"。
修复(移除 quarantine 属性):
xattr -dr com.apple.quarantine /Applications/PodcastDesktop.app
# 或对收到的 dmg:
xattr -dr com.apple.quarantine ~/Downloads/PodcastDesktop.dmg
-d 删除属性、-r 递归整个 bundle。执行后重新双击即可打开。
根治办法是从 §6.4 的浏览器下载页获取,而非沙盒应用转发。
8. 故障排查表(症状 → 原因 → 处置)
8.1 服务端(来源 SERVICE-OPS.md:131-139)
| 症状 | 原因 | 处置 |
|---|---|---|
colima start 报找不到目录 | 外置盘未挂载(~/.colima 指向外置盘的符号链接) | 挂载外置盘后重试 |
docker start podcast-pg 报 no such container | 容器被删(数据卷 podcast-pgdata 仍在) | 用 DELIVERY.md 的 docker run 命令重建(挂同名卷,数据无损) |
| 数据卷也丢失 | — | 重新入库 podcast-api/scripts/etl/reload.sh <etl-work目录>(约 20 分钟);TSV 也没了走 run-etl.sh 全量重建 |
:3000 被占用 | 旧进程未退 | kill $(lsof -tiTCP:3000 -sTCP:LISTEN) 后重启 |
| 客户端连不上 | 防火墙 / 不同网段 | 确认同一内网;macOS 防火墙放行 node 入站;curl http://10.10.20.207:3000/api/health 自查 |
| API 返回 3001 | 数据库未就绪 | docker exec podcast-pg pg_isready -U podcast,未就绪等几秒或 docker logs podcast-pg |
| 服务器睡眠后断连 | Mac 进入睡眠 | 接电源 + 关闭自动睡眠,或 caffeinate -s |
serve.sh 第 2 步 FATAL 退出 | 缺 podcast-pg 容器 | 按提示用 scripts/etl/reload.sh 或 pg dump 恢复(serve.sh:14-16) |
8.2 客户端 / 分发
| 症状 | 原因 | 处置 |
|---|---|---|
| app "can't be opened" | 经微信/钉钉等沙盒应用转发,带 quarantine | xattr -dr com.apple.quarantine /Applications/PodcastDesktop.app(§7.2);或改从浏览器下载页获取 |
| 首次打开被 Gatekeeper 拦 | 未公证版 / 新机首次 | 右键图标 → 打开 → 打开(DELIVERY.md:56);正式分发应公证后 staple |
| 公证被 Apple 拒绝 | 新增依赖裸 dylib 未签 Developer ID | xcrun notarytool log <id> --keychain-profile goland-notary 看逐文件报告,补签后重打包(DELIVERY.md:51) |
| 下载服务启动告警 cannot read DMG | dist/PodcastDesktop.dmg 不存在 | 先 desktop/package.sh 打包(download-server.mjs:41) |
| app 连不上服务器 | 网段不同 / 服务未启 / 地址错 | 同一内网 + serve.sh 已跑;或写 ~/Library/Application Support/Podcast Desktop/server_url.txt(§6.3 第 3 级) |
| 重签后渲染进程崩溃 | 重签丢失 allow-jit entitlements | 切勿重签;回开发机用 package.sh 重新走签名流程(notarize-on-server.sh:10-12) |
9. 相关文件索引
| 文件 | 用途 |
|---|---|
podcast-api/scripts/serve.sh | 一键启动全栈 |
podcast-api/scripts/stop.sh | 一键停止(--vm 连 VM 一起停) |
podcast-api/scripts/etl/ | 数据重建(reload.sh / run-etl.sh / schema) |
podcast-api/.env.local | 服务配置(DATABASE_URL、token 白名单) |
desktop/package.sh | 客户端打包(含公证一键) |
desktop/notarize-on-server.sh | 对已签 DMG 单独公证(含凭据,禁外发) |
desktop/download-server.mjs | 内网 HTTP 下载服务(默认 8000) |
desktop/dist/PodcastDesktop.dmg | 交付安装包 |
docs/SERVICE-OPS.md | 服务端运维原始手册 |
DELIVERY.md | 客户端分发原始说明 |
/tmp/podcast-prod.log | 服务运行日志(pino JSON) |
/tmp/podcast-serve.log | LaunchAgent 自身日志 |
已知边界(来源 DELIVERY.md:61-65)
- 鉴权为内网固定 token(UI 内置
dev-token),不适合公网暴露;外网访问需走架构文档的 JWT 方案(未实现/待确认)。 - 服务器休眠会断服:关闭自动睡眠或
caffeinate -s。 - 音频/封面来自外部 CDN,客户端需可出公网(或后续加代理端点,未实现)。