统一 Node 22 与后端安全运行基线(07-15-production-trust-closeout-cleanup)

这个提交包含在:
2026-07-16 05:17:18 +08:00
父节点 dcf34d7ac6
当前提交 abb6a22f5c
共修改 28 个文件,包含 2250 行新增和 3012 行删除
+1 -1
查看文件
@@ -53,7 +53,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: npm
cache-dependency-path: |
server/package-lock.json
+1 -1
查看文件
@@ -24,7 +24,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: npm
cache-dependency-path: client/package-lock.json
+1 -1
查看文件
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: npm
cache-dependency-path: |
server/package-lock.json
+4 -1
查看文件
@@ -44,7 +44,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: npm
cache-dependency-path: server/package-lock.json
@@ -58,6 +58,9 @@ jobs:
- name: Seed database
working-directory: server
env:
SEED_DEV_ALLOW: "1"
SEED_DEV_DATABASE_CONFIRM: hyb_oa
run: npm run seed:dev
- name: Start server
+1 -1
查看文件
@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: npm
cache-dependency-path: |
server/package-lock.json
+1
查看文件
@@ -0,0 +1 @@
22
+18 -24
查看文件
@@ -1,12 +1,12 @@
# hyb-oa Client Runbook (P1-5)
## 1. 前置条件
- Node.js >= 20
- Node.js `>=22 <23`
- 后端服务可用(`http://127.0.0.1:3000`)
## 2. 安装与启动
```bash
cd /home/shunli/Projects/hyb-oa/client
cd client
npm install
npm run dev
```
@@ -15,19 +15,23 @@ npm run dev
## 3. 本地联调建议顺序
```bash
cd /home/shunli/Projects/hyb-oa
cd ..
export POSTGRES_PASSWORD="<local-only-db-password>"
export JWT_SECRET="<local-only-jwt-secret>"
docker compose up -d db
cd /home/shunli/Projects/hyb-oa/server
cd server
npm run prisma:migrate:deploy
npm run seed:dev
SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=hyb_oa npm run seed:dev
npm run dev
cd /home/shunli/Projects/hyb-oa/client
cd ../client
npm run dev
```
根级 Compose 要求显式提供本地数据库口令和 JWT 密钥;不要把真实值写入仓库,并让 `server/.env` 的 `DATABASE_URL` 使用同一数据库口令。
## 3.1 试运行/单域托管部署(推荐)
试运行/上线演练建议使用仓库内置部署包(server 托管 `client/dist`,同域 cookie 更稳定):
- `deploy/trialrun/runbook.md`
@@ -39,19 +43,9 @@ npm run dev
- 前端在 401 时自动调用 `/api/auth/refresh` 并重试一次请求。
- 登录与刷新请求都会附带 `x-device-fingerprint`。
## 4.1 开发环境默认账号(seed:dev)
仅用于本地联调(`cd server && npm run seed:dev`):
## 4.1 开发账号(seed:dev)
| 角色 | 账号 | 密码 |
|---|---|---|
| employee | `[email protected]` | `EmployeePassword1234` |
| manager | `[email protected]` | `ManagerPassword1234` |
| finance | `[email protected]` | `FinancePassword1234` |
| finance_mgr | `[email protected]` | `FinanceMgrPassword1234` |
| admin | `[email protected]` | `AdminPassword1234` |
| ceo | `[email protected]` | `CeoPassword1234` |
忘记密码:登录页提供邮箱验证码找回;管理员重置密码仍作为试运行/生产兜底。
`seed:dev` 仅用于本机开发库,必须显式允许并确认数据库名;账号与口令以本地受控 `SEED_*` 配置为准,脚本不输出口令。默认不会覆盖已有账号密码;新建账号和主动设置 `SEED_DEV_RESET_PASSWORDS=1` 后轮换的账号,首次登录都必须改密。
## 5. 菜单与权限(当前壳层)
- 仪表盘:登录即可访问。
@@ -74,7 +68,7 @@ npm run dev
- API 层保留后端错误码原文,前端仅做可读化。
## 6. 风格资源复用策略(锁定)
- 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。
- 视觉 Token 来源:`<design-source-repo>`。
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
- 如需同步风格,请记录来源 commit 并通过评审。
@@ -90,18 +84,18 @@ npm run test:e2e
## 8.1 Playwright E2E(E15)
前置:本地启动 DB + server(建议 `SERVE_CLIENT_DIST=true` 单域托管),并启用 mock OCR:
```bash
cd /home/shunli/Projects/hyb-oa
cd ..
docker compose up -d db
cd /home/shunli/Projects/hyb-oa/server
cd server
OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" npm run prisma:migrate:deploy
OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" npm run seed:dev
SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=hyb_oa OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" npm run seed:dev
OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" SERVE_CLIENT_DIST=true npm run dev
```
执行:
```bash
cd /home/shunli/Projects/hyb-oa/client
cd ../client
npm install
npx playwright install --with-deps
npm run test:e2e
@@ -109,7 +103,7 @@ npm run test:e2e
## 9. 仓库级最小门禁
```bash
cd /home/shunli/Projects/hyb-oa
cd ..
./scripts/check-minimal.sh
```
+16 -13
查看文件
@@ -21,7 +21,7 @@
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-router-dom": "^6.30.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1"
},
@@ -36,6 +36,9 @@
"typescript": "^5.4.5",
"vite": "^5.1.0",
"vite-plugin-pwa": "^0.20.5"
},
"engines": {
"node": ">=22 <23"
}
},
"node_modules/@alloc/quick-lru": {
@@ -2880,9 +2883,9 @@
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -6265,12 +6268,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
"@remix-run/router": "1.23.3"
},
"engines": {
"node": ">=14.0.0"
@@ -6280,13 +6283,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
},
"engines": {
"node": ">=14.0.0"
+4 -1
查看文件
@@ -3,6 +3,9 @@
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=22 <23"
},
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
@@ -35,7 +38,7 @@
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"react-router-dom": "^6.30.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1"
},
+6 -1
查看文件
@@ -1,4 +1,9 @@
FROM node:20-alpine AS base
FROM node:22-alpine AS base
ARG BUILD_SHA=unknown
ARG VITE_BUILD_SHA=unknown
ENV BUILD_SHA=$BUILD_SHA
ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app
+6 -6
查看文件
@@ -6,23 +6,23 @@ services:
container_name: hyb-oa-db
restart: always
environment:
POSTGRES_USER: hyboa_user
POSTGRES_PASSWORD: hyboa_password
POSTGRES_DB: hyb_oa
POSTGRES_USER: ${POSTGRES_USER:-hyboa_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the local environment}
POSTGRES_DB: ${POSTGRES_DB:-hyb_oa}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
server:
image: node:20-alpine
image: node:22-alpine
container_name: hyb-oa-server
restart: unless-stopped
working_dir: /app/server
command: sh -c "npm install && npm run dev"
environment:
DATABASE_URL: "postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public"
JWT_SECRET: "change_me"
DATABASE_URL: "postgresql://${POSTGRES_USER:-hyboa_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-hyb_oa}?schema=public"
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the local environment}
APP_TIMEZONE: "Asia/Shanghai"
STORAGE_DIR: "generated/storage"
SERVE_CLIENT_DIST: "false"
+1 -1
查看文件
@@ -245,7 +245,7 @@ echo ">>> [isolated-dedup] Prisma generate + migrate deploy"
echo ">>> [isolated-dedup] 写入开发种子数据"
(
cd "$SERVER_DIR"
npm run seed:dev
SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME" npm run seed:dev
)
echo ">>> [isolated-dedup] 启动临时服务:$QA_BASE_URL"
+1 -1
查看文件
@@ -313,7 +313,7 @@ qa_prepare_db() {
echo ">>> 写入开发种子数据"
(
cd "$SERVER_DIR"
npm run seed:dev
SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME" npm run seed:dev
)
}
+10 -5
查看文件
@@ -1,5 +1,10 @@
DATABASE_URL="postgresql://hyboa_user:hyboa_password@localhost:5432/hyb_oa?schema=public"
JWT_SECRET="change_me"
DATABASE_URL="<local-postgresql-url>"
# seed:dev 默认拒绝执行;仅本机开发库可显式开启,并确认 URL 中的数据库名。
# SEED_DEV_ALLOW="1"
# SEED_DEV_DATABASE_CONFIRM="hyb_oa"
# 仅需主动轮换已有开发账号时开启;轮换后账号必须首次登录改密。
# SEED_DEV_RESET_PASSWORDS="0"
JWT_SECRET="<generate-a-local-secret>"
# ===== 敏感字段加密(F25)=====
# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。
# 生成方式示例:openssl rand -base64 32
@@ -105,8 +110,8 @@ INTEGRATION_OUTBOX_MAX_ATTEMPTS="5"
INTEGRATION_OUTBOX_RETRY_SECONDS="120"
# ===== 试运行引导管理员(E20,可选)=====
# TRIALRUN_ADMIN_EMAIL="admin@hyinda.com"
# TRIALRUN_ADMIN_PHONE="13800000000"
# TRIALRUN_ADMIN_EMAIL="<admin-email>"
# TRIALRUN_ADMIN_PHONE="<admin-phone>"
# TRIALRUN_ADMIN_PASSWORD=""
# R12 lite feature gates
@@ -120,7 +125,7 @@ CUTOVER_CONSOLE_ENABLED=false
OIDC_PROVIDER_LABEL=企业统一登录
# ===== Prisma Migration Diff(本地生成 migration 用,可选) =====
# SHADOW_DATABASE_URL="postgresql://hyboa_user:[email protected]:5432/hyb_oa_shadow"
# SHADOW_DATABASE_URL="<local-shadow-postgresql-url>"
# ===== R13-R17 小团队内部管理 =====
PRESENCE_BOARD_ENABLED=false
+41 -43
查看文件
@@ -1,13 +1,13 @@
# hyb-oa Server Runbook (P0/P1)
## 1. 环境准备
- Node.js: `>=20`
- Node.js: `>=22 <23`
- Docker + Docker Compose
- 本地工具:`curl`、`jq`
- 配置文件:从 `server/.env.example` 复制为 `server/.env` 并按需修改
```bash
cd /home/shunli/Projects/hyb-oa/server
cd server
cp .env.example .env
```
@@ -15,39 +15,37 @@ cp .env.example .env
### 2.1 启动数据库
```bash
cd /home/shunli/Projects/hyb-oa
cd ..
export POSTGRES_PASSWORD="<local-only-db-password>"
export JWT_SECRET="<local-only-jwt-secret>"
docker compose up -d db
```
根级 Compose 不再内置数据库口令或 JWT 密钥;请仅在本地 shell 或未提交的环境文件中设置,并让 `server/.env` 的 `DATABASE_URL` 使用同一数据库口令。
### 2.2 应用迁移
```bash
cd /home/shunli/Projects/hyb-oa/server
cd server
npm run prisma:migrate:deploy
```
### 2.3 执行 seed(幂等)
```bash
cd /home/shunli/Projects/hyb-oa/server
npm run seed:dev
cd server
SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=hyb_oa npm run seed:dev
```
开发环境默认账号(仅用于本地 seed:dev):
- employee:`[email protected]` / `EmployeePassword1234`
- manager:`[email protected]` / `ManagerPassword1234`
- finance:`[email protected]` / `FinancePassword1234`
- finance_mgr:`[email protected]` / `FinanceMgrPassword1234`
- admin:`[email protected]` / `AdminPassword1234`
- ceo:`[email protected]` / `CeoPassword1234`
说明:
- 以上账号可通过 `SEED_*` 环境变量覆盖(见 `server/src/scripts/seed-dev.js`)。
- 生产/试运行环境默认采用“管理员重置密码”,不建议启用默认弱口令。
- `seed:dev` 永久拒绝 production,只接受 loopback PostgreSQL,并要求确认 URL 中的数据库名。
- 开发账号资料和口令可通过 `SEED_*` 环境变量覆盖;脚本不会打印口令。
- 默认保留已有账号密码。新建账号以及主动设置 `SEED_DEV_RESET_PASSWORDS=1` 后轮换的账号,首次登录都必须改密。
- 开发账号配置不得复用于试运行或生产。
试运行管理员引导(E20):
```bash
cd /home/shunli/Projects/hyb-oa/server
TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" \
TRIALRUN_ADMIN_PHONE="13800000000" \
cd server
TRIALRUN_ADMIN_EMAIL="<admin-email>" \
TRIALRUN_ADMIN_PHONE="<admin-phone>" \
TRIALRUN_ADMIN_PASSWORD="" \
npm run seed:trialrun
```
@@ -101,11 +99,11 @@ npm run seed:trialrun
本地双进程示例:
```bash
# 终端 A:API(不跑作业)
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
JOBS_ENABLED=false npm run dev
# 终端 B:Worker(只跑作业)
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
JOBS_ENABLED=true npm run worker
```
@@ -127,19 +125,19 @@ JOBS_ENABLED=true npm run worker
### 2.4 启动服务
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
npm run dev
```
### 2.5 冒烟验证
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/smoke-test.sh
```
### 2.6 本地最小门禁(P1-b)
```bash
cd /home/shunli/Projects/hyb-oa
cd <repo-root>
./scripts/check-minimal.sh
```
@@ -149,7 +147,7 @@ cd /home/shunli/Projects/hyb-oa
## 3. 1 分钟快速验证
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/smoke-test.sh
```
@@ -171,26 +169,26 @@ curl -fsS http://127.0.0.1:3000/metrics | head
### 4.1 脚本回归(推荐)
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh
```
### 4.2 判重专项回归(dedup)
```bash
cd /home/shunli/Projects/hyb-oa/server
DATABASE_URL='postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public' \
cd <repo-root>/server
DATABASE_URL='<local-postgresql-url>' \
BASE_URL=http://127.0.0.1:3000 ./scripts/regression-dedup.sh
```
### 4.3 工作流动作回归
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/regression-workflow-actions.sh
```
可选并发校验:
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh
```
@@ -219,7 +217,7 @@ RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh
### 4.4 一键全量回归
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/regression-all.sh
```
@@ -227,25 +225,25 @@ cd /home/shunli/Projects/hyb-oa/server
可选关闭审计/OCR:
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh
```
可选打开组织/配置回归(org + settings):
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
RUN_ORG_SETTINGS_REGRESSION=1 ./scripts/regression-all.sh
```
可选打开认证安全回归(auth security):
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
RUN_AUTH_SECURITY_REGRESSION=1 ./scripts/regression-all.sh
```
独立执行:
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/regression-audit.sh
OCR_PROVIDER=mock ./scripts/regression-ocr.sh
./scripts/regression-org-settings.sh
@@ -267,7 +265,7 @@ OCR_PROVIDER=mock ./scripts/regression-ocr.sh
### 4.6 前端壳联调(P1-5)
```bash
cd /home/shunli/Projects/hyb-oa/client
cd <repo-root>/client
npm install
npm run dev
```
@@ -279,10 +277,10 @@ npm run dev
### 4.7 单域托管 client/dist(可选,试运行建议)
```bash
cd /home/shunli/Projects/hyb-oa/client
cd <repo-root>/client
npm run build
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
SERVE_CLIENT_DIST=true npm run dev
```
@@ -299,7 +297,7 @@ SERVE_CLIENT_DIST=true npm run dev
备份/恢复脚本(E14):
```bash
cd /home/shunli/Projects/hyb-oa
cd <repo-root>
./scripts/backup-db.sh
./scripts/restore-db.sh deploy/trialrun/backups/<db.dump>
./scripts/backup-storage.sh
@@ -358,7 +356,7 @@ cd /home/shunli/Projects/hyb-oa
### 5.8 定时任务(可手动执行)
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
npm run job:revoke-expired-delegations
npm run job:ocr-once
```
@@ -492,7 +490,7 @@ npm run job:ocr-once
### 9.3 新增回归脚本
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
./scripts/regression-verification.sh
./scripts/regression-dashboard.sh
./scripts/regression-budget.sh
@@ -502,7 +500,7 @@ cd /home/shunli/Projects/hyb-oa/server
可通过总回归开关启用:
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
RUN_VERIFICATION_REGRESSION=1 \
RUN_DASHBOARD_REGRESSION=1 \
RUN_BUDGET_REGRESSION=1 \
@@ -513,7 +511,7 @@ RUN_FEATURE_FLAGS_REGRESSION=1 \
### 9.4 单次任务脚本(调试)
```bash
cd /home/shunli/Projects/hyb-oa/server
cd <repo-root>/server
VERIFICATION_RECORD_ID=<uuid> npm run job:verification-once
npm run job:integration-outbox-once
```
+1492 -2794
查看文件
文件差异内容过多而无法显示 加载差异
+14 -11
查看文件
@@ -17,7 +17,7 @@
"prisma:migrate:deploy": "prisma migrate deploy",
"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:dev": "node src/scripts/dev-seed-safety.js && npm run seed:config && npm run seed:dev-users",
"seed:trialrun": "node src/scripts/seed-trialrun.js",
"report:small-team-observation": "node src/scripts/export-small-team-observation.js",
"job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js",
@@ -47,34 +47,37 @@
"keywords": [],
"author": "",
"license": "ISC",
"engines": {
"node": ">=22 <23"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.985.0",
"@aws-sdk/client-s3": "^3.1087.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/jwt": "^10.2.0",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/auto-instrumentations-node": "^0.78.0",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"exceljs": "^4.4.0",
"fast-xml-parser": "5.3.4",
"fastify": "^5.7.4",
"fast-xml-parser": "5.10.0",
"fastify": "^5.10.0",
"fastify-plugin": "^5.1.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.10.1",
"nodemailer": "^9.0.3",
"openid-client": "^6.8.2",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.3.0",
"prisma": "^7.8.0",
"prom-client": "^15.1.0",
"sharp": "^0.34.5",
"tencentcloud-sdk-nodejs": "^4.1.14",
+20 -5
查看文件
@@ -1,7 +1,7 @@
require("dotenv/config");
const { getConfig } = require("./config");
const { initTracing } = require("./observability/tracing");
const { initTracing, shutdownTracing } = require("./observability/tracing");
const config = getConfig();
initTracing(config);
@@ -35,22 +35,37 @@ async function main() {
}, timeoutMs);
forceTimer.unref?.();
let exitCode = 0;
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);
exitCode = 1;
}
try {
await shutdownTracing();
} catch (error) {
app.log.error({ err: error }, "OTel shutdown failed");
exitCode = 1;
}
clearTimeout(forceTimer);
app.log.info({ exitCode }, "Graceful shutdown completed");
process.exit(exitCode);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
main().catch((err) => {
main().catch(async (err) => {
// eslint-disable-next-line no-console
console.error(err);
try {
await shutdownTracing();
} catch {
// The startup error remains authoritative; shutdown is best effort here.
}
process.exit(1);
});
+47 -33
查看文件
@@ -1,42 +1,56 @@
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { ConsoleSpanExporter } = require("@opentelemetry/sdk-trace-node");
const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node");
const { resourceFromAttributes } = require("@opentelemetry/resources");
const { ATTR_SERVICE_NAME } = require("@opentelemetry/semantic-conventions");
let sdk = null;
function initTracing(config) {
function initTracing(config, overrides = {}) {
if (config.otelTracesEnabled !== true) {
return null;
}
if (sdk) {
return sdk;
}
try {
const NodeSDKImpl = overrides.NodeSDK ?? NodeSDK;
const ConsoleSpanExporterImpl = overrides.ConsoleSpanExporter ?? ConsoleSpanExporter;
const createResource = overrides.resourceFromAttributes ?? resourceFromAttributes;
const buildInstrumentations = overrides.getNodeAutoInstrumentations ?? getNodeAutoInstrumentations;
const logger = overrides.logger ?? console;
const serviceName = overrides.serviceName ?? "hyb-oa-server";
sdk = new NodeSDKImpl({
resource: createResource({
[ATTR_SERVICE_NAME]: serviceName,
}),
traceExporter: new ConsoleSpanExporterImpl(),
instrumentations: [
buildInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
sdk.start();
logger.log("OTel tracing initialized");
return sdk;
} catch (error) {
sdk = null;
(overrides.logger ?? console).error("Error initializing OTel tracing", error);
return null;
}
}
async function shutdownTracing() {
if (!sdk) {
return;
}
const exporter = new ConsoleSpanExporter();
sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'hyb-oa-server',
}),
traceExporter: exporter,
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
})]
});
try {
sdk.start();
console.log('OTel tracing initialized');
} catch (error) {
console.error('Error initializing OTel tracing', error);
}
process.on('SIGTERM', () => {
if (sdk) {
sdk.shutdown()
.then(() => console.log('OTel tracing terminated'))
.catch((error) => console.log('Error terminating OTel tracing', error));
}
});
const activeSdk = sdk;
sdk = null;
await activeSdk.shutdown();
}
module.exports = { initTracing };
module.exports = { initTracing, shutdownTracing };
+81
查看文件
@@ -0,0 +1,81 @@
require("dotenv/config");
const { Client } = require("pg");
const LOCAL_DATABASE_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
class DevSeedSafetyError extends Error {
constructor(code, message) {
super(message);
this.name = "DevSeedSafetyError";
this.code = code;
}
}
function refuse(code, message) {
throw new DevSeedSafetyError(code, message);
}
function validateDevSeedSafety(env = process.env) {
if (String(env.NODE_ENV || "").trim().toLowerCase() === "production") {
refuse("production_forbidden", "seed:dev is permanently disabled in production");
}
if (String(env.SEED_DEV_ALLOW || "").trim() !== "1") {
refuse("explicit_allow_required", "seed:dev requires the explicit local allow switch");
}
let databaseUrl;
try {
databaseUrl = new URL(String(env.DATABASE_URL || ""));
} catch {
refuse("database_url_invalid", "seed:dev requires a valid local PostgreSQL target");
}
if (!["postgres:", "postgresql:"].includes(databaseUrl.protocol)) {
refuse("database_protocol_invalid", "seed:dev requires a local PostgreSQL target");
}
let effectiveTarget;
try {
const client = new Client({ connectionString: databaseUrl.toString() });
effectiveTarget = {
host: String(client.host || "").toLowerCase(),
databaseName: String(client.database || "").trim(),
};
} catch {
refuse("database_url_invalid", "seed:dev requires a valid local PostgreSQL target");
}
if (!LOCAL_DATABASE_HOSTS.has(effectiveTarget.host)) {
refuse("database_host_not_local", "seed:dev only accepts a loopback database target");
}
const databaseName = decodeURIComponent(databaseUrl.pathname.replace(/^\/+/, "")).trim();
if (!databaseName || databaseName.includes("/") || effectiveTarget.databaseName !== databaseName) {
refuse("database_name_invalid", "seed:dev requires one explicit database name");
}
const confirmation = String(env.SEED_DEV_DATABASE_CONFIRM || "").trim();
if (!confirmation || confirmation !== databaseName) {
refuse("database_confirmation_mismatch", "seed:dev database confirmation does not match the target");
}
return {
databaseName,
resetPasswords: String(env.SEED_DEV_RESET_PASSWORDS || "").trim() === "1",
};
}
if (require.main === module) {
try {
const result = validateDevSeedSafety();
const passwordMode = result.resetPasswords ? "one_time_reset" : "preserve_existing";
console.log(`[seed-dev] safety check passed; passwordMode=${passwordMode}`);
} catch (error) {
const code = error instanceof DevSeedSafetyError ? error.code : "unexpected_error";
console.error(`[seed-dev][REFUSED] ${code}`);
process.exit(1);
}
}
module.exports = {
DevSeedSafetyError,
validateDevSeedSafety,
};
+48 -47
查看文件
@@ -2,6 +2,7 @@ require("dotenv/config");
const { createPrismaClient } = require("../prismaClient");
const { hashPassword } = require("../utils/crypto");
const { validateDevSeedSafety } = require("./dev-seed-safety");
async function ensureDepartment(prisma, payload) {
const now = new Date();
@@ -330,9 +331,8 @@ async function ensureWorkspaceSetting(prisma) {
return prisma.workspaceSetting.create({ data });
}
async function ensureEmployee(prisma, payload) {
async function ensureEmployee(prisma, payload, { resetPasswords = false } = {}) {
const now = new Date();
const passwordHash = await hashPassword(payload.password);
const existing = await prisma.employee.findFirst({
where: { email: payload.email },
@@ -340,6 +340,12 @@ async function ensureEmployee(prisma, payload) {
});
if (existing) {
const passwordData = resetPasswords
? {
password_hash: await hashPassword(payload.password),
must_change_password: true,
}
: {};
const employee = await prisma.employee.update({
where: { id: existing.id },
data: {
@@ -353,11 +359,10 @@ async function ensureEmployee(prisma, payload) {
status: "active",
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
must_change_password: false,
...passwordData,
updated_at: now,
},
select: { id: true, email: true },
select: { id: true },
});
await prisma.employeeRole.deleteMany({
@@ -373,9 +378,13 @@ async function ensureEmployee(prisma, payload) {
skipDuplicates: true,
});
return employee;
return {
...employee,
seedPasswordAction: resetPasswords ? "reset" : "preserved",
};
}
const passwordHash = await hashPassword(payload.password);
const employee = await prisma.employee.create({
data: {
employee_no: payload.employeeNo,
@@ -391,11 +400,11 @@ async function ensureEmployee(prisma, payload) {
bank_account_encrypted: "ENC_DEV_ONLY",
bank_name: "DEV_BANK",
password_hash: passwordHash,
must_change_password: false,
must_change_password: true,
created_at: now,
updated_at: now,
},
select: { id: true, email: true },
select: { id: true },
});
await prisma.employeeRole.createMany({
@@ -407,10 +416,11 @@ async function ensureEmployee(prisma, payload) {
skipDuplicates: true,
});
return employee;
return { ...employee, seedPasswordAction: "created" };
}
async function main() {
const safety = validateDevSeedSafety();
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
@@ -451,6 +461,9 @@ async function main() {
});
await ensureWorkspaceSetting(prisma);
const ensureDevEmployee = (payload) => ensureEmployee(prisma, payload, {
resetPasswords: safety.resetPasswords,
});
for (const flagKey of [
"portal_home_v1",
@@ -477,7 +490,7 @@ async function main() {
});
}
const ceo = await ensureEmployee(prisma, {
const ceo = await ensureDevEmployee({
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "HYD000001",
name: "张总",
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
@@ -491,7 +504,7 @@ async function main() {
hireDate,
});
const admin = await ensureEmployee(prisma, {
const admin = await ensureDevEmployee({
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "HYD000006",
name: "王顺利",
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
@@ -505,7 +518,7 @@ async function main() {
hireDate,
});
const manager = await ensureEmployee(prisma, {
const manager = await ensureDevEmployee({
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "HYD000002",
name: "李研发",
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
@@ -519,7 +532,7 @@ async function main() {
hireDate,
});
const employee = await ensureEmployee(prisma, {
const employee = await ensureDevEmployee({
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "HYD000003",
name: "陈工程",
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
@@ -533,7 +546,7 @@ async function main() {
hireDate,
});
const finance = await ensureEmployee(prisma, {
const finance = await ensureDevEmployee({
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "HYD000004",
name: "赵财务",
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
@@ -547,7 +560,7 @@ async function main() {
hireDate,
});
const financeMgr = await ensureEmployee(prisma, {
const financeMgr = await ensureDevEmployee({
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "HYD000005",
name: "钱主管",
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
@@ -561,7 +574,7 @@ async function main() {
hireDate,
});
const hr = await ensureEmployee(prisma, {
const hr = await ensureDevEmployee({
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "HYD000007",
name: "孙人事",
phone: process.env.SEED_HR_PHONE ?? "13300000000",
@@ -575,7 +588,7 @@ async function main() {
hireDate,
});
const administration = await ensureEmployee(prisma, {
const administration = await ensureDevEmployee({
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "HYD000008",
name: "周行政",
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
@@ -659,40 +672,28 @@ async function main() {
warningThresholdPercent: 75,
});
// eslint-disable-next-line no-console
console.log("Seeded dev users:");
// eslint-disable-next-line no-console
console.log(`- 负责人: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 管理员: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 研发负责人: ${manager.email} / ${process.env.SEED_MANAGER_PASSWORD ?? "ManagerPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 员工: ${employee.email} / ${process.env.SEED_EMPLOYEE_PASSWORD ?? "EmployeePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 财务: ${finance.email} / ${process.env.SEED_FINANCE_PASSWORD ?? "FinancePassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 财务主管: ${financeMgr.email} / ${process.env.SEED_FINANCE_MGR_PASSWORD ?? "FinanceMgrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 人事: ${hr.email} / ${process.env.SEED_HR_PASSWORD ?? "HrPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- 行政: ${administration.email} / ${process.env.SEED_ADMINISTRATION_PASSWORD ?? "AdministrationPassword1234"}`);
// eslint-disable-next-line no-console
console.log(`- Legal Entity: ${legalEntity.code} / ${legalEntity.name}`);
// eslint-disable-next-line no-console
console.log(`- HolidayCalendar: ${holidayCalendar.code} / ${holidayCalendar.name}`);
// eslint-disable-next-line no-console
console.log(`- Supplier: ${supplier.code} / ${supplier.name}`);
// eslint-disable-next-line no-console
console.log(`- Budget Pools: ${managementBudgetPool.code}, ${rdBudgetPool.code}`);
const users = [ceo, admin, manager, employee, finance, financeMgr, hr, administration];
const countAction = (action) => users.filter((item) => item.seedPasswordAction === action).length;
console.log(
`[seed-dev] completed; users=${users.length} created=${countAction("created")} ` +
`passwordsPreserved=${countAction("preserved")} oneTimeResets=${countAction("reset")}`,
);
console.log("[seed-dev] reference data synchronized; credential values were not printed");
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
if (require.main === module) {
main().catch((error) => {
const code = typeof error?.code === "string" ? error.code : error?.name || "unexpected_error";
console.error(`[seed-dev][FAIL] ${code}`);
process.exit(1);
});
});
}
module.exports = {
ensureEmployee,
main,
};
+36 -6
查看文件
@@ -17,7 +17,7 @@ function resolveEmailBaseUrl(config) {
let cachedTransportKey = null;
let cachedTransporter = null;
function getTransporter(config) {
function getTransporter(config, createTransport = nodemailer.createTransport) {
if (!config.emailEnabled) {
return null;
}
@@ -41,7 +41,7 @@ function getTransporter(config) {
}
cachedTransportKey = key;
cachedTransporter = nodemailer.createTransport({
cachedTransporter = createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure,
@@ -86,6 +86,36 @@ function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
const SAFE_SMTP_COMMANDS = new Set([
'CONN',
'EHLO',
'HELO',
'MAIL FROM',
'RCPT TO',
'DATA',
'AUTH',
]);
function buildEmailErrorLog(error) {
const result = { errorCode: 'email_delivery_failed' };
const providerCode = String(error?.code ?? '').trim();
if (/^[A-Za-z0-9_-]{1,40}$/.test(providerCode)) {
result.providerCode = providerCode;
}
const smtpCommand = String(error?.command ?? '').trim().toUpperCase();
if (SAFE_SMTP_COMMANDS.has(smtpCommand)) {
result.smtpCommand = smtpCommand;
}
const smtpResponseCode = Number(error?.responseCode);
if (Number.isInteger(smtpResponseCode) && smtpResponseCode >= 100 && smtpResponseCode <= 999) {
result.smtpResponseCode = smtpResponseCode;
}
return result;
}
async function sendNotificationEmailSafe(params) {
try {
const emailResult = await sendNotificationEmail(params);
@@ -95,7 +125,7 @@ async function sendNotificationEmailSafe(params) {
} catch (error) {
params.logger?.error?.(
{
err: error,
...buildEmailErrorLog(error),
requestId: params.requestId,
notificationType: params.type,
recipients: params.recipientEmployeeIds?.length ?? 0,
@@ -118,12 +148,13 @@ async function sendNotificationEmail({
body,
type = 'notification',
targetPath,
createTransport,
}) {
if (!config.emailEnabled) {
return { skipped: true, sent: 0, failed: 0 };
}
const transporter = getTransporter(config);
const transporter = getTransporter(config, createTransport);
if (!transporter) {
logger?.warn?.({ requestId }, 'Email enabled but transporter not configured');
return { skipped: true, sent: 0, failed: 0 };
@@ -184,10 +215,9 @@ async function sendNotificationEmail({
failed += 1;
logger?.error?.(
{
err: error,
...buildEmailErrorLog(error),
requestId,
employeeId: employee.id,
email: employee.email,
notificationType: type,
},
'Email send failed',
+27 -5
查看文件
@@ -1,3 +1,11 @@
require("dotenv/config");
const { getConfig } = require("./config");
const { initTracing, shutdownTracing } = require("./observability/tracing");
const config = getConfig();
initTracing(config, { serviceName: "hyb-oa-worker" });
const { buildApp } = require("./app");
async function main() {
@@ -24,23 +32,37 @@ async function main() {
}, timeoutMs);
forceTimer.unref?.();
let exitCode = 0;
try {
await app.close();
app.log.info("Worker graceful shutdown completed");
process.exit(0);
} catch (error) {
app.log.error({ err: error }, "Worker graceful shutdown failed");
process.exit(1);
exitCode = 1;
}
try {
await shutdownTracing();
} catch (error) {
app.log.error({ err: error }, "Worker OTel shutdown failed");
exitCode = 1;
}
clearTimeout(forceTimer);
app.log.info({ exitCode }, "Worker graceful shutdown completed");
process.exit(exitCode);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
main().catch((err) => {
main().catch(async (err) => {
// eslint-disable-next-line no-console
console.error(err);
try {
await shutdownTracing();
} catch {
// The startup error remains authoritative; shutdown is best effort here.
}
process.exit(1);
});
@@ -0,0 +1,90 @@
const { initTracing, shutdownTracing } = require("../../../src/observability/tracing");
function buildDependencies({ constructError = null } = {}) {
const instances = [];
const resourceFromAttributes = vi.fn((attributes) => ({ attributes }));
const getNodeAutoInstrumentations = vi.fn((options) => ({ options }));
const logger = { log: vi.fn(), error: vi.fn() };
class TestNodeSDK {
constructor(options) {
if (constructError) {
throw constructError;
}
this.options = options;
this.start = vi.fn();
this.shutdown = vi.fn(async () => {});
instances.push(this);
}
}
return {
overrides: {
NodeSDK: TestNodeSDK,
ConsoleSpanExporter: class TestConsoleSpanExporter {},
resourceFromAttributes,
getNodeAutoInstrumentations,
logger,
},
instances,
resourceFromAttributes,
getNodeAutoInstrumentations,
logger,
};
}
describe("observability tracing", () => {
afterEach(async () => {
await shutdownTracing();
});
it("does not initialize the SDK when traces are disabled", () => {
const dependencies = buildDependencies();
expect(initTracing({ otelTracesEnabled: false }, dependencies.overrides)).toBeNull();
expect(dependencies.instances).toHaveLength(0);
});
it("initializes the SDK with the service resource and filesystem instrumentation disabled", () => {
const dependencies = buildDependencies();
const result = initTracing({ otelTracesEnabled: true }, dependencies.overrides);
expect(result).toBe(dependencies.instances[0]);
expect(dependencies.resourceFromAttributes).toHaveBeenCalledWith({ "service.name": "hyb-oa-server" });
expect(dependencies.getNodeAutoInstrumentations).toHaveBeenCalledWith({
"@opentelemetry/instrumentation-fs": { enabled: false },
});
expect(result.start).toHaveBeenCalledOnce();
expect(dependencies.logger.log).toHaveBeenCalledWith("OTel tracing initialized");
});
it("supports a distinct worker service name", () => {
const dependencies = buildDependencies();
initTracing({ otelTracesEnabled: true }, {
...dependencies.overrides,
serviceName: "hyb-oa-worker",
});
expect(dependencies.resourceFromAttributes).toHaveBeenCalledWith({ "service.name": "hyb-oa-worker" });
});
it("contains initialization failures and leaves tracing disabled", () => {
const error = new Error("otel_init_failed");
const dependencies = buildDependencies({ constructError: error });
expect(initTracing({ otelTracesEnabled: true }, dependencies.overrides)).toBeNull();
expect(dependencies.logger.error).toHaveBeenCalledWith("Error initializing OTel tracing", error);
});
it("shuts down an initialized SDK once", async () => {
const dependencies = buildDependencies();
const instance = initTracing({ otelTracesEnabled: true }, dependencies.overrides);
await shutdownTracing();
await shutdownTracing();
expect(instance.shutdown).toHaveBeenCalledOnce();
});
});
+11 -5
查看文件
@@ -21,6 +21,11 @@ const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
const TEST_FUTURE_OFFSET_MS = 24 * 60 * 60 * 1000;
function buildFutureDate() {
return new Date(Date.now() + TEST_FUTURE_OFFSET_MS);
}
function buildEmployee(overrides = {}) {
return {
@@ -47,7 +52,7 @@ function buildAnnouncement(overrides = {}) {
category: "policy",
requires_acknowledgement: true,
acknowledgement_statement: "我已阅读并理解该制度。",
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
acknowledgement_due_at: buildFutureDate(),
created_at: now,
created_by: ADMIN_ID,
updated_at: now,
@@ -76,11 +81,12 @@ function matchAnnouncementWhere(item, where = {}) {
if (where.enabled !== undefined && item.enabled !== where.enabled) {
return false;
}
const now = new Date("2026-05-28T02:00:00.000Z");
if (where.OR && item.starts_at && item.starts_at > now) {
const startsAtUpperBound = where.OR?.find((clause) => clause.starts_at?.lte)?.starts_at.lte ?? new Date();
const endsAtLowerBound = where.AND?.[0]?.OR?.find((clause) => clause.ends_at?.gte)?.ends_at.gte ?? new Date();
if (where.OR && item.starts_at && item.starts_at > startsAtUpperBound) {
return false;
}
if (where.AND && item.ends_at && item.ends_at < now) {
if (where.AND && item.ends_at && item.ends_at < endsAtLowerBound) {
return false;
}
return true;
@@ -107,7 +113,7 @@ function createPrismaState(options = {}) {
buildAnnouncement({
id: HIDDEN_ID,
title: "未来公告",
starts_at: new Date("2026-06-01T00:00:00.000Z"),
starts_at: buildFutureDate(),
}),
];
const acknowledgements = options.acknowledgements ?? [];
@@ -0,0 +1,141 @@
const { validateDevSeedSafety } = require("../../../src/scripts/dev-seed-safety");
const { ensureEmployee } = require("../../../src/scripts/seed-dev");
function safeEnv(overrides = {}) {
return {
NODE_ENV: "development",
SEED_DEV_ALLOW: "1",
SEED_DEV_DATABASE_CONFIRM: "hyb_oa_test",
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test?schema=public",
...overrides,
};
}
function employeePayload() {
return {
employeeNo: "DEV001",
name: "开发员工",
phone: "00000000000",
email: "[email protected]",
password: "OneTimePassword1234",
departmentId: "department-1",
legalEntityId: "entity-1",
title: "测试",
directManagerId: null,
roles: ["employee"],
hireDate: new Date("2020-01-01T00:00:00.000Z"),
};
}
function buildPrisma(existing = { id: "employee-1" }) {
return {
employee: {
findFirst: vi.fn(async () => existing),
update: vi.fn(async () => ({ id: existing?.id ?? "employee-1" })),
create: vi.fn(async () => ({ id: "employee-created" })),
},
employeeRole: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 1 })),
},
};
}
describe("dev seed safety guard", () => {
it("rejects production even when all explicit switches are present", () => {
expect(() => validateDevSeedSafety(safeEnv({ NODE_ENV: "production" }))).toThrowError(
expect.objectContaining({ code: "production_forbidden" }),
);
});
it("rejects execution without the explicit allow switch", () => {
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_ALLOW: "" }))).toThrowError(
expect.objectContaining({ code: "explicit_allow_required" }),
);
});
it("rejects non-loopback database targets", () => {
expect(() => validateDevSeedSafety(safeEnv({
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test",
}))).toThrowError(expect.objectContaining({ code: "database_host_not_local" }));
});
it("rejects a query-string host override that would bypass the loopback authority", () => {
expect(() => validateDevSeedSafety(safeEnv({
DATABASE_URL: "postgresql://local-user:[email protected]:5432/hyb_oa_test?host=database.internal",
}))).toThrowError(expect.objectContaining({ code: "database_host_not_local" }));
});
it("rejects a missing or mismatched database confirmation", () => {
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_DATABASE_CONFIRM: "" }))).toThrowError(
expect.objectContaining({ code: "database_confirmation_mismatch" }),
);
expect(() => validateDevSeedSafety(safeEnv({ SEED_DEV_DATABASE_CONFIRM: "another_database" }))).toThrowError(
expect.objectContaining({ code: "database_confirmation_mismatch" }),
);
});
it.each(["localhost", "127.0.0.1", "[::1]"])("accepts the local host %s with exact confirmation", (host) => {
expect(validateDevSeedSafety(safeEnv({
DATABASE_URL: `postgresql://local-user:local-password@${host}:5432/hyb_oa_test`,
}))).toEqual({ databaseName: "hyb_oa_test", resetPasswords: false });
});
it("enables one-time password rotation only through the separate reset switch", () => {
expect(validateDevSeedSafety(safeEnv({ SEED_DEV_RESET_PASSWORDS: "1" }))).toEqual({
databaseName: "hyb_oa_test",
resetPasswords: true,
});
});
it("does not include database credentials or target details in refusal messages", () => {
const env = safeEnv({
DATABASE_URL: "postgresql://sensitive-user:[email protected]:5432/sensitive_database",
SEED_DEV_DATABASE_CONFIRM: "sensitive_database",
});
try {
validateDevSeedSafety(env);
throw new Error("expected guard refusal");
} catch (error) {
expect(error.message).not.toMatch(/super-secret|sensitive-user|database\.internal|sensitive_database/);
}
});
});
describe("dev seed employee password behavior", () => {
it("preserves the password fields of an existing account by default", async () => {
const prisma = buildPrisma();
const result = await ensureEmployee(prisma, employeePayload());
const updateData = prisma.employee.update.mock.calls[0][0].data;
expect(updateData).not.toHaveProperty("password_hash");
expect(updateData).not.toHaveProperty("must_change_password");
expect(result.seedPasswordAction).toBe("preserved");
});
it("rotates an existing password only when requested and marks it one-time", async () => {
const prisma = buildPrisma();
const result = await ensureEmployee(prisma, employeePayload(), { resetPasswords: true });
const updateData = prisma.employee.update.mock.calls[0][0].data;
expect(updateData.password_hash).toEqual(expect.any(String));
expect(updateData.password_hash).not.toContain("OneTimePassword1234");
expect(updateData.must_change_password).toBe(true);
expect(result.seedPasswordAction).toBe("reset");
});
it("creates a missing development account without printing its credential", async () => {
const prisma = buildPrisma(null);
const result = await ensureEmployee(prisma, employeePayload());
const createData = prisma.employee.create.mock.calls[0][0].data;
expect(createData.password_hash).toEqual(expect.any(String));
expect(createData.password_hash).not.toContain("OneTimePassword1234");
expect(createData.must_change_password).toBe(true);
expect(result.seedPasswordAction).toBe("created");
});
});
+126 -1
查看文件
@@ -1,4 +1,22 @@
const { resolveEmailDeliveryStatus } = require("../../../src/services/email");
const {
resolveEmailDeliveryStatus,
sendNotificationEmail,
} = require("../../../src/services/email");
function buildEmailConfig(overrides = {}) {
return {
emailEnabled: true,
emailFrom: "[email protected]",
emailBaseUrl: "https://oa.hyinda.test/",
appOrigin: "",
smtpHost: "smtp.hyinda.test",
smtpPort: 587,
smtpSecure: false,
smtpUser: "sender",
smtpPass: "test-only-password",
...overrides,
};
}
describe("email delivery status", () => {
it("reports ready only when SMTP delivery has the required public settings", () => {
@@ -35,3 +53,110 @@ describe("email delivery status", () => {
});
});
});
describe("notification email delivery", () => {
it("skips delivery without creating a transporter when email is disabled", async () => {
const createTransport = vi.fn();
const prisma = { employee: { findMany: vi.fn() } };
const result = await sendNotificationEmail({
prisma,
config: buildEmailConfig({ emailEnabled: false }),
recipientEmployeeIds: ["employee-1"],
subject: "待办提醒",
body: "请处理待办。",
createTransport,
});
expect(result).toEqual({ skipped: true, sent: 0, failed: 0 });
expect(createTransport).not.toHaveBeenCalled();
expect(prisma.employee.findMany).not.toHaveBeenCalled();
});
it("creates a Nodemailer 9 transporter and sends text mail with a safe link", async () => {
const sendMail = vi.fn(async () => ({ messageId: "message-1" }));
const createTransport = vi.fn(() => ({ sendMail }));
const prisma = {
employee: {
findMany: vi.fn(async () => [
{ id: "employee-1", email: "[email protected]", name: "员工一" },
{ id: "employee-2", email: null, name: "员工二" },
]),
},
};
const result = await sendNotificationEmail({
prisma,
config: buildEmailConfig({ smtpHost: "smtp-success.hyinda.test" }),
recipientEmployeeIds: ["employee-1", "employee-2", "employee-1"],
subject: "待办提醒",
body: "请处理待办。",
type: "workflow_task",
targetPath: "/workflows/tasks/1",
requestId: "request-1",
createTransport,
});
expect(createTransport).toHaveBeenCalledWith({
host: "smtp-success.hyinda.test",
port: 587,
secure: false,
auth: { user: "sender", pass: "test-only-password" },
});
expect(sendMail).toHaveBeenCalledWith({
from: "[email protected]",
to: "[email protected]",
subject: "待办提醒",
text: "请处理待办。\n\n查看详情:https://oa.hyinda.test/workflows/tasks/1",
headers: {
"x-hyb-notification-type": "workflow_task",
"x-request-id": "request-1",
},
});
expect(result).toEqual({ skipped: false, sent: 1, failed: 1 });
});
it("counts send failures without logging the recipient email address", async () => {
const error = new Error("smtp_rejected");
error.code = "EENVELOPE";
error.command = "RCPT TO";
error.responseCode = 550;
error.response = "550 rejected [email protected]";
error.rejected = ["[email protected]"];
const createTransport = vi.fn(() => ({ sendMail: vi.fn(async () => { throw error; }) }));
const logger = { error: vi.fn() };
const prisma = {
employee: {
findMany: vi.fn(async () => [
{ id: "employee-3", email: "[email protected]", name: "员工三" },
]),
},
};
const result = await sendNotificationEmail({
prisma,
config: buildEmailConfig({ smtpHost: "smtp-failure.hyinda.test" }),
logger,
requestId: "request-2",
recipientEmployeeIds: ["employee-3"],
subject: "待办提醒",
body: "请处理待办。",
createTransport,
});
expect(result).toEqual({ skipped: false, sent: 0, failed: 1 });
expect(logger.error).toHaveBeenCalledOnce();
const [context] = logger.error.mock.calls[0];
expect(context).toMatchObject({
errorCode: "email_delivery_failed",
providerCode: "EENVELOPE",
smtpCommand: "RCPT TO",
smtpResponseCode: 550,
requestId: "request-2",
employeeId: "employee-3",
});
expect(context).not.toHaveProperty("err");
expect(context).not.toHaveProperty("email");
expect(JSON.stringify(context)).not.toContain("[email protected]");
});
});
@@ -114,7 +114,7 @@ describe("workspace small-team navigation defaults", () => {
});
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toEqual(["工作台", "报销", "采购申请", "项目", "资产", "请假记录"]);
expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]);
expect(mainLabels).not.toContain("待我处理");
expect(mainLabels).not.toContain("票据");
expect(mainLabels).not.toContain("个人设置");
@@ -150,8 +150,8 @@ describe("workspace small-team navigation defaults", () => {
"工作台",
"待我处理",
"报销",
"采购申请",
"合同台账",
"采购",
"合同",
"项目",
"资产",
"团队",