文件
hyb-oa/server/src/routes/riskFlags.js
T
wangsli 64d9ded024 R52 A1-A6:完成 AP 运营深度操作
- AP 概览页 Tab 工作台化(待付款/异常付款/风险标记/对账异常)
- AP 导出端点支持 CSV/XLSX 与筛选
- 风险标记 resolve/dismiss 支持说明审计留痕
- 支付批次深链、高风险复核提示、对账确认权限收口
- OpenClaw 浏览器级回归全部通过
2026-04-07 09:37:17 +08:00

140 行
4.4 KiB
JavaScript

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(),
});
const ACTION_BODY_SCHEMA = z.object({
note: z.string().trim().max(500).nullable().optional(),
}).strict();
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;
}
function parseBody(body) {
const parsed = ACTION_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeNote(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed || null;
}
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 body = parseBody(request.body);
const note = normalizeNote(body.note);
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,
note,
},
}),
});
return sendData(reply, request, { item: buildPersistedRiskFlag(updated) });
});
}
}
module.exports = riskFlagsRoutes;