From 5faf843ceebfa22ed318f820981c80f31b0f423d Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 17:45:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E7=AB=99=E5=86=85=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E4=B8=8E=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E4=BA=8B=E4=BB=B6=E8=A7=A6=E5=8F=91(F14,F30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 notifications 表与 API(list/mark-read) - submit/approve/reject/return/transfer/pay/forgot-password 写入站内通知 - 通知失败降级,不阻塞主流程 --- .../migration.sql | 23 +++ server/prisma/schema.prisma | 19 ++ server/src/app.js | 2 + server/src/routes/auth.js | 33 +++ server/src/routes/notifications.js | 195 ++++++++++++++++++ server/src/routes/reimbursements.js | 47 ++++- server/src/routes/workflow.js | 121 +++++++++++ server/src/services/notifications/index.js | 56 +++++ 8 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 server/prisma/migrations/20260209091354_add_notifications_table/migration.sql create mode 100644 server/src/routes/notifications.js create mode 100644 server/src/services/notifications/index.js diff --git a/server/prisma/migrations/20260209091354_add_notifications_table/migration.sql b/server/prisma/migrations/20260209091354_add_notifications_table/migration.sql new file mode 100644 index 00000000..095bee73 --- /dev/null +++ b/server/prisma/migrations/20260209091354_add_notifications_table/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "notifications" ( + "id" UUID NOT NULL, + "recipient_employee_id" UUID NOT NULL, + "type" VARCHAR(64) NOT NULL, + "title" VARCHAR(200) NOT NULL, + "body" TEXT, + "target_type" VARCHAR(64), + "target_id" UUID, + "read_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifications_recipient_employee_id_created_at_idx" ON "notifications"("recipient_employee_id", "created_at"); + +-- CreateIndex +CREATE INDEX "idx_notifications_unread_query" ON "notifications"("recipient_employee_id", "read_at", "created_at"); + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipient_employee_id_fkey" FOREIGN KEY ("recipient_employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index af3a7d83..03211164 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -172,6 +172,7 @@ model Employee { auth_sessions AuthSession[] audit_logs AuditLog[] @relation("AuditActor") + notifications Notification[] submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter") owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner") @@ -286,6 +287,24 @@ model AuditLog { @@map("audit_logs") } +model Notification { + id String @id @default(uuid()) @db.Uuid + recipient_employee_id String @db.Uuid + type String @db.VarChar(64) + title String @db.VarChar(200) + body String? + target_type String? @db.VarChar(64) + target_id String? @db.Uuid + read_at DateTime? + created_at DateTime @default(now()) + + recipient Employee @relation(fields: [recipient_employee_id], references: [id], onDelete: Cascade) + + @@index([recipient_employee_id, created_at]) + @@index([recipient_employee_id, read_at, created_at], map: "idx_notifications_unread_query") + @@map("notifications") +} + model ExpenseType { id String @id @default(uuid()) @db.Uuid code String @unique diff --git a/server/src/app.js b/server/src/app.js index fd6c553f..adea3e09 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -26,6 +26,7 @@ const departmentsRoutes = require("./routes/departments"); const employeesRoutes = require("./routes/employees"); const expenseTypesRoutes = require("./routes/expenseTypes"); const configReadRoutes = require("./routes/configRead"); +const notificationsRoutes = require("./routes/notifications"); const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); const { startOcrJob } = require("./jobs/ocrJob"); const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob"); @@ -200,6 +201,7 @@ async function buildApp() { await app.register(departmentsRoutes, { prefix: "/api" }); await app.register(expenseTypesRoutes, { prefix: "/api" }); await app.register(configReadRoutes, { prefix: "/api" }); + await app.register(notificationsRoutes, { prefix: "/api" }); startDelegationRevokeJob(app); startOcrJob(app); diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index b0f97775..9dafadc3 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -4,6 +4,7 @@ const { buildAuditLogData } = require("../utils/audit"); const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto"); const { validatePassword } = require("../utils/passwordPolicy"); const { sendData } = require("../utils/reply"); +const { createNotifications } = require("../services/notifications"); function getHeaderString(request, name) { const value = request.headers[name]; @@ -661,6 +662,22 @@ async function authRoutes(fastify) { }), }); + try { + await createNotifications(fastify.prisma, { + recipientEmployeeIds: [employee.id], + type: "auth.password_reset_requested", + title: "【安全】收到密码重置请求", + body: "如非本人操作,请尽快联系管理员核查账号安全。", + targetType: "employee", + targetId: employee.id, + }); + } catch (error) { + fastify.log.error( + { err: error, employeeId: employee.id, requestId: request.id }, + "Failed to create password reset requested notifications", + ); + } + if (fastify.config.devShowPasswordResetCode) { fastify.log.info( { employeeId: employee.id, email, code, requestId: request.id }, @@ -763,6 +780,22 @@ async function authRoutes(fastify) { extra: {}, }), }); + + try { + await createNotifications(tx, { + recipientEmployeeIds: [employee.id], + type: "auth.password_reset_succeeded", + title: "【安全】密码已重置", + body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。", + targetType: "employee", + targetId: employee.id, + }); + } catch (error) { + fastify.log.error( + { err: error, employeeId: employee.id, requestId: request.id }, + "Failed to create password reset succeeded notifications", + ); + } }); reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config)); diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js new file mode 100644 index 00000000..5ad05b9a --- /dev/null +++ b/server/src/routes/notifications.js @@ -0,0 +1,195 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { sendData } = require("../utils/reply"); + +const LIST_QUERY_SCHEMA = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), + unreadOnly: z.enum(["0", "1", "true", "false"]).optional(), +}); + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: parsed.error.flatten(), + }); + } + + const page = parsed.data.page ?? 1; + const pageSize = parsed.data.pageSize ?? 20; + const unreadOnlyRaw = parsed.data.unreadOnly ?? "0"; + const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true"; + + return { + page, + pageSize, + unreadOnly, + }; +} + +function parseNotificationId(value) { + const parsed = z.string().uuid().safeParse(value); + if (!parsed.success) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { id: "invalid_uuid" }, + }); + } + return parsed.data; +} + +function mapNotificationItem(record) { + return { + id: record.id, + type: record.type, + title: record.title, + body: record.body ?? null, + targetType: record.target_type ?? null, + targetId: record.target_id ?? null, + readAt: record.read_at ? record.read_at.toISOString() : null, + createdAt: record.created_at.toISOString(), + }; +} + +async function notificationsRoutes(fastify) { + fastify.get( + "/notifications", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const skip = (query.page - 1) * query.pageSize; + + const where = { + recipient_employee_id: request.employee.id, + ...(query.unreadOnly ? { read_at: null } : {}), + }; + + const [items, total, unreadCount] = await Promise.all([ + fastify.prisma.notification.findMany({ + where, + orderBy: { created_at: "desc" }, + skip, + take: query.pageSize, + select: { + id: true, + type: true, + title: true, + body: true, + target_type: true, + target_id: true, + read_at: true, + created_at: true, + }, + }), + fastify.prisma.notification.count({ where }), + fastify.prisma.notification.count({ + where: { + recipient_employee_id: request.employee.id, + read_at: null, + }, + }), + ]); + + return sendData(reply, request, { + page: query.page, + pageSize: query.pageSize, + total, + unreadOnly: query.unreadOnly, + unreadCount, + items: items.map(mapNotificationItem), + }); + }, + ); + + fastify.post( + "/notifications/:id/mark-read", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const notificationId = parseNotificationId(request.params?.id); + + const notification = await fastify.prisma.$transaction(async (tx) => { + const locked = await tx.$queryRaw` + SELECT id + FROM "notifications" + WHERE id = ${notificationId}::uuid + FOR UPDATE + `; + if (!Array.isArray(locked) || locked.length === 0) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const current = await tx.notification.findUnique({ + where: { id: notificationId }, + select: { + id: true, + recipient_employee_id: true, + read_at: true, + type: true, + title: true, + body: true, + target_type: true, + target_id: true, + created_at: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + if (current.recipient_employee_id !== request.employee.id) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + if (current.read_at) { + return current; + } + + return tx.notification.update({ + where: { id: notificationId }, + data: { + read_at: new Date(), + }, + select: { + id: true, + type: true, + title: true, + body: true, + target_type: true, + target_id: true, + read_at: true, + created_at: true, + }, + }); + }); + + return sendData(reply, request, mapNotificationItem(notification)); + }, + ); +} + +module.exports = notificationsRoutes; + diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 39307038..98ca43f8 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -4,6 +4,10 @@ const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date"); const { findActiveDelegationForDelegate } = require("../utils/delegation"); +const { + createNotifications, + resolveRecipientsForAssignee, +} = require("../services/notifications"); const { buildWorkflowStepsFromApprovalRule, resolveActionableStep, @@ -1347,6 +1351,27 @@ async function reimbursementRoutes(fastify) { }, }); + try { + const recipients = await resolveRecipientsForAssignee(tx, { + assigneeId: workflowTask.assignee_id, + assigneeRole: workflowTask.assignee_role, + }); + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "workflow.task_assigned", + title: `【待办】报销单 ${formNumber} 待审批`, + body: `报销单 ${formNumber} 已提交,请及时处理。`, + targetType: "workflow_task", + targetId: workflowTask.id, + }); + } catch (error) { + fastify.log.error( + { err: error, reimbursementId, taskId: workflowTask.id }, + "Failed to create task assigned notifications", + ); + } + if (reimbursement.submitter_id !== reimbursement.owner_id) { await tx.auditLog.create({ data: buildAuditLogData(request, { @@ -1524,7 +1549,8 @@ async function reimbursementRoutes(fastify) { await fastify.prisma.$transaction(async (tx) => { const reimbursementRows = await tx.$queryRaw` - SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by + SELECT id, status, form_number, owner_id, submitter_id, + payment_voucher_no, payment_date, payment_channel, paid_at, paid_by FROM "reimbursement_forms" WHERE id = ${reimbursementId}::uuid FOR UPDATE @@ -1596,6 +1622,25 @@ async function reimbursementRoutes(fastify) { updated_by: request.employee.id, }, }); + + try { + const recipients = Array.from( + new Set([reimbursement.owner_id, reimbursement.submitter_id].filter(Boolean)), + ); + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "reimbursement.paid", + title: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`, + body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`, + targetType: "reimbursement_form", + targetId: reimbursementId, + }); + } catch (error) { + fastify.log.error( + { err: error, reimbursementId }, + "Failed to create reimbursement paid notifications", + ); + } } response = { diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index f53ddd73..a75bffb4 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions"); const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow"); +const { + createNotifications, + resolveRecipientsForAssignee, +} = require("../services/notifications"); const { findActiveDelegationForDelegate, listActiveDelegationPrincipals, @@ -312,6 +316,18 @@ async function loadWorkflowInstanceForTask(tx, instanceId) { return workflowInstance; } +async function loadReimbursementNotifyContext(tx, reimbursementId) { + return tx.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { + id: true, + form_number: true, + owner_id: true, + submitter_id: true, + }, + }); +} + async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) { for (const skipped of skippedSteps) { await tx.workflowHistory.create({ @@ -794,6 +810,31 @@ async function workflowRoutes(fastify) { }, }); + try { + const reimbursement = await loadReimbursementNotifyContext( + tx, + workflowInstance.entity_id, + ); + const recipients = await resolveRecipientsForAssignee(tx, { + assigneeId: actionable.assigneeId, + assigneeRole: actionable.assigneeRole, + }); + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "workflow.task_assigned", + title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`, + targetType: "workflow_task", + targetId: nextTask.id, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId: nextTask.id }, + "Failed to create task assigned notifications", + ); + } + result = { taskId, outcome: "approved", @@ -838,6 +879,27 @@ async function workflowRoutes(fastify) { }, }); + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "reimbursement.approved", + title: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`, + targetType: "reimbursement_form", + targetId: workflowInstance.entity_id, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement approved notifications", + ); + } + result = { taskId, outcome: "approved", @@ -916,6 +978,27 @@ async function workflowRoutes(fastify) { }, }); + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "reimbursement.rejected", + title: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`, + targetType: "reimbursement_form", + targetId: workflowInstance.entity_id, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement rejected notifications", + ); + } + result = { taskId, outcome: "rejected", @@ -993,6 +1076,27 @@ async function workflowRoutes(fastify) { }, }); + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + const recipients = Array.from( + new Set([reimbursement?.owner_id, reimbursement?.submitter_id].filter(Boolean)), + ); + + await createNotifications(tx, { + recipientEmployeeIds: recipients, + type: "reimbursement.returned", + title: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`, + targetType: "reimbursement_form", + targetId: workflowInstance.entity_id, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, reimbursementId: workflowInstance.entity_id }, + "Failed to create reimbursement returned notifications", + ); + } + result = { taskId, outcome: "returned", @@ -1075,6 +1179,23 @@ async function workflowRoutes(fastify) { }, }); + try { + const reimbursement = await loadReimbursementNotifyContext(tx, workflowInstance.entity_id); + await createNotifications(tx, { + recipientEmployeeIds: [toApproverId], + type: "workflow.task_assigned", + title: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`, + body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`, + targetType: "workflow_task", + targetId: taskId, + }); + } catch (error) { + fastify.log.error( + { err: error, taskId, toApproverId }, + "Failed to create task transferred notifications", + ); + } + result = { taskId, outcome: "transferred", diff --git a/server/src/services/notifications/index.js b/server/src/services/notifications/index.js new file mode 100644 index 00000000..23beedfe --- /dev/null +++ b/server/src/services/notifications/index.js @@ -0,0 +1,56 @@ +function uniq(values) { + return Array.from(new Set((values ?? []).filter(Boolean))); +} + +async function resolveRecipientsForAssignee(tx, params) { + if (params.assigneeId) { + return [params.assigneeId]; + } + + if (params.assigneeRole) { + const rows = await tx.employeeRole.findMany({ + where: { + role_code: params.assigneeRole, + employee: { + status: "active", + }, + }, + select: { + employee_id: true, + }, + }); + + return rows.map((row) => row.employee_id); + } + + return []; +} + +async function createNotifications(tx, input) { + const recipientIds = uniq(input.recipientEmployeeIds); + if (recipientIds.length === 0) { + return { count: 0 }; + } + + const title = String(input.title ?? "").slice(0, 200); + const body = input.body != null ? String(input.body).slice(0, 5000) : null; + const type = String(input.type ?? "").slice(0, 64); + const targetType = input.targetType != null ? String(input.targetType).slice(0, 64) : null; + + return tx.notification.createMany({ + data: recipientIds.map((recipientId) => ({ + recipient_employee_id: recipientId, + type, + title, + body, + target_type: targetType, + target_id: input.targetId ?? null, + })), + }); +} + +module.exports = { + createNotifications, + resolveRecipientsForAssignee, +}; +