feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)

- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40)

- 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5)

- 行政代提交/代上传后端校验与审计(F7/F8)

- 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
2026-02-09 16:20:31 +08:00
父节点 a8080b05f8
当前提交 f755ff0dda
共修改 27 个文件,包含 3572 行新增和 97 行删除
+7
查看文件
@@ -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"
+37 -3
查看文件
@@ -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 <token>` + `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)
+9 -1
查看文件
@@ -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"
}
}
}
+2 -1
查看文件
@@ -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"
@@ -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;
+4
查看文件
@@ -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())
+27 -7
查看文件
@@ -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
+215
查看文件
@@ -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"
+11 -5
查看文件
@@ -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" <<PDF
%PDF-1.4
non-dedup-1-$RUN_SUFFIX
@@ -260,6 +264,8 @@ non-dedup-2-$RUN_SUFFIX
PDF
RID_E="$(create_reimbursement "$TMP_DIR/rE.json")"
create_tmp_non_dedup_index "$RID_E"
TMP_INDEX_CREATED=1
status="$(upload_file "$TMP_DIR/non-dedup-1.pdf" "$TMP_DIR/uE1.json")"
assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
IID_E1="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE1.json")"
+346
查看文件
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-org-settings}"
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-admin@hyinda.com}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-AdminPassword1234}"
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
ADMINISTRATION_IDENTIFIER="${ADMINISTRATION_IDENTIFIER:-administration@hyinda.com}"
ADMINISTRATION_PASSWORD="${ADMINISTRATION_PASSWORD:-AdministrationPassword1234}"
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"
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"
+8
查看文件
@@ -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);
+7
查看文件
@@ -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",
+11 -1
查看文件
@@ -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: ["*"],
};
+239 -2
查看文件
@@ -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 },
},
});
+147
查看文件
@@ -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;
+439 -20
查看文件
@@ -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, {
+484
查看文件
@@ -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;
+8
查看文件
@@ -0,0 +1,8 @@
const usersRoutes = require("./users");
async function employeesRoutes(fastify) {
await usersRoutes(fastify, { basePath: "/employees" });
}
module.exports = employeesRoutes;
+296
查看文件
@@ -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;
+92 -26
查看文件
@@ -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, {
+688 -19
查看文件
@@ -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,
+472 -10
查看文件
@@ -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;
+2
查看文件
@@ -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,
},
+1 -1
查看文件
@@ -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);
});
+12 -1
查看文件
@@ -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 };