feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)

这个提交包含在:
2026-02-07 21:54:29 +08:00
父节点 7ea25e9d72
当前提交 c4df9a419a
共修改 23 个文件,包含 2102 行新增和 70 行删除
+297
查看文件
@@ -0,0 +1,297 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { computeDelegationStatus } = require("../utils/delegation");
const UUID_SCHEMA = z.string().uuid();
const CREATE_DELEGATION_SCHEMA = z.object({
delegateEmployeeId: z.string().uuid(),
scope: z.enum(["reimbursement_submit", "all"]).optional(),
validFrom: z.string().datetime().optional(),
validTo: z.string().datetime().optional(),
});
const QUERY_STATUS_SCHEMA = z.object({
status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(),
});
function parseDelegationId(id) {
const parsed = UUID_SCHEMA.safeParse(id);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function parseCreateBody(body) {
const parsed = CREATE_DELEGATION_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date();
const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null;
if (Number.isNaN(validFrom.getTime())) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validFrom: "invalid_datetime" },
});
}
if (validTo && Number.isNaN(validTo.getTime())) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validTo: "invalid_datetime" },
});
}
if (validTo && validTo <= validFrom) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validTo: "must_be_after_validFrom" },
});
}
return {
delegateEmployeeId: parsed.data.delegateEmployeeId,
scope: parsed.data.scope ?? "all",
validFrom,
validTo,
};
}
function parseStatusQuery(query) {
const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.status ?? "active";
}
function matchesStatusFilter(record, status, now) {
if (status === "all") {
return true;
}
return computeDelegationStatus(record, now) === status;
}
function toResponseItem(record, now) {
return {
id: record.id,
principalEmployeeId: record.principal_employee_id,
delegateEmployeeId: record.delegate_employee_id,
scope: record.scope,
validFrom: record.valid_from.toISOString(),
validTo: record.valid_to ? record.valid_to.toISOString() : null,
revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null,
createdAt: record.created_at.toISOString(),
status: computeDelegationStatus(record, now),
};
}
async function delegationRoutes(fastify) {
fastify.post(
"/delegations",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const input = parseCreateBody(request.body);
if (input.delegateEmployeeId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { delegateEmployeeId: "self_not_allowed" },
});
}
const delegateEmployee = await fastify.prisma.employee.findUnique({
where: { id: input.delegateEmployeeId },
select: {
id: true,
status: true,
},
});
if (!delegateEmployee || delegateEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { delegateEmployeeId: "invalid_delegate" },
});
}
const delegation = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.delegationGrant.create({
data: {
principal_employee_id: request.employee.id,
delegate_employee_id: input.delegateEmployeeId,
scope: input.scope,
valid_from: input.validFrom,
valid_to: input.validTo,
created_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DELEGATION_CREATE",
targetType: "delegation_grant",
targetId: created.id,
extra: {
principalEmployeeId: request.employee.id,
delegateEmployeeId: input.delegateEmployeeId,
scope: input.scope,
},
}),
});
return created;
});
return sendData(reply, request, {
delegation: toResponseItem(delegation, new Date()),
});
},
);
fastify.get(
"/delegations/mine",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const status = parseStatusQuery(request.query);
const now = new Date();
const [initiatedRaw, receivedRaw] = await Promise.all([
fastify.prisma.delegationGrant.findMany({
where: {
principal_employee_id: request.employee.id,
},
orderBy: { created_at: "desc" },
}),
fastify.prisma.delegationGrant.findMany({
where: {
delegate_employee_id: request.employee.id,
},
orderBy: { created_at: "desc" },
}),
]);
const initiated = initiatedRaw
.filter((item) => matchesStatusFilter(item, status, now))
.map((item) => toResponseItem(item, now));
const received = receivedRaw
.filter((item) => matchesStatusFilter(item, status, now))
.map((item) => toResponseItem(item, now));
return sendData(reply, request, {
status,
initiated,
received,
});
},
);
fastify.post(
"/delegations/:id/revoke",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const delegationId = parseDelegationId(request.params?.id);
const now = new Date();
const delegation = await fastify.prisma.delegationGrant.findUnique({
where: { id: delegationId },
});
if (!delegation) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const canRevoke =
delegation.principal_employee_id === request.employee.id ||
fastify.hasPermission(request.employee, "system.settings");
if (!canRevoke) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (delegation.revoked_at) {
return sendData(reply, request, {
delegation: toResponseItem(delegation, now),
outcome: "already_revoked",
});
}
const revoked = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.delegationGrant.update({
where: { id: delegationId },
data: {
revoked_at: now,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DELEGATION_REVOKE",
targetType: "delegation_grant",
targetId: delegationId,
extra: {
principalEmployeeId: delegation.principal_employee_id,
delegateEmployeeId: delegation.delegate_employee_id,
},
}),
});
return updated;
});
return sendData(reply, request, {
delegation: toResponseItem(revoked, now),
outcome: "revoked",
});
},
);
}
module.exports = delegationRoutes;