feat(server): 委派回收并发防重 + 最小门禁闸门
- delegationRevokeJob 改用 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领 - regression-workflow-actions 新增 RUN_DELEGATION_CONCURRENCY_CHECK 并发校验开关 - README 补充最小门禁与并发语义说明
这个提交包含在:
+10
@@ -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`。
|
||||
|
||||
+18
@@ -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`。
|
||||
|
||||
@@ -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,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_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_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)"
|
||||
if [[ "$revoke_job_status" != revoked=* ]]; then
|
||||
@@ -1144,6 +1212,7 @@ if [[ "$revoke_job_status" != revoked=* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
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" '{}')"
|
||||
assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json"
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
async function revokeExpiredDelegations(prisma, logger, options = {}) {
|
||||
const batchSize = options.batchSize ?? 200;
|
||||
let total = 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,
|
||||
},
|
||||
});
|
||||
|
||||
if (!expired.length) {
|
||||
break;
|
||||
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
|
||||
`;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
async function revokeExpiredDelegations(prisma, logger, options = {}) {
|
||||
const batchSize = Math.max(1, Number(options.batchSize ?? 200));
|
||||
let total = 0;
|
||||
let round = 0;
|
||||
|
||||
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({
|
||||
data: expired.map((item) => ({
|
||||
request_id: `job:delegation-revoke:${now.getTime()}:${item.id}`,
|
||||
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",
|
||||
@@ -52,11 +51,28 @@ async function revokeExpiredDelegations(prisma, logger, options = {}) {
|
||||
principalEmployeeId: item.principal_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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户