工作项、迭代、里程碑、版本、测试......每个模块都在产生事件。如果没有通知,用户只能靠手动刷新页面发现变化;如果没有预警,风险一直摆在那里却无人知晓。消息通知解决「该让谁知道什么」,预警解决「当前还有什么风险仍然成立」。两者看似都是"提醒",但语义模型完全不同:通知是时刻(moment)------一个事件发生时推给收件人;预警是状态(state)------一个风险当前仍成立时持续展示。本篇记录消息通知与预警两个模块的完整设计:通知的事件分发链路、规则注册表、收件人过滤、聚合幂等、SSE 实时推送、免打扰与定时摘要、个人偏好设置,以及预警的三条 P0 规则、定时物化扫描、幂等 upsert / 自动解除机制。
一、消息通知与预警要解决什么问题
1.1 通知的痛点:事件发生但无人知晓
| 问题 | 表现 |
|---|---|
| 操作无感知 | 同事把工作项指派给你,你不知道;迭代状态流转了,创建人不收到任何提示 |
| 信息流混乱 | 该收到的人没收到,不该收到的人被打扰;自己操作自己也弹通知 |
| 多端不同步 | 一个标签页已读了,另一个标签页角标不变;移动端完全看不到 |
| 无噪音控制 | 一次编辑改了 5 个字段来 5 条通知;一天收 200 条通知,最终直接关掉整类 |
1.2 预警的痛点:风险摆在那里却无人处理
| 问题 | 表现 |
|---|---|
| 逾期无感知 | 工作项逾期 7 天了,负责人不知道、PM 不知道 |
| 里程碑临期 | 里程碑 3 天后到期,还有 10 个工作项没完成,没有人主动检查 |
| 迭代低完成率 | 迭代即将结束,完成率只有 40%,风险在最后一刻才暴露 |
| 通知 ≠ 预警 | 通知是"发生了",预警是"仍成立";逾期的工作项不会因为发过一次通知就不再逾期 |
1.3 目标
通知------一套完整的事件 → 收件人 → 落库 → 推送链路:
- 事件驱动:动态切面捕获业务事件,异步分发,不拖慢业务事务
- 规则可插拔:每种事件类型一个
NotificationRule,启动期注册,按supports匹配 - 收件人精确过滤:排除操作者 → 企业成员校验 → 项目访问权校验 → 偏好开关过滤
- 聚合幂等:同目标同收件人窗口内合并,同来源事件不重复入库
- 实时推送:SSE + Redis Pub/Sub 实现多实例实时投递
- 噪音控制:免打扰时段、按类型关闭、每日工作摘要、单日量阈值告警
预警------定时物化、分页查询、状态生命周期:
- 三条 P0 规则:工作项逾期、里程碑临期未完成、迭代低完成率
- 每小时定时扫描:逐企业注入租户上下文,扫业务表,物化到
biz_alert - 幂等 upsert:同一风险不重复建行,只刷新
last_seen_at - 自动解除:条件消失后扫描自动标
resolved - 与通知分离:预警表示"当前仍成立的风险行",不是推送时刻

二、数据模型
2.1 通知实体总览
biz_notification ──── 收件箱(一事件 × N 收件人 = N 行)
biz_notification_setting ──── 个人偏好(按 username,跨企业共享)
通知与动态(biz_activity)的关系:
| 维度 | 动态(Activity) | 通知(Notification) |
|---|---|---|
| 行数 | 一个事件一行 | 一个事件 × N 收件人 = N 行 |
| 可见性 | 项目内共享 | 个人维度 |
| 已读状态 | 无 | read_flag(个人维度) |
| 推送 | 无 | SSE 实时推送 |
| 来源 | 业务操作 | ACTIVITY / ACTION / REMINDER |
2.2 通知主表
通知主表(biz_notification)记录每一条站内通知:
| 字段 | 说明 |
|---|---|
recipient |
收件人 username(全链路统一 username,不用 userId) |
category |
通知类型,见 NotificationCategory(14 种 + 2 种系统类) |
priority |
优先级:high / normal |
title / content |
渲染后的标题与正文 |
source_type |
来源:ACTIVITY(动态触发)/ ACTION(状态机动作)/ REMINDER(定时提醒) |
source_id |
来源主键(biz_activity.id 或动作记录 id) |
target_type |
目标类型:WORKITEM / MILESTONE / ITERATION / TEST_PLAN / WIKI / MEMBER |
target_id |
目标 ID(点击跳转) |
project_id |
所属项目(企业级事件可空) |
actor / actor_name |
操作者 username 与展示名 |
read_flag / read_time |
是否已读 / 已读时间 |
aggregate_key |
聚合键(非空时同收件人同键的未读通知合并) |
repeat_count |
聚合次数 |
channel |
投递渠道:INBOX / EMAIL |
send_status |
站内入库状态,恒为 sent |
mail_status |
邮件投递结果:success / failed / skipped(null 表示未走邮件) |
mail_time / mail_error |
邮件投递时间 / 失败原因 |

