DevOps平台 — 第十九篇:消息通知与预警的设计与实现

工作项、迭代、里程碑、版本、测试......每个模块都在产生事件。如果没有通知,用户只能靠手动刷新页面发现变化;如果没有预警,风险一直摆在那里却无人知晓。消息通知解决「该让谁知道什么」,预警解决「当前还有什么风险仍然成立」。两者看似都是"提醒",但语义模型完全不同:通知是时刻(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:

  1. @Async 依赖代理,同类内部调用会静默失效
  2. 拒绝策略要精确控制------必须"丢弃 + 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_at
  • ignored:用户主动忽略,扫描不再重开
  • 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 + 插入列表 + 角标 +1
    • unread-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 个字段"只来一条通知;免打扰时段让深夜不弹窗但消息不丢;按类型关闭让用户自主决定关心什么;每日摘要帮用户在信息过载时快速了解"今天该干什么"。这些机制层层叠加,确保通知从"有用"不会变成"噪音"。

相关推荐
掘金酱22 分钟前
1024·新饭碗计划活动 | 获奖名单公示
vibecoding
IT_陈寒42 分钟前
React的setState真是个演员,你以为它同步其实在装睡
前端·人工智能·后端
ServBay2 小时前
Claude Fable 5.5 测试曝光,但它在悄悄给你打分
后端·ai编程·claude
IT规划师3 小时前
一文讲透五种发布机制:滚动、蓝绿、金丝雀、红黑与 AB
后端·面试
一条Go4 小时前
两套权限目录,三重闸门:Alioth 的多租户 RBAC 为什么这么拧
后端·程序员·github
Tokenfree4 小时前
把多模态 OCR 接进业务系统:四千张运单,三个坑
后端
用户859313061354 小时前
我给 AI Agent 写了个下载器:293KB 的网页,不再吃掉 7 万 token
后端
phil65 小时前
沙箱核心功能系列 06:编排调度与资源管理
后端
小兔子5 小时前
if (system(cmd) != 0) 判不出命令失败:它返回的是等待状态,不是退出码
后端