33 KiB
消息推送系统 迁移指南(迁移到已有 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 个枚举。
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 唯一 |
代码要点:
[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
// 在目标系统的表结构同步处(当前系统在 DbSeeder 内)
fsql.CodeFirst.SyncStructure<NotificationType>();
fsql.CodeFirst.SyncStructure<NotificationMessage>();
fsql.CodeFirst.SyncStructure<NotificationReceiver>();
fsql.CodeFirst.SyncStructure<TodoItem>();
fsql.CodeFirst.SyncStructure<UserNotificationSubscription>();
方式 B(旧库已有表时):只补列
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 不会为已存在的表新增列,旧库必须走"补列"。
验证:
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(接口放应用层)。
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 方法):
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);
}
依赖注入(构造函数):
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 处易错点):
- 查询以接收人表为主表:
GetMyMessagesAsync先查NotificationReceiver(UserId),再联消息与类型名,ReadStatus取自接收人(同一消息对不同用户状态不同)。 - 未读数:
CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)。 - 完成待办后标记该消息已读:避免"处理完了还挂未读红点"。
- 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。
两类命令:
// ① 底层命令(按 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 个类型放同一文件)。
/// 在线用户登记表(进程内内存,用于在线/离线判定)
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 的接口。
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)
services.AddScoped<INotificationService, NotificationService>();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly));
② 启动配置(Program.cs)
// 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。
核心实现:
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|前端:页面 + 路由/菜单注册
做什么:
- 新增 3 个页面:
views/notification/{messages,subscribe,type}/index.vue - 在路由的组件映射表中注册这 3 个组件(当前 F9WebAiTest 的
componentMap/ 目标系统的动态组件解析表):
'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')
- (可选,推荐)顶部铃铛角标:在
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 实现)