645 lines
33 KiB
Markdown
645 lines
33 KiB
Markdown
# 消息推送系统 迁移指南(迁移到已有 RBAC 系统)
|
||
|
||
> 适用场景:目标系统与当前系统**架构一致**(.NET 8 分层 + FreeSql + MediatR + SignalR + `IRepository<T>` 泛型仓储 + JWT)。
|
||
> 本文按**可执行顺序**拆分为 14 步,每步含:**要建什么 → 关键代码要点 → 验证方法**。
|
||
> 当前实现基线:`F9WebAiTest` 生活圈管理系统(消息推送 V2.0,即时推送,无后台服务)。
|
||
|
||
---
|
||
|
||
## 0. 迁移前须知
|
||
|
||
### 0.1 目标系统前置条件自查(缺一不可)
|
||
|
||
| # | 依赖项 | 具体要求 | 不满足时的处理 |
|
||
|---|--------|---------|--------------|
|
||
| 1 | 运行时 | .NET 8 / ASP.NET Core | 必须 |
|
||
| 2 | ORM | FreeSql(`IFreeSql` 单例),支持 CodeFirst | 若用 EF Core,需重写仓储调用(数据访问层代码量约 200 行) |
|
||
| 3 | 仓储 | `IRepository<T>` 泛型仓储,含 `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<T>` / `ServiceException` | 目标系统的同名/等价类型 |
|
||
| `ApiResult<T>` / `PageResult<T>` | 目标系统的返回包装(**分页字段名可能不同**) |
|
||
| `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<T>`。
|
||
- 枚举字段在 FreeSql 中统一映射为 `int`(目标系统若没配,实体枚举会存成字符串,导致与其他表不一致)。
|
||
|
||
**验证**:CodeFirst 同步后 5 张表存在,且两个唯一索引已建立(见 Step 3)。
|
||
|
||
---
|
||
|
||
### Step 3|数据库:建表
|
||
|
||
**方式 A(推荐,与当前一致)**:FreeSql CodeFirst
|
||
|
||
```csharp
|
||
// 在目标系统的表结构同步处(当前系统在 DbSeeder 内)
|
||
fsql.CodeFirst.SyncStructure<NotificationType>();
|
||
fsql.CodeFirst.SyncStructure<NotificationMessage>();
|
||
fsql.CodeFirst.SyncStructure<NotificationReceiver>();
|
||
fsql.CodeFirst.SyncStructure<TodoItem>();
|
||
fsql.CodeFirst.SyncStructure<UserNotificationSubscription>();
|
||
```
|
||
|
||
**方式 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<bool> 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<List<NotificationTypeDto>> GetSubscribeListAsync(long userId);
|
||
Task SubscribeAsync(long userId, long typeId, bool subscribe);
|
||
// 消息
|
||
Task<PageResult<NotificationMessageDto>> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category);
|
||
Task MarkReadAsync(long userId, long messageId);
|
||
Task MarkAllReadAsync(long userId);
|
||
Task<int> GetUnreadCountAsync(long userId);
|
||
// 待办
|
||
Task<List<TodoItemDto>> GetMyTodosAsync(long userId, int? status);
|
||
Task CompleteTodoAsync(long userId, long todoId, string handleResult);
|
||
// 管理端:类型维护
|
||
Task<List<NotificationTypeDto>> GetTypeListAsync();
|
||
Task<NotificationTypeDto> CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId);
|
||
Task<NotificationTypeDto> UpdateTypeAsync(long id, NotificationTypeCreateDto dto);
|
||
Task DeleteTypeAsync(long id);
|
||
}
|
||
```
|
||
|
||
**依赖注入(构造函数)**:
|
||
|
||
```csharp
|
||
IRepository<NotificationType> _typeRepo;
|
||
IRepository<UserNotificationSubscription> _subRepo;
|
||
IRepository<NotificationMessage> _msgRepo;
|
||
IRepository<NotificationReceiver> _receiverRepo;
|
||
IRepository<TodoItem> _todoRepo;
|
||
IRepository<RolePermission> _roleMenuRepo; // 目标系统权限关联实体
|
||
IRepository<Permission> _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<long>
|
||
{
|
||
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<long> ForceReceivers { get; set; } = new(); // 精确送达(绕过订阅)
|
||
public string RoutePath { get; set; } = ""; // 覆盖路由模板
|
||
public List<long> 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<long> ForceReceivers = null, List<long> AssigneeIds = null, DateTime? DueTime = null) : IRequest<bool>;
|
||
```
|
||
|
||
**`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<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);
|
||
}
|
||
|
||
/// 通知 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<NotificationHub> _hubContext;
|
||
public NotificationPusher(IHubContext<NotificationHub> hubContext) => _hubContext = hubContext;
|
||
|
||
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; // 离线:不推送
|
||
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<INotificationService, NotificationService>();
|
||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly));
|
||
```
|
||
|
||
**② 启动配置**(`Program.cs`)
|
||
|
||
```csharp
|
||
// SignalR + 用户标识 + 即时推送器(无后台投递服务)
|
||
builder.Services.AddSignalR();
|
||
builder.Services.AddSingleton<IUserIdProvider, NotificationUserIdProvider>();
|
||
builder.Services.AddScoped<INotificationPusher, NotificationPusher>();
|
||
|
||
// 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<NotificationHub>("/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<BaseEntity>("软删除", 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 实现)
|