This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/部署.md
T
xcsandClaude Fable 5 f52edd5383 CI/CD 四件与两份文档入库 —— debt.md 早已写「已接」而文件一直没跟踪
.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
2026-08-26 14:34:19 +08:00

9.7 KiB

部署

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 产物、静态文件、库结构。

一次性准备

# ① 两个目录,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 与证书路径:

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 结尾的入口守卫是字符串比较:

if (import.meta.url === `file://${process.argv[1]}`) { await boot(); }

Node 解析主模块时会 realpath,而 process.argv[1] 保留你传进去的原样。 所以经软链启动时两边不等,boot() 永不执行 —— 进程零输出、退出码 0,看着像"跑完了"(实测过)。 CD 因此一律用 releases/<sha> 的真实路径起;current 只是给人看的指针。

回滚

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 恢复:

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