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 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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户