统一 Node 22 与后端安全运行基线(07-15-production-trust-closeout-cleanup)
这个提交包含在:
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
server/package-lock.json
|
server/package-lock.json
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: client/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
server/package-lock.json
|
server/package-lock.json
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: server/package-lock.json
|
||||||
|
|
||||||
@@ -58,6 +58,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Seed database
|
- name: Seed database
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
env:
|
||||||
|
SEED_DEV_ALLOW: "1"
|
||||||
|
SEED_DEV_DATABASE_CONFIRM: hyb_oa
|
||||||
run: npm run seed:dev
|
run: npm run seed:dev
|
||||||
|
|
||||||
- name: Start server
|
- name: Start server
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
server/package-lock.json
|
server/package-lock.json
|
||||||
|
|||||||
+18
-24
@@ -1,12 +1,12 @@
|
|||||||
# hyb-oa Client Runbook (P1-5)
|
# hyb-oa Client Runbook (P1-5)
|
||||||
|
|
||||||
## 1. 前置条件
|
## 1. 前置条件
|
||||||
- Node.js >= 20
|
- Node.js `>=22 <23`
|
||||||
- 后端服务可用(`http://127.0.0.1:3000`)
|
- 后端服务可用(`http://127.0.0.1:3000`)
|
||||||
|
|
||||||
## 2. 安装与启动
|
## 2. 安装与启动
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/client
|
cd client
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -15,19 +15,23 @@ npm run dev
|
|||||||
|
|
||||||
## 3. 本地联调建议顺序
|
## 3. 本地联调建议顺序
|
||||||
```bash
|
```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
|
docker compose up -d db
|
||||||
|
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd server
|
||||||
npm run prisma:migrate:deploy
|
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
|
npm run dev
|
||||||
|
|
||||||
cd /home/shunli/Projects/hyb-oa/client
|
cd ../client
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
根级 Compose 要求显式提供本地数据库口令和 JWT 密钥;不要把真实值写入仓库,并让 `server/.env` 的 `DATABASE_URL` 使用同一数据库口令。
|
||||||
|
|
||||||
## 3.1 试运行/单域托管部署(推荐)
|
## 3.1 试运行/单域托管部署(推荐)
|
||||||
试运行/上线演练建议使用仓库内置部署包(server 托管 `client/dist`,同域 cookie 更稳定):
|
试运行/上线演练建议使用仓库内置部署包(server 托管 `client/dist`,同域 cookie 更稳定):
|
||||||
- `deploy/trialrun/runbook.md`
|
- `deploy/trialrun/runbook.md`
|
||||||
@@ -39,19 +43,9 @@ npm run dev
|
|||||||
- 前端在 401 时自动调用 `/api/auth/refresh` 并重试一次请求。
|
- 前端在 401 时自动调用 `/api/auth/refresh` 并重试一次请求。
|
||||||
- 登录与刷新请求都会附带 `x-device-fingerprint`。
|
- 登录与刷新请求都会附带 `x-device-fingerprint`。
|
||||||
|
|
||||||
## 4.1 开发环境默认账号(seed:dev)
|
## 4.1 开发账号(seed:dev)
|
||||||
仅用于本地联调(`cd server && npm run seed:dev`):
|
|
||||||
|
|
||||||
| 角色 | 账号 | 密码 |
|
`seed:dev` 仅用于本机开发库,必须显式允许并确认数据库名;账号与口令以本地受控 `SEED_*` 配置为准,脚本不输出口令。默认不会覆盖已有账号密码;新建账号和主动设置 `SEED_DEV_RESET_PASSWORDS=1` 后轮换的账号,首次登录都必须改密。
|
||||||
|---|---|---|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
忘记密码:登录页提供邮箱验证码找回;管理员重置密码仍作为试运行/生产兜底。
|
|
||||||
|
|
||||||
## 5. 菜单与权限(当前壳层)
|
## 5. 菜单与权限(当前壳层)
|
||||||
- 仪表盘:登录即可访问。
|
- 仪表盘:登录即可访问。
|
||||||
@@ -74,7 +68,7 @@ npm run dev
|
|||||||
- API 层保留后端错误码原文,前端仅做可读化。
|
- API 层保留后端错误码原文,前端仅做可读化。
|
||||||
|
|
||||||
## 6. 风格资源复用策略(锁定)
|
## 6. 风格资源复用策略(锁定)
|
||||||
- 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。
|
- 视觉 Token 来源:`<design-source-repo>`。
|
||||||
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
|
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
|
||||||
- 如需同步风格,请记录来源 commit 并通过评审。
|
- 如需同步风格,请记录来源 commit 并通过评审。
|
||||||
|
|
||||||
@@ -90,18 +84,18 @@ npm run test:e2e
|
|||||||
## 8.1 Playwright E2E(E15)
|
## 8.1 Playwright E2E(E15)
|
||||||
前置:本地启动 DB + server(建议 `SERVE_CLIENT_DIST=true` 单域托管),并启用 mock OCR:
|
前置:本地启动 DB + server(建议 `SERVE_CLIENT_DIST=true` 单域托管),并启用 mock OCR:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa
|
cd ..
|
||||||
docker compose up -d db
|
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 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
|
OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" SERVE_CLIENT_DIST=true npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
执行:
|
执行:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/client
|
cd ../client
|
||||||
npm install
|
npm install
|
||||||
npx playwright install --with-deps
|
npx playwright install --with-deps
|
||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
@@ -109,7 +103,7 @@ npm run test:e2e
|
|||||||
|
|
||||||
## 9. 仓库级最小门禁
|
## 9. 仓库级最小门禁
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa
|
cd ..
|
||||||
./scripts/check-minimal.sh
|
./scripts/check-minimal.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -21,7 +21,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.30.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.6.1"
|
"tailwind-merge": "^2.6.1"
|
||||||
},
|
},
|
||||||
@@ -36,6 +36,9 @@
|
|||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.1.0",
|
"vite": "^5.1.0",
|
||||||
"vite-plugin-pwa": "^0.20.5"
|
"vite-plugin-pwa": "^0.20.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -2880,9 +2883,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -6265,12 +6268,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.2"
|
"@remix-run/router": "1.23.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -6280,13 +6283,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.2",
|
"@remix-run/router": "1.23.3",
|
||||||
"react-router": "6.30.3"
|
"react-router": "6.30.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <23"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run typecheck && vite build",
|
"build": "npm run typecheck && vite build",
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.30.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.6.1"
|
"tailwind-merge": "^2.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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -6,23 +6,23 @@ services:
|
|||||||
container_name: hyb-oa-db
|
container_name: hyb-oa-db
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: hyboa_user
|
POSTGRES_USER: ${POSTGRES_USER:-hyboa_user}
|
||||||
POSTGRES_PASSWORD: hyboa_password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the local environment}
|
||||||
POSTGRES_DB: hyb_oa
|
POSTGRES_DB: ${POSTGRES_DB:-hyb_oa}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
server:
|
server:
|
||||||
image: node:20-alpine
|
image: node:22-alpine
|
||||||
container_name: hyb-oa-server
|
container_name: hyb-oa-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app/server
|
working_dir: /app/server
|
||||||
command: sh -c "npm install && npm run dev"
|
command: sh -c "npm install && npm run dev"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://hyboa_user:hyboa_password@db:5432/hyb_oa?schema=public"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-hyboa_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-hyb_oa}?schema=public"
|
||||||
JWT_SECRET: "change_me"
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the local environment}
|
||||||
APP_TIMEZONE: "Asia/Shanghai"
|
APP_TIMEZONE: "Asia/Shanghai"
|
||||||
STORAGE_DIR: "generated/storage"
|
STORAGE_DIR: "generated/storage"
|
||||||
SERVE_CLIENT_DIST: "false"
|
SERVE_CLIENT_DIST: "false"
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ echo ">>> [isolated-dedup] Prisma generate + migrate deploy"
|
|||||||
echo ">>> [isolated-dedup] 写入开发种子数据"
|
echo ">>> [isolated-dedup] 写入开发种子数据"
|
||||||
(
|
(
|
||||||
cd "$SERVER_DIR"
|
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"
|
echo ">>> [isolated-dedup] 启动临时服务:$QA_BASE_URL"
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ qa_prepare_db() {
|
|||||||
echo ">>> 写入开发种子数据"
|
echo ">>> 写入开发种子数据"
|
||||||
(
|
(
|
||||||
cd "$SERVER_DIR"
|
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"
|
DATABASE_URL="<local-postgresql-url>"
|
||||||
JWT_SECRET="change_me"
|
# 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)=====
|
# ===== 敏感字段加密(F25)=====
|
||||||
# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。
|
# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。
|
||||||
# 生成方式示例:openssl rand -base64 32
|
# 生成方式示例:openssl rand -base64 32
|
||||||
@@ -105,8 +110,8 @@ INTEGRATION_OUTBOX_MAX_ATTEMPTS="5"
|
|||||||
INTEGRATION_OUTBOX_RETRY_SECONDS="120"
|
INTEGRATION_OUTBOX_RETRY_SECONDS="120"
|
||||||
|
|
||||||
# ===== 试运行引导管理员(E20,可选)=====
|
# ===== 试运行引导管理员(E20,可选)=====
|
||||||
# TRIALRUN_ADMIN_EMAIL="admin@hyinda.com"
|
# TRIALRUN_ADMIN_EMAIL="<admin-email>"
|
||||||
# TRIALRUN_ADMIN_PHONE="13800000000"
|
# TRIALRUN_ADMIN_PHONE="<admin-phone>"
|
||||||
# TRIALRUN_ADMIN_PASSWORD=""
|
# TRIALRUN_ADMIN_PASSWORD=""
|
||||||
|
|
||||||
# R12 lite feature gates
|
# R12 lite feature gates
|
||||||
@@ -120,7 +125,7 @@ CUTOVER_CONSOLE_ENABLED=false
|
|||||||
OIDC_PROVIDER_LABEL=企业统一登录
|
OIDC_PROVIDER_LABEL=企业统一登录
|
||||||
|
|
||||||
# ===== Prisma Migration Diff(本地生成 migration 用,可选) =====
|
# ===== 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 小团队内部管理 =====
|
# ===== R13-R17 小团队内部管理 =====
|
||||||
PRESENCE_BOARD_ENABLED=false
|
PRESENCE_BOARD_ENABLED=false
|
||||||
|
|||||||
+41
-43
@@ -1,13 +1,13 @@
|
|||||||
# hyb-oa Server Runbook (P0/P1)
|
# hyb-oa Server Runbook (P0/P1)
|
||||||
|
|
||||||
## 1. 环境准备
|
## 1. 环境准备
|
||||||
- Node.js: `>=20`
|
- Node.js: `>=22 <23`
|
||||||
- Docker + Docker Compose
|
- Docker + Docker Compose
|
||||||
- 本地工具:`curl`、`jq`
|
- 本地工具:`curl`、`jq`
|
||||||
- 配置文件:从 `server/.env.example` 复制为 `server/.env` 并按需修改
|
- 配置文件:从 `server/.env.example` 复制为 `server/.env` 并按需修改
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd server
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -15,39 +15,37 @@ cp .env.example .env
|
|||||||
|
|
||||||
### 2.1 启动数据库
|
### 2.1 启动数据库
|
||||||
```bash
|
```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
|
docker compose up -d db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
根级 Compose 不再内置数据库口令或 JWT 密钥;请仅在本地 shell 或未提交的环境文件中设置,并让 `server/.env` 的 `DATABASE_URL` 使用同一数据库口令。
|
||||||
|
|
||||||
### 2.2 应用迁移
|
### 2.2 应用迁移
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd server
|
||||||
npm run prisma:migrate:deploy
|
npm run prisma:migrate:deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 执行 seed(幂等)
|
### 2.3 执行 seed(幂等)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd server
|
||||||
npm run seed:dev
|
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):
|
试运行管理员引导(E20):
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd server
|
||||||
TRIALRUN_ADMIN_EMAIL="admin@hyinda.com" \
|
TRIALRUN_ADMIN_EMAIL="<admin-email>" \
|
||||||
TRIALRUN_ADMIN_PHONE="13800000000" \
|
TRIALRUN_ADMIN_PHONE="<admin-phone>" \
|
||||||
TRIALRUN_ADMIN_PASSWORD="" \
|
TRIALRUN_ADMIN_PASSWORD="" \
|
||||||
npm run seed:trialrun
|
npm run seed:trialrun
|
||||||
```
|
```
|
||||||
@@ -101,11 +99,11 @@ npm run seed:trialrun
|
|||||||
本地双进程示例:
|
本地双进程示例:
|
||||||
```bash
|
```bash
|
||||||
# 终端 A:API(不跑作业)
|
# 终端 A:API(不跑作业)
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
JOBS_ENABLED=false npm run dev
|
JOBS_ENABLED=false npm run dev
|
||||||
|
|
||||||
# 终端 B:Worker(只跑作业)
|
# 终端 B:Worker(只跑作业)
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
JOBS_ENABLED=true npm run worker
|
JOBS_ENABLED=true npm run worker
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,19 +125,19 @@ JOBS_ENABLED=true npm run worker
|
|||||||
|
|
||||||
### 2.4 启动服务
|
### 2.4 启动服务
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.5 冒烟验证
|
### 2.5 冒烟验证
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/smoke-test.sh
|
./scripts/smoke-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.6 本地最小门禁(P1-b)
|
### 2.6 本地最小门禁(P1-b)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa
|
cd <repo-root>
|
||||||
./scripts/check-minimal.sh
|
./scripts/check-minimal.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -149,7 +147,7 @@ cd /home/shunli/Projects/hyb-oa
|
|||||||
|
|
||||||
## 3. 1 分钟快速验证
|
## 3. 1 分钟快速验证
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/smoke-test.sh
|
./scripts/smoke-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,26 +169,26 @@ curl -fsS http://127.0.0.1:3000/metrics | head
|
|||||||
|
|
||||||
### 4.1 脚本回归(推荐)
|
### 4.1 脚本回归(推荐)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh
|
BASE_URL=http://127.0.0.1:3000 ./scripts/smoke-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 判重专项回归(dedup)
|
### 4.2 判重专项回归(dedup)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
DATABASE_URL='postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public' \
|
DATABASE_URL='<local-postgresql-url>' \
|
||||||
BASE_URL=http://127.0.0.1:3000 ./scripts/regression-dedup.sh
|
BASE_URL=http://127.0.0.1:3000 ./scripts/regression-dedup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 工作流动作回归
|
### 4.3 工作流动作回归
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/regression-workflow-actions.sh
|
./scripts/regression-workflow-actions.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
可选并发校验:
|
可选并发校验:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh
|
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 一键全量回归
|
### 4.4 一键全量回归
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/regression-all.sh
|
./scripts/regression-all.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,25 +225,25 @@ cd /home/shunli/Projects/hyb-oa/server
|
|||||||
|
|
||||||
可选关闭审计/OCR:
|
可选关闭审计/OCR:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh
|
RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
可选打开组织/配置回归(org + settings):
|
可选打开组织/配置回归(org + settings):
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
RUN_ORG_SETTINGS_REGRESSION=1 ./scripts/regression-all.sh
|
RUN_ORG_SETTINGS_REGRESSION=1 ./scripts/regression-all.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
可选打开认证安全回归(auth security):
|
可选打开认证安全回归(auth security):
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
RUN_AUTH_SECURITY_REGRESSION=1 ./scripts/regression-all.sh
|
RUN_AUTH_SECURITY_REGRESSION=1 ./scripts/regression-all.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
独立执行:
|
独立执行:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/regression-audit.sh
|
./scripts/regression-audit.sh
|
||||||
OCR_PROVIDER=mock ./scripts/regression-ocr.sh
|
OCR_PROVIDER=mock ./scripts/regression-ocr.sh
|
||||||
./scripts/regression-org-settings.sh
|
./scripts/regression-org-settings.sh
|
||||||
@@ -267,7 +265,7 @@ OCR_PROVIDER=mock ./scripts/regression-ocr.sh
|
|||||||
|
|
||||||
### 4.6 前端壳联调(P1-5)
|
### 4.6 前端壳联调(P1-5)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/client
|
cd <repo-root>/client
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -279,10 +277,10 @@ npm run dev
|
|||||||
|
|
||||||
### 4.7 单域托管 client/dist(可选,试运行建议)
|
### 4.7 单域托管 client/dist(可选,试运行建议)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/client
|
cd <repo-root>/client
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
SERVE_CLIENT_DIST=true npm run dev
|
SERVE_CLIENT_DIST=true npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -299,7 +297,7 @@ SERVE_CLIENT_DIST=true npm run dev
|
|||||||
|
|
||||||
备份/恢复脚本(E14):
|
备份/恢复脚本(E14):
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa
|
cd <repo-root>
|
||||||
./scripts/backup-db.sh
|
./scripts/backup-db.sh
|
||||||
./scripts/restore-db.sh deploy/trialrun/backups/<db.dump>
|
./scripts/restore-db.sh deploy/trialrun/backups/<db.dump>
|
||||||
./scripts/backup-storage.sh
|
./scripts/backup-storage.sh
|
||||||
@@ -358,7 +356,7 @@ cd /home/shunli/Projects/hyb-oa
|
|||||||
|
|
||||||
### 5.8 定时任务(可手动执行)
|
### 5.8 定时任务(可手动执行)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
npm run job:revoke-expired-delegations
|
npm run job:revoke-expired-delegations
|
||||||
npm run job:ocr-once
|
npm run job:ocr-once
|
||||||
```
|
```
|
||||||
@@ -492,7 +490,7 @@ npm run job:ocr-once
|
|||||||
|
|
||||||
### 9.3 新增回归脚本
|
### 9.3 新增回归脚本
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
./scripts/regression-verification.sh
|
./scripts/regression-verification.sh
|
||||||
./scripts/regression-dashboard.sh
|
./scripts/regression-dashboard.sh
|
||||||
./scripts/regression-budget.sh
|
./scripts/regression-budget.sh
|
||||||
@@ -502,7 +500,7 @@ cd /home/shunli/Projects/hyb-oa/server
|
|||||||
|
|
||||||
可通过总回归开关启用:
|
可通过总回归开关启用:
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
RUN_VERIFICATION_REGRESSION=1 \
|
RUN_VERIFICATION_REGRESSION=1 \
|
||||||
RUN_DASHBOARD_REGRESSION=1 \
|
RUN_DASHBOARD_REGRESSION=1 \
|
||||||
RUN_BUDGET_REGRESSION=1 \
|
RUN_BUDGET_REGRESSION=1 \
|
||||||
@@ -513,7 +511,7 @@ RUN_FEATURE_FLAGS_REGRESSION=1 \
|
|||||||
|
|
||||||
### 9.4 单次任务脚本(调试)
|
### 9.4 单次任务脚本(调试)
|
||||||
```bash
|
```bash
|
||||||
cd /home/shunli/Projects/hyb-oa/server
|
cd <repo-root>/server
|
||||||
VERIFICATION_RECORD_ID=<uuid> npm run job:verification-once
|
VERIFICATION_RECORD_ID=<uuid> npm run job:verification-once
|
||||||
npm run job:integration-outbox-once
|
npm run job:integration-outbox-once
|
||||||
```
|
```
|
||||||
|
|||||||
+1492
-2794
文件差异内容过多而无法显示
加载差异
+14
-11
@@ -17,7 +17,7 @@
|
|||||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
"seed:config": "node src/scripts/seed-config.js",
|
"seed:config": "node src/scripts/seed-config.js",
|
||||||
"seed:dev-users": "node src/scripts/seed-dev.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",
|
"seed:trialrun": "node src/scripts/seed-trialrun.js",
|
||||||
"report:small-team-observation": "node src/scripts/export-small-team-observation.js",
|
"report:small-team-observation": "node src/scripts/export-small-team-observation.js",
|
||||||
"job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js",
|
"job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js",
|
||||||
@@ -47,34 +47,37 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22 <23"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.985.0",
|
"@aws-sdk/client-s3": "^3.1087.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.2.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/swagger": "^9.5.2",
|
"@fastify/swagger": "^9.5.2",
|
||||||
"@fastify/swagger-ui": "^5.2.3",
|
"@fastify/swagger-ui": "^5.2.3",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.78.0",
|
||||||
"@opentelemetry/sdk-node": "^0.213.0",
|
"@opentelemetry/sdk-node": "^0.220.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fast-xml-parser": "5.3.4",
|
"fast-xml-parser": "5.10.0",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.10.0",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^9.0.3",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pg-boss": "^10.3.0",
|
"pg-boss": "^10.3.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.8.0",
|
||||||
"prom-client": "^15.1.0",
|
"prom-client": "^15.1.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||||
|
|||||||
+20
-5
@@ -1,7 +1,7 @@
|
|||||||
require("dotenv/config");
|
require("dotenv/config");
|
||||||
|
|
||||||
const { getConfig } = require("./config");
|
const { getConfig } = require("./config");
|
||||||
const { initTracing } = require("./observability/tracing");
|
const { initTracing, shutdownTracing } = require("./observability/tracing");
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
initTracing(config);
|
initTracing(config);
|
||||||
@@ -35,22 +35,37 @@ async function main() {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
forceTimer.unref?.();
|
forceTimer.unref?.();
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
try {
|
try {
|
||||||
await app.close();
|
await app.close();
|
||||||
app.log.info("Graceful shutdown completed");
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error({ err: error }, "Graceful shutdown failed");
|
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("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch(async (err) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
try {
|
||||||
|
await shutdownTracing();
|
||||||
|
} catch {
|
||||||
|
// The startup error remains authoritative; shutdown is best effort here.
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,42 +1,56 @@
|
|||||||
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
const { NodeSDK } = require("@opentelemetry/sdk-node");
|
||||||
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
|
const { ConsoleSpanExporter } = require("@opentelemetry/sdk-trace-node");
|
||||||
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node");
|
||||||
const { Resource } = require('@opentelemetry/resources');
|
const { resourceFromAttributes } = require("@opentelemetry/resources");
|
||||||
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
|
const { ATTR_SERVICE_NAME } = require("@opentelemetry/semantic-conventions");
|
||||||
|
|
||||||
let sdk = null;
|
let sdk = null;
|
||||||
|
|
||||||
function initTracing(config) {
|
function initTracing(config, overrides = {}) {
|
||||||
if (config.otelTracesEnabled !== true) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exporter = new ConsoleSpanExporter();
|
const activeSdk = sdk;
|
||||||
|
sdk = null;
|
||||||
sdk = new NodeSDK({
|
await activeSdk.shutdown();
|
||||||
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', () => {
|
module.exports = { initTracing, shutdownTracing };
|
||||||
if (sdk) {
|
|
||||||
sdk.shutdown()
|
|
||||||
.then(() => console.log('OTel tracing terminated'))
|
|
||||||
.catch((error) => console.log('Error terminating OTel tracing', error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initTracing };
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+46
-45
@@ -2,6 +2,7 @@ require("dotenv/config");
|
|||||||
|
|
||||||
const { createPrismaClient } = require("../prismaClient");
|
const { createPrismaClient } = require("../prismaClient");
|
||||||
const { hashPassword } = require("../utils/crypto");
|
const { hashPassword } = require("../utils/crypto");
|
||||||
|
const { validateDevSeedSafety } = require("./dev-seed-safety");
|
||||||
|
|
||||||
async function ensureDepartment(prisma, payload) {
|
async function ensureDepartment(prisma, payload) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -330,9 +331,8 @@ async function ensureWorkspaceSetting(prisma) {
|
|||||||
return prisma.workspaceSetting.create({ data });
|
return prisma.workspaceSetting.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureEmployee(prisma, payload) {
|
async function ensureEmployee(prisma, payload, { resetPasswords = false } = {}) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const passwordHash = await hashPassword(payload.password);
|
|
||||||
|
|
||||||
const existing = await prisma.employee.findFirst({
|
const existing = await prisma.employee.findFirst({
|
||||||
where: { email: payload.email },
|
where: { email: payload.email },
|
||||||
@@ -340,6 +340,12 @@ async function ensureEmployee(prisma, payload) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const passwordData = resetPasswords
|
||||||
|
? {
|
||||||
|
password_hash: await hashPassword(payload.password),
|
||||||
|
must_change_password: true,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
const employee = await prisma.employee.update({
|
const employee = await prisma.employee.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -353,11 +359,10 @@ async function ensureEmployee(prisma, payload) {
|
|||||||
status: "active",
|
status: "active",
|
||||||
bank_account_encrypted: "ENC_DEV_ONLY",
|
bank_account_encrypted: "ENC_DEV_ONLY",
|
||||||
bank_name: "DEV_BANK",
|
bank_name: "DEV_BANK",
|
||||||
password_hash: passwordHash,
|
...passwordData,
|
||||||
must_change_password: false,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
select: { id: true, email: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.employeeRole.deleteMany({
|
await prisma.employeeRole.deleteMany({
|
||||||
@@ -373,9 +378,13 @@ async function ensureEmployee(prisma, payload) {
|
|||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return employee;
|
return {
|
||||||
|
...employee,
|
||||||
|
seedPasswordAction: resetPasswords ? "reset" : "preserved",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(payload.password);
|
||||||
const employee = await prisma.employee.create({
|
const employee = await prisma.employee.create({
|
||||||
data: {
|
data: {
|
||||||
employee_no: payload.employeeNo,
|
employee_no: payload.employeeNo,
|
||||||
@@ -391,11 +400,11 @@ async function ensureEmployee(prisma, payload) {
|
|||||||
bank_account_encrypted: "ENC_DEV_ONLY",
|
bank_account_encrypted: "ENC_DEV_ONLY",
|
||||||
bank_name: "DEV_BANK",
|
bank_name: "DEV_BANK",
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
must_change_password: false,
|
must_change_password: true,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
select: { id: true, email: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.employeeRole.createMany({
|
await prisma.employeeRole.createMany({
|
||||||
@@ -407,10 +416,11 @@ async function ensureEmployee(prisma, payload) {
|
|||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return employee;
|
return { ...employee, seedPasswordAction: "created" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const safety = validateDevSeedSafety();
|
||||||
const { prisma, pool } = createPrismaClient();
|
const { prisma, pool } = createPrismaClient();
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
|
|
||||||
@@ -451,6 +461,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await ensureWorkspaceSetting(prisma);
|
await ensureWorkspaceSetting(prisma);
|
||||||
|
const ensureDevEmployee = (payload) => ensureEmployee(prisma, payload, {
|
||||||
|
resetPasswords: safety.resetPasswords,
|
||||||
|
});
|
||||||
|
|
||||||
for (const flagKey of [
|
for (const flagKey of [
|
||||||
"portal_home_v1",
|
"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",
|
employeeNo: process.env.SEED_CEO_EMPLOYEE_NO ?? "HYD000001",
|
||||||
name: "张总",
|
name: "张总",
|
||||||
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
|
phone: process.env.SEED_CEO_PHONE ?? "13900000000",
|
||||||
@@ -491,7 +504,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const admin = await ensureEmployee(prisma, {
|
const admin = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "HYD000006",
|
employeeNo: process.env.SEED_ADMIN_EMPLOYEE_NO ?? "HYD000006",
|
||||||
name: "王顺利",
|
name: "王顺利",
|
||||||
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
|
phone: process.env.SEED_ADMIN_PHONE ?? "13800000000",
|
||||||
@@ -505,7 +518,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const manager = await ensureEmployee(prisma, {
|
const manager = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "HYD000002",
|
employeeNo: process.env.SEED_MANAGER_EMPLOYEE_NO ?? "HYD000002",
|
||||||
name: "李研发",
|
name: "李研发",
|
||||||
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
|
phone: process.env.SEED_MANAGER_PHONE ?? "13700000000",
|
||||||
@@ -519,7 +532,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const employee = await ensureEmployee(prisma, {
|
const employee = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "HYD000003",
|
employeeNo: process.env.SEED_EMPLOYEE_EMPLOYEE_NO ?? "HYD000003",
|
||||||
name: "陈工程",
|
name: "陈工程",
|
||||||
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
|
phone: process.env.SEED_EMPLOYEE_PHONE ?? "13600000000",
|
||||||
@@ -533,7 +546,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const finance = await ensureEmployee(prisma, {
|
const finance = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "HYD000004",
|
employeeNo: process.env.SEED_FINANCE_EMPLOYEE_NO ?? "HYD000004",
|
||||||
name: "赵财务",
|
name: "赵财务",
|
||||||
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
|
phone: process.env.SEED_FINANCE_PHONE ?? "13500000000",
|
||||||
@@ -547,7 +560,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const financeMgr = await ensureEmployee(prisma, {
|
const financeMgr = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "HYD000005",
|
employeeNo: process.env.SEED_FINANCE_MGR_EMPLOYEE_NO ?? "HYD000005",
|
||||||
name: "钱主管",
|
name: "钱主管",
|
||||||
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
|
phone: process.env.SEED_FINANCE_MGR_PHONE ?? "13400000000",
|
||||||
@@ -561,7 +574,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hr = await ensureEmployee(prisma, {
|
const hr = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "HYD000007",
|
employeeNo: process.env.SEED_HR_EMPLOYEE_NO ?? "HYD000007",
|
||||||
name: "孙人事",
|
name: "孙人事",
|
||||||
phone: process.env.SEED_HR_PHONE ?? "13300000000",
|
phone: process.env.SEED_HR_PHONE ?? "13300000000",
|
||||||
@@ -575,7 +588,7 @@ async function main() {
|
|||||||
hireDate,
|
hireDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const administration = await ensureEmployee(prisma, {
|
const administration = await ensureDevEmployee({
|
||||||
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "HYD000008",
|
employeeNo: process.env.SEED_ADMINISTRATION_EMPLOYEE_NO ?? "HYD000008",
|
||||||
name: "周行政",
|
name: "周行政",
|
||||||
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
|
phone: process.env.SEED_ADMINISTRATION_PHONE ?? "13200000000",
|
||||||
@@ -659,40 +672,28 @@ async function main() {
|
|||||||
warningThresholdPercent: 75,
|
warningThresholdPercent: 75,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
const users = [ceo, admin, manager, employee, finance, financeMgr, hr, administration];
|
||||||
console.log("Seeded dev users:");
|
const countAction = (action) => users.filter((item) => item.seedPasswordAction === action).length;
|
||||||
// eslint-disable-next-line no-console
|
console.log(
|
||||||
console.log(`- 负责人: ${ceo.email} / ${process.env.SEED_CEO_PASSWORD ?? "CeoPassword1234"}`);
|
`[seed-dev] completed; users=${users.length} created=${countAction("created")} ` +
|
||||||
// eslint-disable-next-line no-console
|
`passwordsPreserved=${countAction("preserved")} oneTimeResets=${countAction("reset")}`,
|
||||||
console.log(`- 管理员: ${admin.email} / ${process.env.SEED_ADMIN_PASSWORD ?? "AdminPassword1234"}`);
|
);
|
||||||
// eslint-disable-next-line no-console
|
console.log("[seed-dev] reference data synchronized; credential values were not printed");
|
||||||
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}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
// eslint-disable-next-line no-console
|
const code = typeof error?.code === "string" ? error.code : error?.name || "unexpected_error";
|
||||||
console.error(error);
|
console.error(`[seed-dev][FAIL] ${code}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureEmployee,
|
||||||
|
main,
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function resolveEmailBaseUrl(config) {
|
|||||||
let cachedTransportKey = null;
|
let cachedTransportKey = null;
|
||||||
let cachedTransporter = null;
|
let cachedTransporter = null;
|
||||||
|
|
||||||
function getTransporter(config) {
|
function getTransporter(config, createTransport = nodemailer.createTransport) {
|
||||||
if (!config.emailEnabled) {
|
if (!config.emailEnabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ function getTransporter(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedTransportKey = key;
|
cachedTransportKey = key;
|
||||||
cachedTransporter = nodemailer.createTransport({
|
cachedTransporter = createTransport({
|
||||||
host: config.smtpHost,
|
host: config.smtpHost,
|
||||||
port: config.smtpPort,
|
port: config.smtpPort,
|
||||||
secure: config.smtpSecure,
|
secure: config.smtpSecure,
|
||||||
@@ -86,6 +86,36 @@ function uniq(values) {
|
|||||||
return Array.from(new Set((values ?? []).filter(Boolean)));
|
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) {
|
async function sendNotificationEmailSafe(params) {
|
||||||
try {
|
try {
|
||||||
const emailResult = await sendNotificationEmail(params);
|
const emailResult = await sendNotificationEmail(params);
|
||||||
@@ -95,7 +125,7 @@ async function sendNotificationEmailSafe(params) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.logger?.error?.(
|
params.logger?.error?.(
|
||||||
{
|
{
|
||||||
err: error,
|
...buildEmailErrorLog(error),
|
||||||
requestId: params.requestId,
|
requestId: params.requestId,
|
||||||
notificationType: params.type,
|
notificationType: params.type,
|
||||||
recipients: params.recipientEmployeeIds?.length ?? 0,
|
recipients: params.recipientEmployeeIds?.length ?? 0,
|
||||||
@@ -118,12 +148,13 @@ async function sendNotificationEmail({
|
|||||||
body,
|
body,
|
||||||
type = 'notification',
|
type = 'notification',
|
||||||
targetPath,
|
targetPath,
|
||||||
|
createTransport,
|
||||||
}) {
|
}) {
|
||||||
if (!config.emailEnabled) {
|
if (!config.emailEnabled) {
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = getTransporter(config);
|
const transporter = getTransporter(config, createTransport);
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
logger?.warn?.({ requestId }, 'Email enabled but transporter not configured');
|
logger?.warn?.({ requestId }, 'Email enabled but transporter not configured');
|
||||||
return { skipped: true, sent: 0, failed: 0 };
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
@@ -184,10 +215,9 @@ async function sendNotificationEmail({
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
logger?.error?.(
|
logger?.error?.(
|
||||||
{
|
{
|
||||||
err: error,
|
...buildEmailErrorLog(error),
|
||||||
requestId,
|
requestId,
|
||||||
employeeId: employee.id,
|
employeeId: employee.id,
|
||||||
email: employee.email,
|
|
||||||
notificationType: type,
|
notificationType: type,
|
||||||
},
|
},
|
||||||
'Email send failed',
|
'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");
|
const { buildApp } = require("./app");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -24,23 +32,37 @@ async function main() {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
forceTimer.unref?.();
|
forceTimer.unref?.();
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
try {
|
try {
|
||||||
await app.close();
|
await app.close();
|
||||||
app.log.info("Worker graceful shutdown completed");
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error({ err: error }, "Worker graceful shutdown failed");
|
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("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch(async (err) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
try {
|
||||||
|
await shutdownTracing();
|
||||||
|
} catch {
|
||||||
|
// The startup error remains authoritative; shutdown is best effort here.
|
||||||
|
}
|
||||||
process.exit(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,11 @@ const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
|
|||||||
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
||||||
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
||||||
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
|
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 = {}) {
|
function buildEmployee(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -47,7 +52,7 @@ function buildAnnouncement(overrides = {}) {
|
|||||||
category: "policy",
|
category: "policy",
|
||||||
requires_acknowledgement: true,
|
requires_acknowledgement: true,
|
||||||
acknowledgement_statement: "我已阅读并理解该制度。",
|
acknowledgement_statement: "我已阅读并理解该制度。",
|
||||||
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
|
acknowledgement_due_at: buildFutureDate(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
created_by: ADMIN_ID,
|
created_by: ADMIN_ID,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
@@ -76,11 +81,12 @@ function matchAnnouncementWhere(item, where = {}) {
|
|||||||
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const now = new Date("2026-05-28T02:00:00.000Z");
|
const startsAtUpperBound = where.OR?.find((clause) => clause.starts_at?.lte)?.starts_at.lte ?? new Date();
|
||||||
if (where.OR && item.starts_at && item.starts_at > now) {
|
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;
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -107,7 +113,7 @@ function createPrismaState(options = {}) {
|
|||||||
buildAnnouncement({
|
buildAnnouncement({
|
||||||
id: HIDDEN_ID,
|
id: HIDDEN_ID,
|
||||||
title: "未来公告",
|
title: "未来公告",
|
||||||
starts_at: new Date("2026-06-01T00:00:00.000Z"),
|
starts_at: buildFutureDate(),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const acknowledgements = options.acknowledgements ?? [];
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("email delivery status", () => {
|
||||||
it("reports ready only when SMTP delivery has the required public settings", () => {
|
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);
|
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("票据");
|
expect(mainLabels).not.toContain("票据");
|
||||||
expect(mainLabels).not.toContain("个人设置");
|
expect(mainLabels).not.toContain("个人设置");
|
||||||
@@ -150,8 +150,8 @@ describe("workspace small-team navigation defaults", () => {
|
|||||||
"工作台",
|
"工作台",
|
||||||
"待我处理",
|
"待我处理",
|
||||||
"报销",
|
"报销",
|
||||||
"采购申请",
|
"采购",
|
||||||
"合同台账",
|
"合同",
|
||||||
"项目",
|
"项目",
|
||||||
"资产",
|
"资产",
|
||||||
"团队",
|
"团队",
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户