2.3 通知设置表
个人通知设置(biz_notification_setting)的维度是「人」不是「人在某企业」:
| 字段 | 说明 |
|---|---|
username |
设置归属人 |
category |
通知类型,或 * 表示全局开关 |
enabled |
是否接收该类型 |
quiet_start / quiet_end |
免打扰时段(仅 * 行承载) |
关键设计决策 :表里只存「关闭」项,未命中即默认开启。* 行同时承载全局开关与免打扰时段。唯一键为 (username, category),同一账号跨企业共享同一套开关。
2.4 预警实体
预警主表(biz_alert)是企业级风险事实,一人不 fan-out:
| 字段 | 说明 |
|---|---|
project_id |
所属项目(可空) |
rule_code |
规则编码(WORK_ITEM_OVERDUE / MILESTONE_DUE_SOON / ITERATION_DUE_LOW_PROGRESS) |
level |
等级:critical / high / mid / low |
status |
状态:open(仍成立)/ ignored(用户忽略)/ resolved(条件消失,自动解除) |
target_type |
目标类型:WORKITEM / MILESTONE / ITERATION |
target_id / target_code |
目标 ID / 业务编号冗余 |
title / content |
标题与摘要 |
owner / creator |
负责人 / 创建人 |
dedupe_key |
幂等键:rule:targetType:targetId |
first_seen_at / last_seen_at |
首次检出 / 最近仍命中 |
resolved_at |
自动解除时间 |
ignored_at / ignored_by |
忽略时间 / 忽略人 |

