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 行删除
+127
查看文件
@@ -0,0 +1,127 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/));
const QUERY_SCHEMA = z.object({ manage: z.enum(["0", "1", "true", "false"]).optional() });
const WRITE_SCHEMA = z.object({
title: z.string().trim().min(1).max(120),
summary: z.string().trim().min(1).max(500),
linkUrl: z.string().trim().url().nullable().optional(),
startsAt: DATE_TIME_SCHEMA.nullable().optional(),
endsAt: DATE_TIME_SCHEMA.nullable().optional(),
enabled: z.boolean().optional(),
priority: z.coerce.number().int().min(1).max(9999).optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" });
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
return parsed.data;
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.announcements, strictDepartment: false });
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409);
}
}
function mapItem(item) {
return {
id: item.id,
title: item.title,
summary: item.summary,
linkUrl: item.link_url ?? null,
startsAt: item.starts_at?.toISOString?.() ?? null,
endsAt: item.ends_at?.toISOString?.() ?? null,
enabled: item.enabled,
priority: item.priority,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
async function announcementsRoutes(fastify) {
fastify.get("/announcements", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const queryParsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
const query = queryParsed.data;
const isManage = query.manage === "1" || query.manage === "true";
const now = new Date();
const where = isManage && fastify.hasPermission(request.employee, "system.settings")
? {}
: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
};
const items = await fastify.prisma.portalAnnouncement.findMany({
where,
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
});
return sendData(reply, request, { items: items.map(mapItem) });
});
fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.portalAnnouncement.create({
data: {
title: body.title,
summary: body.summary,
link_url: body.linkUrl ?? null,
starts_at: body.startsAt ? new Date(body.startsAt) : null,
ends_at: body.endsAt ? new Date(body.endsAt) : null,
enabled: body.enabled ?? true,
priority: body.priority ?? 100,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_CREATED", targetType: "portal_announcement", targetId: created.id }) });
return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created) } });
});
fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const announcementId = parseId(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const updated = await fastify.prisma.portalAnnouncement.update({
where: { id: announcementId },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.linkUrl !== undefined ? { link_url: body.linkUrl ?? null } : {}),
...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}),
...(body.endsAt !== undefined ? { ends_at: body.endsAt ? new Date(body.endsAt) : null } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapItem(updated) });
});
fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const announcementId = parseId(request.params?.id);
const updated = await fastify.prisma.portalAnnouncement.update({
where: { id: announcementId },
data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id },
});
return sendData(reply, request, { item: mapItem(updated) });
});
}
module.exports = announcementsRoutes;