first commit

This commit is contained in:
yanpeng200212
2026-10-08 10:43:59 +08:00
commit 8430ba0452
288 changed files with 29595 additions and 0 deletions
+817
View File
@@ -0,0 +1,817 @@
# 消息推送系统 软件设计说明书
| 项目名称 | 生活圈管理系统(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 代码实现一致)