feat(server): 委派回收并发防重 + 最小门禁闸门

- delegationRevokeJob 改用 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领
- regression-workflow-actions 新增 RUN_DELEGATION_CONCURRENCY_CHECK 并发校验开关
- README 补充最小门禁与并发语义说明
这个提交包含在:
2026-02-08 11:38:58 +08:00
父节点 4c7018ca4a
当前提交 0cd44a3d6a
共修改 4 个文件,包含 166 行新增和 53 行删除
+10
查看文件
@@ -54,3 +54,13 @@ npm run typecheck
npm run build npm run build
npm run preview npm run preview
``` ```
## 8. 仓库级最小门禁
```bash
cd /home/shunli/Projects/hyb-oa
./scripts/check-minimal.sh
```
说明:
- 会执行 client 的 `typecheck/build` 与 server 的静态检查。
- 不启动服务,不替代发版前的 `server/scripts/regression-all.sh`。
+18
查看文件
@@ -43,6 +43,16 @@ cd /home/shunli/Projects/hyb-oa/server
./scripts/smoke-test.sh ./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 分钟快速验证 ## 3. 1 分钟快速验证
```bash ```bash
cd /home/shunli/Projects/hyb-oa/server cd /home/shunli/Projects/hyb-oa/server
@@ -72,6 +82,12 @@ cd /home/shunli/Projects/hyb-oa/server
./scripts/regression-workflow-actions.sh ./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` - `reject`:实例=`rejected`,报销=`rejected`
- `return`:实例=`terminated`,报销=`draft` - `return`:实例=`terminated`,报销=`draft`
@@ -93,6 +109,7 @@ cd /home/shunli/Projects/hyb-oa/server
- `pay` 幂等冲突:已付款后提交冲突凭证字段返回 `409 VALID_001`(`already_paid_conflict`) - `pay` 幂等冲突:已付款后提交冲突凭证字段返回 `409 VALID_001`(`already_paid_conflict`)
- `delegation`:有效期内可代办并在 history 写入 delegation 证据 - `delegation`:有效期内可代办并在 history 写入 delegation 证据
- `delegation` 过期:代理人返回 `PERM_002`,自动回收任务可撤销过期委派 - `delegation` 过期:代理人返回 `PERM_002`,自动回收任务可撤销过期委派
- `delegation` 并发回收(可选):`RUN_DELEGATION_CONCURRENCY_CHECK=1` 时双并发只回收一次并仅写一条自动回收审计
### 4.4 一键全量回归 ### 4.4 一键全量回归
```bash ```bash
@@ -145,6 +162,7 @@ npm run dev
- `GET /api/delegations/mine`:查看我发起/我接收的委派 - `GET /api/delegations/mine`:查看我发起/我接收的委派
- `POST /api/delegations/:id/revoke`:撤销委派 - `POST /api/delegations/:id/revoke`:撤销委派
- 委派仅作用于 `assignee_id` 任务,不改写 `assignee_role` 语义。 - 委派仅作用于 `assignee_id` 任务,不改写 `assignee_role` 语义。
- 自动回收实现采用 `FOR UPDATE SKIP LOCKED + UPDATE ... RETURNING`,多实例并发下确保同一委派仅回收一次。
### 5.3 OCR 异步状态查询 ### 5.3 OCR 异步状态查询
- 上传发票在 OCR 开启且 `ocr_type=vat` 时会进入 `pending_ocr`。 - 上传发票在 OCR 开启且 `ocr_type=vat` 时会进入 `pending_ocr`。
@@ -12,6 +12,7 @@ FINANCE_PASSWORD="${FINANCE_PASSWORD:-FinancePassword1234}"
CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}" CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}"
CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}" CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}"
RUN_PAY_CONCURRENCY_CHECK="${RUN_PAY_CONCURRENCY_CHECK:-0}" 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 if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2 echo "[ERROR] curl not found" >&2
@@ -864,6 +865,42 @@ const { createPrismaClient } = require('./src/prismaClient');
}); });
NODE 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" echo "[1/23] Health check + login baseline users"
health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")" 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" assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json"
@@ -1137,6 +1174,37 @@ 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_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" assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden"
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_output="$(npm run --silent job:revoke-expired-delegations)"
revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)" revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)"
if [[ "$revoke_job_status" != revoked=* ]]; then if [[ "$revoke_job_status" != revoked=* ]]; then
@@ -1144,6 +1212,7 @@ if [[ "$revoke_job_status" != revoked=* ]]; then
exit 1 exit 1
fi fi
assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke" assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke"
fi
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" '{}')" 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" assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json"
+57 -41
查看文件
@@ -1,47 +1,46 @@
const cron = require("node-cron"); const cron = require("node-cron");
async function revokeExpiredDelegations(prisma, logger, options = {}) { async function claimAndRevokeExpiredDelegations(tx, batchSize) {
const batchSize = options.batchSize ?? 200; return tx.$queryRaw`
let total = 0; WITH picked AS (
SELECT id
while (true) { FROM "delegation_grants"
const now = new Date(); WHERE revoked_at IS NULL
const expired = await prisma.delegationGrant.findMany({ AND valid_to IS NOT NULL
where: { AND valid_to <= NOW()
revoked_at: null, ORDER BY valid_to ASC
valid_to: { LIMIT ${batchSize}
not: null, FOR UPDATE SKIP LOCKED
lte: now, )
}, UPDATE "delegation_grants" AS dg
}, SET revoked_at = NOW()
orderBy: { valid_to: "asc" }, FROM picked
take: batchSize, WHERE dg.id = picked.id
select: { AND dg.revoked_at IS NULL
id: true, RETURNING dg.id, dg.principal_employee_id, dg.delegate_employee_id, dg.revoked_at
principal_employee_id: true, `;
delegate_employee_id: true,
},
});
if (!expired.length) {
break;
} }
await prisma.$transaction(async (tx) => { async function revokeExpiredDelegations(prisma, logger, options = {}) {
const ids = expired.map((item) => item.id); const batchSize = Math.max(1, Number(options.batchSize ?? 200));
await tx.delegationGrant.updateMany({ let total = 0;
where: { let round = 0;
id: { in: ids },
revoked_at: null,
},
data: {
revoked_at: now,
},
});
while (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({ await tx.auditLog.createMany({
data: expired.map((item) => ({ data: claimed.map((item, index) => {
request_id: `job:delegation-revoke:${now.getTime()}:${item.id}`, 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, actor_id: item.principal_employee_id,
action: "DELEGATION_AUTO_REVOKED", action: "DELEGATION_AUTO_REVOKED",
target_type: "delegation_grant", target_type: "delegation_grant",
@@ -52,11 +51,28 @@ async function revokeExpiredDelegations(prisma, logger, options = {}) {
principalEmployeeId: item.principal_employee_id, principalEmployeeId: item.principal_employee_id,
delegateEmployeeId: item.delegate_employee_id, delegateEmployeeId: item.delegate_employee_id,
}, },
})), };
}); }),
}); });
total += expired.length; return claimed;
});
if (!revokedRows.length) {
break;
}
total += revokedRows.length;
logger.info(
{
round,
claimed: revokedRows.length,
updated: revokedRows.length,
audited: revokedRows.length,
},
"Expired delegations batch processed",
);
} }
if (total > 0) { if (total > 0) {