完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -0,0 +1,769 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { Client } = require("pg");
|
||||
|
||||
const { buildApp } = require("../app");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
class PaymentConcurrencyCheckError extends Error {
|
||||
constructor(code) {
|
||||
super(code);
|
||||
this.name = "PaymentConcurrencyCheckError";
|
||||
this.safeCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code) {
|
||||
throw new PaymentConcurrencyCheckError(code);
|
||||
}
|
||||
|
||||
function assertCondition(condition, code) {
|
||||
if (!condition) fail(code);
|
||||
}
|
||||
|
||||
function requireValue(env, name) {
|
||||
const value = String(env[name] ?? "").trim();
|
||||
if (!value) fail(`${name.toLowerCase()}_required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireExactValue(env, name, expected, code) {
|
||||
if (String(env[name] ?? "") !== expected) fail(code);
|
||||
}
|
||||
|
||||
function validatePaymentConcurrencyEnvironment(env = process.env) {
|
||||
requireExactValue(
|
||||
env,
|
||||
"RUN_PAYMENT_POSTGRES_CONCURRENCY",
|
||||
"1",
|
||||
"explicit_opt_in_required",
|
||||
);
|
||||
requireExactValue(env, "NODE_ENV", "test", "test_environment_required");
|
||||
requireExactValue(env, "JOBS_ENABLED", "false", "jobs_must_be_disabled");
|
||||
requireExactValue(env, "RATE_LIMIT_ENABLED", "false", "rate_limit_must_be_disabled");
|
||||
requireExactValue(env, "TRUST_PROXY_HOPS", "0", "trust_proxy_must_be_disabled");
|
||||
requireExactValue(
|
||||
env,
|
||||
"PAYMENT_BATCH_ENABLED",
|
||||
"true",
|
||||
"payment_batch_must_be_enabled",
|
||||
);
|
||||
requireExactValue(env, "ERP_SYNC_ENABLED", "false", "erp_sync_must_be_disabled");
|
||||
requireExactValue(env, "EMAIL_ENABLED", "false", "email_provider_must_be_disabled");
|
||||
requireExactValue(env, "IM_NOTIFIER_ENABLED", "false", "im_notifier_must_be_disabled");
|
||||
requireExactValue(env, "WECOM_ENABLED", "false", "wecom_must_be_disabled");
|
||||
requireExactValue(env, "OIDC_ENABLED", "false", "oidc_must_be_disabled");
|
||||
requireExactValue(env, "SERVE_CLIENT_DIST", "false", "static_serving_must_be_disabled");
|
||||
if (String(env.SMTP_HOST ?? "").trim()) fail("smtp_host_forbidden");
|
||||
if (String(env.SMTP_USER ?? "").trim() || String(env.SMTP_PASS ?? "").trim()) {
|
||||
fail("smtp_credentials_forbidden");
|
||||
}
|
||||
if (
|
||||
String(env.WECOM_AGENT_SECRET ?? "").trim() ||
|
||||
String(env.WECOM_CONTACT_SECRET ?? "").trim() ||
|
||||
String(env.WECOM_HR_ASSISTANT_SECRET ?? "").trim()
|
||||
) {
|
||||
fail("wecom_secret_forbidden");
|
||||
}
|
||||
if (String(env.OIDC_CLIENT_SECRET ?? "").trim()) fail("oidc_secret_forbidden");
|
||||
|
||||
const databaseUrl = requireValue(env, "DATABASE_URL");
|
||||
let parsedDatabaseUrl;
|
||||
try {
|
||||
parsedDatabaseUrl = new URL(databaseUrl);
|
||||
} catch {
|
||||
fail("database_url_invalid");
|
||||
}
|
||||
if (!["postgres:", "postgresql:"].includes(parsedDatabaseUrl.protocol)) {
|
||||
fail("database_protocol_invalid");
|
||||
}
|
||||
|
||||
const pgConfig = new Client({ connectionString: databaseUrl });
|
||||
const databaseHost = String(pgConfig.host ?? "").toLowerCase();
|
||||
const databaseName = String(pgConfig.database ?? "");
|
||||
if (!LOOPBACK_HOSTS.has(databaseHost)) fail("database_host_not_loopback");
|
||||
if (!/^hyb_oa_payment_[A-Za-z0-9_]+$/.test(databaseName)) {
|
||||
fail("database_name_not_isolated");
|
||||
}
|
||||
if (requireValue(env, "PAYMENT_CONCURRENCY_DATABASE_CONFIRM") !== databaseName) {
|
||||
fail("database_confirmation_mismatch");
|
||||
}
|
||||
requireValue(env, "JWT_SECRET");
|
||||
requireValue(env, "FIELD_ENCRYPTION_KEY");
|
||||
|
||||
return { databaseName, databaseUrl };
|
||||
}
|
||||
|
||||
function responseJson(response) {
|
||||
try {
|
||||
return response.json();
|
||||
} catch {
|
||||
fail("http_response_not_json");
|
||||
}
|
||||
}
|
||||
|
||||
async function injectJson(app, { method, url, token, payload }) {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(payload === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
...(payload === undefined ? {} : { payload }),
|
||||
});
|
||||
return {
|
||||
status: response.statusCode,
|
||||
body: responseJson(response),
|
||||
};
|
||||
}
|
||||
|
||||
function assertStatusPair(responses, expectedStatuses, code) {
|
||||
const actual = responses.map((response) => response.status).sort((a, b) => a - b);
|
||||
const expected = [...expectedStatuses].sort((a, b) => a - b);
|
||||
assertCondition(JSON.stringify(actual) === JSON.stringify(expected), code);
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, code) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new PaymentConcurrencyCheckError(code)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBlockedConnections(observer, excludedPids, expectedCount, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await observer.query(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> $1
|
||||
AND pid <> $2
|
||||
AND cardinality(pg_blocking_pids(pid)) > 0`,
|
||||
excludedPids,
|
||||
);
|
||||
if (Number(result.rows[0]?.count ?? 0) >= expectedCount) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
fail("blocked_connection_barrier_timeout");
|
||||
}
|
||||
|
||||
async function runBlockedRace({
|
||||
databaseUrl,
|
||||
lockSql,
|
||||
lockParams,
|
||||
requestFactories,
|
||||
timeoutCode,
|
||||
}) {
|
||||
const holder = new Client({ connectionString: databaseUrl });
|
||||
const observer = new Client({ connectionString: databaseUrl });
|
||||
let holderInTransaction = false;
|
||||
let requests = [];
|
||||
|
||||
try {
|
||||
await holder.connect();
|
||||
await observer.connect();
|
||||
await holder.query("BEGIN");
|
||||
holderInTransaction = true;
|
||||
await holder.query(lockSql, lockParams);
|
||||
|
||||
requests = requestFactories.map((factory) => factory());
|
||||
await waitForBlockedConnections(
|
||||
observer,
|
||||
[holder.processID, observer.processID],
|
||||
requestFactories.length,
|
||||
15_000,
|
||||
);
|
||||
|
||||
await holder.query("COMMIT");
|
||||
holderInTransaction = false;
|
||||
return await withTimeout(Promise.all(requests), 30_000, timeoutCode);
|
||||
} finally {
|
||||
if (holderInTransaction) {
|
||||
await holder.query("ROLLBACK").catch(() => undefined);
|
||||
}
|
||||
await Promise.allSettled(requests);
|
||||
await holder.end().catch(() => undefined);
|
||||
await observer.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }) {
|
||||
return {
|
||||
id,
|
||||
employee_no: `QA-${runKey}-${kind}`,
|
||||
name: `合成${kind}`,
|
||||
phone: `synthetic-${runKey}-${kind}`,
|
||||
email: `${runKey}-${kind}@example.test`,
|
||||
department_id: departmentId,
|
||||
legal_entity_id: legalEntityId,
|
||||
title: "并发验收",
|
||||
hire_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
bank_account_encrypted: `synthetic-${runKey}`,
|
||||
bank_name: "合成银行",
|
||||
password_hash: "unused-by-token-based-concurrency-check",
|
||||
token_version: 0,
|
||||
must_change_password: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSyntheticFixture(prisma) {
|
||||
const runKey = crypto.randomBytes(6).toString("hex");
|
||||
const department = await prisma.department.create({
|
||||
data: { name: `合成部门-${runKey}` },
|
||||
});
|
||||
const excludedDepartment = await prisma.department.create({
|
||||
data: { name: `合成范围外部门-${runKey}` },
|
||||
});
|
||||
const legalEntity = await prisma.legalEntity.create({
|
||||
data: { code: `QA${runKey.toUpperCase()}`, name: `合成主体-${runKey}` },
|
||||
});
|
||||
const ids = {
|
||||
owner: crypto.randomUUID(),
|
||||
finance: crypto.randomUUID(),
|
||||
manager: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
for (const kind of Object.keys(ids)) {
|
||||
await prisma.employee.create({
|
||||
data: syntheticEmployeeData({
|
||||
id: ids[kind],
|
||||
runKey,
|
||||
kind,
|
||||
departmentId: department.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
await prisma.employeeRole.createMany({
|
||||
data: [
|
||||
{ employee_id: ids.owner, role_code: "employee" },
|
||||
{ employee_id: ids.finance, role_code: "employee" },
|
||||
{ employee_id: ids.finance, role_code: "finance" },
|
||||
{ employee_id: ids.manager, role_code: "employee" },
|
||||
{ employee_id: ids.manager, role_code: "manager" },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "payment_batch_v1" },
|
||||
create: {
|
||||
flag_key: "payment_batch_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: [department.id],
|
||||
note: "synthetic concurrency check",
|
||||
},
|
||||
update: {
|
||||
override_mode: "force_on",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: [department.id],
|
||||
note: "synthetic concurrency check",
|
||||
},
|
||||
});
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { flag_key: "erp_sync" },
|
||||
create: {
|
||||
flag_key: "erp_sync",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "synthetic concurrency check",
|
||||
},
|
||||
update: {
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "synthetic concurrency check",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
runKey,
|
||||
departmentId: department.id,
|
||||
excludedDepartmentId: excludedDepartment.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
...ids,
|
||||
};
|
||||
}
|
||||
|
||||
async function createApprovedReimbursement(prisma, fixture, sequence) {
|
||||
return prisma.reimbursementForm.create({
|
||||
data: {
|
||||
form_number: `QA-PAY-${fixture.runKey}-${String(sequence).padStart(2, "0")}`,
|
||||
submitter_id: fixture.owner,
|
||||
owner_id: fixture.owner,
|
||||
department_id: fixture.departmentId,
|
||||
reimbursement_date: new Date("2026-07-18T00:00:00.000Z"),
|
||||
total_amount: "1683.00",
|
||||
status: "approved",
|
||||
description: "合成付款并发验收",
|
||||
created_by: fixture.owner,
|
||||
updated_by: fixture.owner,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createReviewedBatch({ app, reimbursementId, financeToken, managerToken, name }) {
|
||||
const created = await injectJson(app, {
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
token: financeToken,
|
||||
payload: {
|
||||
entityType: "reimbursement",
|
||||
name,
|
||||
entityIds: [reimbursementId],
|
||||
defaultPaymentChannel: "cash",
|
||||
voucherPrefix: "QA",
|
||||
},
|
||||
});
|
||||
assertCondition(created.status === 201, "batch_fixture_create_failed");
|
||||
const batchId = created.body?.data?.item?.id;
|
||||
assertCondition(typeof batchId === "string", "batch_fixture_id_missing");
|
||||
|
||||
const reviewed = await injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/review`,
|
||||
token: managerToken,
|
||||
payload: { comment: "合成独立复核" },
|
||||
});
|
||||
assertCondition(reviewed.status === 200, "batch_fixture_review_failed");
|
||||
assertCondition(reviewed.body?.data?.item?.status === "reviewed", "batch_not_reviewed");
|
||||
return batchId;
|
||||
}
|
||||
|
||||
async function countPaymentEffects(prisma, { reimbursementId, batchId }) {
|
||||
const [paymentAuditCount, executionAuditCount, cancellationAuditCount, notificationCount] =
|
||||
await Promise.all([
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
||||
target_type: "reimbursement_form",
|
||||
target_id: reimbursementId,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
action: "PAYMENT_BATCH_EXECUTED",
|
||||
target_type: "payment_batch",
|
||||
target_id: batchId,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
action: "PAYMENT_BATCH_CANCELLED",
|
||||
target_type: "payment_batch",
|
||||
target_id: batchId,
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({
|
||||
where: {
|
||||
type: "reimbursement.paid",
|
||||
target_type: "reimbursement_form",
|
||||
target_id: reimbursementId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { paymentAuditCount, executionAuditCount, cancellationAuditCount, notificationCount };
|
||||
}
|
||||
|
||||
async function setPaymentBatchDepartmentAllowlist(prisma, departmentIds) {
|
||||
await prisma.featureFlag.update({
|
||||
where: { flag_key: "payment_batch_v1" },
|
||||
data: {
|
||||
override_mode: "force_on",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: departmentIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runDoubleExecuteRace({ app, prisma, context, fixture, tokens }) {
|
||||
const reimbursement = await createApprovedReimbursement(prisma, fixture, 1);
|
||||
const batchId = await createReviewedBatch({
|
||||
app,
|
||||
reimbursementId: reimbursement.id,
|
||||
financeToken: tokens.finance,
|
||||
managerToken: tokens.manager,
|
||||
name: "合成双执行竞争",
|
||||
});
|
||||
|
||||
const responses = await runBlockedRace({
|
||||
databaseUrl: context.databaseUrl,
|
||||
lockSql: 'SELECT id FROM "payment_batches" WHERE id = $1::uuid FOR UPDATE',
|
||||
lockParams: [batchId],
|
||||
requestFactories: [
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/execute`,
|
||||
token: tokens.finance,
|
||||
payload: {},
|
||||
}),
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/execute`,
|
||||
token: tokens.finance,
|
||||
payload: {},
|
||||
}),
|
||||
],
|
||||
timeoutCode: "double_execute_timeout",
|
||||
});
|
||||
assertStatusPair(responses, [200, 409], "double_execute_status_contract_invalid");
|
||||
const loser = responses.find((response) => response.status === 409);
|
||||
assertCondition(
|
||||
loser?.body?.error?.details?.status === "only_reviewed_or_failed_executable",
|
||||
"double_execute_conflict_contract_invalid",
|
||||
);
|
||||
|
||||
const [batch, paid, itemCount, effects] = await Promise.all([
|
||||
prisma.paymentBatch.findUnique({ where: { id: batchId } }),
|
||||
prisma.reimbursementForm.findUnique({ where: { id: reimbursement.id } }),
|
||||
prisma.paymentBatchItem.count({ where: { payment_batch_id: batchId, status: "executed" } }),
|
||||
countPaymentEffects(prisma, { reimbursementId: reimbursement.id, batchId }),
|
||||
]);
|
||||
assertCondition(batch?.status === "executed", "double_execute_batch_state_invalid");
|
||||
assertCondition(paid?.status === "paid" && paid.paid_at instanceof Date, "double_execute_payment_state_invalid");
|
||||
assertCondition(itemCount === 1, "double_execute_item_count_invalid");
|
||||
assertCondition(effects.paymentAuditCount === 1, "double_execute_payment_audit_duplicated");
|
||||
assertCondition(effects.executionAuditCount === 1, "double_execute_batch_audit_duplicated");
|
||||
assertCondition(effects.cancellationAuditCount === 0, "double_execute_cancel_audit_created");
|
||||
assertCondition(effects.notificationCount === 1, "double_execute_notification_duplicated");
|
||||
}
|
||||
|
||||
async function runExecuteCancelRace({ app, prisma, context, fixture, tokens }) {
|
||||
const reimbursement = await createApprovedReimbursement(prisma, fixture, 2);
|
||||
const batchId = await createReviewedBatch({
|
||||
app,
|
||||
reimbursementId: reimbursement.id,
|
||||
financeToken: tokens.finance,
|
||||
managerToken: tokens.manager,
|
||||
name: "合成执行取消竞争",
|
||||
});
|
||||
|
||||
const responses = await runBlockedRace({
|
||||
databaseUrl: context.databaseUrl,
|
||||
lockSql: 'SELECT id FROM "payment_batches" WHERE id = $1::uuid FOR UPDATE',
|
||||
lockParams: [batchId],
|
||||
requestFactories: [
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/execute`,
|
||||
token: tokens.finance,
|
||||
payload: {},
|
||||
}),
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/cancel`,
|
||||
token: tokens.finance,
|
||||
payload: { reason: "合成竞争验收取消" },
|
||||
}),
|
||||
],
|
||||
timeoutCode: "execute_cancel_timeout",
|
||||
});
|
||||
assertStatusPair(responses, [200, 409], "execute_cancel_status_contract_invalid");
|
||||
|
||||
const [batch, current, items, effects] = await Promise.all([
|
||||
prisma.paymentBatch.findUnique({ where: { id: batchId } }),
|
||||
prisma.reimbursementForm.findUnique({ where: { id: reimbursement.id } }),
|
||||
prisma.paymentBatchItem.findMany({ where: { payment_batch_id: batchId } }),
|
||||
countPaymentEffects(prisma, { reimbursementId: reimbursement.id, batchId }),
|
||||
]);
|
||||
const executeWon = batch?.status === "executed";
|
||||
const cancelWon = batch?.status === "cancelled";
|
||||
assertCondition(executeWon || cancelWon, "execute_cancel_terminal_state_invalid");
|
||||
assertCondition(items.length === 1, "execute_cancel_item_count_invalid");
|
||||
if (executeWon) {
|
||||
const cancelResponse = responses[1];
|
||||
assertCondition(cancelResponse.status === 409, "execute_cancel_cancel_loser_status_invalid");
|
||||
assertCondition(
|
||||
cancelResponse.body?.error?.details?.status === "batch_not_cancellable",
|
||||
"execute_cancel_cancel_loser_contract_invalid",
|
||||
);
|
||||
assertCondition(current?.status === "paid", "execute_cancel_paid_state_missing");
|
||||
assertCondition(items[0].status === "executed", "execute_cancel_executed_item_missing");
|
||||
assertCondition(effects.paymentAuditCount === 1, "execute_cancel_payment_audit_invalid");
|
||||
assertCondition(effects.executionAuditCount === 1, "execute_cancel_execute_audit_invalid");
|
||||
assertCondition(effects.cancellationAuditCount === 0, "execute_cancel_cancel_audit_invalid");
|
||||
assertCondition(effects.notificationCount === 1, "execute_cancel_notification_invalid");
|
||||
} else {
|
||||
const executeResponse = responses[0];
|
||||
assertCondition(executeResponse.status === 409, "execute_cancel_execute_loser_status_invalid");
|
||||
assertCondition(
|
||||
executeResponse.body?.error?.details?.status === "only_reviewed_or_failed_executable",
|
||||
"execute_cancel_execute_loser_contract_invalid",
|
||||
);
|
||||
assertCondition(current?.status === "approved", "execute_cancel_cancelled_source_changed");
|
||||
assertCondition(items[0].status === "cancelled", "execute_cancel_cancelled_item_missing");
|
||||
assertCondition(effects.paymentAuditCount === 0, "execute_cancel_payment_audit_created");
|
||||
assertCondition(effects.executionAuditCount === 0, "execute_cancel_execute_audit_created");
|
||||
assertCondition(effects.cancellationAuditCount === 1, "execute_cancel_cancel_audit_invalid");
|
||||
assertCondition(effects.notificationCount === 0, "execute_cancel_notification_created");
|
||||
}
|
||||
}
|
||||
|
||||
async function runDoubleCreateRace({ app, prisma, context, fixture, tokens }) {
|
||||
const reimbursement = await createApprovedReimbursement(prisma, fixture, 3);
|
||||
const payload = {
|
||||
entityType: "reimbursement",
|
||||
name: "合成双创建竞争",
|
||||
entityIds: [reimbursement.id],
|
||||
defaultPaymentChannel: "cash",
|
||||
};
|
||||
const responses = await runBlockedRace({
|
||||
databaseUrl: context.databaseUrl,
|
||||
lockSql: 'SELECT id FROM "reimbursement_forms" WHERE id = $1::uuid FOR UPDATE',
|
||||
lockParams: [reimbursement.id],
|
||||
requestFactories: [
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
token: tokens.finance,
|
||||
payload,
|
||||
}),
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
token: tokens.finance,
|
||||
payload,
|
||||
}),
|
||||
],
|
||||
timeoutCode: "double_create_timeout",
|
||||
});
|
||||
assertStatusPair(responses, [201, 409], "double_create_status_contract_invalid");
|
||||
const loser = responses.find((response) => response.status === 409);
|
||||
assertCondition(
|
||||
loser?.body?.error?.details?.entityIds === "contains_active_payment_batch",
|
||||
"double_create_conflict_contract_invalid",
|
||||
);
|
||||
|
||||
const items = await prisma.paymentBatchItem.findMany({
|
||||
where: { entity_id: reimbursement.id },
|
||||
select: { payment_batch_id: true },
|
||||
});
|
||||
assertCondition(items.length === 1, "double_create_item_count_invalid");
|
||||
const [batchCount, auditCount, notificationCount] = await Promise.all([
|
||||
prisma.paymentBatch.count({ where: { id: items[0].payment_batch_id } }),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
action: "PAYMENT_BATCH_CREATED",
|
||||
target_type: "payment_batch",
|
||||
target_id: items[0].payment_batch_id,
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({ where: { target_id: reimbursement.id } }),
|
||||
]);
|
||||
assertCondition(batchCount === 1, "double_create_batch_count_invalid");
|
||||
assertCondition(auditCount === 1, "double_create_audit_duplicated");
|
||||
assertCondition(notificationCount === 0, "double_create_notification_created");
|
||||
}
|
||||
|
||||
async function runMissingMakerReviewCheck({ app, prisma, fixture, tokens }) {
|
||||
const reimbursement = await createApprovedReimbursement(prisma, fixture, 5);
|
||||
const created = await injectJson(app, {
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
token: tokens.finance,
|
||||
payload: {
|
||||
entityType: "reimbursement",
|
||||
name: "合成缺失制单人检查",
|
||||
entityIds: [reimbursement.id],
|
||||
defaultPaymentChannel: "cash",
|
||||
},
|
||||
});
|
||||
assertCondition(created.status === 201, "missing_maker_fixture_create_failed");
|
||||
const batchId = created.body?.data?.item?.id;
|
||||
assertCondition(typeof batchId === "string", "missing_maker_batch_id_missing");
|
||||
await prisma.paymentBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { created_by: null },
|
||||
});
|
||||
|
||||
const reviewed = await injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/review`,
|
||||
token: tokens.manager,
|
||||
payload: { comment: "不应复核缺失制单人的批次" },
|
||||
});
|
||||
assertCondition(reviewed.status === 409, "missing_maker_review_not_blocked");
|
||||
assertCondition(
|
||||
reviewed.body?.error?.code === "VALID_001",
|
||||
"missing_maker_review_contract_invalid",
|
||||
);
|
||||
const [batch, auditCount] = await Promise.all([
|
||||
prisma.paymentBatch.findUnique({ where: { id: batchId } }),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
action: "PAYMENT_BATCH_REVIEWED",
|
||||
target_type: "payment_batch",
|
||||
target_id: batchId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assertCondition(batch?.status === "draft" && batch.reviewed_at === null, "missing_maker_state_changed");
|
||||
assertCondition(auditCount === 0, "missing_maker_audit_created");
|
||||
}
|
||||
|
||||
async function runDirectPayBatchRace({ app, prisma, context, fixture, tokens }) {
|
||||
const reimbursement = await createApprovedReimbursement(prisma, fixture, 4);
|
||||
const batchId = await createReviewedBatch({
|
||||
app,
|
||||
reimbursementId: reimbursement.id,
|
||||
financeToken: tokens.finance,
|
||||
managerToken: tokens.manager,
|
||||
name: "合成直付批次竞争",
|
||||
});
|
||||
|
||||
await setPaymentBatchDepartmentAllowlist(prisma, [fixture.excludedDepartmentId]);
|
||||
const excludedResponses = await runBlockedRace({
|
||||
databaseUrl: context.databaseUrl,
|
||||
lockSql: 'SELECT id FROM "reimbursement_forms" WHERE id = $1::uuid FOR UPDATE',
|
||||
lockParams: [reimbursement.id],
|
||||
requestFactories: [
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursement.id}/pay`,
|
||||
token: tokens.finance,
|
||||
payload: { paymentChannel: "cash", paymentNote: "合成范围外直付竞争" },
|
||||
}),
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/execute`,
|
||||
token: tokens.finance,
|
||||
payload: {},
|
||||
}),
|
||||
],
|
||||
timeoutCode: "excluded_department_payment_race_timeout",
|
||||
});
|
||||
assertStatusPair(
|
||||
excludedResponses,
|
||||
[409, 409],
|
||||
"excluded_department_payment_paths_not_fail_closed",
|
||||
);
|
||||
assertCondition(
|
||||
excludedResponses.every((response) => response.body?.error?.code === "VALID_001"),
|
||||
"excluded_department_payment_conflict_contract_invalid",
|
||||
);
|
||||
|
||||
const [excludedBatch, excludedSource, excludedItems, excludedEffects] = await Promise.all([
|
||||
prisma.paymentBatch.findUnique({ where: { id: batchId } }),
|
||||
prisma.reimbursementForm.findUnique({ where: { id: reimbursement.id } }),
|
||||
prisma.paymentBatchItem.findMany({ where: { payment_batch_id: batchId } }),
|
||||
countPaymentEffects(prisma, { reimbursementId: reimbursement.id, batchId }),
|
||||
]);
|
||||
assertCondition(excludedBatch?.status === "reviewed", "excluded_department_batch_changed");
|
||||
assertCondition(excludedSource?.status === "approved", "excluded_department_source_changed");
|
||||
assertCondition(
|
||||
excludedItems.length === 1 && excludedItems[0].status === "pending",
|
||||
"excluded_department_item_changed",
|
||||
);
|
||||
assertCondition(
|
||||
Object.values(excludedEffects).every((count) => count === 0),
|
||||
"excluded_department_side_effect_created",
|
||||
);
|
||||
|
||||
await setPaymentBatchDepartmentAllowlist(prisma, [fixture.departmentId]);
|
||||
|
||||
const responses = await runBlockedRace({
|
||||
databaseUrl: context.databaseUrl,
|
||||
lockSql: 'SELECT id FROM "reimbursement_forms" WHERE id = $1::uuid FOR UPDATE',
|
||||
lockParams: [reimbursement.id],
|
||||
requestFactories: [
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursement.id}/pay`,
|
||||
token: tokens.finance,
|
||||
payload: { paymentChannel: "cash", paymentNote: "合成直付竞争" },
|
||||
}),
|
||||
() => injectJson(app, {
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${batchId}/execute`,
|
||||
token: tokens.finance,
|
||||
payload: {},
|
||||
}),
|
||||
],
|
||||
timeoutCode: "direct_pay_batch_timeout",
|
||||
});
|
||||
assertCondition(responses[0].status === 409, "direct_pay_not_blocked");
|
||||
assertCondition(
|
||||
responses[0].body?.error?.details?.payment === "payment_batch_v1_required",
|
||||
"direct_pay_conflict_contract_invalid",
|
||||
);
|
||||
assertCondition(responses[1].status === 200, "batch_execute_lost_to_direct_pay");
|
||||
|
||||
const [batch, current, itemCount, effects] = await Promise.all([
|
||||
prisma.paymentBatch.findUnique({ where: { id: batchId } }),
|
||||
prisma.reimbursementForm.findUnique({ where: { id: reimbursement.id } }),
|
||||
prisma.paymentBatchItem.count({ where: { payment_batch_id: batchId, status: "executed" } }),
|
||||
countPaymentEffects(prisma, { reimbursementId: reimbursement.id, batchId }),
|
||||
]);
|
||||
assertCondition(batch?.status === "executed", "direct_pay_batch_state_invalid");
|
||||
assertCondition(current?.status === "paid", "direct_pay_source_state_invalid");
|
||||
assertCondition(itemCount === 1, "direct_pay_item_count_invalid");
|
||||
assertCondition(effects.paymentAuditCount === 1, "direct_pay_payment_audit_duplicated");
|
||||
assertCondition(effects.executionAuditCount === 1, "direct_pay_execute_audit_duplicated");
|
||||
assertCondition(effects.cancellationAuditCount === 0, "direct_pay_cancel_audit_created");
|
||||
assertCondition(effects.notificationCount === 1, "direct_pay_notification_duplicated");
|
||||
}
|
||||
|
||||
async function runPaymentPostgresConcurrencyCheck(env = process.env) {
|
||||
const context = validatePaymentConcurrencyEnvironment(env);
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
let app = null;
|
||||
try {
|
||||
const fixture = await seedSyntheticFixture(prisma);
|
||||
app = await buildApp({ startJobs: async () => undefined });
|
||||
await app.ready();
|
||||
const tokens = {
|
||||
finance: app.issueAccessToken({ id: fixture.finance, token_version: 0 }),
|
||||
manager: app.issueAccessToken({ id: fixture.manager, token_version: 0 }),
|
||||
};
|
||||
|
||||
console.log("[PAYMENT-CONCURRENCY] double-execute: start");
|
||||
await runDoubleExecuteRace({ app, prisma, context, fixture, tokens });
|
||||
console.log("[PAYMENT-CONCURRENCY] double-execute: pass");
|
||||
console.log("[PAYMENT-CONCURRENCY] execute-cancel: start");
|
||||
await runExecuteCancelRace({ app, prisma, context, fixture, tokens });
|
||||
console.log("[PAYMENT-CONCURRENCY] execute-cancel: pass");
|
||||
console.log("[PAYMENT-CONCURRENCY] double-create: start");
|
||||
await runDoubleCreateRace({ app, prisma, context, fixture, tokens });
|
||||
console.log("[PAYMENT-CONCURRENCY] double-create: pass");
|
||||
console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: start");
|
||||
await runDirectPayBatchRace({ app, prisma, context, fixture, tokens });
|
||||
console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: pass");
|
||||
console.log("[PAYMENT-CONCURRENCY] missing-maker-review: start");
|
||||
await runMissingMakerReviewCheck({ app, prisma, fixture, tokens });
|
||||
console.log("[PAYMENT-CONCURRENCY] missing-maker-review: pass");
|
||||
console.log("[PAYMENT-CONCURRENCY][PASS] all races passed");
|
||||
} finally {
|
||||
await app?.close().catch(() => undefined);
|
||||
await prisma.$disconnect().catch(() => undefined);
|
||||
await pool.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runPaymentPostgresConcurrencyCheck().catch((error) => {
|
||||
const code =
|
||||
error instanceof PaymentConcurrencyCheckError
|
||||
? error.safeCode
|
||||
: "payment_concurrency_check_failed";
|
||||
console.error(`[PAYMENT-CONCURRENCY][FAIL] ${code}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PaymentConcurrencyCheckError,
|
||||
runPaymentPostgresConcurrencyCheck,
|
||||
validatePaymentConcurrencyEnvironment,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户