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