feat(msg): SSE 实时通知体系 (Phase 1-4 + 7 部分)

后端:
- MessageSseService (emitter 注册表, 25s 心跳, pushNewMessage 仅 log 不抛)
- BizMessageSseController (独立 Controller, 避开 /{msgId} 路径抢匹配)
- BizMessageMapper.countUnread SQL 修复 (跟 limit 解耦, SSE 推真值)
- BizMessageServiceImpl.insert/markRead 成功后 pushNewMessage 触发 SSE
- BizNotifyService.expertAuditResult (#1: 审核结果通知专家本人)
- BizExpertServiceImpl.updateByPrimaryKey 比对旧 audit_status, 真变化才发通知
- PlaceholderConfig (Spring 7 严格 placeholder 兜底, ignoreUnresolvable=true)

前端:
- utils/sseClient.js (@microsoft/fetch-event-source 封装, Bearer 鉴权, 单例连接)
- 全局事件总线 onGlobal: SSE new_message 扇出到业务页面 (切路由不掉)
- AdminLayout 全局订阅 + bell 角标 (el-badge 显示 unreadCount)
- userStore.expertAuditApproved: doctor 审核状态实时跟 DB
- AdminLayout doctor menu 加 requireAuditApproved 过滤 (未通过审核隐藏 3 menu)
- doctor/Home: 待参加会议/待签协议 2 pannel 按 audit 状态显示; load() 仅审核通过才拉数据
- doctor/Home + doctor/Messages: 点列表弹详情 dialog + markMessageRead 真已读
- AdminLayout SSE 触发时: 刷新 unread + 重新拉 audit 状态 (审核通过瞬间可见)

业务规则:
- 专家注册不群发 manager (manager 群体可能 100+, 每条注册发 N 条浪费)
- 通过审核无需重新登录 (SSE 链路实时联动)
This commit is contained in:
郭庆泰
2026-08-22 08:46:26 +08:00
parent c3eb8ed9c3
commit b6ac5b7d41
18 changed files with 697 additions and 39 deletions
+115
View File
@@ -0,0 +1,115 @@
// SSE 客户端封装 — 用 @microsoft/fetch-event-source (可设 Authorization header, 原生 EventSource 不行)
// 设计要点 (沿用 task_plan.md 决策):
// - 单例连接: App 启动时调 sseStart() 一次, 退出时 sseStop()
// - 推送只推信号: new_message 事件 = 重拉 /business/message/my, 内容由 DB 提供
// - 断线重连: fetch-event-source 内置无限重试, 无需自己实现指数退避
// - 鉴权: Authorization: Bearer <localStorage['ry_token']>, 避免 token 进 URL/nginx log
// - 后端心跳 ": ping" (SSE 注释帧) 不触发 onmessage, 仅保活防代理 60s 断连
import { fetchEventSource } from '@microsoft/fetch-event-source'
const SSE_PATH = '/dev-api/business/message/stream'
// 监听器表 (允许多组件订阅)
const listeners = { new_message: new Set(), connected: new Set() }
// SSE 连接单例状态 (避免重复订阅)
let ctrl = null // AbortController, 用于关闭连接
let started = false // 是否已启动订阅
// ====================== 全局事件总线 ======================
// 设计: 路由切换时 page 组件会 unmount, 直接 sseOn('new_message') 会丢订阅.
// AdminLayout 是唯一永驻的组件, 由它统一把 SSE 事件转发到 'global:new_message' 总线,
// 各 page 订阅总线, 这样 navbar bell 角标永远在, 切页面不影响.
const globalBus = new Set()
function emit(eventName, payload) {
const set = listeners[eventName]
if (set) {
for (const fn of set) {
try { fn(payload) } catch (e) { console.error(`[SSE] listener error on ${eventName}`, e) }
}
}
// SSE 的 new_message 同时扇出到全局总线, 由 AdminLayout 维护 unreadCount, page 订阅刷新列表
if (eventName === 'new_message') {
for (const fn of globalBus) {
try { fn(payload) } catch (e) { console.error(`[bus] listener error`, e) }
}
}
}
export function on(eventName, handler) {
const set = listeners[eventName]
if (!set) throw new Error(`[SSE] unknown event: ${eventName}`)
set.add(handler)
return () => set.delete(handler) // 返回 unsubscribe
}
/**
* 订阅全局总线 (SSE 事件的二级 fan-out, 用于 page 组件, 跨路由不掉)
* 推荐各业务页面用这个, 不要直接 sseOn('new_message') — 后者绑在 AdminLayout 里更合理
*/
export function onGlobal(handler) {
globalBus.add(handler)
return () => globalBus.delete(handler)
}
export function sseStart() {
if (started) return
started = true
const token = localStorage.getItem('ry_token')
if (!token) {
console.warn('[SSE] 无 token, 跳过订阅 (用户未登录)')
started = false
return
}
ctrl = new AbortController()
fetchEventSource(SSE_PATH, {
signal: ctrl.signal,
headers: { Authorization: `Bearer ${token}` },
openWhenHidden: true, // 切到后台 tab 也保持, 减少重连风暴
onopen(response) {
if (response.ok) {
console.info('[SSE] connected')
return
}
// 401 等鉴权失败: 立刻停, fetch-event-source 会一直重试会刷屏
if (response.status === 401 || response.status === 403) {
console.warn(`[SSE] 鉴权失败 status=${response.status}, 停止重试`)
ctrl.abort()
started = false
return
}
throw new Error(`SSE open failed: ${response.status}`)
},
onmessage(msg) {
// msg.event: 'connected' | 'new_message' | 其它
// msg.data: 服务端 .data() 写入的字符串
if (msg.event === 'new_message') {
let unread = 0
try { unread = JSON.parse(msg.data).unread ?? 0 } catch {}
emit('new_message', { unread })
} else if (msg.event === 'connected') {
emit('connected', {})
}
},
onclose() {
console.info('[SSE] closed')
},
onerror(err) {
console.warn('[SSE] error', err)
// fetch-event-source 内部会自动重试, 这里只需要记日志, **不要 throw** (throw 会中断重试)
}
}).catch((e) => {
if (e?.name !== 'AbortError') console.error('[SSE] fetchEventSource error', e)
started = false
})
}
export function sseStop() {
if (ctrl) { try { ctrl.abort() } catch {} ; ctrl = null }
started = false
console.info('[SSE] stopped')
}