From f52edd5383e406251dbc044a201ed35230aee0c6 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 14:26:53 +0800 Subject: [PATCH] =?UTF-8?q?CI/CD=20=E5=9B=9B=E4=BB=B6=E4=B8=8E=E4=B8=A4?= =?UTF-8?q?=E4=BB=BD=E6=96=87=E6=A1=A3=E5=85=A5=E5=BA=93=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20debt.md=20=E6=97=A9=E5=B7=B2=E5=86=99=E3=80=8C?= =?UTF-8?q?=E5=B7=B2=E6=8E=A5=E3=80=8D=E8=80=8C=E6=96=87=E4=BB=B6=E4=B8=80?= =?UTF-8?q?=E7=9B=B4=E6=B2=A1=E8=B7=9F=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .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 Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq --- .gitea/workflows/ci.yml | 320 ++++++++++ docs/keycloak-backend-guide.md | 1065 ++++++++++++++++++++++++++++++++ docs/数据域字段说明-E04-I01.md | 458 ++++++++++++++ docs/部署.md | 208 +++++++ tools/cd.mjs | 486 +++++++++++++++ tools/ci.mjs | 206 ++++++ 6 files changed, 2743 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 docs/keycloak-backend-guide.md create mode 100644 docs/数据域字段说明-E04-I01.md create mode 100644 docs/部署.md create mode 100644 tools/cd.mjs create mode 100644 tools/ci.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a9c36d3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,320 @@ +# JCAI-WOS CI —— 五道门禁 + 从空库起的集成测试 + 生产构建 +# +# 只在主干跑:push 到 mvp-foundation(PR 合并产生的那一推同样算)。 +# runner 是 host 模式,label `ubuntu-latest`;宿主机自带 node22 / pnpm10 / docker。 +# 门禁一律以退出码验(CLAUDE.md 铁规矩 1),不以 echo 验。 +name: CI + +on: + push: + branches: [mvp-foundation] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + # 国内镜像源:pnpm 认 npm 的 env 形式 + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + COREPACK_NPM_REGISTRY: https://registry.npmmirror.com + # NODE_ENV 一律不设 production —— secret-box.ts 在 production 下拿不到 + # JCAI_SECRET_KEY 会直接抛;CI 要的是 dev 兜底那条路。 + +jobs: + ci: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + # ── 取代码:纯 git,一个第三方 action 都不依赖 ───────────────────── + - name: 取代码 + env: + T1: ${{ secrets.GITEA_TOKEN }} + T2: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE" + git init -q . + git config --global --add safe.directory "$GITHUB_WORKSPACE" + URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote set-url origin "$URL" 2>/dev/null || git remote add origin "$URL" + + TOKEN="${T1:-${T2:-}}" + HEADER="http.${GITHUB_SERVER_URL}/.extraheader" + if [ -n "$TOKEN" ]; then + AUTH="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + git config --local "$HEADER" "Authorization: basic $AUTH" + fi + + fetch() { git fetch --depth=1 --no-tags origin "+${1}:refs/ci/head"; } + + # 两档退让:先按 commit(服务端未必允许按 SHA 取),再按分支 ref + if ! fetch "$GITHUB_SHA" && ! fetch "$GITHUB_REF"; then + # 换一种带凭据的写法再试一遍(extraheader 与 token 进 URL,Gitea 各版本认的不一样) + if [ -z "$TOKEN" ]; then + echo '取代码失败:没有可用 token,匿名也拉不下来'; exit 1 + fi + git config --local --unset-all "$HEADER" || true + SCHEME="${GITHUB_SERVER_URL%%://*}" + HOST="${GITHUB_SERVER_URL#*://}" + git remote set-url origin "${SCHEME}://${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" + fetch "$GITHUB_SHA" || fetch "$GITHUB_REF" + fi + + git checkout -qf refs/ci/head + # 留着 node_modules(host 模式工作区跨次复用,留着就等于一份缓存), + # 其余未跟踪物一律清掉 —— 上一跑的 dist/.build/.api-tmp 不许影响这一跑 + git clean -ffdx -e node_modules + echo "HEAD=$(git rev-parse HEAD) 期望=${GITHUB_SHA}" + + - name: 工具链断言 + run: | + set -euo pipefail + node -v + pnpm -v + node -e 'const [a, b] = process.versions.node.split(".").map(Number); + if (a < 22 || (a === 22 && b < 6)) { + console.error("需要 Node >= 22.6 —— gen:tiers 用 --experimental-strip-types"); + process.exit(1); + }' + case "$(pnpm -v)" in + 10.*) ;; + *) echo "需要 pnpm 10.x(仓库钉的是 10.33.0)"; exit 1 ;; + esac + + - name: 装依赖 + run: pnpm install --frozen-lockfile + + # ── 五道门禁,逐条一步 ──────────────────────────────────────────── + - name: 门禁前置 · 生成层级表 + run: pnpm gen:tiers + + - name: 门禁 1/5 · 类型检查 + run: pnpm typecheck + + - name: 门禁 2/5 · Lint + run: pnpm lint + + - name: 门禁 3/5 · 依赖门禁 + run: pnpm depcruise + + - name: 门禁 4/5 · 契约快照 + run: pnpm api:check + + # api:check 自己那道 git 比对是**会失败开口**的(git 一抛错就印「非 git 仓库, + # 跳过比对」然后 exit 0)。这一步自己再 status 一遍,吞不掉; + # 顺带管住 gen:tiers 的入库产物 tools/depcruise-tiers.generated.cjs。 + # 位置在测试之前 —— 测试期的 seed 可能往 .data/ 落文件,那不算漂移。 + - name: 生成物无漂移 + run: | + set -euo pipefail + DIRTY="$(git status --porcelain)" + if [ -n "$DIRTY" ]; then + echo "$DIRTY" + echo '---' + git --no-pager diff --stat + echo '门禁跑完工作树有改动:入库的生成物与现生成的不一致。' + echo '本地跑 pnpm gates,把 api-report/ 与 tools/depcruise-tiers.generated.cjs 一并提交。' + exit 1 + fi + echo '工作树干净 ✔' + + # ── 库:一次性,跑完就拆 ────────────────────────────────────────── + - name: 建本次跑的一次性库 + id: db + env: + CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} + run: node tools/ci.mjs db-create "jcai_ci_${GITHUB_RUN_ID}" + + - name: 从空库跑完整条迁移链 + run: pnpm db:migrate + + - name: 库真的连得上 + run: node tools/ci.mjs db-probe + + - name: 门禁 5/5 · 单元与集成测试 + run: | + set -euo pipefail + pnpm test --reporter=default --reporter=json \ + --outputFile.json="${RUNNER_TEMP:-/tmp}/vitest.json" + node tools/ci.mjs assert-it "${RUNNER_TEMP:-/tmp}/vitest.json" + + # 用 steps.db.outcome 而不是裸 always():装依赖就失败的那种跑法里 + # 根本没有库可拆,裸 always() 只会多吐一条看着吓人的假失败。 + - name: 拆掉一次性库 + if: ${{ always() && steps.db.outcome == 'success' }} + env: + CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} + run: node tools/ci.mjs db-drop "jcai_ci_${GITHUB_RUN_ID}" + + # ── 生产构建 ──────────────────────────────────────────────────── + - name: 生产构建 · api + run: pnpm --filter @app/api build + + - name: 生产构建 · web + run: pnpm --filter @app/web build + + - name: 生产构建 · 活动包 + run: pnpm activities:build + + # ══ CD ══════════════════════════════════════════════════════════ + # 门禁全绿之后才走到这儿(同一个 job,前面任一步红后面自然不跑)。 + # 机器相关的东西一律从 vars.* 读,不写死;机密从 secrets.* 读。 + - name: 部署 · 前置断言 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + PM2_APP: ${{ vars.PM2_APP }} + WEB_ROOT: ${{ vars.WEB_ROOT }} + PG_CONTAINER: ${{ vars.PG_CONTAINER }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} + run: node tools/cd.mjs preflight + + # 迁移没有 down,备份是唯一的回滚手段 —— 所以它在换代码之前,且备不成就红 + - name: 部署 · 备份生产库 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + PG_CONTAINER: ${{ vars.PG_CONTAINER }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs backup + + - name: 部署 · 确保生产库在 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs db-ensure + + - name: 部署 · 发布产物 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + run: | + set -euo pipefail + REL="${DEPLOY_DIR}/releases/${GITHUB_SHA}" + rm -rf "$REL" + mkdir -p "$REL" + + # 产物 + db/(migrate.js 靠向上找 db/migrations,相对位置不能变)+ 根三件 + rsync -a --relative \ + ./apps/api/dist ./apps/api/package.json \ + ./apps/web/dist \ + ./db \ + ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml \ + "$REL"/ + # 活动包产物(装包时要读;装完就不再需要) + rsync -a --relative ./activities/*/dist "$REL"/ + # 各 workspace 包的 package.json —— pnpm 认工作区要它们全在,少一个 lockfile 就对不上 + git ls-files '*/package.json' | grep -v '^spike/' > "$RUNNER_TEMP/pkgs.txt" + rsync -a --files-from="$RUNNER_TEMP/pkgs.txt" ./ "$REL"/ + + # 产物是 ESM 且 skipNodeModulesBundle —— 目标目录必须有自己的 node_modules + cd "$REL" + pnpm install --prod --frozen-lockfile --ignore-scripts + + printf 'sha=%s\nref=%s\nrun=%s\nat=%s\n' \ + "$GITHUB_SHA" "$GITHUB_REF_NAME" "$GITHUB_RUN_ID" "$(date -Iseconds)" > "$REL/RELEASE" + echo "RELEASE_DIR=$REL" >> "$GITHUB_ENV" + echo "发布目录:$REL" + + # 直接跑 dist 里的入口,不走 pnpm db:migrate —— 那条会先 tsup(clean:true), + # 在正跑着的服务脚下把 dist 清掉 + - name: 部署 · 迁移 + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: cd "$RELEASE_DIR" && node apps/api/dist/migrate.js + + - name: 部署 · 迁移到位断言 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs migrate-verify + + # 出两个旗:FIRST_DEPLOY / ACTIVITIES_INSTALL。 + # 版本没升但产物内容变了会在这里当场红(否则新代码静默不上线)。 + - name: 部署 · 判首次播种与活动包升级 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs activities-plan + + # 空库唯一该跑 seed 的时刻。升级时绝不跑 —— 它会把治理台配好的 + # 工具连接端点打回 replay://crm、重置白名单、重置模型实例的 baseUrl/model。 + - name: 部署 · 首次播种 + if: ${{ env.FIRST_DEPLOY == '1' }} + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} + run: | + set -euo pipefail + # 上面 if: 判错的危险方向是「升级时误跑 seed」—— 那会重写整套治理台配置。 + # 所以这里自己再确认一次:表达式判错也伤不到生产。 + [ "${FIRST_DEPLOY:-0}" = "1" ] || { echo "FIRST_DEPLOY 不是 1,不播种(这一步本不该执行到)"; exit 0; } + cd "$RELEASE_DIR" + JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/seed.js + + - name: 部署 · 装活动包 + if: ${{ env.FIRST_DEPLOY == '0' && env.ACTIVITIES_INSTALL == '1' }} + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + run: | + set -euo pipefail + # 同上:装包会按主键覆盖 8 行样例主数据并发出 data 事件,不该装的时候别装 + [ "${ACTIVITIES_INSTALL:-0}" = "1" ] || { echo "ACTIVITIES_INSTALL 不是 1,不装(这一步本不该执行到)"; exit 0; } + cd "$RELEASE_DIR" + JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/install-activities.js + + # install-activities 的所有失败路径都只打日志、永远 exit 0 —— 回查库才说得出实情 + - name: 部署 · 活动包回查 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs activities-verify + + - name: 部署 · 发前端 + env: + WEB_ROOT: ${{ vars.WEB_ROOT }} + run: | + set -euo pipefail + rsync -a --delete ./apps/web/dist/ "$WEB_ROOT"/ + echo "前端已发到 $WEB_ROOT($(find "$WEB_ROOT" -type f | wc -l | tr -d ' ') 个文件)" + + # 用 releases 的**真实路径**起,不经 current 软链: + # main.ts 的入口守卫拿 import.meta.url 与 process.argv[1] 做字符串比较, + # 经软链启动两边不等 → boot() 永不执行 → 进程零输出 exit 0(实测过)。 + # 换版本时脚本路径会变,所以是 delete+start 而不是 restart。 + - name: 部署 · 重启 pm2 + env: + PM2_APP: ${{ vars.PM2_APP }} + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + APP_NODE_ENV: ${{ vars.APP_NODE_ENV }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + run: | + set -euo pipefail + # 有 HTTPS 才该设 production:production 会给登录 cookie 加 Secure, + # 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效。 + export NODE_ENV="${APP_NODE_ENV:-production}" + export PORT="${DEPLOY_PORT:-8787}" + export DATABASE_URL="$PROD_DATABASE_URL" + export JCAI_ATTACHMENT_ROOT="$DEPLOY_DIR/data/attachments" + pm2 delete "$PM2_APP" >/dev/null 2>&1 || true + pm2 start "$RELEASE_DIR/apps/api/dist/main.js" --name "$PM2_APP" --cwd "$RELEASE_DIR" --update-env + pm2 list + # 探活地址交给下一步,别让它去猜端口 —— 猜错会打到别人的服务上,还判成绿 + echo "HEALTH_URL=http://127.0.0.1:$PORT/api/health" >> "$GITHUB_ENV" + + # 库挂了 /api/health 照样返 200,只是 body 里 db:'down' —— 所以验 body + - name: 部署 · 探活 + run: node tools/cd.mjs health + + - name: 部署 · 收尾 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + run: | + set -euo pipefail + ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR/current" + node tools/cd.mjs prune + echo '── 这一版发出去了 ──' + cat "$RELEASE_DIR/RELEASE" + ls -1t "$DEPLOY_DIR/backups" | head -1 | sed 's/^/最近一份备份:/' diff --git a/docs/keycloak-backend-guide.md b/docs/keycloak-backend-guide.md new file mode 100644 index 0000000..38a9f61 --- /dev/null +++ b/docs/keycloak-backend-guide.md @@ -0,0 +1,1065 @@ +# 统一鉴权中心(Keycloak)—— 后端接入手册(Node.js) + +> **本文档的用途**:从零把一个 Node.js 后端服务接进统一鉴权中心, +> 一步一步做到第一个受保护接口跑通。按第 1 步到第 12 步顺序执行, +> 每步都写了"做完应该看到什么",对不上就别往下走。 +> +> **配套文档**:浏览器登录、前端 token 处理见《Keycloak 前端接入手册》。 +> +> **文档基准**:Keycloak 26.7.1,`jose` 5.10.0,Node.js 20+,核实日期 2026-08-12。 +> 文中代码均用真实签名的 RS256 token 端到端测试通过。 +> +> **给 AI agent 的执行原则** +> 1. 第 4 步的代码可直接落地,四个服务共用同一份,不要各写一套验签逻辑。 +> 2. 任何写操作(建用户、改角色、删客户端)执行前先向人类确认,只读查询可直接执行。 +> 3. 遇到本文档没写的 Admin API 端点,去 `https://www.keycloak.org/docs-api/latest/rest-api/` 查,不要猜。 +> 4. 每步的"做完应该看到什么"是硬检查点,不要跳过。 + +--- + +## 0. 环境信息 + +| 项 | 值 | +|---|---| +| Keycloak 地址 | `https://keycloak.akhr-ai.com` | +| Realm | `corp` | +| Issuer | `https://keycloak.akhr-ai.com/realms/corp` | +| JWKS | `https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs` | + +四个后端服务,各自一个 client: + +| 服务 | clientId | service account | client secret | +|---|---|---|---| +| API 网关 | `api-gateway` | ✓ | 有 | +| 智能体服务 | `agent-service` | ✓ | 有 | +| 任务分发服务 | `task-dispatcher` | ✓ | 有 | +| 业务数据服务 | `data-service` | ✗ | **没有,也不需要**(见 13.2) | + +两个前端(token 的来源): + +| | clientId | 要求的角色 | +|---|---|---| +| 用户端 `jcai-bpos.akhr-ai.com` | `web-portal` | `user` | +| 管理端 `jcai-bpos-opt.akhr-ai.com` | `web-console` | `admin` | + +角色模型(realm 级,所有服务共用同一套): + +``` +admin ──── user 管理员:组合包含 user + +user 普通用户;realm 默认角色,新建用户自动获得 +``` + +--- + +## 1. 确认 Keycloak 侧已就绪 + +代码写得再对,Keycloak 那边没配好也验不过。先花 30 秒确认: + +```bash +curl -s https://keycloak.akhr-ai.com/realms/corp/.well-known/openid-configuration | jq -r .issuer +``` + +**做完应该看到**: + +``` +https://keycloak.akhr-ai.com/realms/corp +``` + +出来是别的或报错,**先停下**,让管理员修 Keycloak 的 `KC_HOSTNAME`。 +这种情况下后端怎么写都验不过,而且报错完全指不到这里。 + +再确认公钥能拉到: + +```bash +curl -s https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs | jq '.keys[0].kid' +``` + +有输出即可。这是个**公开端点,不需要任何凭据**——后端验签只需要它。 + +--- + +## 2. 装依赖,建目录 + +```bash +npm i jose express +``` + +> **不要用 `keycloak-connect`**(Node 官方适配器),已标记 DEPRECATED、不再维护。 +> `jose` 是标准 JOSE 实现,`createRemoteJWKSet` 自带公钥缓存和密钥轮换处理。 + +目录结构(四个服务都一样): + +``` +your-service/ +├── .env ← 第 3 步 +└── src/ + ├── keycloak-auth.js ← 第 4 步,核心,四个服务共用 + └── server.js ← 第 5 步 +``` + +`package.json` 里加 `"type": "module"`(下面代码用 ESM)。 + +--- + +## 3. 写环境变量 + +**每个服务填自己的 `KEYCLOAK_CLIENT_ID`**,这决定了它校验 `aud` 时比对哪个值。填错会导致所有请求 401。 + +```bash +# api-gateway 的 .env +KEYCLOAK_URL=https://keycloak.akhr-ai.com +KEYCLOAK_REALM=corp +KEYCLOAK_CLIENT_ID=api-gateway +KEYCLOAK_CLIENT_SECRET=<管理台 Clients → api-gateway → Credentials 抄> +PORT=8080 +``` + +```bash +# data-service 的 .env —— 没有 SECRET 是对的,不是配漏了 +KEYCLOAK_URL=https://keycloak.akhr-ai.com +KEYCLOAK_REALM=corp +KEYCLOAK_CLIENT_ID=data-service +PORT=8083 +``` + +`agent-service` / `task-dispatcher` 同 `api-gateway` 的形式,改 `CLIENT_ID` 和 `SECRET`。 + +**为什么 `data-service` 没有 secret**:secret 的用途是"向 Keycloak 证明自己是谁", +不是"验别人的 token"。验签只需要公钥,公钥是公开的。`data-service` 只验不调, +所以用不上。详见 13.2。 + +--- + +## 4. 写 `src/keycloak-auth.js` + +核心文件,四个服务共用一份。下面分五段讲,最后给完整版。 + +### 4.1 远程公钥集 + +```js +import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; + +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; + +export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; + +const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { + cooldownDuration: 30_000, // 两次重新拉取之间至少间隔 30 秒 + cacheMaxAge: 10 * 60_000, // 公钥缓存 10 分钟 + timeoutDuration: 5_000, +}); +``` + +`createRemoteJWKSet` 内部行为: + +- **首次用到时才拉取**,不是启动时 +- 结果缓存 `cacheMaxAge` +- 遇到缓存里没有的 `kid` 会自动重新拉,但受 `cooldownDuration` 限流 + +**密钥轮换你不用管。** Keycloak 换签名密钥后新 token 的 `kid` 是缓存里没有的, +库会自动重拉;`cooldownDuration` 防止恶意 token 把 Keycloak 打爆。 + +### 4.2 验签 + +```js +export async function verifyAccessToken(token) { + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256'], // 写死,绝不读 token header 里的 alg + issuer: ISSUER, + audience: CLIENT_ID, + clockTolerance: 30, + }); + return payload; +} +``` + +四项校验,顺序即安全要点: + +| 校验 | 为什么 | +|---|---| +| `algorithms: ['RS256']` | **必须写死**。读 header 里的 `alg` 会被 `alg: none` 攻击绕过 | +| `issuer` | 确认 token 来自本 realm,不是另一个 Keycloak 实例 | +| `audience` | 确认这个 token 是**发给本服务**的 | +| `clockTolerance: 30` | 容忍 30 秒时钟偏移,避免边界抖动 | + +**这一步是纯本地计算,不联网。** 公钥缓存后连续验 500 次实测约 57ms +(平均 0.1ms/次),Keycloak 不在请求热路径上。 + +### 4.3 Express 中间件 + +```js +function extractBearer(req) { + const h = req.headers?.authorization || ''; + if (!/^bearer /i.test(h)) return null; + return h.slice(7).trim() || null; +} + +export function requireAuth(options = {}) { + const { optional = false } = options; + + return async function authMiddleware(req, res, next) { + const token = extractBearer(req); + if (!token) { + if (optional) return next(); + return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); + } + + try { + const p = await verifyAccessToken(token); + req.auth = p; // 原始 payload + req.user = { + id: p.sub, // 稳定 UUID,业务表外键用它 + username: p.preferred_username, + email: p.email, + name: p.name, + roles: p.realm_access?.roles ?? [], + groups: p.groups ?? [], // 服务账号 token 没有这个,默认空数组 + isService: (p.preferred_username || '').startsWith('service-account-'), + }; + return next(); + } catch (err) { + if (optional) return next(); + return res.status(401).json({ error: 'invalid_token', message: explain(err) }); + } + }; +} +``` + +`req.user.groups` 默认成空数组很重要——**服务账号 token 没有 `groups`**, +按组过滤数据的代码不处理会崩。 + +### 4.4 把 jose 的错误翻译成人话 + +排错时能省很多时间: + +```js +function explain(err) { + if (err instanceof joseErrors.JWTExpired) { + return 'token 已过期,用 refresh_token 换个新的'; + } + if (err instanceof joseErrors.JWTClaimValidationFailed) { + if (err.claim === 'aud') { + return `token 的 aud 不含 "${CLIENT_ID}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (err.claim === 'iss') { + return `token 的 iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + } + } + if (err instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (err instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + return err.message; +} +``` + +### 4.5 角色守卫 + +```js +/** 要求持有指定 realm 角色之一。admin 组合含 user,所以管理员能过 user 的检查 */ +export function requireRealmRole(...roles) { + return (req, res, next) => { + const have = req.user?.roles ?? []; + if (roles.some((r) => have.includes(r))) return next(); + return res.status(403).json({ + error: 'forbidden', + message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, + }); + }; +} + +/** 按组过滤:groups 是完整路径数组,判断"属于某部门或其子部门"用前缀匹配 */ +export function isInGroup(user, groupPath) { + const base = groupPath.replace(/\/$/, ''); + return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); +} +``` + +**401 和 403 必须分开**: + +| 状态码 | 含义 | 前端该怎么反应 | +|---|---|---| +| 401 | token 缺失/无效/过期 | 重新登录 | +| 403 | token 有效但角色不够 | 提示"没有权限",重新登录没用 | + +### 4.6 完整文件 + +```js +// src/keycloak-auth.js +import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; + +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; +const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; + +export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; +const TOKEN_URL = `${ISSUER}/protocol/openid-connect/token`; + +const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, +}); + +export async function verifyAccessToken(token) { + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256'], + issuer: ISSUER, + audience: CLIENT_ID, + clockTolerance: 30, + }); + return payload; +} + +function extractBearer(req) { + const h = req.headers?.authorization || ''; + if (!/^bearer /i.test(h)) return null; + return h.slice(7).trim() || null; +} + +function explain(err) { + if (err instanceof joseErrors.JWTExpired) return 'token 已过期'; + if (err instanceof joseErrors.JWTClaimValidationFailed) { + if (err.claim === 'aud') return `token 的 aud 不含 "${CLIENT_ID}" —— 给签发方 client 加 audience mapper`; + if (err.claim === 'iss') return `iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + } + if (err instanceof joseErrors.JWKSNoMatchingKey) return 'JWKS 里找不到匹配的公钥'; + if (err instanceof joseErrors.JWSSignatureVerificationFailed) return '签名校验失败'; + return err.message; +} + +export function requireAuth(options = {}) { + const { optional = false } = options; + return async function authMiddleware(req, res, next) { + const token = extractBearer(req); + if (!token) { + if (optional) return next(); + return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); + } + try { + const p = await verifyAccessToken(token); + req.auth = p; + req.user = { + id: p.sub, + username: p.preferred_username, + email: p.email, + name: p.name, + roles: p.realm_access?.roles ?? [], + groups: p.groups ?? [], + isService: (p.preferred_username || '').startsWith('service-account-'), + }; + return next(); + } catch (err) { + if (optional) return next(); + return res.status(401).json({ error: 'invalid_token', message: explain(err) }); + } + }; +} + +export function requireRealmRole(...roles) { + return (req, res, next) => { + const have = req.user?.roles ?? []; + if (roles.some((r) => have.includes(r))) return next(); + return res.status(403).json({ + error: 'forbidden', + message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, + }); + }; +} + +export function isInGroup(user, groupPath) { + const base = groupPath.replace(/\/$/, ''); + return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); +} + +// ───────── 服务账号:用于调用其它服务或 Admin API ───────── +let _svcToken = null; +let _svcExpireAt = 0; + +/** 用 client_credentials 拿代表"本服务自己"的 token,内部带缓存 */ +export async function getServiceToken() { + if (_svcToken && Date.now() < _svcExpireAt) return _svcToken; + if (!CLIENT_SECRET) throw new Error('未配置 KEYCLOAK_CLIENT_SECRET,无法使用服务账号'); + + const resp = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + }); + if (!resp.ok) throw new Error(`获取服务账号 token 失败: ${resp.status} ${await resp.text()}`); + + const data = await resp.json(); + _svcToken = data.access_token; + _svcExpireAt = Date.now() + (data.expires_in - 60) * 1000; // 提前 60 秒过期 + return _svcToken; +} + +// ───────── 启动自检 ───────── +/** 启动时调一次,把配置错误暴露在启动阶段而不是第一个请求 */ +export async function checkKeycloakConnection() { + const url = `${ISSUER}/.well-known/openid-configuration`; + const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = await resp.json(); + if (conf.issuer !== ISSUER) { + throw new Error( + `issuer 不一致!\n 期望: ${ISSUER}\n 实际: ${conf.issuer}\n` + + ` → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了` + ); + } + return { issuer: conf.issuer, clientId: CLIENT_ID }; +} + +export const config = { KEYCLOAK_URL, REALM, CLIENT_ID, ISSUER, TOKEN_URL }; +``` + +--- + +## 5. 挂到 Express + +```js +// src/server.js +import express from 'express'; +import { requireAuth, requireRealmRole, checkKeycloakConnection, config } from './keycloak-auth.js'; + +const app = express(); +app.use(express.json()); + +// 健康检查必须放在鉴权之前 —— 让 k8s/负载均衡探活不需要 token +app.get('/healthz', (req, res) => res.json({ ok: true })); + +// 公开接口 +app.get('/api/public', (req, res) => res.json({ message: '不需要登录' })); + +// 只要求登录 +app.get('/api/me', requireAuth(), (req, res) => { + res.json({ + user: req.user, + tokenExpiresAt: new Date(req.auth.exp * 1000).toISOString(), + }); +}); + +// 普通用户能访问(admin 组合含 user,管理员也能过) +app.get('/api/records', requireAuth(), requireRealmRole('user'), (req, res) => { + res.json({ by: req.user.username, records: [] }); +}); + +// 管理端专属 +app.get('/api/admin/stats', requireAuth(), requireRealmRole('admin'), (req, res) => { + res.json({ message: '管理员数据' }); +}); + +const PORT = process.env.PORT || 8083; + +try { + const info = await checkKeycloakConnection(); + console.log('✓ Keycloak 连通'); + console.log(' issuer:', info.issuer); + console.log(' client:', info.clientId); +} catch (e) { + console.error('✗ Keycloak 连不上或配置有误:', e.message); + process.exit(1); // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因 +} + +app.listen(PORT, () => console.log(`✓ 服务已启动 http://localhost:${PORT}`)); +``` + +**做完应该看到**: + +``` +✓ Keycloak 连通 + issuer: https://keycloak.akhr-ai.com/realms/corp + client: data-service +✓ 服务已启动 http://localhost:8083 +``` + +启动就报 `issuer 不一致` 说明 `KEYCLOAK_URL` 或 realm 名写错了,改完再跑。 + +> 顶层 `await` 需要 ESM(`"type": "module"`)。 +> 用 Express 4 的话异步中间件抛错不会自动进错误处理器——本文的中间件内部已经 +> `try/catch` 兜住了,不受影响。 + +--- + +## 6. 第一次运行:用真 token 打通 + +### 6.1 取一个真实 token + +```bash +cd /opt/keycloak + +# 临时开密码模式(仅为调试取 token) +WEB=$(sudo ./kc kcadm get clients -r corp -q clientId=web-portal --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=true + +TOKEN=$(curl -s -X POST \ + "https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=web-portal \ + -d username=<你的测试账号> -d password='<密码>' -d scope=openid \ + | jq -r .access_token) + +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, azp, preferred_username, realm_access}' +``` + +**必须确认 `aud` 里有本服务的 clientId**,没有的话验签必 401——那是 Keycloak 侧 +audience mapper 没配,改后端代码没用。 + +用完立刻关掉: + +```bash +sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=false +``` + +> 报 `Account is not fully set up` 的话,是账号上挂着必输动作。 +> Users → 该用户 → Details → **Required user actions** 清空,Credentials 里 +> 重设一个非临时密码。 + +### 6.2 打接口 + +```bash +curl -s http://localhost:8083/api/public | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8083/api/me | jq . +curl -s http://localhost:8083/api/me | jq . # 无 token,应 401 +``` + +**做完应该看到**: + +| 请求 | 期望 | +|---|---| +| `/api/public` | 200 | +| `/api/me` 带 token | 200,返回 `user` 对象含 `username` / `roles` | +| `/api/me` 不带 token | 401 `{"error":"unauthorized"}` | +| `/api/records` 带普通用户 token | 200 | +| `/api/admin/stats` 带普通用户 token | **403**(不是 401) | + +最后一条最关键——**403 说明验签通过了、只是角色不够**,整条链路是通的。 + +### 6.3 这一步的三个常见卡点 + +| 现象 | 原因 | 处理 | +|---|---|---| +| 401 且提示 `aud 不含 xxx` | Keycloak 侧没给前端 client 配 audience mapper | 找管理员补,见 14.5 | +| 401 且提示 `iss 与 ... 不符` | `KEYCLOAK_URL` 或 realm 名写错 | 对照第 0 节 | +| 全部 401 且提示签名校验失败 | token 来自另一个 Keycloak 实例 | 确认取 token 和验 token 用的是同一个地址 | + +--- + +## 7. 角色鉴权怎么用 + +```js +// 管理端专属接口 +app.get('/api/admin/users', requireAuth(), requireRealmRole('admin'), handler); +app.delete('/api/records/:id', requireAuth(), requireRealmRole('admin'), handler); + +// 普通接口(admin 组合含 user,管理员也能过) +app.get('/api/records', requireAuth(), requireRealmRole('user'), handler); +``` + +**前端的角色判断不是安全措施。** 前端是 public client,代码在用户浏览器里, +能被绕过。管理端专属接口**必须**在后端加 `requireRealmRole('admin')`, +这是唯一真正的安全边界。 + +按部门过滤数据: + +```js +import { isInGroup } from './keycloak-auth.js'; + +app.get('/api/dept-docs', requireAuth(), requireRealmRole('user'), async (req, res) => { + const all = await db.docs.findAll(); + // 资源表上存 visible_to_group(组的完整路径),前缀匹配 + res.json(all.filter((d) => isInGroup(req.user, d.visible_to_group))); +}); +``` + +`groups` 是完整路径数组(如 `["/技术部", "/技术部/平台组"]`), +需要 Keycloak 侧配了 group membership mapper 才有。**服务账号 token 没有它**, +所以上面代码对服务调用会返回空——这是正确行为。 + +--- + +## 8. 业务库怎么存用户 + +不要每次都问 Keycloak。首次见到这个 `sub` 就在自己库里落一条: + +```js +app.use(requireAuth(), async (req, res, next) => { + let local = await db.users.findOne({ keycloak_sub: req.user.id }); + if (!local) { + local = await db.users.insert({ + keycloak_sub: req.user.id, // 稳定 UUID,所有业务外键指向它 + username: req.user.username, // 冗余一份仅供显示,不要当主键 + created_at: new Date(), + }); + } + req.localUser = local; + next(); +}); +``` + +**外键必须用 `sub`,不能用 username。** username 允许修改,`sub` 永不变。 +用 username 做外键的话,用户改个名你的数据就对不上了。 + +--- + +## 9. API 网关的写法(和下游不一样) + +网关只做两件事:**验签拦截 + 原样转发**。不判业务角色,那是下游各自的事。 + +```js +// gateway.js —— KEYCLOAK_CLIENT_ID=api-gateway +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { requireAuth } from './keycloak-auth.js'; + +const app = express(); + +// 第一件事:剥掉客户端可能伪造的内部 header +app.use((req, _res, next) => { + for (const h of ['x-user-id', 'x-user-roles', 'x-internal-token']) delete req.headers[h]; + next(); +}); + +app.get('/healthz', (req, res) => res.json({ ok: true })); + +app.use('/api', requireAuth()); // 这里校验的 aud 是 api-gateway + +const forward = (target) => createProxyMiddleware({ + target, + changeOrigin: true, + // 关键:原样带上 Authorization,下游会自己再验一遍 + on: { + proxyReq: (proxyReq, req) => { + proxyReq.setHeader('Authorization', req.headers.authorization); + }, + }, +}); + +app.use('/api/agent', forward(process.env.AGENT_SERVICE_URL)); +app.use('/api/task', forward(process.env.TASK_SERVICE_URL)); +app.use('/api/data', forward(process.env.DATA_SERVICE_URL)); + +app.listen(process.env.PORT || 8080); +``` + +### 两条铁律 + +**1. 下游必须自己验签,不能信任网关。** +这是纵深防御:网关被绕过、内网被横向移动时,下游仍然安全。 +验签是纯本地计算,成本可以忽略。 + +**2. 网关不要把解出的用户信息塞进自定义 header 让下游信任。** + +```js +// ❌ 错误做法 +proxyReq.setHeader('X-User-Id', req.user.id); +// 下游读 X-User-Id 就信 —— 等于把安全边界搬到了内网可伪造的地方 +``` + +下游从自己验出来的 token 里读用户身份。 + +> 网关和下游能验通同一个 token,是因为前端 token 的 `aud` 同时包含四个后端 +> (`web-portal` 上挂了四个 audience mapper)。网关校验 `aud` 含 `api-gateway`, +> `data-service` 校验 `aud` 含 `data-service`,各查各的。 + +--- + +## 10. 服务间调用 + +定时任务、后台作业这类脱离用户请求的场景,用服务账号拿代表"服务自己"的 token。 + +```js +import { getServiceToken } from './keycloak-auth.js'; + +async function callDataService(path) { + const token = await getServiceToken(); // 内部有缓存,不会每次都换 + const resp = await fetch(`${process.env.DATA_SERVICE_URL}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`调用失败 ${resp.status}`); + return resp.json(); +} +``` + +### 服务账号 token 与用户 token 的三处不同 + +| claim | 用户 token | 服务账号 token | +|---|---|---| +| `sub` | 真实用户 UUID | 服务账号用户的 UUID | +| `preferred_username` | 用户名 | `service-account-` | +| `groups` | 用户所在组 | **不存在** | +| `email` | 有 | 通常没有 | + +下游收到时要注意: + +1. **不要假设 `req.user.groups` 非空**,按组过滤的代码要处理空数组 +2. **不要用 `sub` 去业务用户表查人**,会查不到 +3. 判断是不是服务调用,**推荐给服务账号授予专门的 realm 角色**再判角色 —— + `preferred_username` 的前缀只是 Keycloak 的命名约定,判角色更稳 + +**做完应该看到**:服务账号 token 解开后 `preferred_username` 是 +`service-account-task-dispatcher`,`realm_access.roles` 是你给它挂的角色。 + +--- + +## 11. 调 Admin API(管用户和角色) + +需要一个有管理权限的服务账号。**不要用人的账号密码做自动化。** + +### 11.1 一次性准备(人类执行) + +```bash +cd /opt/keycloak && sudo ./kc login + +sudo ./kc kcadm create clients -r master \ + -s clientId=provisioning -s enabled=true -s publicClient=false \ + -s standardFlowEnabled=false -s serviceAccountsEnabled=true + +PROV=$(sudo ./kc kcadm get clients -r master -q clientId=provisioning \ + --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm get "clients/$PROV/client-secret" -r master # 记下 secret + +SA=$(sudo ./kc kcadm get "clients/$PROV/service-account-user" -r master \ + --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm add-roles -r master --uid "$SA" \ + --cclientid corp-realm --rolename manage-users --rolename view-users \ + --rolename query-groups --rolename view-clients +``` + +> `corp-realm` 是 master realm 里自动生成的、代表 `corp` realm 的管理 client, +> 命名规则是 `-realm`。 + +### 11.2 Node 里调用 + +```js +// admin.js +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const PROV_ID = process.env.PROVISIONING_CLIENT_ID; // provisioning +const PROV_SECRET = process.env.PROVISIONING_CLIENT_SECRET; + +let _tok = null, _exp = 0; + +async function adminToken() { + if (_tok && Date.now() < _exp) return _tok; + const resp = await fetch(`${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: PROV_ID, + client_secret: PROV_SECRET, + }), + }); + if (!resp.ok) throw new Error(`取管理 token 失败: ${resp.status}`); + const d = await resp.json(); + _tok = d.access_token; + _exp = Date.now() + (d.expires_in - 60) * 1000; + return _tok; +} + +/** 调 Admin REST API。path 相对于 /admin/realms/{realm} */ +export async function adminFetch(path, { method = 'GET', body, ...rest } = {}) { + const token = await adminToken(); + const resp = await fetch(`${KEYCLOAK_URL}/admin/realms/${REALM}${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + ...rest, + }); + if (!resp.ok) throw new Error(`Admin API ${method} ${path} 失败: ${resp.status} ${await resp.text()}`); + + // 201 Created 没有响应体,新资源 ID 在 Location 头最后一段 + const text = await resp.text(); + if (!text) { + const loc = resp.headers.get('location'); + return loc ? { id: loc.split('/').pop() } : null; + } + return JSON.parse(text); +} +``` + +### 11.3 常用操作 + +```js +// 精确查用户(能用 exact 就别用 search,search 在大 realm 上很慢) +const [u] = await adminFetch('/users?username=alice&exact=true'); + +// 建用户,201 的 Location 头带新 ID +const { id } = await adminFetch('/users', { method: 'POST', body: { + username: 'bob', email: 'bob@akhr-ai.com', enabled: true, emailVerified: true, + credentials: [{ type: 'password', value: 'Init@2026', temporary: true }], +}}); + +// 查实际生效的角色(composite 会展开组合角色) +const roles = await adminFetch(`/users/${id}/role-mappings/realm/composite`); + +// 授予 admin 角色(body 是角色对象数组,不是名字数组) +const adminRole = await adminFetch('/roles/admin'); +await adminFetch(`/users/${id}/role-mappings/realm`, { method: 'POST', body: [adminRole] }); + +// 强制下线 —— 撤权后让其立即失效的唯一手段 +await adminFetch(`/users/${id}/logout`, { method: 'POST' }); +``` + +**做完应该看到**:`adminFetch('/users?username=xxx&exact=true')` 返回一个数组。 +403 的话是服务账号缺 realm-management 角色,对照 11.1 补。 + +--- + +## 12. 上线前检查清单 + +- [ ] 每个服务的 `KEYCLOAK_CLIENT_ID` 是**自己的** clientId(填错会全线 401) +- [ ] `data-service` 没配 `KEYCLOAK_CLIENT_SECRET`(正确,见 13.2) +- [ ] secret 只在环境变量里,没进 Git、没写死在代码里 +- [ ] 启动自检 `checkKeycloakConnection()` 已接上,配置错了直接退出 +- [ ] `/healthz` 在鉴权中间件**之前**注册(否则探活会 401) +- [ ] 管理端专属接口都加了 `requireRealmRole('admin')` +- [ ] 401 和 403 分开返回,没有混用 +- [ ] 网关剥掉了 `x-user-id` 之类的伪造 header +- [ ] 下游服务各自验签,没有"信任网关"的代码 +- [ ] 没有在请求热路径上调 introspect / userinfo / Admin API +- [ ] 业务表外键用的是 `sub`,不是 username + +--- + +## 13. 鉴权原理速查 + +### 13.1 谁验什么 + +| 角色 | 要做的验证 | 不做什么 | +|---|---|---| +| 前端 | 读 `realm_access.roles` 决定显示什么 | **不做安全判断** | +| API 网关 | 完整验签,拒绝无效 token,原样转发 | 不做业务权限判断 | +| 下游三个服务 | **各自再完整验一遍签**,然后判角色 | 不假设"网关已经验过了" | + +### 13.2 验 token 不需要 client secret + +**secret 的用途是"向 Keycloak 证明自己是谁",不是"验别人的 token"。** + +| 服务在做什么 | 需要 secret | +|---|---| +| 验别人递来的 Bearer token | ❌ | +| 用 `client_credentials` 换自己的 token 去调别的服务 | ✅ | +| 调 token introspection | ✅(但不推荐) | +| 做 token exchange | ✅ | + +所以 `data-service` 的 `.env` 里没有 `KEYCLOAK_CLIENT_SECRET` 是正确的, +不是配漏了。看到它没 secret 时不要自作主张配一个——不用的凭据多存一处 +就多一个泄漏面。 + +### 13.3 身份数据从哪来 —— 不要向 Keycloak 回源查 + +> **后端不向 Keycloak「要」用户身份。身份就在 token 里。** +> 看到「验签通过后调 `/userinfo` 拿用户信息」这类写法,那是错的。 + +``` +首次请求 + 客户端 ──Bearer eyJ...──► 服务 + ├ 解 header 拿 kid,本地无此公钥 + ├───► GET /certs ← 唯一一次网络往返 + ├ 公钥存内存,缓存 10 分钟 + └ 验签 → 校验 iss/aud/exp → 读 sub/roles/groups + ≈ 50–200 ms + +第二次及以后 + 客户端 ──Bearer eyJ...──► 服务 + ├ kid 命中缓存 + └ 纯本地运算 ≈ 0.1 ms,Keycloak 全程未被打扰 +``` + +**需要 token 里没有的字段时,改配 mapper,不要调接口。** +比如要用户工号:Clients → `web-portal` → Client scopes → `web-portal-dedicated` +→ Add mapper → By configuration → **User Attribute**, +配 `employee_id` → `employee_id`,Add to access token = On。 +之后 `req.auth.employee_id` 直接可读,零网络开销。**两个前端都要配。** + +### 13.4 撤权的时效性 + +本地验签的代价:**改了角色或禁用用户,现有 access token 在过期前仍然有效**。 +access token 是 5 分钟,所以最坏滞后 5 分钟。 + +要立即生效调 `POST /admin/realms/corp/users/{id}/logout`, +它会作废该用户所有会话和 refresh token。 +**不要为了消除这 5 分钟去每请求调 introspect**,那个代价大得多。 + +--- + +## 14. Admin REST API 清单 + +基地址 `https://keycloak.akhr-ai.com/admin/realms/corp`, +带 `Authorization: Bearer <管理 token>`。 + +### 14.1 三条必须记住的路径规则 + +1. **路径里的 `{client-uuid}` 是 client 的内部 UUID,不是 clientId 字符串。** + 官方文档原文:`id of client (not client-id!)`。 + 先 `GET /clients?clientId=data-service` 拿 `[0].id`。 +2. **创建成功返回 201,响应体为空,新资源 ID 在 `Location` 头最后一段。** +3. **列表接口默认 `max=100`**,必须分页。 + +### 14.2 用户 + +| 操作 | 方法与路径 | +|---|---| +| 精确查 | `GET /users?username=alice&exact=true` | +| 模糊搜索 | `GET /users?search=&first=0&max=50`(大 realm 上慢,能用 exact 就别用) | +| 详情 | `GET /users/{user-id}` | +| 创建 | `POST /users` → 201 + Location | +| 更新 | `PUT /users/{user-id}`(全量覆盖,先 GET 再改再 PUT) | +| 删除 | `DELETE /users/{user-id}` | +| 设密码 | `PUT /users/{user-id}/reset-password` body `{"type":"password","value":"...","temporary":true}` | +| 查会话 | `GET /users/{user-id}/sessions` | +| **强制下线** | `POST /users/{user-id}/logout` | +| 解除暴力锁定 | `DELETE /attack-detection/brute-force/users/{user-id}` | + +### 14.3 组 + +| 操作 | 方法与路径 | +|---|---| +| 组树 | `GET /groups?briefRepresentation=false` | +| 建顶层组 | `POST /groups` body `{"name":"技术部"}` | +| 建子组 | `POST /groups/{parent-id}/children` | +| 组成员 | `GET /groups/{group-id}/members?first=0&max=100` | +| **加进组** | `PUT /users/{user-id}/groups/{group-id}`(无 body,204) | +| 移出组 | `DELETE /users/{user-id}/groups/{group-id}` | +| 查用户所属组 | `GET /users/{user-id}/groups` | + +### 14.4 角色 + +| 操作 | 方法与路径 | +|---|---| +| 列 realm 角色 | `GET /roles` | +| 建 realm 角色 | `POST /roles` body `{"name":"auditor"}` | +| **查实际生效的 realm 角色** | `GET /users/{user-id}/role-mappings/realm/composite` | +| 授 realm 角色 | `POST /users/{user-id}/role-mappings/realm` body 是**角色对象数组** | +| 撤 realm 角色 | `DELETE /users/{user-id}/role-mappings/realm` | +| 给**组**授角色 | 同上,`users/{id}` 换成 `groups/{id}` | + +`/composite` 会递归展开组合角色,返回"实际生效"的集合。 +**排查权限问题永远看 `/composite`。** 注意它不包含通过组继承的角色, +要查完整生效集合需同时看用户所属组的角色。 + +### 14.5 客户端与 mapper + +| 操作 | 方法与路径 | +|---|---| +| 按 clientId 查 | `GET /clients?clientId=data-service` → 取 `[0].id` | +| 取 client secret | `GET /clients/{client-uuid}/client-secret` | +| 重新生成 secret | `POST /clients/{client-uuid}/client-secret` | +| 服务账号对应用户 | `GET /clients/{client-uuid}/service-account-user` | +| 列 mapper | `GET /clients/{client-uuid}/protocol-mappers/models` | +| 加 mapper | `POST /clients/{client-uuid}/protocol-mappers/models` | +| **预览 token** | `GET /clients/{client-uuid}/evaluate-scopes/generate-example-access-token?userId=` | + +最后一个在调 mapper 时极其有用:不用真登录就能看到 token 会变成什么样。 + +audience mapper 的 body: + +```json +{ "name": "aud-data-service", "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { "included.client.audience": "data-service", "access.token.claim": "true" } } +``` + +### 14.6 服务账号所需权限(403 时查这里) + +| 你要做的事 | 需要的 realm-management 角色 | +|---|---| +| 查用户 | `view-users` | +| 建/改/删用户、改密码、加入组 | `manage-users` | +| 搜索组、查组成员 | `query-groups` + `view-users` | +| 查 client 与其角色 | `view-clients` | +| 建/改 client、加 mapper | `manage-clients` | +| 改 realm 设置 | `manage-realm` | + +**最小权限原则。** `realm-admin` 是包含一切的组合角色,除非确有必要否则不要授予。 + +--- + +## 15. 错误对照表 + +| 现象 | 根因 | 处理 | +|---|---|---| +| 401 提示 `aud 不含 xxx` | token 的 `aud` 不含本服务 clientId | 给**发 token 的那个 client**加 audience mapper(14.5) | +| 401 提示 `iss 不符` | realm 名或 `KEYCLOAK_URL` 写错 | 对照 `.well-known` 里的 issuer | +| 401 提示 JWKS 找不到公钥 | token 来自别的 realm,或密钥刚轮换 | 确认 realm;库应自动重拉 | +| 401 提示签名校验失败 | token 被篡改或来自另一个实例 | 确认取/验 token 用同一地址 | +| 启动就报 `issuer 不一致` | `KEYCLOAK_URL` 或 realm 名错 | 见第 1 步 | +| 探活接口也 401 | `/healthz` 注册在鉴权中间件之后 | 挪到前面 | +| Admin API 403 | 服务账号缺 realm-management 角色 | 对照 14.6 | +| Admin API 404,路径里有两个斜杠 | 变量取值为空 | 先 `console.log` 确认变量非空 | +| 路径填了 clientId 却 404 | 路径参数要的是内部 UUID | 先 `GET /clients?clientId=xxx` 取 `[0].id` | +| 撤了角色但用户还能访问 | JWT 自包含,要等 5 分钟过期 | 调 `POST /users/{id}/logout` | +| `req.user.groups` 是 undefined 导致崩溃 | 服务账号 token 没有 groups | 代码里默认成 `[]`(4.3 已处理) | +| 通过 nginx 访问全部 403 | Keycloak 侧没设 `proxy-headers` | 运维改 `KC_PROXY_HEADERS=xforwarded` | +| 502 / `upstream sent too big header` | token 撑爆 nginx 缓冲区 | 调大 `proxy_buffer_size`、`large_client_header_buffers` | + +--- + +## 16. 硬性约束(不要违反,也不要建议人类违反) + +1. **不要用 `keycloak-connect`**(Node 官方适配器,已废弃)。 +2. **`algorithms` 必须写死 `['RS256']`**,绝不读 token header 里的 `alg`。 +3. **不要为每个请求调 introspect**,用本地 JWKS 验签。 + 同理不要在热路径调 `/userinfo` 或 Admin API 补查字段——需要什么字段就配 mapper。 +4. **下游服务必须自己验签**,不能因为"网关已经验过了"就跳过。 +5. **网关不要把解出的用户信息塞进自定义 header 让下游信任。** +6. **不要给"只验 token 不主动调别人"的服务配 secret**(见 13.2)。 +7. **不要用 `realm-admin` 做日常自动化**,按 14.6 授最小权限。 +8. **业务表外键用 `sub`**,不要用 username。 +9. **401 和 403 不要混用**,前端靠它区分"该重新登录"和"没权限"。 +10. **业务用户不放 `master` realm**,`master` 只放管理员。 +11. **不要把细粒度对象权限写进 Keycloak**(如"用户 A 能看文档 12345")。 + Keycloak 管身份 + 组织结构 + 粗粒度角色,对象级权限留在业务库。 +12. 涉及删除用户、删除 client、改 realm 设置的操作,**执行前必须向人类确认**。 + +--- + +## 17. Token 结构速查 + +用户 token: + +```json +{ + "iss": "https://keycloak.akhr-ai.com/realms/corp", + "aud": ["api-gateway", "agent-service", "task-dispatcher", "data-service", "account"], + "sub": "8f3c1a2b-...", + "exp": 1755000000, + "azp": "web-portal", + "preferred_username": "chaihao", + "email": "chaihao@akhr-ai.com", + "name": "张三", + "groups": ["/技术部", "/技术部/平台组"], + "realm_access": { "roles": ["user", "default-roles-corp"] } +} +``` + +| claim | 含义 | 注意 | +|---|---|---| +| `sub` | 用户唯一 ID,**永不变** | 业务表存这个 | +| `azp` | 哪个前端签发的 | 与 `aud` 不同,别混用 | +| `aud` | 目标受众 | 默认不含后端,靠 audience mapper | +| `groups` | 组完整路径数组 | 默认不存在,靠 group membership mapper;服务账号没有 | +| `realm_access.roles` | realm 角色 | 只有 `user` / `admin` 两种 | + +--- + +## 18. 官方文档索引 + +| 主题 | 链接 | +|---|---| +| Admin REST API 完整参考 | https://www.keycloak.org/docs-api/latest/rest-api/ | +| 应用接入 OIDC | https://www.keycloak.org/securing-apps/oidc-layers | +| Token Exchange | https://www.keycloak.org/securing-apps/token-exchange | +| 服务器管理指南 | https://www.keycloak.org/docs/latest/server_admin/ | +| jose 库文档 | https://github.com/panva/jose | diff --git a/docs/数据域字段说明-E04-I01.md b/docs/数据域字段说明-E04-I01.md new file mode 100644 index 0000000..c26b7ac --- /dev/null +++ b/docs/数据域字段说明-E04-I01.md @@ -0,0 +1,458 @@ +# 数据域字段说明 —— E04 关系覆盖 · I01 品牌内容 + +> 对应**设计中心 → 数据域**(按活动分组的那一屏)。当前系统里装着的活动只有两个: +> `crm-relationship-coverage`(E04 关系覆盖与关键人经营,v2.1.9)与 +> `brand-content-toolkit`(I01 品牌形象与销售工具包管理,v1.2.2)—— +> 报价链 A1–A7 已于 2026-08-24 从系统卸载,它们的表不在本文范围内。 +> +> 字段以**活动清单的台账声明**为准(`activities/<包>/src/manifest.ts` 的 +> `declarations.ledger`)。那份声明同时是**授权面**:没声明的列写不进去, +> 也不会出现在件上、判据里、数据域的活动分组里。 + +--- + +## 怎么读这份文档 + +- **★ = 重点字段**。每张表挑 5–8 个,在「重点字段」小节里**按重要程度从高到低**排; + 数据结构表里在列名后打 ★。 +- **【人填】= 人在活动过程中亲手填、提交后入库的字段** —— 执行工单的件上编辑,或数据录入卡。 + 这类字段**一律进重点**:它们是这一单里唯一不能靠 AI 补出来的事实,AI 替它们编值就是编数据。 +- 「写入者」一栏的口径: + - **人(件上)** —— 工作区那份 HTML 上点「编辑」改的格子,保存即落草稿层 + - **人(录入卡)** —— 对话区的数据录入卡,人确认那一下基座直接落库 + - **AI** —— 模型按说明书与剧本写 + - **工具** —— 活动声明的工具算出来、AI 照抄落表 + - **基座** —— 平台自己生成(自增序号等),不问人也不经模型 + - **承继** —— 从本活动上一张表照抄过来,供只读展示 +- 「约束」一栏的词: + +| 词 | 含义 | +|---|---| +| 主键 | 认行的钥匙。件上主键格也挂着绑定,那是为了认行,不是让人改 | +| 自增 | 基座按「同组主键值-两位序号」续号(如 `CU-0142-02`),不进表单 | +| 必填 | 件上红带 / 执行卡提交闸 / 放行门判据**三处同源**,缺了交不了卷 | +| 建议填 | 橘色占位提示,只提示不拦单 | +| 取值域 | 只许写列出的那几个值(tally),基座据此出分档计数 | +| 引用 | 值必须在另一张表里真实存在,查无此值写入当场被拒 | +| 去重计数 | 基座数「有几个不同的值」,判据拿它做闭合(如「计划盖住了几家客户」) | +| 发布时必填 | 行的状态流到某个值才必填(如内容状态 = 已发布) | +| 脱敏 | 展示与留痕按脱敏口径处理 | + +### 一览 + +| 活动 | 台账地址 | 人读名 | 性质 | 字段数 | 重点 | 人填列 | +|---|---|---|---|---|---|---| +| E04 | `customer.profile` | 客户档案(L-02 客户主台账) | 主数据 · 共享 | 8 | 6 | 5 | +| E04 | `customer.contact` | 客户联系人(L-08) | 主数据 · 共享 | 12 | 8 | 7 | +| E04 | `crm_coverage.candidate` | 候选覆盖客户清单 | 本活动工作台账 | 9 | 7 | 6 | +| E04 | `crm_coverage.plan` | 关键人经营计划 | 本活动工作台账 | 12 | 8 | 0(只审批,不直接改) | +| E04 | `crm_coverage.receipt` | 经营计划执行回执 | 本活动工作台账 | 11 | 6 | 4 | +| E04 | `crm_coverage.ledger_update` | 台账更新明细 | 本活动工作台账 | 11 | 7 | 0 | +| E04 | `crm_coverage.verdict` | 本轮覆盖结论 | 本活动工作台账 | 10 | 6 | 0 | +| I01 | `market.signal` | 宏观市场台账(L-01) | 主数据 · **只读** | 14 | 6 | 0 | +| I01 | `content.asset` | 品牌内容库(L-05) | 主数据 · I01 唯一读写者 | 14 | 8 | 0 | +| I01 | `content_brand.requirement` | 内容需求清单 | 本活动工作台账 | 8 | 6 | 4 | +| I01 | `content_brand.verdict` | 本轮内容生产结论 | 本活动工作台账 | 10 | 6 | 0 | + +--- + +## 一、E04 关系覆盖与关键人经营 + +七步:识别候选客户 → **覆盖范围确认(人)** → 缺口分析与制定计划 → **经营计划审批(人)** → +**执行经营动作与回执(人)** → 更新关系台账 → **放行收口(人)**。 +四道人环里有三道会往台账里写下人亲手填的事实。 + +### 1.1 `crm_coverage.candidate` 候选覆盖客户清单 + +本轮要经营哪几家客户。**AI 先识别出候选基线,人在「覆盖范围确认」那一屏增删改** —— +加一行 = 额外纳入(AI 没识别到但人有价值判断的客户),删一行 = 排除,改格子 = 修正。 +人加的行同等作数:后面的计划、回执、收口都得盖住它(`计划里不同客户数 = 候选行数` 是硬闭合)。 + +**重点字段(按重要程度)** + +1. `customer_code` ★【人填】主键之一、必填 —— 覆盖闭合判据的分母,人加行时自己填 +2. `customer_name` ★【人填】必填 —— 卡面、计划、回执上的「对象」都从它来 +3. `bt_level` ★【人填】决策链层级,重算覆盖度的入参 +4. `depth` ★【人填】本轮要做到多深(维护 / 突破 / 首访) +5. `basis` ★【人填】纳入依据 —— 人加的那一行为什么值得经营,只有他说得出 +6. `cust_type` ★【人填】客户类型,有客户档案就抄档案 +7. `status` ★ 状态 —— 收口时置「已覆盖」,界面上的状态徽靠它 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | 案号,按开案时刻铸(含年秒,跨单唯一) | +| `customer_code` ★ | 客户编码 | text | 主键 · 必填 | AI / **人(件上)** | 新客户由录入卡现场给码,AI 不许自编 | +| `customer_name` ★ | 客户名称 | text | 必填 | AI / **人(件上)** | | +| `cust_type` ★ | 客户类型 | text | 建议填 | AI / **人(件上)** | 有 `customer.profile.cust_type` 就抄它 | +| `bt_level` ★ | BT 层级 | text | 建议填 | **人(件上下拉)** | 存 ASCII(`bt-01/02/03`),件上摆人读名「BT-01 · 采购/供应链」 | +| `status` ★ | 状态 | text | 取值域 `pending` / `covered` / `excluded` | AI | S6 收口时置 `covered` | +| `basis` ★ | 纳入依据 | text | 建议填 | AI / **人(件上)** | | +| `depth` ★ | 覆盖深度 | text | 建议填 | **人(件上下拉)** | | +| `origin` | 来源 | text | — | AI | `ai` / `human-added`,标出哪几行是人加的 | + +### 1.2 `crm_coverage.plan` 关键人经营计划 + +一位关键人一行:该做什么动作、什么时候做、为什么。**件是只读的** —— +业务口径写死「人只做审批,不能直接改结论」;人点「重做」并写意见,AI 按意见改了再重新提交。 +意见里点名哪一家/哪一位,基座会先按字面比对把归属摆给模型,不让它凭印象挑行。 + +**重点字段(按重要程度)** + +1. `contact_code` ★ 主键之一 · **引用** `customer.contact.contact_code` —— 计划只许指向真实存在的人 +2. `customer_code` ★ 必填 · **去重计数** —— 「计划盖住了几家客户」就是数它 +3. `action` ★ 必填 —— 建议动作,审批被驳回时最常改的一格 +4. `target_date` ★ 必填 —— 没有时间点的建议不算数,验收判据点名核它 +5. `rationale` ★ 必填 —— 理由;驳回重做时若没写具体意见,先改实这一格 +6. `rank_score` ★ 必填 —— 排序分,模版不排序,必须已按此列降序写入 +7. `priority` ★ 必填 · 取值域 —— 优先级 +8. `relationship_gap` ★ 必填 —— 亲密度缺口(目标亲密度 − 当前亲密度) + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | | +| `contact_code` ★ | 联系人编码 | text | 主键 · 引用 `customer.contact.contact_code` | AI | 查无此码当场拒,并指路「先发录入卡把人收进来」 | +| `customer_code` ★ | 客户编码 | text | 必填 · 去重计数 | AI | 覆盖闭合判据的落点 | +| `customer_name` | 客户名称 | text | 建议填 | AI | | +| `contact_name` | 联系人姓名 | text | 建议填 | AI | | +| `action` ★ | 建议动作 | text | 必填 | AI(可按人的驳回意见改) | | +| `priority` ★ | 优先级 | text | 必填 · 取值域 `high` / `medium` / `low` | AI | | +| `rationale` ★ | 理由 | text | 必填 | AI | | +| `influence` | 影响力(写计划那一刻的快照) | text | 必填 | AI | 抄自联系人档案 | +| `relationship_gap` ★ | 亲密度缺口 | integer | 必填 | 工具 → AI | | +| `rank_score` ★ | 排序分 | number | 必填 | 工具 `rank-key-persons` → AI | | +| `target_date` ★ | 建议动作时间点 | date | 必填 | AI | | + +### 1.3 `crm_coverage.receipt` 经营计划执行回执 + +**这张表是「人填后入库」最典型的一张**:S3 只写骨架(与计划一一对应,行数必须相等), +完成状态那几列全部留空;人真的去拜访/沟通完,在「执行经营动作与回执」那一屏把结果填回来。 +空手交卷会被提交闸拦住,并点名缺在哪一行哪一格。 + +**重点字段(按重要程度)** + +1. `completion_status` ★【人填】必填 · 取值域 —— 这件事到底办没办成 +2. `result_summary` ★【人填】必填 —— 见到谁、谈了什么、达成什么(多行文本) +3. `key_person_change` ★【人填】必填 · 取值域 —— 关键人有没有变动,变了就触发主数据改档 +4. `customer_feedback` ★【人填】建议填 —— 客户态度 / 诉求 / 下一步信号(多行文本) +5. `contact_code` ★ 主键之一 · 引用 —— 与计划一一对应,回执行数 = 计划行数是硬门 +6. `customer_name` ★ 承继 —— 件上「对象」列长「客户名 · 关键人」,两个名字都得在 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | | +| `contact_code` ★ | 联系人编码(与计划一一对应) | text | 主键 · 引用 `customer.contact.contact_code` | AI | | +| `customer_code` | 客户编码 | text | 建议填 | 承继自计划 | | +| `customer_name` ★ | 客户名称 | text | — | 承继自计划 | 只读展示 | +| `contact_name` | 联系人姓名 | text | 建议填 | 承继自计划 | | +| `action` | 计划动作 | text | — | 承继自计划 | 只读展示,人照着它去执行 | +| `target_date` | 建议时间点 | date | — | 承继自计划 | 只读展示 | +| `completion_status` ★ | 完成状态 | text | 必填 · 取值域 `done` / `partial` / `skipped` | **人(件上下拉)** | | +| `result_summary` ★ | 结果摘要 | text | 必填 | **人(件上多行文本)** | | +| `customer_feedback` ★ | 客户反馈 | text | 建议填 | **人(件上多行文本)** | | +| `key_person_change` ★ | 关键人变动 | text | 必填 · 取值域 `changed` / `unchanged` | **人(件上下拉)** | | + +### 1.4 `customer.profile` 客户档案(L-02 客户主台账 · 共享主数据) + +E04 平时只读它;两个例外会写:①**新客户建立合作**那一刻代行建档职责(数据录入卡); +②收口时写回关系经营的结果(覆盖度、决策链地图、互动时间线)。 +放行前那一屏有「将写入的客户档案」落库预览 —— 摆的是**签下去之后这一行长什么样**,可就地改。 + +**重点字段(按重要程度)** + +1. `customer_code` ★【人填】主键 —— 新客户的编码由人在录入卡上现场给,AI 不许自编 +2. `coverage_score` ★ 必填 —— 本活动的核心产出:关系覆盖度,由工具重算后写回 +3. `decision_chain` ★ 必填 —— 决策链地图(JSON),覆盖缺口从它算出来 +4. `name` ★【人填】必填 —— 无名档案进不了正式表(2026-08-24 立的墙) +5. `cust_type` ★【人填】建议填 —— 客户类型 +6. `interaction_timeline` ★ 建议填 —— 互动时间线(JSON),每次整个数组重写 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `customer_code` ★ | 客户编码 | text | 主键 | **人(录入卡)** / 既有客户沿用 | 主数据主键,编错一次后面每一单都跟着错 | +| `name` ★ | 客户名称 | text | 必填 | **人(录入卡 / 件上)** | | +| `industry` | 所属行业 | text | 建议填 | **人(录入卡 / 件上)** / AI 推 | 推不出就留空给人补,不许编 | +| `region` | 区域 | text | 建议填 | **人(录入卡 / 件上)** / AI 推 | 同上 | +| `cust_type` ★ | 客户类型 | text | 建议填 | **人(录入卡 / 件上)** | 存中文枚举字面值,不带取值域 | +| `coverage_score` ★ | 关系覆盖度 | integer | 必填 | 工具 `recompute-score` → AI | | +| `decision_chain` ★ | 决策链地图 | json | 必填 | AI | `{nodes:[{node,btLevel,required,contactCode,covered}]}` | +| `interaction_timeline` ★ | 互动时间线 | json | 建议填 | AI | `{entries:[{date,type,contactCode,note}]}`,追加式重写 | + +### 1.5 `customer.contact` 客户联系人(L-08 · 共享主数据) + +计划与回执都**引用**这张表:计划只能指向这里真实存在的人。 +候选客户查无联系人时,S3 先发**数据录入卡**把第一位关键人收进来(人填姓名/职务/决策链节点), +编码由基座续号;放行前那一屏还可以再加一行(编码留空即可)。 + +**重点字段(按重要程度)** + +1. `contact_code` ★ 主键之一 · **自增** —— 计划/回执的引用目标;人不用填,基座按「客户编码-两位序号」续号 +2. `name` ★【人填】必填 —— 无名联系人进不了正式表 +3. `relationship` ★【人填】必填 —— 关系亲密度 1–5,缺口与排序分的入参 +4. `influence` ★【人填】必填 · 取值域 —— 影响力评级 +5. `key_person` ★【人填】必填 —— 关键人标记,覆盖度的分子 +6. `decision_node` ★【人填】建议填 —— 决策链节点(采购 / 技术 / 高层) +7. `title` ★【人填】职务 +8. `customer_code` ★ 主键之一 —— 这位联系人属于哪家客户 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `customer_code` ★ | 客户编码 | text | 主键 | 承继 | | +| `contact_code` ★ | 联系人编码 | text | 主键 · 自增 | **基座** | 声明里标了 `auto`,件上留空即续号 | +| `name` ★ | 姓名 | text | 必填 | **人(录入卡 / 件上)** | | +| `title` ★ | 职务 | text | — | **人(录入卡 / 件上)** | | +| `email` | 邮箱 | text | 脱敏 | **人(录入卡)** | | +| `role` | 角色 | text | 建议填 | AI | | +| `decision_node` ★ | 决策链节点 | text | 建议填 | **人(录入卡 / 件上下拉)** | `bt-01` 采购 / `bt-02` 技术 / `bt-03` 高层 | +| `influence` ★ | 影响力评级 | text | 必填 · 取值域 `high` / `medium` / `low` | **人(件上下拉)** / AI 初评 | | +| `relationship` ★ | 关系亲密度(1-5) | integer | 必填 | **人(件上)** / AI 初评 | | +| `comm_pref` | 沟通偏好 | text | 建议填 | AI | | +| `key_person` ★ | 关键人标记 | boolean | 必填 | **人(件上下拉)** / AI | | +| `change_log` | 变动记录 | text | — | AI | 入职 / 离职 / 调岗留痕 | + +### 1.6 `crm_coverage.ledger_update` 台账更新明细 + +「这一签下去,哪几张台账的哪几格会变成什么」——一处字段一行,给签字的人核对用。 +放行时人可以点重做,AI 按意见改;改过的那一处**同位置覆盖写**,老行要撤掉,不许两条并存。 + +**重点字段(按重要程度)** + +1. `update_key` ★ 必填 · **去重计数** —— 更新位置键(台账地址 · 客户 · 联系人 · 字段),同一处只许留一条 +2. `field_label` ★ 必填 —— 改的是哪一格(人读列名,不写机器列名) +3. `after_value` ★ 必填 —— 更新后的值 +4. `before_value` ★ 必填 —— 更新前的值(无值写「(无)」) +5. `evidence_ref` ★ 必填 —— 依据,用人话引用回执事实(不写编码前缀) +6. `ledger_label` ★ 必填 —— 改的是哪张台账(人读名) +7. `customer_code` ★ 必填 —— 落在哪家客户头上 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | | +| `update_seq` | 本轮更新序号 | integer | 主键 | AI | | +| `ledger_label` ★ | 台账(人读名) | text | 必填 | AI | 如「客户联系人」 | +| `ledger_address` | 台账地址(机器地址) | text | 必填 | AI | 如 `customer.contact` | +| `customer_code` ★ | 客户编码 | text | 必填 | AI | | +| `contact_code` | 联系人编码 | text | 建议填 | AI | 空 = 客户级字段 | +| `field_label` ★ | 字段(人读列名) | text | 必填 | AI | | +| `before_value` ★ | 更新前 | text | 必填 | AI | 无值写「(无)」 | +| `after_value` ★ | 更新后 | text | 必填 | AI | | +| `evidence_ref` ★ | 依据 | text | 必填 | AI | 用人话引回执:「某某某·某某的回执:……」 | +| `update_key` ★ | 更新位置键 | text | 必填 · 去重计数 | AI | `台账地址\|客户\|联系人\|字段` | + +### 1.7 `crm_coverage.verdict` 本轮覆盖结论 + +一单一行的收口结论,同时挂着几面「人环走到哪儿了」的记账旗 —— 它们是下一步能不能开工的哨兵。 + +**重点字段(按重要程度)** + +1. `scope_confirmed_total` ★ —— 覆盖范围已确认(人确认过才写),制定计划那一步的入场哨兵 +2. `plan_approved_total` ★ —— 经营计划已审批通过,派执行回执单的入场哨兵 +3. `ledger_update_confirmed_total` ★ —— 台账更新已收口 +4. `summary` ★ —— 本轮覆盖结论(摆在归档件上的那段话) +5. `covered_customer_total` ★ —— 本轮覆盖客户数 +6. `key_person_covered_total` ★ —— 本轮覆盖关键人数 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | | +| `trigger_label` | 触发方式(人读标签) | text | — | AI | 写下之前那一格真空着,不摆入参英文码 | +| `event_type` | 事件类型 | text | — | AI | 月度例行留空 | +| `note` | 本轮说明 | text | — | AI | | +| `scope_confirmed_total` ★ | 覆盖范围已确认 | integer | — | AI | 人环凭据 | +| `plan_approved_total` ★ | 经营计划已审批通过 | integer | — | AI | 人环凭据 | +| `ledger_update_confirmed_total` ★ | 台账更新已收口 | integer | — | AI | | +| `covered_customer_total` ★ | 本轮覆盖客户数 | integer | — | AI | | +| `key_person_covered_total` ★ | 本轮覆盖关键人数 | integer | — | AI | | +| `summary` ★ | 本轮覆盖结论 | text | — | AI | | + +> 注:执行回执那一步**没有**记账旗 —— 回执四列全是人填的,门直接读基座数 +> (回执有几行、缺不缺必填),被叫的那一方不给自己记账。 + +--- + +## 二、I01 品牌形象与销售工具包管理 + +五步:需求识别 → **需求受理与信息补充(人)** → 采集与创作 → **初稿审核(人)** → +拟发布 → **发布入库放行(人)**。人填的事实集中在第一道人环;后两道是审核与签字。 + +### 2.1 `content_brand.requirement` 内容需求清单 + +本轮要生产哪几份内容。AI 出候选基线,**人在「需求受理与信息补充」那一屏增删改** —— +加一行 = 补充新需求,删一行 = 本轮不做,改格子 = 修正 AI 识别的内容。 +序号留空由基座续号(人加行时不用自己编号)。 + +**重点字段(按重要程度)** + +1. `content_type` ★【人填】必填 —— 要产出什么(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书…) +2. `scene` ★【人填】建议填 —— 适用场景,S3 会照抄进品牌内容库那一行 +3. `target_customer` ★【人填】建议填 —— 目标客户(定向版本从它来) +4. `source` ★【人填】建议填 —— 需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求) +5. `req_seq` ★【人填/基座】主键之一 —— 人加行时留空,基座按现有最大序号 +1 续号 +6. `priority` ★ 取值域 —— 优先级,件上是只读徽标 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 案号 | text | 主键 | AI | | +| `req_seq` ★ | 本轮需求序号 | integer | 主键 | AI / **基座续号** | 件上加行留空即续号 | +| `content_type` ★ | 内容类型 | text | 必填 | AI / **人(件上)** | | +| `source` ★ | 需求来源 | text | 建议填 | AI / **人(件上)** | | +| `target_customer` ★ | 目标客户 | text | 建议填 | AI / **人(件上)** | | +| `scene` ★ | 适用场景 | text | 建议填 | AI / **人(件上)** | 存人读标签(首触 / 方案 / 攻坚 / 展会 / 报价),不存 ASCII 码 | +| `priority` ★ | 优先级 | text | 取值域 `high` / `medium` / `low` | AI | 件上只读徽标 | +| `origin` | 来源 | text | — | AI | `ai` / `human-added` | + +### 2.2 `content.asset` 品牌内容库(L-05 · 主数据,I01 是唯一读写者) + +**一行 = 一份资料文件**。正文只写一份 Markdown,基座按格式列现造成真字节 +(.docx 用 Word 打得开、.xlsx 用 Excel 打得开),件上点资料名就能打开渲染、下载原件; +放行那一刻还会把每一份**冻进存档**,归档件里点开的永远是签字那一刻的那一份。 + +**重点字段(按重要程度)** + +1. `content_id` ★ 主键 —— 资料的身份;件上的资料名链接、存档、下载都按它认 +2. `title` ★ 必填 —— 内容标题,同时就是资料文件名 +3. `body_md` ★ 必填 —— 内容正文(Markdown 原文),**文件的字节从它现造** +4. `file_format` ★ 必填 · 取值域 —— 决定造出哪种文件:表格用 `xlsx`、正式文档用 `docx`、网页用 `html`、短要点用 `md` +5. `content_status` ★ 必填 · 取值域 —— 草稿 → 已审核 → 已发布,不许跳步 +6. `scene` ★ 建议填 · **发布时必填** —— 适用场景,状态一到已发布就三处同源地拦 +7. `bt_tag` ★ 建议填 · **发布时必填** —— BT 层级标签 +8. `content_type` ★ 必填 —— 内容类型 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `content_id` ★ | 内容ID | text | 主键 | AI | | +| `content_type` ★ | 内容类型 | text | 必填 | AI | 抄需求清单那一行 | +| `title` ★ | 内容标题 | text | 必填 | AI | 资料文件的标题与文件名 | +| `language` | 语言版本 | text | 必填 | AI | 多语言用「 · 」连接 | +| `version` | 版本号 | text | 必填 | AI | 审核通过后升版 | +| `scene` ★ | 适用场景 | text | 建议填 · 发布时必填 | AI | 状态 = 已发布 时才拦 | +| `bt_tag` ★ | BT 层级标签 | text | 建议填 · 发布时必填 | AI | 存人读值(`BT-01`/`BT-02`/`BT-03`) | +| `content_status` ★ | 内容状态 | text | 必填 · 取值域 `draft` / `reviewed` / `published` / `deprecated` | AI | 人在决策卡上审核,AI 据此改状态 | +| `brand_tone` | 品牌调性标签 | text | 建议填 | AI | | +| `target_customer` | 定向客户版本 | text | — | AI | | +| `body_md` ★ | 内容正文(Markdown 原文) | text | 必填 | AI | 资料文件的唯一正文来源 | +| `file_format` ★ | 资料文件格式 | text | 必填 · 取值域 `md` / `html` / `docx` / `xlsx` | AI | 按内容形态挑 | +| `file_ref` | 文件位置 | text | — | AI | | +| `reviewer` | 审核人 | text | — | AI | 审核通过后补一句 | + +> 这张表声明了 `file` 段(标题取 `title`、正文取 `body_md`、格式取 `file_format`)—— +> 那一段同时是**授权面**:不声明它,件上的资料名点不开。 + +### 2.3 `market.signal` 宏观市场台账(L-01 · 主数据,I01 **只读**) + +创作那一步的市场依据从这儿取。I01 一个字都不写它,读不到相关信号时如实说读不到,不许编造。 + +**重点字段(按重要程度)** + +1. `signal_id` ★ 主键 —— 引用信号时点它 +2. `title` ★ —— 信号标题,创作时的依据摘要从它来 +3. `content` ★ —— 详情正文 +4. `signal_type` ★ 取值域 —— 宏观经济 / 行业趋势 / 竞争 / 政策 / 客户市场 +5. `importance` ★ —— 重要性评分 1–5,挑哪几条先看 +6. `impact_dir` ★ 取值域 —— 机会 / 威胁 / 中性 + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `signal_id` ★ | 信号ID | text | 主键 | 外部 / 其它活动 | I01 只读 | +| `signal_type` ★ | 信号类型 | text | 取值域 `macro_economy` / `industry_trend` / `competition` / `policy` / `customer_market` | — | | +| `title` ★ | 标题 | text | — | — | | +| `content` ★ | 详情 | text | — | — | | +| `source` | 信号来源 | text | — | — | | +| `signal_date` | 信号日期 | date | — | — | | +| `market_region` | 所属市场 | text | — | — | | +| `industry` | 细分行业 | text | — | — | | +| `chip_category` | 关联芯片品类 | text | — | — | | +| `importance` ★ | 重要性评分(1-5) | integer | — | — | | +| `impact_dir` ★ | 影响方向 | text | 取值域 `opportunity` / `threat` / `neutral` | — | | +| `signal_status` | 信号状态 | text | 取值域 `pending` / `evaluated` / `archived` / `expired` | — | | +| `version` | 版本号 | text | — | — | | +| `review_note` | 复盘备注 | text | — | — | | + +### 2.4 `content_brand.verdict` 本轮内容生产结论 + +一单一行的收口结论,同样挂着人环记账旗。 + +**重点字段(按重要程度)** + +1. `requirement_confirmed_total` ★ —— 需求已确认,采集创作那一步的入场哨兵 +2. `draft_approved_total` ★ —— 初稿已审核通过,拟发布那一步的入场哨兵 +3. `publish_prepared_total` ★ —— 发布准备已收口 +4. `content_produced_total` ★ —— 本轮产出内容份数 +5. `summary` ★ —— 本轮生产结论 +6. `trigger` ★ —— 触发方式(人读标签) + +**数据结构** + +| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 | +|---|---|---|---|---|---| +| `round_ref` | 案号 | text | 主键 | AI | | +| `trigger` ★ | 触发方式(人读标签) | text | — | AI | | +| `trigger_detail` | 触发细节(人读标签) | text | — | AI | | +| `target_customer` | 定向客户 | text | — | AI | | +| `note` | 本轮说明 | text | — | AI | | +| `requirement_confirmed_total` ★ | 需求已确认 | integer | — | AI | 人环凭据 | +| `draft_approved_total` ★ | 初稿已审核通过 | integer | — | AI | 人环凭据 | +| `publish_prepared_total` ★ | 发布准备已收口 | integer | — | AI | | +| `content_produced_total` ★ | 本轮产出内容份数 | integer | — | AI | | +| `summary` ★ | 本轮生产结论 | text | — | AI | | + +--- + +## 三、人填字段总表(按人办事的那一屏) + +| 活动 · 屏 | 卡的形式 | 落进哪张表 | 人亲手填的列 | +|---|---|---|---| +| E04 · 覆盖范围确认 | 执行工单(件上编辑 + 增删行) | `crm_coverage.candidate` | `customer_code` · `customer_name` · `cust_type` · `bt_level` · `depth` · `basis` | +| E04 · 新客户建档(仅新客户) | 数据录入卡(一卡两段) | `customer.profile` + `customer.contact` | 客户段:`customer_code` · `name` · `cust_type` · `industry` · `region`;联系人段:`name` · `title` · `email` | +| E04 · 新纳入客户补关键人 | 数据录入卡 | `customer.contact` | `name` · `title` · `decision_node`(编码基座续号) | +| E04 · 经营计划审批 | 决策卡(通过 / 重做 + 意见) | 间接改 `crm_coverage.plan` | 意见文本(AI 据此改 `action` / `rationale` / `target_date`) | +| E04 · 执行经营动作与回执 | 执行工单(件上编辑) | `crm_coverage.receipt` | `completion_status` · `result_summary` · `customer_feedback` · `key_person_change` | +| E04 · 台账更新确认(放行前) | 验收工单(落库预览可改 · 联系人可加行) | `customer.profile` · `customer.contact` | 档案:`name` · `cust_type` · `industry` · `region`;联系人:`name` · `title` · `decision_node` · `influence` · `relationship` · `key_person` | +| I01 · 需求受理与信息补充 | 执行工单(件上编辑 + 增删行) | `content_brand.requirement` | `content_type` · `source` · `target_customer` · `scene`(`req_seq` 留空续号) | +| I01 · 初稿审核 | 决策卡(通过 / 驳回 + 意见) | 间接改 `content.asset` | 意见文本(AI 据此改稿并升状态) | +| I01 · 发布入库放行 | 验收工单(签字) | `content.asset` 合并进正式表 | 裁决与意见 | + +--- + +## 四、在数据域里看这些表时的三条提醒 + +1. **主数据是共享的,物理表里还有别的活动留下的列。** + `customer.profile` 物理上还有 `grade` / `owner` / `payment_terms` / `credit_limit` / + `profile_tags` / `status`,`customer.contact` 还有 `phone` —— 那是报价链 A1 早先声明的, + E04 不写它们,本文也不列。记录浏览里看到它们空着是正常的。 + +2. **台账只增列不删列:退役的列还在。** + `crm_coverage.plan.status`、`crm_coverage.verdict` 的 `trigger` / `event_note` / + `receipt_confirmed_total` 都已退役,当前版本一个字都不写(为什么退役见清单里的墓志铭注释)。 + 判断一列还算不算数,以**活动清单的声明**为准,不以物理表结构为准。 + +3. **试跑单默认也落真库。** + 测试台起单默认 `land_master = true` —— 跑一遍活动,主数据真的会变 + (新客户、新联系人、覆盖度都会落进正式表)。要它不落,起单时显式关掉 + (端到端脚本就是这么做的:只能测一次的样例归脚本自己管)。 + +--- + +*字段口径以 `activities/crm-relationship-coverage/src/manifest.ts` 与 +`activities/brand-content-toolkit/src/manifest.ts` 的 `declarations.ledger` 为准; +业务设计源见 `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html` +与 `docs/SS01-客户与市场/E04I01-Demo-台账设计/*.xlsx`(L-01 / L-02 / L-05 / L-08)。* diff --git a/docs/部署.md b/docs/部署.md new file mode 100644 index 0000000..e641559 --- /dev/null +++ b/docs/部署.md @@ -0,0 +1,208 @@ +# 部署 + +> 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` | diff --git a/tools/cd.mjs b/tools/cd.mjs new file mode 100644 index 0000000..4d2585a --- /dev/null +++ b/tools/cd.mjs @@ -0,0 +1,486 @@ +#!/usr/bin/env node +/** + * CD 的八件小事(只在 Gitea Actions 的部署段用,本地开发不需要): + * + * preflight 配置齐不齐、密钥换没换、外部命令在不在、目录写不写得动 + * backup pg_dump 生产库(迁移没有 down,这是唯一的回滚手段) + * db-ensure 生产库不在就建 + 灌 db/init + * migrate-verify 已应用迁移条数 == journal 条数 + * activities-plan 判「首次播种 / 装活动包 / 什么都不做 / 当场红」 + * activities-verify 回查 sys.activity_package + * health 轮询 /api/health,**验 body** + * prune releases 只留最近几版 + * + * 三条设计律,每条都对着一个「信号全绿而事情没办成」的真实机制: + * ① `install-activities` 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** + * —— 所以装完必须回查库,不能信它的退出码(activities-verify)。 + * ② 活动包版本没升时 `savePackage` 是 onConflictDoNothing,新代码静默丢失而日志照样说成功 + * —— 所以版本没变但产物内容变了要**当场红**(activities-plan)。 + * ③ `/api/health` 在库挂掉时**照样返 200**,只是 body 里 `db:'down'` + * —— 所以探活验 body 不验状态码(health)。 + * + * 不新增依赖:pg 是根 devDependency(§12.5#3);共用的连库与灌 init 从 ci.mjs 取,不另抄一份。 + */ +import { createHash } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { + accessSync, + appendFileSync, + constants, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { applyInit, connect, die, withDatabase } from './ci.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** 路径类配置(Gitea Variables)与机密(Gitea Secrets)—— 少一条就别往下走 */ +const VARS = ['DEPLOY_DIR', 'PM2_APP', 'WEB_ROOT']; +const SECRETS = ['PROD_DATABASE_URL', 'JCAI_SECRET_KEY', 'JCAI_SEED_PASSWORD', 'JCAI_DEEPSEEK_API_KEY']; + +function need(name) { + const value = process.env[name]; + if (value === undefined || value.trim() === '') die(`缺 ${name} —— 在 Gitea 仓库设置里补上再部署`); + return value; +} + +function emit(line) { + process.stdout.write(`${line}\n`); +} + +/** 写进 $GITHUB_ENV 供后续步骤读;本地跑没有这个文件就把该导的那行摆出来 */ +function exportEnv(key, value) { + const file = process.env['GITHUB_ENV']; + if (file === undefined) { + emit(`export ${key}='${value}'`); + return; + } + appendFileSync(file, `${key}=${value}\n`); +} + +/** + * 命令在不在 PATH 上 —— 借 shell 的 `command -v`,别自己拼 PATH 去猜。 + * 走 `sh -c` 而不是 `{ shell: true }`:后者在 node 22 上每次都吐一条 + * 「传 args 给 shell 有安全风险」的弃用告警,而 CI 日志里那种噪音会让人不信别的话。 + * 参数来自本文件里写死的名单,不接外部输入。 + */ +function which(command) { + return spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }).status === 0; +} + +/* ── 生产库地址 ─────────────────────────────────────────────────────── */ + +function prodUrl() { + return need('PROD_DATABASE_URL'); +} + +function adminUrlOf(url) { + return withDatabase(url, 'postgres'); +} + +function databaseNameOf(url) { + return new URL(url).pathname.replace(/^\//, ''); +} + +async function databaseExists(url) { + const admin = await connect(adminUrlOf(url)); + try { + const found = await admin.query('select 1 from pg_database where datname = $1', [databaseNameOf(url)]); + return found.rowCount > 0; + } finally { + await admin.end(); + } +} + +/* ── preflight ─────────────────────────────────────────────────────── */ + +async function preflight() { + const missing = [...VARS, ...SECRETS].filter((name) => { + const value = process.env[name]; + return value === undefined || value.trim() === ''; + }); + if (missing.length > 0) { + die( + `以下配置还没设(一次说全,免得你来回补):\n` + + missing.map((name) => ` · ${name}${VARS.includes(name) ? '(Variables)' : '(Secrets)'}`).join('\n'), + ); + } + + const key = Buffer.from(need('JCAI_SECRET_KEY'), 'base64'); + if (key.length !== 32) { + die(`JCAI_SECRET_KEY 必须是 32 字节的 base64(现在解出来 ${key.length} 字节)—— openssl rand -base64 32`); + } + + const deployDir = need('DEPLOY_DIR'); + const webRoot = need('WEB_ROOT'); + mkdirSync(join(deployDir, 'releases'), { recursive: true }); + mkdirSync(join(deployDir, 'backups'), { recursive: true }); + mkdirSync(join(deployDir, 'data', 'attachments'), { recursive: true }); + for (const [label, path] of [['DEPLOY_DIR', deployDir], ['WEB_ROOT', webRoot]]) { + if (!existsSync(path)) die(`${label} 不存在:${path} —— 先在主机上建好并让 runner 用户写得动`); + try { + accessSync(path, constants.W_OK); + } catch { + die(`${label} 写不动:${path} —— runner 用户没有写权限(nginx 的目录常常是 root 的)`); + } + } + + const tools = ['pm2', 'rsync'].filter((command) => !which(command)); + if (tools.length > 0) { + die( + `这几个命令不在 runner 用户的 PATH 上:${tools.join(' / ')}\n` + + '(pm2 常装在某个 node 版本管理器的 shim 里,而非交互 shell 读不到 ~/.zshrc)', + ); + } + if (!which('pg_dump') && !(which('docker') && (process.env['PG_CONTAINER'] ?? '') !== '')) { + die( + '备份没有着落:PATH 上没有 pg_dump,也没给 PG_CONTAINER(库在容器里时用它 docker exec)。\n' + + '你选的是「自动跑迁移,先备份」—— 而迁移没有 down,备份是唯一的回滚手段,不能悄悄跳过。', + ); + } + + // 密钥指纹:换了这把钥匙 = 库里所有凭证密文全部解不开,而且不在启动时炸,是第一次真调模型才炸 + const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 16); + const marker = join(deployDir, '.secret-fingerprint'); + if (existsSync(marker)) { + const known = readFileSync(marker, 'utf8').trim(); + if (known !== fingerprint) { + die( + `JCAI_SECRET_KEY 与上一次部署的不是同一把(${known} → ${fingerprint})。\n` + + 'sys.llm_instance.api_key_cipher 与 sys.tool.connection_cipher 都是用它封的 ——\n' + + '换了就全部解不开,而且要等第一次真调模型才炸。\n' + + `确实要换,先把 ${marker} 删掉,并准备重配所有凭证。`, + ); + } + } else { + writeFileSync(marker, `${fingerprint}\n`, 'utf8'); + emit(`记下密钥指纹:${fingerprint}(以后每次部署都比对)`); + } + + emit('部署前置检查通过 ✔'); +} + +/* ── backup ────────────────────────────────────────────────────────── */ + +async function backup() { + const url = prodUrl(); + if (!(await databaseExists(url))) { + emit(`生产库 ${databaseNameOf(url)} 还不存在 —— 这是首次部署,没有东西可备份,跳过`); + return; + } + + const deployDir = need('DEPLOY_DIR'); + const dir = join(deployDir, 'backups'); + mkdirSync(dir, { recursive: true }); + const parsed = new URL(url); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const sha = (process.env['GITHUB_SHA'] ?? 'local').slice(0, 8); + const file = join(dir, `${stamp}-${sha}.sql.gz`); + + const database = databaseNameOf(url); + const user = decodeURIComponent(parsed.username); + const env = { ...process.env, PGPASSWORD: decodeURIComponent(parsed.password) }; + + // 口令一律走环境,不进 argv(argv 在 ps 里对同机其他用户可见) + const [command, args] = + which('pg_dump') + ? ['pg_dump', ['-h', parsed.hostname, '-p', parsed.port === '' ? '5432' : parsed.port, '-U', user, '-d', database, '--no-owner', '--no-privileges']] + : ['docker', ['exec', '-e', 'PGPASSWORD', '-i', need('PG_CONTAINER'), 'pg_dump', '-U', user, '-d', database, '--no-owner', '--no-privileges']]; + + const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const finished = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${command} 退出码 ${code}\n${stderr}`)))); + }); + + try { + await Promise.all([pipeline(child.stdout, createGzip(), createWriteStream(file)), finished]); + } catch (error) { + die(`备份失败:${error instanceof Error ? error.message : String(error)}`); + } + + emit(`已备份 → ${file}(${(statSync(file).size / 1024).toFixed(0)} KB)`); + keepNewest(dir, Number(process.env['KEEP_BACKUPS'] ?? 10), '备份'); +} + +/* ── db-ensure ─────────────────────────────────────────────────────── */ + +async function dbEnsure() { + const url = prodUrl(); + const name = databaseNameOf(url); + if (await databaseExists(url)) { + emit(`生产库 ${name} 已在 ✔`); + return; + } + const admin = await connect(adminUrlOf(url)); + try { + await admin.query(`create database "${name}"`); + } catch (error) { + die( + `建库失败:${error instanceof Error ? error.message : String(error)}\n` + + 'PROD_DATABASE_URL 里那个账号需要 CREATEDB 权限,或者你先手工把库建好。', + ); + } finally { + await admin.end(); + } + emit(`已建生产库 ${name}`); + // 空库不灌 db/init 迁不动:0026 往 ledger.__object_meta 建表,而迁移链只会替自己建 sys + await applyInit(url); +} + +/* ── migrate-verify ────────────────────────────────────────────────── */ + +async function migrateVerify() { + const journal = JSON.parse(readFileSync(join(root, 'db', 'migrations', 'meta', '_journal.json'), 'utf8')); + const expected = Array.isArray(journal.entries) ? journal.entries.length : 0; + const client = await connect(prodUrl()); + let applied; + try { + const result = await client.query('select count(*)::int as n from sys.__drizzle_migrations'); + applied = result.rows[0].n; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + // 记账表不存在 = 迁移那一步压根没跑成(而它可能仍然打印了「完成」并 exit 0) + if (detail.includes('__drizzle_migrations')) { + die('库里连 sys.__drizzle_migrations 都没有 —— 迁移那一步根本没跑成。先看上一步的输出。'); + } + die(`查迁移记账失败:${detail}`); + } finally { + await client.end(); + } + if (applied !== expected) { + die( + `迁移没到位:库里 ${applied} 条,db/migrations 里 ${expected} 条。\n` + + 'drizzle 只比「最后一条的时间戳」当水位线,不逐条比 hash ——\n' + + '新迁移在 _journal.json 里的 when 若 ≤ 水位线,会被永久静默跳过,而 migrate 照样打印「完成」。\n' + + '查:select max(created_at) from sys.__drizzle_migrations,与新增那条的 when 比一比。', + ); + } + emit(`迁移到位 ✔ ${applied}/${expected} 条`); +} + +/* ── 活动包 ────────────────────────────────────────────────────────── */ + +/** 装哪几个包是 seed-activities.ts 说了算 —— 读它,不在这儿另存一份名单 */ +function realPackages() { + const source = readFileSync(join(root, 'apps', 'api', 'src', 'app', 'seed-activities.ts'), 'utf8'); + const matched = /const REAL_PACKAGES = \[([^\]]*)\]/.exec(source); + if (matched === null) { + die('读不出 REAL_PACKAGES —— seed-activities.ts 的写法变了,来改 tools/cd.mjs(别让它拿一份过期名单继续跑)'); + } + const names = [...matched[1].matchAll(/'([^']+)'/g)].map((one) => one[1]); + if (names.length === 0) die('REAL_PACKAGES 是空的 —— 那就没有活动包要装,确认这是不是你要的'); + return names; +} + +function distOf(pkg) { + const dir = join(root, 'activities', pkg, 'dist'); + const manifestPath = join(dir, 'manifest.json'); + const entryPath = join(dir, 'index.js'); + if (!existsSync(manifestPath) || !existsSync(entryPath)) { + die(`活动包 ${pkg} 没有构建产物(${dir})—— 先跑 pnpm activities:build`); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + return { + activityId: manifest.identity.activityId, + version: manifest.identity.packageVersion, + artifact: readFileSync(entryPath, 'utf8'), + }; +} + +async function activitiesPlan() { + const client = await connect(prodUrl()); + let staffCount; + const decisions = []; + try { + const staff = await client.query('select count(*)::int as n from sys.staff'); + staffCount = staff.rows[0].n; + + for (const pkg of realPackages()) { + const dist = distOf(pkg); + const row = await client.query( + 'select artifact from sys.activity_package where activity_id = $1 and version = $2', + [dist.activityId, dist.version], + ); + if (row.rowCount === 0) decisions.push({ pkg, dist, verdict: 'install' }); + else if (row.rows[0].artifact === dist.artifact) decisions.push({ pkg, dist, verdict: 'skip' }); + else decisions.push({ pkg, dist, verdict: 'conflict' }); + } + } finally { + await client.end(); + } + + const conflicts = decisions.filter((one) => one.verdict === 'conflict'); + if (conflicts.length > 0) { + die( + `这几个活动包**版本没升但内容变了**:\n` + + conflicts.map((one) => ` · ${one.pkg}@${one.dist.version}`).join('\n') + + '\n安装恒新增:同版本重装是 no-op(onConflictDoNothing),库里还是旧产物,\n' + + '而日志会照样打印「活动包已装」、退出码 0 —— 新代码静默不上线。\n' + + '改了内容就升 manifest 里的 packageVersion(CLAUDE.md 铁规矩 7)。', + ); + } + + const first = staffCount === 0; + const toInstall = decisions.filter((one) => one.verdict === 'install'); + + if (first) { + emit('库里一个员工都没有 → 判为**首次部署**,走 seed.js 播一次种(此后永不再跑 seed)'); + exportEnv('FIRST_DEPLOY', '1'); + exportEnv('ACTIVITIES_INSTALL', '0'); + return; + } + + exportEnv('FIRST_DEPLOY', '0'); + exportEnv('ACTIVITIES_INSTALL', toInstall.length > 0 ? '1' : '0'); + if (toInstall.length === 0) { + emit('活动包版本与库里一致、内容也一致 → 不装(顺带避开它会覆盖样例主数据、并发出 data 事件)'); + return; + } + emit(`要装的活动包:${toInstall.map((one) => `${one.pkg}@${one.dist.version}`).join(' / ')}`); +} + +async function activitiesVerify() { + const client = await connect(prodUrl()); + const bad = []; + try { + for (const pkg of realPackages()) { + const dist = distOf(pkg); + const row = await client.query( + 'select artifact from sys.activity_package where activity_id = $1 and version = $2', + [dist.activityId, dist.version], + ); + if (row.rowCount === 0) bad.push(`${pkg}@${dist.version}:库里根本没有这一版`); + else if (row.rows[0].artifact !== dist.artifact) bad.push(`${pkg}@${dist.version}:库里那一版的产物与刚构建的不一致`); + else emit(` ✔ ${pkg}@${dist.version}`); + } + } finally { + await client.end(); + } + if (bad.length > 0) { + die( + `活动包没装成:\n${bad.map((one) => ` · ${one}`).join('\n')}\n` + + 'install-activities 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** ——\n' + + '所以这道回查才是唯一说得出实情的地方。看它的日志里有没有「本次跳过」。', + ); + } + emit('活动包已在库里 ✔'); +} + +/* ── health ────────────────────────────────────────────────────────── */ + +async function health() { + const url = process.env['HEALTH_URL'] ?? `http://127.0.0.1:${process.env['PORT'] ?? '8787'}/api/health`; + const deadline = Date.now() + Number(process.env['HEALTH_TIMEOUT_MS'] ?? 60_000); + let last = '还没连上'; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + const body = await response.json(); + // 库挂了它照样返 200,只是 body 里 db:'down' —— 所以验 body,不验状态码 + if (body.ok === true && body.db === 'up') { + emit(`探活通过 ✔ ${url} → ok=true db=up version=${body.version}`); + return; + } + last = `HTTP ${response.status} ${JSON.stringify(body)}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + die( + `探活没过:${url}\n最后一次看到的是:${last}\n` + + '两种常见死法:①进程压根没起来(入口守卫遇软链会静默 exit 0 —— pm2 必须用 releases 的真实路径起);\n' + + '②起来了但连不上库(body 里 db:"down",而 HTTP 仍是 200)。先看 pm2 logs。', + ); +} + +/* ── prune ─────────────────────────────────────────────────────────── */ + +function keepNewest(dir, keep, label) { + const entries = readdirSync(dir) + .map((name) => ({ name, path: join(dir, name) })) + .map((one) => ({ ...one, at: statSync(one.path).mtimeMs })) + .sort((a, b) => b.at - a.at); + const drop = entries.slice(keep); + for (const one of drop) rmSync(one.path, { recursive: true, force: true }); + emit(`${label}:留 ${Math.min(keep, entries.length)} 份,清掉 ${drop.length} 份`); +} + +function prune() { + const deployDir = need('DEPLOY_DIR'); + const dir = join(deployDir, 'releases'); + if (!existsSync(dir)) return; + const keep = Number(process.env['KEEP_RELEASES'] ?? 5); + const currentLink = join(deployDir, 'current'); + const current = existsSync(currentLink) ? realpathSync(currentLink) : undefined; + + const entries = readdirSync(dir) + .map((name) => join(dir, name)) + .map((path) => ({ path, at: statSync(path).mtimeMs })) + .sort((a, b) => b.at - a.at); + + let kept = 0; + let dropped = 0; + for (const one of entries) { + // 在跑的那一版永远不删,哪怕它排到了第 6 位 + if (kept < keep || one.path === current) { + kept += 1; + continue; + } + rmSync(one.path, { recursive: true, force: true }); + dropped += 1; + } + emit(`发布目录:留 ${kept} 版,清掉 ${dropped} 版`); +} + +/* ── 分发 ──────────────────────────────────────────────────────────── */ + +const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; +const [command] = process.argv.slice(2); + +if (isMain) { + switch (command) { + case 'preflight': + await preflight(); + break; + case 'backup': + await backup(); + break; + case 'db-ensure': + await dbEnsure(); + break; + case 'migrate-verify': + await migrateVerify(); + break; + case 'activities-plan': + await activitiesPlan(); + break; + case 'activities-verify': + await activitiesVerify(); + break; + case 'health': + await health(); + break; + case 'prune': + prune(); + break; + default: + die('用法:node tools/cd.mjs '); + } +} diff --git a/tools/ci.mjs b/tools/ci.mjs new file mode 100644 index 0000000..8817d20 --- /dev/null +++ b/tools/ci.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** + * CI 的四件小事(只在 Gitea Actions 里用,本地开发不需要): + * + * db-create 在常驻 PG 上建一个一次性库、灌 db/init(四个 schema), + * 连接串写进 $GITHUB_ENV 的 DATABASE_URL + * db-drop 拆掉它 + * db-probe 拿 DATABASE_URL 探一次 —— 连不上就红 + * assert-it 断言 ledger 集成测试**真的跑了**,不是静默跳过 + * + * 后两条治的是同一个病:`tools/it-database.ts` 连不上库、又起不了 Testcontainers 时, + * 打一行警告就返回 undefined —— 整套集成测试跳过而退出码仍是 0, + * CI 会拿「什么都没测」冒充绿灯。**界面不撒谎**:探不到库就红在探库那一步, + * 跑完发现被跳过就红在断言这一步。 + * + * 不新增依赖:pg 是根 devDependency(§12.5#3)。 + */ +import { appendFileSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; + +/** 一次性库的前缀白名单 —— 本脚本永远只碰以它打头的库,`jcai_wos` 之类绝不误伤 */ +const PREFIX = 'jcai_ci_'; +/** 唯一一个「库连得上就真跑」的集成测试文件(其余三个被 SKIP_UNTIL_MODEL 钉着) */ +const LEDGER_IT = 'ledger-store.it.test.ts'; + +export function die(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function adminUrl() { + const url = process.env['CI_PG_ADMIN_URL']; + if (url === undefined || url === '') { + die('缺 CI_PG_ADMIN_URL —— 仓库设置 → Actions → Secrets 里加一条,指向常驻 PG 的 postgres 库'); + } + return url; +} + +function checkName(name) { + if (name === undefined || !name.startsWith(PREFIX)) { + die(`库名必须以 ${PREFIX} 打头(拿到的是「${name ?? ''}」)—— 这道白名单挡的是误删开发库`); + } + if (!/^[a-z0-9_]+$/.test(name)) die(`库名只许小写字母、数字、下划线:${name}`); + return name; +} + +/** 换掉连接串里的库名 —— 走 URL 解析,`?sslmode=` 这类查询串照样保住 */ +export function withDatabase(url, name) { + const parsed = new URL(url); + parsed.pathname = `/${name}`; + return parsed.toString(); +} + +export async function connect(url) { + const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 5000 }); + await client.connect(); + return client; +} + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * 新库要先灌 `db/init` 才迁得动 —— `0026_object_meta.sql` 往 `ledger.` 里建表, + * 而迁移链本身只会替自己建 `sys`。docker-compose 是靠把这个目录挂进 + * `/docker-entrypoint-initdb.d` 在首次起卷时灌的;`createdb` 出来的库没人替它灌。 + * **灌的是仓库里那份文件本身**,不在这里抄一份 —— 同一份事实两处各存一份,改一处就分叉。 + */ +export async function applyInit(url) { + const dir = join(root, 'db', 'init'); + const files = readdirSync(dir) + .filter((name) => name.endsWith('.sql')) + .sort(); + const client = await connect(url); + try { + for (const file of files) { + await client.query(readFileSync(join(dir, file), 'utf8')); + process.stdout.write(`灌了 db/init/${file}\n`); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + die( + `灌 db/init 失败:${detail}\n` + + '若卡在 CREATE EXTENSION pgcrypto,是账号权限不够(该扩展默认非 trusted,要超级用户)——\n' + + '让 DBA 先在那台 PG 上装一次,或给 CI 账号超级用户权限。', + ); + } finally { + await client.end(); + } +} + +async function dbCreate(name) { + const admin = await connect(adminUrl()); + try { + // 上一跑被取消时会留下库(拆库那一步没轮到跑)—— 顺手清掉,只清本前缀 + const stale = await admin.query('select datname from pg_database where datname like $1', [ + `${PREFIX}%`, + ]); + for (const row of stale.rows) { + await admin.query(`drop database if exists "${row.datname}" with (force)`); + process.stdout.write(`清掉上一跑的残留:${row.datname}\n`); + } + await admin.query(`create database "${name}"`); + } finally { + await admin.end(); + } + + const url = withDatabase(adminUrl(), name); + await applyInit(url); + + // 只把库名摆出来:派生串与 secret 不逐字相同,Gitea 的日志掩码盖不住它 + process.stdout.write(`本次跑的库:${name}\n`); + + const envFile = process.env['GITHUB_ENV']; + if (envFile === undefined) { + // 本地跑:没有 $GITHUB_ENV,把该导的那一行直接给出来(自己的终端,不存在日志外泄) + process.stdout.write(`export DATABASE_URL='${url}'\n`); + return; + } + appendFileSync(envFile, `DATABASE_URL=${url}\n`); +} + +async function dbDrop(name) { + const admin = await connect(adminUrl()); + try { + await admin.query(`drop database if exists "${name}" with (force)`); + } finally { + await admin.end(); + } + process.stdout.write(`已拆掉:${name}\n`); +} + +async function dbProbe() { + const url = process.env['DATABASE_URL']; + if (url === undefined || url === '') die('DATABASE_URL 没设 —— 建库那一步该把它写进 $GITHUB_ENV'); + + let client; + try { + client = await connect(url); + } catch (error) { + die( + `连不上库:${error instanceof Error ? error.message : String(error)}\n` + + '集成测试会因此静默跳过,这一跑等于什么都没测 —— 先修库,不是放松断言。', + ); + } + try { + const result = await client.query('select version()'); + process.stdout.write(`库连得上 ✔ ${result.rows[0].version}\n`); + } finally { + await client.end(); + } +} + +function assertIt(file) { + if (file === undefined) die('用法:node tools/ci.mjs assert-it '); + + let report; + try { + report = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + die(`读不到 vitest 的 json 报告(${file}):${error instanceof Error ? error.message : String(error)}`); + } + + const files = Array.isArray(report.testResults) ? report.testResults : []; + const target = files.find((one) => typeof one.name === 'string' && one.name.endsWith(LEDGER_IT)); + if (target === undefined) { + die(`vitest 报告里没有 ${LEDGER_IT} —— 集成测试整个没被收集到,这一跑等于什么都没测`); + } + + const cases = Array.isArray(target.assertionResults) ? target.assertionResults : []; + const passed = cases.filter((one) => one.status === 'passed'); + const skipped = cases.filter((one) => one.status !== 'passed' && one.status !== 'failed'); + + if (passed.length === 0 || skipped.length > 0) { + process.stderr.write( + `${LEDGER_IT}:${passed.length} 条通过、${skipped.length} 条被跳过。\n` + + '集成测试被跳过 = 这一跑什么都没测(库没连上时那几条会退化成 it.skip,而退出码仍是 0)。\n' + + '去看「建本次跑的一次性库」与「库真的连得上」两步,别改这道断言。\n', + ); + process.exit(1); + } + process.stdout.write(`集成测试真跑了 ✔ ${LEDGER_IT}:${passed.length} 条通过\n`); +} + +/** 被 import 时不跑分发器。用 pathToFileURL 归一 —— 拼字符串那种写法遇软链会不成立(实测过) */ +const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; + +const [command, argument] = process.argv.slice(2); +if (isMain) + switch (command) { + case 'db-create': + await dbCreate(checkName(argument)); + break; + case 'db-drop': + await dbDrop(checkName(argument)); + break; + case 'db-probe': + await dbProbe(); + break; + case 'assert-it': + assertIt(argument); + break; + default: + die('用法:node tools/ci.mjs [参数]'); +}