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