---
title: 通知中心
doc_type: feature-spec
domain: runtime
keywords:
  - 通知中心
  - 系统通知
  - 通知规则
  - 通知历史
summary: 定义统一通知中心的一阶段边界、公共入口、策略匹配和通知历史真值。
when_to_read:
  - 你要新增或修改系统通知
  - 你要实现通知规则配置页或通知历史页
  - 你要判断通知是否应该静默、发送或去重
read_next:
  - ../operations/startup-sync-self-check.md
  - ../overview/architecture.md
---
# 通知中心

这篇文档说明桌面端统一通知中心的一阶段设计。当前落地先覆盖桌面系统通知，但产品入口已经按“通知中心 + 设置内通知治理”双入口组织；后续如果要接公告类、应用类消息，继续沿同一边界扩展。读完后如果要看运行说明，再跳去 [startup-sync-self-check.md](../operations/startup-sync-self-check.md)。

## 一阶段边界

- 当前正式 producer 先从桌面系统通知 `SYSTEM` 起步
- 当前第一批 producer 只有任务通知
- 当前入口分两层：
  - `/notifications` 通知中心，先消费通知历史
  - `/settings/notifications` 通知治理页，管理规则与审计
- 当前支持两类正式能力：
  - 通知历史消费
  - 通知规则治理
- 当前不实现：
  - 应用内消息收件箱
  - 未读 / 已读
  - 红点 / 角标
  - 多 channel 分发

## 数据模型

### `sys_notification_policy`

这张表定义“什么通知该发、什么通知该静默、去重窗口多长”。

| 字段 | 含义 |
| --- | --- |
| `policy_key` | 策略唯一标识 |
| `scope_type` | 作用域类型：`GLOBAL` / `TRIGGER_SOURCE` / `TASK_KEY` |
| `scope_key` | 作用域值，例如 `*`、`MANUAL`、`notice.ai.analysis` |
| `channel` | 当前固定为 `SYSTEM` |
| `enabled` | 策略是否启用 |
| `notify_on_success` | 成功是否通知 |
| `notify_on_skipped` | 跳过是否通知 |
| `notify_on_failed` | 失败是否通知 |
| `dedupe_window_seconds` | 去重窗口秒数 |
| `created_at` / `updated_at` | 审计时间 |

### `sys_notification_history`

这张表定义“通知中心这次到底记录了什么结果”。虽然当前首批只写系统通知历史，但字段设计已经给后续多来源消息预留了统一边界。

| 字段 | 含义 |
| --- | --- |
| `id` | 主键 |
| `source_type` | 当前第一批为 `TASK` |
| `source_key` | 当前任务键 |
| `channel` | 当前固定为 `SYSTEM` |
| `title` / `body` | 实际通知内容 |
| `severity` | 严重度，如 `INFO` / `ERROR` |
| `trigger_source` | `MANUAL` / `SCHEDULE` / `STARTUP` / `RECOVERY` / `EVENT` |
| `outcome` | `SUCCEEDED` / `SKIPPED` / `FAILED` |
| `delivery_status` | `DELIVERED` / `SUPPRESSED` / `FAILED` |
| `policy_key` | 最终命中的策略 |
| `dedupe_key` | 去重键 |
| `suppressed_reason` | `POLICY_DISABLED` / `OUTCOME_DISABLED` / `DEDUPED` |
| `details_json` | 额外上下文，例如任务耗时、原始任务键等 |
| `created_at` | 记录时间 |

## 策略优先级

通知中心按固定优先级匹配策略，不做多层合并：

1. `TASK_KEY/<source_key>`
2. `TRIGGER_SOURCE/<trigger_source>`
3. `GLOBAL/*`

命中最具体策略后停止继续向下匹配。历史记录必须回写 `policy_key`，方便在历史页里解释这条通知为什么被发送、静默或去重。

## 默认规则

当前初始化时写入以下默认策略：

| 作用域 | 成功 | 跳过 | 失败 | 去重窗口 |
| --- | --- | --- | --- | --- |
| `GLOBAL/*` | 否 | 否 | 是 | 600 秒 |
| `TRIGGER_SOURCE/MANUAL` | 是 | 是 | 是 | 0 秒 |
| `TRIGGER_SOURCE/SCHEDULE` | 否 | 否 | 是 | 600 秒 |
| `TRIGGER_SOURCE/STARTUP` | 否 | 否 | 是 | 600 秒 |
| `TRIGGER_SOURCE/RECOVERY` | 否 | 否 | 是 | 600 秒 |
| `TRIGGER_SOURCE/EVENT` | 否 | 否 | 是 | 600 秒 |

当前不预置任务级 `TASK_KEY/*` 覆盖规则，但设置页允许后续新增。

## 去重语义

- 第一阶段只对“非手动触发的失败通知”启用去重
- 成功和跳过当前默认静默，不需要额外去重
- 手动触发永远不去重
- 去重键固定为：

```text
channel + source_type + source_key + trigger_source + outcome + normalized_body
```

- 去重窗口只参考最近一次 `DELIVERED` 的同键通知
- 不参考 `SUPPRESSED` 记录

这意味着：

1. 第一次失败会真实发送
2. 窗口内同键失败会写历史，但状态为 `SUPPRESSED`
3. 窗口过后如果同键故障还在，会再次发送新的通知

## 运行流程

```mermaid
flowchart LR
    A["Producer\n(task notification)"] --> B["NotificationCenter.publish"]
    B --> C["match policy"]
    C --> D{"should deliver?"}
    D -->|"no"| E["write history\nSUPPRESSED"]
    D -->|"yes"| F{"dedupe hit?"}
    F -->|"yes"| E
    F -->|"no"| G["show system notification"]
    G -->|"ok"| H["write history\nDELIVERED"]
    G -->|"error"| I["write history\nFAILED"]
```

## 页面入口

通知入口分成“消费入口”和“治理入口”两层，不再混在同一个设置页里：

- 普通用户入口：
  - 路径：`/notifications`
  - 位置：工作台顶部公共导航
  - 当前默认展示 `通知历史`
- 治理入口：
  - 路径：`/settings/notifications`
  - 子页：
    - `通知规则`
    - `通知历史`
  - 当前放在设置页“运维与诊断”分组

## 当前 producer

当前第一批 producer 只有任务通知：

- `scheduler.rs -> run_task_once()`
- `task_notification.rs`

这里不再直接决定是否弹系统通知，而是统一构建通知草稿，交给 `NotificationCenter` 做：

- 策略匹配
- 发送判定
- 去重判定
- 历史入库
- 运行轨迹补记：`tsk_run_event` 会追加 `NOTIFICATION_DELIVERED / NOTIFICATION_SUPPRESSED / NOTIFICATION_FAILED`

## 后续扩展方向

如果后续要继续扩展统一通知中心，优先顺序建议是：

1. 新增更多 producer，例如公告解析完成、初始化包导入结果、更新提醒
2. 新增多 channel 分发
3. 再评估应用内 inbox / 未读体系

不要反过来先做 inbox，再回头补系统通知治理。
