借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-09 21:03:07 +08:00
共同撰写人 Claude Opus 5.5
父节点 c1034bbe38
当前提交 45737ccb9a
共修改 20 个文件,包含 1157 行新增和 28 行删除
+5
查看文件
@@ -1,4 +1,9 @@
const ERROR_REASONS = [
{"code":"loan.employee_source_incomplete","domain":"loan","audience":"user","defaultMessage":"员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。","recovery":"follow_guidance"},
{"code":"loan.employee_source_changed","domain":"loan","audience":"user","defaultMessage":"员工资料已变化,请重新加载并核对后再保存。","recovery":"follow_guidance"},
{"code":"loan.employee_lender_conflict","domain":"loan","audience":"user","defaultMessage":"该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。","recovery":"follow_guidance"},
{"code":"loan.employee_lender_refresh_required","domain":"loan","audience":"user","defaultMessage":"已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。","recovery":"follow_guidance"},
{"code":"loan.employee_account_managed","domain":"loan","audience":"user","defaultMessage":"员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。","recovery":"follow_guidance"},
{"code":"loan.related_authorization_required","domain":"loan","audience":"user","defaultMessage":"本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。","recovery":"follow_guidance"},
{"code":"loan.related_scope_invalid","domain":"loan","audience":"user","defaultMessage":"关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。","recovery":"follow_guidance"},
{"code":"loan.related_actor_conflict","domain":"loan","audience":"user","defaultMessage":"关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。","recovery":"follow_guidance"},
+29 -2
查看文件
@@ -14,12 +14,14 @@ const {
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
const approvalPolicy = require("../services/loanApprovalPolicy");
const employeeLenders = require("../services/loanLenderEmployee");
const UUID = z.string().uuid();
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
const MONEY = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/);
const TEXT = z.string().trim().min(2).max(500);
const VERSION = z.number().int().min(1).max(2147483646);
const EMPLOYEE_SOURCE = z.object({ expectedSourceVersion: z.string().regex(/^[a-f0-9]{64}$/), detailsConfirmed: z.literal(true) });
const ACCOUNT = z.object({
bankInstitutionId: UUID, bankName: z.string().trim().min(1).max(200),
bankAccount: z.string().regex(/^\d{10,30}$/), accountName: z.string().trim().min(1).max(120),
@@ -131,8 +133,30 @@ async function companyLoansRoutes(fastify) {
const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {});
const items = await fastify.prisma.employee.findMany({ where: { status: "active", archived_at: null,
...(query.q ? { OR: [{ name: { contains: query.q, mode: "insensitive" } }, { employee_no: { contains: query.q } }] } : {}),
}, select: { id: true, name: true, employee_no: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no })) });
}, select: { id: true, name: true, employee_no: true, legal_entity_id: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no, legalEntityId: item.legal_entity_id })) });
});
fastify.get("/loan-lenders/employee-source/:employeeId", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const employeeId = parse(UUID, request.params.employeeId);
const query = parse(z.object({ legalEntityId: UUID }).strict(), request.query ?? {});
const preview = await employeeLenders.previewEmployeeLender(fastify.prisma, fastify.config, employeeId, query.legalEntityId);
reply.header("Cache-Control", "no-store");
return sendData(reply, request, preview);
});
fastify.post("/loan-lenders/from-employee", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const body = parse(EMPLOYEE_SOURCE.extend({ requestKey: UUID, employeeId: UUID, legalEntityId: UUID }).strict(), request.body);
const result = await employeeLenders.linkEmployeeLender(fastify.prisma, fastify.config, request, body);
const inFlight = await inFlightLenderIds(fastify.prisma, [result.lender.id]);
return sendData(reply, request, { lender: mapLender(fastify.config, result.lender, { inFlight: inFlight.has(result.lender.id) }), reused: result.reused });
});
fastify.post("/loan-lenders/:id/employee-refresh", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const id = parse(UUID, request.params.id);
const body = parse(EMPLOYEE_SOURCE.extend({ expectedIdentityVersion: VERSION, expectedAccountVersion: VERSION, reason: TEXT }).strict(), request.body);
const result = await fastify.prisma.$transaction((tx) => employeeLenders.refreshEmployeeLender(tx, fastify.config, request, id, body));
return sendData(reply, request, mapLender(fastify.config, result));
});
fastify.get("/loan-lenders", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
@@ -177,9 +201,11 @@ async function companyLoansRoutes(fastify) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.identity_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
await employeeLenders.lockEmployeeIdentities(tx, [lender.employee_id, body.employeeId]);
if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) {
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
}
await employeeLenders.assertUniqueEmployeeLender(tx, body.employeeId, lender.legal_entity_id, id);
const result = await tx.loanLender.update({ where: { id }, data: {
employee_id: body.employeeId, identity_status: "verified", identity_version: { increment: 1 },
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
@@ -201,6 +227,7 @@ async function companyLoansRoutes(fastify) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.account_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
if (lender.employee_id) throw buildBusinessValidationError("loan.employee_account_managed", {}, 409);
await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id });
const result = await tx.loanLender.update({ where: { id }, data: {
bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body),
+201
查看文件
@@ -0,0 +1,201 @@
const { createHash } = require("node:crypto");
const { Prisma } = require("@prisma/client");
const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow, maskBankAccount } = require("./crypto");
const {
LENDER_INCLUDE, ensureLoanManager, lockLender, assertLenderNotInFlight,
assertCurrentVersion, encryptLenderAccount, readLenderAccount, mapLender, auditLoan, inFlightLenderIds,
} = require("./companyLoans");
function ensureEmployeeBankAccess(fastify, request) {
ensureLoanManager(fastify, request);
if (!fastify.hasPermission(request.employee, "employee.bank.read") &&
!fastify.hasPermission(request.employee, "employee.bank.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
// Existing-lender actions always acquire the lender before these personnel locks.
// Creation has no existing lender to lock and must never acquire one afterwards.
// Non-key updates still serialize source changes and competing associations,
// while allowing audit/actor foreign keys to acquire their implicit KEY SHARE.
async function lockEmployeeIdentities(tx, ids) {
const unique = [...new Set(ids.filter(Boolean))].sort();
if (unique.length) await tx.$queryRaw`SELECT id FROM employees
WHERE id IN (${Prisma.join(unique.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR NO KEY UPDATE`;
}
async function linkedLenders(db, employeeId, legalEntityId) {
// Archived or ambiguous historical links also block a second registration.
return db.loanLender.findMany({ where: { employee_id: employeeId, legal_entity_id: legalEntityId },
include: LENDER_INCLUDE, orderBy: { id: "asc" }, take: 2 });
}
async function assertUniqueEmployeeLender(tx, employeeId, legalEntityId, lenderId) {
if (!employeeId) return;
const links = await linkedLenders(tx, employeeId, legalEntityId);
if (links.some((lender) => lender.id !== lenderId)) {
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
}
async function readEmployeeSource(db, config, employeeId, legalEntityId) {
const employee = await db.employee.findUnique({ where: { id: employeeId }, select: {
id: true, name: true, employee_no: true, legal_entity_id: true, status: true, archived_at: true, updated_at: true,
bank_name: true, bank_institution_id: true, bank_account_encrypted: true,
legal_entity: { select: { name: true } },
bank_institution: { select: { id: true, code: true, name: true, enabled: true, archived_at: true } },
} });
const company = await db.legalEntity.findFirst({ where: { id: legalEntityId, enabled: true, archived_at: null, currency_code: "CNY" } });
if (!employee || employee.status !== "active" || employee.archived_at || !company) {
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
}
const name = employee.name?.trim() ?? "";
const bankName = employee.bank_name?.trim() ?? "";
const bank = employee.bank_institution;
const problems = [];
if (!name || name.length > 120) problems.push("name_invalid");
if (!bank || !bankName || bankName.length > 200) problems.push("bank_missing");
if (bank && (bank.code !== "CCB" || !bank.enabled || bank.archived_at)) problems.push("bank_unsupported");
let bankAccount = null;
if (!employee.bank_account_encrypted) problems.push("account_missing");
else {
try {
bankAccount = decryptStringOrThrow(config, employee.bank_account_encrypted);
if (!bankAccount) problems.push("account_unavailable");
else if (!/^\d{10,30}$/.test(bankAccount)) problems.push("account_invalid");
} catch { problems.push("account_unavailable"); }
}
// Bind to all source fields, not just the millisecond timestamp. Ciphertext is
// hashed without exposing it or making a plaintext account hash enumerable.
const sourceVersion = createHash("sha256").update(JSON.stringify([
"loan-employee-source-v1", legalEntityId, employee.id, employee.updated_at,
employee.name, employee.legal_entity_id, employee.status, employee.archived_at,
employee.bank_name, employee.bank_institution_id, employee.bank_account_encrypted, bank,
])).digest("hex");
return { employee, company, name, bankName, bankAccount, bankInstitutionId: bank?.id, problems, sourceVersion };
}
function assertUsableSource(source, expectedSourceVersion) {
if (source.sourceVersion !== expectedSourceVersion) {
throw buildBusinessValidationError("loan.employee_source_changed", {}, 409);
}
if (source.problems.length) throw buildBusinessValidationError("loan.employee_source_incomplete", {}, 409);
}
function matchesSource(config, lender, source) {
if (lender.identity_status !== "verified" || lender.name !== source.name ||
lender.bank_institution_id !== source.bankInstitutionId || lender.bank_name !== source.bankName) return false;
try {
const account = readLenderAccount(config, lender);
return account.bankAccount === source.bankAccount && account.accountName === source.name;
} catch { return false; }
}
async function previewEmployeeLender(db, config, employeeId, legalEntityId) {
const source = await readEmployeeSource(db, config, employeeId, legalEntityId);
const links = await linkedLenders(db, employeeId, legalEntityId);
const conflict = links.length > 1 || Boolean(links[0]?.archived_at);
const existing = !conflict ? links[0] : null;
const inFlight = existing ? await inFlightLenderIds(db, [existing.id]) : new Set();
return {
employee: { id: source.employee.id, name: source.name, employeeNo: source.employee.employee_no,
legalEntityId: source.employee.legal_entity_id, legalEntityName: source.employee.legal_entity?.name ?? null },
legalEntityId, legalEntityName: source.company.name, sourceVersion: source.sourceVersion,
bankName: source.bankName, bankAccountMasked: source.problems.some((p) => p.startsWith("account_"))
? null : maskBankAccount(source.bankAccount), problems: source.problems,
linkState: conflict ? "conflict" : !existing ? "new" : matchesSource(config, existing, source) ? "reusable" : "refresh_required",
existingLender: existing ? mapLender(config, existing, { inFlight: inFlight.has(existing.id) }) : null,
};
}
async function linkEmployeeLender(prisma, config, request, input) {
// A link may appear while creation waits on the employee lock. Finish that
// transaction, then reacquire in lender -> employee order; never invert it.
for (let attempt = 0; attempt < 2; attempt++) {
const before = await linkedLenders(prisma, input.employeeId, input.legalEntityId);
if (before.length > 1 || before[0]?.archived_at) throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
try {
const result = await prisma.$transaction(async (tx) => {
const lender = before[0] ? await lockLender(tx, before[0].id) : null;
// Also serialize this actor's request keys across different employees.
await lockEmployeeIdentities(tx, [input.employeeId, request.employee.id]);
const source = await readEmployeeSource(tx, config, input.employeeId, input.legalEntityId);
assertUsableSource(source, input.expectedSourceVersion);
const links = await linkedLenders(tx, input.employeeId, input.legalEntityId);
if (links.length > 1 || links[0]?.archived_at || lender && links[0]?.id !== lender.id) {
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
if (!lender && links.length) return null;
const requestBinding = { employeeId: input.employeeId, legalEntityId: input.legalEntityId,
sourceVersion: input.expectedSourceVersion };
const previousRequest = await tx.auditLog.findFirst({ where: { actor_id: request.employee.id,
action: { in: ["LOAN_LENDER_EMPLOYEE_CREATED", "LOAN_LENDER_EMPLOYEE_REUSED"] },
extra: { path: ["employeeSourceRequestKey"], equals: input.requestKey },
}, select: { target_id: true, extra: true } });
if (previousRequest && (previousRequest.target_id !== lender?.id ||
Object.entries(requestBinding).some(([key, value]) => previousRequest.extra[key] !== value))) {
throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
}
const keyed = await tx.loanLender.findUnique({ where: {
created_by_request_key: { created_by: request.employee.id, request_key: input.requestKey },
} });
if (keyed && keyed.id !== lender?.id) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
if (lender) {
if (!matchesSource(config, lender, source)) throw buildBusinessValidationError("loan.employee_lender_refresh_required", {}, 409);
if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {
...requestBinding, employeeSourceRequestKey: input.requestKey,
});
return { lender, reused: true };
}
const created = await tx.loanLender.create({ data: {
legal_entity_id: input.legalEntityId, employee_id: input.employeeId, name: source.name,
bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
identity_status: "verified", identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
identity_confirmation: "财务已核对所选员工、借款所属公司及员工收款资料",
created_by: request.employee.id, updated_by: request.employee.id, request_key: input.requestKey,
}, include: LENDER_INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {
...requestBinding, employeeSourceRequestKey: input.requestKey,
});
return { lender: created, reused: false };
});
if (result) return result;
} catch (error) {
if (error?.code === "P2002") throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
throw error;
}
}
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
async function refreshEmployeeLender(tx, config, request, id, input) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
assertCurrentVersion(lender.account_version, input.expectedAccountVersion);
await assertLenderNotInFlight(tx, id);
if (!lender.employee_id || lender.archived_at) throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
await lockEmployeeIdentities(tx, [lender.employee_id]);
await assertUniqueEmployeeLender(tx, lender.employee_id, lender.legal_entity_id, lender.id);
const source = await readEmployeeSource(tx, config, lender.employee_id, lender.legal_entity_id);
assertUsableSource(source, input.expectedSourceVersion);
const result = await tx.loanLender.update({ where: { id }, data: {
name: source.name, bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
identity_version: { increment: 1 }, account_version: { increment: 1 }, identity_status: "verified",
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(), identity_confirmation: input.reason,
updated_by: request.employee.id,
}, include: LENDER_INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REFRESHED", id, {
employeeId: lender.employee_id, sourceVersion: source.sourceVersion,
identityVersion: result.identity_version, accountVersion: result.account_version, reason: input.reason,
});
return result;
}
module.exports = {
ensureEmployeeBankAccess, lockEmployeeIdentities, assertUniqueEmployeeLender,
previewEmployeeLender, linkEmployeeLender, refreshEmployeeLender,
};