Files
f9-web-ai-test/消息推送系统软件设计说明书.md
T
2026-10-08 10:43:59 +08:00

818 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 消息推送系统 软件设计说明书
| 项目名称 | 生活圈管理系统(F9WebAiTest)- 消息推送子系统 |
|---------|------------------------------------------|
| 文档类型 | 软件设计说明书(SDD) |
| 版本号 | **V2.0** |
| 编写日期 | 2026-09-15 |
| 后端技术栈 | .NET 8 / FreeSql(CodeFirst)/ MediatR / SignalR / JWT / SQLite |
| 前端技术栈 | Vue 3 / Vite / Element Plus / Pinia / @microsoft/signalr |
---
## 修订记录
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| V1.1 | 2026-09-09 | 初版:后台投递服务(BackgroundService 每 3 秒轮询扫描 Ready 消息)异步投递 |
| **V2.0** | **2026-09-15** | **取消后台投递服务与轮询,改为"创建消息即同步投递 + 立即推送"**;投递逻辑由 Api 层后台服务迁入 Application 层命令处理器;新增 `INotificationPusher` 推送抽象;明确在线/离线判定机制;同步修订可靠性设计与接口说明 |
### V2.0 架构变更说明(重点)
| | V1.1(旧) | V2.0(现) |
|---|---|---|
| 投递触发 | 后台服务每 3 秒轮询 `Status = Ready` 的消息 | 业务创建消息的**同一次请求内**完成投递 |
| 投递位置 | `F9WebAiTest.Api/Services/NotificationDeliveryService.cs`(BackgroundService) | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs`(命令处理器) |
| 消息状态 | 创建 `Ready` → 投递后 `Sent` | 创建即 `Sent`(无中间态) |
| 推送方式 | 后台服务持有 `IHubContext` 直接推送 | 经 `INotificationPusher` 抽象推送(接口在应用层、实现在 Api 层) |
| 延迟 | 0 ~ 3 秒(取决于轮询周期) | **无延迟**(创建即推送) |
| 失败重试 | `RetryCount++`,超 `MaxRetryCount` 标记 `Failed` | **无自动重试**(见 7.3 取舍说明) |
**为什么取消后台服务**:投递本身是一次内存 + 一次数据库写入的轻量操作,用后台轮询换取"解耦"的收益远小于代价——引入了 0~3 秒延迟、常驻线程与定时器、Ready 状态的中间态管理、以及 SQLite 上的高频无谓查询。改为同步投递后,链路更短、行为更可预期、代码量显著减少。
---
## 1. 引言
### 1.1 编写目的
本文档描述消息推送子系统的总体架构、数据模型、**各模块的具体实现方式、所用技术与关键流程**,特别是**用户在线/离线如何判定**、离线用户如何兜底,作为开发、测试、维护与后续扩展的依据。
### 1.2 项目背景
业务系统(请假审批、考勤、质量检验等)在运行过程中需要向相关人员推送提醒、通知与待办。为统一承载这些消息,构建一套通用、可扩展、多端协同的消息推送子系统。
### 1.3 术语定义
| 术语 | 英文/类型 | 说明 |
|------|----------|------|
| 消息类型 | NotificationType | 消息的元数据模板:编码、名称、分类、订阅权限码、跳转路由模板 |
| 消息 | NotificationMessage | 一条具体的消息实例,由业务触发创建 |
| 接收人 | NotificationReceiver | 消息 × 用户 的关联记录,含该用户的推送状态与阅读状态 |
| 订阅 | UserNotificationSubscription | 用户对某个消息类型的订阅关系 |
| 待办 | TodoItem | 待办类消息附带的事项,含处理人、状态、处理结果 |
| 强制接收人 | ForceReceivers | 绕过订阅、精准指定送达的用户 ID 集合(JSON 数组) |
| 在线 | Online | 该用户在 SignalR Hub 上存在**活动连接**(见 4.6) |
| 离线 | Offline | 该用户在 Hub 上无活动连接,推送无接收者 |
| 拉取兜底 | Pull Fallback | 离线用户上线后通过 REST 接口读取历史消息,保证消息不丢 |
---
## 2. 系统概述
### 2.1 设计目标
1. **统一触达**:业务侧一行命令即可发送消息,不关心投递细节。
2. **双送达机制**:订阅式广播(按消息类型订阅关系)+ 强制接收(精准必达)。
3. **实时推送**:在线用户 SignalR **即时**推送,离线用户持久化后拉取兜底。
4. **三类消息**:通知(Notification)/ 待办(Todo)/ 公告(Announcement)。
5. **待办闭环**:待办类消息自动生成 `TodoItem`,支持查询与完成。
6. **多端跳转与同步**:消息携带业务标识与路由,多端一致。
7. **架构简洁**:无后台任务、无轮询、无中间队列。
### 2.2 功能范围
| 模块 | 功能点 |
|------|--------|
| 消息类型管理 | 类型增删改查、分类、订阅权限码、跳转路由模板 |
| 消息订阅 | 可订阅列表(含权限判定)、订阅/退订 |
| 消息发送 | 业务侧经 MediatR 命令触发(枚举式 / 底层式两种命令) |
| 消息投递 | 创建即投递:确定接收人 → 写接收人表 → 立即推送 |
| 消息读取 | 我的消息(分页 + 分类/已读筛选)、标记已读、全部已读、未读数 |
| 待办管理 | 我的待办、完成待办 |
| 实时推送 | 新消息推送、未读统计推送、业务变更事件推送 |
| 广播通知 | 拥有权限的用户发送广播(订阅广播) |
### 2.3 运行环境与技术栈
| 项目 | 选型 | 用途 |
|------|------|------|
| 后端框架 | .NET 8(ASP.NET Core) | Web API 宿主 |
| ORM | **FreeSql**(`UseConnectionString(DataType.Sqlite, ...)`) | 实体映射、CodeFirst 建表、仓储实现 |
| 数据结构 | SQLite(`f9_web_ai_test_rbac.db`) | 单文件数据库,便于开发与部署 |
| 应用层模式 | **MediatR** | 命令/查询分发(CQRS 风格) |
| 实时通信 | **SignalR**(WebSocket,自动降级 SSE / Long Polling) | 服务端 → 客户端实时推送 |
| 鉴权 | JWT(`System.IdentityModel.Tokens.Jwt`) | REST 与 Hub 统一鉴权 |
| API 文档 | NSwag(OpenAPI + Swagger UI) | 接口文档与客户端生成 |
| 在线登记 | `System.Collections.Concurrent.ConcurrentDictionary` | 进程内在线用户表 |
| 前端 | Vue 3 + Vite + Element Plus + Pinia | 管理端界面 |
| 前端实时 | `@microsoft/signalr` | SignalR 客户端 |
### 2.4 架构总览
```
┌────────────────────────────────────────────────────────────────────────┐
│ 业务子系统(Application) │
│ 请假审批 AttendanceService 考勤 质量检验 … │
│ │ │
│ │ await mediator.Send(new SendNotificationCommand(...)) │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ CreateNotificationCommandHandler ← 投递入口(同步执行) │ │
│ │ ① 校验消息类型 ② 建消息(Sent) ③ 生成待办(Todo) │ │
│ │ ④ 确定接收人 ⑤ 写接收人表 ⑥ 立即推送 │ │
│ └───────────────────────────┬──────────────────────────────────┘ │
│ │ INotificationPusher(应用层接口) │
└──────────────────────────────┼─────────────────────────────────────────┘
│ 实现(Api 层)
▼
┌──────────────────────────────────┐
│ NotificationPusher │
│ · IsOnline() ← 在线表判定 │
│ · PushNewMessageAsync() │
│ · PushUnreadCountAsync() │
└──────────────┬───────────────────┘
│ IHubContext<NotificationHub>
▼
┌───────────────────────────────────────────────────┐
│ SignalR Hub /hubs/notification │
│ OnConnectedAsync → OnlineUserRegistry.Add │
│ OnDisconnectedAsync → OnlineUserRegistry.Remove │
│ UserIdentifier → JWT "id" 声明 │
└───────────────┬───────────────────────────────────┘
│ 事件:ReceiveNotification / UnreadCountChanged
▼
┌───────────────────────────────────────────────────┐
│ 前端 notification.js(SignalR 客户端,自动重连) │
│ unreadCount+1 → 弹窗 / 角标 / 页面刷新 │
└───────────────────────────────────────────────────┘
```
---
## 3. 数据模型
### 3.1 枚举定义
文件:`F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs`
```csharp
public enum NotificationPriority { Normal = 0, Important = 1, Urgent = 2 } // 优先级
public enum NotificationStatus { Ready = 0, Sent = 1, Failed = 2, PartialSuccess = 3, Revoked = 4 }
public enum NotificationReadStatus { Unread = 0, Read = 1 } // 阅读状态
public enum NotificationSendStatus { NotSent = 0, Success = 1, Failed = 2 } // 推送状态
public enum TodoItemStatus { Pending = 0, Processing = 1, Completed = 2, Cancelled = 3 }
public enum NotificationCategory { Notification = 0, Todo = 1, Announcement = 2 }
public enum NotificationTypeEnum { TeyeFound = 1, TdcFound = 2, LeaveApproval = 3, LeaveResult = 4 }
```
> `NotificationStatus`:V2.0 下消息创建即为 `Sent`,`Ready`/`Failed`/`Revoked` 保留以兼容历史数据与后续扩展。
### 3.2 实体一览(5 张表)
目录:`F9WebAiTest.Domain/Entities/Notice/`(每个实体独立文件)
| 实体 | 表名 | 关键字段 | 用途 |
|------|------|---------|------|
| `NotificationType` | `NotificationTypes` | `Code`(唯一)、`Name`、`Category`、`RequiredPermission`、`RouteTemplate`、`IsActive`、`Sort` | 消息类型模板 |
| `NotificationMessage` | `NotificationMessages` | `NotificationTypeId`、`MessageTypeCode`、`Title`、`Content`、`Priority`、`Status`、`ForceReceivers`、`Category`、`RoutePath` | 消息主表 |
| `NotificationReceiver` | `NotificationReceivers` | `MessageId`、`UserId`、`ReadStatus`、`ReadTime`、`IsOffline`、`SendStatus`、`SendTime`、`ErrorMsg` | 每人一条,独立推送/阅读状态 |
| `TodoItem` | `TodoItems` | `MessageId`、`AssigneeId`、`TodoStatus`、`DueTime`、`CompletedTime`、`HandleResult` | 待办事项 |
| `UserNotificationSubscription` | `UserNotificationSubscriptions` | `UserId`、`NotificationTypeId`、`IsActive`、`SubscribedTime`、`CancelTime` | 订阅关系 |
### 3.3 关系
```
NotificationType 1 ── N NotificationMessage 1 ── N NotificationReceiver N ── 1 User
│ │
│ 1 │ 1
└── N UserNotificationSubscription └── N TodoItem (AssigneeId → User)
```
### 3.4 关键约束(幂等保障)
| 约束 | 位置 | 作用 |
|------|------|------|
| `Code` 唯一 | `NotificationTypes` | 类型编码唯一,业务按 Code 发送 |
| `MessageId + UserId` 唯一 | `NotificationReceivers` | **接收人幂等**,重复投递不会产生重复记录 |
| `UserId + NotificationTypeId` 唯一 | `UserNotificationSubscriptions` | 订阅幂等 |
| 软删除 | 全部实体(`BaseEntity`) | 逻辑删除,审计可追溯 |
建表方式:FreeSql CodeFirst(`Publish` + 种子由 `Infrastructure/Data/DbSeeder.cs` 完成)。
### 3.5 实体 ↔ DTO ↔ 接口 对照总表
> 说明:本项目 DTO 以 `class` 定义在 `F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(集中文件);
> 实体为**纯属性 POCO**(无领域方法,状态变更由应用服务直接赋值后 `UpdateAsync` 持久化)。
| 实体(表) | 出参 DTO(读) | 入参 DTO / 命令(写) | 数据访问接口 | 业务处理接口 |
|---|---|---|---|---|
| `NotificationType`(`NotificationTypes`) | `NotificationTypeDto` | `NotificationTypeCreateDto` | `IRepository<NotificationType>` | `INotificationService.CreateTypeAsync / UpdateTypeAsync / DeleteTypeAsync / GetTypeListAsync / GetSubscribeListAsync`(内部 `SyncSubscribePermissionAsync` 同步 RBAC 权限单元) |
| `NotificationMessage`(`NotificationMessages`) | `NotificationMessageDto`(列表 + **SignalR 推送载荷**) | `CreateNotificationCommand`、`SendNotificationCommand`、`BroadcastNotificationDto`、`TriggerDto` | `IRepository<NotificationMessage>` | `CreateNotificationCommandHandler`(创建 + 即时投递)、`INotificationService.GetMyMessagesAsync` |
| `NotificationReceiver`(`NotificationReceivers`) | `NotificationMessageDto.ReadStatus / ReadTime`、`UnreadCountDto` | 无(由投递过程内部生成,不接受外部入参) | `IRepository<NotificationReceiver>` | `CreateNotificationCommandHandler.DeliverAsync`(写入 + 回写推送状态)、`INotificationService.MarkReadAsync / MarkAllReadAsync / GetUnreadCountAsync` |
| `TodoItem`(`TodoItems`) | `TodoItemDto` | `CompleteTodoDto` | `IRepository<TodoItem>` | `CreateNotificationCommandHandler`(按 `AssigneeIds` 生成待办)、`INotificationService.GetMyTodosAsync / CompleteTodoAsync` |
| `UserNotificationSubscription`(`UserNotificationSubscriptions`) | `NotificationTypeDto.Subscribed`(并入类型列表) | `SubscribeDto` | `IRepository<UserNotificationSubscription>` | `INotificationService.SubscribeAsync`(含 `RequiredPermission` 权限校验) |
### 3.6 DTO 明细(字段级)
文件:`F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(`TriggerDto` 定义在控制器文件内)
**① 消息类型**
| DTO | 字段 | 类型 | 说明 |
|-----|------|------|------|
| `NotificationTypeDto`(出参) | Id / Code / Name / Description | long / string ×3 | 基础信息 |
| | RequiredPermission | string | 订阅所需权限码,空 = 所有人可订阅 |
| | IsActive / CreatedBy / Sort | bool / long / int | 开放订阅 / 创建人 / 排序 |
| | Category / Icon / RouteTemplate | int / string / string | 分类(0通知1待办2公告) / 图标 / 路由模板 |
| | **Subscribed** | bool | **当前用户是否已订阅**(`GetSubscribeListAsync` 填充) |
| | **CanSubscribe** | bool | **当前用户是否有权订阅**(权限码比对结果) |
| `NotificationTypeCreateDto`(入参) | Code / Name / Description / RequiredPermission / IsActive / Sort / Category / Icon / RouteTemplate | 同上(无 Id/CreatedBy/Subscribed/CanSubscribe) | 创建与更新共用 |
**② 订阅**
| DTO | 字段 | 类型 | 说明 |
|-----|------|------|------|
| `SubscribeDto` | NotificationTypeId | long | 目标消息类型 |
| | Subscribe | bool | true=订阅,false=退订 |
**③ 消息**
| DTO | 字段 | 类型 | 说明 |
|-----|------|------|------|
| `NotificationMessageDto` | Id / NotificationTypeId | long | 消息ID / 类型ID |
| | MessageTypeCode / TypeName | string | 类型编码(冗余,免联表)/ 类型名称 |
| | Title / Content | string | 标题 / 内容 |
| | Priority | int | 0普通 1重要 2紧急 |
| | BusinessType / BusinessId | string | 业务类型 / 业务单据ID(多端跳转依据) |
| | Category | int | 0通知 1待办 2公告 |
| | RoutePath | string | 最终跳转路由(模板 + 业务ID 生成) |
| | CreatedTime | DateTime | 创建时间 |
| | **ReadStatus** / ReadTime | int / DateTime? | **当前用户**阅读状态(来自接收人表) |
| `UnreadCountDto` | Count | int | 未读消息数 |
**④ 待办**
| DTO | 字段 | 类型 | 说明 |
|-----|------|------|------|
| `TodoItemDto` | Id / MessageId | long | 待办ID / 关联消息ID |
| | Title / Content / Priority | string / string / int | 取自关联消息 |
| | BusinessType / BusinessId / RoutePath | string | 取自关联消息(供"去处理"跳转) |
| | TodoStatus | int | 0待处理 1处理中 2已完成 3已取消 |
| | DueTime / CompletedTime / CreatedTime | DateTime? / DateTime? / DateTime | 截止 / 完成 / 创建时间 |
| `CompleteTodoDto` | HandleResult | string | 处理结果说明 |
**⑤ 广播与测试**
| DTO | 字段 | 说明 |
|-----|------|------|
| `BroadcastNotificationDto` | Title、Content、Priority(`NotificationPriority?`)、RoutePath | 广播入参(需 `notification:broadcast` 权限) |
| `TriggerDto` | Code(默认 `TeyeFound`)、Title、Content、BusinessType、BusinessId、Priority、RoutePath、AssigneeIds | 测试/联调触发(`POST /api/notification/trigger`) |
**通用返回包装**
| 类型 | 说明 |
|------|------|
| `ApiResult<T>` | `{ success, message, code, data }`,`ApiResult<T>.Ok/Fail` |
| `PageResult<T>` | `{ items, total, pageIndex, pageSize }`(`NotificationMessageDto` 分页) |
### 3.7 接口清单与实现绑定
**① 领域层 —— 通用仓储(所有实体共用)**
| 接口 | 方法 | 实现类 | 注册 |
|------|------|--------|------|
| `IRepository<T>`(`Domain/Common/IRepository.cs`) | `GetByIdAsync`、`GetAllAsync`、`FindAsync`、`FirstOrDefaultAsync`、`AnyAsync`、`CountAsync`、`AddAsync`、`AddRangeAsync`、`UpdateAsync`、`DeleteAsync`、`DeleteRangeAsync`、`HardDeleteAsync`、`HardDeleteRangeAsync`、`SaveChangesAsync` | `Repository<T>`(`Infrastructure/Repositories`) | `AddScoped(typeof(IRepository<>), typeof(Repository<>))` |
> 5 个通知实体**不各自定义仓储接口**,统一使用泛型 `IRepository<T>`(FreeSql 实现,内含软删除全局过滤器)。
**② 应用层 —— 通知服务**
| 接口 | 方法(11) | 实现类 | 生命周期 |
|------|-----------|--------|---------|
| `INotificationService`(`Application/Services/Notice/NotificationService.cs`) | `GetSubscribeListAsync`、`SubscribeAsync`、`GetMyMessagesAsync`、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync`、`GetMyTodosAsync`、`CompleteTodoAsync`、`GetTypeListAsync`、`CreateTypeAsync`、`UpdateTypeAsync`、`DeleteTypeAsync` | `NotificationService` | `AddScoped<INotificationService, NotificationService>()` |
依赖(构造函数注入):`IRepository<NotificationType>`、`IRepository<UserNotificationSubscription>`、`IRepository<NotificationMessage>`、`IRepository<NotificationReceiver>`、`IRepository<TodoItem>`、`IRepository<RolePermission>`、`IRepository<Permission>`、`ISnowflakeIdGenerator`、`IAuthService`。
**③ 应用层 —— 推送抽象**
| 接口 | 方法(3) | 实现类(所在层) | 生命周期 |
|------|----------|----------------|---------|
| `INotificationPusher`(`Application/Services/Notice/INotificationPusher.cs`) | `bool IsOnline(long userId)`、`Task<bool> PushNewMessageAsync(long userId, NotificationMessageDto, CancellationToken)`、`Task PushUnreadCountAsync(long userId, int, int, CancellationToken)` | `NotificationPusher`(**Api 层**,`Api/Services/NotificationPusher.cs`) | `Program.cs`:`AddScoped<INotificationPusher, NotificationPusher>()` |
**④ 应用层 —— MediatR 命令(消息创建的接口实现)**
| 类型 | 实现的接口 | 处理器 | 返回 |
|------|-----------|--------|------|
| `CreateNotificationCommand` | `IRequest<long>` | `CreateNotificationCommandHandler : IRequestHandler<CreateNotificationCommand, long>` | 消息 ID |
| `SendNotificationCommand`(record) | `IRequest<bool>` | `SendNotificationCommandHandler : IRequestHandler<SendNotificationCommand, bool>` | 是否成功(内部转调 Create) |
注册:`services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly))`。
**⑤ 表示层 —— SignalR 与控制器**
| 类型 | 实现的接口/基类 | 职责 | 注册 |
|------|---------------|------|------|
| `NotificationHub` | `Hub`(`Microsoft.AspNetCore.SignalR`) | 连接生命周期 → 在线表登记与移除 | `AddSignalR()` + `MapHub<NotificationHub>("/hubs/notification")` |
| `NotificationUserIdProvider` | `IUserIdProvider` | 用户标识 = JWT `"id"` 声明(决定 `Clients.User` 命中规则) | `AddSingleton<IUserIdProvider, NotificationUserIdProvider>()` |
| `NotificationTypeController` | `ControllerBase`(`[Authorize]`) | 类型管理 REST | `AddControllers()` |
| `NotificationController` | `ControllerBase`(`[Authorize]`) | 订阅/消息/待办/未读/触发/广播 REST | 依赖 `INotificationService`、`IMediator`、`IAuthService` |
**⑥ 业务变更事件(多端同步)**
| 类型 | 实现的接口 | 说明 |
|------|-----------|------|
| `BusinessChangedEvent` | `INotification`(MediatR 通知) | 业务状态变化事件 |
| `BusinessChangedEventHandler` | `INotificationHandler<BusinessChangedEvent>` | 经 SignalR 推送 `BusinessChanged` 事件;注册 `AddScoped<INotificationHandler<BusinessChangedEvent>, BusinessChangedEventHandler>()` |
**⑦ 依赖的框架级接口**
| 接口 | 用途 | 实现/注册 |
|------|------|----------|
| `ISnowflakeIdGenerator` | 雪花 ID(消息/接收人/待办/权限单元主键) | `SnowflakeIdGenerator`,`AddSingleton` |
| `IAuthService` | `GetUserPermissionsAsync(userId)` → 订阅权限校验 | `AuthService`,`AddScoped` |
| `IJwtService` | 签发 access/refresh token(含 `"id"` 声明) | `JwtService`,`AddScoped` |
---
## 4. 模块设计(各模块如何实现)
### 4.1 领域层:实体与枚举
- 位置:`F9WebAiTest.Domain/Entities/Notice/`
- 实现要点:每个实体独立文件、继承 `BaseEntity`(含 `Id`、审计字段与软删除);实体为**纯数据 POCO**(属性 + 默认值),状态变更由应用服务赋值(如 `receiver.ReadStatus = Read`)后经仓储 `UpdateAsync` 持久化。
- 字段映射:`[Table(Name = "...")]` + `[Index(...)]` 特性声明表名与索引;枚举统一映射为 `int`(`Infrastructure/DependencyInjection.cs` 的 `Aop.ConfigEntityProperty`)。
- 技术:纯 POCO,无框架依赖(仅 `FreeSql.DataAnnotations` 特性,领域层不引用基础设施)。
### 4.2 应用层:消息创建命令 —— 投递核心 ★
- 位置:`F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs`
- 类型:
- `SendNotificationCommand`(**简洁命令**,基于 `NotificationTypeEnum`,业务侧推荐)
- `CreateNotificationCommand`(**底层命令**,按字符串 Code,灵活控制)
- `SendNotificationCommandHandler`:枚举名 → Code,转调底层命令(复用同一套投递逻辑)
- `CreateNotificationCommandHandler`:**投递实现**
**处理流程(一次请求内同步完成)**:
```
① 校验消息类型:按 Code 查 NotificationType,不存在/已关闭订阅 → 抛 ServiceException
② 生成跳转路由:命令指定的 RoutePath 优先,否则用类型模板替换 {businessId}
③ 创建消息:状态直接置 NotificationStatus.Sent(无后台队列、无 Ready 中间态)
ForceReceivers 序列化为 JSON 存入消息(空 = 走订阅广播)
④ 待办类消息(Category = Todo 且有 AssigneeIds):为每个处理人生成 TodoItem(Pending)
⑤ 投递 DeliverAsync:
a. 确定接收人:
ForceReceivers 非空 → 精确送达(绕过订阅)
否则 → 查该类型 IsActive 的订阅记录,取 UserId 集合
b. 幂等写接收人:按 MessageId + UserId 判重,仅写入新增的
c. 逐个推送(见 4.3/4.4),并按结果回写推送状态
⑥ 返回消息 ID
```
**接收人回写状态(V2.0 明确区分三种结果)**:
| 推送结果 | `SendStatus` | `IsOffline` | 说明 |
|---------|-------------|------------|------|
| 在线且推送成功 | `Success` + `SendTime` | `false` | 同时推送未读统计 |
| 用户离线 | `NotSent` | `true` | 不推送,等待上线拉取 |
| 推送抛异常(连接刚断开) | `Failed` + `ErrorMsg` | `true` | 消息已持久化,仍可拉取 |
**分层实现要点**:命令处理器位于 **Application 层**,此处不能引用 Api 层的 `IHubContext`(SignalR 属于表示层),因此推送能力被抽象为 `INotificationPusher`(见 4.3)。
### 4.3 应用层:推送抽象 `INotificationPusher`
- 位置:`F9WebAiTest.Application/Services/Notice/INotificationPusher.cs`
```csharp
public interface INotificationPusher
{
bool IsOnline(long userId); // 在线判定
Task<bool> PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default);
Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default);
}
```
**为什么接口在应用层、实现在 Api 层**:SignalR 的 `IHubContext` 由 ASP.NET Core 提供,属于表示层;依赖方向必须是 `Api → Application`,若 Application 直接依赖 `IHubContext` 会形成循环依赖。抽象后,依赖倒置,Application 只依赖自己定义的接口,DI 容器在启动时把 Api 层实现注入进去。
### 4.4 表示层:推送实现 `NotificationPusher`
- 位置:`F9WebAiTest.Api/Services/NotificationPusher.cs`
- 注册:`Program.cs` → `builder.Services.AddScoped<INotificationPusher, NotificationPusher>()`
```csharp
public bool IsOnline(long userId) => OnlineUserRegistry.IsOnline(userId);
public async Task<bool> PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default)
{
if (!OnlineUserRegistry.IsOnline(userId)) return false; // 离线:不推送,返回 false
await _hubContext.Clients.User(userId.ToString())
.SendAsync("ReceiveNotification", message, ct); // 在线:定向推送
return true;
}
public Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default)
=> _hubContext.Clients.User(userId.ToString())
.SendAsync("UnreadCountChanged", new { unreadCount, todoCount }, ct);
```
**技术要点**:
- `Clients.User(userId)` —— SignalR 按 **UserIdentifier** 定向推送,可一次命中该用户的**所有在线连接**(支持同一用户多端/多标签页同时在线)。
- **必须显式判在线**:SignalR 对"该用户当前无连接"的 `Clients.User(...)` 调用是**静默成功**的(不抛异常、无返回)。若只靠 try/catch,离线用户会被误记为 `SendStatus = Success`。因此推送前用在线表判定,离线直接返回 `false` 交由上层标记 `IsOffline`。
### 4.5 表示层:SignalR Hub 与用户标识
- 位置:`F9WebAiTest.Api/Hubs/NotificationHub.cs`
- 端点映射:`Program.cs` → `app.MapHub<NotificationHub>("/hubs/notification")`
```csharp
public class NotificationHub : Hub
{
public override async Task OnConnectedAsync()
{
var userId = GetUserId();
if (userId > 0) OnlineUserRegistry.Add(userId); // 连接建立 → 登记在线
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
var userId = GetUserId();
if (userId > 0) OnlineUserRegistry.Remove(userId); // 连接断开 → 移除在线
await base.OnDisconnectedAsync(exception);
}
private long GetUserId() // 用户ID来源:JWT 的 "id" 声明
=> long.TryParse(Context.User?.FindFirst("id")?.Value, out var id) ? id : 0;
}
/// 自定义 UserIdentifier:决定 Clients.User(...) 用哪个标识匹配连接
public class NotificationUserIdProvider : IUserIdProvider
{
public string GetUserId(HubConnectionContext connection)
=> connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId;
}
```
**用户标识如何统一**(这是定向推送能命中的前提):
| 环节 | 取值方式 |
|------|---------|
| 登录签发 JWT | `JwtService.GenerateAccessToken`:`new Claim("id", user.Id.ToString())` |
| REST 控制器 | `User.FindFirst("id").Value` |
| SignalR 连接标识 | `NotificationUserIdProvider` → JWT `"id"` 声明 |
| 推送目标 | `Clients.User(userId.ToString())` |
四者一致,因此"REST 里的当前用户"与"SignalR 里的连接"指向同一用户。
**WebSocket 携带 JWT**:WebSocket 无法自定义请求头,浏览器 SignalR 客户端自动把 token 拼到查询串 `?access_token=xxx`;后端在 `Program.cs` 的 `JwtBearerEvents.OnMessageReceived` 中针对 `/hubs` 路径从 query 读取:
```csharp
options.Events = new JwtBearerEvents {
OnMessageReceived = context => {
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
context.Token = accessToken; // 仅对 Hub 路径生效,不影响普通接口
return Task.CompletedTask;
}
};
```
### 4.6 用户在线 / 离线判定机制 ★
**判定方式:进程内在线登记表(内存)**
```csharp
public static class OnlineUserRegistry
{
private static readonly ConcurrentDictionary<long, byte> _users = new();
public static void Add(long userId) => _users[userId] = 0;
public static void Remove(long userId) => _users.TryRemove(userId, out _);
public static bool IsOnline(long userId) => _users.ContainsKey(userId);
}
```
| 问题 | 答案 |
|------|------|
| 在哪判定? | `NotificationPusher.IsOnline(userId)` → `OnlineUserRegistry.IsOnline(userId)` |
| 何时登记? | `NotificationHub.OnConnectedAsync`(SignalR 连接建立时) |
| 何时移除? | `NotificationHub.OnDisconnectedAsync`(连接断开、超时、客户端关闭浏览器/窗口时) |
| 为何用 `ConcurrentDictionary`? | Hub 回调在多个连接线程并发执行,必须线程安全;`byte` 作为占位值(仅需键) |
| 为何不查数据库? | 在线是**连接级瞬时状态**,不适合落库;内存表读取为 O(1),无 IO |
| 为何不用 `Clients.User` 是否报错来判断? | SignalR 对无连接用户是**静默成功**,无法据此区分在线/离线(见 4.4) |
| 一个用户多端在线? | `Add` 幂等(同一 userId 只登记一次);`Clients.User` 会推给该用户**全部**连接,任一端断开不影响其它端(断开即 `Remove`,若仍有其它端在线则出现偏差——见下方限制) |
| 多实例部署? | ❌ **不适用**:在线表是进程内内存,A 实例无法知道用户在 B 实例上在线。需引入 Redis + SignalR 背板(见 9.3) |
**已如实说明的实现限制**:`Remove` 在任一连接断开时直接移除,若用户有两个连接(如两个浏览器窗口),关闭其中一个会导致在线表短暂误判为离线,直到其余连接的心跳/重连重新登记。业务影响可控(该场景下消息仍会落库,用户重新触发任意连接动作或重连即恢复)。若需严格的多连接计数,可将 `_users` 的值由 `byte` 改为连接数 `int`(`Add` 时 `+1`、`Remove` 时 `-1`,归零再移除)——这是后续可选的优化点。
### 4.7 未读统计推送(`UnreadCountChanged`)
- 触发时机:新消息推送成功后(在线用户)
- 载荷:`{ unreadCount, todoCount }`
- 实现:`NotificationPusher.PushUnreadCountAsync`,未读数由 `IRepository<NotificationReceiver>.CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)` 统计(此时接收人已落库,数值包含本条)
- 前端:`web/src/utils/notification.js` 目前通过 `ReceiveNotification` 自增 `unreadCount`;若需**服务端权威值**,可直接订阅 `UnreadCountChanged`(事件已就绪)
### 4.8 消息读取与已读
- 位置:`F9WebAiTest.Application/Services/Notice/NotificationService.cs`
- 接口(REST):我的消息分页查询(按 `readStatus` / `category` 筛选)、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync`
- 实现要点:**查询以接收人表为主表**(每人一条状态),联表消息与类型名;已读时写 `ReadStatus = Read` 与 `ReadTime`
### 4.9 待办机制
- 待办类消息(`Category = Todo`)在创建消息、生成待办后,走同一套"确定接收人 → 推送"逻辑(通常用 `ForceReceivers` 精确送审批人)
- `TodoItem` 状态:`Pending → Processing → Completed / Cancelled`
- 审批流转复用同一条消息与待办记录,避免多级审批产生冗余消息
### 4.10 业务变更事件(多端同步)
- 位置:`F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs`
- 机制:业务变更时发布 `BusinessChangedEvent`(实现 `INotificationHandler` 的处理器),经 SignalR 推送 `BusinessChanged` 事件
- 前端:`onBusinessChanged(handler)` 订阅,某端操作后其它端刷新已打开的页面
- 与消息推送共用同一个 Hub 连接与用户标识
### 4.11 前端模块
- 位置:`web/src/utils/notification.js`;页面:`web/src/views/notification/{messages,subscribe,type}/`
```javascript
// 建立连接(幂等):token 由 accessTokenFactory 提供,自动重连
connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notification', { accessTokenFactory: () => getAccessToken() || '' })
.withAutomaticReconnect()
.build();
connection.on('ReceiveNotification', (msg) => { unreadCount.value++; /* 弹窗 + 页面刷新 */ });
connection.on('BusinessChanged', (e) => { /* 多端同步刷新 */ });
connection.start().catch(() => {});
```
| 导出 | 作用 |
|------|------|
| `unreadCount` | 全局未读数(Pinia 之外用 `ref` 共享,供角标与消息页使用) |
| `initNotification(onMessage)` | 建立连接并注册回调(幂等,重复调用复用同一连接) |
| `loadUnreadCount()` | 拉取未读数(首次进入、标记已读后校准) |
| `openMessage(msg, router)` | 打开消息:非待办且未读 → 标记已读 → 按 `routePath` 跳转业务页 |
| `onBusinessChanged(handler)` | 订阅业务变更(多端同步) |
| `stopNotification()` | 登出时停止连接并清空状态 |
**技术要点**:
- `accessTokenFactory`:浏览器 WebSocket 不能带自定义头,SignalR 客户端自动以 `access_token` 查询串传递,后端 `OnMessageReceived` 已适配。
- `withAutomaticReconnect()`:网络抖动/服务重启后自动重连,重连成功后 Hub 会再次登记在线,恢复推送能力。
- 消息跳转:`openMessage` 读取消息自带 `routePath` 直达业务页面(待办类不在此处标记已读,保持提醒直到真正处理完成)。
---
## 5. 关键流程(时序)
### 5.1 在线用户:创建 → 推送 → 展示
```
业务代码(如请假审批 ApproveLeave)
│ mediator.Send(new SendNotificationCommand(LeaveResult, ...))
▼
CreateNotificationCommandHandler
├─ 校验类型 / 生成路由
├─ 写 NotificationMessage (Status = Sent)
├─ 待办类:写 TodoItem
├─ 确定接收人(ForceReceivers 或 订阅广播)
├─ 写 NotificationReceiver(幂等)
└─ NotificationPusher.PushNewMessageAsync(userId, dto)
├─ OnlineUserRegistry.IsOnline(userId) == true
├─ Clients.User(userId).SendAsync("ReceiveNotification", dto) ← 实时
└─ 回写 receiver:SendStatus=Success, SendTime=now
└─ PushUnreadCountAsync → "UnreadCountChanged" { unreadCount, todoCount }
▼
前端 notification.js
├─ unreadCount++ → 顶部角标更新
├─ 弹窗提示(标题 + 内容)
└─ 消息页/待办页订阅回调刷新
▼
用户点击消息 → openMessage()
├─ PUT /api/notification/messages/{id}/read
└─ router.push(msg.routePath) → 直达业务页面
```
### 5.2 离线用户:落库 + 拉取兜底
```
业务创建消息 → 接收人含离线用户 U
└─ IsOnline(U) == false
├─ 不推送,receiver.IsOffline = true、SendStatus = NotSent
└─ 消息与接收人记录均已持久化 ✅
用户 U 之后登录 / 打开页面
└─ GET /api/notification/unread-count → 未读数正确(含离线期间的消息)
└─ GET /api/notification/messages → 消息列表完整可见
└─ SignalR 连接建立 → OnlineUserRegistry.Add(U) → 后续消息恢复实时推送
```
> **重要语义**:实时推送只在"消息产生那一刻收件人在线"时触达。离线期间的消息**不会在用户上线时补弹窗**,而是通过未读数与消息列表体现(避免上线瞬间弹出一堆历史消息)。如需"上线补推",可在 Hub 的 `OnConnectedAsync` 中查询未读并推送一次通知——属于可扩展项。
### 5.3 待办闭环
```
提交请假 → SendNotificationCommand(LeaveApproval, ForceReceivers:[审批人], AssigneeIds:[审批人])
→ 消息 + TodoItem(Pending) + 推送给审批人
审批人处理 → POST /api/notification/todos/{id}/complete
→ TodoItem → Completed,写 HandleResult
→ 有下一级:再发待办给下一审批人
→ 流程结束:SendNotificationCommand(LeaveResult, ForceReceivers:[申请人])
```
---
## 6. 接口设计
### 6.1 REST 接口
**消息类型管理**(`/api/notification/type`,需登录)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/notification/type` | 类型列表 |
| POST | `/api/notification/type` | 创建类型 |
| PUT | `/api/notification/type/{id}` | 更新类型 |
| DELETE | `/api/notification/type/{id}` | 删除类型 |
**用户端**(`/api/notification`,需登录)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/notification/subscribe` | 可订阅类型列表(含订阅状态与可订阅判定) |
| POST | `/api/notification/subscribe` | 订阅 / 退订(`{ notificationTypeId, subscribe }`) |
| GET | `/api/notification/messages` | 我的消息(`pageIndex`/`pageSize`/`readStatus`/`category`) |
| GET | `/api/notification/todos` | 我的待办(`status` 可选) |
| POST | `/api/notification/todos/{id}/complete` | 完成待办(`{ handleResult }`) |
| PUT | `/api/notification/messages/{id}/read` | 标记已读 |
| PUT | `/api/notification/messages/read-all` | 全部已读 |
| GET | `/api/notification/unread-count` | 未读数量(返回 `{ count }`) |
| POST | `/api/notification/trigger` | 触发测试消息(演示/联调用) |
| POST | `/api/notification/broadcast` | 发送广播(需 `notification:broadcast` 权限) |
统一响应包装:`ApiResult<T>`(`Ok` / `Fail`),分页为 `PageResult<T>`。
### 6.2 SignalR 事件契约
端点:`/hubs/notification`;鉴权:JWT(query `access_token`)。
| 事件 | 方向 | 载荷 | 说明 |
|------|------|------|------|
| `ReceiveNotification` | 服务端 → 客户端 | `NotificationMessageDto` | 新消息实时推送 |
| `UnreadCountChanged` | 服务端 → 客户端 | `{ unreadCount, todoCount }` | 未读统计变化 |
| `BusinessChanged` | 服务端 → 客户端 | `{ businessType, businessId, action }` | 业务数据变更(多端同步) |
`NotificationMessageDto` 主要字段:`Id`、`NotificationTypeId`、`MessageTypeCode`、`TypeName`、`Title`、`Content`、`Priority`、`BusinessType`、`BusinessId`、`Category`、`RoutePath`、`CreatedTime`、`ReadStatus`。
### 6.3 业务侧发送消息的两种命令
```csharp
// ① 简洁命令(推荐):枚举名即消息类型 Code
await mediator.Send(new SendNotificationCommand(
NotificationTypeEnum.LeaveApproval,
BusinessId: leaveId.ToString(),
Title: "请假审批",
Content: "张三提交了请假申请",
Priority: NotificationPriority.Important,
ForceReceivers: new List<long> { approverId }, // 精确送达;留空 = 按订阅广播
AssigneeIds: new List<long> { approverId })); // 生成待办
// ② 底层命令:按字符串 Code,完全控制
await mediator.Send(new CreateNotificationCommand {
NotificationTypeCode = "TeyeFound",
BusinessType = "Xdjy",
BusinessId = lsjlId.ToString(),
Title = "发现特野",
Content = "…",
ForceReceivers = new List<long>()
});
```
---
## 7. 可靠性设计
### 7.1 保障措施
| 风险 | 措施 | 实现位置 |
|------|------|---------|
| 消息丢失 | **先持久化再推送**:消息与接收人记录先落库,推送失败仍可拉取 | `CreateNotificationCommandHandler` |
| 重复投递 | `MessageId + UserId` 唯一约束 + 投递前判重 | 实体约束 + `DeliverAsync` |
| 推送异常 | 单接收人 try/catch 隔离,仅回写 `Failed`,不影响业务主流程 | `DeliverAsync` |
| 用户离线 | `IsOffline` 标记 + 上线后 REST 拉取兜底 | `NotificationPusher` + 前端 |
| 连接断开 | SignalR `withAutomaticReconnect()` 自动重连,重连后重新登记在线 | 前端 + Hub |
| 订阅越权 | 订阅时校验消息类型的 `RequiredPermission` | `NotificationService.SubscribeAsync` |
| 业务被消息拖累 | 消息发送失败不回滚业务(业务侧可自行选择 try/catch) | 业务调用处 |
### 7.2 与 V1.1 的可靠性差异(如实说明)
| 能力 | V1.1 | V2.0 |
|------|------|------|
| 失败自动重试 | ✅ `RetryCount++`,超上限标记 `Failed` | ❌ 无(无后台任务) |
| 过期撤销 | ✅ 扫描时检查 `ExpireTime` → `Revoked` | ❌ 未实现(字段保留) |
| 部分成功统计 | ✅ `PartialSuccess` | ⚠️ 逐接收人状态已记录,消息级汇总未启用 |
| 投递延迟 | 0~3 秒 | **0(同步即时)** |
### 7.3 V2.0 新增的已知限制
1. **无自动重试**:推送依赖 SignalR 连接,瞬时故障只记录 `Failed`,不做补偿。补偿手段:业务侧重发、或后续增加"失败消息人工重发"接口。
2. **在线表是进程内内存**:多实例部署下在线判定不准(见 4.6 / 9.3)。
3. **同步推送增加请求耗时**:推送为本地内存 + Socket 写入,开销在毫秒级;接收人很多(如全员广播)时建议改为后台队列/批量推送。
4. **推送不保证送达**:`Success` 仅代表"已写入该用户的在线连接",不代表客户端已渲染(浏览器后台标签页可能延迟处理)。
---
## 8. 技术选型说明(技术 → 用途 → 原因)
| 技术 | 用途 | 选型理由 |
|------|------|---------|
| **SignalR** | 实时推送通道 | 免手写 WebSocket 协议;自动协商传输(WebSocket → SSE → Long Polling)、自动重连、按用户/组定向推送、与 ASP.NET Core 鉴权无缝集成 |
| **JWT** | 统一鉴权 | REST 与 Hub 共用一套 token;用户标识 `"id"` 声明贯穿两端,使 `Clients.User` 可精准命中 |
| **MediatR** | 命令/查询分发 | 业务侧只需 `Send` 一条命令即可发消息;投递逻辑集中在命令处理器,便于统一维护 |
| **FreeSql** | ORM / CodeFirst | 支持多数据库切换(SQLite/MySQL/PG)、CodeFirst 建表、`IRepository<T>` 泛型仓储简化数据访问 |
| **SQLite** | 数据库 | 单文件、零部署,适合当前规模;切换数据库只需改连接串与 `DataType` |
| **ConcurrentDictionary** | 在线用户登记 | 读多写少、O(1) 判定、线程安全,Hub 并发回调下无锁竞争 |
| **抽象接口(依赖倒置)** | 跨层推送能力 | 接口在 Application、实现在 Api,避免应用层反向依赖表示层 |
| **Vue3 + @microsoft/signalr** | 前端实时接入 | 官方 JS 客户端;`accessTokenFactory` 自动处理 WebSocket token;组合式 API 便于共享 `unreadCount` |
| **NSwag** | 接口文档 | 自动生成 OpenAPI + Swagger UI,便于联调与生成 TS 客户端 |
---
## 9. 扩展指南
### 9.1 新增一种消息类型
1. `NotificationEnums.cs` 的 `NotificationTypeEnum` 增加枚举值(枚举名 = 类型 `Code`)
2. 配置消息类型(管理端「消息类型管理」页面,或 `DbSeeder` 种子):名称、分类、`RouteTemplate`(含 `{businessId}` 占位符)、订阅权限码(可选)
3. 业务侧一行发送:
```csharp
await mediator.Send(new SendNotificationCommand(
NotificationTypeEnum.YourNewType,
BusinessId: bizId.ToString(),
Title: "…", Content: "…",
ForceReceivers: receivers)); // 留空 = 按订阅广播
```
**无需改动投递与推送代码**(投递链路对类型无感知)。
### 9.2 新增推送渠道(短信 / 企业微信 / 邮件)
实现 `INotificationPusher` 的多渠道组合(装饰器或策略):
```csharp
public class CompositePusher : INotificationPusher
{
// 站内(SignalR)为主渠道;离线或紧急消息可扩展短信/企微
}
```
或新增独立接口(如 `ISmsSender`)在 `CreateNotificationCommandHandler` 中并行调用——**投递编排集中在命令处理器**,扩展点清晰。
### 9.3 多实例部署改造(重要)
当前 `OnlineUserRegistry` 为进程内内存表,多实例下在线判定失效。改造步骤:
1. 引入 **Redis** 与 **SignalR Redis 背板**(`Microsoft.AspNetCore.SignalR.StackExchangeRedis`):
```csharp
builder.Services.AddSignalR().AddStackExchangeRedis("redis-connection");
```
背板使 `Clients.User(...)` 可跨实例路由到用户所在实例。
2. 在线表改用 Redis(`SET notice:online:{userId}` + 过期时间),或直接依赖背板的 `All` 通道做推送,不再自行判定在线(把 `IsOnline` 改为"始终返回 true,交由背板路由")。
3. 消息/接收人表迁移到 PostgreSQL 或 MySQL(SQLite 不适合多实例并发写)。
### 9.4 恢复"后台投递 + 重试"能力
若业务量增长需要异步化:保留 `CreateNotificationCommandHandler` 的建消息部分,把 `DeliverAsync` 投递部分改为写入队列(本地 Channel / MQ),由消费者执行投递与重试——**接口 `INotificationPusher` 与 Hub 无需改动**。
---
## 10. 附录
### 10.1 关键文件索引
| 功能 | 文件 |
|------|------|
| 枚举定义 | `F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs` |
| 实体(5 个) | `F9WebAiTest.Domain/Entities/Notice/{NotificationType, NotificationMessage, NotificationReceiver, TodoItem, UserNotificationSubscription}.cs` |
| 消息创建命令 + 投递实现 ★ | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs` |
| 推送抽象(接口) | `F9WebAiTest.Application/Services/Notice/INotificationPusher.cs` |
| 推送实现 | `F9WebAiTest.Api/Services/NotificationPusher.cs` |
| Hub + 在线表 + 用户标识 | `F9WebAiTest.Api/Hubs/NotificationHub.cs` |
| 订阅/消息/待办服务 | `F9WebAiTest.Application/Services/Notice/NotificationService.cs` |
| REST 控制器 | `F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs` |
| 业务变更事件 | `F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs` |
| 数据种子 | `F9WebAiTest.Infrastructure/Data/DbSeeder.cs` |
| 启动装配(SignalR/Hub/推送器/JWT query) | `F9WebAiTest.Api/Program.cs` |
| 前端实时客户端 | `web/src/utils/notification.js` |
| 前端页面 | `web/src/views/notification/{messages, subscribe, type}/` |
| JWT 声明(`id`) | `F9WebAiTest.Application/Services/Auth/JwtService.cs` |
### 10.2 端到端排查清单(联调时按此顺序)
| 现象 | 检查点 |
|------|--------|
| 前端收不到任何推送 | ① 浏览器控制器 Network → WS 是否连上 `/hubs/notification`(101 Switching Protocols)② 连接 URL 是否带 `access_token` ③ 后端是否返回 401(token 无效) |
| 消息创建了但没推送 | ① 消息 `ForceReceivers` 与订阅关系是否命中该用户 ② 该用户当时是否在线(`OnlineUserRegistry`)③ 接收人表是否有该用户记录、`SendStatus`/`IsOffline` 值 |
| 在线却没收到 | ① JWT 的 `id` 声明是否存在(`NotificationUserIdProvider` 取不到会退化为 `ConnectionId`,导致 `Clients.User(用户ID)` 无法命中)② 推送时的 userId 与连接登记的 userId 是否一致 |
| 偶发收不到 | 连接是否处于重连中(`withAutomaticReconnect` 期间 `OnDisconnectedAsync` 会移除在线登记) |
### 10.3 参考资料
- Microsoft ASP.NET Core SignalR 官方文档(Hubs / IUserIdProvider / 背板 / JWT 鉴权)
- MediatR 官方文档
- FreeSql 官方文档(CodeFirst / 连接串配置)
- IEEE 1016 软件设计描述标准
---
**文档结束**(V2.0,与 2026-09-15 代码实现一致)