借款出借人增加员工与公司组合数据库唯一约束(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新迁移 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
共同撰写人 Claude Opus 5.5
父节点 5469db585c
当前提交 36b949ecf6
共修改 8 个文件,包含 91 行新增和 10 行删除
+12 -6
查看文件
@@ -136,13 +136,19 @@ module.exports = async function employeeFixture(h) {
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);
// Model an old ambiguous dataset directly; this is not an application path.
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } });
assert.equal((await source(staff)).linkState, 'conflict');
await link(input(staff, await source(staff)), 409, 'loan.employee_lender_conflict');
await refresh(lender.id, refreshInput({ ...preview, existingLender: lender }), 409, 'loan.employee_lender_conflict');
await prisma.loanLender.update({ where: { id: other.id }, data: { 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');
+23 -3
查看文件
@@ -16,7 +16,10 @@ module.exports = async function employeeMutations(h, runFixture) {
// Restores the pre-fix fingerprint (updated_at selected and hashed).
['source_version_updated_at', [['status: true, archived_at: true,\n', 'status: true, archived_at: true, updated_at: true,\n'],
['"loan-employee-source-v2", legalEntityId, employee.id,', '"loan-employee-source-v2", legalEntityId, employee.id, employee.updated_at,']]],
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
// The code check is proven with the database constraint removed; with it in
// place the same mutant must be fully absorbed, reporting the same conflict.
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { withoutConstraint: true }],
['legacy_duplicate_db_backstop', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { expectAbsorbed: true }],
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
@@ -25,7 +28,8 @@ module.exports = async function employeeMutations(h, runFixture) {
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
];
const results = [];
for (const [name, from, to] of mutations) {
const INDEX = 'loan_lenders_legal_entity_id_employee_id_key';
for (const [name, from, to, options = {}] of mutations) {
const edits = Array.isArray(from) ? from : [[from, to]];
let mutated = source;
for (const [target, replacement] of edits) {
@@ -37,8 +41,24 @@ module.exports = async function employeeMutations(h, runFixture) {
mutant._compile(mutated, filename);
let failure;
Object.assign(live, mutant.exports);
if (options.withoutConstraint) await h.prisma.$executeRawUnsafe(`DROP INDEX "${INDEX}"`);
try { await runFixture(h); } catch (error) { failure = error; }
finally { Object.assign(live, original); }
finally {
Object.assign(live, original);
if (options.withoutConstraint) {
// Detach the duplicates this mutant let through, then restore the index.
await h.prisma.$executeRawUnsafe(`UPDATE loan_lenders SET employee_id = NULL WHERE id IN (SELECT id FROM (
SELECT id, row_number() OVER (PARTITION BY legal_entity_id, employee_id ORDER BY created_at, id) AS n
FROM loan_lenders WHERE employee_id IS NOT NULL) ranked WHERE n > 1)`);
await h.prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX "${INDEX}" ON loan_lenders (legal_entity_id, employee_id)`);
}
}
if (options.expectAbsorbed) {
assert.ok(!failure, `mutation ${name} was not absorbed by the database constraint: ${failure?.message?.split('\n')[0]}`);
results.push({ name, absorbedByDatabase: true, witness: 'database_constraint_backstop' });
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
continue;
}
assert.ok(failure, `mutation ${name} survived`);
// A generic environment, SQL or parsing failure is not a killed guard.
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);