diff --git a/client/README.md b/client/README.md index bc8d50a0..8ba2ccd7 100644 --- a/client/README.md +++ b/client/README.md @@ -54,3 +54,13 @@ npm run typecheck npm run build npm run preview ``` + +## 8. 仓库级最小门禁 +```bash +cd /home/shunli/Projects/hyb-oa +./scripts/check-minimal.sh +``` + +说明: +- 会执行 client 的 `typecheck/build` 与 server 的静态检查。 +- 不启动服务,不替代发版前的 `server/scripts/regression-all.sh`。 diff --git a/server/README.md b/server/README.md index f714202b..471e5436 100644 --- a/server/README.md +++ b/server/README.md @@ -43,6 +43,16 @@ cd /home/shunli/Projects/hyb-oa/server ./scripts/smoke-test.sh ``` +### 2.6 本地最小门禁(P1-b) +```bash +cd /home/shunli/Projects/hyb-oa +./scripts/check-minimal.sh +``` + +说明: +- 该脚本只做静态/构建级门禁,不启动服务与数据库。 +- 全量回归仍通过 `server/scripts/regression-all.sh` 执行。 + ## 3. 1 分钟快速验证 ```bash cd /home/shunli/Projects/hyb-oa/server @@ -72,6 +82,12 @@ cd /home/shunli/Projects/hyb-oa/server ./scripts/regression-workflow-actions.sh ``` +可选并发校验: +```bash +cd /home/shunli/Projects/hyb-oa/server +RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh +``` + 覆盖场景: - `reject`:实例=`rejected`,报销=`rejected` - `return`:实例=`terminated`,报销=`draft` @@ -93,6 +109,7 @@ cd /home/shunli/Projects/hyb-oa/server - `pay` 幂等冲突:已付款后提交冲突凭证字段返回 `409 VALID_001`(`already_paid_conflict`) - `delegation`:有效期内可代办并在 history 写入 delegation 证据 - `delegation` 过期:代理人返回 `PERM_002`,自动回收任务可撤销过期委派 +- `delegation` 并发回收(可选):`RUN_DELEGATION_CONCURRENCY_CHECK=1` 时双并发只回收一次并仅写一条自动回收审计 ### 4.4 一键全量回归 ```bash @@ -145,6 +162,7 @@ npm run dev - `GET /api/delegations/mine`:查看我发起/我接收的委派 - `POST /api/delegations/:id/revoke`:撤销委派 - 委派仅作用于 `assignee_id` 任务,不改写 `assignee_role` 语义。 +- 自动回收实现采用 `FOR UPDATE SKIP LOCKED + UPDATE ... RETURNING`,多实例并发下确保同一委派仅回收一次。 ### 5.3 OCR 异步状态查询 - 上传发票在 OCR 开启且 `ocr_type=vat` 时会进入 `pending_ocr`。 diff --git a/server/scripts/regression-workflow-actions.sh b/server/scripts/regression-workflow-actions.sh index dff833aa..b1f3f198 100755 --- a/server/scripts/regression-workflow-actions.sh +++ b/server/scripts/regression-workflow-actions.sh @@ -12,6 +12,7 @@ FINANCE_PASSWORD="${FINANCE_PASSWORD:-FinancePassword1234}" CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}" CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}" RUN_PAY_CONCURRENCY_CHECK="${RUN_PAY_CONCURRENCY_CHECK:-0}" +RUN_DELEGATION_CONCURRENCY_CHECK="${RUN_DELEGATION_CONCURRENCY_CHECK:-0}" if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 @@ -864,6 +865,42 @@ const { createPrismaClient } = require('./src/prismaClient'); }); NODE } + +assert_delegation_auto_revoke_audit_count() { + local delegation_id="$1" + local expected_count="$2" + local scenario="$3" + + DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$expected_count" "$scenario" <<'NODE' +const [delegationId, expectedCountRaw, scenario] = process.argv.slice(2); +const expectedCount = Number(expectedCountRaw); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const count = await prisma.auditLog.count({ + where: { + action: 'DELEGATION_AUTO_REVOKED', + target_id: delegationId, + }, + }); + + if (count !== expectedCount) { + console.error(`[ERROR] ${scenario} expected audit count=${expectedCount} but got ${count}`); + process.exit(1); + } + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} echo "[1/23] Health check + login baseline users" health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")" assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json" @@ -1137,13 +1174,45 @@ ceo_approve_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$ assert_status "$ceo_approve_expired_status" "403" "expired delegation should be forbidden" "$TMP_DIR/delegation-expired-ceo-approve.json" assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden" -revoke_job_output="$(npm run --silent job:revoke-expired-delegations)" -revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)" -if [[ "$revoke_job_status" != revoked=* ]]; then - echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2 - exit 1 +if [[ "$RUN_DELEGATION_CONCURRENCY_CHECK" == "1" ]]; then + echo "[18.1/23] optional delegation revoke concurrency check" + ( + npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-a.out" + ) & + revoke_pid_a=$! + ( + npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-b.out" + ) & + revoke_pid_b=$! + wait "$revoke_pid_a" + wait "$revoke_pid_b" + + revoke_job_output_a="$(cat "$TMP_DIR/delegation-revoke-a.out")" + revoke_job_output_b="$(cat "$TMP_DIR/delegation-revoke-b.out")" + revoke_job_status_a="$(printf '%s\n' "$revoke_job_output_a" | tail -n 1)" + revoke_job_status_b="$(printf '%s\n' "$revoke_job_output_b" | tail -n 1)" + + if [[ "$revoke_job_status_a" != revoked=* ]]; then + echo "[ERROR] revoke concurrency output A invalid: $revoke_job_output_a" >&2 + exit 1 + fi + + if [[ "$revoke_job_status_b" != revoked=* ]]; then + echo "[ERROR] revoke concurrency output B invalid: $revoke_job_output_b" >&2 + exit 1 + fi + + assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke concurrency" + assert_delegation_auto_revoke_audit_count "$DELEGATION_EXPIRED_ID" "1" "delegation auto revoke concurrency" +else + revoke_job_output="$(npm run --silent job:revoke-expired-delegations)" + revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)" + if [[ "$revoke_job_status" != revoked=* ]]; then + echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2 + exit 1 + fi + assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke" fi -assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke" manager_approve_after_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$MANAGER_TOKEN" "$TMP_DIR/delegation-expired-manager-approve.json" '{}')" assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json" diff --git a/server/src/jobs/delegationRevokeJob.js b/server/src/jobs/delegationRevokeJob.js index 2188b716..38f71161 100644 --- a/server/src/jobs/delegationRevokeJob.js +++ b/server/src/jobs/delegationRevokeJob.js @@ -1,62 +1,78 @@ const cron = require("node-cron"); +async function claimAndRevokeExpiredDelegations(tx, batchSize) { + return tx.$queryRaw` + WITH picked AS ( + SELECT id + FROM "delegation_grants" + WHERE revoked_at IS NULL + AND valid_to IS NOT NULL + AND valid_to <= NOW() + ORDER BY valid_to ASC + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ) + UPDATE "delegation_grants" AS dg + SET revoked_at = NOW() + FROM picked + WHERE dg.id = picked.id + AND dg.revoked_at IS NULL + RETURNING dg.id, dg.principal_employee_id, dg.delegate_employee_id, dg.revoked_at + `; +} + async function revokeExpiredDelegations(prisma, logger, options = {}) { - const batchSize = options.batchSize ?? 200; + const batchSize = Math.max(1, Number(options.batchSize ?? 200)); let total = 0; + let round = 0; while (true) { - const now = new Date(); - const expired = await prisma.delegationGrant.findMany({ - where: { - revoked_at: null, - valid_to: { - not: null, - lte: now, - }, - }, - orderBy: { valid_to: "asc" }, - take: batchSize, - select: { - id: true, - principal_employee_id: true, - delegate_employee_id: true, - }, + round += 1; + + const revokedRows = await prisma.$transaction(async (tx) => { + const claimed = await claimAndRevokeExpiredDelegations(tx, batchSize); + if (!claimed.length) { + return []; + } + + const requestTime = Date.now(); + await tx.auditLog.createMany({ + data: claimed.map((item, index) => { + const revokedAt = item.revoked_at ? new Date(item.revoked_at).getTime() : requestTime; + return { + request_id: `job:delegation-revoke:${revokedAt}:${item.id}:${index}`, + actor_id: item.principal_employee_id, + action: "DELEGATION_AUTO_REVOKED", + target_type: "delegation_grant", + target_id: item.id, + ip_address: null, + user_agent: "system/node-cron", + extra: { + principalEmployeeId: item.principal_employee_id, + delegateEmployeeId: item.delegate_employee_id, + }, + }; + }), + }); + + return claimed; }); - if (!expired.length) { + if (!revokedRows.length) { break; } - await prisma.$transaction(async (tx) => { - const ids = expired.map((item) => item.id); - await tx.delegationGrant.updateMany({ - where: { - id: { in: ids }, - revoked_at: null, - }, - data: { - revoked_at: now, - }, - }); + total += revokedRows.length; - await tx.auditLog.createMany({ - data: expired.map((item) => ({ - request_id: `job:delegation-revoke:${now.getTime()}:${item.id}`, - actor_id: item.principal_employee_id, - action: "DELEGATION_AUTO_REVOKED", - target_type: "delegation_grant", - target_id: item.id, - ip_address: null, - user_agent: "system/node-cron", - extra: { - principalEmployeeId: item.principal_employee_id, - delegateEmployeeId: item.delegate_employee_id, - }, - })), - }); - }); - - total += expired.length; + logger.info( + { + round, + claimed: revokedRows.length, + updated: revokedRows.length, + audited: revokedRows.length, + }, + "Expired delegations batch processed", + ); } if (total > 0) {