feat(server): 完成 F15 邮件通知(SMTP/Nodemailer)\n\n- 新增 email 服务(EMAIL_ENABLED + SMTP_* 配置)\n- 待办/审批/付款/密码重置触发可选邮件,失败降级不阻塞\n- 更新 server/README 邮件配置说明,并推进 CSV 状态为 DONE
这个提交包含在:
@@ -13,7 +13,7 @@ F11,OCR失败重试API(次数/频控),"新增 POST /api/invoice-pool/:id/ret
|
|||||||
F12,90天未绑定自动归档(unused_timeout),"新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重","作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径","cd server && ./scripts/regression-ocr.sh",manual,DONE,DONE,DONE,"server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md","[""F11""]","后续迁移到 pg-boss;当前可先 node-cron 实现"
|
F12,90天未绑定自动归档(unused_timeout),"新增作业:available/ocr_failed 超过 90 天未绑定自动归档为 archived_reason=unused_timeout;并保证不参与判重","作业可重复运行幂等;归档后仍可查询/预览;不影响全局判重历史口径","cd server && ./scripts/regression-ocr.sh",manual,DONE,DONE,DONE,"server/src/jobs/invoiceArchiveJob.js | server/src/app.js | server/README.md","[""F11""]","后续迁移到 pg-boss;当前可先 node-cron 实现"
|
||||||
F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文)","无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/routes/audit.js","[""F1""]","extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息"
|
F13,signed-url审计与下载行为记录,"对 signed-url 签发与下载两类事件写 audit_logs(不记录敏感 token 明文)","无权限仍 403;有权限获取 signed-url 与下载时均可在 audit_logs 检索到对应记录","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/src/routes/audit.js","[""F1""]","extra 仅保存 invoiceId/actorId/expireAt 等脱敏信息"
|
||||||
F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt"
|
F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list/markRead),用于工作流与系统事件通知","可分页读取;可标记已读;权限隔离正确;最小索引满足试运行","cd server && npm run prisma:migrate:dev && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/prisma/schema.prisma | server/src/routes/notifications.js | server/src/app.js","[""F1""]","只做最小字段:title/body/type/targetId/readAt"
|
||||||
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,TODO,TODO,TODO,"server/src/services/email/* | server/src/config.js | server/package.json","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
||||||
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
||||||
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)"
|
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)"
|
||||||
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/routes/approvalRules.js | client/src/pages/ApprovalRulesPage.tsx | server/src/app.js","[""F5""]","只做最小可运营;复杂可视化后置"
|
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/routes/approvalRules.js | client/src/pages/ApprovalRulesPage.tsx | server/src/app.js","[""F5""]","只做最小可运营;复杂可视化后置"
|
||||||
|
|||||||
|
+14
-1
@@ -65,7 +65,20 @@ npm run seed:trialrun
|
|||||||
- 解锁:`POST /api/users/:id/unlock`(权限:`system.users`)。
|
- 解锁:`POST /api/users/:id/unlock`(权限:`system.users`)。
|
||||||
- 弱密码拦截:
|
- 弱密码拦截:
|
||||||
- 采用 `zxcvbn` 评分,默认 `score < 2` 拒绝(返回 `VALID_001.details.password`)。
|
- 采用 `zxcvbn` 评分,默认 `score < 2` 拒绝(返回 `VALID_001.details.password`)。
|
||||||
- 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。
|
- 可通过 `WEAK_PASSWORD_CHECK_ENABLED=false` 关闭(不建议生产关闭)。
|
||||||
|
|
||||||
|
### 2.3.2 邮件通知(可选,F15)
|
||||||
|
- 默认关闭:`EMAIL_ENABLED=false`
|
||||||
|
- 开启后支持触发邮件(失败降级不阻塞主流程):
|
||||||
|
- 新待办分配(workflow.task_assigned)
|
||||||
|
- 审批结果(approved/rejected/returned)
|
||||||
|
- 付款完成(reimbursement.paid)
|
||||||
|
- 密码重置(forgot-password / reset-password)
|
||||||
|
- 需要配置 SMTP:
|
||||||
|
- `SMTP_HOST`、`SMTP_PORT`(默认 587)、`SMTP_SECURE`(465 通常为 true)
|
||||||
|
- `SMTP_USER`、`SMTP_PASS`(按 SMTP 服务要求)
|
||||||
|
- `EMAIL_FROM`(发件人地址)
|
||||||
|
- `EMAIL_BASE_URL`(可选,邮件内“查看详情”链接的站点前缀;为空则尝试使用 `APP_ORIGIN` 的第一个值)
|
||||||
|
|
||||||
### 2.4 启动服务
|
### 2.4 启动服务
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+10
@@ -23,6 +23,7 @@
|
|||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||||
@@ -1934,6 +1935,15 @@
|
|||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.4",
|
"version": "0.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ function getConfig() {
|
|||||||
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
|
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
|
||||||
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
|
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
|
||||||
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
|
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
|
||||||
|
|
||||||
|
emailEnabled: process.env.EMAIL_ENABLED === "true",
|
||||||
|
emailFrom: process.env.EMAIL_FROM ?? "[email protected]",
|
||||||
|
emailBaseUrl: process.env.EMAIL_BASE_URL ?? "",
|
||||||
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
|
smtpPort: Number(process.env.SMTP_PORT ?? 587),
|
||||||
|
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||||
|
smtpUser: process.env.SMTP_USER ?? "",
|
||||||
|
smtpPass: process.env.SMTP_PASS ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require(
|
|||||||
const { validatePassword } = require("../utils/passwordPolicy");
|
const { validatePassword } = require("../utils/passwordPolicy");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { createNotifications } = require("../services/notifications");
|
const { createNotifications } = require("../services/notifications");
|
||||||
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
|
|
||||||
function getHeaderString(request, name) {
|
function getHeaderString(request, name) {
|
||||||
const value = request.headers[name];
|
const value = request.headers[name];
|
||||||
@@ -678,6 +679,17 @@ async function authRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sendNotificationEmailSafe({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
logger: fastify.log,
|
||||||
|
requestId: request.id,
|
||||||
|
type: "auth.password_reset_requested",
|
||||||
|
recipientEmployeeIds: [employee.id],
|
||||||
|
subject: "【安全】密码重置验证码",
|
||||||
|
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||||
|
});
|
||||||
|
|
||||||
if (fastify.config.devShowPasswordResetCode) {
|
if (fastify.config.devShowPasswordResetCode) {
|
||||||
fastify.log.info(
|
fastify.log.info(
|
||||||
{ employeeId: employee.id, email, code, requestId: request.id },
|
{ employeeId: employee.id, email, code, requestId: request.id },
|
||||||
@@ -748,6 +760,8 @@ async function authRoutes(fastify) {
|
|||||||
|
|
||||||
const newPasswordHash = await hashPassword(newPassword);
|
const newPasswordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
await tx.passwordResetCode.update({
|
await tx.passwordResetCode.update({
|
||||||
where: { id: resetCode.id },
|
where: { id: resetCode.id },
|
||||||
@@ -790,6 +804,13 @@ async function authRoutes(fastify) {
|
|||||||
targetType: "employee",
|
targetType: "employee",
|
||||||
targetId: employee.id,
|
targetId: employee.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emailTasks.push({
|
||||||
|
type: "auth.password_reset_succeeded",
|
||||||
|
recipientEmployeeIds: [employee.id],
|
||||||
|
subject: "【安全】密码已重置",
|
||||||
|
body: "你的账号密码已重置成功。如非本人操作,请立即联系管理员。",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, employeeId: employee.id, requestId: request.id },
|
{ err: error, employeeId: employee.id, requestId: request.id },
|
||||||
@@ -798,6 +819,16 @@ async function authRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const task of emailTasks) {
|
||||||
|
void sendNotificationEmailSafe({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
logger: fastify.log,
|
||||||
|
requestId: request.id,
|
||||||
|
...task,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
|
reply.clearCookie(fastify.config.refreshCookieName, refreshCookieOptions(fastify.config));
|
||||||
return sendData(reply, request, {});
|
return sendData(reply, request, {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
createNotifications,
|
createNotifications,
|
||||||
resolveRecipientsForAssignee,
|
resolveRecipientsForAssignee,
|
||||||
} = require("../services/notifications");
|
} = require("../services/notifications");
|
||||||
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
buildWorkflowStepsFromApprovalRule,
|
||||||
resolveActionableStep,
|
resolveActionableStep,
|
||||||
@@ -1141,6 +1142,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
let submitResult = null;
|
let submitResult = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
@@ -1365,6 +1367,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: workflowTask.id,
|
targetId: workflowTask.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emailTasks.push({
|
||||||
|
type: "workflow.task_assigned",
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
subject: `【待办】报销单 ${formNumber} 待审批`,
|
||||||
|
body: `报销单 ${formNumber} 已提交,请及时处理。`,
|
||||||
|
targetPath: `/workflow/tasks/${workflowTask.id}`,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, reimbursementId, taskId: workflowTask.id },
|
{ err: error, reimbursementId, taskId: workflowTask.id },
|
||||||
@@ -1397,6 +1407,16 @@ async function reimbursementRoutes(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, submitResult);
|
return sendData(reply, request, submitResult);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1546,6 +1566,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
const payInput = parsePayBody(request.body);
|
const payInput = parsePayBody(request.body);
|
||||||
let response = null;
|
let response = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
@@ -1635,6 +1656,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: reimbursementId,
|
targetId: reimbursementId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emailTasks.push({
|
||||||
|
type: "reimbursement.paid",
|
||||||
|
recipientEmployeeIds: recipients,
|
||||||
|
subject: `【已付款】报销单 ${reimbursement.form_number ?? reimbursementId}`,
|
||||||
|
body: `报销单 ${reimbursement.form_number ?? reimbursementId} 已完成付款。`,
|
||||||
|
targetPath: `/reimbursements/${reimbursementId}`,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, reimbursementId },
|
{ err: error, reimbursementId },
|
||||||
@@ -1675,6 +1704,16 @@ async function reimbursementRoutes(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, response);
|
return sendData(reply, request, response);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
createNotifications,
|
createNotifications,
|
||||||
resolveRecipientsForAssignee,
|
resolveRecipientsForAssignee,
|
||||||
} = require("../services/notifications");
|
} = require("../services/notifications");
|
||||||
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const {
|
const {
|
||||||
findActiveDelegationForDelegate,
|
findActiveDelegationForDelegate,
|
||||||
listActiveDelegationPrincipals,
|
listActiveDelegationPrincipals,
|
||||||
@@ -727,6 +728,7 @@ async function workflowRoutes(fastify) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const taskId = parseTaskId(request.params?.taskId);
|
const taskId = parseTaskId(request.params?.taskId);
|
||||||
let result = null;
|
let result = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
@@ -828,6 +830,14 @@ async function workflowRoutes(fastify) {
|
|||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: nextTask.id,
|
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) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId: nextTask.id },
|
{ err: error, taskId: nextTask.id },
|
||||||
@@ -893,6 +903,14 @@ async function workflowRoutes(fastify) {
|
|||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
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) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ 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);
|
return sendData(reply, request, result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -922,6 +950,7 @@ async function workflowRoutes(fastify) {
|
|||||||
const taskId = parseTaskId(request.params?.taskId);
|
const taskId = parseTaskId(request.params?.taskId);
|
||||||
const comment = parseRequiredComment(request.body);
|
const comment = parseRequiredComment(request.body);
|
||||||
let result = null;
|
let result = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
@@ -992,6 +1021,14 @@ async function workflowRoutes(fastify) {
|
|||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
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) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ 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);
|
return sendData(reply, request, result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1020,6 +1067,7 @@ async function workflowRoutes(fastify) {
|
|||||||
const taskId = parseTaskId(request.params?.taskId);
|
const taskId = parseTaskId(request.params?.taskId);
|
||||||
const comment = parseRequiredComment(request.body);
|
const comment = parseRequiredComment(request.body);
|
||||||
let result = null;
|
let result = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
@@ -1090,6 +1138,14 @@ async function workflowRoutes(fastify) {
|
|||||||
targetType: "reimbursement_form",
|
targetType: "reimbursement_form",
|
||||||
targetId: workflowInstance.entity_id,
|
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) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, reimbursementId: workflowInstance.entity_id },
|
{ 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);
|
return sendData(reply, request, result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1118,6 +1184,7 @@ async function workflowRoutes(fastify) {
|
|||||||
const taskId = parseTaskId(request.params?.taskId);
|
const taskId = parseTaskId(request.params?.taskId);
|
||||||
const { toApproverId, reason } = parseTransferBody(request.body);
|
const { toApproverId, reason } = parseTransferBody(request.body);
|
||||||
let result = null;
|
let result = null;
|
||||||
|
const emailTasks = [];
|
||||||
|
|
||||||
await fastify.prisma.$transaction(async (tx) => {
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
const task = await lockWorkflowTask(tx, taskId);
|
const task = await lockWorkflowTask(tx, taskId);
|
||||||
@@ -1189,6 +1256,14 @@ async function workflowRoutes(fastify) {
|
|||||||
targetType: "workflow_task",
|
targetType: "workflow_task",
|
||||||
targetId: taskId,
|
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) {
|
} catch (error) {
|
||||||
fastify.log.error(
|
fastify.log.error(
|
||||||
{ err: error, taskId, toApproverId },
|
{ 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);
|
return sendData(reply, request, result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const nodemailer = require("nodemailer");
|
||||||
|
|
||||||
|
function resolveEmailBaseUrl(config) {
|
||||||
|
const explicit = String(config.emailBaseUrl ?? "").trim();
|
||||||
|
if (explicit) {
|
||||||
|
return explicit.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = String(config.appOrigin ?? "").split(",")[0]?.trim?.() ?? "";
|
||||||
|
if (origin) {
|
||||||
|
return origin.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTransportKey = null;
|
||||||
|
let cachedTransporter = null;
|
||||||
|
|
||||||
|
function getTransporter(config) {
|
||||||
|
if (!config.emailEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.smtpHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = String(config.smtpUser ?? "").trim();
|
||||||
|
const authPass = String(config.smtpPass ?? "").trim();
|
||||||
|
|
||||||
|
const key = JSON.stringify({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpSecure,
|
||||||
|
user: authUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedTransporter && cachedTransportKey === key) {
|
||||||
|
return cachedTransporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedTransportKey = key;
|
||||||
|
cachedTransporter = nodemailer.createTransport({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpSecure,
|
||||||
|
auth: authUser ? { user: authUser, pass: authPass } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedTransporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNotificationEmailSafe(params) {
|
||||||
|
try {
|
||||||
|
return await sendNotificationEmail(params);
|
||||||
|
} catch (error) {
|
||||||
|
params.logger?.error?.(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
requestId: params.requestId,
|
||||||
|
notificationType: params.type,
|
||||||
|
recipients: params.recipientEmployeeIds?.length ?? 0,
|
||||||
|
},
|
||||||
|
"Failed to send notification email",
|
||||||
|
);
|
||||||
|
return { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNotificationEmail({
|
||||||
|
prisma,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
requestId,
|
||||||
|
recipientEmployeeIds,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
type = "notification",
|
||||||
|
targetPath,
|
||||||
|
}) {
|
||||||
|
if (!config.emailEnabled) {
|
||||||
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = getTransporter(config);
|
||||||
|
if (!transporter) {
|
||||||
|
logger?.warn?.({ requestId }, "Email enabled but transporter not configured");
|
||||||
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = Array.from(new Set((recipientEmployeeIds ?? []).filter(Boolean)));
|
||||||
|
if (!recipients.length) {
|
||||||
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: recipients },
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailFrom = String(config.emailFrom ?? "").trim();
|
||||||
|
if (!emailFrom) {
|
||||||
|
logger?.warn?.({ requestId }, "Email enabled but EMAIL_FROM is empty");
|
||||||
|
return { skipped: true, sent: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolveEmailBaseUrl(config);
|
||||||
|
const link = targetPath && baseUrl ? `${baseUrl}${targetPath}` : null;
|
||||||
|
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
|
||||||
|
const safeBody = String(body ?? "").trim();
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const employee of employees) {
|
||||||
|
if (!employee.email) {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = link ? `${safeBody}\n\n查看详情:${link}` : safeBody;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: emailFrom,
|
||||||
|
to: employee.email,
|
||||||
|
subject: safeSubject,
|
||||||
|
text,
|
||||||
|
headers: {
|
||||||
|
"x-hyb-notification-type": type,
|
||||||
|
"x-request-id": requestId ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sent += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
logger?.error?.(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
requestId,
|
||||||
|
employeeId: employee.id,
|
||||||
|
email: employee.email,
|
||||||
|
notificationType: type,
|
||||||
|
},
|
||||||
|
"Email send failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skipped: false, sent, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendNotificationEmailSafe,
|
||||||
|
sendNotificationEmail,
|
||||||
|
};
|
||||||
|
|
||||||
在新工单中引用
屏蔽一个用户