From 25c045aa51f601148876e26736a14bd43e0b7838 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 18:36:30 +0800 Subject: [PATCH] =?UTF-8?q?R26-1=20R26-2=20R26-3:=20=E4=BF=AE=E5=A4=8D=20e?= =?UTF-8?q?nv=20=E6=B3=A8=E5=85=A5=E5=B9=B6=E8=A1=A5=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8E=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/trialrun/.env.example | 264 +++++++++++------- deploy/trialrun/deploy.sh | 52 +++- deploy/trialrun/docker-compose.yml | 129 +-------- deploy/trialrun/validate-env-contract.js | 154 ++++++++++ ...22_16-35-00-r26-portal-env-flag-hotfix.csv | 7 + server/src/scripts/smoke-trialrun-runtime.js | 136 +++++++++ 6 files changed, 514 insertions(+), 228 deletions(-) create mode 100755 deploy/trialrun/validate-env-contract.js create mode 100644 issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv create mode 100644 server/src/scripts/smoke-trialrun-runtime.js diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index cf4282d9..e653bab3 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -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 -# ===== 数据库(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" +# ===== 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 -# ===== 存储(票据上传目录)===== -# 在 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" +# ===== 企业微信入口 / 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 -MINIO_ROOT_USER="minioadmin" -MINIO_ROOT_PASSWORD="minioadmin" +# ===== 数据库(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 +# SHADOW_DATABASE_URL=postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa_shadow -# ===== 单域托管 client/dist ===== -SERVE_CLIENT_DIST="true" -CLIENT_DIST_DIR="../client/dist" -OPENAPI_DOCS_ENABLED="false" +# ===== 存储与上传 ===== +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 -# ===== 网络与端口 ===== -HOST="0.0.0.0" -PORT="3000" +# ===== 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 -# ===== Cookie 安全(HTTPS 反代时建议 true)===== -COOKIE_SECURE="true" +# ===== 验真 / 发票 / 节假日 ===== +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 -# ===== Metrics(Prometheus)===== -METRICS_ENABLED="true" +# ===== 预算 / 费控 ===== +BUDGET_CONTROL_MODE=warn +BUDGET_PRECHECK_ENABLED=false +PAYMENT_BATCH_ENABLED=false -# ===== 安全基线(试运行建议打开)===== -RATE_LIMIT_ENABLED="true" -RATE_LIMIT_LOGIN_MAX="10" -RATE_LIMIT_LOGIN_WINDOW="1 minute" +# ===== 周边集成 / 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 -# ===== 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" +# ===== 邮件 ===== +EMAIL_ENABLED=false +EMAIL_FROM=no-reply@hyinda.com +EMAIL_BASE_URL=https://oa.example.com +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= -# ===== 委派回收 ===== -DELEGATION_REVOKE_CRON="0 * * * *" +# ===== 委派 / 作业 ===== +DELEGATION_REVOKE_CRON=0 * * * * +JOB_DRIVER=pgboss +JOBS_ENABLED=true +PG_BOSS_SCHEMA=pgboss -# ===== 作业系统(pg-boss,可回滚到 cron)===== -JOB_DRIVER="pgboss" # cron|pgboss -JOBS_ENABLED="true" # false 时整体关闭作业(排障/回滚/或启用 worker 拆分) -PG_BOSS_SCHEMA="pgboss" # pg-boss 在 PG 内的 schema +# ===== AI(默认关闭) ===== +AI_ASSIST_ENABLED=false +AI_PROVIDER=mock +AI_BASE_URL= +AI_MODEL= +AI_TIMEOUT_MS=10000 +AI_AUTH_TOKEN= -# ===== Worker 拆分(可选,F20)===== -# 如需启用“API 进程 + Worker 进程”: -# 1) 设置 JOBS_ENABLED="false"(server 不跑作业) -# 2) 启动 compose 时加:--profile worker(会启动 worker 容器,worker 内强制 JOBS_ENABLED=true) +# ===== Trialrun 初始化管理员 ===== +TRIALRUN_ADMIN_EMAIL=admin@hyinda.com +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 -# ===== 试运行引导管理员(E20)===== -TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" -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 diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 9f8b86e1..e3bd05ba 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -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 >> 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" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index 2a1fe97b..d4199e6c 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -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" diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js new file mode 100755 index 00000000..d3c89818 --- /dev/null +++ b/deploy/trialrun/validate-env-contract.js @@ -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(', ')}`); diff --git a/issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv b/issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv new file mode 100644 index 00000000..95f00018 --- /dev/null +++ b/issues/2026-03-22_16-35-00-r26-portal-env-flag-hotfix.csv @@ -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,不作为本轮阻塞 diff --git a/server/src/scripts/smoke-trialrun-runtime.js b/server/src/scripts/smoke-trialrun-runtime.js new file mode 100644 index 00000000..99be83f7 --- /dev/null +++ b/server/src/scripts/smoke-trialrun-runtime.js @@ -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 ?? ''}`); + 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 || 'admin@hyinda.com').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); +});