diff --git a/.gitignore b/.gitignore index 7a2a4b07..2a4352fd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,11 @@ server/generated/prisma **/dist server/generated/storage + +# trialrun deployment artifacts +deploy/trialrun/data +deploy/trialrun/backups + +# Playwright artifacts +client/test-results +client/playwright-report diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example new file mode 100644 index 00000000..f8753d29 --- /dev/null +++ b/deploy/trialrun/.env.example @@ -0,0 +1,60 @@ +## hyb-oa 试运行环境变量示例(单机) +## +## 使用方式: +## cp deploy/trialrun/.env.example deploy/trialrun/.env +## 然后按需修改 deploy/trialrun/.env 中的值(不要提交 .env) +## + +# ===== 基础域名(用于 refresh cookie 的 origin 校验)===== +# 单域托管模式下,前端与后端同域访问(建议生产/试运行)。 +APP_ORIGIN="http://127.0.0.1:3000" + +# ===== 服务端密钥(必须修改)===== +JWT_SECRET="change_me_in_trialrun" + +# ===== 数据库(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" + +# ===== 存储(票据上传目录)===== +# 在 deploy/trialrun/docker-compose.yml 中映射到宿主机 deploy/trialrun/data/storage +STORAGE_DIR="/data/storage" + +# ===== 单域托管 client/dist ===== +SERVE_CLIENT_DIST="true" +CLIENT_DIST_DIR="../client/dist" + +# ===== 网络与端口 ===== +HOST="0.0.0.0" +PORT="3000" + +# ===== Cookie 安全(HTTPS 反代时建议 true)===== +COOKIE_SECURE="false" + +# ===== 安全基线(试运行建议打开)===== +RATE_LIMIT_ENABLED="true" +RATE_LIMIT_LOGIN_MAX="10" +RATE_LIMIT_LOGIN_WINDOW="1 minute" + +# ===== 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" + +# ===== 委派回收 ===== +DELEGATION_REVOKE_CRON="0 * * * *" + +# ===== 试运行引导管理员(E20)===== +TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" +TRIALRUN_ADMIN_PHONE="13800000000" +# 留空则首次 seed 自动生成并打印一次性口令 +TRIALRUN_ADMIN_PASSWORD="" + diff --git a/deploy/trialrun/Dockerfile b/deploy/trialrun/Dockerfile new file mode 100644 index 00000000..c788067c --- /dev/null +++ b/deploy/trialrun/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-alpine AS base + +WORKDIR /app + +FROM base AS client_deps +COPY client/package.json client/package-lock.json ./client/ +RUN cd client && npm ci + +FROM client_deps AS client_build +COPY client ./client +RUN cd client && npm run build + +FROM base AS server_deps +COPY server/package.json server/package-lock.json ./server/ +COPY server/prisma ./server/prisma +RUN cd server && npm ci + +FROM server_deps AS server_build +COPY server ./server +COPY --from=client_build /app/client/dist ./client/dist + +WORKDIR /app/server +ENV NODE_ENV=production +CMD ["npm", "start"] diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md new file mode 100644 index 00000000..6cc84c64 --- /dev/null +++ b/deploy/trialrun/checklist.md @@ -0,0 +1,43 @@ +# hyb-oa 试运行发布 Checklist(E19) + +> 目标:一次发布可追踪、可回归、可回滚(10 分钟内恢复到上个版本)。 + +## A. 发布前(Pre-flight) +- [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"` +- [ ] 本地门禁:`./scripts/check-minimal.sh` +- [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh` +- [ ] 备份(DB + 文件存储): + - [ ] `set -a && source deploy/trialrun/.env && set +a` + - [ ] `./scripts/backup-db.sh`(记录输出文件名) + - [ ] `./scripts/backup-storage.sh`(记录输出文件名) + +## B. 发布(Deploy) +- [ ] `git checkout ` +- [ ] 构建并启动: + - [ ] `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` +- [ ] 探活: + - [ ] `curl -fsS http://127.0.0.1:3000/ready` +- [ ] 登录验证(管理员账号): + - [ ] 访问 `http://127.0.0.1:3000` + - [ ] 登录成功,菜单权限正确 + +## C. 发布后(Post-check) +- [ ] 关键链路手工验证(建议至少一次): + - [ ] 员工:新建报销 → 上传票据 → 绑定 → 提交 + - [ ] 经理:待办审批(approve) + - [ ] 财务:付款(含凭证字段) +- [ ] 查看 server 日志(无异常错误风暴): + - [ ] `docker logs --tail=200 hyb-oa-trialrun-server` + +## D. 回滚(Rollback) +> 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。 + +- [ ] 停止 server:`docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server` +- [ ] 恢复 DB:`./scripts/restore-db.sh ` +- [ ] 恢复存储:`./scripts/restore-storage.sh deploy/trialrun/data/storage` +- [ ] 回到上个版本:`git checkout ` +- [ ] 重启并探活: + - [ ] `docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` + - [ ] `curl -fsS http://127.0.0.1:3000/ready` + diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml new file mode 100644 index 00000000..7ef2dbc9 --- /dev/null +++ b/deploy/trialrun/docker-compose.yml @@ -0,0 +1,102 @@ +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 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + server: + build: + context: ../.. + dockerfile: deploy/trialrun/Dockerfile + container_name: hyb-oa-trialrun-server + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: production + APP_ORIGIN: ${APP_ORIGIN} + JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: ${DATABASE_URL} + STORAGE_DIR: ${STORAGE_DIR} + 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} + ports: + - "3000:3000" + volumes: + - ./data/storage:${STORAGE_DIR} + + init: + build: + context: ../.. + dockerfile: deploy/trialrun/Dockerfile + container_name: hyb-oa-trialrun-init + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: production + APP_ORIGIN: ${APP_ORIGIN} + JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: ${DATABASE_URL} + STORAGE_DIR: ${STORAGE_DIR} + 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} + + 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 + && npm run prisma:migrate:deploy + && npm run seed:config + && npm run seed:trialrun" + restart: "no" + diff --git a/deploy/trialrun/nginx.conf.example b/deploy/trialrun/nginx.conf.example new file mode 100644 index 00000000..3946ed21 --- /dev/null +++ b/deploy/trialrun/nginx.conf.example @@ -0,0 +1,22 @@ +# hyb-oa 反向代理示例(Nginx) +# +# 说明: +# - 适用于单机试运行:Nginx(443) -> hyb-oa server(3000) +# - server 内已启用 `trustProxy: true`,会读取 X-Forwarded-*。 +# +server { + listen 80; + server_name oa.example.com; + + # 试运行可以先走 http;生产建议配 TLS 并把 COOKIE_SECURE=true + location / { + proxy_pass http://127.0.0.1:3000; + 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_set_header X-Forwarded-Host $host; + } +} + diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md new file mode 100644 index 00000000..4ad9b9fb --- /dev/null +++ b/deploy/trialrun/runbook.md @@ -0,0 +1,136 @@ +# hyb-oa 试运行 Runbook(单机) + +> 目标:按本文档在一台新机器完成 **启动 → 迁移 → 初始化管理员 → 验收 → 备份 → 恢复 → 回滚** 演练。 + +## 0. 前置条件 +- Docker + Docker Compose(v2) +- 机器时间正确(影响 token / cron / OCR) + +## 1. 首次启动(推荐:单域托管) + +### 1.1 准备环境变量 +```bash +cd /path/to/hyb-oa +cp deploy/trialrun/.env.example deploy/trialrun/.env +``` + +必须修改: +- `JWT_SECRET` +- `APP_ORIGIN`(建议与对外域名一致,例如 `https://oa.example.com`) + +### 1.2 启动数据库与服务 +```bash +cd /path/to/hyb-oa +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 up -d --build server +``` + +### 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 +``` + +说明: +- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。 +- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。 + +### 1.4 验收 +```bash +curl -fsS http://127.0.0.1:3000/health +curl -fsS http://127.0.0.1:3000/ready +``` + +浏览器访问: +- `http://127.0.0.1:3000`(单域托管前端) + +## 1.5 备份与恢复演练(强烈建议至少做一次) +> 备份建议做到“DB + 文件存储(票据上传目录)”双份齐全。 + +### 1.5.1 备份 +```bash +cd /path/to/hyb-oa +set -a +source deploy/trialrun/.env +set +a + +./scripts/backup-db.sh +./scripts/backup-storage.sh +``` + +输出默认在: +- `deploy/trialrun/backups/` + +### 1.5.2 恢复(到同一台机器或新机器) +1) 先停止 server: +```bash +docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server +``` + +2) 恢复 DB: +```bash +set -a +source deploy/trialrun/.env +set +a + +./scripts/restore-db.sh deploy/trialrun/backups/ +``` + +3) 恢复文件存储: +```bash +./scripts/restore-storage.sh deploy/trialrun/backups/ deploy/trialrun/data/storage +``` + +4) 重启并验收: +```bash +docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server +curl -fsS http://127.0.0.1:3000/ready +``` + +## 2. 升级流程(试运行) +建议版本化方式: +- 每次发布打 tag,例如 `trialrun-2026-02-08-01` + +步骤: +1) 先做备份(见 E14 脚本) +2) 拉取代码:`git pull` / `git checkout ` +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 +``` + +发布/回滚 Checklist: +- `deploy/trialrun/checklist.md` + +## 3. 回滚流程(试运行) +> 回滚分两类:仅回滚代码(无迁移),或需要 DB 回滚(恢复备份)。 + +### 3.1 仅回滚代码 +```bash +git checkout +docker compose -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 +``` +2) 按 E14 恢复 DB + 文件存储备份 +3) 启动服务并验收 `/ready` 与登录 + +## 4. 日志与排障 +- server 容器日志: +```bash +docker logs -f hyb-oa-trialrun-server +``` +- `/ready` 失败通常为 DB 不可达或连接串错误。 + +## 5. 优雅停机(E21) +说明: +- `docker compose stop server` 会发送 `SIGTERM`,server 会尝试在 10s 内完成: + - 停止 OCR/委派回收定时任务 + - 关闭 HTTP 监听 + - 断开 Prisma/PG 连接 +- 超时会强制退出(避免卡死导致升级失败)。 diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 00000000..e930709e --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/trialrun/backups}" +mkdir -p "$BACKUP_DIR" + +TS="$(date +%Y%m%d_%H%M%S)" +OUT_FILE="${OUT_FILE:-$BACKUP_DIR/hyb-oa-db-${TS}.dump}" + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "[ERR] DATABASE_URL is required" >&2 + exit 1 +fi + +echo "[INFO] DB backup start: $OUT_FILE" + +if command -v pg_dump >/dev/null 2>&1; then + pg_dump "$DATABASE_URL" --format=c --no-owner --no-acl --file "$OUT_FILE" + echo "[OK] DB backup done (pg_dump): $OUT_FILE" + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "[ERR] pg_dump not found and docker not available" >&2 + exit 1 +fi + +DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}" +POSTGRES_DB="${POSTGRES_DB:-}" +POSTGRES_USER="${POSTGRES_USER:-}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + +if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then + echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2 + exit 1 +fi + +docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \ + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=c --no-owner --no-acl \ + >"$OUT_FILE" + +echo "[OK] DB backup done (docker exec): $OUT_FILE" + diff --git a/scripts/backup-storage.sh b/scripts/backup-storage.sh new file mode 100755 index 00000000..70c8a732 --- /dev/null +++ b/scripts/backup-storage.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/trialrun/backups}" +mkdir -p "$BACKUP_DIR" + +TS="$(date +%Y%m%d_%H%M%S)" +OUT_FILE="${OUT_FILE:-$BACKUP_DIR/hyb-oa-storage-${TS}.tar.gz}" + +STORAGE_DIR_HOST="${STORAGE_DIR_HOST:-}" +if [[ -z "$STORAGE_DIR_HOST" ]]; then + if [[ -n "${STORAGE_DIR:-}" && -d "${STORAGE_DIR:-}" ]]; then + STORAGE_DIR_HOST="$STORAGE_DIR" + elif [[ -d "$ROOT_DIR/deploy/trialrun/data/storage" ]]; then + STORAGE_DIR_HOST="$ROOT_DIR/deploy/trialrun/data/storage" + else + echo "[ERR] storage dir not found; set STORAGE_DIR_HOST" >&2 + exit 1 + fi +fi + +echo "[INFO] Storage backup start: $OUT_FILE" +echo "[INFO] Storage dir: $STORAGE_DIR_HOST" + +tar -czf "$OUT_FILE" -C "$STORAGE_DIR_HOST" . + +echo "[OK] Storage backup done: $OUT_FILE" + diff --git a/scripts/check-minimal.sh b/scripts/check-minimal.sh index 3071a044..fe465f20 100755 --- a/scripts/check-minimal.sh +++ b/scripts/check-minimal.sh @@ -18,6 +18,7 @@ echo "=== [2/3] Server Static Checks ===" node --check src/routes/delegations.js node --check src/routes/invoicePool.js node --check src/routes/audit.js + node --check src/routes/users.js node --check src/jobs/delegationRevokeJob.js node --check src/jobs/ocrJob.js node --check src/services/ocr/index.js diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100755 index 00000000..45fcd5c6 --- /dev/null +++ b/scripts/restore-db.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +BACKUP_FILE="${1:-${BACKUP_FILE:-}}" +if [[ -z "${BACKUP_FILE:-}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "[ERR] backup file not found: $BACKUP_FILE" >&2 + exit 1 +fi + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "[ERR] DATABASE_URL is required" >&2 + exit 1 +fi + +echo "[INFO] DB restore start: $BACKUP_FILE" + +if command -v pg_restore >/dev/null 2>&1; then + pg_restore --clean --if-exists --no-owner --no-acl -d "$DATABASE_URL" "$BACKUP_FILE" + echo "[OK] DB restore done (pg_restore): $BACKUP_FILE" + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "[ERR] pg_restore not found and docker not available" >&2 + exit 1 +fi + +DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}" +POSTGRES_DB="${POSTGRES_DB:-}" +POSTGRES_USER="${POSTGRES_USER:-}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + +if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then + echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2 + exit 1 +fi + +cat "$BACKUP_FILE" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \ + pg_restore --clean --if-exists --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB" + +echo "[OK] DB restore done (docker exec): $BACKUP_FILE" + diff --git a/scripts/restore-storage.sh b/scripts/restore-storage.sh new file mode 100755 index 00000000..5c658b38 --- /dev/null +++ b/scripts/restore-storage.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +BACKUP_FILE="${1:-${BACKUP_FILE:-}}" +if [[ -z "${BACKUP_FILE:-}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "[ERR] backup file not found: $BACKUP_FILE" >&2 + exit 1 +fi + +STORAGE_DIR_HOST="${2:-${STORAGE_DIR_HOST:-}}" +if [[ -z "$STORAGE_DIR_HOST" ]]; then + if [[ -n "${STORAGE_DIR:-}" && -d "${STORAGE_DIR:-}" ]]; then + STORAGE_DIR_HOST="$STORAGE_DIR" + else + STORAGE_DIR_HOST="$ROOT_DIR/deploy/trialrun/data/storage" + fi +fi + +mkdir -p "$STORAGE_DIR_HOST" + +echo "[INFO] Storage restore start: $BACKUP_FILE" +echo "[INFO] Storage dir: $STORAGE_DIR_HOST" + +tar -xzf "$BACKUP_FILE" -C "$STORAGE_DIR_HOST" + +echo "[OK] Storage restore done: $BACKUP_FILE" + diff --git a/server/.env.example b/server/.env.example index 88495b6c..4ac1e2d0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -8,6 +8,15 @@ STORAGE_DIR="generated/storage" SIGNED_URL_TTL_SECONDS="600" MAX_UPLOAD_SIZE_BYTES="10485760" +# ===== 单域托管 client/dist(试运行/生产建议)===== +# SERVE_CLIENT_DIST="true" +# CLIENT_DIST_DIR="../client/dist" + +# ===== 安全基线(默认:production=true,其它环境=false,可显式覆盖)===== +# RATE_LIMIT_ENABLED="true" +# RATE_LIMIT_LOGIN_MAX="10" +# RATE_LIMIT_LOGIN_WINDOW="1 minute" + DELEGATION_REVOKE_CRON="0 * * * *" OCR_PROVIDER="off" @@ -19,3 +28,8 @@ OCR_TIMEOUT_MS="5000" TENCENT_SECRET_ID="" TENCENT_SECRET_KEY="" TENCENT_OCR_REGION="ap-beijing" + +# ===== 试运行引导管理员(E20,可选)===== +# TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" +# TRIALRUN_ADMIN_PHONE="13800000000" +# TRIALRUN_ADMIN_PASSWORD="" diff --git a/server/README.md b/server/README.md index b00a5a61..c1514056 100644 --- a/server/README.md +++ b/server/README.md @@ -43,6 +43,19 @@ npm run seed:dev - 以上账号可通过 `SEED_*` 环境变量覆盖(见 `server/src/scripts/seed-dev.js`)。 - 生产/试运行环境默认采用“管理员重置密码”,不建议启用默认弱口令。 +试运行管理员引导(E20): +```bash +cd /home/shunli/Projects/hyb-oa/server +TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" \ +TRIALRUN_ADMIN_PHONE="13800000000" \ +TRIALRUN_ADMIN_PASSWORD="" \ +npm run seed:trialrun +``` + +说明: +- `TRIALRUN_ADMIN_PASSWORD` 为空时会生成并打印一次性口令(请立刻保存)。 +- 若管理员已存在,脚本只确保其为 active 且具备 `admin+employee` 角色,不会改动密码。 + ### 2.4 启动服务 ```bash cd /home/shunli/Projects/hyb-oa/server @@ -192,6 +205,25 @@ SERVE_CLIENT_DIST=true npm run dev - 访问 `http://127.0.0.1:3000` 即可打开前端(同域 cookie,减少 CORS 复杂度)。 - SPA 路由(如 `/dashboard`)会回退到 `index.html`;`/api/*` 不参与回退。 +### 4.8 试运行部署包(Docker Compose) +试运行/上线演练建议使用仓库内置部署包: +- `deploy/trialrun/runbook.md` +- `deploy/trialrun/docker-compose.yml` +- `deploy/trialrun/.env.example` +- `deploy/trialrun/checklist.md` + +备份/恢复脚本(E14): +```bash +cd /home/shunli/Projects/hyb-oa +./scripts/backup-db.sh +./scripts/restore-db.sh deploy/trialrun/backups/ +./scripts/backup-storage.sh +./scripts/restore-storage.sh deploy/trialrun/backups/ deploy/trialrun/data/storage +``` + +优雅停机(E21): +- `docker stop` / `Ctrl+C` 会触发 `SIGTERM/SIGINT`,server 会调用 `app.close()` 完成收敛关闭(定时任务/HTTP/DB)。 + --- ## 5. P2 API 摘要 @@ -237,6 +269,17 @@ npm run job:revoke-expired-delegations npm run job:ocr-once ``` +### 5.8 账号与角色管理(E17,试运行运营) +- `GET /api/users`:员工列表(分页/搜索) +- `POST /api/users`:创建员工(含初始密码) +- `POST /api/users/:id/disable`:禁用员工(tokenVersion+1 + 撤销 refresh) +- `POST /api/users/:id/enable`:启用员工(tokenVersion+1 + 撤销 refresh) +- `POST /api/users/:id/reset-password`:重置密码(返回一次性口令,tokenVersion+1) +- `POST /api/users/:id/roles`:设置角色(tokenVersion+1 + 撤销 refresh) + +权限: +- `system.users`(hr/admin;admin 具备 `*`) + --- ## 6. 关键环境变量 diff --git a/server/package.json b/server/package.json index d3d9eef4..635a881b 100644 --- a/server/package.json +++ b/server/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "postinstall": "prisma generate", "dev": "node src/index.js", "start": "node src/index.js", "test": "echo \"Error: no test specified\" && exit 1", @@ -15,6 +16,7 @@ "seed:config": "node src/scripts/seed-config.js", "seed:dev-users": "node src/scripts/seed-dev.js", "seed:dev": "npm run seed:config && npm run seed:dev-users", + "seed:trialrun": "node src/scripts/seed-trialrun.js", "job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js", "job:ocr-once": "node src/scripts/run-ocr-once.js" }, diff --git a/server/src/app.js b/server/src/app.js index 49202bc3..b635b063 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -21,6 +21,7 @@ const invoicePoolRoutes = require("./routes/invoicePool"); const workflowRoutes = require("./routes/workflow"); const delegationRoutes = require("./routes/delegations"); const auditRoutes = require("./routes/audit"); +const usersRoutes = require("./routes/users"); const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); const { startOcrJob } = require("./jobs/ocrJob"); const { AppError } = require("./utils/AppError"); @@ -189,6 +190,7 @@ async function buildApp() { await app.register(workflowRoutes, { prefix: "/api" }); await app.register(delegationRoutes, { prefix: "/api" }); await app.register(auditRoutes, { prefix: "/api" }); + await app.register(usersRoutes, { prefix: "/api" }); startDelegationRevokeJob(app); startOcrJob(app); diff --git a/server/src/index.js b/server/src/index.js index b1ffa397..30ab47db 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -5,6 +5,33 @@ async function main() { const { host, port } = app.config; await app.listen({ host, port }); + + let shuttingDown = false; + const shutdown = async (signal) => { + if (shuttingDown) return; + shuttingDown = true; + + app.log.info({ signal }, "Graceful shutdown start"); + + const timeoutMs = 10_000; + const forceTimer = setTimeout(() => { + app.log.error({ timeoutMs }, "Graceful shutdown timeout, force exit"); + process.exit(1); + }, timeoutMs); + forceTimer.unref?.(); + + try { + await app.close(); + app.log.info("Graceful shutdown completed"); + process.exit(0); + } catch (error) { + app.log.error({ err: error }, "Graceful shutdown failed"); + process.exit(1); + } + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); } main().catch((err) => { @@ -12,4 +39,3 @@ main().catch((err) => { console.error(err); process.exit(1); }); - diff --git a/server/src/routes/users.js b/server/src/routes/users.js new file mode 100644 index 00000000..77647a3c --- /dev/null +++ b/server/src/routes/users.js @@ -0,0 +1,562 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); +const { hashPassword } = require("../utils/crypto"); +const { validatePassword } = require("../utils/passwordPolicy"); + +const roleCodeSchema = z.enum([ + "employee", + "manager", + "ceo", + "finance", + "finance_mgr", + "administration", + "hr", + "admin", +]); + +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + status: z.enum(["active", "archived"]).optional(), + q: z.string().max(100).optional(), +}); + +const createBodySchema = z.object({ + employeeNo: z.string().min(1).max(50), + name: z.string().min(1).max(100), + phone: z.string().min(6).max(30), + email: z.string().email().max(200), + departmentId: z.string().uuid().optional(), + title: z.string().min(1).max(100).optional(), + directManagerId: z.string().uuid().nullable().optional(), + hireDate: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/).optional(), + roles: z.array(roleCodeSchema).min(1).optional(), + password: z.string().min(1), +}); + +const setRolesBodySchema = z.object({ + roles: z.array(roleCodeSchema).min(1), +}); + +const resetPasswordBodySchema = z.object({ + newPassword: z.string().min(1).optional(), +}); + +function parseUserId(params) { + const parsed = z.string().uuid().safeParse(params?.id); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + return parsed.data; +} + +function parseListQuery(query) { + const parsed = listQuerySchema.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + page: parsed.data.page ?? 1, + pageSize: parsed.data.pageSize ?? 20, + status: parsed.data.status, + q: parsed.data.q?.trim() ?? "", + }; +} + +function parseCreateBody(body) { + const parsed = createBodySchema.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const errors = validatePassword(parsed.data.password); + if (errors.length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { password: errors }, + }); + } + + return { + ...parsed.data, + roles: parsed.data.roles ?? ["employee"], + }; +} + +function mapEmployeeSummary(row) { + return { + id: row.id, + employeeNo: row.employee_no, + name: row.name, + phone: row.phone, + email: row.email, + status: row.status, + departmentId: row.department_id, + title: row.title, + roleCodes: row.roles.map((item) => item.role_code), + createdAt: row.created_at?.toISOString?.() ?? null, + updatedAt: row.updated_at?.toISOString?.() ?? null, + }; +} + +function normalizeUniqueErrorDetails(error) { + const meta = error?.meta ?? {}; + const target = meta.target ?? meta?.cause?.constraint?.fields ?? null; + const fields = Array.isArray(target) ? target : typeof target === "string" ? [target] : []; + + const details = {}; + for (const field of fields) { + if (typeof field === "string" && field.trim()) { + details[field] = "duplicate"; + } + } + + return Object.keys(details).length ? details : { unique: "duplicate" }; +} + +async function usersRoutes(fastify) { + fastify.get( + "/users", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + + const q = query.q; + const where = { + ...(query.status ? { status: query.status } : {}), + ...(q + ? { + OR: [ + { employee_no: { contains: q, mode: "insensitive" } }, + { name: { contains: q, mode: "insensitive" } }, + { phone: { contains: q, mode: "insensitive" } }, + { email: { contains: q, mode: "insensitive" } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + fastify.prisma.employee.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }), + fastify.prisma.employee.count({ where }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + items: items.map(mapEmployeeSummary), + }); + }, + ); + + fastify.post( + "/users", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const body = parseCreateBody(request.body); + const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date(); + const departmentId = body.departmentId ?? request.employee.department_id; + + const department = await fastify.prisma.department.findUnique({ + where: { id: departmentId }, + select: { id: true }, + }); + if (!department) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { departmentId: "not_found" }, + }); + } + + const passwordHash = await hashPassword(body.password); + const now = new Date(); + + let created = null; + + try { + created = await fastify.prisma.$transaction(async (tx) => { + const employee = await tx.employee.create({ + data: { + employee_no: body.employeeNo, + name: body.name, + phone: body.phone, + email: body.email, + department_id: departmentId, + title: body.title ?? "员工", + direct_manager_id: body.directManagerId ?? null, + hire_date: hireDate, + status: "active", + bank_account_encrypted: "ENC_TBD", + bank_name: "TBD", + password_hash: passwordHash, + created_at: now, + created_by: request.employee.id, + updated_at: now, + updated_by: request.employee.id, + }, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.employeeRole.createMany({ + data: body.roles.map((roleCode) => ({ + employee_id: employee.id, + role_code: roleCode, + created_at: now, + created_by: request.employee.id, + })), + skipDuplicates: true, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_CREATED", + targetType: "employee", + targetId: employee.id, + extra: { + employeeNo: employee.employee_no, + email: employee.email, + phone: employee.phone, + roles: body.roles, + }, + }), + }); + + return employee; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: normalizeUniqueErrorDetails(error), + }); + } + throw error; + } + + const roles = await fastify.prisma.employeeRole.findMany({ + where: { employee_id: created.id }, + select: { role_code: true }, + }); + + return sendData(reply, request, { + ...mapEmployeeSummary({ ...created, roles }), + }); + }, + ); + + fastify.post( + "/users/:id/disable", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + if (userId === request.employee.id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { user: "self_disable_not_allowed" }, + }); + } + + const now = new Date(); + const employee = await fastify.prisma.$transaction(async (tx) => { + const updated = await tx.employee.update({ + where: { id: userId }, + data: { + status: "archived", + archived_at: now, + token_version: { increment: 1 }, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + token_version: true, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: userId, revoked_at: null }, + data: { revoked_at: now }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_DISABLED", + targetType: "employee", + targetId: userId, + extra: {}, + }), + }); + + return updated; + }); + + return sendData(reply, request, { + id: employee.id, + status: employee.status, + }); + }, + ); + + fastify.post( + "/users/:id/enable", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const now = new Date(); + + const employee = await fastify.prisma.$transaction(async (tx) => { + const updated = await tx.employee.update({ + where: { id: userId }, + data: { + status: "active", + archived_at: null, + token_version: { increment: 1 }, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + token_version: true, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: userId, revoked_at: null }, + data: { revoked_at: now }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_ENABLED", + targetType: "employee", + targetId: userId, + extra: {}, + }), + }); + + return updated; + }); + + return sendData(reply, request, { + id: employee.id, + status: employee.status, + }); + }, + ); + + fastify.post( + "/users/:id/reset-password", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const parsed = resetPasswordBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const newPassword = + typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim() + ? parsed.data.newPassword.trim() + : null; + + const oneTimePassword = newPassword ?? `ResetA1${crypto.randomBytes(6).toString("hex")}`; + const errors = validatePassword(oneTimePassword); + if (errors.length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { newPassword: errors }, + }); + } + + const passwordHash = await hashPassword(oneTimePassword); + const now = new Date(); + + await fastify.prisma.$transaction(async (tx) => { + await tx.employee.update({ + where: { id: userId }, + data: { + password_hash: passwordHash, + token_version: { increment: 1 }, + updated_by: request.employee.id, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: userId, revoked_at: null }, + data: { revoked_at: now }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_PASSWORD_RESET", + targetType: "employee", + targetId: userId, + extra: {}, + }), + }); + }); + + return sendData(reply, request, { + id: userId, + oneTimePassword: oneTimePassword, + }); + }, + ); + + fastify.post( + "/users/:id/roles", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + if (userId === request.employee.id) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { user: "self_role_change_not_allowed" }, + }); + } + + const parsed = setRolesBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const roles = parsed.data.roles; + const now = new Date(); + + await fastify.prisma.$transaction(async (tx) => { + await tx.employeeRole.deleteMany({ + where: { employee_id: userId }, + }); + + await tx.employeeRole.createMany({ + data: roles.map((roleCode) => ({ + employee_id: userId, + role_code: roleCode, + created_at: now, + created_by: request.employee.id, + })), + skipDuplicates: true, + }); + + await tx.employee.update({ + where: { id: userId }, + data: { + token_version: { increment: 1 }, + updated_by: request.employee.id, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: userId, revoked_at: null }, + data: { revoked_at: now }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_ROLES_SET", + targetType: "employee", + targetId: userId, + extra: { roles }, + }), + }); + }); + + return sendData(reply, request, { + id: userId, + roleCodes: roles, + }); + }, + ); +} + +module.exports = usersRoutes; + diff --git a/server/src/scripts/seed-trialrun.js b/server/src/scripts/seed-trialrun.js new file mode 100644 index 00000000..e95d9211 --- /dev/null +++ b/server/src/scripts/seed-trialrun.js @@ -0,0 +1,181 @@ +require("dotenv/config"); + +const crypto = require("node:crypto"); + +const { createPrismaClient } = require("../prismaClient"); +const { hashPassword } = require("../utils/crypto"); +const { validatePassword } = require("../utils/passwordPolicy"); + +function requiredEnv(name) { + const value = process.env[name]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Missing required env: ${name}`); + } + return value.trim(); +} + +async function ensureDepartment(prisma, payload) { + const now = new Date(); + const existing = await prisma.department.findFirst({ + where: { + name: payload.name, + parent_id: payload.parent_id ?? null, + }, + select: { id: true }, + }); + + if (existing) { + return prisma.department.update({ + where: { id: existing.id }, + data: { + parent_id: payload.parent_id ?? null, + archived_at: null, + updated_at: now, + }, + select: { id: true }, + }); + } + + return prisma.department.create({ + data: { + name: payload.name, + parent_id: payload.parent_id ?? null, + created_at: now, + updated_at: now, + }, + select: { id: true }, + }); +} + +function generateOneTimePassword() { + // 确保满足 passwordPolicy:>=12 + 小写 + 大写 + 数字 + const suffix = crypto.randomBytes(6).toString("hex"); + return `TrialrunA1${suffix}`; +} + +async function ensureAdminEmployee(prisma, params) { + const now = new Date(); + + const existing = await prisma.employee.findFirst({ + where: { OR: [{ email: params.email }, { phone: params.phone }] }, + select: { id: true, email: true, phone: true, status: true }, + }); + + if (existing) { + await prisma.employee.update({ + where: { id: existing.id }, + data: { + status: "active", + archived_at: null, + updated_at: now, + }, + }); + + await prisma.employeeRole.deleteMany({ + where: { employee_id: existing.id }, + }); + + await prisma.employeeRole.createMany({ + data: [ + { employee_id: existing.id, role_code: "admin", created_at: now, created_by: null }, + { employee_id: existing.id, role_code: "employee", created_at: now, created_by: null }, + ], + skipDuplicates: true, + }); + + return { employeeId: existing.id, created: false, password: null }; + } + + const password = params.password || generateOneTimePassword(); + const errors = validatePassword(password); + if (errors.length) { + throw new Error(`Invalid TRIALRUN_ADMIN_PASSWORD: ${errors.join("; ")}`); + } + + const passwordHash = await hashPassword(password); + + const employee = await prisma.employee.create({ + data: { + employee_no: params.employeeNo, + name: params.name, + phone: params.phone, + email: params.email, + department_id: params.departmentId, + title: params.title, + direct_manager_id: null, + hire_date: params.hireDate, + status: "active", + bank_account_encrypted: "ENC_TRIALRUN", + bank_name: "TRIALRUN_BANK", + password_hash: passwordHash, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }, + select: { id: true }, + }); + + await prisma.employeeRole.createMany({ + data: [ + { employee_id: employee.id, role_code: "admin", created_at: now, created_by: null }, + { employee_id: employee.id, role_code: "employee", created_at: now, created_by: null }, + ], + skipDuplicates: true, + }); + + return { employeeId: employee.id, created: true, password }; +} + +async function main() { + const adminEmail = requiredEnv("TRIALRUN_ADMIN_EMAIL"); + const adminPhone = requiredEnv("TRIALRUN_ADMIN_PHONE"); + const adminPassword = process.env.TRIALRUN_ADMIN_PASSWORD?.trim() || ""; + + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + + try { + const root = await ensureDepartment(prisma, { name: "公司", parent_id: null }); + const management = await ensureDepartment(prisma, { name: "管理部", parent_id: root.id }); + + const result = await ensureAdminEmployee(prisma, { + email: adminEmail, + phone: adminPhone, + password: adminPassword || null, + employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "T0000", + name: process.env.TRIALRUN_ADMIN_NAME ?? "Trialrun Admin", + title: process.env.TRIALRUN_ADMIN_TITLE ?? "系统管理员", + departmentId: management.id, + hireDate: new Date(process.env.TRIALRUN_ADMIN_HIRE_DATE ?? "2020-01-01"), + }); + + // eslint-disable-next-line no-console + console.log("Seeded trialrun admin:"); + // eslint-disable-next-line no-console + console.log(`- email: ${adminEmail}`); + // eslint-disable-next-line no-console + console.log(`- phone: ${adminPhone}`); + // eslint-disable-next-line no-console + console.log(`- created: ${result.created}`); + if (result.password) { + // eslint-disable-next-line no-console + console.log(`- one-time password: ${result.password}`); + // eslint-disable-next-line no-console + console.log(" (请立刻保存,并在首次登录后由管理员重置/更换密码)"); + } else { + // eslint-disable-next-line no-console + console.log("- password: unchanged"); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); +