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

48 KiB
Raw Permalink Blame History

消息推送系统 软件设计说明书

项目名称 生活圈管理系统(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

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 新增的已知限制

  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. 业务侧一行发送:
    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 为进程内内存表,多实例下在线判定失效。改造步骤:

  1. 引入 Redis 与 SignalR Redis 背板(Microsoft.AspNetCore.SignalR.StackExchangeRedis):
    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 代码实现一致)