# 部署 > CD 由 `.gitea/workflows/ci.yml` 的后半段做:推主干 → 五道门禁全绿 → 三份构建 → 发到 runner 这台机上。 > 这份文档写的是**机器那一侧**:一次性要准备什么、nginx 怎么配、出事怎么回滚、什么命令绝对不能在生产上跑。 ## 这套东西部署起来长什么样 ``` 浏览器 ──HTTPS──► nginx ┬── / → $WEB_ROOT(apps/web/dist 的静态文件,SPA) └── /api/* → 127.0.0.1:8787(pm2 托的 node 进程) /api/stream 是一条 SSE 长连接,单独配 $DEPLOY_DIR/ releases// 一次发布的完整目录(产物 + db/ + 自己的 node_modules) current -> releases/ **只是给人看的指针**,pm2 不经它启动(见下面「为什么不用软链启动」) data/attachments/ 来信原件。**唯一一份不在数据库里的持久数据,定了不能改** backups/<时刻>-.sql.gz 每次部署在迁移之前 pg_dump 一份,留最近 10 份 .secret-fingerprint JCAI_SECRET_KEY 的指纹,换了钥匙下次部署当场红 ``` 治理台上配的一切(模型实例连密钥、工具连接凭证、白名单、活动包连代码产物、流程包、触发器、 员工、台账)**全在数据库里**,换代码重启不丢。所以「发布」这件事只换三样:node 产物、静态文件、库结构。 ## 一次性准备 ```bash # ① 两个目录,runner 用户要写得动 sudo mkdir -p /opt/jcai /var/www/jcai sudo chown -R "$(whoami)" /opt/jcai /var/www/jcai # 换成 runner 跑的那个用户 # ② 确认 runner 用户的非交互 shell 里有这两个命令 # (pm2 常装在 nvm/fnm 的 shim 里,而非交互 shell 读不到 ~/.zshrc —— 那会让部署红在前置断言) ssh @<主机> 'command -v pm2 rsync' # ③ 生成密钥。**生成一次,永久不变** —— 换了它,库里所有凭证密文全部解不开, # 而且不在启动时炸,是第一次真调模型才炸。 openssl rand -base64 32 # ④ 备份手段二选一:主机上装 postgresql-client(有 pg_dump), # 或者库在容器里时把容器名填进 Variables 的 PG_CONTAINER ``` **不需要开机自启**(按你的口径)。要的话:`pm2 startup` 生成 systemd 单元(那一步要 sudo)+ `pm2 save`。 ## Gitea 上要填的 **仓库设置 → Actions → Variables**(不敏感,可随时改) | 名 | 例 | 说明 | |---|---|---| | `DEPLOY_DIR` | `/opt/jcai` | 发布根目录 | | `PM2_APP` | `jcai-api` | pm2 里的应用名 | | `WEB_ROOT` | `/var/www/jcai` | nginx 的静态根 | | `DEPLOY_PORT` | `8787` | 不填就是 8787 | | `APP_NODE_ENV` | `production` | 不填就是 production。**没有 HTTPS 时必须置空**,见下 | | `PG_CONTAINER` | `jcai-pg` | 只在主机没有 `pg_dump`、库跑在容器里时才要 | **Secrets** | 名 | 说明 | |---|---| | `PROD_DATABASE_URL` | 指向 `jcai_prod`。首次部署要建库,账号需 CREATEDB;也可以你先手建好 | | `JCAI_SECRET_KEY` | `openssl rand -base64 32`。**永久不变** | | `JCAI_SEED_PASSWORD` | 首次播种时给五个员工与 AI 账号设的登录口令 | | `JCAI_DEEPSEEK_API_KEY` | 模型密钥。没有它,模型实例会建成「未绑定」,而案例活动每一拍都要 `agent.decide` —— 一步都跑不动 | ## nginx 放 `/etc/nginx/conf.d/jcai.conf`(或 `sites-available` + 软链),改掉 `server_name` 与证书路径: ```nginx server { listen 80; server_name jcai.example.internal; # 内网也要 HTTPS:后端在 production 下给登录 cookie 加 Secure, # 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效(见排障表第一行) return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; # nginx < 1.25 写成:listen 443 ssl http2; server_name jcai.example.internal; ssl_certificate /etc/nginx/certs/jcai.crt; ssl_certificate_key /etc/nginx/certs/jcai.key; root /var/www/jcai; # = WEB_ROOT index index.html; client_max_body_size 16m; # 上传走 base64,接口自己封顶 8MB # 带指纹的资源可长缓存 location /assets/ { try_files $uri =404; expires 1y; add_header Cache-Control "public, immutable"; } # SSE —— 整套界面的实时刷新全靠这一条长连接。 # nginx 默认会缓冲响应,缓冲之后这条流就整个不动了(卡片要手动刷新才变)。 location = /api/stream { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_cache off; gzip off; chunked_transfer_encoding off; proxy_read_timeout 1h; proxy_send_timeout 1h; } location /api/ { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; } # 三个台面是同一个 SPA:/(工作台)· /design(设计中心)· /ops(运营中心), # 外加 /gov(旧链,前端自己跳 /ops)。刷新任意子路径都要吐 index.html。 # 前端的 vite base 没配,所以资源引用是绝对路径 —— **必须挂在域名根,不能挂子路径**。 location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-store"; } } ``` `sudo nginx -t && sudo nginx -s reload`。 ## 部署时都发生了什么 前置断言 → 备份 → 确保库在 → 发布产物 → 迁移 → 迁移到位断言 → 判首次/升级 → (首次播种 | 装活动包) → 活动包回查 → 发前端 → 重启 pm2 → 探活 → 收尾。 几处口径值得知道: - **备份在换代码之前**,而且备不成就红。迁移只有 up 没有 down,dump 是唯一的回滚手段。 - **`db:seed` 只在首次(空库)跑一次**。升级时跑它会把治理台配好的东西打回播种值: 工具的连接端点变回 `replay://crm`、工具白名单被全量替换、模型实例的 baseUrl/model 被覆盖。 - **活动包只在版本变了才装**。版本没升但产物内容变了会**当场红** —— 因为安装恒新增, 同版本重装是 no-op,库里还是旧产物,而日志会照样说「已装」、退出码 0(CLAUDE.md 铁规矩 7)。 - **装完回查库**:`install-activities` 的所有失败路径都只打一行日志然后 continue,**永远 exit 0**。 - **探活验 body 不验状态码**:`/api/health` 在库挂掉时照样返 200,只是 body 里 `db:"down"`。 ### 为什么 pm2 不用 `current` 软链启动 `apps/api/src/app/main.ts` 结尾的入口守卫是字符串比较: ```ts if (import.meta.url === `file://${process.argv[1]}`) { await boot(); } ``` Node 解析主模块时会 realpath,而 `process.argv[1]` 保留你传进去的原样。 所以经软链启动时两边不等,`boot()` 永不执行 —— **进程零输出、退出码 0,看着像"跑完了"**(实测过)。 CD 因此一律用 `releases/` 的真实路径起;`current` 只是给人看的指针。 ## 回滚 ```bash ls -1t /opt/jcai/releases # 看有哪些版本 PREV=/opt/jcai/releases/<上一版的 sha> pm2 delete jcai-api NODE_ENV=production PORT=8787 \ DATABASE_URL='' \ JCAI_SECRET_KEY='<同一把钥匙>' \ JCAI_SEED_PASSWORD='<...>' \ JCAI_ATTACHMENT_ROOT=/opt/jcai/data/attachments \ pm2 start "$PREV/apps/api/dist/main.js" --name jcai-api --cwd "$PREV" --update-env ln -sfn "$PREV" /opt/jcai/current rsync -a --delete "$PREV/apps/web/dist/" /var/www/jcai/ # 前端也要跟着退 ``` **数据库不跟着回滚** —— 迁移没有 down。要连库一起退,只能拿 `backups/` 里那份 dump 恢复: ```bash gzip -dc /opt/jcai/backups/<时刻>-.sql.gz | psql '' ``` ## 生产上绝对不能跑的 | 命令 | 后果 | |---|---| | `pnpm --filter @app/api reset-runs` | TRUNCATE 33 张 `sys` 表 + `ledger` 下几乎所有表(还带 cascade):所有任务、工作流、卡、事件、trace、上传件、沟通记录、产出的资料、台账单据全没,**不可恢复** | | `pnpm db:seed` | 重写整套治理台配置(见上) | | 改 `JCAI_SECRET_KEY` | 库里所有凭证密文全部解不开,且要等第一次真调模型才炸 | | 改 `JCAI_ATTACHMENT_ROOT` | 历史来信原件的指针全部指空(存进库的是 `file://绝对路径`) | ## 排障速查 | 症状 | 多半是 | |---|---| | 页面开得了,一登录就弹回登录页,**前后端日志全绿** | HTTP 下的 `Secure` cookie:`POST /api/login` 返 200、服务端会话也真写了,但浏览器直接丢掉 cookie。上 HTTPS,或把 Variables 的 `APP_NODE_ENV` 置空(代价:凭证退到开发派生密钥加密) | | pm2 说 online,但端口没人听、日志一行都没有 | 入口守卫:启动路径经过了软链。用 `releases/` 的真实路径起 | | 部署全绿,但系统里一个活动都没有 | `install-activities` 永远 exit 0。看「部署 · 活动包回查」那一步 —— 它才说得出实情 | | 界面不实时刷新,要手动刷新才变 | nginx 把 `/api/stream` 缓冲了。照上面 SSE 那段配 | | 迁移「完成」了但新表没有 | drizzle 只比最后一条的时间戳当水位线,不逐条比 hash。新迁移在 `db/migrations/meta/_journal.json` 里的 `when` 若 ≤ 水位线会被永久静默跳过。「部署 · 迁移到位断言」就是拦这个的 | | 第一次真调模型才炸「解不开」 | `JCAI_SECRET_KEY` 换过了 | | 换版之后浏览器还是旧界面 | `index.html` 被缓存了。照上面 `location /` 那段配 `no-store` |