feat(server): 完成 F15 邮件通知(SMTP/Nodemailer)\n\n- 新增 email 服务(EMAIL_ENABLED + SMTP_* 配置)\n- 待办/审批/付款/密码重置触发可选邮件,失败降级不阻塞\n- 更新 server/README 邮件配置说明,并推进 CSV 状态为 DONE

这个提交包含在:
2026-02-09 19:07:32 +08:00
父节点 c9ac18e2e2
当前提交 38b66b01a6
共修改 9 个文件,包含 355 行新增和 2 行删除
+85
查看文件
@@ -7,6 +7,7 @@ const {
createNotifications,
resolveRecipientsForAssignee,
} = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const {
findActiveDelegationForDelegate,
listActiveDelegationPrincipals,
@@ -727,6 +728,7 @@ async function workflowRoutes(fastify) {
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
let result = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
@@ -828,6 +830,14 @@ async function workflowRoutes(fastify) {
targetType: "workflow_task",
targetId: nextTask.id,
});
emailTasks.push({
type: "workflow.task_assigned",
recipientEmployeeIds: recipients,
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 待审批`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已流转到你,请及时处理。`,
targetPath: `/workflow/tasks/${nextTask.id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId: nextTask.id },
@@ -893,6 +903,14 @@ async function workflowRoutes(fastify) {
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.approved",
recipientEmployeeIds: recipients,
subject: `【已批准】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已审批通过,等待财务付款。`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
@@ -909,6 +927,16 @@ async function workflowRoutes(fastify) {
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, result);
},
);
@@ -922,6 +950,7 @@ async function workflowRoutes(fastify) {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
let result = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
@@ -992,6 +1021,14 @@ async function workflowRoutes(fastify) {
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.rejected",
recipientEmployeeIds: recipients,
subject: `【已驳回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已被驳回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
@@ -1007,6 +1044,16 @@ async function workflowRoutes(fastify) {
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, result);
},
);
@@ -1020,6 +1067,7 @@ async function workflowRoutes(fastify) {
const taskId = parseTaskId(request.params?.taskId);
const comment = parseRequiredComment(request.body);
let result = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
@@ -1090,6 +1138,14 @@ async function workflowRoutes(fastify) {
targetType: "reimbursement_form",
targetId: workflowInstance.entity_id,
});
emailTasks.push({
type: "reimbursement.returned",
recipientEmployeeIds: recipients,
subject: `【已退回】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id}`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已退回:${comment}`,
targetPath: `/reimbursements/${workflowInstance.entity_id}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
@@ -1105,6 +1161,16 @@ async function workflowRoutes(fastify) {
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, result);
},
);
@@ -1118,6 +1184,7 @@ async function workflowRoutes(fastify) {
const taskId = parseTaskId(request.params?.taskId);
const { toApproverId, reason } = parseTransferBody(request.body);
let result = null;
const emailTasks = [];
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
@@ -1189,6 +1256,14 @@ async function workflowRoutes(fastify) {
targetType: "workflow_task",
targetId: taskId,
});
emailTasks.push({
type: "workflow.task_assigned",
recipientEmployeeIds: [toApproverId],
subject: `【待办】报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 转审待处理`,
body: `报销单 ${reimbursement?.form_number ?? workflowInstance.entity_id} 已转审给你,请及时处理。`,
targetPath: `/workflow/tasks/${taskId}`,
});
} catch (error) {
fastify.log.error(
{ err: error, taskId, toApproverId },
@@ -1208,6 +1283,16 @@ async function workflowRoutes(fastify) {
};
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, result);
},
);