# 消息推送系统 迁移指南(迁移到已有 RBAC 系统) > 适用场景:目标系统与当前系统**架构一致**(.NET 8 分层 + FreeSql + MediatR + SignalR + `IRepository` 泛型仓储 + JWT)。 > 本文按**可执行顺序**拆分为 14 步,每步含:**要建什么 → 关键代码要点 → 验证方法**。 > 当前实现基线:`F9WebAiTest` 生活圈管理系统(消息推送 V2.0,即时推送,无后台服务)。 --- ## 0. 迁移前须知 ### 0.1 目标系统前置条件自查(缺一不可) | # | 依赖项 | 具体要求 | 不满足时的处理 | |---|--------|---------|--------------| | 1 | 运行时 | .NET 8 / ASP.NET Core | 必须 | | 2 | ORM | FreeSql(`IFreeSql` 单例),支持 CodeFirst | 若用 EF Core,需重写仓储调用(数据访问层代码量约 200 行) | | 3 | 仓储 | `IRepository` 泛型仓储,含 `GetByIdAsync / GetAllAsync / FindAsync / FirstOrDefaultAsync / AnyAsync / CountAsync / AddAsync / AddRangeAsync / UpdateAsync / DeleteAsync / HardDeleteAsync / HardDeleteRangeAsync / SaveChangesAsync` | 方法名不同需批量替换(约 40 处调用) | | 4 | MediatR | 已接入 `AddMediatR(...)` | 必须(消息创建入口) | | 5 | SignalR | .NET 8 内置 `Microsoft.AspNetCore.SignalR` | 必须(实时推送) | | 6 | JWT | token 中含**用户 ID 声明** | 当前实现用 `"id"`;若目标系统用 `ClaimTypes.NameIdentifier` / `"userId"`,需改 **3 处**(见 4.1) | | 7 | RBAC | `Permission`、`RolePermission` 实体;按用户查权限码的能力(`IAuthService.GetUserPermissionsAsync(userId)`);枚举含 `Resource_Type_Enum.消息订阅`、`Permission_Type_Enum.Visble` | 缺失则**订阅权限校验**与**角色分配订阅权限**两处功能需降级(可先改为“所有人可订阅”) | | 8 | 主键生成 | 雪花 ID `ISnowflakeIdGenerator` | 或改用目标系统的主键策略 | | 9 | 前端 | Vue3 + Vite + Element Plus + Pinia + axios 封装(`request`)+ 依赖 `@microsoft/signalr` | 缺 `@microsoft/signalr` 需 `npm i @microsoft/signalr` | | 10 | 实体基类 | `BaseEntity`(含 `Id` + 审计字段 + 软删除 `IsDeleted`) | 必须(唯一索引/软删过滤依赖它) | ### 0.2 命名空间与类型适配表(迁移时全局替换) | 当前系统 | 目标系统改为 | |---------|-------------| | `F9WebAiTest.Domain.Entities.Notice` | `{目标}.Domain.Entities.Notice`(或你们既有实体目录) | | `F9WebAiTest.Application.Dtos.Notice` | `{目标}.Application.Dtos.Notice` | | `F9WebAiTest.Application.Services.Notice` | `{目标}.Application.Services.Notice` | | `F9WebAiTest.Api.Hubs` / `F9WebAiTest.Api.Services` | `{目标}.Api.Hubs` / `{目标}.Api.Services` | | `BaseEntity` / `IRepository` / `ServiceException` | 目标系统的同名/等价类型 | | `ApiResult` / `PageResult` | 目标系统的返回包装(**分页字段名可能不同**) | | `SysPermission` / `SysRolePermission`(种子中的别名) | 目标系统权限/角色权限实体实际名 | | `ISnowflakeIdGenerator` | 目标系统主键生成器 | | JWT claim `"id"` | 目标系统用户 ID 声明名 | | 前端 `request`(axios 封装)、`getAccessToken` | 目标系统对应工具 | --- ## 1. 迁移文件总览 **后端(12 个文件)** | # | 源路径(F9WebAiTest) | 层 | 迁移动作 | |---|---------------------|----|---------| | 1 | `Domain/Entities/Notice/NotificationEnums.cs` | Domain | 新增 | | 2 | `Domain/Entities/Notice/NotificationType.cs` | Domain | 新增 | | 3 | `Domain/Entities/Notice/NotificationMessage.cs` | Domain | 新增 | | 4 | `Domain/Entities/Notice/NotificationReceiver.cs` | Domain | 新增 | | 5 | `Domain/Entities/Notice/TodoItem.cs` | Domain | 新增 | | 6 | `Domain/Entities/Notice/UserNotificationSubscription.cs` | Domain | 新增 | | 7 | `Application/Dtos/Notice/NotificationDtos.cs` | Application | 新增(9 个 DTO) | | 8 | `Application/Services/Notice/INotificationPusher.cs` | Application | 新增(推送抽象,3 方法) | | 9 | `Application/Services/Notice/NotificationService.cs` | Application | 新增(`INotificationService` 接口 + 实现,12 方法) | | 10 | `Application/Services/Notice/NotificationMediatR.cs` | Application | 新增(创建命令 + **投递实现**) | | 11 | `Api/Hubs/NotificationHub.cs` | Api | 新增(Hub + 在线表 + UserIdProvider) | | 12 | `Api/Services/NotificationPusher.cs` | Api | 新增(推送实现) | | 13 | `Api/Controllers/Notice/NotificationControllers.cs` | Api | 新增(2 控制器 / 15 端点) | | 14 | `Infrastructure/Data/DbSeeder.cs` 的 `SeedNotificationData` | Infrastructure | **片段移植**(种子数据) | | 15 | `Application/DependencyInjection.cs` + `Api/Program.cs` | 装配 | **片段修改**(DI 注册) | **前端(4 个文件)** | # | 源路径 | 动作 | |---|--------|------| | 16 | `web/src/utils/notification.js` | 新增 | | 17 | `web/src/views/notification/messages/index.vue` | 新增 | | 18 | `web/src/views/notification/subscribe/index.vue` | 新增 | | 19 | `web/src/views/notification/type/index.vue` | 新增 | | 20 | 路由 `componentMap`(或目标系统动态组件映射表) | **必改**(否则页面打不开,见 Step 14) | --- ## 2. 分步实施 ### Step 1|领域层:枚举 **做什么**:新增 `NotificationEnums.cs`,定义 6 个枚举。 ```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 } // 枚举名 = 消息类型 Code ``` **要点**:`NotificationTypeEnum` 的**枚举名必须与消息类型的 `Code` 一致**(业务侧按枚举名发送)。 **验证**:编译通过。 --- ### Step 2|领域层:5 个实体 **做什么**:`Notice/` 目录下新增 5 个实体文件(每个实体独立文件)。 | 实体 | 表名 | 关键字段 | 索引(**必须**) | |------|------|---------|----------------| | `NotificationType` | `NotificationTypes` | `Code`(唯一)、`Name`、`Description`、`RequiredPermission`、`IsActive`、`CreatedBy`、`Sort`、`Category`、`Icon`、`RouteTemplate` | `Code` 唯一 | | `NotificationMessage` | `NotificationMessages` | `NotificationTypeId`、`MessageTypeCode`(冗余)、`BusinessType`、`BusinessId`、`Title`、`Content`、`Priority`、`Status`、`RetryCount`、`MaxRetryCount`、`BizContext`、`ForceReceivers`(JSON)、`CreatorId`、`CreatorName`、`Category`(冗余)、`RoutePath` | `MessageTypeCode` 普通索引 | | `NotificationReceiver` | `NotificationReceivers` | `MessageId`、`UserId`、`ReadStatus`、`ReadTime`、`IsOffline`、`SendStatus`、`SendTime`、`ErrorMsg` | **`MessageId + UserId` 唯一**、`UserId + ReadStatus` | | `TodoItem` | `TodoItems` | `MessageId`、`AssigneeId`、`TodoStatus`、`DueTime`、`CompletedTime`、`HandleResult` | `AssigneeId + TodoStatus` | | `UserNotificationSubscription` | `UserNotificationSubscriptions` | `UserId`、`NotificationTypeId`、`IsActive`、`SubscribedTime`、`CancelTime` | **`UserId + NotificationTypeId` 唯一** | **代码要点**: ```csharp [Table(Name = "NotificationMessages")] [Index("IX_NotificationMessages_MessageTypeCode", "MessageTypeCode")] public class NotificationMessage : BaseEntity { public long NotificationTypeId { get; set; } public string MessageTypeCode { get; set; } = ""; public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; public NotificationStatus Status { get; set; } = NotificationStatus.Ready; public string ForceReceivers { get; set; } = ""; // JSON 数组,如 "[1001,1002]",空 = 按订阅广播 // …其余字段见源文件 } ``` **要点**: - 实体为**纯属性 POCO**(状态由应用服务赋值后 `UpdateAsync` 持久化),不要加仓储接口——统一用 `IRepository`。 - 枚举字段在 FreeSql 中统一映射为 `int`(目标系统若没配,实体枚举会存成字符串,导致与其他表不一致)。 **验证**:CodeFirst 同步后 5 张表存在,且两个唯一索引已建立(见 Step 3)。 --- ### Step 3|数据库:建表 **方式 A(推荐,与当前一致)**:FreeSql CodeFirst ```csharp // 在目标系统的表结构同步处(当前系统在 DbSeeder 内) fsql.CodeFirst.SyncStructure(); fsql.CodeFirst.SyncStructure(); fsql.CodeFirst.SyncStructure(); fsql.CodeFirst.SyncStructure(); fsql.CodeFirst.SyncStructure(); ``` **方式 B(旧库已有表时)**:只补列 ```csharp EnsureColumn(fsql, "NotificationTypes", "Category", "INTEGER NOT NULL DEFAULT 0"); EnsureColumn(fsql, "NotificationTypes", "Icon", "TEXT NOT NULL DEFAULT ''"); EnsureColumn(fsql, "NotificationTypes", "RouteTemplate", "TEXT NOT NULL DEFAULT ''"); EnsureColumn(fsql, "NotificationMessages", "Category", "INTEGER NOT NULL DEFAULT 0"); EnsureColumn(fsql, "NotificationMessages", "RoutePath", "TEXT NOT NULL DEFAULT ''"); ``` **要点**:`SyncStructure` **不会为已存在的表新增列**,旧库必须走"补列"。 **验证**: ```sql SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Notification%'; SELECT name FROM sqlite_master WHERE type='table' AND name='TodoItems'; -- 唯一索引 SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='NotificationReceivers'; ``` --- ### Step 4|应用层:DTO(9 个) **做什么**:新增 `Application/Dtos/Notice/NotificationDtos.cs`。 | DTO | 类型 | 用途 | 关键字段 | |-----|------|------|---------| | `NotificationTypeDto` | 出参 | 类型列表/订阅列表 | `Id, Code, Name, Description, RequiredPermission, IsActive, CreatedBy, Sort, Category, Icon, RouteTemplate, **Subscribed**, **CanSubscribe**` | | `NotificationTypeCreateDto` | 入参 | 创建/更新类型 | `Code, Name, Description, RequiredPermission, IsActive, Sort, Category, Icon, RouteTemplate` | | `SubscribeDto` | 入参 | 订阅/退订 | `NotificationTypeId, Subscribe` | | `NotificationMessageDto` | 出参 **+ SignalR 推送载荷** | 消息列表/实时推送 | `Id, NotificationTypeId, MessageTypeCode, TypeName, Title, Content, Priority, BusinessType, BusinessId, Category, RoutePath, CreatedTime, ReadStatus, ReadTime` | | `UnreadCountDto` | 出参 | 未读数 | `Count` | | `TodoItemDto` | 出参 | 待办列表 | `Id, MessageId, Title, Content, Priority, BusinessType, BusinessId, RoutePath, TodoStatus, DueTime, CompletedTime, CreatedTime` | | `CompleteTodoDto` | 入参 | 完成待办 | `HandleResult` | | `BroadcastNotificationDto` | 入参 | 广播 | `Title, Content, Priority, RoutePath` | | `TriggerDto` | 入参 | 测试触发 | `Code, Title, Content, BusinessType, BusinessId, Priority, RoutePath, AssigneeIds` | **要点**: - `NotificationMessageDto` **同时是 SignalR 推送载荷**(服务端直接推送该对象,前端按同名字段消费)——字段名必须与前端一致。 - **待办类消息没有独立 DTO**:`TodoItemDto` 的 `Title/Content/RoutePath` 取自关联消息(查询时拼装)。 **验证**:编译通过;`NotificationMessageDto` 字段与前端 `messages/index.vue` 使用字段逐一对齐。 --- ### Step 5|应用层:推送抽象接口 `INotificationPusher` **做什么**:新增 `Application/Services/Notice/INotificationPusher.cs`(**接口放应用层**)。 ```csharp public interface INotificationPusher { bool IsOnline(long userId); Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default); Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default); } ``` **为什么必须有这层抽象**:投递逻辑在 Application 层,但 SignalR 的 `IHubContext` 属于 Api 层。若 Application 直接引用会形成循环依赖。接口在 Application、实现在 Api(依赖倒置)。 **验证**:编译通过(实现留到 Step 9)。 --- ### Step 6|应用层:消息服务 `INotificationService` + `NotificationService` **做什么**:新增 `Application/Services/Notice/NotificationService.cs`(接口与实现同文件)。 **接口(12 方法)**: ```csharp public interface INotificationService { // 订阅 Task> GetSubscribeListAsync(long userId); Task SubscribeAsync(long userId, long typeId, bool subscribe); // 消息 Task> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category); Task MarkReadAsync(long userId, long messageId); Task MarkAllReadAsync(long userId); Task GetUnreadCountAsync(long userId); // 待办 Task> GetMyTodosAsync(long userId, int? status); Task CompleteTodoAsync(long userId, long todoId, string handleResult); // 管理端:类型维护 Task> GetTypeListAsync(); Task CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId); Task UpdateTypeAsync(long id, NotificationTypeCreateDto dto); Task DeleteTypeAsync(long id); } ``` **依赖注入(构造函数)**: ```csharp IRepository _typeRepo; IRepository _subRepo; IRepository _msgRepo; IRepository _receiverRepo; IRepository _todoRepo; IRepository _roleMenuRepo; // 目标系统权限关联实体 IRepository _permissionRepo; // 目标系统权限实体 ISnowflakeIdGenerator _snowflake; IAuthService _authService; // GetUserPermissionsAsync(userId) ``` **实现要点(4 处易错点)**: 1. **查询以接收人表为主表**:`GetMyMessagesAsync` 先查 `NotificationReceiver(UserId)`,再联消息与类型名,`ReadStatus` 取自接收人(同一消息对不同用户状态不同)。 2. **未读数**:`CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)`。 3. **完成待办后标记该消息已读**:避免"处理完了还挂未读红点"。 4. **RBAC 联动 `SyncSubscribePermissionAsync`**(`CreateTypeAsync` / `UpdateTypeAsync` 后调用,`DeleteTypeAsync` 反向清理): - `RequiredPermission` 为空 → 删除该类型对应的权限单元及其角色关联(所有人可订阅,无需权限) - 非空 → 按 `ResourceType = Resource_Type_Enum.消息订阅 && ResourceId = type.Id` 查或建 `Permission`,并 `permission.SetCode(type.RequiredPermission)` 覆盖权限码 - **必须覆盖权限码**:否则默认生成的码(如 `subscribe:3:visble`)与 `RequiredPermission` 不一致,订阅校验永远失败 **验证**:调 `GET /api/notification/subscribe` 返回类型列表且含 `subscribed` / `canSubscribe`。 --- ### Step 7|应用层:MediatR 命令 + 投递实现(核心步骤) **做什么**:新增 `Application/Services/Notice/NotificationMediatR.cs`。 **两类命令**: ```csharp // ① 底层命令(按 Code,返回消息ID) public class CreateNotificationCommand : IRequest { public string NotificationTypeCode { get; set; } = ""; public string BusinessType { get; set; } = ""; public string BusinessId { get; set; } = ""; public string Title { get; set; } = ""; public string Content { get; set; } = ""; public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; public string BizContext { get; set; } = ""; public List ForceReceivers { get; set; } = new(); // 精确送达(绕过订阅) public string RoutePath { get; set; } = ""; // 覆盖路由模板 public List AssigneeIds { get; set; } = new(); // 待办处理人(Category=Todo) public DateTime? DueTime { get; set; } public long CreatorId { get; set; } public string CreatorName { get; set; } = ""; } // ② 简洁命令(业务侧推荐):枚举名即 Code public record SendNotificationCommand( NotificationTypeEnum Type, string BusinessId = "", string Title = "", string Content = "", string BizContext = "", NotificationPriority Priority = NotificationPriority.Normal, List ForceReceivers = null, List AssigneeIds = null, DateTime? DueTime = null) : IRequest; ``` **`CreateNotificationCommandHandler` 处理流程(6 步,一次请求内同步完成)**: ``` ① 按 Code 查 NotificationType → 不存在/已停用则抛异常 ② 生成 RoutePath:命令直接指定 > 类型模板替换 {businessId} ③ 建消息(Status 直接 = Sent),ForceReceivers 序列化为 JSON 存库 ④ Category = Todo 且有 AssigneeIds → 为每个处理人建 TodoItem(Pending) ⑤ DeliverAsync(投递):见下 ⑥ 返回消息 ID ``` **`DeliverAsync` 投递逻辑(投递的全部实现就在这里)**: ``` a. 确定接收人: ForceReceivers 非空 → 精确送达(绕过订阅) 否则 → 查该类型 IsActive 的订阅记录,收集 UserId b. 幂等写接收人:按 MessageId + UserId 判重,只写不存在的 c. 保存接收人(先落库,再推送 —— 保证离线可拉取) d. 逐个推送并按结果回写: pusher.PushNewMessageAsync(userId, dto) 返回 true → SendStatus = Success、SendTime = now、IsOffline = false → 追加推送未读统计 PushUnreadCountAsync(userId, unread, todo) 返回 false(用户离线) → SendStatus = NotSent、IsOffline = true(不推送) 抛异常(连接瞬断) → SendStatus = Failed、IsOffline = true、ErrorMsg = ex.Message e. 回写 receiver 并保存 ``` **注意**:推送给**在线**用户时,未读数要**先保存接收人再统计**,否则统计不含本条。 **验证**:调 `POST /api/notification/trigger` 创建一条消息,检查: - `NotificationMessages` 有记录且 `Status = 1(Sent)` - `NotificationReceivers` 有对应接收人,`SendStatus`/`IsOffline` 与用户当时是否在线一致 --- ### Step 8|表示层:SignalR Hub + 在线表 + 用户标识 **做什么**:新增 `Api/Hubs/NotificationHub.cs`(**3 个类型放同一文件**)。 ```csharp /// 在线用户登记表(进程内内存,用于在线/离线判定) public static class OnlineUserRegistry { private static readonly ConcurrentDictionary _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); } /// 通知 Hub:连接/断开时维护在线表 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() // ★ 与目标系统 JWT 声明保持一致 => long.TryParse(Context.User?.FindFirst("id")?.Value, out var id) ? id : 0; } /// 用户标识提供器:决定 Clients.User(...) 用什么匹配连接 public class NotificationUserIdProvider : IUserIdProvider { public string GetUserId(HubConnectionContext connection) // ★ 同上,必须与 GetUserId 同源 => connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId; } ``` **要点**: - 用户 ID 的**声明名必须三处一致**:Hub `GetUserId`、`UserIdProvider`、REST 控制器 `GetUserId()`,且与登录签发 token 时的声明名一致。 - 在线表是**进程内内存**:单实例正确;多实例需 Redis + SignalR 背板(见 4.8)。 **验证**:连上 Hub 后,`OnlineUserRegistry.IsOnline(userId)` 为 true;断开为 false。 --- ### Step 9|表示层:推送实现 `NotificationPusher` **做什么**:新增 `Api/Services/NotificationPusher.cs`,实现 Step 5 的接口。 ```csharp public class NotificationPusher : INotificationPusher { private readonly IHubContext _hubContext; public NotificationPusher(IHubContext hubContext) => _hubContext = hubContext; public bool IsOnline(long userId) => OnlineUserRegistry.IsOnline(userId); public async Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default) { if (!OnlineUserRegistry.IsOnline(userId)) return 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); } ``` **关键设计(迁移时必须保留)**:**不能靠 try/catch 判断是否送达** —— SignalR 对"该用户无连接"的 `Clients.User(...)` 调用是**静默成功**的,不判在线会把离线用户误记为推送成功。 **验证**:在线用户收到 `ReceiveNotification`;离线用户 `receiver.IsOffline = true`。 --- ### Step 10|表示层:REST 控制器 **做什么**:新增 `Api/Controllers/Notice/NotificationControllers.cs`(2 个控制器)。 | 控制器 | 路由前缀 | 端点 | |--------|---------|------| | `NotificationTypeController` | `/api/notification/type` | `GET` 列表 / `POST` 创建 / `PUT {id}` 更新 / `DELETE {id}` 删除 | | `NotificationController` | `/api/notification` | `GET subscribe`、`POST subscribe`、`GET messages`、`GET todos`、`POST todos/{id}/complete`、`PUT messages/{id}/read`、`PUT messages/read-all`、`GET unread-count`、`POST trigger`、`POST broadcast` | **要点**: - 全部 `[Authorize]`;用户 ID 从 token 声明取(`User.FindFirst("id")`)。 - `unread-count` 返回 `{ count }`(前端按此字段读取)。 - `broadcast` 需校验权限码 `notification:broadcast`。 **验证**:Swagger 中可见全部端点;`GET /api/notification/unread-count` 返回 200。 --- ### Step 11|装配:依赖注入与 SignalR 配置 **① 应用层注册**(`Application/DependencyInjection.cs`) ```csharp services.AddScoped(); services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly)); ``` **② 启动配置**(`Program.cs`) ```csharp // SignalR + 用户标识 + 即时推送器(无后台投递服务) builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddScoped(); // JWT:让 Hub 能从 query 取 token(WebSocket 不能带自定义请求头) .AddJwtBearer(options => { 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; } }; }); // 终点映射 app.MapHub("/hubs/notification"); ``` **③ 基础设施层**:无需新增注册(复用 `AddScoped(typeof(IRepository<>), typeof(Repository<>))`)。 **要点**:若目标系统的 JWT 已配置 `OnMessageReceived`,需**追加** `/hubs` 分支(不要覆盖原有逻辑)。 **验证**:启动无异常;`POST /hubs/notification/negotiate` 未带 token 返回 401;带合法 token 返回 200。 --- ### Step 12|种子数据(消息类型 + 权限 + 菜单) **做什么**:把 `DbSeeder.SeedNotificationData` 片段移植到目标系统的种子类。 **① 消息类型(5 个,幂等:按 Code 判存)** | Code | 名称 | 分类 | 订阅权限码 | 路由模板 | |------|------|------|-----------|---------| | `TeyeFound` | 发现特野 | 通知 | 空(所有人可订阅) | `/prod/xdjy` | | `TdcFound` | 发现特大糙 | 通知 | 空 | `/prod/hbjy` | | `LeaveApproval` | 请假审批 | **待办** | `notification:subscribe:leaveapproval` | `/attendance/approve?leaveId={businessId}` | | `LeaveResult` | 请假结果通知 | 通知 | 空 | `/attendance/my` | | `Broadcast` | 广播通知 | 公告 | 空 | 空 | **② 广播发送权限单元**:`Permission{ ResourceType=操作, Code="notification:broadcast" }`(admin 自动拥有)。 **③ 菜单(1 目录 + 3 菜单)**: ``` 消息中心(directory, path=/notification, icon=Bell) ├─ 我的消息 path=/notification/messages component=notification/messages/index ├─ 消息订阅 path=/notification/subscribe component=notification/subscribe/index └─ 消息类型管理 path=/notification/type component=notification/type/index ``` **④ admin 角色分配消息中心菜单**(否则 admin 看不到菜单)。 **⑤ 订阅权限同步**:遍历所有 `RequiredPermission` 非空的消息类型,按 `ResourceType=消息订阅 + ResourceId` 幂等创建/更新 `Permission`(`Code = RequiredPermission`),使「角色管理 → 分配权限」可勾选。 **前置**:目标系统枚举中需存在 `Resource_Type_Enum.消息订阅`(缺失则需新增枚举值,或改为复用目标系统已有的"操作/资源"类型)。 **验证**:`NotificationTypes` 有 5 条;`RoleMenus` 中 admin 关联了 4 条消息中心菜单。 --- ### Step 13|前端:SignalR 客户端 **做什么**:新增 `web/src/utils/notification.js`。 **核心实现**: ```javascript export const unreadCount = ref(0); // 全局未读数(角标与消息页共用) export function initNotification(onMessage) { if (connection) { if (onMessage) messageHandlers.push(onMessage); return connection; } if (onMessage) messageHandlers.push(onMessage); connection = new signalR.HubConnectionBuilder() .withUrl('/hubs/notification', { accessTokenFactory: () => getAccessToken() || '' }) .withAutomaticReconnect() .configureLogging(signalR.LogLevel.Warning) .build(); connection.on('ReceiveNotification', (msg) => { // 事件名与后端一致 unreadCount.value++; messageHandlers.forEach(h => { try { h(msg); } catch (e) {} }); }); connection.start().catch(() => {}); return connection; } ``` 导出清单:`unreadCount`、`initNotification(onMessage)`、`loadUnreadCount()`、`openMessage(msg, router)`、`onBusinessChanged(handler)`(可选)、`stopNotification()`。 **要点**: - `accessTokenFactory` 必须提供 token(WebSocket 不能用请求头)。 - 开发环境 `/hubs` 需在 Vite 代理中配置 **`ws: true`**(否则 WebSocket 握手失败)。 - `openMessage`:非待办且未读 → `PUT /notification/messages/{id}/read` → 按 `msg.routePath` 跳转。 **验证**:浏览器 Network → WS 出现 `/hubs/notification` 且状态 101。 --- ### Step 14|前端:页面 + 路由/菜单注册 **做什么**: 1. 新增 3 个页面:`views/notification/{messages,subscribe,type}/index.vue` 2. **在路由的组件映射表中注册这 3 个组件**(当前 F9WebAiTest 的 `componentMap` / 目标系统的动态组件解析表): ```javascript 'notification/messages/index': () => import('../views/notification/messages/index.vue'), 'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'), 'notification/type/index': () => import('../views/notification/type/index.vue') ``` 3. **(可选,推荐)顶部铃铛角标**:在 `layout` 侧边/顶部放一个铃铛 + `unreadCount` 角标,点击进消息中心;登录后调用 `initNotification(...)` 与 `loadUnreadCount()`,登出调用 `stopNotification()`。 **⚠️ 重要提醒**:当前 F9WebAiTest 系统中,**后端的菜单种子(Step 12)已写入 `component=notification/.../index`,但前端 `router` 的组件映射表尚未注册这 3 个组件**——因此菜单点开可能报组件未找到。迁移到目标系统时**务必补上第 2 条**(顺带建议回补当前系统)。 **验证**:菜单「消息中心」下三个页面可正常打开;收到消息时角标 +1、消息页可见新消息。 --- ## 3. 迁移后验收清单 | # | 验收项 | 期望结果 | |---|--------|---------| | 1 | 5 张表 + 唯一索引创建 | 表与索引均存在 | | 2 | 种子:5 个消息类型 | `NotificationTypes` 5 条 | | 3 | 种子:菜单与 admin 授权 | admin 可见「消息中心」4 个菜单 | | 4 | 接口:类型 CRUD / 订阅 / 消息 / 待办 / 未读 | 全部 200,数据正确 | | 5 | Hub 连接与鉴权 | 未带 token → 401;带 token → 101 | | 6 | 在线表 | 连接后 `IsOnline = true`;断开 = false | | 7 | 在线实时推送 | 另一端发消息 → 1 秒内收到 `ReceiveNotification` | | 8 | 离线标记 | 离线用户接收人记录 `IsOffline = true`、`SendStatus = 0/NotSent` | | 9 | 上线拉取兜底 | 离线期间的消息在消息中心可见、未读数正确 | | 10 | 已读 | 标记已读后未读数减少 | | 11 | 待办闭环 | 待办可查询、可完成;完成后消息转已读 | | 12 | 订阅权限联动 | 受限类型:无权限用户订阅被拒(403);角色分配权限后可订阅 | | 13 | 广播 | 有 `notification:broadcast` 权限者发送成功,订阅者收到 | | 14 | 前端页面与角标 | 三页面可打开,角标实时更新 | --- ## 4. 已知坑与适配点(迁移时逐条核对) | # | 坑 | 处理 | |---|----|------| | 4.1 | **JWT 用户 ID 声明名不同** | 改 3 处:`NotificationHub.GetUserId`、`NotificationUserIdProvider.GetUserId`、控制器 `GetUserId()`;必须与登录签发 token 的声明一致(当前为 `"id"`) | | 4.2 | **枚举字段未映射为 int** | 在 FreeSql 配置中加 `Aop.ConfigEntityProperty`:枚举 → `typeof(int)`;否则枚举存字符串,与旧库/其它表不一致 | | 4.3 | **软删除全局过滤器缺失** | 需为实体基类注册 `GlobalFilter.Apply("软删除", a => a.IsDeleted == false)`,否则查询结果与唯一索引行为异常 | | 4.4 | **唯一索引未创建** | `MessageId+UserId`(接收人幂等)、`UserId+NotificationTypeId`(订阅幂等)必须建,否则重复投递/重复订阅 | | 4.5 | **WebSocket token 传递** | `OnMessageReceived` 必须有 `/hubs` 分支读 `access_token`;否则 Hub 连不上(普通接口正常,容易误判) | | 4.6 | **Vite 代理未开 ws** | `'/hubs': { target, changeOrigin: true, ws: true }` | | 4.7 | **`Clients.User` 命中不到** | 前提是 `IUserIdProvider` 与登录 token 声明一致;取不到时会退化为 `ConnectionId`,导致定向推送全部失效 | | 4.8 | **多实例部署** | 在线表是进程内内存,多实例下在线判定不准 → 需 Redis + SignalR 背板(`AddStackExchangeRedis`),并把在线判定交给背板 | | 4.9 | **同步推送影响请求耗时** | 接收人很多(如全员广播)时,推送在同一请求内执行;量大时改为后台队列/批量推送(投递入口集中在 `CreateNotificationCommandHandler`,改造点单一) | | 4.10 | **种子插入 NOT NULL 字段** | `BaseEntity.CreatedAt` 等审计字段若非空,插入种子时需赋值(当前系统曾因 `created_at` NOT NULL 导致种子失败) | | 4.11 | **前端组件未注册** | 菜单的 `component` 路径必须在前端组件映射表中注册,否则页面打不开(见 Step 14) | | 4.12 | **路由跳转目标不存在** | 消息类型 `RouteTemplate` 指向的业务页面需在目标系统存在(如 `/prod/xdjy`、`/attendance/approve`),否则点击消息跳转 404 | | 4.13 | **CORS** | 前端与后端不同源时需允许跨域;SignalR 若启用 `AllowCredentials` 则不能 `AllowAnyOrigin`(二者互斥) | | 4.14 | **命名空间/类型差异** | 按 0.2 表格全局替换后再编译,避免逐个报错 | --- ## 5. 迁移顺序速查(TL;DR) ``` 后端 1. 枚举 NotificationEnums.cs 2. 5 个实体 Notice/*.cs(含唯一索引) 3. 建表 SyncStructure / 补列 4. 9 个 DTO NotificationDtos.cs 5. 推送接口 INotificationPusher.cs 6. 消息服务 NotificationService.cs(含 RBAC 权限联动) 7. 创建命令+投递 NotificationMediatR.cs ★核心 8. Hub+在线表+标识 NotificationHub.cs 9. 推送实现 NotificationPusher.cs 10. 控制器 NotificationControllers.cs 11. DI 与 SignalR DI + Program.cs(含 access_token query) 12. 种子 消息类型 + 广播权限 + 菜单 + admin 授权 + 订阅权限同步 前端 13. SignalR 客户端 utils/notification.js 14. 3 个页面 + 组件映射注册 +(可选)顶部角标 ``` > 每完成一步都编译一次;Step 7 完成后即可用 `POST /api/notification/trigger` 做端到端验证,再往下做前端。 --- **文档结束**(迁移指南 v1.0,对应消息推送系统 V2.0 实现)