Files
f9-web-ai-test/消息推送系统_迁移指南.md
T
2026-10-08 10:43:59 +08:00

33 KiB
Raw Blame History

消息推送系统 迁移指南(迁移到已有 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 处易错点):

  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。

两类命令:

// ① 底层命令(按 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|前端:页面 + 路由/菜单注册

做什么:

  1. 新增 3 个页面:views/notification/{messages,subscribe,type}/index.vue
  2. 在路由的组件映射表中注册这 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')
  1. (可选,推荐)顶部铃铛角标:在 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 实现)