48 KiB
消息推送系统 软件设计说明书
| 项目名称 | 生活圈管理系统(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 设计目标
- 统一触达:业务侧一行命令即可发送消息,不关心投递细节。
- 双送达机制:订阅式广播(按消息类型订阅关系)+ 强制接收(精准必达)。
- 实时推送:在线用户 SignalR 即时推送,离线用户持久化后拉取兜底。
- 三类消息:通知(Notification)/ 待办(Todo)/ 公告(Announcement)。
- 待办闭环:待办类消息自动生成
TodoItem,支持查询与完成。 - 多端跳转与同步:消息携带业务标识与路由,多端一致。
- 架构简洁:无后台任务、无轮询、无中间队列。
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
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
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>()
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")
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 读取:
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 用户在线 / 离线判定机制 ★
判定方式:进程内在线登记表(内存)
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}/
// 建立连接(幂等):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 业务侧发送消息的两种命令
// ① 简洁命令(推荐):枚举名即消息类型 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 新增的已知限制
- 无自动重试:推送依赖 SignalR 连接,瞬时故障只记录
Failed,不做补偿。补偿手段:业务侧重发、或后续增加"失败消息人工重发"接口。 - 在线表是进程内内存:多实例部署下在线判定不准(见 4.6 / 9.3)。
- 同步推送增加请求耗时:推送为本地内存 + Socket 写入,开销在毫秒级;接收人很多(如全员广播)时建议改为后台队列/批量推送。
- 推送不保证送达:
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 新增一种消息类型
NotificationEnums.cs的NotificationTypeEnum增加枚举值(枚举名 = 类型Code)- 配置消息类型(管理端「消息类型管理」页面,或
DbSeeder种子):名称、分类、RouteTemplate(含{businessId}占位符)、订阅权限码(可选) - 业务侧一行发送:
无需改动投递与推送代码(投递链路对类型无感知)。
await mediator.Send(new SendNotificationCommand( NotificationTypeEnum.YourNewType, BusinessId: bizId.ToString(), Title: "…", Content: "…", ForceReceivers: receivers)); // 留空 = 按订阅广播
9.2 新增推送渠道(短信 / 企业微信 / 邮件)
实现 INotificationPusher 的多渠道组合(装饰器或策略):
public class CompositePusher : INotificationPusher
{
// 站内(SignalR)为主渠道;离线或紧急消息可扩展短信/企微
}
或新增独立接口(如 ISmsSender)在 CreateNotificationCommandHandler 中并行调用——投递编排集中在命令处理器,扩展点清晰。
9.3 多实例部署改造(重要)
当前 OnlineUserRegistry 为进程内内存表,多实例下在线判定失效。改造步骤:
- 引入 Redis 与 SignalR Redis 背板(
Microsoft.AspNetCore.SignalR.StackExchangeRedis):背板使builder.Services.AddSignalR().AddStackExchangeRedis("redis-connection");Clients.User(...)可跨实例路由到用户所在实例。 - 在线表改用 Redis(
SET notice:online:{userId}+ 过期时间),或直接依赖背板的All通道做推送,不再自行判定在线(把IsOnline改为"始终返回 true,交由背板路由")。 - 消息/接收人表迁移到 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 代码实现一致)