Archived
.gitea/workflows/ci.yml · tools/ci.mjs · tools/cd.mjs · docs/部署.md:推主干跑五道门禁 +
从空库起的集成测试 + 三份构建,全绿后部署到 runner 那台机(机密一律 secrets.*,机器相关
一律 vars.*,文件里不写死)。debt.md 在 9848e2f 就点名了这几个文件,远端却从未有过 .gitea。
docs/keycloak-backend-guide.md:ADR-012 引为出处的后端接入手册(只有 clientId 与「有/没有」,
无密钥)。docs/数据域字段说明-E04-I01.md:设计中心数据域那一屏的字段说明。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq
209 lines
9.7 KiB
Markdown
209 lines
9.7 KiB
Markdown
# 部署
|
|
|
|
> 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/<commit sha>/ 一次发布的完整目录(产物 + db/ + 自己的 node_modules)
|
|
current -> releases/<sha> **只是给人看的指针**,pm2 不经它启动(见下面「为什么不用软链启动」)
|
|
data/attachments/ 来信原件。**唯一一份不在数据库里的持久数据,定了不能改**
|
|
backups/<时刻>-<sha>.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 <runner用户>@<主机> '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/<sha>` 的真实路径起;`current` 只是给人看的指针。
|
|
|
|
## 回滚
|
|
|
|
```bash
|
|
ls -1t /opt/jcai/releases # 看有哪些版本
|
|
PREV=/opt/jcai/releases/<上一版的 sha>
|
|
|
|
pm2 delete jcai-api
|
|
NODE_ENV=production PORT=8787 \
|
|
DATABASE_URL='<PROD_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/<时刻>-<sha>.sql.gz | psql '<PROD_DATABASE_URL>'
|
|
```
|
|
|
|
## 生产上绝对不能跑的
|
|
|
|
| 命令 | 后果 |
|
|
|---|---|
|
|
| `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/<sha>` 的真实路径起 |
|
|
| 部署全绿,但系统里一个活动都没有 | `install-activities` 永远 exit 0。看「部署 · 活动包回查」那一步 —— 它才说得出实情 |
|
|
| 界面不实时刷新,要手动刷新才变 | nginx 把 `/api/stream` 缓冲了。照上面 SSE 那段配 |
|
|
| 迁移「完成」了但新表没有 | drizzle 只比最后一条的时间戳当水位线,不逐条比 hash。新迁移在 `db/migrations/meta/_journal.json` 里的 `when` 若 ≤ 水位线会被永久静默跳过。「部署 · 迁移到位断言」就是拦这个的 |
|
|
| 第一次真调模型才炸「解不开」 | `JCAI_SECRET_KEY` 换过了 |
|
|
| 换版之后浏览器还是旧界面 | `index.html` 被缓存了。照上面 `location /` 那段配 `no-store` |
|