文件
hyb-oa/scripts/qa/company-loan-employee-fixture.cjs
T
wangsli和Claude Opus 5.5 36b949ecf6
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
借款出借人增加员工与公司组合数据库唯一约束(09-17-company-loan-principal-repayment)
- 新迁移 20261011010000_loan_lender_employee_unique:(legal_entity_id, employee_id) 唯一,归档计入、无员工出借人不冲突;生产只读排查重复 0 组
- 触到约束时返回原有 loan.employee_lender_conflict,不报 500 或误报请求重复;员工行锁与代码检查保留
- 测试改为断言数据库拒绝模糊旧数据,补空员工共存与归档占用;变异 12 项含去索引与数据库兜底两种运行
- PRD 补本迁移回退说明;独立只读复核无阻断,发布窗口需重跑重复计数(AC59)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 13:21:08 +08:00

359 行
28 KiB
JavaScript

const assert = require('node:assert/strict');
const { randomUUID, createHash } = require('node:crypto');
module.exports = async function employeeFixture(h) {
const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, bank,
confirmedLoan, repay, exportOrder, receipt } = h;
const { encryptStringOrThrow, decryptStringOrThrow } = req('./src/services/crypto');
const checks = [];
const sourceUrl = id => `/loan-lenders/employee-source/${id}?legalEntityId=${company.id}`;
const source = who => call(finance, 'GET', sourceUrl(who.id));
async function employee(label, roles = ['employee']) {
const who = await actor(roles);
await prisma.employee.update({ where: { id: who.id }, data: { name: `合成员工-${label}`,
bank_institution_id: bank.id, bank_name: '合成建行员工收款资料',
bank_account_encrypted: encryptStringOrThrow(config, '2'.repeat(16)) } });
return who;
}
const input = (who, preview, key = randomUUID()) => ({ employeeId: who.id, legalEntityId: company.id,
expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true, requestKey: key });
const link = (body, status = 200, reason) => call(finance, 'POST', '/loan-lenders/from-employee', body, status, reason);
const refreshInput = preview => ({ expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true,
expectedIdentityVersion: preview.existingLender.identityVersion, expectedAccountVersion: preview.existingLender.accountVersion,
reason: '合成根据员工档案重新核对姓名及收款资料' });
const refresh = (id, body, status = 200, reason) => call(finance, 'POST', `/loan-lenders/${id}/employee-refresh`, body, status, reason);
const staff = await employee('资料复用', ['manager']);
const employeeCount = await prisma.employee.count();
const preview = await source(staff);
assert.equal(preview.linkState, 'new'); assert.deepEqual(preview.problems, []);
assert.ok(!JSON.stringify(preview).includes('2'.repeat(16)), 'preview must contain only a masked account');
assert.ok(!JSON.stringify(preview).includes('encrypted'), 'preview never exposes encrypted source columns');
const body = input(staff, preview);
const created = await link(body); const lender = created.lender;
assert.equal(created.reused, false); assert.equal(lender.employeeId, staff.id); assert.equal(lender.identityStatus, 'verified');
const persisted = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(persisted.name, preview.employee.name);
assert.ok(decryptStringOrThrow(config, persisted.bank_account_encrypted) === '2'.repeat(16), 'account comes from employee');
assert.ok(decryptStringOrThrow(config, persisted.account_name_encrypted) === preview.employee.name, 'account holder comes from employee name');
assert.equal((await link(body)).lender.id, lender.id);
assert.equal((await link(input(staff, await source(staff)))).reused, true);
assert.equal(await prisma.employee.count(), employeeCount, 'linking never creates an employee');
assert.equal(await prisma.loanLender.count({ where: { employee_id: staff.id, legal_entity_id: company.id } }), 1);
const reuseKey = randomUUID();
await link(input(staff, await source(staff), reuseKey));
const keyTarget = await employee('复用请求键冲突');
await link(input(keyTarget, await source(keyTarget), reuseKey), 409, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyTarget.id } }), 0);
assert.equal((await link(input(staff, await source(staff), reuseKey))).lender.id, lender.id);
checks.push('employee_source_masked_server_copy_verified_link_and_reuse');
for (const who of [manager, ordinary, null]) {
await call(who, 'GET', sourceUrl(staff.id), undefined, who ? 403 : 401);
await call(who, 'POST', '/loan-lenders/from-employee', body, who ? 403 : 401);
await call(who, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, refreshInput(await source(staff)), who ? 403 : 401);
}
// An otherwise qualified finance user still needs employee bank permission.
const hasPermission = app.hasPermission;
app.hasPermission = (person, code) => code === 'employee.bank.read' || code === 'employee.bank.manage' ? false : hasPermission(person, code);
try {
await call(finance, 'GET', sourceUrl(staff.id), undefined, 403);
await call(finance, 'POST', '/loan-lenders/from-employee', body, 403);
await call(finance, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, {}, 403);
} finally { app.hasPermission = hasPermission; }
for (const addition of [{ name: '伪造姓名' }, { bankAccount: '9'.repeat(16) }, { accountName: '伪造账户名' }, { detailsConfirmed: false }]) {
await link({ ...body, ...addition }, 400, 'loan.invalid_input');
}
await link({ ...body, legalEntityId: randomUUID() }, 409, 'loan.source_unavailable');
const otherCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成另一个借款公司' } });
const explicitCompanySource = await call(finance, 'GET', `/loan-lenders/employee-source/${staff.id}?legalEntityId=${otherCompany.id}`);
assert.equal(explicitCompanySource.employee.legalEntityId, company.id);
const otherCompanyLender = await link({ ...input(staff, explicitCompanySource), legalEntityId: otherCompany.id });
assert.equal(otherCompanyLender.lender.legalEntityId, otherCompany.id, 'employee employer must not replace the explicitly chosen debtor');
checks.push('employee_source_role_bank_permission_and_strict_input_guards');
const incomplete = await employee('资料缺项');
const normal = await prisma.employee.findUniqueOrThrow({ where: { id: incomplete.id } });
const unsupported = await prisma.bankInstitution.create({ data: { code: `SYN-${randomUUID().slice(0, 8)}`, name: '合成其他银行' } });
for (const [patch, problem] of [
[{ name: '' }, 'name_invalid'], [{ bank_name: '' }, 'bank_missing'],
[{ bank_institution_id: null }, 'bank_missing'], [{ bank_institution_id: unsupported.id }, 'bank_unsupported'],
[{ bank_account_encrypted: '' }, 'account_missing'], [{ bank_account_encrypted: 'synthetic-unreadable' }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow({ fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64') }, '3'.repeat(16)) }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow(config, 'invalid-account') }, 'account_invalid'],
]) {
await prisma.employee.update({ where: { id: incomplete.id }, data: { name: normal.name, bank_name: normal.bank_name,
bank_institution_id: normal.bank_institution_id, bank_account_encrypted: normal.bank_account_encrypted, ...patch } });
const invalid = await source(incomplete); assert.ok(invalid.problems.includes(problem), `expected ${problem}; observed ${invalid.problems.join(',')}`);
await link(input(incomplete, invalid), 409, 'loan.employee_source_incomplete');
}
await prisma.employee.update({ where: { id: incomplete.id }, data: { status: 'archived' } });
await call(finance, 'GET', sourceUrl(incomplete.id), undefined, 409, 'loan.source_unavailable');
assert.equal(await prisma.loanLender.count({ where: { employee_id: incomplete.id } }), 0);
checks.push('employee_missing_unreadable_unsupported_and_inactive_sources_rejected');
const stale = await employee('预览变更');
const old = await source(stale);
const row = await prisma.employee.findUniqueOrThrow({ where: { id: stale.id } });
// Holding updated_at constant proves the version binds actual fields too.
await prisma.employee.update({ where: { id: stale.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)), updated_at: row.updated_at } });
await link(input(stale, old), 409, 'loan.employee_source_changed');
assert.equal(await prisma.loanLender.count({ where: { employee_id: stale.id } }), 0);
const fresh = await source(stale);
const freshLender = (await link(input(stale, fresh))).lender;
await link({ ...input(stale, fresh), requestKey: body.requestKey }, 409, 'loan.duplicate_registration');
checks.push('employee_source_version_covers_fields_and_request_key_conflicts');
// A real login between preview and save (a failed attempt, then a successful
// one resetting the counters) touches updated_at only and must not block.
const loginWith = (who, password) => app.inject({ method: 'POST', url: '/api/auth/login',
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: who.email, password } });
const loggedIn = await employee('预览后登录');
const beforeLogin = await source(loggedIn);
const touched = (await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at;
assert.notEqual((await loginWith(loggedIn, `${h.password}-wrong`)).statusCode, 200);
assert.equal((await loginWith(loggedIn, h.password)).statusCode, 200);
assert.notEqual((await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at.getTime(), touched.getTime(), 'login touches updated_at');
assert.equal((await source(loggedIn)).sourceVersion, beforeLogin.sourceVersion);
assert.equal((await link(input(loggedIn, beforeLogin))).lender.employeeId, loggedIn.id);
// Every real source field still invalidates a preview, with updated_at held.
for (const [field, patch] of [['name', { name: '合成员工-改名' }], ['bank_name', { bank_name: '合成建行员工收款资料-改' }],
['legal_entity_id', { legal_entity_id: otherCompany.id }], ['bank_institution_id', { bank_institution_id: null }]]) {
const who = await employee(`字段-${field}`);
const seen = await source(who);
const at = (await prisma.employee.findUniqueOrThrow({ where: { id: who.id } })).updated_at;
await prisma.employee.update({ where: { id: who.id }, data: { ...patch, updated_at: at } });
await link(input(who, seen), 409, 'loan.employee_source_changed');
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id } }), 0, `${field} change blocks the save`);
}
checks.push('employee_login_between_preview_and_save_allowed_real_field_changes_blocked');
async function manualFor(who) {
const e = await prisma.employee.findUniqueOrThrow({ where: { id: who.id } });
return call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name: e.name,
bankInstitutionId: bank.id, bankName: e.bank_name, bankAccount: decryptStringOrThrow(config, e.bank_account_encrypted), accountName: e.name });
}
const other = await manualFor(staff);
await call(finance, 'POST', `/loan-lenders/${other.id}/identity-confirmation`, { expectedVersion: other.identityVersion,
employeeId: staff.id, externalPersonConfirmed: false, reason: '合成旧入口重复关联拒绝' }, 409, 'loan.employee_lender_conflict');
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
// An ambiguous dataset can no longer be written at all: the database rejects
// a second lender for the same employee and company, even outside the app.
await assert.rejects(prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } }),
(error) => error.code === 'P2002' && req('./src/services/loanLenderEmployee').isEmployeeLenderUniqueViolation(error));
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
assert.equal((await source(staff)).linkState, 'reusable');
// NULL employee_id never conflicts: several manual lenders coexist in one company.
const manualTwin = await manualFor(staff);
assert.equal(await prisma.loanLender.count({ where: { legal_entity_id: company.id, employee_id: null, id: { in: [other.id, manualTwin.id] } } }), 2);
// An archived link still occupies the pair, in the app and in the database.
await prisma.loanLender.update({ where: { id: freshLender.id }, data: { archived_at: new Date() } });
await assert.rejects(prisma.loanLender.update({ where: { id: manualTwin.id }, data: { employee_id: stale.id } }),
(error) => error.code === 'P2002');
assert.equal((await source(stale)).linkState, 'conflict');
await link(input(stale, await source(stale)), 409, 'loan.employee_lender_conflict');
checks.push('legacy_identity_entry_and_ambiguous_or_archived_links_cannot_duplicate');
const { Pool } = req('pg');
// Pause the real refresh immediately before its audit INSERT checks actor A's
// FK. It already holds employee B; B's concurrent link locks A then waits on B.
// An UPDATE lock on A would deadlock with the audit's implicit KEY SHARE.
const [financeA, financeB] = (await Promise.all([
employee('外键交错甲', ['finance']), employee('外键交错乙', ['finance']),
])).sort((a, b) => a.id.localeCompare(b.id));
const fkLender = (await call(financeA, 'POST', '/loan-lenders/from-employee', input(financeB, await source(financeB)))).lender;
const fkRefresh = refreshInput(await source(financeB));
const fkLink = input(financeA, await source(financeA));
const fkLocks = new Pool({ ...pool.options, max: 2 });
const fkHolder = await fkLocks.connect(); const fkResponses = [];
const deadlocksBefore = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
await pool.query(`CREATE FUNCTION synthetic_pause_employee_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.target_id = TG_ARGV[0]::uuid THEN PERFORM pg_advisory_xact_lock(293174183); END IF; RETURN NEW; END $$`);
await pool.query(`CREATE TRIGGER synthetic_employee_fk_pause BEFORE INSERT ON audit_logs FOR EACH ROW
WHEN (NEW.action = 'LOAN_LENDER_EMPLOYEE_REFRESHED') EXECUTE FUNCTION synthetic_pause_employee_audit('${fkLender.id}')`);
let fkResults; let fkLinkResponse;
try {
await fkHolder.query('SELECT pg_advisory_lock(293174183)');
fkResponses.push(app.inject({ method: 'POST', url: `/api/loan-lenders/${fkLender.id}/employee-refresh`,
headers: financeA.headers, payload: fkRefresh }).then(r => r));
async function observeFkWait(count) {
for (let i = 0; i < 200; i++) {
const rows = await fkLocks.query(`SELECT count(*)::int AS n, count(*) FILTER (WHERE wait_event='advisory')::int AS advisory
FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'`);
if (rows.rows[0].n >= count && rows.rows[0].advisory === 1) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
if (count === 2 && fkLinkResponse?.statusCode === 200) {
throw Object.assign(new Error('source association finished without waiting for the audit-held employee'), { code: 'EMPLOYEE_SOURCE_LOCK_NOT_HELD' });
}
throw new Error('synthetic audit/employee interleaving did not reach both database lock waits');
}
await observeFkWait(1);
fkResponses.push(app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee',
headers: financeB.headers, payload: fkLink }).then(r => { fkLinkResponse = r; return r; }));
await observeFkWait(2);
await fkHolder.query('SELECT pg_advisory_unlock(293174183)');
fkResults = await Promise.all(fkResponses);
} finally {
await fkHolder.query('SELECT pg_advisory_unlock(293174183)'); fkHolder.release();
await Promise.allSettled(fkResponses); await fkLocks.end();
await pool.query('DROP TRIGGER synthetic_employee_fk_pause ON audit_logs');
await pool.query('DROP FUNCTION synthetic_pause_employee_audit()');
}
if (fkResults.some(r => r.statusCode !== 200)) {
// Classify the reverse mutation only with PostgreSQL's actual deadlock
// counter, never a generic 500, timeout or fixture/environment failure.
for (let i = 0; i < 200; i++) {
const count = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
if (count > deadlocksBefore) throw Object.assign(new Error('observed employee source/audit FK deadlock'), { code: 'EMPLOYEE_SOURCE_FK_DEADLOCK' });
await new Promise(resolve => setTimeout(resolve, 10));
}
}
assert.deepEqual(fkResults.map(r => r.statusCode), [200, 200], 'refresh audit and concurrent source association must both finish');
assert.equal(fkResults[0].json().data.identityVersion, fkLender.identityVersion + 1);
assert.equal(fkResults[1].json().data.lender.employeeId, financeA.id);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_EMPLOYEE_REFRESHED', target_id: fkLender.id } }), 1);
checks.push('employee_source_locks_allow_implicit_audit_fk_without_deadlock');
const locks = new Pool({ ...pool.options, max: 2 });
async function waiters(count) {
for (let i = 0; i < 200; i++) {
const result = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (result.rows[0].n >= count) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error('synthetic employee operation did not actually wait for the row lock');
}
async function interleave(who, starts) {
const holder = await locks.connect(); const responses = [];
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM employees WHERE id=$1 FOR UPDATE', [who.id]);
for (const start of starts) { responses.push(start()); await waiters(responses.length); }
await holder.query('COMMIT'); return await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
}
const startLink = body => () => app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee', headers: finance.headers, payload: body }).then(r => r);
try {
const simultaneous = await employee('并发新建'); const p = await source(simultaneous);
const replies = await interleave(simultaneous, [startLink(input(simultaneous, p)), startLink(input(simultaneous, p))]);
assert.deepEqual(replies.map(r => r.statusCode), [200, 200]);
assert.equal(replies[0].json().data.lender.id, replies[1].json().data.lender.id);
assert.equal(await prisma.loanLender.count({ where: { employee_id: simultaneous.id, legal_entity_id: company.id } }), 1);
const keyed = await employee('并发复用请求键'); const concurrentKey = randomUUID();
const keyedReplies = await interleave(finance, [startLink(input(staff, await source(staff), concurrentKey)), startLink(input(keyed, await source(keyed), concurrentKey))]);
assert.deepEqual(keyedReplies.map(r => r.statusCode), [200, 409]);
assert.equal(keyedReplies[1].json().error?.details.reasonCode, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyed.id } }), 0);
for (const legacyFirst of [false, true]) {
const who = await employee(`并发旧入口-${legacyFirst}`); const p = await source(who); const manual = await manualFor(who);
const legacy = () => app.inject({ method: 'POST', url: `/api/loan-lenders/${manual.id}/identity-confirmation`, headers: finance.headers,
payload: { expectedVersion: manual.identityVersion, employeeId: who.id, externalPersonConfirmed: false, reason: '合成并发身份关联' } }).then(r => r);
const replies = await interleave(who, legacyFirst ? [legacy, startLink(input(who, p))] : [startLink(input(who, p)), legacy]);
assert.deepEqual(replies.map(r => r.statusCode), legacyFirst ? [200, 200] : [200, 409]);
if (!legacyFirst) assert.equal(replies[1].json().error?.details.reasonCode, 'loan.employee_lender_conflict');
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id, legal_entity_id: company.id } }), 1);
}
} finally { await locks.end(); }
checks.push('employee_creation_and_legacy_entry_observed_lock_interleavings');
const loan = await confirmedLoan(lender.id, '100.00', `合成员工原借入-${randomUUID()}`);
async function authorize() {
const auth = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: lender.id, loanIds: [loan.id],
requestKey: randomUUID(), basis: '合成已存在的公司代表权及此笔借款范围' });
await call(staff, 'POST', `/loan-related-authorizations/${auth.id}/actions`, { action: 'confirm', reason: '合成公司代表本人确认授权范围' });
return auth;
}
const auth = await authorize(); const batch = await repay(lender.id, loan.id, '10.00');
assert.equal(batch.loanRepayment.approvalMode, 'related_party');
await call(staff, 'POST', `/payment-batches/${batch.id}/review`, {});
await exportOrder(batch.id);
async function snapshots() {
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId },
select: { lender_identity_version: true, lender_account_version: true, payee_snapshot_json: true, approval_context: true } });
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id },
select: { payment_account_snapshot_json: true, items: { select: { snapshot_json: true } } } });
return createHash('sha256').update(JSON.stringify({ repayment, savedBatch })).digest('hex');
}
const originalSnapshots = await snapshots();
const beforeChange = await source(staff);
await prisma.employee.update({ where: { id: staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
assert.equal(await snapshots(), originalSnapshots);
const changed = await source(staff); assert.equal(changed.linkState, 'refresh_required'); assert.equal(changed.existingLender.hasInFlightRepayment, true);
await link(input(staff, changed), 409, 'loan.employee_lender_refresh_required');
await refresh(lender.id, refreshInput(changed), 409, 'loan.lender_in_flight');
const proof = await receipt(batch.id, '1000', `SYN-EMPLOYEE-${randomUUID()}`, { payeeAccount: '2'.repeat(16) });
assert.equal(proof.result.status, 'matched');
assert.equal((await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {})).item.status, 'executed');
await refresh(lender.id, refreshInput(beforeChange), 409, 'loan.employee_source_changed');
const updated = await refresh(lender.id, refreshInput(await source(staff)));
assert.equal(updated.accountVersion, lender.accountVersion + 1); assert.equal(updated.identityVersion, lender.identityVersion + 1);
assert.equal(updated.identityStatus, 'verified'); assert.equal(updated.employeeId, staff.id);
assert.equal(await snapshots(), originalSnapshots, 'explicit source refresh never rewrites historical payment snapshots');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '90.00');
await refresh(lender.id, refreshInput(changed), 409, 'loan.version_changed');
await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { expectedVersion: updated.accountVersion,
bankInstitutionId: bank.id, bankName: '伪造覆盖', bankAccount: '9'.repeat(16), accountName: '伪造覆盖' }, 409, 'loan.employee_account_managed');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: lender.id, reason: '合成旧授权拒绝',
allocations: [{ loanId: loan.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid');
assert.equal((await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: auth.id } })).identity_version, lender.identityVersion);
await authorize(); const renewed = await repay(lender.id, loan.id, '1.00');
await call(finance, 'POST', `/payment-batches/${renewed.id}/cancel`, { reason: '合成刷新后新授权验证结束' });
checks.push('employee_refresh_inflight_guard_old_payment_snapshot_settlement_and_authority_versions');
const raceLocks = new Pool({ ...pool.options, max: 2 });
try {
for (const first of ['create', 'refresh']) {
await authorize();
const current = await source(staff); const holder = await raceLocks.connect(); const responses = [];
let results;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]);
const starts = {
create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: {
requestKey: randomUUID(), lenderId: lender.id, reason: '合成员工刷新并发还款', allocations: [{ loanId: loan.id, amount: '1.00' }] } }).then(r => r),
refresh: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${lender.id}/employee-refresh`, headers: finance.headers,
payload: refreshInput(current) }).then(r => r),
};
for (const action of [first, first === 'create' ? 'refresh' : 'create']) {
responses.push(starts[action]());
let waiting = false;
for (let i = 0; i < 200; i++) {
const count = await raceLocks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (count.rows[0].n >= responses.length) { waiting = true; break; }
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok(waiting, 'both source refresh and repayment creation must actually wait on the lender lock');
}
await holder.query('COMMIT'); results = await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
assert.deepEqual(results.map(r => r.statusCode), [200, 409], `${first} wins and the other action refuses stale or reserved data`);
assert.equal(results[1].json().error?.details.reasonCode, first === 'create' ? 'loan.lender_in_flight' : 'loan.related_scope_invalid');
const currentLender = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id, allocations: { some: { reservation_state: 'reserved' } },
OR: [{ lender_identity_version: { not: currentLender.identity_version } }, { lender_account_version: { not: currentLender.account_version } }] } }), 0);
if (first === 'create') await call(finance, 'POST', `/payment-batches/${results[0].json().data.item.id}/cancel`, { reason: '合成员工刷新交错验证结束' });
}
} finally { await raceLocks.end(); }
checks.push('employee_refresh_and_repayment_creation_both_observed_lender_lock_orders');
const rollbackStaff = await employee('审计回滚'); const rollbackSource = await source(rollbackStaff);
await pool.query(`CREATE FUNCTION synthetic_fail_employee_lender_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action IN ('LOAN_LENDER_EMPLOYEE_CREATED','LOAN_LENDER_EMPLOYEE_REFRESHED') THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_employee_lender_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_employee_lender_audit()');
try {
await link(input(rollbackStaff, rollbackSource), 500);
assert.equal(await prisma.loanLender.count({ where: { employee_id: rollbackStaff.id } }), 0);
const state = await source(staff); await refresh(lender.id, refreshInput(state), 500);
const retained = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(retained.identity_version, state.existingLender.identityVersion); assert.equal(retained.account_version, state.existingLender.accountVersion);
} finally {
await pool.query('DROP TRIGGER synthetic_employee_lender_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_employee_lender_audit()');
}
checks.push('employee_source_creation_and_refresh_audit_failure_roll_back');
const ui = [];
for (const mode of ['desktop', 'mobile']) ui.push({ mode, staff: await employee(`浏览器-${mode}`, ['manager']),
switchStaff: await employee(`浏览器切换-${mode}`), company });
console.log(JSON.stringify({ stage: 'loan_employee_http', status: 'PASS', checks }));
return { ui, checks, otherFinance: financeB };
};