加固部署隔离与备份来源契约(07-26-internal-ops-recovery-baseline)

这个提交包含在:
2026-07-27 06:13:02 +08:00
父节点 130518f471
当前提交 23342035ee
共修改 16 个文件,包含 304 行新增和 80 行删除
+4
查看文件
@@ -98,6 +98,9 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE=
IM_NOTIFIER_ENABLED=true
# ===== 数据库(Compose 内部连接) =====
# Compose 项目名由 deploy/timer 命令通过 -p 显式传入,不写入本文件。
# 数据目录默认相对 deploy/trialrun/;生产切换时必须与项目名在同一停机窗口确认和启用。
POSTGRES_DATA_DIR=./data/postgres
POSTGRES_DB=<managed-db-name>
POSTGRES_USER=<managed-db-user>
POSTGRES_PASSWORD=<managed-db-password>
@@ -105,6 +108,7 @@ DATABASE_URL=<managed-postgresql-url>
# SHADOW_DATABASE_URL=<managed-shadow-postgresql-url>
# ===== 存储与上传 =====
STORAGE_HOST_DIR=./data/storage
STORAGE_DIR=/data/storage
STORAGE_DRIVER=local
S3_ENDPOINT=http://minio:9000
+14 -8
查看文件
@@ -25,6 +25,9 @@
- [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:<APP_HOST_PORT>`
## A. 发布前(Pre-flight)
- [ ] 已冻结唯一 `COMPOSE_PROJECT_NAME`,所有 deploy、timer、runbook 命令均显式使用 `-p`,未依赖目录名或固定容器名
- [ ] 已核对 `POSTGRES_DATA_DIR` 与 `STORAGE_HOST_DIR` 指向活动数据;项目名与数据目录只在同一次获批停机窗口切换
- [ ] 已证明没有两个 PostgreSQL 实例同时挂载同一 PGDATA;任何不一致均停止发布
- [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"`
- [ ] 本地门禁:`./scripts/check-minimal.sh`
- [ ] 部署契约校验:`node deploy/trialrun/validate-env-contract.js`
@@ -33,7 +36,9 @@
- [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh`
- [ ] 备份(DB + 文件存储):
- [ ] `set -a && source deploy/trialrun/.env && set +a`
- [ ] `DB_TOOL_MODE=docker DB_CONTAINER=hyb-oa-trialrun-db ./scripts/backup-all.sh`
- [ ] `DB_CONTAINER="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db)"` 返回唯一非空容器 ID
- [ ] `DB_TOOL_MODE=docker DB_CONTAINER="$DB_CONTAINER" STORAGE_DIR_HOST=<verified-active-storage-host-directory> ./scripts/backup-all.sh`
- [ ] `DB_CONTAINER` 或 `STORAGE_DIR_HOST` 缺失时备份硬失败,没有回退到历史容器名或仓库默认附件目录
- [ ] 同一批次包含 `database.dump`、`storage.tar.gz`、`manifest.json`,两份 SHA-256 校验通过
- [ ] 完整批次已复制到获批异机/异盘,并记录保留周期
@@ -44,12 +49,13 @@
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
- [ ] 探活:
- [ ] 已用 `docker-compose ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT`
- [ ] 已用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT`
- [ ] `/ready` 的 JSON 在 server 容器内用 Node 核对 build SHA;宿主机无需 Node.js
- [ ] Runtime smoke:
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js`
- [ ] 若 runtime smoke 报 `announcements_v1` 或 `global_search_v1` 为 `force_off`,已记录为发布阻塞,不忽略通过
- [ ] external deps smoke:
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js`
- [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过
- [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION`
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
@@ -61,7 +67,7 @@
- [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果
- [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项
- [ ] 查看 server 日志(无异常错误风暴):
- [ ] `docker logs --tail=200 hyb-oa-trialrun-server`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs --tail=200 server`
- [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务
- [ ] 已确认 live smoke 仅检查 `/ready`、登录、Portal、项目、公告、个人设置和企微配置状态
- [ ] 若涉及票据/OCR,已执行真实 OCR 辅助复核或记录跳过原因:`PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 ... npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts`
@@ -71,13 +77,13 @@
> 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。
- [ ] **EXTERNAL_GATED**:若回滚目标仍包含旧认证挑战逻辑,人工确认边缘继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;脚本不会自动改边缘,未确认不得回滚开放,直到安全版本恢复
- [ ] 停止 server:`docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server`
- [ ] 停止 server:`docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server`
- [ ] 恢复 DB/存储属于获批事故操作;当前脚本仅允许 `RESTORE_SCOPE=shadow`,不得绕过后直接指向真实目标
- [ ] 已先在独立数据库和空附件目录完成同批次影子恢复,数据库名精确确认且 manifest/SHA-256 匹配
- [ ] 回到上个版本:`git checkout <previous-tag>`
- [ ] 重启并探活:
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server`
- [ ] 已重新用 `docker-compose ... port server 3000` 解析实际宿主端口并执行 `/ready`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server`
- [ ] 已重新用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口并执行 `/ready`
- [ ] 若仅为 OCR 应急降级,已把远端 `.env` 的 `OCR_PROVIDER` 临时切回 `mock`,重启 server,并在 Issue/发布记录中写明原因、影响范围和恢复时间
+13 -5
查看文件
@@ -8,6 +8,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"
ENABLE_WORKER="${ENABLE_WORKER:-0}"
ENABLE_MINIO="${ENABLE_MINIO:-0}"
SYNC_ENV="${SYNC_ENV:-0}"
@@ -47,6 +48,11 @@ if [[ -z "$DEPLOY_SERVER" ]]; then
exit 1
fi
if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "[ERROR] COMPOSE_PROJECT_NAME 只能包含小写字母、数字、下划线和连字符" >&2
exit 1
fi
if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2
exit 1
@@ -137,7 +143,7 @@ fi
echo ">>> 6. 在服务器执行部署与 smoke"
ssh "${DEPLOY_SERVER}" \
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
set -euo pipefail
cd "$REMOTE_DIR"
mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio
@@ -148,9 +154,9 @@ if [[ ! -f "$REMOTE_ENV_FILE" ]]; then
fi
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
else
echo "[ERROR] 远端既没有 docker compose,也没有 docker-compose" >&2
exit 1
@@ -202,8 +208,10 @@ fi
ready_ok=0
for i in $(seq 1 60); do
if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then
if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e '
const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \
-e EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" server node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
'; then
ready_ok=1
+4 -12
查看文件
@@ -3,16 +3,13 @@ version: "3.8"
services:
db:
image: postgres:16-alpine
container_name: hyb-oa-trialrun-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
- ${POSTGRES_DATA_DIR:-./data/postgres}:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@@ -21,7 +18,6 @@ services:
minio:
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
container_name: hyb-oa-trialrun-minio
restart: unless-stopped
profiles: ["minio"]
environment:
@@ -36,7 +32,6 @@ services:
minio_init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: hyb-oa-trialrun-minio-init
profiles: ["minio"]
depends_on:
- minio
@@ -58,7 +53,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-server
restart: unless-stopped
depends_on:
db:
@@ -71,7 +65,7 @@ services:
ports:
- "127.0.0.1:${APP_HOST_PORT:-3000}:3000"
volumes:
- ./data/storage:${STORAGE_DIR}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
worker:
build:
@@ -80,7 +74,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-worker
restart: unless-stopped
profiles: ["worker"]
depends_on:
@@ -95,7 +88,7 @@ services:
SERVE_CLIENT_DIST: "false"
JOBS_ENABLED: "true"
volumes:
- ./data/storage:${STORAGE_DIR}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
init:
build:
@@ -104,7 +97,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-init
depends_on:
db:
condition: service_healthy
@@ -114,7 +106,7 @@ services:
NODE_ENV: production
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
volumes:
- ./data/storage:${STORAGE_DIR}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
entrypoint: ["sh", "-c"]
command: >
cd /app/server
+7 -1
查看文件
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"
SERVICE_NAME="${SERVICE_NAME:-hyb-oa-warch3-ops}"
RUN_AT="${RUN_AT:-07:30}"
MODE="${1:-install}"
@@ -37,6 +38,10 @@ install_timer() {
echo "[ERROR] 远端 env 不存在: $REMOTE_ENV_FILE" >&2
exit 1
fi
if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "[ERROR] COMPOSE_PROJECT_NAME 格式无效" >&2
exit 1
fi
local compose_exec
if docker compose version >/dev/null 2>&1; then
@@ -60,10 +65,11 @@ WorkingDirectory=${REMOTE_DIR}
Environment=REMOTE_DIR=${REMOTE_DIR}
Environment=REMOTE_ENV_FILE=${REMOTE_ENV_FILE}
Environment=COMPOSE_FILE=${COMPOSE_FILE}
Environment=COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
Environment=LIVE_SMOKE_BASE_URL=https://oa.hyinda.com
Environment=WARCH3_SEND_ALERT=1
Environment=WARCH3_OPS_ALERT_SEND=1
ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"'
ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"'
SERVICE
cat >"$TIMER_FILE" <<TIMER
+37 -28
查看文件
@@ -20,6 +20,7 @@
- 部署目录通过 `REMOTE_DIR` 显式传入。
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
- 所有 Compose 命令必须显式使用同一个 `COMPOSE_PROJECT_NAME` 和 `-p`;不得依赖目录名推导项目身份,也不得写死容器名。
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
- 企业微信与 OCR 已进入公网试运行配置。核查时只检查开关、长度和 smoke 结果,不在日志、Issue 或文档中输出 secret 明文。
@@ -46,6 +47,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
- `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。
- 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。
- `PORT=3000` 是容器内端口;`APP_HOST_PORT` 是宿主回环端口。发布脚本通过 Compose 动态解析后者,Caddy 必须指向相同值。
- `POSTGRES_DATA_DIR` 和 `STORAGE_HOST_DIR` 是宿主数据事实源;默认相对 `deploy/trialrun/`。生产环境更换项目名或任一数据目录只能在同一次获批停机窗口完成,禁止两个 PostgreSQL 实例同时挂载同一 PGDATA。
- 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。
- 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。
- **EXTERNAL_GATED**:涉及认证安全迁移时,发布或回滚前必须由操作人先在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再执行“新镜像配置预检 -> additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。
@@ -65,10 +67,11 @@ cd /path/to/hyb-oa
node deploy/trialrun/validate-env-contract.js
export BUILD_SHA="<approved-full-commit>"
export VITE_BUILD_SHA="<matching-12-char-commit>"
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
export COMPOSE_PROJECT_NAME="<approved-compose-project>"
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。
@@ -76,7 +79,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
可选:启用 MinIO(对象存储,F21):
```bash
cd /path/to/hyb-oa
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init
```
可选:启用 Worker 进程(推荐在试运行/多进程模式使用):
@@ -84,13 +87,13 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
cd /path/to/hyb-oa
# 1) 在 deploy/trialrun/.env 中设置:JOBS_ENABLED="false"
# 2) 启用 worker profile(会额外启动 worker 容器)
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker
```
### 1.3 初始化(迁移 + 配置 seed + 试运行管理员)
```bash
cd /path/to/hyb-oa
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
```
说明:
@@ -103,12 +106,12 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
### 1.4 验收
```bash
SERVER_HOST_PORT="$(docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)"
SERVER_HOST_PORT="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)"
SERVER_HOST_PORT="${SERVER_HOST_PORT##*:}"
curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/health"
curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/metrics" | head
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
```
说明:
@@ -122,7 +125,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
- 2026-05-05 公网复核显示:`OCR_PROVIDER=tencent` 可用,真实发票上传后进入 `available`,provider 为 `tencent`;后续发布不能再把 OCR 回退到 mock 而不记录原因。
浏览器访问:
- `docker-compose ... port server 3000` 返回的宿主回环端口(单域托管前端)。
- `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 返回的宿主回环端口(单域托管前端)。
### 1.4.1 腾讯 OCR 试运行核查
远端 `.env` 是 OCR 配置事实源,禁止提交或复制密钥明文。只做以下脱敏检查:
@@ -147,8 +150,8 @@ printf 'TENCENT_OCR_REGION=%s\n' "$TENCENT_OCR_REGION"
发布后 smoke:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
```
真实 OCR 辅助复核(本地执行,不提交发票文件):
@@ -164,7 +167,7 @@ npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --repo
回滚到 mock 只允许作为应急降级:
1. 在远端 `.env` 将 `OCR_PROVIDER=tencent` 改为 `OCR_PROVIDER=mock`。
2. 执行 `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。
2. 执行 `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。
3. 在 Issue/发布记录中写明降级原因、预计恢复时间和影响范围。
## 1.5 备份与恢复演练(正式启用前必须完成)
@@ -177,12 +180,17 @@ set -a
source deploy/trialrun/.env
set +a
mapfile -t DB_CONTAINERS < <(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db)
test "${#DB_CONTAINERS[@]}" -eq 1
DB_CONTAINER="${DB_CONTAINERS[0]}"
DB_TOOL_MODE=docker \
DB_CONTAINER=hyb-oa-trialrun-db \
DB_CONTAINER="$DB_CONTAINER" \
STORAGE_DIR_HOST="<verified-active-storage-host-directory>" \
./scripts/backup-all.sh
```
`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。
`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。`DB_CONTAINER` 和 `STORAGE_DIR_HOST` 均无隐式默认值:前者必须由已核验 Compose 项目的 `ps -q db` 解析,后者必须与活动 server 的附件挂载源一致。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。
输出默认在 `deploy/trialrun/backups/hyb-oa-backup-<run-id>/`,其中包含:
@@ -267,12 +275,13 @@ RESTORE_SCOPE=shadow \
步骤:
1) 先做备份(见 E14 脚本)
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
- 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录迁移在同一停机窗口启用;本地提交本身不授权切换。
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
3) 构建并重启:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
```
### 2.2 前端部署一致性排障
@@ -283,13 +292,13 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
```bash
# 1) 确认 Caddy 指向 Compose 实际发布的宿主回环端口
sudo sed -n '/oa.hyinda.com/,/}/p' /etc/caddy/Caddyfile
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000
# 2) 比对公网首页引用的 asset hash
curl -fsS https://oa.hyinda.com/ | grep -o '/assets/[^" ]*' | head
# 3) 在容器内查构建产物是否包含关键 test id
docker exec hyb-oa-trialrun-server sh -lc \
# 3) 在当前 Compose 项目的 server 容器内查构建产物是否包含关键 test id
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server sh -lc \
'for pat in invoice-pool-mobile-list reimbursements-mobile-list mobile-capture-file multiple:!0; do printf "%s " "$pat"; grep -Rao "$pat" /app/client/dist/assets /app/client/dist/index.html 2>/dev/null | wc -l; done'
```
@@ -297,9 +306,9 @@ docker exec hyb-oa-trialrun-server sh -lc \
```bash
cd /opt/hyb-oa-trialrun
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm -T init </dev/null
SERVER_HOST_PORT="$(docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)"
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm -T init </dev/null
SERVER_HOST_PORT="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)"
curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT##*:}/ready"
```
@@ -318,13 +327,13 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT##*:}/ready"
```bash
# 远端目录不是 git 工作树,推荐在本地切到目标 tag 后重新执行 deploy.sh
# git checkout <previous-tag>
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
### 3.2 需要 DB 回滚(推荐做法)
1) 停止服务:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server
```
2) 按 E14 恢复 DB + 文件存储备份
3) 启动服务并验收 `/ready` 与登录
@@ -332,7 +341,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
## 4. 日志与排障
- server 容器日志:
```bash
docker logs -f hyb-oa-trialrun-server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs -f server
```
- `/ready` 失败通常为 DB 不可达或连接串错误。
@@ -453,7 +462,7 @@ npm run test:e2e -- tests/e2e/manual/live-invoice-ocr-batch.spec.ts
- `OPENAPI_DOCS_ENABLED=false`
3. 重新执行:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
4. 验证浏览器和企业微信 WebView 都能正常登录、刷新 token 与退出登录。
+56 -2
查看文件
@@ -4,10 +4,21 @@ const path = require('node:path');
const rootDir = path.resolve(__dirname, '..', '..');
const configPath = path.join(rootDir, 'server/src/config.js');
const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example');
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
const envExamplePath = path.resolve(
process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'),
);
const composePath = path.resolve(
process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'),
);
const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md');
const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md');
const deployScriptPath = path.resolve(
process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'),
);
const timerScriptPath = path.resolve(
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|| path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'),
);
const nginxPaths = [
path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'),
path.join(rootDir, 'deploy/trialrun/nginx.conf.example'),
@@ -130,6 +141,8 @@ const envExampleContent = readFile(envExamplePath);
const composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
const deployScriptContent = readFile(deployScriptPath);
const timerScriptContent = readFile(timerScriptPath);
const nginxContents = nginxPaths.map((filePath) => ({
filePath,
content: readFile(filePath),
@@ -150,6 +163,15 @@ for (const key of deploymentInjectedEnvKeys) {
}
}
for (const key of ['APP_HOST_PORT', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR']) {
if (!envExampleKeys.has(key)) {
errors.push(`.env.example 缺少 Compose 宿主契约键 ${key}`);
}
}
if (envExampleKeys.has('COMPOSE_PROJECT_NAME')) {
errors.push('.env.example 不应持久化 COMPOSE_PROJECT_NAME,调用方必须通过 -p 显式选择项目');
}
for (const serviceName of requiredServices) {
const block = extractServiceBlock(composeContent, serviceName);
if (!block) {
@@ -189,6 +211,38 @@ if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000[
errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址');
}
const dbBlock = extractServiceBlock(composeContent, 'db');
if (!dbBlock) {
errors.push('docker-compose.yml 缺少服务 db');
} else {
if (/^\s{4}ports:\s*$/m.test(dbBlock)) {
errors.push('服务 db 不得向宿主发布端口');
}
if (!/\$\{POSTGRES_DATA_DIR:-\.\/data\/postgres\}:\/var\/lib\/postgresql\/data/.test(dbBlock)) {
errors.push('服务 db 必须通过 POSTGRES_DATA_DIR 参数化宿主数据目录');
}
}
if (/^\s+container_name:\s*/m.test(composeContent)) {
errors.push('docker-compose.yml 不得固定 container_name,容器身份必须由 Compose project 隔离');
}
for (const serviceName of ['server', 'worker', 'init']) {
const block = extractServiceBlock(composeContent, serviceName);
if (block && !/\$\{STORAGE_HOST_DIR:-\.\/data\/storage\}:\$\{STORAGE_DIR\}/.test(block)) {
errors.push(`服务 ${serviceName} 必须通过 STORAGE_HOST_DIR 参数化宿主附件目录`);
}
}
for (const [name, content] of [
['deploy.sh', deployScriptContent],
['install-warch3-ops-timer.sh', timerScriptContent],
]) {
if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) {
errors.push(`${name} 必须向 Compose 显式传入独立 project name`);
}
}
for (const { filePath, content } of nginxContents) {
const relativePath = path.relative(rootDir, filePath);
if (content.includes('$proxy_add_x_forwarded_for')) {