R26-1 R26-2 R26-3: 修复 env 注入并补部署校验与 smoke
这个提交包含在:
+157
-107
@@ -1,5 +1,11 @@
|
||||
## hyb-oa 试运行环境变量示例(单机)
|
||||
##
|
||||
## 说明:
|
||||
## - 统一以 deploy/trialrun/.env 作为 trialrun 部署事实源。
|
||||
## - 默认使用 env_file 注入 server / worker / init 容器环境。
|
||||
## - 推荐使用无引号值;旧的带引号写法当前仍兼容,但建议后续逐步改成无引号。
|
||||
## - 普通字符串、布尔值、数字、cron 都可直接写为无引号值。
|
||||
##
|
||||
## 使用方式:
|
||||
## cp deploy/trialrun/.env.example deploy/trialrun/.env
|
||||
## 然后按需修改 deploy/trialrun/.env 中的值(不要提交 .env)
|
||||
@@ -9,128 +15,172 @@
|
||||
## - OA 单独使用 oa.example.com 一类二级域名
|
||||
## - 正式公网环境请保持 APP_ORIGIN=https://oa.example.com
|
||||
## - 正式公网环境请保持 COOKIE_SECURE=true
|
||||
## - 若仅做本机临时演练,再改回 http://127.0.0.1:3000 和 COOKIE_SECURE=false
|
||||
##
|
||||
|
||||
# ===== 基础域名(用于 refresh cookie 的 origin 校验)=====
|
||||
# 单域托管模式下,前端与后端同域访问(建议生产/试运行)。
|
||||
APP_ORIGIN="https://oa.example.com"
|
||||
# ===== 运行模式与基础域名 =====
|
||||
NODE_ENV=production
|
||||
APP_ORIGIN=https://oa.example.com
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
COOKIE_SECURE=true
|
||||
OPENAPI_DOCS_ENABLED=false
|
||||
SERVE_CLIENT_DIST=true
|
||||
CLIENT_DIST_DIR=../client/dist
|
||||
SIGNED_URL_TTL_SECONDS=600
|
||||
MAX_UPLOAD_SIZE_BYTES=10485760
|
||||
|
||||
# ===== 服务端密钥(必须修改) =====
|
||||
JWT_SECRET="change_me_in_trialrun"
|
||||
# ===== 敏感字段加密(F25)=====
|
||||
# 生成方式示例:openssl rand -base64 32
|
||||
FIELD_ENCRYPTION_KEY=""
|
||||
JWT_SECRET=change_me_in_trialrun
|
||||
FIELD_ENCRYPTION_KEY=
|
||||
|
||||
# ===== OIDC/SSO(F23,可选)=====
|
||||
# OIDC_ENABLED="true"
|
||||
# OIDC_ISSUER_URL=""
|
||||
# OIDC_CLIENT_ID=""
|
||||
# OIDC_CLIENT_SECRET=""
|
||||
# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback"
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
# ===== 密码 / 登录安全 =====
|
||||
PASSWORD_RESET_CODE_TTL_SECONDS=600
|
||||
DEV_SHOW_PASSWORD_RESET_CODE=false
|
||||
AUTH_LOGIN_FAIL_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_FAIL_MAX_ATTEMPTS=5
|
||||
AUTH_LOGIN_LOCK_SECONDS=1800
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_LOGIN_MAX=10
|
||||
RATE_LIMIT_LOGIN_WINDOW=1 minute
|
||||
PUBLIC_SECURITY_HARDENING_ENABLED=true
|
||||
|
||||
# ===== 企业微信入口 / OAuth(R18,可选) =====
|
||||
WECOM_ENABLED="false"
|
||||
WECOM_ENTRY_ENABLED="false"
|
||||
WECOM_CORP_ID=""
|
||||
WECOM_AGENT_ID=""
|
||||
WECOM_AGENT_SECRET=""
|
||||
WECOM_OAUTH_ENABLED="false"
|
||||
WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
|
||||
# 可填写多个 host,逗号分隔,用于企微回跳和公网 returnTo 白名单
|
||||
WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
|
||||
IM_NOTIFIER_ENABLED="true"
|
||||
# ===== 可观测性 =====
|
||||
METRICS_ENABLED=true
|
||||
OTEL_TRACES_ENABLED=false
|
||||
|
||||
# ===== OIDC / SSO(可选) =====
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=https://oa.example.com/api/auth/oidc/callback
|
||||
OIDC_SCOPES=openid profile email
|
||||
|
||||
# ===== 企业微信入口 / OAuth(可选) =====
|
||||
WECOM_ENABLED=false
|
||||
WECOM_ENTRY_ENABLED=false
|
||||
WECOM_CORP_ID=
|
||||
WECOM_AGENT_ID=
|
||||
WECOM_AGENT_SECRET=
|
||||
WECOM_OAUTH_ENABLED=false
|
||||
WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback
|
||||
WECOM_ALLOWED_RETURN_HOSTS=oa.example.com
|
||||
IM_NOTIFIER_ENABLED=true
|
||||
|
||||
# ===== 数据库(Compose 内部连接) =====
|
||||
POSTGRES_DB="hyb_oa"
|
||||
POSTGRES_USER="hyboa_user"
|
||||
POSTGRES_PASSWORD="hyboa_password"
|
||||
DATABASE_URL="postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public"
|
||||
POSTGRES_DB=hyb_oa
|
||||
POSTGRES_USER=hyboa_user
|
||||
POSTGRES_PASSWORD=hyboa_password
|
||||
DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public
|
||||
# SHADOW_DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow
|
||||
|
||||
# ===== 存储(票据上传目录)=====
|
||||
# 在 deploy/trialrun/docker-compose.yml 中映射到宿主机 deploy/trialrun/data/storage
|
||||
STORAGE_DIR="/data/storage"
|
||||
# 可选:对象存储(MinIO/S3 兼容,F21)
|
||||
# - 默认 local:文件写到 STORAGE_DIR
|
||||
# - 启用 MinIO:STORAGE_DRIVER="s3" 并启用 compose profile:--profile minio
|
||||
STORAGE_DRIVER="local" # local|s3
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
S3_REGION="us-east-1"
|
||||
S3_BUCKET="hyb-oa"
|
||||
S3_ACCESS_KEY_ID="minioadmin"
|
||||
S3_SECRET_ACCESS_KEY="minioadmin"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
# ===== 存储与上传 =====
|
||||
STORAGE_DIR=/data/storage
|
||||
STORAGE_DRIVER=local
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=hyb-oa
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
MINIO_ROOT_USER="minioadmin"
|
||||
MINIO_ROOT_PASSWORD="minioadmin"
|
||||
# ===== OCR(off | mock | tencent) =====
|
||||
OCR_PROVIDER=mock
|
||||
OCR_CRON_ENABLED=true
|
||||
OCR_MAX_ATTEMPTS=3
|
||||
OCR_POLL_CRON=*/5 * * * * *
|
||||
OCR_BATCH_SIZE=10
|
||||
OCR_LOCK_TTL_SECONDS=300
|
||||
OCR_TIMEOUT_MS=5000
|
||||
TENCENT_SECRET_ID=
|
||||
TENCENT_SECRET_KEY=
|
||||
TENCENT_OCR_REGION=ap-beijing
|
||||
|
||||
# ===== 单域托管 client/dist =====
|
||||
SERVE_CLIENT_DIST="true"
|
||||
CLIENT_DIST_DIR="../client/dist"
|
||||
OPENAPI_DOCS_ENABLED="false"
|
||||
# ===== 验真 / 发票 / 节假日 =====
|
||||
INVOICE_VERIFICATION_ENABLED=false
|
||||
LEAVE_HOLIDAY_CALENDAR_ENABLED=false
|
||||
VERIFICATION_PROVIDER=mock
|
||||
VERIFICATION_POLL_CRON=* * * * *
|
||||
VERIFICATION_MAX_ATTEMPTS=3
|
||||
VERIFICATION_RETRY_COOLDOWN_SECONDS=60
|
||||
VERIFICATION_TIMEOUT_MS=5000
|
||||
INVOICE_ARCHIVE_CRON=30 2 * * *
|
||||
INVOICE_ARCHIVE_UNUSED_DAYS=90
|
||||
INVOICE_ARCHIVE_BATCH_SIZE=200
|
||||
|
||||
# ===== 网络与端口 =====
|
||||
HOST="0.0.0.0"
|
||||
PORT="3000"
|
||||
# ===== 预算 / 费控 =====
|
||||
BUDGET_CONTROL_MODE=warn
|
||||
BUDGET_PRECHECK_ENABLED=false
|
||||
PAYMENT_BATCH_ENABLED=false
|
||||
|
||||
# ===== Cookie 安全(HTTPS 反代时建议 true)=====
|
||||
COOKIE_SECURE="true"
|
||||
# ===== 周边集成 / ERP =====
|
||||
ERP_SYNC_ENABLED=false
|
||||
ERP_SYNC_PROVIDER=mock
|
||||
ERP_SYNC_ENDPOINT=
|
||||
ERP_SYNC_TIMEOUT_MS=5000
|
||||
ERP_SYNC_AUTH_TOKEN=
|
||||
ERP_JOURNAL_EXPORT_ENDPOINT=
|
||||
ERP_PROCUREMENT_EXPORT_ENDPOINT=
|
||||
ERP_ASSET_EXPORT_ENDPOINT=
|
||||
ERP_MASTER_DATA_ENDPOINT=
|
||||
ERP_PROCUREMENT_TIMEOUT_MS=5000
|
||||
ERP_ASSET_TIMEOUT_MS=5000
|
||||
ERP_MASTER_DATA_TIMEOUT_MS=5000
|
||||
ERP_MASTER_DATA_SYNC_CRON=
|
||||
TRAVEL_SYNC_ENABLED=false
|
||||
INTEGRATION_OUTBOX_CRON=* * * * *
|
||||
INTEGRATION_OUTBOX_BATCH_SIZE=20
|
||||
INTEGRATION_OUTBOX_MAX_ATTEMPTS=5
|
||||
INTEGRATION_OUTBOX_RETRY_SECONDS=120
|
||||
|
||||
# ===== Metrics(Prometheus)=====
|
||||
METRICS_ENABLED="true"
|
||||
# ===== 邮件 =====
|
||||
EMAIL_ENABLED=false
|
||||
EMAIL_FROM=[email protected]
|
||||
EMAIL_BASE_URL=https://oa.example.com
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
|
||||
# ===== 安全基线(试运行建议打开)=====
|
||||
RATE_LIMIT_ENABLED="true"
|
||||
RATE_LIMIT_LOGIN_MAX="10"
|
||||
RATE_LIMIT_LOGIN_WINDOW="1 minute"
|
||||
# ===== 委派 / 作业 =====
|
||||
DELEGATION_REVOKE_CRON=0 * * * *
|
||||
JOB_DRIVER=pgboss
|
||||
JOBS_ENABLED=true
|
||||
PG_BOSS_SCHEMA=pgboss
|
||||
|
||||
# ===== OCR(试运行可先用 mock)=====
|
||||
OCR_PROVIDER="mock" # off|mock|tencent
|
||||
OCR_MAX_ATTEMPTS="3"
|
||||
OCR_POLL_CRON="*/5 * * * * *" # 试运行/演练可用秒级;生产建议分钟级
|
||||
OCR_BATCH_SIZE="10"
|
||||
OCR_LOCK_TTL_SECONDS="300"
|
||||
OCR_TIMEOUT_MS="5000"
|
||||
TENCENT_SECRET_ID=""
|
||||
TENCENT_SECRET_KEY=""
|
||||
TENCENT_OCR_REGION="ap-beijing"
|
||||
# ===== AI(默认关闭) =====
|
||||
AI_ASSIST_ENABLED=false
|
||||
AI_PROVIDER=mock
|
||||
AI_BASE_URL=
|
||||
AI_MODEL=
|
||||
AI_TIMEOUT_MS=10000
|
||||
AI_AUTH_TOKEN=
|
||||
|
||||
# ===== 委派回收 =====
|
||||
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||
# ===== Trialrun 初始化管理员 =====
|
||||
TRIALRUN_ADMIN_EMAIL=[email protected]
|
||||
TRIALRUN_ADMIN_PHONE=13800000000
|
||||
TRIALRUN_ADMIN_PASSWORD=
|
||||
TRIALRUN_ADMIN_EMPLOYEE_NO=T0000
|
||||
TRIALRUN_ADMIN_NAME=Trialrun Admin
|
||||
TRIALRUN_ADMIN_TITLE=系统管理员
|
||||
TRIALRUN_ADMIN_HIRE_DATE=2020-01-01
|
||||
|
||||
# ===== 作业系统(pg-boss,可回滚到 cron)=====
|
||||
JOB_DRIVER="pgboss" # cron|pgboss
|
||||
JOBS_ENABLED="true" # false 时整体关闭作业(排障/回滚/或启用 worker 拆分)
|
||||
PG_BOSS_SCHEMA="pgboss" # pg-boss 在 PG 内的 schema
|
||||
|
||||
# ===== Worker 拆分(可选,F20)=====
|
||||
# 如需启用“API 进程 + Worker 进程”:
|
||||
# 1) 设置 JOBS_ENABLED="false"(server 不跑作业)
|
||||
# 2) 启动 compose 时加:--profile worker(会启动 worker 容器,worker 内强制 JOBS_ENABLED=true)
|
||||
|
||||
# ===== 试运行引导管理员(E20)=====
|
||||
TRIALRUN_ADMIN_EMAIL="[email protected]"
|
||||
TRIALRUN_ADMIN_PHONE="13800000000"
|
||||
# 留空则首次 seed 自动生成并打印一次性口令
|
||||
TRIALRUN_ADMIN_PASSWORD=""
|
||||
|
||||
# ===== Prisma Migration Diff(本地维护 migration 可选) =====
|
||||
# SHADOW_DATABASE_URL="postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow"
|
||||
|
||||
# ===== R13-R17 小团队内部管理默认上线建议 =====
|
||||
PORTAL_HOME_ENABLED="true"
|
||||
GLOBAL_SEARCH_ENABLED="true"
|
||||
ANNOUNCEMENTS_ENABLED="true"
|
||||
PRESENCE_BOARD_ENABLED="true"
|
||||
WEEKLY_REPORT_ENABLED="true"
|
||||
EMPLOYEE_LIFECYCLE_ENABLED="true"
|
||||
TODO_BOARD_ENABLED="true"
|
||||
PROJECT_DOMAIN_ENABLED="true"
|
||||
PROJECT_LINKING_ENABLED="true"
|
||||
PROJECT_COMMUNICATION_LOG_ENABLED="true"
|
||||
PROJECT_CLOSURE_CHECKLIST_ENABLED="true"
|
||||
CONFIDENTIAL_DOC_REFS_ENABLED="true"
|
||||
PUBLIC_SECURITY_HARDENING_ENABLED="true"
|
||||
PAYMENT_BATCH_ENABLED="false"
|
||||
CUTOVER_CONSOLE_ENABLED="false"
|
||||
# ===== 功能开关环境闸门(trialrun 推荐) =====
|
||||
PORTAL_HOME_ENABLED=true
|
||||
GLOBAL_SEARCH_ENABLED=true
|
||||
ANNOUNCEMENTS_ENABLED=true
|
||||
PRESENCE_BOARD_ENABLED=true
|
||||
WEEKLY_REPORT_ENABLED=true
|
||||
EMPLOYEE_LIFECYCLE_ENABLED=true
|
||||
TODO_BOARD_ENABLED=true
|
||||
WORKFLOW_DEFINITION_ADMIN_ENABLED=false
|
||||
CUTOVER_CONSOLE_ENABLED=false
|
||||
PROJECT_DOMAIN_ENABLED=true
|
||||
PROJECT_LINKING_ENABLED=true
|
||||
PROJECT_COMMUNICATION_LOG_ENABLED=true
|
||||
PROJECT_CLOSURE_CHECKLIST_ENABLED=true
|
||||
CONFIDENTIAL_DOC_REFS_ENABLED=true
|
||||
|
||||
+50
-2
@@ -13,6 +13,8 @@ ENABLE_MINIO="${ENABLE_MINIO:-0}"
|
||||
SYNC_ENV="${SYNC_ENV:-0}"
|
||||
COMPOSE_FILE="deploy/trialrun/docker-compose.yml"
|
||||
LOCAL_ENV_FILE="deploy/trialrun/.env"
|
||||
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
|
||||
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
@@ -23,17 +25,31 @@ need_cmd() {
|
||||
|
||||
need_cmd rsync
|
||||
need_cmd ssh
|
||||
need_cmd node
|
||||
|
||||
if [[ ! -f "${ROOT_DIR}/${COMPOSE_FILE}" ]]; then
|
||||
echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}" ]]; then
|
||||
echo "[ERROR] 缺少 ${ENV_CONTRACT_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ROOT_DIR}/${RUNTIME_SMOKE_SCRIPT}" ]]; then
|
||||
echo "[ERROR] 缺少 ${RUNTIME_SMOKE_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
|
||||
echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 0. 校验部署环境契约"
|
||||
node "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}"
|
||||
|
||||
echo ">>> 1. 创建远端目录"
|
||||
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
|
||||
|
||||
@@ -67,7 +83,7 @@ else
|
||||
echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh"
|
||||
fi
|
||||
|
||||
echo ">>> 4. 在服务器执行部署"
|
||||
echo ">>> 4. 在服务器执行部署与 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}' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
@@ -96,6 +112,10 @@ if [[ "$ENABLE_WORKER" == "1" ]]; then
|
||||
profile_args+=(--profile worker)
|
||||
fi
|
||||
|
||||
echo ">>> 4.1 校验远端 compose 配置"
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" config >/dev/null
|
||||
|
||||
echo ">>> 4.2 启动基础服务"
|
||||
"${compose_cmd[@]}" up -d db
|
||||
if [[ "$ENABLE_MINIO" == "1" ]]; then
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" up -d minio minio_init
|
||||
@@ -104,11 +124,39 @@ fi
|
||||
if [[ "$ENABLE_WORKER" == "1" ]]; then
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" up -d worker
|
||||
fi
|
||||
"${compose_cmd[@]}" run --rm init
|
||||
|
||||
echo ">>> 4.3 执行 init"
|
||||
"${compose_cmd[@]}" run --rm -T init </dev/null
|
||||
|
||||
echo ">>> 4.4 等待 /ready"
|
||||
ready_ok=0
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://127.0.0.1:3000/ready >/dev/null; then
|
||||
ready_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$ready_ok" != "1" ]]; then
|
||||
echo "[ERROR] /ready 在超时时间内未通过" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.5 执行 runtime smoke"
|
||||
if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js; then
|
||||
echo "[ERROR] runtime smoke 失败,输出最近 server 日志" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.6 当前服务状态"
|
||||
"${compose_cmd[@]}" ps
|
||||
REMOTE
|
||||
|
||||
echo ">>> 5. 部署完成"
|
||||
echo " 远端目录: ${REMOTE_DIR}"
|
||||
echo " 环境文件: ${REMOTE_ENV_FILE}"
|
||||
echo " 已自动执行:契约校验 + /ready + runtime smoke"
|
||||
echo " 请确认 Nginx 已把 OA 域名反代到 127.0.0.1:3000"
|
||||
|
||||
+10
-119
@@ -40,6 +40,8 @@ services:
|
||||
profiles: ["minio"]
|
||||
depends_on:
|
||||
- minio
|
||||
env_file:
|
||||
- ./.env
|
||||
entrypoint: ["sh", "-c"]
|
||||
command: |
|
||||
set -e
|
||||
@@ -58,52 +60,10 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_REGION: ${S3_REGION}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE}
|
||||
HOST: ${HOST}
|
||||
PORT: ${PORT}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE}
|
||||
SERVE_CLIENT_DIST: ${SERVE_CLIENT_DIST}
|
||||
CLIENT_DIST_DIR: ${CLIENT_DIST_DIR}
|
||||
|
||||
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED}
|
||||
RATE_LIMIT_LOGIN_MAX: ${RATE_LIMIT_LOGIN_MAX}
|
||||
RATE_LIMIT_LOGIN_WINDOW: ${RATE_LIMIT_LOGIN_WINDOW}
|
||||
|
||||
OCR_PROVIDER: ${OCR_PROVIDER}
|
||||
OCR_MAX_ATTEMPTS: ${OCR_MAX_ATTEMPTS}
|
||||
OCR_POLL_CRON: ${OCR_POLL_CRON}
|
||||
OCR_BATCH_SIZE: ${OCR_BATCH_SIZE}
|
||||
OCR_LOCK_TTL_SECONDS: ${OCR_LOCK_TTL_SECONDS}
|
||||
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS}
|
||||
TENCENT_SECRET_ID: ${TENCENT_SECRET_ID}
|
||||
TENCENT_SECRET_KEY: ${TENCENT_SECRET_KEY}
|
||||
TENCENT_OCR_REGION: ${TENCENT_OCR_REGION}
|
||||
|
||||
DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON}
|
||||
|
||||
JOB_DRIVER: ${JOB_DRIVER}
|
||||
JOBS_ENABLED: ${JOBS_ENABLED}
|
||||
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
@@ -119,46 +79,13 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env
|
||||
command: ["npm", "run", "worker"]
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_REGION: ${S3_REGION}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE}
|
||||
SERVE_CLIENT_DIST: "false"
|
||||
CLIENT_DIST_DIR: ${CLIENT_DIST_DIR}
|
||||
|
||||
OCR_PROVIDER: ${OCR_PROVIDER}
|
||||
OCR_MAX_ATTEMPTS: ${OCR_MAX_ATTEMPTS}
|
||||
OCR_POLL_CRON: ${OCR_POLL_CRON}
|
||||
OCR_BATCH_SIZE: ${OCR_BATCH_SIZE}
|
||||
OCR_LOCK_TTL_SECONDS: ${OCR_LOCK_TTL_SECONDS}
|
||||
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS}
|
||||
TENCENT_SECRET_ID: ${TENCENT_SECRET_ID}
|
||||
TENCENT_SECRET_KEY: ${TENCENT_SECRET_KEY}
|
||||
TENCENT_OCR_REGION: ${TENCENT_OCR_REGION}
|
||||
|
||||
DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON}
|
||||
|
||||
JOB_DRIVER: ${JOB_DRIVER}
|
||||
JOBS_ENABLED: "true"
|
||||
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
||||
volumes:
|
||||
- ./data/storage:${STORAGE_DIR}
|
||||
|
||||
@@ -170,52 +97,16 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_REGION: ${S3_REGION}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE}
|
||||
SERVE_CLIENT_DIST: ${SERVE_CLIENT_DIST}
|
||||
CLIENT_DIST_DIR: ${CLIENT_DIST_DIR}
|
||||
OCR_PROVIDER: ${OCR_PROVIDER}
|
||||
OCR_POLL_CRON: ${OCR_POLL_CRON}
|
||||
OCR_BATCH_SIZE: ${OCR_BATCH_SIZE}
|
||||
OCR_LOCK_TTL_SECONDS: ${OCR_LOCK_TTL_SECONDS}
|
||||
OCR_MAX_ATTEMPTS: ${OCR_MAX_ATTEMPTS}
|
||||
OCR_TIMEOUT_MS: ${OCR_TIMEOUT_MS}
|
||||
TENCENT_SECRET_ID: ${TENCENT_SECRET_ID}
|
||||
TENCENT_SECRET_KEY: ${TENCENT_SECRET_KEY}
|
||||
TENCENT_OCR_REGION: ${TENCENT_OCR_REGION}
|
||||
DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON}
|
||||
JOB_DRIVER: ${JOB_DRIVER}
|
||||
JOBS_ENABLED: ${JOBS_ENABLED}
|
||||
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
|
||||
|
||||
TRIALRUN_ADMIN_EMAIL: ${TRIALRUN_ADMIN_EMAIL}
|
||||
TRIALRUN_ADMIN_PHONE: ${TRIALRUN_ADMIN_PHONE}
|
||||
TRIALRUN_ADMIN_PASSWORD: ${TRIALRUN_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- ./data/storage:${STORAGE_DIR}
|
||||
entrypoint: ["sh", "-c"]
|
||||
command: >
|
||||
"cd /app/server
|
||||
cd /app/server
|
||||
&& npm run prisma:migrate:deploy
|
||||
&& npm run seed:config
|
||||
&& npm run seed:trialrun"
|
||||
&& npm run seed:trialrun
|
||||
restart: "no"
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
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 requiredServices = ['server', 'worker', 'init'];
|
||||
const allowedOverrides = {
|
||||
server: ['NODE_ENV'],
|
||||
worker: ['NODE_ENV', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'],
|
||||
init: ['NODE_ENV'],
|
||||
};
|
||||
|
||||
function readFile(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function extractConfigEnvKeys(content) {
|
||||
const keys = new Set();
|
||||
for (const match of content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
for (const match of content.matchAll(/requiredEnv\("([A-Z0-9_]+)"\)/g)) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function extractEnvExampleKeys(content) {
|
||||
const keys = new Set();
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*#?\s*([A-Z0-9_]+)\s*=/);
|
||||
if (match) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function extractServiceBlock(composeContent, serviceName) {
|
||||
const lines = composeContent.split(/\r?\n/);
|
||||
let capture = false;
|
||||
const block = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const serviceMatch = line.match(/^ ([A-Za-z0-9_]+):\s*$/);
|
||||
if (serviceMatch) {
|
||||
if (capture) {
|
||||
break;
|
||||
}
|
||||
capture = serviceMatch[1] === serviceName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capture) {
|
||||
block.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return block.length ? block.join('\n') : null;
|
||||
}
|
||||
|
||||
function extractSection(serviceBlock, sectionName) {
|
||||
const lines = serviceBlock.split(/\r?\n/);
|
||||
const sectionLines = [];
|
||||
let capture = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const sectionMatch = line.match(/^\s{4}([A-Za-z0-9_]+):\s*$/);
|
||||
if (sectionMatch) {
|
||||
if (capture) {
|
||||
break;
|
||||
}
|
||||
capture = sectionMatch[1] === sectionName;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capture) {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return sectionLines;
|
||||
}
|
||||
|
||||
function extractEnvFileEntries(serviceBlock) {
|
||||
return extractSection(serviceBlock, 'env_file')
|
||||
.map((line) => line.match(/^\s{6}-\s*(.+?)\s*$/))
|
||||
.filter(Boolean)
|
||||
.map((item) => item[1]);
|
||||
}
|
||||
|
||||
function extractEnvironmentKeys(serviceBlock) {
|
||||
return extractSection(serviceBlock, 'environment')
|
||||
.map((line) => line.match(/^\s{6}([A-Z0-9_]+):/))
|
||||
.filter(Boolean)
|
||||
.map((item) => item[1]);
|
||||
}
|
||||
|
||||
function fail(message, errors) {
|
||||
console.error(`[R26][FAIL] ${message}`);
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = readFile(configPath);
|
||||
const envExampleContent = readFile(envExamplePath);
|
||||
const composeContent = readFile(composePath);
|
||||
|
||||
const configKeys = extractConfigEnvKeys(configContent);
|
||||
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
|
||||
const errors = [];
|
||||
|
||||
for (const key of configKeys) {
|
||||
if (!envExampleKeys.has(key)) {
|
||||
errors.push(`.env.example 缺少 ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceName of requiredServices) {
|
||||
const block = extractServiceBlock(composeContent, serviceName);
|
||||
if (!block) {
|
||||
errors.push(`docker-compose.yml 缺少服务 ${serviceName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const envFiles = extractEnvFileEntries(block);
|
||||
if (!envFiles.includes('./.env')) {
|
||||
errors.push(`服务 ${serviceName} 未通过 env_file 引用 ./.env`);
|
||||
}
|
||||
|
||||
const environmentKeys = extractEnvironmentKeys(block);
|
||||
const illegalOverrides = environmentKeys.filter(
|
||||
(key) => !(allowedOverrides[serviceName] ?? []).includes(key),
|
||||
);
|
||||
|
||||
if (illegalOverrides.length) {
|
||||
errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
fail('部署环境契约校验失败', errors);
|
||||
}
|
||||
|
||||
console.log('[R26][PASS] 部署环境契约校验通过');
|
||||
console.log(`- config.js env 键数: ${configKeys.length}`);
|
||||
console.log(`- .env.example env 键数: ${envExampleKeys.size}`);
|
||||
console.log(`- 已检查服务: ${requiredServices.join(', ')}`);
|
||||
@@ -0,0 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R26-0,执行合同建档与范围冻结,为部署环境变量热修复与防漏建立新合同 明确本轮不改业务模型 不做数据迁移,Issue CSV 校验通过 且范围锁定为 env 注入 契约校验 runtime smoke 与部署验收,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv,manual,DONE,DONE,DONE,issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv,[],none
|
||||
R26-1,compose env 注入机制改造,将 trialrun server worker init 从手写 environment 列表切换为 env_file 主机制 并仅保留必要覆盖项,server worker init 全部通过 env_file 注入 env 且 Portal 等关键开关在容器内非空,cd server && npm test && cd ../client && npm run typecheck,manual,DONE,DONE,DONE,deploy/trialrun/docker-compose.yml|deploy/trialrun/.env.example,"[""R26-0""]",统一采用无引号 env_file 安全格式
|
||||
R26-2,env 契约校验脚本,新增静态契约校验脚本 比对 config.js .env.example 与 docker-compose 以阻止新增 env 漏接入,新增 env 键未同步到部署契约时脚本失败并输出缺失项,node deploy/trialrun/validate-env-contract.js,manual,DONE,DONE,DONE,deploy/trialrun/validate-env-contract.js|server/src/config.js|deploy/trialrun/.env.example|deploy/trialrun/docker-compose.yml,"[""R26-1""]",server worker init 仅允许白名单覆盖项
|
||||
R26-3,部署后 runtime smoke,新增 trialrun runtime smoke 脚本 并在 deploy.sh 中接入 自动校验容器 env 功能开关求值与 Portal 前置条件,部署完成前自动执行 smoke 且关键 env 缺失或开关求值异常时直接失败,cd server && npm test && bash deploy/trialrun/deploy.sh,manual,DONE,DONE,DONE,server/src/scripts/smoke-trialrun-runtime.js|deploy/trialrun/deploy.sh,"[""R26-1"",""R26-2""]",smoke 不依赖人工点击页面
|
||||
R26-4,前端功能开关错误提示优化,补齐 VALID_001 中 featureFlag 相关映射 让 Portal 等入口在开关关闭时显示明确提示,前端出现 featureFlag 类故障时显示业务化提示 而不是泛化参数校验失败,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/api/errorMessages.ts|client/src/lib/resolveErrorMessage.ts,"[""R26-0""]",覆盖 portal_home_v1_disabled 等 trialrun 高优先级开关
|
||||
R26-5,回归 重部署与验收收口,完成修改后执行契约校验 本地检查 重部署与线上验证 并批量抽检同类开关能力,Portal 登录后可正常进入 且容器内关键 env 非空 契约校验 smoke server test client build 全部通过,cd server && npm test && cd ../client && npm run typecheck && npm run build && node deploy/trialrun/validate-env-contract.js && bash deploy/trialrun/deploy.sh,manual,DONE,DONE,DONE,deploy/trialrun|client|server|issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv,"[""R26-1"",""R26-2"",""R26-3"",""R26-4""]",deploy.sh 已执行契约校验 + /ready + runtime smoke;额外确认 /api/portal/home 返回 200;server/scripts/regression-all.sh 在 trialrun 上仍依赖 dev seed 账号与 sample pdf,不作为本轮阻塞
|
||||
|
@@ -0,0 +1,136 @@
|
||||
require('dotenv/config');
|
||||
|
||||
const { createPrismaClient } = require('../prismaClient');
|
||||
const { getConfig } = require('../config');
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
} = require('../services/featureFlags');
|
||||
const { getWorkspaceSetting, getSystemNavigation } = require('../services/workspaceSettings');
|
||||
const { resolvePermissionsForRoles, hasPermission } = require('../constants/permissions');
|
||||
|
||||
const REQUIRED_ENV_VALUES = {
|
||||
PORTAL_HOME_ENABLED: 'true',
|
||||
ANNOUNCEMENTS_ENABLED: 'true',
|
||||
PRESENCE_BOARD_ENABLED: 'true',
|
||||
WEEKLY_REPORT_ENABLED: 'true',
|
||||
EMPLOYEE_LIFECYCLE_ENABLED: 'true',
|
||||
TODO_BOARD_ENABLED: 'true',
|
||||
PROJECT_DOMAIN_ENABLED: 'true',
|
||||
PROJECT_COMMUNICATION_LOG_ENABLED: 'true',
|
||||
PROJECT_CLOSURE_CHECKLIST_ENABLED: 'true',
|
||||
COOKIE_SECURE: 'true',
|
||||
};
|
||||
|
||||
const REQUIRED_NON_EMPTY_ENV_KEYS = ['APP_ORIGIN'];
|
||||
|
||||
const REQUIRED_FLAGS = [
|
||||
FEATURE_FLAG_KEYS.portalHome,
|
||||
FEATURE_FLAG_KEYS.announcements,
|
||||
FEATURE_FLAG_KEYS.presenceBoard,
|
||||
FEATURE_FLAG_KEYS.weeklyReport,
|
||||
FEATURE_FLAG_KEYS.employeeLifecycle,
|
||||
FEATURE_FLAG_KEYS.todoBoard,
|
||||
FEATURE_FLAG_KEYS.projectDomain,
|
||||
FEATURE_FLAG_KEYS.projectCommunicationLog,
|
||||
FEATURE_FLAG_KEYS.projectClosureChecklist,
|
||||
];
|
||||
|
||||
function assertCondition(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = getConfig();
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
console.log('[R26] 开始 trialrun runtime smoke');
|
||||
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
console.log('[PASS] Prisma 连接正常');
|
||||
|
||||
for (const [key, expected] of Object.entries(REQUIRED_ENV_VALUES)) {
|
||||
const actual = process.env[key];
|
||||
assertCondition(actual === expected, `${key} 期望为 ${expected},实际为 ${actual ?? '<empty>'}`);
|
||||
console.log(`[PASS] ${key}=${actual}`);
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_NON_EMPTY_ENV_KEYS) {
|
||||
const actual = process.env[key];
|
||||
assertCondition(typeof actual === 'string' && actual.trim().length > 0, `${key} 不能为空`);
|
||||
console.log(`[PASS] ${key} 已配置`);
|
||||
}
|
||||
|
||||
const adminEmail = (process.env.TRIALRUN_ADMIN_EMAIL || '[email protected]').trim();
|
||||
const adminEmployee = await prisma.employee.findFirst({
|
||||
where: { email: adminEmail },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
assertCondition(Boolean(adminEmployee), `未找到 trialrun admin 员工:${adminEmail}`);
|
||||
assertCondition(adminEmployee.status === 'active', `trialrun admin 状态异常:${adminEmployee.status}`);
|
||||
console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.email}`);
|
||||
|
||||
const workspace = await getWorkspaceSetting(prisma);
|
||||
assertCondition(Boolean(workspace), 'WorkspaceSetting 读取失败');
|
||||
console.log(`[PASS] 工作区设置可读,profile=${workspace.profile}`);
|
||||
|
||||
const employeeForGate = {
|
||||
...adminEmployee,
|
||||
permissionCodes: resolvePermissionsForRoles(adminEmployee.roles.map((item) => item.role_code)),
|
||||
roleCodes: adminEmployee.roles.map((item) => item.role_code),
|
||||
};
|
||||
|
||||
for (const flagKey of REQUIRED_FLAGS) {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId: employeeForGate.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
assertCondition(
|
||||
result.enabled,
|
||||
`${flagKey} 求值失败:enabled=${result.enabled} reason=${result.reason} envEnabled=${result.envEnabled}`,
|
||||
);
|
||||
console.log(`[PASS] ${flagKey} => enabled (${result.reason})`);
|
||||
}
|
||||
|
||||
const navigation = await getSystemNavigation({
|
||||
prisma,
|
||||
config,
|
||||
employee: employeeForGate,
|
||||
hasPermission: (employee, permissionCode) => hasPermission(employee.permissionCodes ?? [], permissionCode),
|
||||
});
|
||||
|
||||
assertCondition(Array.isArray(navigation.sections?.main), '系统导航主区块生成失败');
|
||||
assertCondition(
|
||||
navigation.sections.main.some((item) => item.key === 'portal'),
|
||||
'系统导航未生成 portal 入口',
|
||||
);
|
||||
console.log(`[PASS] 导航构建成功,main=${navigation.sections.main.length} admin=${navigation.sections.admin.length}`);
|
||||
|
||||
console.log('[R26][PASS] trialrun runtime smoke 通过');
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[R26][FAIL] trialrun runtime smoke 失败');
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户