feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力

关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+117
查看文件
@@ -0,0 +1,117 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { buildPersistedRiskFlag } = require("../services/feeControl");
const UUID_SCHEMA = z.string().uuid();
const QUERY_SCHEMA = z.object({
entityType: z.string().trim().max(32).optional(),
entityId: UUID_SCHEMA.optional(),
status: z.enum(["open", "resolved", "dismissed"]).optional(),
source: z.string().trim().max(32).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function hasListPermission(fastify, request) {
return (
fastify.hasPermission(request.employee, "budget.view") ||
fastify.hasPermission(request.employee, "payment.batch.manage") ||
fastify.hasPermission(request.employee, "risk.review.manage")
);
}
function ensureListPermission(fastify, request) {
if (!hasListPermission(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureManagePermission(fastify, request) {
if (!fastify.hasPermission(request.employee, "risk.review.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function parseQuery(query) {
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
async function riskFlagsRoutes(fastify) {
fastify.get("/risk-flags", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureListPermission(fastify, request);
const query = parseQuery(request.query);
const items = await fastify.prisma.entityRiskFlag.findMany({
where: {
...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.entityId ? { entity_id: query.entityId } : {}),
...(query.status ? { status: query.status } : {}),
...(query.source ? { source: query.source } : {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, { items: items.map(buildPersistedRiskFlag) });
});
for (const action of ["resolve", "dismiss"]) {
fastify.post(`/risk-flags/:id/${action}`, { preHandler: fastify.authenticate }, async (request, reply) => {
ensureManagePermission(fastify, request);
const riskFlagId = parseId(request.params?.id);
const current = await fastify.prisma.entityRiskFlag.findUnique({ where: { id: riskFlagId } });
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (current.status !== "open") {
throw buildValidationError({ status: "risk_flag_not_open" }, 409);
}
const updated = await fastify.prisma.entityRiskFlag.update({
where: { id: riskFlagId },
data: {
status: action === "resolve" ? "resolved" : "dismissed",
resolved_at: new Date(),
resolved_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: action === "resolve" ? "ENTITY_RISK_FLAG_RESOLVED" : "ENTITY_RISK_FLAG_DISMISSED",
targetType: "entity_risk_flag",
targetId: updated.id,
extra: {
entityType: updated.entity_type,
entityId: updated.entity_id,
riskCode: updated.risk_code,
},
}),
});
return sendData(reply, request, { item: buildPersistedRiskFlag(updated) });
});
}
}
module.exports = riskFlagsRoutes;