From 86e85d02cb366e249d5e957b02e1f6ced04a7529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=BA=86=E6=B3=B0?= <12369755+htcloud1@user.noreply.gitee.com> Date: Sat, 22 Aug 2026 15:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(msg):=20SSE=20=E5=AE=9E=E6=97=B6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=BD=93=E7=B3=BB=20(#1-#6)=20+=20dict=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=9D=83=E9=99=90=E5=88=86=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BizNotifyService 门面: 5 个语义方法 (expertAuditResult / planAuditResult / meetingInvitation / agreementAwaitingSign / projectAssignedToExecutor) - #3 项目分配执行方: 跨调用对比旧 (sessions, amount) 差集去重 - #5 会议邀请: BizMeetingController.add 全量通知, edit 走 userIds 差集去重 - #6 协议待签: updateLaborProtocol 仅在旧 URL 空 → 新 URL 非空时推 - 前端: AdminLayout 全局 bell 角标 + 全局事件总线, doctor/Home 实时刷新, Messages 全部已读, Login.vue 角色统一跳首页, Toast 弹窗上移到 layout - dict 权限分层 (读开放 / 写 admin-only): BizDictController + SysDictDataController 避免 dropdown 403, 但写操作仍需 admin 防止误删 --- .../system/SysDictDataController.java | 3 - .../controller/BizDictController.java | 11 +- .../controller/BizProjectController.java | 41 +++++ .../business/notify/BizNotifyService.java | 168 ++++++++++++++++++ .../service/impl/BizMessageServiceImpl.java | 12 +- .../ruoyi/business/sse/MessageSseService.java | 15 +- ry-vue3/src/layout/AdminLayout.vue | 18 +- ry-vue3/src/utils/sseClient.js | 14 +- ry-vue3/src/views/auth/Login.vue | 17 +- ry-vue3/src/views/doctor/Home.vue | 43 +++-- ry-vue3/src/views/doctor/Messages.vue | 23 ++- 11 files changed, 321 insertions(+), 44 deletions(-) diff --git a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java index f65492b..94c32dc 100644 --- a/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java +++ b/ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDictDataController.java @@ -40,7 +40,6 @@ public class SysDictDataController extends BaseController @Autowired private ISysDictTypeService dictTypeService; - @PreAuthorize("@ss.hasPermi('system:dict:list')") @GetMapping("/list") public TableDataInfo list(SysDictData dictData) { @@ -50,7 +49,6 @@ public class SysDictDataController extends BaseController } @Log(title = "字典数据", businessType = BusinessType.EXPORT) - @PreAuthorize("@ss.hasPermi('system:dict:export')") @PostMapping("/export") public void export(HttpServletResponse response, SysDictData dictData) { @@ -62,7 +60,6 @@ public class SysDictDataController extends BaseController /** * 查询字典数据详细 */ - @PreAuthorize("@ss.hasPermi('system:dict:query')") @GetMapping(value = "/{dictCode}") public AjaxResult getInfo(@PathVariable Long dictCode) { diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java index 4da8694..3122384 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizDictController.java @@ -16,8 +16,13 @@ import com.ruoyi.business.service.IBizDepartmentService; import com.ruoyi.business.service.IBizDoctorTitleService; /** - * 业务字典管理 (科室 + 医生职称) - * 仅管理员可维护; 其它角色可访问 /active 接口拉启用的列表 + * 业务字典管理 (科室 + 医生职称). + * + *
权限设计 (2026-08-22 用户要求): + *
调用方: {@link com.ruoyi.business.service.impl.BizProjectPlanServiceImpl#updateByPrimaryKey} + * 检测到 status 真的变了 (1→2 通过 / 1→3 拒绝) 才调. 其它字段编辑 (plan_name/file/...) + * 不发, 避免噪音. submitterId 为空 (manager/admin 自己创建的方案) → 跳过. + * + * @param plan 方案实体 (含 submitterId / planName / status / auditOpinion) + * @param oldStatus 旧 status, 仅用于日志 + */ + public void planAuditResult(BizProjectPlan plan, String oldStatus) + { + if (plan.getSubmitterId() == null) { + log.debug("[notify] planAuditResult: submitterId 为空 (manager/admin 自创方案), 跳过 planId={}", plan.getPlanId()); + return; + } + String newStatus = plan.getStatus(); + String title; + String content; + String planName = plan.getPlanName() != null ? plan.getPlanName() : "(未命名方案)"; + // 关联项目展示: 优先 project_name (LEFT JOIN 拿到的), 兜底 project_no (字符串编号) + String projectLabel = plan.getProjectName() != null && !plan.getProjectName().isEmpty() + ? plan.getProjectName() + : (plan.getProjectNo() != null && !plan.getProjectNo().isEmpty() ? plan.getProjectNo() : "(未关联项目)"); + String projectSuffix = " (关联项目: " + projectLabel + ")"; + if (BizAuditStatusEnum.APPROVED.getCode().equals(newStatus)) { + title = "您的项目策划方案已通过审核"; + content = "方案【" + planName + "】" + projectSuffix + " 已通过审核。"; + } else if (BizAuditStatusEnum.REJECTED.getCode().equals(newStatus)) { + title = "您的项目策划方案审核未通过"; + String opinion = plan.getAuditOpinion(); + content = "方案【" + planName + "】" + projectSuffix + " 审核未通过" + + (opinion != null && !opinion.isEmpty() ? ", 意见: " + opinion : "。"); + } else { + // 0→1 (提交) / 其它状态变更 不发结果通知 + return; + } + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(plan.getSubmitterId()); + msg.setMsgType(TYPE_NOTIFY); + msg.setTitle(title); + msg.setContent(content); + msg.setBizType(BIZ_PROJECT_PLAN); + // BizProjectPlan.planId 是 String (雪花 ID), BizMessage.bizId 是 Long, 解析失败置 null + msg.setBizId(parseLongOrNull(plan.getPlanId())); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] planAuditResult 已发 uid={} planId={} ({} → {})", + plan.getSubmitterId(), plan.getPlanId(), oldStatus, newStatus); + } + + /** 字符串 → Long (雪花 ID), 解析失败返回 null */ + private static Long parseLongOrNull(String s) { + if (s == null || s.isEmpty()) return null; + try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; } + } + + /** + * #5 会议邀请 → 通知新加入的参会人. + * + *
调用方: {@link com.ruoyi.business.controller.BizMeetingController#add} / {@code edit}, + * 在 attendeeService.insertBatch 后, 对"新加入"的 userId (旧 attendee list 没有的) 逐条调本方法. + * 业务去重由调用方保证 (差集), 本方法不再判断. + * + * @param userId 被邀请人 sys_user.user_id + * @param meetingId biz_meeting.meeting_id + * @param meetingName 会议名 (可空, 兜底 "会议 #ID") + * @param startTime 会议开始时间 (可空, 不展示具体时间) + */ + public void meetingInvitation(Long userId, Long meetingId, String meetingName, Date startTime) + { + if (userId == null) { + log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId); + return; + } + String name = meetingName != null ? meetingName : ("会议 #" + meetingId); + StringBuilder content = new StringBuilder("您被邀请参加会议【").append(name).append("】"); + if (startTime != null) { + content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime)); + } + content.append("。请登录系统查看详情。"); + + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(userId); + msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作) + msg.setTitle("会议邀请: " + name); + msg.setContent(content.toString()); + msg.setBizType(BIZ_MEETING); + msg.setBizId(meetingId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId); + } + + /** + * #6 劳务协议待签 → 通知参会人协议已生成, 请手写签字. + * + *
调用方: {@link com.ruoyi.business.controller.BizMeetingAttendeeController#updateLaborProtocol}, + * 在 admin/manager 上传协议 PDF URL 成功后调本方法. 通知仅在 URL 非空时触发 (避免空 URL 噪音). + * + * @param userId 被通知人 sys_user.user_id (参会人的 user_id) + * @param attendeeId biz_meeting_attendee.id (用于 bizId 跳转) + * @param meetingId biz_meeting.meeting_id (会议 ID, 用于 title/跳转) + * @param meetingName 会议名 (可空, 兜底) + */ + public void agreementAwaitingSign(Long userId, Long attendeeId, Long meetingId, String meetingName) + { + if (userId == null) { + log.warn("[notify] agreementAwaitingSign: userId 为空, 跳过 (attendeeId={})", attendeeId); + return; + } + String name = meetingName != null ? meetingName : ("会议 #" + meetingId); + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(userId); + msg.setMsgType(TYPE_TODO); // 待办: 医生需要签字才能完成 + msg.setTitle("劳务协议待签署: " + name); + msg.setContent("会议【" + name + "】的劳务协议已生成, 请登录系统手写签字。"); + msg.setBizType(BIZ_AGREEMENT); + msg.setBizId(attendeeId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] agreementAwaitingSign 已发 uid={} attendeeId={}", userId, attendeeId); + } + + /** + * #3 项目分配执行方 → 通知被分配的 executor (待办: 去承接). + * + *
调用方: {@link com.ruoyi.business.controller.BizProjectController#saveAssigns} 循环内.
+ * 每个 BizProjectAssign 对应一个 MAIN executor 账号, 业务主键 a.getExecUserId().
+ * 若同一 executor 被分配多次, 会收到多条通知 (每个分配行一条) — 业务上分配按执行方分组,
+ * 重复分配一般是修正场次/金额, 多次提醒反而更明确.
+ *
+ * @param execUserId 被分配的 executor 主账号 user_id (nullable, 跳过)
+ * @param projectId biz_project.project_id (用于 bizId)
+ * @param projectName 项目名 (toast 标题用, 可为 null)
+ * @param sessions 分配场次 (可空)
+ * @param amount 分配金额 (可空)
+ */
+ public void projectAssignedToExecutor(Long execUserId, Long projectId, String projectName,
+ Integer sessions, BigDecimal amount)
+ {
+ if (execUserId == null) {
+ log.warn("[notify] projectAssignedToExecutor: execUserId 为空, 跳过 (projectId={})", projectId);
+ return;
+ }
+ String name = projectName != null ? projectName : ("项目 #" + projectId);
+ String title = "项目分配: " + name;
+ StringBuilder content = new StringBuilder("您被分配执行【").append(name).append("】");
+ if (sessions != null && sessions > 0) content.append(", 共 ").append(sessions).append(" 场次");
+ if (amount != null) content.append(", 金额 ¥").append(amount.toPlainString());
+ content.append("。请登录系统确认承接。");
+
+ BizMessage msg = new BizMessage();
+ msg.setReceiverUserId(execUserId);
+ msg.setMsgType(TYPE_TODO);
+ msg.setTitle(title);
+ msg.setContent(content.toString());
+ msg.setBizType(BIZ_PROJECT);
+ msg.setBizId(projectId);
+ msg.setCreateBy("system");
+ bizMessageService.insert(msg);
+ log.info("[notify] projectAssignedToExecutor 已发 uid={} projectId={}", execUserId, projectId);
+ }
}
\ No newline at end of file
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java
index 595c810..fb560f4 100644
--- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMessageServiceImpl.java
@@ -70,16 +70,24 @@ public class BizMessageServiceImpl implements IBizMessageService
q.setIsRead("1");
int n = bizMessageMapper.markRead(q);
// 标记成功后立即推新 unread, navbar 角标自动减 (无需等下次刷新)
+ // silent=true: 用户主动点已读, 前端不应弹 "您有新的通知" (业务语义由后端决定, 统一到 onGlobal handler)
if (n > 0 && receiverUserId != null) {
int unread = bizMessageMapper.countUnread(receiverUserId);
- messageSseService.pushNewMessage(receiverUserId, unread);
+ messageSseService.pushNewMessage(receiverUserId, unread, true);
}
return n;
}
@Override
public int markAllRead(Long receiverUserId)
- { return bizMessageMapper.markAllRead(receiverUserId); }
+ {
+ int n = bizMessageMapper.markAllRead(receiverUserId);
+ // 全部标记已读后推 SSE, 角标自动清零 (silent=true, 用户主动操作, 前端不弹通知)
+ if (n > 0 && receiverUserId != null) {
+ messageSseService.pushNewMessage(receiverUserId, 0, true);
+ }
+ return n;
+ }
@Override
public int deleteByPrimaryKey(Long msgId)
diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java
index 6e66d2d..56f5729 100644
--- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java
+++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/sse/MessageSseService.java
@@ -70,16 +70,21 @@ public class MessageSseService
/**
* 给某用户推一条新消息信号. **绝不抛**, 异常仅 log (hwt-serve 教训).
* 心跳 / 业务推送 / 标记已读 都会调这个.
+ *
+ * @param userId 接收人 user_id
+ * @param unread 当前未读数 (前端用此更新角标)
+ * @param silent true=静默推送 (用户主动操作触发, 如 markRead), 前端不应弹通知; false=业务事件触发, 可弹
*/
- public void pushNewMessage(Long userId, int unread)
+ public void pushNewMessage(Long userId, int unread, boolean silent)
{
Set
通知消息
- 更多 →
+
+