三、通知类型体系
3.1 通知类型常量
通知类型(NotificationCategory)命名规则为 业务对象_事件,值一旦上线不再改名:
| 类型 | 说明 | 收件人 |
|---|---|---|
WORK_ITEM_ASSIGN |
工作项指派给我 | 新负责人 |
WORK_ITEM_STATUS |
工作项状态流转 | 创建人 + 负责人 + 协作人 |
WORK_ITEM_EDIT |
工作项被编辑 | 创建人 + 负责人 |
WORK_ITEM_DELETE |
工作项被删除 / 恢复 | 创建人 + 负责人(优先级高) |
ITERATION_STATE |
迭代开始 / 完成 | 负责人 + 迭代内工作项负责人 |
ITERATION_ASSIGN |
迭代负责人变更为我 | 新负责人 |
MILESTONE_STATE |
里程碑开始 / 关闭 / 重新打开 | 负责人 + 项目 PM |
MILESTONE_ASSIGN |
里程碑负责人变更为我 | 新负责人 |
TEST_ASSIGN |
测试用例指派给我 | 指派执行人 |
TEST_REVIEW |
测试评审提交结论 | 评审参与人 |
MEMBER_CHANGE |
项目成员加入 / 移出 | 被加入 / 移出的人 |
WIKI_SHARE |
知识库文档分享给我 | 被分享者 |
CUSTOM |
状态机 notify_user 触发 |
运行期指定 |
DUE_REMINDER |
到期提醒 | 工作项负责人 / 里程碑负责人 + PM |
QUIET_DIGEST |
免打扰期间的次日汇总 | 免打扰用户(不出现在设置页) |
DAILY_SUMMARY |
每日工作摘要 | 有待办或未读的人 |
3.2 为什么指派要单独成类
WORK_ITEM_ASSIGN 与 WORK_ITEM_STATUS 分开的原因:指派是"这件事归你了",属于必须让本人知道的动作;状态流转是"状态推进了",收件人范围更广。混在一起用户既没法单独关,排查时也分不清是"被指派"还是"状态变了"。
同理,ITERATION_ASSIGN 和 MILESTONE_ASSIGN 也与各自的状态类分开,保持对称结构。
四、通知分发链路
4.1 整体架构
less
@Activity 埋点(事务提交后)
↓
NotificationDispatcher.dispatch(task)
↓ 异步线程池(notif- 线程)
↓ LoginHelper.runWithCompany(companyCode, ...)
↓
NotificationServiceImpl.doDistribute(task)
├── loadTarget(task) → 查业务对象展示信息
├── ruleRegistry.resolve() → 规则匹配,产出 NotificationIntent
├── filterRecipients() → 排除操作者、上限截断
├── retainCompanyMembers() → R2:企业成员校验
├── retainProjectMembers() → R6:项目访问权校验
├── settingGuard.disabled() → F-19:按类型关闭
├── settingGuard.inQuietHours() → F-20:免打扰
└── deliver() → 渲染 → 聚合或插入 → 推送
4.2 为什么不用 @Async
分发走显式 executor.execute 而非 @Async:
@Async依赖代理,同类内部调用会静默失效- 拒绝策略要精确控制------必须"丢弃 + WARN",若用
CallerRunsPolicy会把通知落库的压力压回业务线程
线程池配置:
java
@Bean("notificationExecutor")
public ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notif-");
// 不用 DiscardPolicy(静默丢弃),换成显式打 WARN 的处理器
executor.setRejectedExecutionHandler((task, pool) ->
log.warn("通知线程池已满 poolSize={} active={} queue={}",
pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()));
return executor;
}
4.3 规则注册表
每种事件类型对应一个 NotificationRule 实现,启动期由 Spring 自动收集:
java
@Component
public class NotificationRuleRegistry {
private final List<NotificationRule> rules;
public NotificationRuleRegistry(List<NotificationRule> rules) {
this.rules = List.copyOf(rules);
log.info("通知规则就绪:{} 条", this.rules.size());
}
public List<NotificationIntent> resolve(NotificationTask task, NotificationTargetInfo target) {
List<NotificationIntent> intents = new ArrayList<>();
for (NotificationRule rule : rules) {
try {
if (!rule.supports(task)) continue;
intents.addAll(rule.resolve(task, target));
} catch (Exception e) {
// 单条规则失败不影响其他规则(通知是旁路能力)
log.warn("通知规则执行失败 rule={} : {}", rule.getClass().getSimpleName(), e.getMessage());
}
}
return intents;
}
}
以工作项规则为例:
java
@Component
public class WorkItemNotificationRule implements NotificationRule {
@Override
public boolean supports(NotificationTask task) {
return "WORKITEM".equals(task.targetType());
}
@Override
public List<NotificationIntent> resolve(NotificationTask task, NotificationTargetInfo target) {
return switch (task.actionType()) {
case "CREATE" -> List.of(new NotificationIntent(
WORK_ITEM_ASSIGN, HIGH, Set.of(task.owner()), vars, "ASSIGN:WORKITEM:" + task.targetId()));
case "STATUS_CHANGE" -> List.of(new NotificationIntent(
WORK_ITEM_STATUS, NORMAL, creatorOwnerCollaborators, vars, "STATUS:WORKITEM:" + task.targetId()));
case "EDIT" -> List.of(new NotificationIntent(
WORK_ITEM_EDIT, NORMAL, creatorOwner, vars, "EDIT:WORKITEM:" + task.targetId()));
case "DELETE", "RECOVER" -> List.of(new NotificationIntent(
WORK_ITEM_DELETE, HIGH, creatorOwner, vars, null)); // 不聚合
default -> List.of();
};
}
}
4.4 收件人四道过滤
通知分发中的收件人过滤是防止信息泄露和噪音的核心:
r
候选收件人(规则产出)
↓ R1:排除操作者(除非开了"自己也通知自己")、去空、上限截断
↓ R2:企业成员校验(查 sys_user_company)
↓ R6:项目访问权校验(查 biz_project_member)
↓ F-19:按类型关闭(查 biz_notification_setting)
↓ F-20:免打扰时段(照常入库但不实时推送)
→ 逐条 deliver
R2 企业成员校验:协作人表和创建人字段都是历史快照,用户移出企业后关系不会自动清理。不做校验的话,离开企业的账号仍会持续收到通知,属于跨企业信息泄露。
R6 项目访问权校验 :同理,用户被移出项目后,biz_work_item_collaborator 里的记录不会自动清理。例外 :MEMBER_CHANGE 事件------"你已被移出项目"的收件人恰恰是不在成员表里的人,按 R6 过滤会让它永远发不出去。
过滤出错时放行而非拦截:查询抖动导致的"整批通知消失"比多发一条更难排查。

