From f755ff0dda9fdd938321f0cba797761c202255c7 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 16:20:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20P5=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E8=A1=A5=E9=BD=90=EF=BC=88=E7=BB=84=E7=BB=87?= =?UTF-8?q?/=E6=8A=A5=E9=94=80/=E4=BB=A3=E5=8A=9E/=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=AE=89=E5=85=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41) --- docs/requirements-specification.md | 1 + scripts/check-minimal.sh | 2 + server/.env.example | 7 + server/README.md | 40 +- server/package-lock.json | 10 +- server/package.json | 3 +- .../migration.sql | 5 + server/prisma/schema.prisma | 4 + server/scripts/regression-all.sh | 34 +- server/scripts/regression-auth-security.sh | 215 ++++++ server/scripts/regression-dedup.sh | 16 +- server/scripts/regression-org-settings.sh | 346 +++++++++ server/src/app.js | 8 + server/src/config.js | 7 + server/src/constants/permissions.js | 12 +- server/src/routes/auth.js | 241 +++++- server/src/routes/configRead.js | 147 ++++ server/src/routes/delegations.js | 459 +++++++++++- server/src/routes/departments.js | 484 ++++++++++++ server/src/routes/employees.js | 8 + server/src/routes/expenseTypes.js | 296 ++++++++ server/src/routes/invoicePool.js | 118 ++- server/src/routes/reimbursements.js | 707 +++++++++++++++++- server/src/routes/users.js | 482 +++++++++++- server/src/scripts/seed-dev.js | 2 + server/src/scripts/seed-trialrun.js | 2 +- server/src/utils/passwordPolicy.js | 13 +- 27 files changed, 3572 insertions(+), 97 deletions(-) create mode 100644 server/prisma/migrations/20260209073251_add_auth_security_fields/migration.sql create mode 100755 server/scripts/regression-auth-security.sh create mode 100755 server/scripts/regression-org-settings.sh create mode 100644 server/src/routes/configRead.js create mode 100644 server/src/routes/departments.js create mode 100644 server/src/routes/employees.js create mode 100644 server/src/routes/expenseTypes.js diff --git a/docs/requirements-specification.md b/docs/requirements-specification.md index 868f29cf..3f76f8ee 100644 --- a/docs/requirements-specification.md +++ b/docs/requirements-specification.md @@ -5,6 +5,7 @@ **范围**:报销系统及其依赖模块(员工/权限/工作流/附件) **相关文档**: +- [系统架构概览](./architecture.md) - [技术栈与复用报告](./tech_stack_report.md) - [实施计划](./implementation_plan.md) diff --git a/scripts/check-minimal.sh b/scripts/check-minimal.sh index fe465f20..0aeb3002 100755 --- a/scripts/check-minimal.sh +++ b/scripts/check-minimal.sh @@ -25,6 +25,8 @@ echo "=== [2/3] Server Static Checks ===" bash -n scripts/regression-workflow-actions.sh bash -n scripts/regression-audit.sh bash -n scripts/regression-ocr.sh + bash -n scripts/regression-org-settings.sh + bash -n scripts/regression-auth-security.sh bash -n scripts/regression-all.sh ) diff --git a/server/.env.example b/server/.env.example index 4ac1e2d0..fa4c03c4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,6 +17,13 @@ MAX_UPLOAD_SIZE_BYTES="10485760" # RATE_LIMIT_LOGIN_MAX="10" # RATE_LIMIT_LOGIN_WINDOW="1 minute" +# ===== 登录安全(失败锁定/弱密码拦截)===== +# AUTH_LOGIN_FAIL_WINDOW_SECONDS="900" # 15min +# AUTH_LOGIN_FAIL_MAX_ATTEMPTS="5" +# AUTH_LOGIN_LOCK_SECONDS="1800" # 30min +# WEAK_PASSWORD_CHECK_ENABLED="true" +# WEAK_PASSWORD_MIN_SCORE="2" # zxcvbn score < 2 -> reject + DELEGATION_REVOKE_CRON="0 * * * *" OCR_PROVIDER="off" diff --git a/server/README.md b/server/README.md index c1514056..302ca996 100644 --- a/server/README.md +++ b/server/README.md @@ -56,6 +56,17 @@ npm run seed:trialrun - `TRIALRUN_ADMIN_PASSWORD` 为空时会生成并打印一次性口令(请立刻保存)。 - 若管理员已存在,脚本只确保其为 active 且具备 `admin+employee` 角色,不会改动密码。 +### 2.3.1 认证安全说明(P5) +- `POST /api/auth/login` 成功响应包含 `mustChangePassword`: + - `true`:表示该账号处于“首次登录/管理员重置密码后”状态;前端应强制引导改密后再进入业务页。 + - 改密接口:`POST /api/auth/change-password`(需要 `authorization: Bearer ` + `x-device-fingerprint`)。 +- 登录失败锁定(默认): + - 15 分钟内失败 5 次会锁定 30 分钟,返回 `423 AUTH_002`(含 `lockedUntil`)。 + - 解锁:`POST /api/users/:id/unlock`(权限:`system.users`)。 +- 弱密码拦截: + - 采用 `zxcvbn` 评分,默认 `score < 2` 拒绝(返回 `VALID_001.details.password`)。 + - 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。 + ### 2.4 启动服务 ```bash cd /home/shunli/Projects/hyb-oa/server @@ -160,11 +171,25 @@ cd /home/shunli/Projects/hyb-oa/server RUN_AUDIT_REGRESSION=0 RUN_OCR_REGRESSION=0 ./scripts/regression-all.sh ``` +可选打开组织/配置回归(org + settings): +```bash +cd /home/shunli/Projects/hyb-oa/server +RUN_ORG_SETTINGS_REGRESSION=1 ./scripts/regression-all.sh +``` + +可选打开认证安全回归(auth security): +```bash +cd /home/shunli/Projects/hyb-oa/server +RUN_AUTH_SECURITY_REGRESSION=1 ./scripts/regression-all.sh +``` + 独立执行: ```bash cd /home/shunli/Projects/hyb-oa/server ./scripts/regression-audit.sh OCR_PROVIDER=mock ./scripts/regression-ocr.sh +./scripts/regression-org-settings.sh +./scripts/regression-auth-security.sh ``` ### 4.5 Postman 回归 @@ -258,18 +283,27 @@ cd /home/shunli/Projects/hyb-oa - `GET /api/workflow/tasks/:taskId`:任务详情(实例、报销摘要、可执行动作、处理历史)。 - `GET /api/workflow/approvers`:转审候选人列表(active + 具备 `expense.approve`)。 -### 5.6 列表查询接口(P3) +### 5.6 部门管理(P5 蓝图 F26) +- `GET /api/departments`:部门列表 + 树(登录即可;默认不含归档部门) + - 可选:`GET /api/departments?includeArchived=1`(包含归档部门) +- `POST /api/departments`:创建部门(`system.users`) +- `PATCH /api/departments/:id`:更新部门(`system.users`;已归档禁止修改;禁止形成父子环) +- `POST /api/departments/:id/archive`:归档部门(`system.users`) + - 归档约束:有子部门/有在职员工则禁止归档 + - 幂等:已归档重复调用返回 `outcome=already_archived` + +### 5.7 列表查询接口(P3) - `GET /api/reimbursements`:支持 `page/pageSize/status/scope`。 - `GET /api/invoice-pool`:支持 `page/pageSize/status/ownerScope`。 -### 5.7 定时任务(可手动执行) +### 5.8 定时任务(可手动执行) ```bash cd /home/shunli/Projects/hyb-oa/server npm run job:revoke-expired-delegations npm run job:ocr-once ``` -### 5.8 账号与角色管理(E17,试运行运营) +### 5.9 账号与角色管理(E17,试运行运营) - `GET /api/users`:员工列表(分页/搜索) - `POST /api/users`:创建员工(含初始密码) - `POST /api/users/:id/disable`:禁用员工(tokenVersion+1 + 撤销 refresh) diff --git a/server/package-lock.json b/server/package-lock.json index 478a7b69..4e1f0314 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "server", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { "@fastify/cookie": "^11.0.2", @@ -25,7 +26,8 @@ "pg": "^8.18.0", "prisma": "^7.3.0", "tencentcloud-sdk-nodejs": "^4.1.14", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zxcvbn": "^4.4.2" }, "devDependencies": { "dotenv": "^16.4.5" @@ -2762,6 +2764,12 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/server/package.json b/server/package.json index 635a881b..4f05254a 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,8 @@ "pg": "^8.18.0", "prisma": "^7.3.0", "tencentcloud-sdk-nodejs": "^4.1.14", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zxcvbn": "^4.4.2" }, "devDependencies": { "dotenv": "^16.4.5" diff --git a/server/prisma/migrations/20260209073251_add_auth_security_fields/migration.sql b/server/prisma/migrations/20260209073251_add_auth_security_fields/migration.sql new file mode 100644 index 00000000..cf4c5825 --- /dev/null +++ b/server/prisma/migrations/20260209073251_add_auth_security_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "employees" ADD COLUMN "login_failed_count" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "login_failed_first_at" TIMESTAMP(3), +ADD COLUMN "login_locked_until" TIMESTAMP(3), +ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 80b02cb2..9e8b6c7b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -150,6 +150,10 @@ model Employee { password_hash String token_version Int @default(0) + must_change_password Boolean @default(false) + login_failed_count Int @default(0) + login_failed_first_at DateTime? + login_locked_until DateTime? archived_at DateTime? created_at DateTime @default(now()) diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh index 2d9ceaf8..92e7e069 100755 --- a/server/scripts/regression-all.sh +++ b/server/scripts/regression-all.sh @@ -4,34 +4,54 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}" RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}" +RUN_ORG_SETTINGS_REGRESSION="${RUN_ORG_SETTINGS_REGRESSION:-0}" +RUN_AUTH_SECURITY_REGRESSION="${RUN_AUTH_SECURITY_REGRESSION:-0}" -echo "=== [1/5] Smoke Test ===" +echo "=== [1/7] Smoke Test ===" "$SCRIPT_DIR/smoke-test.sh" echo "" -echo "=== [2/5] Dedup Regression ===" +echo "=== [2/7] Dedup Regression ===" "$SCRIPT_DIR/regression-dedup.sh" echo "" -echo "=== [3/5] Workflow Actions Regression ===" +echo "=== [3/7] Workflow Actions Regression ===" "$SCRIPT_DIR/regression-workflow-actions.sh" echo "" if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then - echo "=== [4/5] Audit Regression ===" + echo "=== [4/7] Audit Regression ===" "$SCRIPT_DIR/regression-audit.sh" echo "" else - echo "=== [4/5] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ===" + echo "=== [4/7] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ===" echo "" fi if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then - echo "=== [5/5] OCR Regression ===" + echo "=== [5/7] OCR Regression ===" "$SCRIPT_DIR/regression-ocr.sh" echo "" else - echo "=== [5/5] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ===" + echo "=== [5/7] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ===" + echo "" +fi + +if [[ "$RUN_ORG_SETTINGS_REGRESSION" == "1" ]]; then + echo "=== [6/7] Org + Settings Regression ===" + "$SCRIPT_DIR/regression-org-settings.sh" + echo "" +else + echo "=== [6/7] Org + Settings Regression (skipped, RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION) ===" + echo "" +fi + +if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then + echo "=== [7/7] Auth Security Regression ===" + "$SCRIPT_DIR/regression-auth-security.sh" + echo "" +else + echo "=== [7/7] Auth Security Regression (skipped, RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION) ===" echo "" fi diff --git a/server/scripts/regression-auth-security.sh b/server/scripts/regression-auth-security.sh new file mode 100755 index 00000000..20cbe005 --- /dev/null +++ b/server/scripts/regression-auth-security.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-auth-security}" +ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}" + +LOGIN_FAIL_MAX_ATTEMPTS="${LOGIN_FAIL_MAX_ATTEMPTS:-${AUTH_LOGIN_FAIL_MAX_ATTEMPTS:-5}}" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +extract_json_field() { + local file_path="$1" + local jq_expr="$2" + jq -er "$jq_expr" "$file_path" +} + +assert_status() { + local actual="$1" + local expected="$2" + local name="$3" + local body_file="${4:-}" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 + if [[ -n "$body_file" && -f "$body_file" ]]; then + cat "$body_file" >&2 + fi + exit 1 + fi +} + +assert_error_code() { + local file_path="$1" + local expected_code="$2" + local name="$3" + local actual_code + actual_code="$(jq -r '.error.code // empty' "$file_path")" + if [[ "$actual_code" != "$expected_code" ]]; then + echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +login() { + local identifier="$1" + local password="$2" + local fingerprint="$3" + local out_body="$4" + + local status + status="$(curl -sS -o "$out_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $fingerprint" \ + -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" + echo "$status" +} + +echo "[1/8] Login admin" +admin_login_body="$TMP_DIR/admin-login.json" +admin_login_status="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")" +assert_status "$admin_login_status" "200" "admin login" "$admin_login_body" +ADMIN_TOKEN="$(extract_json_field "$admin_login_body" '.data.accessToken')" + +echo "[2/8] Weak password should be rejected" +weak_employee_no="W$(date +%s)$RANDOM" +weak_employee_email="weak_${weak_employee_no}@hyinda.com" +weak_employee_phone="139${RANDOM}${RANDOM}" +weak_create_body="$TMP_DIR/weak-create.json" +weak_create_status="$(curl -sS -o "$weak_create_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"employeeNo\":\"$weak_employee_no\",\"name\":\"Weak User\",\"phone\":\"$weak_employee_phone\",\"email\":\"$weak_employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"Password123456\"}")" +assert_status "$weak_create_status" "400" "weak password rejected" "$weak_create_body" +assert_error_code "$weak_create_body" "VALID_001" "weak password rejected" +if ! jq -e '.error.details.password | type == "array" and (map(tostring) | join(" ") | contains("常见"))' "$weak_create_body" >/dev/null; then + echo "[ERROR] weak password error message missing" >&2 + cat "$weak_create_body" >&2 + exit 1 +fi + +echo "[3/8] Create employee (initial password => mustChangePassword)" +employee_no="A$(date +%s)$RANDOM" +employee_email="auth_${employee_no}@hyinda.com" +employee_phone="138${RANDOM}${RANDOM}" +INITIAL_PASSWORD="InitA1$(date +%s%N)Xy" +create_body="$TMP_DIR/create.json" +create_status="$(curl -sS -o "$create_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"employeeNo\":\"$employee_no\",\"name\":\"Auth User\",\"phone\":\"$employee_phone\",\"email\":\"$employee_email\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"$INITIAL_PASSWORD\"}")" +assert_status "$create_status" "200" "create employee" "$create_body" +EMPLOYEE_ID="$(extract_json_field "$create_body" '.data.id')" + +login_body_1="$TMP_DIR/login1.json" +login_status_1="$(login "$employee_email" "$INITIAL_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$login_body_1")" +assert_status "$login_status_1" "200" "initial login" "$login_body_1" +if [[ "$(jq -r '.data.mustChangePassword' "$login_body_1")" != "true" ]]; then + echo "[ERROR] expected mustChangePassword=true on initial login" >&2 + cat "$login_body_1" >&2 + exit 1 +fi +ACCESS_TOKEN_1="$(extract_json_field "$login_body_1" '.data.accessToken')" + +echo "[4/8] Change password should revoke old token" +NEW_PASSWORD="NewA1$(date +%s%N)Zz" +change_body="$TMP_DIR/change.json" +change_status="$(curl -sS -o "$change_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/change-password" \ + -H "authorization: Bearer $ACCESS_TOKEN_1" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-employee" \ + -d "{\"oldPassword\":\"$INITIAL_PASSWORD\",\"newPassword\":\"$NEW_PASSWORD\"}")" +assert_status "$change_status" "200" "change password" "$change_body" +ACCESS_TOKEN_2="$(extract_json_field "$change_body" '.data.accessToken')" +if [[ "$(jq -r '.data.mustChangePassword' "$change_body")" != "false" ]]; then + echo "[ERROR] expected mustChangePassword=false after change" >&2 + cat "$change_body" >&2 + exit 1 +fi + +old_me_body="$TMP_DIR/old-me.json" +old_me_status="$(curl -sS -o "$old_me_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/me" \ + -H "authorization: Bearer $ACCESS_TOKEN_1")" +assert_status "$old_me_status" "401" "old token revoked" "$old_me_body" +assert_error_code "$old_me_body" "AUTH_004" "old token revoked" + +new_me_body="$TMP_DIR/new-me.json" +new_me_status="$(curl -sS -o "$new_me_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/me" \ + -H "authorization: Bearer $ACCESS_TOKEN_2")" +assert_status "$new_me_status" "200" "new token works" "$new_me_body" + +echo "[5/8] Admin reset password => mustChangePassword=true" +reset_body="$TMP_DIR/reset.json" +reset_status="$(curl -sS -o "$reset_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users/$EMPLOYEE_ID/reset-password" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d '{}')" +assert_status "$reset_status" "200" "admin reset password" "$reset_body" +ONE_TIME_PASSWORD="$(extract_json_field "$reset_body" '.data.oneTimePassword')" + +login_body_2="$TMP_DIR/login2.json" +login_status_2="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-employee-reset" "$login_body_2")" +assert_status "$login_status_2" "200" "login with reset password" "$login_body_2" +if [[ "$(jq -r '.data.mustChangePassword' "$login_body_2")" != "true" ]]; then + echo "[ERROR] expected mustChangePassword=true after admin reset" >&2 + cat "$login_body_2" >&2 + exit 1 +fi + +echo "[6/8] Login failure lockout (AUTH_002)" +wrong_password="WrongPassword1234" +locked=0 +for i in $(seq 1 "$LOGIN_FAIL_MAX_ATTEMPTS"); do + attempt_body="$TMP_DIR/lock-attempt-$i.json" + attempt_status="$(login "$employee_email" "$wrong_password" "${DEVICE_FINGERPRINT}-lock-$i" "$attempt_body")" + if [[ "$i" -lt "$LOGIN_FAIL_MAX_ATTEMPTS" ]]; then + assert_status "$attempt_status" "401" "lock attempt $i" "$attempt_body" + assert_error_code "$attempt_body" "AUTH_001" "lock attempt $i" + else + assert_status "$attempt_status" "423" "lock attempt $i triggers lock" "$attempt_body" + assert_error_code "$attempt_body" "AUTH_002" "lock attempt $i triggers lock" + if [[ "$(jq -r '.error.details.lockedUntil // empty' "$attempt_body")" == "" ]]; then + echo "[ERROR] expected lockedUntil in AUTH_002 details" >&2 + cat "$attempt_body" >&2 + exit 1 + fi + locked=1 + fi +done + +if [[ "$locked" != "1" ]]; then + echo "[ERROR] expected account to be locked" >&2 + exit 1 +fi + +echo "[7/8] Locked account cannot login, unlock restores" +locked_login_body="$TMP_DIR/locked-login.json" +locked_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-locked-correct" "$locked_login_body")" +assert_status "$locked_login_status" "423" "locked account login blocked" "$locked_login_body" +assert_error_code "$locked_login_body" "AUTH_002" "locked account login blocked" + +unlock_body="$TMP_DIR/unlock.json" +unlock_status="$(curl -sS -o "$unlock_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users/$EMPLOYEE_ID/unlock" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$unlock_status" "200" "unlock user" "$unlock_body" + +post_unlock_login_body="$TMP_DIR/post-unlock-login.json" +post_unlock_login_status="$(login "$employee_email" "$ONE_TIME_PASSWORD" "${DEVICE_FINGERPRINT}-post-unlock" "$post_unlock_login_body")" +assert_status "$post_unlock_login_status" "200" "login after unlock" "$post_unlock_login_body" + +echo "[8/8] Done" +echo "[OK] Auth security regression passed" diff --git a/server/scripts/regression-dedup.sh b/server/scripts/regression-dedup.sh index dbd1e9cc..c29cd4b2 100755 --- a/server/scripts/regression-dedup.sh +++ b/server/scripts/regression-dedup.sh @@ -136,15 +136,22 @@ NODE } create_tmp_non_dedup_index() { - DATABASE_URL="$DATABASE_URL" node <<'NODE' + local reimbursement_id="$1" + DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { + const reimbursementId = process.env.REIMBURSEMENT_ID; + if (!reimbursementId) { + throw new Error('REIMBURSEMENT_ID is required'); + } + const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { + await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"'); await prisma.$executeRawUnsafe( - 'CREATE UNIQUE INDEX IF NOT EXISTS tmp_unique_linked_reim ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" IS NOT NULL', + `CREATE UNIQUE INDEX "tmp_unique_linked_reim" ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" = '${reimbursementId}'`, ); } finally { await prisma.$disconnect(); @@ -247,9 +254,6 @@ assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json" assert_error_code "$TMP_DIR/bD.json" "VALID_003" echo "[4/7] Non-dedup P2002 should not map to VALID_003" -create_tmp_non_dedup_index -TMP_INDEX_CREATED=1 - cat > "$TMP_DIR/non-dedup-1.pdf" </dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +extract_json_field() { + local file_path="$1" + local jq_expr="$2" + jq -er "$jq_expr" "$file_path" +} + +assert_status() { + local actual="$1" + local expected="$2" + local name="$3" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 + exit 1 + fi +} + +assert_error_code() { + local file_path="$1" + local expected_code="$2" + local name="$3" + local actual_code + actual_code="$(jq -r '.error.code // empty' "$file_path")" + if [[ "$actual_code" != "$expected_code" ]]; then + echo "[ERROR] $name expected error code $expected_code but got $actual_code" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +assert_jq() { + local file_path="$1" + local jq_expr="$2" + local name="$3" + if ! jq -e "$jq_expr" "$file_path" >/dev/null; then + echo "[ERROR] $name jq assertion failed: $jq_expr" >&2 + cat "$file_path" >&2 + exit 1 + fi +} + +login() { + local identifier="$1" + local password="$2" + local fingerprint="$3" + local out_body="$4" + + local status + status="$(curl -sS -o "$out_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $fingerprint" \ + -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" + assert_status "$status" "200" "login $identifier" + extract_json_field "$out_body" '.data.accessToken' +} + +echo "[1/12] Login admin/employee/administration" +admin_login_body="$TMP_DIR/admin-login.json" +ADMIN_TOKEN="$(login "$ADMIN_IDENTIFIER" "$ADMIN_PASSWORD" "${DEVICE_FINGERPRINT}-admin" "$admin_login_body")" +admin_me_body="$TMP_DIR/admin-me.json" +admin_me_status="$(curl -sS -o "$admin_me_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/me" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$admin_me_status" "200" "admin me" +ADMIN_ID="$(extract_json_field "$admin_me_body" '.data.id')" + +employee_login_body="$TMP_DIR/employee-login.json" +EMPLOYEE_TOKEN="$(login "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "${DEVICE_FINGERPRINT}-employee" "$employee_login_body")" +employee_me_body="$TMP_DIR/employee-me.json" +employee_me_status="$(curl -sS -o "$employee_me_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/me" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$employee_me_status" "200" "employee me" +EMPLOYEE_ID="$(extract_json_field "$employee_me_body" '.data.id')" + +administration_login_body="$TMP_DIR/administration-login.json" +ADMINISTRATION_TOKEN="$(login "$ADMINISTRATION_IDENTIFIER" "$ADMINISTRATION_PASSWORD" "${DEVICE_FINGERPRINT}-administration" "$administration_login_body")" +administration_me_body="$TMP_DIR/administration-me.json" +administration_me_status="$(curl -sS -o "$administration_me_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/me" \ + -H "authorization: Bearer $ADMINISTRATION_TOKEN")" +assert_status "$administration_me_status" "200" "administration me" +ADMINISTRATION_ID="$(extract_json_field "$administration_me_body" '.data.id')" + +echo "[2/12] Expense types (read-only)" +expense_list_body="$TMP_DIR/expense-types.json" +expense_list_status="$(curl -sS -o "$expense_list_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/expense-types?enabled=true" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$expense_list_status" "200" "expense types list" +assert_jq "$expense_list_body" '.data.items | length > 0' "expense types non-empty" +assert_jq "$expense_list_body" '([.data.items[].sortOrder] as $a | ($a|length) as $n | if $n <= 1 then true else [range(1; $n) | $a[.-1] <= $a[.]] | all end)' "expense types sorted" + +echo "[3/12] Expense types (management + audit)" +expense_code="TEST_$(date +%s)_$RANDOM" +expense_create_body="$TMP_DIR/expense-create.json" +expense_create_status="$(curl -sS -o "$expense_create_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/expense-types" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"code\":\"$expense_code\",\"name\":\"$expense_code\",\"category\":\"other\",\"sortOrder\":9999,\"enabled\":true}")" +assert_status "$expense_create_status" "200" "expense type create" +EXPENSE_TYPE_ID="$(extract_json_field "$expense_create_body" '.data.id')" + +expense_patch_body="$TMP_DIR/expense-patch.json" +expense_patch_status="$(curl -sS -o "$expense_patch_body" -w "%{http_code}" \ + -X PATCH "$BASE_URL/api/expense-types/$EXPENSE_TYPE_ID" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d '{"enabled":false}')" +assert_status "$expense_patch_status" "200" "expense type disable" + +expense_enabled_body="$TMP_DIR/expense-enabled.json" +expense_enabled_status="$(curl -sS -o "$expense_enabled_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/expense-types?enabled=true" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$expense_enabled_status" "200" "expense types enabled list" +assert_jq "$expense_enabled_body" "([.data.items[].id] | index(\"$EXPENSE_TYPE_ID\") == null)" "disabled expense type filtered" + +audit_expense_body="$TMP_DIR/audit-expense.json" +audit_expense_status="$(curl -sS -o "$audit_expense_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/audit-logs?action=EXPENSE_TYPE_CREATED&targetType=expense_type&pageSize=50" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$audit_expense_status" "200" "audit expense type created" +assert_jq "$audit_expense_body" "([.data.items[].targetId] | index(\"$EXPENSE_TYPE_ID\") != null)" "audit contains created expense type" + +echo "[4/12] Config read: approval rules (login required)" +approval_rules_body="$TMP_DIR/approval-rules.json" +approval_rules_status="$(curl -sS -o "$approval_rules_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/approval-rules?enabled=true" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$approval_rules_status" "200" "approval rules list" +assert_jq "$approval_rules_body" '.data.items | length > 0' "approval rules non-empty" + +echo "[5/12] Config read: document sequence (system.settings)" +sequence_body="$TMP_DIR/document-sequence.json" +sequence_status="$(curl -sS -o "$sequence_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/document-sequences/reimbursement" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$sequence_status" "200" "document sequence read" +assert_jq "$sequence_body" '.data.documentType == "reimbursement"' "document sequence type" + +echo "[6/12] Departments (list)" +departments_list_body="$TMP_DIR/departments-list.json" +departments_list_status="$(curl -sS -o "$departments_list_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/departments" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$departments_list_status" "200" "departments list" +assert_jq "$departments_list_body" '(.data.items | type) == "array"' "departments items array" +assert_jq "$departments_list_body" '(.data.tree | type) == "array"' "departments tree array" + +echo "[7/12] Departments (archive constraints)" +dept_parent_name="TEST_DEPT_PARENT_$(date +%s)_$RANDOM" +dept_child_name="TEST_DEPT_CHILD_$(date +%s)_$RANDOM" +dept_leaf_name="TEST_DEPT_LEAF_$(date +%s)_$RANDOM" +dept_parent_body="$TMP_DIR/dept-parent.json" +dept_parent_status="$(curl -sS -o "$dept_parent_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"name\":\"$dept_parent_name\"}")" +assert_status "$dept_parent_status" "200" "department create parent" +DEPT_PARENT_ID="$(extract_json_field "$dept_parent_body" '.data.department.id')" + +dept_child_body="$TMP_DIR/dept-child.json" +dept_child_status="$(curl -sS -o "$dept_child_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"name\":\"$dept_child_name\",\"parentId\":\"$DEPT_PARENT_ID\"}")" +assert_status "$dept_child_status" "200" "department create child" +DEPT_CHILD_ID="$(extract_json_field "$dept_child_body" '.data.department.id')" + +dept_archive_parent_body="$TMP_DIR/dept-archive-parent.json" +dept_archive_parent_status="$(curl -sS -o "$dept_archive_parent_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments/$DEPT_PARENT_ID/archive" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$dept_archive_parent_status" "409" "department archive parent blocked" +assert_error_code "$dept_archive_parent_body" "VALID_001" "department archive parent blocked" +assert_jq "$dept_archive_parent_body" '.error.details.department == "has_children"' "department archive parent has_children" + +dept_archive_child_body="$TMP_DIR/dept-archive-child.json" +dept_archive_child_status="$(curl -sS -o "$dept_archive_child_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments/$DEPT_CHILD_ID/archive" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$dept_archive_child_status" "200" "department archive child" + +dept_leaf_body="$TMP_DIR/dept-leaf.json" +dept_leaf_status="$(curl -sS -o "$dept_leaf_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"name\":\"$dept_leaf_name\"}")" +assert_status "$dept_leaf_status" "200" "department create leaf" +DEPT_LEAF_ID="$(extract_json_field "$dept_leaf_body" '.data.department.id')" + +dept_archive_leaf_body="$TMP_DIR/dept-archive-leaf.json" +dept_archive_leaf_status="$(curl -sS -o "$dept_archive_leaf_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments/$DEPT_LEAF_ID/archive" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$dept_archive_leaf_status" "200" "department archive leaf" + +echo "[8/12] Employees alias (/users vs /employees)" +users_list_body="$TMP_DIR/users-list.json" +users_list_status="$(curl -sS -o "$users_list_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/users?page=1&pageSize=5" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$users_list_status" "200" "users list" + +employees_list_body="$TMP_DIR/employees-list.json" +employees_list_status="$(curl -sS -o "$employees_list_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/employees?page=1&pageSize=5" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$employees_list_status" "200" "employees list" +assert_jq "$employees_list_body" '(.data.total | type) == "number"' "employees list total" +assert_jq "$employees_list_body" "(.data.total == $(jq -r '.data.total' "$users_list_body"))" "employees list total matches users" + +echo "[9/12] Employee can read self but not others" +employee_self_body="$TMP_DIR/employee-self.json" +employee_self_status="$(curl -sS -o "$employee_self_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/employees/$EMPLOYEE_ID" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$employee_self_status" "200" "employee get self" + +employee_other_body="$TMP_DIR/employee-other.json" +employee_other_status="$(curl -sS -o "$employee_other_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/employees/$ADMIN_ID" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$employee_other_status" "403" "employee get other forbidden" +assert_error_code "$employee_other_body" "PERM_001" "employee get other forbidden" + +echo "[10/12] Department archive blocks active employees" +dept_emp_name="TEST_DEPT_EMP_$(date +%s)_$RANDOM" +dept_emp_body="$TMP_DIR/dept-emp.json" +dept_emp_status="$(curl -sS -o "$dept_emp_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"name\":\"$dept_emp_name\"}")" +assert_status "$dept_emp_status" "200" "department create for employee" +DEPT_EMP_ID="$(extract_json_field "$dept_emp_body" '.data.department.id')" + +new_employee_no="T$(date +%s)$RANDOM" +new_employee_email="test_${new_employee_no}@hyinda.com" +new_employee_phone="139${RANDOM}${RANDOM}" +employee_create_body="$TMP_DIR/employee-create.json" +employee_create_status="$(curl -sS -o "$employee_create_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"employeeNo\":\"$new_employee_no\",\"name\":\"Test User\",\"phone\":\"$new_employee_phone\",\"email\":\"$new_employee_email\",\"departmentId\":\"$DEPT_EMP_ID\",\"title\":\"测试\",\"directManagerId\":null,\"hireDate\":\"2020-01-01\",\"roles\":[\"employee\"],\"password\":\"TestPassword1234\"}")" +assert_status "$employee_create_status" "200" "employee create" +NEW_EMPLOYEE_ID="$(extract_json_field "$employee_create_body" '.data.id')" + +dept_archive_blocked_body="$TMP_DIR/dept-archive-blocked.json" +dept_archive_blocked_status="$(curl -sS -o "$dept_archive_blocked_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$dept_archive_blocked_status" "409" "department archive blocked by active employees" +assert_error_code "$dept_archive_blocked_body" "VALID_001" "department archive blocked by active employees" +assert_jq "$dept_archive_blocked_body" '.error.details.department == "has_active_employees"' "department archive has_active_employees" + +user_disable_body="$TMP_DIR/user-disable.json" +user_disable_status="$(curl -sS -o "$user_disable_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/users/$NEW_EMPLOYEE_ID/disable" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$user_disable_status" "200" "user disable" + +dept_archive_after_disable_body="$TMP_DIR/dept-archive-after-disable.json" +dept_archive_after_disable_status="$(curl -sS -o "$dept_archive_after_disable_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/departments/$DEPT_EMP_ID/archive" \ + -H "authorization: Bearer $ADMIN_TOKEN")" +assert_status "$dept_archive_after_disable_status" "200" "department archive after disable" + +echo "[11/12] Delegation grants: admin maintain principal + alias consistency" +delegation_create_body="$TMP_DIR/delegation-create.json" +delegation_create_status="$(curl -sS -o "$delegation_create_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/delegation-grants" \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"principalEmployeeId\":\"$EMPLOYEE_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")" +assert_status "$delegation_create_status" "200" "delegation grant create" +DELEGATION_ID="$(extract_json_field "$delegation_create_body" '.data.delegation.id')" +assert_jq "$delegation_create_body" ".data.delegation.principal.id == \"$EMPLOYEE_ID\"" "delegation principal embedded" +assert_jq "$delegation_create_body" ".data.delegation.delegate.id == \"$ADMINISTRATION_ID\"" "delegation delegate embedded" + +employee_delegations_body="$TMP_DIR/delegations-mine.json" +employee_delegations_status="$(curl -sS -o "$employee_delegations_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/delegations/mine?status=active" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$employee_delegations_status" "200" "delegations mine" +assert_jq "$employee_delegations_body" "([.data.initiated[].id] | index(\"$DELEGATION_ID\") != null)" "delegations mine contains created" + +delegation_list_body="$TMP_DIR/delegation-grants-list.json" +delegation_list_status="$(curl -sS -o "$delegation_list_body" -w "%{http_code}" \ + -X GET "$BASE_URL/api/delegation-grants?includeInactive=1" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$delegation_list_status" "200" "delegation grants list (employee view)" +assert_jq "$delegation_list_body" "([.data.items[].id] | index(\"$DELEGATION_ID\") != null)" "delegation grants list contains created" + +delegation_forbidden_body="$TMP_DIR/delegation-forbidden.json" +delegation_forbidden_status="$(curl -sS -o "$delegation_forbidden_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/delegation-grants" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN" \ + -H "content-type: application/json" \ + -d "{\"principalEmployeeId\":\"$ADMIN_ID\",\"delegateEmployeeId\":\"$ADMINISTRATION_ID\",\"scope\":\"reimbursement_submit\"}")" +assert_status "$delegation_forbidden_status" "403" "delegation grant forbidden" +assert_error_code "$delegation_forbidden_body" "PERM_001" "delegation grant forbidden" + +echo "[12/12] Revoke delegation (cleanup)" +delegation_revoke_body="$TMP_DIR/delegation-revoke.json" +delegation_revoke_status="$(curl -sS -o "$delegation_revoke_body" -w "%{http_code}" \ + -X POST "$BASE_URL/api/delegation-grants/$DELEGATION_ID/revoke" \ + -H "authorization: Bearer $EMPLOYEE_TOKEN")" +assert_status "$delegation_revoke_status" "200" "delegation revoke" + +echo "[OK] Org/Settings regression passed" diff --git a/server/src/app.js b/server/src/app.js index b635b063..a3312b16 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -22,6 +22,10 @@ const workflowRoutes = require("./routes/workflow"); const delegationRoutes = require("./routes/delegations"); const auditRoutes = require("./routes/audit"); const usersRoutes = require("./routes/users"); +const departmentsRoutes = require("./routes/departments"); +const employeesRoutes = require("./routes/employees"); +const expenseTypesRoutes = require("./routes/expenseTypes"); +const configReadRoutes = require("./routes/configRead"); const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); const { startOcrJob } = require("./jobs/ocrJob"); const { AppError } = require("./utils/AppError"); @@ -191,6 +195,10 @@ async function buildApp() { await app.register(delegationRoutes, { prefix: "/api" }); await app.register(auditRoutes, { prefix: "/api" }); await app.register(usersRoutes, { prefix: "/api" }); + await app.register(employeesRoutes, { prefix: "/api" }); + await app.register(departmentsRoutes, { prefix: "/api" }); + await app.register(expenseTypesRoutes, { prefix: "/api" }); + await app.register(configReadRoutes, { prefix: "/api" }); startDelegationRevokeJob(app); startOcrJob(app); diff --git a/server/src/config.js b/server/src/config.js index a7cad485..4ffa189b 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -1,6 +1,7 @@ const path = require("node:path"); const ONE_DAY_SECONDS = 24 * 60 * 60; +const ONE_MINUTE_SECONDS = 60; function requiredEnv(name) { const value = process.env[name]; @@ -39,6 +40,12 @@ function getConfig() { devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true", + loginFailWindowSeconds: Number( + process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS, + ), + loginFailMaxAttempts: Number(process.env.AUTH_LOGIN_FAIL_MAX_ATTEMPTS ?? 5), + loginLockSeconds: Number(process.env.AUTH_LOGIN_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS), + appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai", storageDir: path.resolve(serverRoot, storageDir), serveClientDist: process.env.SERVE_CLIENT_DIST === "true", diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 98cf6bbb..ec4fcd5f 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -31,7 +31,17 @@ const ROLE_PERMISSIONS = { "system.settings", "system.audit.view", ], - administration: ["expense.proxy_submit", "expense.view_own"], + administration: [ + "expense.proxy_submit", + "expense.create", + "expense.view_own", + "expense.edit_own", + "expense.delete_own", + "expense.submit", + "expense.withdraw", + "invoice.upload", + "invoice.view_own", + ], hr: ["system.users", "expense.view_all"], admin: ["*"], }; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c74e307a..b0f97775 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -76,6 +76,10 @@ async function authRoutes(fastify) { status: true, password_hash: true, token_version: true, + must_change_password: true, + login_failed_count: true, + login_failed_first_at: true, + login_locked_until: true, }, }); if (!employee) { @@ -93,8 +97,96 @@ async function authRoutes(fastify) { }); } + const now = new Date(); + if (employee.login_locked_until && employee.login_locked_until > now) { + throw new AppError({ + code: "AUTH_002", + message: "账号已锁定,请稍后重试", + statusCode: 423, + details: { + lockedUntil: employee.login_locked_until.toISOString(), + }, + }); + } + const ok = await verifyPassword(password, employee.password_hash); if (!ok) { + const windowMs = fastify.config.loginFailWindowSeconds * 1000; + const lockMs = fastify.config.loginLockSeconds * 1000; + const maxAttempts = fastify.config.loginFailMaxAttempts; + + const outcome = await fastify.prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw` + SELECT id, login_failed_count, login_failed_first_at + FROM "employees" + WHERE id = ${employee.id}::uuid + FOR UPDATE + `; + + if (!Array.isArray(rows) || rows.length === 0) { + throw new AppError({ + code: "AUTH_001", + message: "用户名或密码错误", + statusCode: 401, + }); + } + + const current = rows[0]; + const firstAt = current.login_failed_first_at; + let nextFirstAt = firstAt; + let nextCount = Number(current.login_failed_count ?? 0); + + if (!nextFirstAt || now.getTime() - nextFirstAt.getTime() > windowMs) { + nextFirstAt = now; + nextCount = 0; + } + + nextCount += 1; + + let lockedUntil = null; + let locked = false; + if (nextCount >= maxAttempts) { + locked = true; + lockedUntil = new Date(now.getTime() + lockMs); + } + + await tx.employee.update({ + where: { id: employee.id }, + data: { + login_failed_count: nextCount, + login_failed_first_at: nextFirstAt, + login_locked_until: lockedUntil, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: locked ? "AUTH_LOGIN_LOCKED" : "AUTH_LOGIN_FAILED", + targetType: "employee", + targetId: employee.id, + extra: { + failedCount: nextCount, + windowStart: nextFirstAt.toISOString(), + lockedUntil: lockedUntil ? lockedUntil.toISOString() : null, + }, + }), + }); + + return { locked, lockedUntil }; + }); + + if (outcome.locked) { + throw new AppError({ + code: "AUTH_002", + message: "账号已锁定,请稍后重试", + statusCode: 423, + details: { + lockedUntil: outcome.lockedUntil ? outcome.lockedUntil.toISOString() : null, + }, + }); + } + throw new AppError({ code: "AUTH_001", message: "用户名或密码错误", @@ -104,10 +196,18 @@ async function authRoutes(fastify) { const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); - const now = new Date(); const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); await fastify.prisma.$transaction(async (tx) => { + await tx.employee.update({ + where: { id: employee.id }, + data: { + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + }, + }); + const activeSessions = await tx.authSession.findMany({ where: { employee_id: employee.id, @@ -158,7 +258,10 @@ async function authRoutes(fastify) { ); const accessToken = fastify.issueAccessToken(employee); - return sendData(reply, request, { accessToken }); + return sendData(reply, request, { + accessToken, + mustChangePassword: Boolean(employee.must_change_password), + }); }); fastify.post("/refresh", async (request, reply) => { @@ -378,6 +481,136 @@ async function authRoutes(fastify) { return sendData(reply, request, {}); }); + const changePasswordBodySchema = z.object({ + oldPassword: z.string().min(1), + newPassword: z.string().min(1), + }); + + fastify.post("/change-password", { preHandler: fastify.authenticate }, async (request, reply) => { + const parsed = changePasswordBodySchema.safeParse(request.body); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const deviceFingerprint = getHeaderString(request, "x-device-fingerprint"); + if (!deviceFingerprint) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { "x-device-fingerprint": "missing" }, + }); + } + + const { oldPassword, newPassword } = parsed.data; + + const policyErrors = validatePassword(newPassword); + if (policyErrors.length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { password: policyErrors }, + }); + } + + const employee = await fastify.prisma.employee.findUnique({ + where: { id: request.employee.id }, + select: { + id: true, + status: true, + password_hash: true, + token_version: true, + }, + }); + + if (!employee || employee.status !== "active") { + throw new AppError({ + code: "AUTH_003", + message: "账号已离职/禁用", + statusCode: 403, + }); + } + + const ok = await verifyPassword(oldPassword, employee.password_hash); + if (!ok) { + throw new AppError({ + code: "AUTH_001", + message: "用户名或密码错误", + statusCode: 401, + }); + } + + const newPasswordHash = await hashPassword(newPassword); + const refreshToken = randomRefreshToken(); + const refreshTokenHash = sha256Hex(refreshToken); + const now = new Date(); + const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000); + + const updatedEmployee = await fastify.prisma.$transaction(async (tx) => { + const updated = await tx.employee.update({ + where: { id: employee.id }, + data: { + password_hash: newPasswordHash, + must_change_password: false, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + token_version: { increment: 1 }, + }, + select: { + id: true, + token_version: true, + }, + }); + + await tx.authSession.updateMany({ + where: { employee_id: employee.id, revoked_at: null }, + data: { revoked_at: now, last_seen_at: now }, + }); + + await tx.authSession.create({ + data: { + employee_id: employee.id, + refresh_token_hash: refreshTokenHash, + device_fingerprint: deviceFingerprint, + ip_address: request.ip, + user_agent: getHeaderString(request, "user-agent") ?? "", + expires_at: expiresAt, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: "AUTH_PASSWORD_CHANGED", + targetType: "employee", + targetId: employee.id, + extra: {}, + }), + }); + + return updated; + }); + + reply.setCookie( + fastify.config.refreshCookieName, + refreshToken, + refreshCookieOptions(fastify.config), + ); + + const accessToken = fastify.issueAccessToken(updatedEmployee); + return sendData(reply, request, { + accessToken, + mustChangePassword: false, + }); + }); + const forgotPasswordBodySchema = z.object({ email: z.string().email(), }); @@ -508,6 +741,10 @@ async function authRoutes(fastify) { where: { id: employee.id }, data: { password_hash: newPasswordHash, + must_change_password: false, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, token_version: { increment: 1 }, }, }); diff --git a/server/src/routes/configRead.js b/server/src/routes/configRead.js new file mode 100644 index 00000000..adcda409 --- /dev/null +++ b/server/src/routes/configRead.js @@ -0,0 +1,147 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); + +const ENABLED_QUERY_SCHEMA = z.object({ + enabled: z.enum(["true", "false", "1", "0"]).optional(), +}); + +function parseEnabledQuery(query) { + const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const enabledRaw = parsed.data.enabled; + const enabled = + enabledRaw == null + ? null + : ["true", "1"].includes(enabledRaw) + ? true + : ["false", "0"].includes(enabledRaw) + ? false + : null; + + return { enabled }; +} + +function mapApprovalRule(item) { + return { + id: item.id, + name: item.name, + minAmount: item.min_amount?.toString?.() ?? String(item.min_amount ?? "0"), + maxAmount: item.max_amount == null ? null : item.max_amount?.toString?.() ?? String(item.max_amount), + level1Role: item.level1_role, + level2Role: item.level2_role ?? null, + level3Role: item.level3_role ?? null, + enabled: item.enabled, + priority: item.priority, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function mapDocumentSequence(item, exists) { + const fallback = { + documentType: "reimbursement", + prefix: "BX", + dateFormat: "YYYYMMDD", + separator: "-", + numberLength: 4, + resetPeriod: "none", + currentNumber: 0, + lastResetDate: null, + updatedAt: null, + updatedBy: null, + }; + + if (!item) { + return { exists, ...fallback }; + } + + return { + exists, + documentType: item.document_type, + prefix: item.prefix, + dateFormat: item.date_format, + separator: item.separator, + numberLength: item.number_length, + resetPeriod: item.reset_period, + currentNumber: item.current_number, + lastResetDate: item.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + updatedBy: item.updated_by ?? null, + }; +} + +async function configReadRoutes(fastify) { + fastify.get( + "/approval-rules", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseEnabledQuery(request.query); + + const items = await fastify.prisma.approvalRule.findMany({ + where: { + ...(query.enabled == null ? {} : { enabled: query.enabled }), + }, + orderBy: [{ priority: "asc" }, { min_amount: "asc" }], + select: { + id: true, + name: true, + min_amount: true, + max_amount: true, + level1_role: true, + level2_role: true, + level3_role: true, + enabled: true, + priority: true, + created_at: true, + updated_at: true, + }, + }); + + return sendData(reply, request, { + enabled: query.enabled, + items: items.map(mapApprovalRule), + }); + }, + ); + + fastify.get( + "/document-sequences/reimbursement", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const sequence = await fastify.prisma.documentSequence.findUnique({ + where: { document_type: "reimbursement" }, + select: { + document_type: true, + prefix: true, + date_format: true, + separator: true, + number_length: true, + reset_period: true, + current_number: true, + last_reset_date: true, + updated_at: true, + updated_by: true, + }, + }); + + return sendData(reply, request, mapDocumentSequence(sequence, Boolean(sequence))); + }, + ); +} + +module.exports = configReadRoutes; + diff --git a/server/src/routes/delegations.js b/server/src/routes/delegations.js index 5252cfa3..dfbcdf51 100644 --- a/server/src/routes/delegations.js +++ b/server/src/routes/delegations.js @@ -3,8 +3,10 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { computeDelegationStatus } = require("../utils/delegation"); +const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const UUID_SCHEMA = z.string().uuid(); + const CREATE_DELEGATION_SCHEMA = z.object({ delegateEmployeeId: z.string().uuid(), scope: z.enum(["reimbursement_submit", "all"]).optional(), @@ -12,10 +14,20 @@ const CREATE_DELEGATION_SCHEMA = z.object({ validTo: z.string().datetime().optional(), }); +const CREATE_DELEGATION_GRANT_SCHEMA = CREATE_DELEGATION_SCHEMA.extend({ + principalEmployeeId: z.string().uuid().optional(), +}); + const QUERY_STATUS_SCHEMA = z.object({ status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(), }); +const LIST_DELEGATION_GRANTS_QUERY_SCHEMA = z.object({ + includeInactive: z.enum(["true", "false", "1", "0"]).optional(), + principalEmployeeId: z.string().uuid().optional(), + delegateEmployeeId: z.string().uuid().optional(), +}); + function parseDelegationId(id) { const parsed = UUID_SCHEMA.safeParse(id); if (!parsed.success) { @@ -79,6 +91,56 @@ function parseCreateBody(body) { }; } +function parseCreateGrantBody(body) { + const parsed = CREATE_DELEGATION_GRANT_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date(); + const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null; + + if (Number.isNaN(validFrom.getTime())) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { validFrom: "invalid_datetime" }, + }); + } + + if (validTo && Number.isNaN(validTo.getTime())) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { validTo: "invalid_datetime" }, + }); + } + + if (validTo && validTo <= validFrom) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { validTo: "must_be_after_validFrom" }, + }); + } + + return { + principalEmployeeId: parsed.data.principalEmployeeId, + delegateEmployeeId: parsed.data.delegateEmployeeId, + scope: parsed.data.scope ?? "all", + validFrom, + validTo, + }; +} + function parseStatusQuery(query) { const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { @@ -93,6 +155,26 @@ function parseStatusQuery(query) { return parsed.data.status ?? "active"; } +function parseListDelegationGrantsQuery(query) { + const parsed = LIST_DELEGATION_GRANTS_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const includeInactive = ["true", "1"].includes(parsed.data.includeInactive ?? "false"); + + return { + includeInactive, + principalEmployeeId: parsed.data.principalEmployeeId, + delegateEmployeeId: parsed.data.delegateEmployeeId, + }; +} + function matchesStatusFilter(record, status, now) { if (status === "all") { return true; @@ -101,6 +183,23 @@ function matchesStatusFilter(record, status, now) { return computeDelegationStatus(record, now) === status; } +function mapEmployeeEmbedded(employee) { + if (!employee) { + return null; + } + + return { + id: employee.id, + employeeNo: employee.employee_no, + name: employee.name, + departmentId: employee.department_id, + status: employee.status, + roleCodes: Array.isArray(employee.roles) + ? employee.roles.map((item) => item.role_code) + : [], + }; +} + function toResponseItem(record, now) { return { id: record.id, @@ -112,10 +211,299 @@ function toResponseItem(record, now) { revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null, createdAt: record.created_at.toISOString(), status: computeDelegationStatus(record, now), + principal: record.principal_employee ? mapEmployeeEmbedded(record.principal_employee) : null, + delegate: record.delegate_employee ? mapEmployeeEmbedded(record.delegate_employee) : null, }; } async function delegationRoutes(fastify) { + async function ensureDelegateEligible(tx, delegateEmployeeId, scope) { + const delegateEmployee = await tx.employee.findUnique({ + where: { id: delegateEmployeeId }, + select: { + id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }); + + if (!delegateEmployee || delegateEmployee.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { delegateEmployeeId: "invalid_delegate" }, + }); + } + + const roleCodes = delegateEmployee.roles.map((item) => item.role_code); + const permissionCodes = resolvePermissionsForRoles(roleCodes); + + if (scope === "reimbursement_submit") { + if (!hasPermission(permissionCodes, "expense.proxy_submit")) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { delegateEmployeeId: "delegate_must_be_administration" }, + }); + } + + return delegateEmployee; + } + + if (!hasPermission(permissionCodes, "expense.approve")) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { delegateEmployeeId: "delegate_must_be_approver" }, + }); + } + + return delegateEmployee; + } + + async function loadGrantById(tx, delegationId) { + return tx.delegationGrant.findUnique({ + where: { id: delegationId }, + include: { + principal_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + delegate_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + }, + }); + } + + fastify.get( + "/delegation-grants", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseListDelegationGrantsQuery(request.query); + const now = new Date(); + + const canManageAll = fastify.hasPermission(request.employee, "system.users"); + + const baseWhere = canManageAll + ? { + ...(query.principalEmployeeId ? { principal_employee_id: query.principalEmployeeId } : {}), + ...(query.delegateEmployeeId ? { delegate_employee_id: query.delegateEmployeeId } : {}), + } + : { + OR: [ + { principal_employee_id: request.employee.id }, + { delegate_employee_id: request.employee.id }, + ], + }; + + const where = { + ...baseWhere, + ...(query.includeInactive + ? {} + : { + revoked_at: null, + valid_from: { lte: now }, + OR: [{ valid_to: null }, { valid_to: { gt: now } }], + }), + }; + + const items = await fastify.prisma.delegationGrant.findMany({ + where, + orderBy: { created_at: "desc" }, + include: { + principal_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + delegate_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + }, + }); + + return sendData(reply, request, { + includeInactive: query.includeInactive, + items: items.map((item) => toResponseItem(item, now)), + }); + }, + ); + + fastify.post( + "/delegation-grants", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const input = parseCreateGrantBody(request.body); + const canManageAll = fastify.hasPermission(request.employee, "system.users"); + + const principalEmployeeId = input.principalEmployeeId ?? request.employee.id; + if (principalEmployeeId !== request.employee.id && !canManageAll) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (input.delegateEmployeeId === principalEmployeeId) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { delegateEmployeeId: "self_not_allowed" }, + }); + } + + const delegation = await fastify.prisma.$transaction(async (tx) => { + const principal = await tx.employee.findUnique({ + where: { id: principalEmployeeId }, + select: { id: true, status: true }, + }); + if (!principal || principal.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { principalEmployeeId: "invalid_principal" }, + }); + } + + await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope); + + const created = await tx.delegationGrant.create({ + data: { + principal_employee_id: principalEmployeeId, + delegate_employee_id: input.delegateEmployeeId, + scope: input.scope, + valid_from: input.validFrom, + valid_to: input.validTo, + created_by: request.employee.id, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DELEGATION_CREATE", + targetType: "delegation_grant", + targetId: created.id, + extra: { + principalEmployeeId, + delegateEmployeeId: input.delegateEmployeeId, + scope: input.scope, + }, + }), + }); + + return loadGrantById(tx, created.id); + }); + + return sendData(reply, request, { + delegation: toResponseItem(delegation, new Date()), + }); + }, + ); + + fastify.post( + "/delegation-grants/:id/revoke", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const delegationId = parseDelegationId(request.params?.id); + const now = new Date(); + const canManageAll = fastify.hasPermission(request.employee, "system.users"); + + const delegation = await loadGrantById(fastify.prisma, delegationId); + if (!delegation) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const canRevoke = + delegation.principal_employee_id === request.employee.id || canManageAll; + if (!canRevoke) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (delegation.revoked_at) { + return sendData(reply, request, { + delegation: toResponseItem(delegation, now), + outcome: "already_revoked", + }); + } + + const revoked = await fastify.prisma.$transaction(async (tx) => { + const updated = await tx.delegationGrant.update({ + where: { id: delegationId }, + data: { + revoked_at: now, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DELEGATION_REVOKE", + targetType: "delegation_grant", + targetId: delegationId, + extra: { + principalEmployeeId: delegation.principal_employee_id, + delegateEmployeeId: delegation.delegate_employee_id, + }, + }), + }); + + return loadGrantById(tx, updated.id); + }); + + return sendData(reply, request, { + delegation: toResponseItem(revoked, now), + outcome: "revoked", + }); + }, + ); + fastify.post( "/delegations", { @@ -133,24 +521,9 @@ async function delegationRoutes(fastify) { }); } - const delegateEmployee = await fastify.prisma.employee.findUnique({ - where: { id: input.delegateEmployeeId }, - select: { - id: true, - status: true, - }, - }); - - if (!delegateEmployee || delegateEmployee.status !== "active") { - throw new AppError({ - code: "VALID_001", - message: "参数校验失败", - statusCode: 409, - details: { delegateEmployeeId: "invalid_delegate" }, - }); - } - const delegation = await fastify.prisma.$transaction(async (tx) => { + await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope); + const created = await tx.delegationGrant.create({ data: { principal_employee_id: request.employee.id, @@ -176,7 +549,7 @@ async function delegationRoutes(fastify) { }), }); - return created; + return loadGrantById(tx, created.id); }); return sendData(reply, request, { @@ -200,12 +573,56 @@ async function delegationRoutes(fastify) { principal_employee_id: request.employee.id, }, orderBy: { created_at: "desc" }, + include: { + principal_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + delegate_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + }, }), fastify.prisma.delegationGrant.findMany({ where: { delegate_employee_id: request.employee.id, }, orderBy: { created_at: "desc" }, + include: { + principal_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + delegate_employee: { + select: { + id: true, + employee_no: true, + name: true, + department_id: true, + status: true, + roles: { select: { role_code: true } }, + }, + }, + }, }), ]); @@ -246,6 +663,7 @@ async function delegationRoutes(fastify) { const canRevoke = delegation.principal_employee_id === request.employee.id || + fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings"); if (!canRevoke) { throw new AppError({ @@ -256,8 +674,9 @@ async function delegationRoutes(fastify) { } if (delegation.revoked_at) { + const embedded = await loadGrantById(fastify.prisma, delegationId); return sendData(reply, request, { - delegation: toResponseItem(delegation, now), + delegation: toResponseItem(embedded ?? delegation, now), outcome: "already_revoked", }); } @@ -283,7 +702,7 @@ async function delegationRoutes(fastify) { }), }); - return updated; + return loadGrantById(tx, updated.id); }); return sendData(reply, request, { diff --git a/server/src/routes/departments.js b/server/src/routes/departments.js new file mode 100644 index 00000000..adab5796 --- /dev/null +++ b/server/src/routes/departments.js @@ -0,0 +1,484 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); + +const UUID_SCHEMA = z.string().uuid(); + +const LIST_QUERY_SCHEMA = z.object({ + includeArchived: z.enum(["0", "1", "true", "false"]).optional(), +}); + +const CREATE_BODY_SCHEMA = z.object({ + name: z.string().trim().min(1).max(100), + parentId: z.string().uuid().nullable().optional(), +}); + +const UPDATE_BODY_SCHEMA = z.object({ + name: z.string().trim().min(1).max(100).optional(), + parentId: z.string().uuid().nullable().optional(), +}); + +function parseDepartmentId(value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + return parsed.data; +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const raw = parsed.data.includeArchived ?? "0"; + const includeArchived = raw === "1" || raw === "true"; + + return { includeArchived }; +} + +function parseCreateBody(body) { + const parsed = CREATE_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + return { + name: parsed.data.name.trim(), + parentId: parsed.data.parentId ?? null, + }; +} + +function parseUpdateBody(body) { + const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const hasUpdate = typeof parsed.data.name === "string" || "parentId" in parsed.data; + if (!hasUpdate) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + return { + ...(typeof parsed.data.name === "string" ? { name: parsed.data.name.trim() } : {}), + ...(Object.prototype.hasOwnProperty.call(parsed.data, "parentId") + ? { parentId: parsed.data.parentId ?? null } + : {}), + }; +} + +function mapDepartmentItem(record) { + return { + id: record.id, + name: record.name, + parentId: record.parent_id, + archivedAt: record.archived_at ? record.archived_at.toISOString() : null, + createdAt: record.created_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + }; +} + +function buildDepartmentTree(items) { + const nodesById = new Map(); + for (const record of items) { + nodesById.set(record.id, { ...mapDepartmentItem(record), children: [] }); + } + + const roots = []; + for (const record of items) { + const node = nodesById.get(record.id); + if (!node) continue; + + const parentId = record.parent_id; + if (parentId && nodesById.has(parentId)) { + nodesById.get(parentId).children.push(node); + } else { + roots.push(node); + } + } + + return roots; +} + +function detectParentCycle(departments, departmentId, parentId) { + const parentMap = new Map(departments.map((item) => [item.id, item.parent_id])); + let cursor = parentId; + const visited = new Set(); + + while (cursor) { + if (visited.has(cursor)) { + break; + } + visited.add(cursor); + + if (cursor === departmentId) { + return true; + } + + cursor = parentMap.get(cursor) ?? null; + } + + return false; +} + +async function departmentsRoutes(fastify) { + fastify.get( + "/departments", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const where = query.includeArchived ? {} : { archived_at: null }; + + const items = await fastify.prisma.department.findMany({ + where, + orderBy: [{ name: "asc" }, { created_at: "asc" }], + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + return sendData(reply, request, { + includeArchived: query.includeArchived, + items: items.map(mapDepartmentItem), + tree: buildDepartmentTree(items), + }); + }, + ); + + fastify.post( + "/departments", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const input = parseCreateBody(request.body); + const now = new Date(); + + if (input.parentId) { + const parent = await fastify.prisma.department.findUnique({ + where: { id: input.parentId }, + select: { id: true, archived_at: true }, + }); + if (!parent) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { parentId: "not_found" }, + }); + } + if (parent.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { parentId: "archived" }, + }); + } + } + + const created = await fastify.prisma.$transaction(async (tx) => { + const department = await tx.department.create({ + data: { + name: input.name, + parent_id: input.parentId, + created_by: request.employee.id, + updated_by: request.employee.id, + created_at: now, + }, + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DEPARTMENT_CREATE", + targetType: "department", + targetId: department.id, + extra: { + name: department.name, + parentId: department.parent_id, + }, + }), + }); + + return department; + }); + + return sendData(reply, request, { department: mapDepartmentItem(created) }); + }, + ); + + fastify.patch( + "/departments/:id", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const departmentId = parseDepartmentId(request.params?.id); + const input = parseUpdateBody(request.body); + const now = new Date(); + + const updated = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.department.findUnique({ + where: { id: departmentId }, + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (current.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "archived" }, + }); + } + + const parentId = + Object.prototype.hasOwnProperty.call(input, "parentId") ? input.parentId : current.parent_id; + + if (parentId === departmentId) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { parentId: "self_not_allowed" }, + }); + } + + if (parentId) { + const parent = await tx.department.findUnique({ + where: { id: parentId }, + select: { id: true, archived_at: true }, + }); + if (!parent) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { parentId: "not_found" }, + }); + } + if (parent.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { parentId: "archived" }, + }); + } + + const allDepartments = await tx.department.findMany({ + select: { id: true, parent_id: true }, + }); + + if (detectParentCycle(allDepartments, departmentId, parentId)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { parentId: "cycle_not_allowed" }, + }); + } + } + + const changes = {}; + if (typeof input.name === "string" && input.name !== current.name) { + changes.name = { before: current.name, after: input.name }; + } + if (Object.prototype.hasOwnProperty.call(input, "parentId")) { + changes.parentId = { before: current.parent_id, after: parentId }; + } + + const department = await tx.department.update({ + where: { id: departmentId }, + data: { + ...(typeof input.name === "string" ? { name: input.name } : {}), + ...(Object.prototype.hasOwnProperty.call(input, "parentId") ? { parent_id: parentId } : {}), + updated_by: request.employee.id, + updated_at: now, + }, + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + if (Object.keys(changes).length) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DEPARTMENT_UPDATE", + targetType: "department", + targetId: department.id, + extra: { changes }, + }), + }); + } + + return department; + }); + + return sendData(reply, request, { department: mapDepartmentItem(updated) }); + }, + ); + + fastify.post( + "/departments/:id/archive", + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const departmentId = parseDepartmentId(request.params?.id); + const now = new Date(); + + const result = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.department.findUnique({ + where: { id: departmentId }, + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (current.archived_at) { + return { department: current, outcome: "already_archived" }; + } + + const [childCount, activeEmployeeCount] = await Promise.all([ + tx.department.count({ where: { parent_id: departmentId } }), + tx.employee.count({ where: { department_id: departmentId, status: "active" } }), + ]); + + if (childCount > 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { department: "has_children" }, + }); + } + + if (activeEmployeeCount > 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { department: "has_active_employees" }, + }); + } + + const department = await tx.department.update({ + where: { id: departmentId }, + data: { + archived_at: now, + archived_by: request.employee.id, + updated_by: request.employee.id, + updated_at: now, + }, + select: { + id: true, + name: true, + parent_id: true, + archived_at: true, + created_at: true, + updated_at: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "DEPARTMENT_ARCHIVE", + targetType: "department", + targetId: department.id, + extra: { name: department.name }, + }), + }); + + return { department, outcome: "archived" }; + }); + + return sendData(reply, request, { + department: mapDepartmentItem(result.department), + outcome: result.outcome, + }); + }, + ); +} + +module.exports = departmentsRoutes; diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js new file mode 100644 index 00000000..c6b68d5a --- /dev/null +++ b/server/src/routes/employees.js @@ -0,0 +1,8 @@ +const usersRoutes = require("./users"); + +async function employeesRoutes(fastify) { + await usersRoutes(fastify, { basePath: "/employees" }); +} + +module.exports = employeesRoutes; + diff --git a/server/src/routes/expenseTypes.js b/server/src/routes/expenseTypes.js new file mode 100644 index 00000000..562ecd67 --- /dev/null +++ b/server/src/routes/expenseTypes.js @@ -0,0 +1,296 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); + +const LIST_QUERY_SCHEMA = z.object({ + enabled: z.enum(["true", "false", "1", "0"]).optional(), +}); + +const CREATE_BODY_SCHEMA = z.object({ + code: z.string().trim().min(1).max(50), + name: z.string().trim().min(1).max(100), + description: z.string().trim().max(500).optional(), + category: z.enum(["travel", "rd", "production", "office", "business", "assets", "other"]), + sortOrder: z.number().int().min(0).max(10000), + accountCodeDefault: z.string().trim().max(50).optional(), + enabled: z.boolean().optional(), +}); + +const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial(); + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const enabledRaw = parsed.data.enabled; + const enabled = + enabledRaw == null + ? null + : ["true", "1"].includes(enabledRaw) + ? true + : ["false", "0"].includes(enabledRaw) + ? false + : null; + + return { enabled }; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + return parsed.data; +} + +function parseExpenseTypeId(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 mapExpenseType(item) { + return { + id: item.id, + code: item.code, + name: item.name, + description: item.description, + category: item.category, + sortOrder: item.sort_order, + accountCodeDefault: item.account_code_default, + isSystemDefault: item.is_system_default, + enabled: item.enabled, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +async function expenseTypesRoutes(fastify) { + fastify.get( + "/expense-types", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseListQuery(request.query); + + const items = await fastify.prisma.expenseType.findMany({ + where: { + ...(query.enabled == null ? {} : { enabled: query.enabled }), + }, + orderBy: [{ sort_order: "asc" }, { code: "asc" }], + select: { + id: true, + code: true, + name: true, + description: true, + category: true, + sort_order: true, + account_code_default: true, + is_system_default: true, + enabled: true, + created_at: true, + updated_at: true, + }, + }); + + return sendData(reply, request, { + enabled: query.enabled, + items: items.map(mapExpenseType), + }); + }, + ); + + fastify.post( + "/expense-types", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const now = new Date(); + + let created = null; + try { + created = await fastify.prisma.$transaction(async (tx) => { + const expenseType = await tx.expenseType.create({ + data: { + code: body.code, + name: body.name, + description: body.description ?? null, + category: body.category, + sort_order: body.sortOrder, + account_code_default: body.accountCodeDefault ?? null, + enabled: body.enabled ?? true, + created_by: request.employee.id, + updated_by: request.employee.id, + created_at: now, + updated_at: now, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EXPENSE_TYPE_CREATED", + targetType: "expense_type", + targetId: expenseType.id, + extra: { + code: expenseType.code, + name: expenseType.name, + enabled: expenseType.enabled, + }, + }), + }); + + return expenseType; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { code: "duplicate" }, + }); + } + throw error; + } + + return sendData(reply, request, mapExpenseType(created)); + }, + ); + + fastify.patch( + "/expense-types/:id", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const expenseTypeId = parseExpenseTypeId(request.params); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + + if (!Object.keys(body).length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + const now = new Date(); + + let updated = null; + try { + updated = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.expenseType.findUnique({ + where: { id: expenseTypeId }, + }); + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const changes = {}; + if (typeof body.name === "string" && body.name !== current.name) { + changes.name = { before: current.name, after: body.name }; + } + if (typeof body.description === "string" && body.description !== (current.description ?? "")) { + changes.description = { before: current.description, after: body.description }; + } + if (typeof body.category === "string" && body.category !== current.category) { + changes.category = { before: current.category, after: body.category }; + } + if (typeof body.sortOrder === "number" && body.sortOrder !== current.sort_order) { + changes.sortOrder = { before: current.sort_order, after: body.sortOrder }; + } + if ( + typeof body.accountCodeDefault === "string" && + body.accountCodeDefault !== (current.account_code_default ?? "") + ) { + changes.accountCodeDefault = { + before: current.account_code_default, + after: body.accountCodeDefault, + }; + } + if (typeof body.enabled === "boolean" && body.enabled !== current.enabled) { + changes.enabled = { before: current.enabled, after: body.enabled }; + } + + const expenseType = await tx.expenseType.update({ + where: { id: expenseTypeId }, + data: { + ...(typeof body.name === "string" ? { name: body.name } : {}), + ...(typeof body.description === "string" ? { description: body.description } : {}), + ...(typeof body.category === "string" ? { category: body.category } : {}), + ...(typeof body.sortOrder === "number" ? { sort_order: body.sortOrder } : {}), + ...(typeof body.accountCodeDefault === "string" + ? { account_code_default: body.accountCodeDefault } + : {}), + ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), + updated_at: now, + updated_by: request.employee.id, + }, + }); + + if (Object.keys(changes).length) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EXPENSE_TYPE_UPDATED", + targetType: "expense_type", + targetId: expenseTypeId, + extra: { + code: expenseType.code, + changes, + }, + }), + }); + } + + return expenseType; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { code: "duplicate" }, + }); + } + throw error; + } + + return sendData(reply, request, mapExpenseType(updated)); + }, + ); +} + +module.exports = expenseTypesRoutes; + diff --git a/server/src/routes/invoicePool.js b/server/src/routes/invoicePool.js index 65eeb384..212b15e7 100644 --- a/server/src/routes/invoicePool.js +++ b/server/src/routes/invoicePool.js @@ -6,10 +6,12 @@ const path = require("node:path"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); const { compactDateString } = require("../utils/date"); const { sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { toMoneyString } = require("../utils/workflow"); +const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { isOcrEnabledForType } = require("../services/ocr"); const MIME_ALLOWLIST = new Set([ @@ -392,14 +394,57 @@ async function invoicePoolRoutes(fastify) { }); } - const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id; - if (ownerEmployeeId !== request.employee.id) { - throw new AppError({ - code: "PERM_001", - message: "无权限", - statusCode: 403, - details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" }, + const ownerEmployeeId = + typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim() + ? fields.owner_employee_id.trim() + : request.employee.id; + const isCrossOwner = ownerEmployeeId !== request.employee.id; + let delegationGrantId = null; + + if (isCrossOwner) { + const canManageUsers = fastify.hasPermission(request.employee, "system.users"); + if (!canManageUsers) { + const isAdministration = request.employee.roleCodes?.includes("administration"); + const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit"); + if (!isAdministration || !canProxySubmit) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { owner_employee_id: "cross_owner_upload_not_allowed" }, + }); + } + + const delegation = await findActiveDelegationForDelegate(fastify.prisma, { + principalEmployeeId: ownerEmployeeId, + delegateEmployeeId: request.employee.id, + allowedScopes: ["reimbursement_submit", "all"], + now: new Date(), + }); + if (!delegation) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { delegation: "missing_active_grant" }, + }); + } + + delegationGrantId = delegation.id; + } + + const ownerEmployee = await fastify.prisma.employee.findUnique({ + where: { id: ownerEmployeeId }, + select: { id: true, status: true }, }); + if (!ownerEmployee || ownerEmployee.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { owner_employee_id: "invalid_owner" }, + }); + } } const ocrType = fields.ocr_type ?? "general"; @@ -448,25 +493,46 @@ async function invoicePoolRoutes(fastify) { const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available"; - const invoice = await fastify.prisma.invoicePool.create({ - data: { - owner_employee_id: ownerEmployeeId, - uploaded_by_employee_id: request.employee.id, - storage_path: storagePath, - original_name: filePart.filename ?? "upload", - mime_type: filePart.mimetype, - file_size: fileSize, - file_sha256: fileSha256, - ocr_type: ocrType, - status, - created_by: request.employee.id, - updated_by: request.employee.id, - }, - select: { - id: true, - status: true, - file_sha256: true, - }, + const invoice = await fastify.prisma.$transaction(async (tx) => { + const created = await tx.invoicePool.create({ + data: { + owner_employee_id: ownerEmployeeId, + uploaded_by_employee_id: request.employee.id, + storage_path: storagePath, + original_name: filePart.filename ?? "upload", + mime_type: filePart.mimetype, + file_size: fileSize, + file_sha256: fileSha256, + ocr_type: ocrType, + status, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + file_sha256: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVOICE_POOL_UPLOADED", + targetType: "invoice_pool", + targetId: created.id, + extra: { + ownerEmployeeId, + uploadedByEmployeeId: request.employee.id, + crossOwner: isCrossOwner, + delegationGrantId, + ocrType, + fileSha256, + }, + }), + }); + + return created; }); return sendData(reply, request, { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index db800c45..39307038 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -1,7 +1,9 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); +const { buildAuditLogData } = require("../utils/audit"); const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); +const { findActiveDelegationForDelegate } = require("../utils/delegation"); const { buildWorkflowStepsFromApprovalRule, resolveActionableStep, @@ -24,6 +26,18 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({ scope: z.enum(["own", "all"]).optional(), }); +const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({ + reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + description: z.string().trim().max(500).nullable().optional(), +}); + +const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({ + expenseTypeId: z.string().uuid(), + expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + amount: z.string().regex(/^\d+(\.\d{1,2})?$/), + description: z.string().trim().min(1).max(500), +}); + function ensureOwnOrCanViewAll(fastify, request, reimbursement) { const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (canViewAll) { @@ -50,6 +64,24 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) { } } +function ensureApplicantOrAdmin(request, reimbursement) { + if (request.employee?.roleCodes?.includes("admin")) { + return; + } + + const isApplicant = + reimbursement.owner_id === request.employee.id || + reimbursement.submitter_id === request.employee.id; + + if (!isApplicant) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } +} + function formatFormNumber(sequence, timezone) { const datePart = compactDateString(timezone); const serial = String(sequence.current_number).padStart(sequence.number_length, "0"); @@ -88,6 +120,53 @@ function parseReimbursementId(id) { return parsed.data; } +function parseReimbursementUpdateBody(body) { + const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + if (!Object.keys(parsed.data).length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + return parsed.data; +} + +function parseItemBody(body) { + const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const amountNumber = Number(parsed.data.amount); + if (!Number.isFinite(amountNumber) || amountNumber < 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { amount: "invalid" }, + }); + } + + return parsed.data; +} + function parsePayBody(body) { const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { @@ -214,6 +293,24 @@ function ensureNonNegativeAmount(totalAmount) { } } +async function recomputeTotalAmount(tx, reimbursementId, actorId) { + const aggregate = await tx.reimbursementItem.aggregate({ + where: { reimbursement_id: reimbursementId }, + _sum: { amount: true }, + }); + + const nextTotal = aggregate._sum.amount ?? "0"; + await tx.reimbursementForm.update({ + where: { id: reimbursementId }, + data: { + total_amount: nextTotal, + updated_by: actorId, + }, + }); + + return nextTotal; +} + async function loadApprovalRuleForAmount(tx, totalAmount) { const amountString = toMoneyString(totalAmount); const approvalRule = await tx.approvalRule.findFirst({ @@ -246,6 +343,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) { async function reimbursementRoutes(fastify) { const createBodySchema = z.object({ + ownerEmployeeId: z.string().uuid().optional(), reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), description: z.string().max(500).optional(), }); @@ -269,29 +367,102 @@ async function reimbursementRoutes(fastify) { const reimbursementDate = parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone); - const reimbursement = await fastify.prisma.reimbursementForm.create({ - data: { - submitter_id: request.employee.id, - owner_id: request.employee.id, - department_id: request.employee.department_id, - reimbursement_date: parseDateOnlyString(reimbursementDate), - description: parsed.data.description ?? null, - created_by: request.employee.id, - updated_by: request.employee.id, - }, - select: { - id: true, - status: true, - reimbursement_date: true, - description: true, - total_amount: true, - form_number: true, - }, + const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id; + const isProxyCreate = ownerEmployeeId !== request.employee.id; + + const reimbursement = await fastify.prisma.$transaction(async (tx) => { + let departmentId = request.employee.department_id; + let delegationGrantId = null; + + if (isProxyCreate) { + const isAdministration = request.employee.roleCodes?.includes("administration"); + const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit"); + if (!isAdministration || !canProxySubmit) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const ownerEmployee = await tx.employee.findUnique({ + where: { id: ownerEmployeeId }, + select: { id: true, status: true, department_id: true }, + }); + if (!ownerEmployee || ownerEmployee.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { ownerEmployeeId: "invalid_owner" }, + }); + } + + const delegation = await findActiveDelegationForDelegate(tx, { + principalEmployeeId: ownerEmployeeId, + delegateEmployeeId: request.employee.id, + allowedScopes: ["reimbursement_submit", "all"], + now: new Date(), + }); + if (!delegation) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + details: { delegation: "missing_active_grant" }, + }); + } + + delegationGrantId = delegation.id; + departmentId = ownerEmployee.department_id; + } + + const created = await tx.reimbursementForm.create({ + data: { + submitter_id: request.employee.id, + owner_id: ownerEmployeeId, + department_id: departmentId, + reimbursement_date: parseDateOnlyString(reimbursementDate), + description: parsed.data.description ?? null, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + reimbursement_date: true, + description: true, + total_amount: true, + form_number: true, + owner_id: true, + submitter_id: true, + }, + }); + + if (isProxyCreate) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "REIMBURSEMENT_PROXY_CREATED", + targetType: "reimbursement_form", + targetId: created.id, + extra: { + ownerEmployeeId, + submitterEmployeeId: request.employee.id, + delegationGrantId, + }, + }), + }); + } + + return created; }); return sendData(reply, request, { id: reimbursement.id, formNumber: reimbursement.form_number, + submitterId: reimbursement.submitter_id, + ownerId: reimbursement.owner_id, reimbursementDate, status: reimbursement.status, description: reimbursement.description, @@ -402,6 +573,25 @@ async function reimbursementRoutes(fastify) { submitter: { select: { id: true, employee_no: true, name: true }, }, + items: { + orderBy: { expense_date: "desc" }, + select: { + id: true, + expense_type_id: true, + expense_date: true, + amount: true, + description: true, + expense_type: { + select: { + id: true, + code: true, + name: true, + category: true, + enabled: true, + }, + }, + }, + }, invoice_pool_items: { select: { id: true, @@ -458,6 +648,22 @@ async function reimbursementRoutes(fastify) { description: reimbursement.description, owner: reimbursement.owner, submitter: reimbursement.submitter, + items: reimbursement.items.map((item) => ({ + id: item.id, + expenseTypeId: item.expense_type_id, + expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null, + amount: toMoneyString(item.amount), + description: item.description, + expenseType: item.expense_type + ? { + id: item.expense_type.id, + code: item.expense_type.code, + name: item.expense_type.name, + category: item.expense_type.category, + enabled: item.expense_type.enabled, + } + : null, + })), invoices: reimbursement.invoice_pool_items.map((item) => ({ id: item.id, status: item.status, @@ -476,6 +682,453 @@ async function reimbursementRoutes(fastify) { }, ); + fastify.patch( + "/reimbursements/:id", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const body = parseReimbursementUpdateBody(request.body); + + const updated = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_edit_status" }, + }); + } + + const nextDescription = + body.description === undefined ? undefined : body.description?.trim() || null; + + return tx.reimbursementForm.update({ + where: { id: reimbursementId }, + data: { + ...(body.reimbursementDate + ? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) } + : {}), + ...(body.description !== undefined ? { description: nextDescription } : {}), + updated_by: request.employee.id, + }, + select: { + id: true, + status: true, + reimbursement_date: true, + description: true, + total_amount: true, + }, + }); + }); + + return sendData(reply, request, { + id: updated.id, + status: updated.status, + reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10), + description: updated.description, + totalAmount: toMoneyString(updated.total_amount), + }); + }, + ); + + fastify.delete( + "/reimbursements/:id", + { + preHandler: fastify.requirePermission("expense.delete_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + + const result = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + + if (reimbursement.status !== "draft") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_delete_status" }, + }); + } + + const unbound = await tx.invoicePool.updateMany({ + where: { + linked_reimbursement_id: reimbursementId, + status: "bound", + }, + data: { + status: "available", + linked_reimbursement_id: null, + linked_item_id: null, + updated_by: request.employee.id, + }, + }); + + await tx.reimbursementForm.delete({ + where: { id: reimbursementId }, + }); + + return { + reimbursementId, + unboundInvoiceCount: unbound.count, + }; + }); + + return sendData(reply, request, { + reimbursementId: result.reimbursementId, + outcome: "deleted", + unboundInvoiceCount: result.unboundInvoiceCount, + }); + }, + ); + + fastify.post( + "/reimbursements/:id/items", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const body = parseItemBody(request.body); + + const result = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_item_edit_status" }, + }); + } + + const expenseType = await tx.expenseType.findUnique({ + where: { id: body.expenseTypeId }, + select: { id: true, enabled: true }, + }); + if (!expenseType || !expenseType.enabled) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { expenseTypeId: "invalid_or_disabled" }, + }); + } + + const created = await tx.reimbursementItem.create({ + data: { + reimbursement_id: reimbursementId, + expense_type_id: body.expenseTypeId, + expense_date: parseDateOnlyString(body.expenseDate), + amount: body.amount, + description: body.description, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + expense_type_id: true, + expense_date: true, + amount: true, + description: true, + }, + }); + + const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); + + return { + item: created, + totalAmount: nextTotal, + }; + }); + + return sendData(reply, request, { + item: { + id: result.item.id, + expenseTypeId: result.item.expense_type_id, + expenseDate: result.item.expense_date.toISOString().slice(0, 10), + amount: toMoneyString(result.item.amount), + description: result.item.description, + }, + totalAmount: toMoneyString(result.totalAmount), + }); + }, + ); + + fastify.patch( + "/reimbursements/:id/items/:itemId", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId); + if (!parsedItemId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { itemId: "invalid_uuid" }, + }); + } + + const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().safeParse(request.body ?? {}); + if (!parsedBody.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsedBody.error.flatten(), + }); + } + + if (!Object.keys(parsedBody.data).length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + if (typeof parsedBody.data.amount === "string") { + const amountNumber = Number(parsedBody.data.amount); + if (!Number.isFinite(amountNumber) || amountNumber < 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { amount: "invalid" }, + }); + } + } + + const result = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_item_edit_status" }, + }); + } + + const item = await tx.reimbursementItem.findFirst({ + where: { + id: parsedItemId.data, + reimbursement_id: reimbursementId, + }, + select: { id: true }, + }); + if (!item) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (typeof parsedBody.data.expenseTypeId === "string") { + const expenseType = await tx.expenseType.findUnique({ + where: { id: parsedBody.data.expenseTypeId }, + select: { id: true, enabled: true }, + }); + if (!expenseType || !expenseType.enabled) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { expenseTypeId: "invalid_or_disabled" }, + }); + } + } + + const updated = await tx.reimbursementItem.update({ + where: { id: parsedItemId.data }, + data: { + ...(typeof parsedBody.data.expenseTypeId === "string" + ? { expense_type_id: parsedBody.data.expenseTypeId } + : {}), + ...(typeof parsedBody.data.expenseDate === "string" + ? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) } + : {}), + ...(typeof parsedBody.data.amount === "string" + ? { amount: parsedBody.data.amount } + : {}), + ...(typeof parsedBody.data.description === "string" + ? { description: parsedBody.data.description.trim() } + : {}), + updated_by: request.employee.id, + }, + select: { + id: true, + expense_type_id: true, + expense_date: true, + amount: true, + description: true, + }, + }); + + const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); + + return { + item: updated, + totalAmount: nextTotal, + }; + }); + + return sendData(reply, request, { + item: { + id: result.item.id, + expenseTypeId: result.item.expense_type_id, + expenseDate: result.item.expense_date.toISOString().slice(0, 10), + amount: toMoneyString(result.item.amount), + description: result.item.description, + }, + totalAmount: toMoneyString(result.totalAmount), + }); + }, + ); + + fastify.delete( + "/reimbursements/:id/items/:itemId", + { + preHandler: fastify.requirePermission("expense.edit_own"), + }, + async (request, reply) => { + const reimbursementId = parseReimbursementId(request.params?.id); + const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId); + if (!parsedItemId.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { itemId: "invalid_uuid" }, + }); + } + + const result = await fastify.prisma.$transaction(async (tx) => { + const reimbursementRows = await tx.$queryRaw` + SELECT id, owner_id, submitter_id, status + FROM "reimbursement_forms" + WHERE id = ${reimbursementId}::uuid + FOR UPDATE + `; + if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const reimbursement = reimbursementRows[0]; + ensureApplicantOrAdmin(request, reimbursement); + + if (!["draft", "rejected"].includes(reimbursement.status)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { status: "invalid_item_edit_status" }, + }); + } + + const deleted = await tx.reimbursementItem.deleteMany({ + where: { + id: parsedItemId.data, + reimbursement_id: reimbursementId, + }, + }); + if (deleted.count === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id); + + return { totalAmount: nextTotal }; + }); + + return sendData(reply, request, { + outcome: "deleted", + totalAmount: toMoneyString(result.totalAmount), + }); + }, + ); + fastify.post( "/reimbursements/:id/submit", { @@ -502,7 +1155,7 @@ async function reimbursementRoutes(fastify) { } const reimbursement = reimbursementRows[0]; - ensureOwnOrCanViewAll(fastify, request, reimbursement); + ensureApplicantOrAdmin(request, reimbursement); if (!["draft", "rejected"].includes(reimbursement.status)) { throw new AppError({ @@ -694,6 +1347,22 @@ async function reimbursementRoutes(fastify) { }, }); + if (reimbursement.submitter_id !== reimbursement.owner_id) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "REIMBURSEMENT_PROXY_SUBMITTED", + targetType: "reimbursement_form", + targetId: reimbursementId, + extra: { + ownerEmployeeId: reimbursement.owner_id, + submitterEmployeeId: reimbursement.submitter_id, + formNumber, + }, + }), + }); + } + submitResult = { reimbursementId, formNumber, diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 77647a3c..f895cb60 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -1,3 +1,5 @@ +const crypto = require("node:crypto"); + const { z } = require("zod"); const { AppError } = require("../utils/AppError"); @@ -32,11 +34,22 @@ const createBodySchema = z.object({ 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(), + hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), roles: z.array(roleCodeSchema).min(1).optional(), password: z.string().min(1), }); +const updateBodySchema = z.object({ + employeeNo: z.string().trim().min(1).max(50).optional(), + name: z.string().trim().min(1).max(100).optional(), + phone: z.string().trim().min(6).max(30).optional(), + email: z.string().trim().email().max(200).optional(), + departmentId: z.string().uuid().optional(), + title: z.string().trim().min(1).max(100).optional(), + directManagerId: z.string().uuid().nullable().optional(), + hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}); + const setRolesBodySchema = z.object({ roles: z.array(roleCodeSchema).min(1), }); @@ -104,6 +117,29 @@ function parseCreateBody(body) { }; } +function parseUpdateBody(body) { + const parsed = updateBodySchema.safeParse(body ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + if (!Object.keys(parsed.data).length) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { body: "empty" }, + }); + } + + return parsed.data; +} + function mapEmployeeSummary(row) { return { id: row.id, @@ -120,6 +156,15 @@ function mapEmployeeSummary(row) { }; } +function mapEmployeeDetail(row) { + return { + ...mapEmployeeSummary(row), + directManagerId: row.direct_manager_id ?? null, + hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null, + archivedAt: row.archived_at?.toISOString?.() ?? null, + }; +} + function normalizeUniqueErrorDetails(error) { const meta = error?.meta ?? {}; const target = meta.target ?? meta?.cause?.constraint?.fields ?? null; @@ -135,9 +180,14 @@ function normalizeUniqueErrorDetails(error) { return Object.keys(details).length ? details : { unique: "duplicate" }; } -async function usersRoutes(fastify) { +async function usersRoutes(fastify, opts = {}) { + const basePathRaw = + typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users"; + const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`; + const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + fastify.get( - "/users", + routeBasePath, { preHandler: fastify.requirePermission("system.users"), }, @@ -192,8 +242,61 @@ async function usersRoutes(fastify) { }, ); + fastify.get( + `${routeBasePath}/:id`, + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const canManage = fastify.hasPermission(request.employee, "system.users"); + + if (!canManage) { + const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own"); + const isSelf = request.employee.id === userId; + if (!canViewOwn || !isSelf) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + } + + const employee = await fastify.prisma.employee.findUnique({ + where: { id: userId }, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + direct_manager_id: true, + hire_date: true, + archived_at: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }); + + if (!employee) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + return sendData(reply, request, mapEmployeeDetail(employee)); + }, + ); + fastify.post( - "/users", + routeBasePath, { preHandler: fastify.requirePermission("system.users"), }, @@ -204,7 +307,7 @@ async function usersRoutes(fastify) { const department = await fastify.prisma.department.findUnique({ where: { id: departmentId }, - select: { id: true }, + select: { id: true, archived_at: true }, }); if (!department) { throw new AppError({ @@ -215,6 +318,38 @@ async function usersRoutes(fastify) { }); } + if (department.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { departmentId: "archived" }, + }); + } + + if (typeof body.directManagerId === "string") { + const manager = await fastify.prisma.employee.findUnique({ + where: { id: body.directManagerId }, + select: { id: true, status: true }, + }); + if (!manager) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { directManagerId: "not_found" }, + }); + } + if (manager.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { directManagerId: "inactive" }, + }); + } + } + const passwordHash = await hashPassword(body.password); const now = new Date(); @@ -236,6 +371,7 @@ async function usersRoutes(fastify) { bank_account_encrypted: "ENC_TBD", bank_name: "TBD", password_hash: passwordHash, + must_change_password: true, created_at: now, created_by: request.employee.id, updated_at: now, @@ -305,8 +441,209 @@ async function usersRoutes(fastify) { }, ); + fastify.patch( + `${routeBasePath}/:id`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const body = parseUpdateBody(request.body); + const now = new Date(); + + const hireDate = + typeof body.hireDate === "string" ? new Date(`${body.hireDate}T00:00:00.000Z`) : null; + if (hireDate && Number.isNaN(hireDate.getTime())) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { hireDate: "invalid_date" }, + }); + } + + if (typeof body.departmentId === "string") { + const department = await fastify.prisma.department.findUnique({ + where: { id: body.departmentId }, + select: { id: true, archived_at: true }, + }); + if (!department) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { departmentId: "not_found" }, + }); + } + if (department.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { departmentId: "archived" }, + }); + } + } + + if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { + if (body.directManagerId === userId) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { directManagerId: "self_not_allowed" }, + }); + } + + if (typeof body.directManagerId === "string") { + const manager = await fastify.prisma.employee.findUnique({ + where: { id: body.directManagerId }, + select: { id: true, status: true }, + }); + if (!manager) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { directManagerId: "not_found" }, + }); + } + if (manager.status !== "active") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { directManagerId: "inactive" }, + }); + } + } + } + + let updated = null; + try { + updated = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.employee.findUnique({ + where: { id: userId }, + select: { + id: true, + employee_no: true, + name: true, + phone: true, + email: true, + status: true, + department_id: true, + title: true, + direct_manager_id: true, + hire_date: true, + archived_at: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }); + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const changes = {}; + if (typeof body.employeeNo === "string" && body.employeeNo !== current.employee_no) { + changes.employeeNo = { before: current.employee_no, after: body.employeeNo }; + } + if (typeof body.name === "string" && body.name !== current.name) { + changes.name = { before: current.name, after: body.name }; + } + if (typeof body.phone === "string" && body.phone !== current.phone) { + changes.phone = { before: current.phone, after: body.phone }; + } + if (typeof body.email === "string" && body.email !== current.email) { + changes.email = { before: current.email, after: body.email }; + } + if (typeof body.departmentId === "string" && body.departmentId !== current.department_id) { + changes.departmentId = { before: current.department_id, after: body.departmentId }; + } + if (typeof body.title === "string" && body.title !== current.title) { + changes.title = { before: current.title, after: body.title }; + } + if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) { + changes.directManagerId = { before: current.direct_manager_id, after: body.directManagerId ?? null }; + } + if (typeof body.hireDate === "string") { + const currentHireDate = current.hire_date?.toISOString?.()?.slice(0, 10) ?? null; + if (body.hireDate !== currentHireDate) { + changes.hireDate = { before: currentHireDate, after: body.hireDate }; + } + } + + const employee = await tx.employee.update({ + where: { id: userId }, + data: { + ...(typeof body.employeeNo === "string" ? { employee_no: body.employeeNo } : {}), + ...(typeof body.name === "string" ? { name: body.name } : {}), + ...(typeof body.phone === "string" ? { phone: body.phone } : {}), + ...(typeof body.email === "string" ? { email: body.email } : {}), + ...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}), + ...(typeof body.title === "string" ? { title: body.title } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "directManagerId") + ? { direct_manager_id: body.directManagerId ?? null } + : {}), + ...(typeof body.hireDate === "string" ? { hire_date: hireDate } : {}), + 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, + direct_manager_id: true, + hire_date: true, + archived_at: true, + created_at: true, + updated_at: true, + roles: { select: { role_code: true } }, + }, + }); + + if (Object.keys(changes).length) { + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_UPDATED", + targetType: "employee", + targetId: userId, + extra: { changes }, + }), + }); + } + + return employee; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: normalizeUniqueErrorDetails(error), + }); + } + throw error; + } + + return sendData(reply, request, mapEmployeeDetail(updated)); + }, + ); + fastify.post( - "/users/:id/disable", + `${routeBasePath}/:id/archive`, { preHandler: fastify.requirePermission("system.users"), }, @@ -364,7 +701,65 @@ async function usersRoutes(fastify) { ); fastify.post( - "/users/:id/enable", + `${routeBasePath}/: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( + `${routeBasePath}/:id/enable`, { preHandler: fastify.requirePermission("system.users"), }, @@ -414,7 +809,7 @@ async function usersRoutes(fastify) { ); fastify.post( - "/users/:id/reset-password", + `${routeBasePath}/:id/reset-password`, { preHandler: fastify.requirePermission("system.users"), }, @@ -454,6 +849,10 @@ async function usersRoutes(fastify) { where: { id: userId }, data: { password_hash: passwordHash, + must_change_password: true, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, token_version: { increment: 1 }, updated_by: request.employee.id, }, @@ -483,7 +882,71 @@ async function usersRoutes(fastify) { ); fastify.post( - "/users/:id/roles", + `${routeBasePath}/:id/unlock`, + { + preHandler: fastify.requirePermission("system.users"), + }, + async (request, reply) => { + const userId = parseUserId(request.params); + const now = new Date(); + + const result = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.employee.findUnique({ + where: { id: userId }, + select: { + id: true, + login_locked_until: true, + login_failed_count: true, + }, + }); + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const wasLocked = Boolean(current.login_locked_until && current.login_locked_until > now); + + await tx.employee.update({ + where: { id: userId }, + data: { + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + updated_by: request.employee.id, + updated_at: now, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "SYSTEM_USER_UNLOCKED", + targetType: "employee", + targetId: userId, + extra: { + previousLockedUntil: current.login_locked_until + ? current.login_locked_until.toISOString() + : null, + previousFailedCount: current.login_failed_count ?? 0, + }, + }), + }); + + return { wasLocked }; + }); + + return sendData(reply, request, { + id: userId, + outcome: result.wasLocked ? "unlocked" : "already_unlocked", + }); + }, + ); + + fastify.post( + `${routeBasePath}/:id/roles`, { preHandler: fastify.requirePermission("system.users"), }, @@ -559,4 +1022,3 @@ async function usersRoutes(fastify) { } module.exports = usersRoutes; - diff --git a/server/src/scripts/seed-dev.js b/server/src/scripts/seed-dev.js index 3588d3ba..2a005214 100644 --- a/server/src/scripts/seed-dev.js +++ b/server/src/scripts/seed-dev.js @@ -57,6 +57,7 @@ async function ensureEmployee(prisma, payload) { bank_account_encrypted: "ENC_DEV_ONLY", bank_name: "DEV_BANK", password_hash: passwordHash, + must_change_password: false, updated_at: now, }, select: { id: true, email: true }, @@ -92,6 +93,7 @@ async function ensureEmployee(prisma, payload) { bank_account_encrypted: "ENC_DEV_ONLY", bank_name: "DEV_BANK", password_hash: passwordHash, + must_change_password: false, created_at: now, updated_at: now, }, diff --git a/server/src/scripts/seed-trialrun.js b/server/src/scripts/seed-trialrun.js index e95d9211..1422f572 100644 --- a/server/src/scripts/seed-trialrun.js +++ b/server/src/scripts/seed-trialrun.js @@ -108,6 +108,7 @@ async function ensureAdminEmployee(prisma, params) { bank_account_encrypted: "ENC_TRIALRUN", bank_name: "TRIALRUN_BANK", password_hash: passwordHash, + must_change_password: true, created_at: now, created_by: null, updated_at: now, @@ -178,4 +179,3 @@ main().catch((error) => { console.error(error); process.exit(1); }); - diff --git a/server/src/utils/passwordPolicy.js b/server/src/utils/passwordPolicy.js index 9ce60324..9df98255 100644 --- a/server/src/utils/passwordPolicy.js +++ b/server/src/utils/passwordPolicy.js @@ -1,3 +1,5 @@ +const zxcvbn = require("zxcvbn"); + function validatePassword(password) { const errors = []; @@ -14,8 +16,17 @@ function validatePassword(password) { errors.push("密码必须包含数字"); } + const weakCheckEnabled = process.env.WEAK_PASSWORD_CHECK_ENABLED !== "false"; + const weakMinScore = Number(process.env.WEAK_PASSWORD_MIN_SCORE ?? 2); + + if (weakCheckEnabled && typeof password === "string" && password.length >= 1) { + const score = zxcvbn(password).score ?? 0; + if (score < weakMinScore) { + errors.push("密码过于常见/弱"); + } + } + return errors; } module.exports = { validatePassword }; -