feat(msg): SSE 实时通知体系 (#1-#6) + dict 接口权限分层

- 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 防止误删
This commit is contained in:
郭庆泰
2026-08-22 15:53:01 +08:00
parent b6ac5b7d41
commit 86e85d02cb
11 changed files with 321 additions and 44 deletions
@@ -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)
{
@@ -16,8 +16,13 @@ import com.ruoyi.business.service.IBizDepartmentService;
import com.ruoyi.business.service.IBizDoctorTitleService;
/**
* 业务字典管理 (科室 + 医生职称)
* 仅管理员可维护; 其它角色可访问 /active 接口拉启用的列表
* 业务字典管理 (科室 + 医生职称).
*
* <p>权限设计 (2026-08-22 用户要求):
* <ul>
* <li><b>读接口</b> (list / active / getById): 开放给所有登录用户, dropdown / 详情都要用</li>
* <li><b>写接口</b> (POST / PUT / DELETE): admin-only, 防止误删字典影响业务</li>
* </ul>
*/
@RestController
@RequestMapping("/business/dict")
@@ -30,7 +35,6 @@ public class BizDictController extends BaseController
// ============== 科室 ==============
@PreAuthorize("@ss.hasRole('admin')")
@GetMapping("/department/list")
public TableDataInfo listDept(BizDepartment entity)
{
@@ -86,7 +90,6 @@ public class BizDictController extends BaseController
// ============== 医生职称 ==============
@PreAuthorize("@ss.hasRole('admin')")
@GetMapping("/title/list")
public TableDataInfo listTitle(BizDoctorTitle entity)
{
@@ -3,7 +3,10 @@ package com.ruoyi.business.controller;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -19,6 +22,7 @@ import com.ruoyi.business.domain.BizProjectRating;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.domain.BizProjectSponsorAssign;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizProjectAssignService;
import com.ruoyi.business.service.IBizProjectRatingService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService;
@@ -42,6 +46,8 @@ public class BizProjectController extends BaseController
@Autowired
private IBizProjectAssignService bizProjectAssignService;
@Autowired
private BizNotifyService bizNotifyService;
@Autowired
private IBizProjectRatingService bizProjectRatingService;
@Autowired
private IBizProjectSponsorAssignService bizProjectSponsorAssignService;
@@ -175,15 +181,50 @@ public class BizProjectController extends BaseController
{
if (assigns == null) assigns = new ArrayList<>();
bizProjectAssignService.validateSum(projectId, assigns);
// #3 通知去重: 拉旧数据按 execUserId 索引, 同 (execUserId, sessions, amount) → 无变化 → 跳过
// 业务场景: manager 仅调整其他执行人时, 老的 executor 不应被打扰
List<BizProjectAssign> oldList = bizProjectAssignService.selectByProjectId(projectId);
Map<Long, BizProjectAssign> oldByExec = new HashMap<>();
for (BizProjectAssign o : oldList) {
if (o.getExecUserId() != null) oldByExec.put(o.getExecUserId(), o);
}
bizProjectAssignService.deleteByProjectId(projectId);
// #3 通知: 查一次项目名, 避免循环里重复查 DB
BizProject project = bizProjectService.getById(projectId);
String projectName = project != null ? project.getProjectName() : null;
for (BizProjectAssign a : assigns) {
a.setProjectId(projectId);
if (a.getStatus() == null) a.setStatus("0");
bizProjectAssignService.insert(a);
// 跳过空 execUserId 行 (notify 内部也会跳过, 这里直接 continue 省 lookup)
if (a.getExecUserId() == null) continue;
BizProjectAssign old = oldByExec.get(a.getExecUserId());
if (isAssignUnchanged(old, a)) {
logger.debug("[projectAssign] execUserId={} (sessions, amount) 未变, 跳过通知", a.getExecUserId());
continue;
}
// #3 通知被分配的 executor (待办: 去承接). 业务事务回滚时通知自动回滚
bizNotifyService.projectAssignedToExecutor(
a.getExecUserId(), projectId, projectName, a.getSessions(), a.getAmount());
}
return success();
}
/**
* 比较两条 BizProjectAssign 是否对 executor 而言"未变".
* 判定维度: sessions + amount. projectId 隐含相同 (旧数据就是本 projectId 的).
* amount 用 compareTo (100 vs 100.00 都 = 0), 规避 BigDecimal.scale 差异.
*/
private static boolean isAssignUnchanged(BizProjectAssign old, BizProjectAssign n) {
if (old == null) return false; // 新分配或被替换 → 算变化
if (!Objects.equals(old.getSessions(), n.getSessions())) return false;
BigDecimal oa = old.getAmount();
BigDecimal na = n.getAmount();
if (oa == null && na == null) return true;
if (oa == null || na == null) return false;
return oa.compareTo(na) == 0;
}
@Log(title = "项目执行方分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{projectId}/assigns")
public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId)
@@ -1,5 +1,7 @@
package com.ruoyi.business.notify;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -7,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizMessage;
import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.mapper.BizSysUserQueryMapper;
import com.ruoyi.business.service.IBizMessageService;
import com.ruoyi.common.enums.BizAuditStatusEnum;
@@ -54,6 +57,7 @@ public class BizNotifyService
// ====================== bizType 常量 ======================
public static final String BIZ_EXPERT = "expert";
public static final String BIZ_PROJECT = "project";
public static final String BIZ_PROJECT_PLAN = "projectPlan";
public static final String BIZ_MEETING = "meeting";
public static final String BIZ_AGREEMENT = "agreement";
public static final String BIZ_PUBLICITY = "publicity";
@@ -96,4 +100,168 @@ public class BizNotifyService
bizMessageService.insert(msg);
log.info("[notify] expertAuditResult 已发 uid={} ({} → {})", expert.getUserId(), oldAuditStatus, newStatus);
}
/**
* #4 项目策划方案审核结果 → 通知投稿人 (通常是 doctor).
*
* <p>调用方: {@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 会议邀请 → 通知新加入的参会人.
*
* <p>调用方: {@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 劳务协议待签 → 通知参会人协议已生成, 请手写签字.
*
* <p>调用方: {@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 (待办: 去承接).
*
* <p>调用方: {@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);
}
}
@@ -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)
@@ -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<SseEmitter> set = emitters.get(userId);
if (set == null || set.isEmpty()) return;
String payload = "{\"type\":\"new_message\",\"unread\":" + unread + ",\"silent\":" + silent + "}";
for (SseEmitter em : set) {
try {
em.send(SseEmitter.event()
.name("new_message")
.data("{\"type\":\"new_message\",\"unread\":" + unread + "}"));
.data(payload));
} catch (Exception e) {
log.warn("[SSE] push 失败, 自动清理该 emitter uid={} err={}", userId, e.toString());
try { em.completeWithError(e); } catch (Exception ignore) {}
@@ -89,6 +94,12 @@ public class MessageSseService
if (set.isEmpty()) emitters.remove(userId);
}
/** 兼容旧调用: 业务事件触发推送 (silent=false) */
public void pushNewMessage(Long userId, int unread)
{
pushNewMessage(userId, unread, false);
}
/**
* 25s 心跳 — SSE 注释帧 (以 ':' 开头) 不会触发 EventSource.onmessage,
* 仅用来保活防 nginx/proxy 60s 空闲断连.