五、聚合与幂等
5.1 聚合:同目标窗口内合并
同一收件人 + 同一聚合键 + 未读 + 在聚合窗口内 → 不新增行,只累加 repeat_count 并覆盖标题/正文:
java
public boolean tryAggregate(Notification entity) {
LocalDateTime since = LocalDateTime.now().minusMinutes(window);
Notification existing = mapper.selectOne(new LambdaQueryWrapper<Notification>()
.eq(Notification::getRecipient, entity.getRecipient())
.eq(Notification::getAggregateKey, entity.getAggregateKey())
.eq(Notification::getReadFlag, false)
.ge(Notification::getUpdateTime, since)
.orderByDesc(Notification::getId)
.last("LIMIT 1"));
if (existing == null) return false;
// 原子累加:并发命中同一条时"读出来+1再写回"会丢计数
mapper.incrementAggregate(existing.getId(), entity.getTitle(), entity.getContent(), ...);
return true;
}
为什么在代码里判时间窗口而不是建唯一索引 :一条 INSERT 只能有一个 ON CONFLICT 子句,无法同时表达"来源幂等"与"聚合"两种冲突;唯一索引也表达不了滑动窗口。
5.2 幂等:来源级别去重
insertIgnore 使用 PostgreSQL ON CONFLICT 实现来源级幂等------同一 source_id 的通知只入库一次。聚合键负责"窗口内合并",来源幂等负责"同一事件不重复入库",两者互不冲突。
六、SSE 实时推送
6.1 推送架构
perl
Notification 落库
↓ NotificationPusher.push(payload)
↓ Redis PUBLISH notif:push {payload}
↓
所有实例的 NotificationPushSubscriber 收到
↓ sseEmitterManager.send(companyCode, recipient, "notification", payload)
↓ 只投递给本实例持有的连接
→ 浏览器 SSE EventSource 收到 → 弹 toast + 插入列表
为什么必须走 Redis:多实例部署时用户可能连在任意一个实例上,进程内的连接 Map 只能覆盖本实例。Redis 在这里只做实例间信号转发,不存储消息、不做离线队列------数据库已经是那份队列。
6.2 SSE 连接管理
连接按 企业 + username 登记,而非只按 username:
java
// companyCode:username → 该用户在该企业的全部连接
private final Map<String, Set<SseEmitter>> emitters = new ConcurrentHashMap<>();
// 每个连接一把锁(SseEmitter.send 并发不安全)
private final Map<SseEmitter, Object> locks = new ConcurrentHashMap<>();
同一用户允许多条连接(多标签页 / 多设备),同一企业在多个标签页都登记在同一个 key 下。
连接清理 :onCompletion / onTimeout / onError 三处都要注销,集合空了必须删 key,否则 Map 只增不减。
6.3 Token 走 Query
浏览器原生 EventSource 不支持自定义请求头,token 只能走 ?token=xxx:
java
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@PathVariable String companyCode, @RequestParam String token) {
StpUtil.setTokenValue(token); // 手动设进 sa-token 上下文
String username = LoginHelper.getUsername();
SseEmitter emitter = sseEmitterManager.register(companyCode, username);
// 建连后立刻推一次未读数(D4-3:前端不必再多发一次 HTTP)
sseEmitterManager.send(companyCode, username, "unread-count",
Map.of("total", notificationService.unreadCount().getTotal()));
return emitter;
}
6.4 心跳保活
反向代理通常 60 秒空闲就断连,必须服务端主动发心跳:
java
@Scheduled(fixedRateString = "${notification.heartbeat-ms:25000}")
public void ping() {
if (sseEmitterManager.getConnectionCount() == 0) return;
sseEmitterManager.broadcast("ping", System.currentTimeMillis());
}
6.5 已读广播
已读状态通过独立 Redis 频道(notif:read)广播,与推送频道分开------"有新通知"和"已读过"是两种语义完全不同的信号,前端处理方式也不同:
bash
用户在标签页 A 点了已读
↓ Redis PUBLISH notif:read {username, id, total}
↓ 所有实例的 NotificationReadSubscriber
↓ sseEmitterManager.send(company, username, "read", payload)
→ 标签页 B 收到 → 静默更新角标
七、通知偏好与噪音控制
7.1 按类型关闭(F-19)
用户可在通知偏好抽屉中逐类型开关。表里只存"关闭"项,开启时不留行。过滤时一次查询批量取回本批收件人中关闭了该类型的用户,避免逐人查表(N+1):
java
public Set<String> disabled(Collection<String> recipients, String category) {
List<NotificationSetting> rows = mapper.selectList(new LambdaQueryWrapper<NotificationSetting>()
.in(NotificationSetting::getUsername, recipients)
.eq(NotificationSetting::getEnabled, false)
.in(NotificationSetting::getCategory, List.of(category, "*")));
return rows.stream().map(NotificationSetting::getUsername).collect(toSet());
}
7.2 免打扰时段(F-20)
免打扰只抑制实时推送,通知照常入库。用户上线后仍能在列表里看到,次日由摘要任务汇总一条提醒。
支持跨零点(22:00-08:00):此时 end 早于 start,判定条件是"不早于 start 或 早于 end"。
7.3 三个定时任务
| 任务 | 时间 | 语义 | 数据口径 |
|---|---|---|---|
NotificationDueReminderJob |
每日 09:00 | "哪条要到期了" | 今天到期的工作项、逾期 30 天内的工作项、3 天内到期的里程碑 |
NotificationDigestJob |
每日 08:05 | "昨晚有没有事" | 免打扰用户最近 12 小时的未读通知 |
NotificationDailySummaryJob |
每日 08:30 | "今天该干什么" | 此刻的存量:未完成工作项 + 今天到期 + 已逾期 + 未读通知 |
三个任务的职责不重叠:
- 到期提醒指向具体对象(
targetId),点击能直达 - 免打扰汇总只服务免打扰用户,回答"昨晚有没有事"
- 每日摘要服务所有人,回答"今天该干什么"
当日只提醒/汇总一次 :用 (category, targetType, targetId, 今天) 查一次是否已经发过。不能只靠聚合键去重------聚合窗口只有 5 分钟,用户读掉后重跑任务仍会再插一条。
空盘子不发:既没有未完成工作项、也没有未读通知的人跳过------每天收一条"今日无事"只会让用户关掉整类通知。
7.4 单日量阈值告警
人均日通知量超过阈值时只观测不拦截,计数走 Redis INCR:
java
String key = "notif:daily:" + companyCode + ":" + recipient + ":" + today;
Long total = redisTemplate.opsForValue().increment(key);
if (total == threshold + 1) {
// 刚好越过阈值那一条上查一次 DB 拿 Top 类型
log.warn("单日通知量超阈值 recipient={} total={} topCategory={}", ...);
}
原来每条通知都发一次 COUNT(*) 扫一遍当天该公司的全部通知------一次 50 人的事件就是 50 次扫描。这里 O(1) 计数,只在越过阈值那一条上查一次。
八、外部通道
8.1 邮件(F-30)
默认关闭,需同时配置 notification.mail-enabled=true 和 spring.mail.host。投递结果回写到 mail_status(success / failed / skipped),与站内 send_status 分开------同一个值既表示"已入收件箱"又表示"邮件已发"会互相覆盖。
8.2 Webhook(F-31)
notification.webhook-url 非空即启用,每条通知 POST 一份 JSON。3 秒超时兜底,外部通道不能拖慢通知链路。
失败只记 WARN:外部通道是增值能力,不能影响站内通知落库。
九、预警规则体系
9.1 三条 P0 规则
| 规则 | 编码 | 等级 | 触发条件 |
|---|---|---|---|
| 工作项逾期 | WORK_ITEM_OVERDUE |
按天数与优先级动态判定 | 计划结束日期已过且未完成 |
| 里程碑临期未完成 | MILESTONE_DUE_SOON |
HIGH |
N 天内到期且仍有未完成工作项 |
| 迭代低完成率 | ITERATION_DUE_LOW_PROGRESS |
MID |
临近结束且完成率低于阈值 |
9.2 逾期等级动态判定
工作项逾期的等级不是固定的,而是根据逾期天数和优先级动态计算:
java
private static String overdueLevel(long days, String priority) {
if (days >= 7) return AlertLevel.CRITICAL;
if (days >= 3) return AlertLevel.HIGH;
if ("high".equalsIgnoreCase(priority) || "紧急".equals(priority) || "高".equals(priority))
return AlertLevel.HIGH;
if (days >= 1) return AlertLevel.MID;
return AlertLevel.LOW;
}
9.3 预警等级与状态
四个等级按权重排序(critical > high > mid > low),由于字典序不满足(critical 在 high 之前但 mid 在 low 之后),查询时用 CASE WHEN 手工排序:
sql
ORDER BY CASE level
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'mid' THEN 2
ELSE 3
END, id DESC
三个状态的生命周期:
arduino
扫描命中
┌──────────────────────┐
↓ │
open ──── 用户忽略 ───→ ignored
│
条件消失(扫描未命中)
↓
resolved
open:风险仍成立,每小时刷新last_seen_atignored:用户主动忽略,扫描不再重开resolved:条件消失,扫描自动解除

