修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
+46 -18
查看文件
@@ -100,6 +100,9 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() })
.strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
.object({ projectId: z.string().uuid().nullable() })
.strict();
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
@@ -583,16 +586,12 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
};
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
}
const isApplicant =
function ensureReimbursementParticipant(request, reimbursement) {
const isParticipant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
if (!isParticipant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
@@ -680,6 +679,19 @@ function parseReimbursementUpdateBody(body) {
return parsed.data;
}
function parseReimbursementProjectLinkBody(body) {
const parsed = REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementAttachmentDeleteBody(body) {
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
@@ -2758,7 +2770,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
@@ -2861,7 +2873,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (reimbursement.status !== "draft") {
throw new AppError({
@@ -2928,7 +2940,7 @@ async function reimbursementRoutes(fastify) {
});
}
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
@@ -3140,7 +3152,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
@@ -3617,7 +3629,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3745,7 +3757,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3866,7 +3878,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3910,6 +3922,14 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
@@ -3948,7 +3968,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -4486,7 +4506,7 @@ async function reimbursementRoutes(fastify) {
fastify.post(
"/reimbursements/:id/link-project",
{
preHandler: fastify.authenticate,
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
@@ -4500,7 +4520,7 @@ async function reimbursementRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
}
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
const body = parseReimbursementProjectLinkBody(request.body);
const current = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
@@ -4508,7 +4528,15 @@ async function reimbursementRoutes(fastify) {
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, current);
ensureReimbursementParticipant(request, current);
if (!["draft", "rejected"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_project_link" },
});
}
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },