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.shserve.sh:1
停止(保留 VM)podcast-api/scripts/stop.shstop.sh:1
停止(含 VM,拔盘前)podcast-api/scripts/stop.sh --vmstop.sh:27
打包客户端 DMG(含公证)cd desktop && NOTARY_PROFILE=goland-notary ./package.shpackage.sh:148
单独公证已签 DMGdesktop/notarize-on-server.sh <dmg>notarize-on-server.sh:24
起内网下载页(8000 端口)node desktop/download-server.mjsdownload-server.mjs:15
健康检查curl -s http://localhost:3000/api/healthserve.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 为什么必须是这个顺序

层为什么依赖下层锚点
外置盘 → colimaVM 的磁盘镜像物理存放在外置盘 /Volumes/External/develop/.colima(经 ~/.colima 符号链接),盘不在就无目录可启SERVICE-OPS.md:18,22,133
colima → podcast-pgPostgreSQL 跑在 colima 提供的 docker 运行时里,无 VM 即无 dockerserve.sh:11-12
podcast-pg → Next.jsNext 服务启动后要连库;库未 pg_isready 时 API 会回 3001(数据库未就绪)serve.sh:18-21,SERVICE-OPS.md:138

一句话:数据库镜像在外置盘的 colima 虚拟机磁盘里,所以先盘、再 VM、再库、最后服务(SERVICE-OPS.md:18)。停止顺序严格相反(见 §3)。

1.3 组件清单

组件关键参数锚点
colima VM4C/6G,镜像 /Volumes/External/develop/.colimaSERVICE-OPS.md:22
podcast-pgPostgreSQL 16,--restart unless-stopped(VM 在则自动拉起),仅绑 127.0.0.1:5432,数据卷 podcast-pgdataSERVICE-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.logSERVICE-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 VMcolima status 失败则 colima startserve.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 codeserve.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 Nextlsof -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 statusRunning
容器状态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 120JSON 数据
数据库就绪docker exec podcast-pg pg_isready -U podcastaccepting 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 / StandardErrorPathLaunchAgent 自身日志 /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用户机覆盖(改地址无需重装,放一行文本即可)
4app 内置 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.shdownload-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 --waitpackage.sh:149-153
2stapler staple 票据装订进 app bundle(离线可过 Gatekeeper)package.sh:154-156
3从已装订的 app 构建 DMGpackage.sh:158-159
4DMG 第二次提交公证(DMG 文件本身还没记录)package.sh:160-163
5校验 DMG 内 app 副本确实带票据,否则 FATALpackage.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:

  1. 用浏览器从内网下载页(§6.4)下载 PodcastDesktop.dmg,勿用微信/钉钉转发。
  2. 双击挂载,把 PodcastDesktop 拖进 Applications。
  3. 首次打开:右键图标 → 打开 → 打开(公证完成的版本此步可消失)。
  4. 应用自动连 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"经微信/钉钉等沙盒应用转发,带 quarantinexattr -dr com.apple.quarantine /Applications/PodcastDesktop.app(§7.2);或改从浏览器下载页获取
首次打开被 Gatekeeper 拦未公证版 / 新机首次右键图标 → 打开 → 打开(DELIVERY.md:56);正式分发应公证后 staple
公证被 Apple 拒绝新增依赖裸 dylib 未签 Developer IDxcrun notarytool log <id> --keychain-profile goland-notary 看逐文件报告,补签后重打包(DELIVERY.md:51)
下载服务启动告警 cannot read DMGdist/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.logLaunchAgent 自身日志

已知边界(来源 DELIVERY.md:61-65)

  • 鉴权为内网固定 token(UI 内置 dev-token),不适合公网暴露;外网访问需走架构文档的 JWT 方案(未实现/待确认)。
  • 服务器休眠会断服:关闭自动睡眠或 caffeinate -s。
  • 音频/封面来自外部 CDN,客户端需可出公网(或后续加代理端点,未实现)。