十、预警定时扫描机制
10.1 扫描架构
less
@Scheduled(cron = "0 0 * * * ?") // 每小时整点
AlertScanJob.scheduledScan()
↓
scanAll(today)
├── listActiveCompanyCodes() → 查活跃企业
└── 逐企业:
LoginHelper.runWithCompany(company, () -> scanOne(company, day))
├── scanWorkItemOverdue() → A-01
├── scanMilestoneDueSoon() → A-02
├── scanIterationLowProgress() → A-03
├── 逐 hit: upsertHit(entity) → 幂等写入/刷新
└── resolveMissing(hitKeys) → 未命中的 open → resolved
10.2 多租户上下文注入
定时任务没有登录态,必须逐企业注入租户上下文后再扫业务表:
java
for (String company : companies) {
LoginHelper.runWithCompany(company, () -> scanOne(company, day));
}
不注入的后果 :租户插件会给 biz_work_item 拼 company_code = NULL,命中为 0,却仍能通过白名单表 biz_alert 找到 open 企业并空 hitKeys resolve,导致误清空已有预警。
10.3 幂等 Upsert
同一风险不重复建行,只刷新 last_seen_at 和文案。ignored 行不会被重开:
sql
INSERT INTO biz_alert (...) VALUES (...)
ON CONFLICT (company_code, dedupe_key) WHERE deleted = FALSE
DO UPDATE SET
level = EXCLUDED.level,
title = EXCLUDED.title,
content = EXCLUDED.content,
last_seen_at = NOW(),
status = CASE
WHEN biz_alert.status = 'ignored' THEN biz_alert.status
ELSE 'open'
END
RETURNING id
10.4 自动解除
本轮扫描未再命中的 open 行自动标 resolved。hitKeys 为空集合表示该规则下全部 open 都应解除:
sql
UPDATE biz_alert
SET status = 'resolved', resolved_at = NOW()
WHERE company_code = #{companyCode}
AND deleted = FALSE
AND status = 'open'
AND rule_code IN (...)
AND dedupe_key NOT IN (#{hitKeys})
10.5 "与我相关"过滤
预警的"与我相关"口径与通知不同:
| 目标类型 | 与我相关 |
|---|---|
| 工作项 | 负责人 或 协作人(实时关联 biz_work_item_collaborator) |
| 里程碑 / 迭代 | 仅负责人 |
sql
(target_type = 'WORKITEM' AND (
owner = {username}
OR EXISTS (
SELECT 1 FROM biz_work_item_collaborator c
WHERE c.work_item_id = target_id AND c.username = {username}
)
))
OR (target_type IN ('MILESTONE', 'ITERATION') AND owner = {username})

十一、前端设计
11.1 通知中心
通知中心(NotificationCenter.vue)提供完整的历史通知浏览:
- 分页列表:按"最后发生时间"排序(聚合过的通知回到列表顶部)
- 筛选维度:已读/未读切换、类型下拉、时间范围(近 7 天 / 近 30 天 / 全部)
- 分组展示:按日期分组,每组显示条数
- 操作:单条标已读、全部已读、删除
- 聚合展示 :
repeatCount > 1时显示"共 N 次更新" - 点击跳转:点击通知项直达对应业务对象
11.2 通知偏好抽屉
通知偏好(NotificationSettingDrawer.vue)提供噪音控制:
- 免打扰时段:两个时间选择器,起止必须同时填写或同时清空
- 自己操作也通知我:默认关闭
- 按类型开关 :逐类型
el-switch,局部更新语义
11.3 预警页面
预警页面(alert/index.vue)是企业级风险中心:
- KPI 卡片:未解除总数、与我相关、紧急、严重
- 筛选工具栏:全部 / 与我相关、状态、等级、规则、关键词搜索
- 列表表格:等级标签(按颜色区分)、标题与摘要、规则标签、负责人、状态标签、最近检出时间
- 操作:忽略、立即扫描(验收用)
11.4 SSE 前端对接
前端使用 EventSource 建立 SSE 连接:
- 建连 :
new EventSource('/companies/{code}/notifications/stream?token=xxx') - 事件处理 :
notification:弹 toast + 插入列表 + 角标 +1unread-count:角标校正read:其他标签页的已读同步ping:心跳,不做处理
- 断线重连 :
onerror后延迟重连,重连后拉一次unread-count补齐离线期间的通知 - 乐观更新 :收到 notification 先本地角标 +1,随即拉
unread-count以服务端回包校正

十二、通知与预警的关系
通知和预警是两个独立但互补的体系:
| 维度 | 通知 | 预警 |
|---|---|---|
| 语义 | 时刻(moment):事件发生了 | 状态(state):风险仍成立 |
| 行数 | 一事件 × N 收件人 = N 行 | 一风险一行(不 fan-out) |
| 生命周期 | 入库后等待已读,不会自动消失 | 条件消失后自动 resolved |
| 触发 | 事件驱动(实时) | 定时扫描(每小时) |
| 推送 | SSE 实时推送 | 无推送,列表查询 |
| 已读 | 个人维度 read_flag |
无已读概念,只有 open / ignored / resolved |
| 个人维度 | 是(收件人、偏好设置) | 否(企业级风险事实) |
它们不重叠也不互相依赖 :通知回答"发生了什么",预警回答"还有什么风险"。一个逾期工作项既会触发到期提醒通知(DUE_REMINDER),也会物化为一条预警(WORK_ITEM_OVERDUE),但两者的数据模型、生命周期和用户交互完全独立。
十三、关键设计决策总结
| 决策 | 理由 |
|---|---|
通知按 (company, recipient) 隔离 |
跨企业不串数据;用户在 A 企业的通知不弹到 B 企业窗口 |
偏好按 username 不按 (company, username) |
同一账号跨企业共享一套开关,避免重复配置 |
| 表里只存"关闭"项 | 默认开启不需要留行,减少存储与查询 |
| 推送走 Redis Pub/Sub 而非消息队列 | 推送只是加速信号,数据库已是那份队列;MQ 会增加运维成本 |
SSE 连接按 company + username 登记 |
跨企业推送会弹到无关窗口 |
| 预警不 fan-out | 一条风险一行,所有人看同一份事实;"与我相关"是查询时过滤 |
预警 ignored 不重开 |
用户判断不需要关注的,扫描不应该反复重开 |
| 免打扰照常入库但不推送 | 用户上线后仍能在列表看到;关掉入库才是真丢消息 |
| 每日摘要排除自身 | 摘要产生未读 → 下次摘要把它算进去 → 自我指涉累积 |
| 到期提醒不经过动态 | 到期提醒没有具体操作者,不该出现在"谁做了什么"的动态流里 |
| 线程池拒绝策略用"丢弃 + WARN" | 不压回业务线程;也不用 DiscardPolicy(静默丢弃查不出原因) |
十四、小结
消息通知与预警是平台"信息到达"能力的两个支柱。通知从事件到收件人的完整链路------规则匹配、四道过滤、聚合幂等、SSE 实时推送------确保每一条通知都精准送到该收的人。预警通过定时物化与自动解除,让风险从"无人知晓"变成"持续可见"。
两者的共同设计原则是:数据库是 source of truth 。推送可能丢、SSE 可能断、Redis 可能挂,但只要数据入库了,用户下次上线拉取就能补齐。推送只是加速信号,不是可靠投递------这个认知贯穿了整个设计:异步线程池的拒绝策略是"丢弃 + WARN"而不是"阻塞业务线程",Redis Pub/Sub 失败只打日志不影响落库,SSE 断线后前端重连拉一次 unread-count 即可恢复。
通知系统的噪音控制同样是核心设计目标而非附加功能:聚合窗口让"改 5 个字段"只来一条通知;免打扰时段让深夜不弹窗但消息不丢;按类型关闭让用户自主决定关心什么;每日摘要帮用户在信息过载时快速了解"今天该干什么"。这些机制层层叠加,确保通知从"有用"不会变成"噪音"。