# 消息推送系统 软件设计说明书 | 项目名称 | 生活圈管理系统(F9WebAiTest)- 消息推送子系统 | |---------|------------------------------------------| | 文档类型 | 软件设计说明书(SDD) | | 版本号 | **V2.0** | | 编写日期 | 2026-09-15 | | 后端技术栈 | .NET 8 / FreeSql(CodeFirst)/ MediatR / SignalR / JWT / SQLite | | 前端技术栈 | Vue 3 / Vite / Element Plus / Pinia / @microsoft/signalr | --- ## 修订记录 | 版本 | 日期 | 变更说明 | |------|------|---------| | V1.1 | 2026-09-09 | 初版:后台投递服务(BackgroundService 每 3 秒轮询扫描 Ready 消息)异步投递 | | **V2.0** | **2026-09-15** | **取消后台投递服务与轮询,改为"创建消息即同步投递 + 立即推送"**;投递逻辑由 Api 层后台服务迁入 Application 层命令处理器;新增 `INotificationPusher` 推送抽象;明确在线/离线判定机制;同步修订可靠性设计与接口说明 | ### V2.0 架构变更说明(重点) | | V1.1(旧) | V2.0(现) | |---|---|---| | 投递触发 | 后台服务每 3 秒轮询 `Status = Ready` 的消息 | 业务创建消息的**同一次请求内**完成投递 | | 投递位置 | `F9WebAiTest.Api/Services/NotificationDeliveryService.cs`(BackgroundService) | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs`(命令处理器) | | 消息状态 | 创建 `Ready` → 投递后 `Sent` | 创建即 `Sent`(无中间态) | | 推送方式 | 后台服务持有 `IHubContext` 直接推送 | 经 `INotificationPusher` 抽象推送(接口在应用层、实现在 Api 层) | | 延迟 | 0 ~ 3 秒(取决于轮询周期) | **无延迟**(创建即推送) | | 失败重试 | `RetryCount++`,超 `MaxRetryCount` 标记 `Failed` | **无自动重试**(见 7.3 取舍说明) | **为什么取消后台服务**:投递本身是一次内存 + 一次数据库写入的轻量操作,用后台轮询换取"解耦"的收益远小于代价——引入了 0~3 秒延迟、常驻线程与定时器、Ready 状态的中间态管理、以及 SQLite 上的高频无谓查询。改为同步投递后,链路更短、行为更可预期、代码量显著减少。 --- ## 1. 引言 ### 1.1 编写目的 本文档描述消息推送子系统的总体架构、数据模型、**各模块的具体实现方式、所用技术与关键流程**,特别是**用户在线/离线如何判定**、离线用户如何兜底,作为开发、测试、维护与后续扩展的依据。 ### 1.2 项目背景 业务系统(请假审批、考勤、质量检验等)在运行过程中需要向相关人员推送提醒、通知与待办。为统一承载这些消息,构建一套通用、可扩展、多端协同的消息推送子系统。 ### 1.3 术语定义 | 术语 | 英文/类型 | 说明 | |------|----------|------| | 消息类型 | NotificationType | 消息的元数据模板:编码、名称、分类、订阅权限码、跳转路由模板 | | 消息 | NotificationMessage | 一条具体的消息实例,由业务触发创建 | | 接收人 | NotificationReceiver | 消息 × 用户 的关联记录,含该用户的推送状态与阅读状态 | | 订阅 | UserNotificationSubscription | 用户对某个消息类型的订阅关系 | | 待办 | TodoItem | 待办类消息附带的事项,含处理人、状态、处理结果 | | 强制接收人 | ForceReceivers | 绕过订阅、精准指定送达的用户 ID 集合(JSON 数组) | | 在线 | Online | 该用户在 SignalR Hub 上存在**活动连接**(见 4.6) | | 离线 | Offline | 该用户在 Hub 上无活动连接,推送无接收者 | | 拉取兜底 | Pull Fallback | 离线用户上线后通过 REST 接口读取历史消息,保证消息不丢 | --- ## 2. 系统概述 ### 2.1 设计目标 1. **统一触达**:业务侧一行命令即可发送消息,不关心投递细节。 2. **双送达机制**:订阅式广播(按消息类型订阅关系)+ 强制接收(精准必达)。 3. **实时推送**:在线用户 SignalR **即时**推送,离线用户持久化后拉取兜底。 4. **三类消息**:通知(Notification)/ 待办(Todo)/ 公告(Announcement)。 5. **待办闭环**:待办类消息自动生成 `TodoItem`,支持查询与完成。 6. **多端跳转与同步**:消息携带业务标识与路由,多端一致。 7. **架构简洁**:无后台任务、无轮询、无中间队列。 ### 2.2 功能范围 | 模块 | 功能点 | |------|--------| | 消息类型管理 | 类型增删改查、分类、订阅权限码、跳转路由模板 | | 消息订阅 | 可订阅列表(含权限判定)、订阅/退订 | | 消息发送 | 业务侧经 MediatR 命令触发(枚举式 / 底层式两种命令) | | 消息投递 | 创建即投递:确定接收人 → 写接收人表 → 立即推送 | | 消息读取 | 我的消息(分页 + 分类/已读筛选)、标记已读、全部已读、未读数 | | 待办管理 | 我的待办、完成待办 | | 实时推送 | 新消息推送、未读统计推送、业务变更事件推送 | | 广播通知 | 拥有权限的用户发送广播(订阅广播) | ### 2.3 运行环境与技术栈 | 项目 | 选型 | 用途 | |------|------|------| | 后端框架 | .NET 8(ASP.NET Core) | Web API 宿主 | | ORM | **FreeSql**(`UseConnectionString(DataType.Sqlite, ...)`) | 实体映射、CodeFirst 建表、仓储实现 | | 数据结构 | SQLite(`f9_web_ai_test_rbac.db`) | 单文件数据库,便于开发与部署 | | 应用层模式 | **MediatR** | 命令/查询分发(CQRS 风格) | | 实时通信 | **SignalR**(WebSocket,自动降级 SSE / Long Polling) | 服务端 → 客户端实时推送 | | 鉴权 | JWT(`System.IdentityModel.Tokens.Jwt`) | REST 与 Hub 统一鉴权 | | API 文档 | NSwag(OpenAPI + Swagger UI) | 接口文档与客户端生成 | | 在线登记 | `System.Collections.Concurrent.ConcurrentDictionary` | 进程内在线用户表 | | 前端 | Vue 3 + Vite + Element Plus + Pinia | 管理端界面 | | 前端实时 | `@microsoft/signalr` | SignalR 客户端 | ### 2.4 架构总览 ``` ┌────────────────────────────────────────────────────────────────────────┐ │ 业务子系统(Application) │ │ 请假审批 AttendanceService 考勤 质量检验 … │ │ │ │ │ │ await mediator.Send(new SendNotificationCommand(...)) │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ CreateNotificationCommandHandler ← 投递入口(同步执行) │ │ │ │ ① 校验消息类型 ② 建消息(Sent) ③ 生成待办(Todo) │ │ │ │ ④ 确定接收人 ⑤ 写接收人表 ⑥ 立即推送 │ │ │ └───────────────────────────┬──────────────────────────────────┘ │ │ │ INotificationPusher(应用层接口) │ └──────────────────────────────┼─────────────────────────────────────────┘ │ 实现(Api 层) ▼ ┌──────────────────────────────────┐ │ NotificationPusher │ │ · IsOnline() ← 在线表判定 │ │ · PushNewMessageAsync() │ │ · PushUnreadCountAsync() │ └──────────────┬───────────────────┘ │ IHubContext ▼ ┌───────────────────────────────────────────────────┐ │ SignalR Hub /hubs/notification │ │ OnConnectedAsync → OnlineUserRegistry.Add │ │ OnDisconnectedAsync → OnlineUserRegistry.Remove │ │ UserIdentifier → JWT "id" 声明 │ └───────────────┬───────────────────────────────────┘ │ 事件:ReceiveNotification / UnreadCountChanged ▼ ┌───────────────────────────────────────────────────┐ │ 前端 notification.js(SignalR 客户端,自动重连) │ │ unreadCount+1 → 弹窗 / 角标 / 页面刷新 │ └───────────────────────────────────────────────────┘ ``` --- ## 3. 数据模型 ### 3.1 枚举定义 文件:`F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs` ```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 } ``` > `NotificationStatus`:V2.0 下消息创建即为 `Sent`,`Ready`/`Failed`/`Revoked` 保留以兼容历史数据与后续扩展。 ### 3.2 实体一览(5 张表) 目录:`F9WebAiTest.Domain/Entities/Notice/`(每个实体独立文件) | 实体 | 表名 | 关键字段 | 用途 | |------|------|---------|------| | `NotificationType` | `NotificationTypes` | `Code`(唯一)、`Name`、`Category`、`RequiredPermission`、`RouteTemplate`、`IsActive`、`Sort` | 消息类型模板 | | `NotificationMessage` | `NotificationMessages` | `NotificationTypeId`、`MessageTypeCode`、`Title`、`Content`、`Priority`、`Status`、`ForceReceivers`、`Category`、`RoutePath` | 消息主表 | | `NotificationReceiver` | `NotificationReceivers` | `MessageId`、`UserId`、`ReadStatus`、`ReadTime`、`IsOffline`、`SendStatus`、`SendTime`、`ErrorMsg` | 每人一条,独立推送/阅读状态 | | `TodoItem` | `TodoItems` | `MessageId`、`AssigneeId`、`TodoStatus`、`DueTime`、`CompletedTime`、`HandleResult` | 待办事项 | | `UserNotificationSubscription` | `UserNotificationSubscriptions` | `UserId`、`NotificationTypeId`、`IsActive`、`SubscribedTime`、`CancelTime` | 订阅关系 | ### 3.3 关系 ``` NotificationType 1 ── N NotificationMessage 1 ── N NotificationReceiver N ── 1 User │ │ │ 1 │ 1 └── N UserNotificationSubscription └── N TodoItem (AssigneeId → User) ``` ### 3.4 关键约束(幂等保障) | 约束 | 位置 | 作用 | |------|------|------| | `Code` 唯一 | `NotificationTypes` | 类型编码唯一,业务按 Code 发送 | | `MessageId + UserId` 唯一 | `NotificationReceivers` | **接收人幂等**,重复投递不会产生重复记录 | | `UserId + NotificationTypeId` 唯一 | `UserNotificationSubscriptions` | 订阅幂等 | | 软删除 | 全部实体(`BaseEntity`) | 逻辑删除,审计可追溯 | 建表方式:FreeSql CodeFirst(`Publish` + 种子由 `Infrastructure/Data/DbSeeder.cs` 完成)。 ### 3.5 实体 ↔ DTO ↔ 接口 对照总表 > 说明:本项目 DTO 以 `class` 定义在 `F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(集中文件); > 实体为**纯属性 POCO**(无领域方法,状态变更由应用服务直接赋值后 `UpdateAsync` 持久化)。 | 实体(表) | 出参 DTO(读) | 入参 DTO / 命令(写) | 数据访问接口 | 业务处理接口 | |---|---|---|---|---| | `NotificationType`(`NotificationTypes`) | `NotificationTypeDto` | `NotificationTypeCreateDto` | `IRepository` | `INotificationService.CreateTypeAsync / UpdateTypeAsync / DeleteTypeAsync / GetTypeListAsync / GetSubscribeListAsync`(内部 `SyncSubscribePermissionAsync` 同步 RBAC 权限单元) | | `NotificationMessage`(`NotificationMessages`) | `NotificationMessageDto`(列表 + **SignalR 推送载荷**) | `CreateNotificationCommand`、`SendNotificationCommand`、`BroadcastNotificationDto`、`TriggerDto` | `IRepository` | `CreateNotificationCommandHandler`(创建 + 即时投递)、`INotificationService.GetMyMessagesAsync` | | `NotificationReceiver`(`NotificationReceivers`) | `NotificationMessageDto.ReadStatus / ReadTime`、`UnreadCountDto` | 无(由投递过程内部生成,不接受外部入参) | `IRepository` | `CreateNotificationCommandHandler.DeliverAsync`(写入 + 回写推送状态)、`INotificationService.MarkReadAsync / MarkAllReadAsync / GetUnreadCountAsync` | | `TodoItem`(`TodoItems`) | `TodoItemDto` | `CompleteTodoDto` | `IRepository` | `CreateNotificationCommandHandler`(按 `AssigneeIds` 生成待办)、`INotificationService.GetMyTodosAsync / CompleteTodoAsync` | | `UserNotificationSubscription`(`UserNotificationSubscriptions`) | `NotificationTypeDto.Subscribed`(并入类型列表) | `SubscribeDto` | `IRepository` | `INotificationService.SubscribeAsync`(含 `RequiredPermission` 权限校验) | ### 3.6 DTO 明细(字段级) 文件:`F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(`TriggerDto` 定义在控制器文件内) **① 消息类型** | DTO | 字段 | 类型 | 说明 | |-----|------|------|------| | `NotificationTypeDto`(出参) | Id / Code / Name / Description | long / string ×3 | 基础信息 | | | RequiredPermission | string | 订阅所需权限码,空 = 所有人可订阅 | | | IsActive / CreatedBy / Sort | bool / long / int | 开放订阅 / 创建人 / 排序 | | | Category / Icon / RouteTemplate | int / string / string | 分类(0通知1待办2公告) / 图标 / 路由模板 | | | **Subscribed** | bool | **当前用户是否已订阅**(`GetSubscribeListAsync` 填充) | | | **CanSubscribe** | bool | **当前用户是否有权订阅**(权限码比对结果) | | `NotificationTypeCreateDto`(入参) | Code / Name / Description / RequiredPermission / IsActive / Sort / Category / Icon / RouteTemplate | 同上(无 Id/CreatedBy/Subscribed/CanSubscribe) | 创建与更新共用 | **② 订阅** | DTO | 字段 | 类型 | 说明 | |-----|------|------|------| | `SubscribeDto` | NotificationTypeId | long | 目标消息类型 | | | Subscribe | bool | true=订阅,false=退订 | **③ 消息** | DTO | 字段 | 类型 | 说明 | |-----|------|------|------| | `NotificationMessageDto` | Id / NotificationTypeId | long | 消息ID / 类型ID | | | MessageTypeCode / TypeName | string | 类型编码(冗余,免联表)/ 类型名称 | | | Title / Content | string | 标题 / 内容 | | | Priority | int | 0普通 1重要 2紧急 | | | BusinessType / BusinessId | string | 业务类型 / 业务单据ID(多端跳转依据) | | | Category | int | 0通知 1待办 2公告 | | | RoutePath | string | 最终跳转路由(模板 + 业务ID 生成) | | | CreatedTime | DateTime | 创建时间 | | | **ReadStatus** / ReadTime | int / DateTime? | **当前用户**阅读状态(来自接收人表) | | `UnreadCountDto` | Count | int | 未读消息数 | **④ 待办** | DTO | 字段 | 类型 | 说明 | |-----|------|------|------| | `TodoItemDto` | Id / MessageId | long | 待办ID / 关联消息ID | | | Title / Content / Priority | string / string / int | 取自关联消息 | | | BusinessType / BusinessId / RoutePath | string | 取自关联消息(供"去处理"跳转) | | | TodoStatus | int | 0待处理 1处理中 2已完成 3已取消 | | | DueTime / CompletedTime / CreatedTime | DateTime? / DateTime? / DateTime | 截止 / 完成 / 创建时间 | | `CompleteTodoDto` | HandleResult | string | 处理结果说明 | **⑤ 广播与测试** | DTO | 字段 | 说明 | |-----|------|------| | `BroadcastNotificationDto` | Title、Content、Priority(`NotificationPriority?`)、RoutePath | 广播入参(需 `notification:broadcast` 权限) | | `TriggerDto` | Code(默认 `TeyeFound`)、Title、Content、BusinessType、BusinessId、Priority、RoutePath、AssigneeIds | 测试/联调触发(`POST /api/notification/trigger`) | **通用返回包装** | 类型 | 说明 | |------|------| | `ApiResult` | `{ success, message, code, data }`,`ApiResult.Ok/Fail` | | `PageResult` | `{ items, total, pageIndex, pageSize }`(`NotificationMessageDto` 分页) | ### 3.7 接口清单与实现绑定 **① 领域层 —— 通用仓储(所有实体共用)** | 接口 | 方法 | 实现类 | 注册 | |------|------|--------|------| | `IRepository`(`Domain/Common/IRepository.cs`) | `GetByIdAsync`、`GetAllAsync`、`FindAsync`、`FirstOrDefaultAsync`、`AnyAsync`、`CountAsync`、`AddAsync`、`AddRangeAsync`、`UpdateAsync`、`DeleteAsync`、`DeleteRangeAsync`、`HardDeleteAsync`、`HardDeleteRangeAsync`、`SaveChangesAsync` | `Repository`(`Infrastructure/Repositories`) | `AddScoped(typeof(IRepository<>), typeof(Repository<>))` | > 5 个通知实体**不各自定义仓储接口**,统一使用泛型 `IRepository`(FreeSql 实现,内含软删除全局过滤器)。 **② 应用层 —— 通知服务** | 接口 | 方法(11) | 实现类 | 生命周期 | |------|-----------|--------|---------| | `INotificationService`(`Application/Services/Notice/NotificationService.cs`) | `GetSubscribeListAsync`、`SubscribeAsync`、`GetMyMessagesAsync`、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync`、`GetMyTodosAsync`、`CompleteTodoAsync`、`GetTypeListAsync`、`CreateTypeAsync`、`UpdateTypeAsync`、`DeleteTypeAsync` | `NotificationService` | `AddScoped()` | 依赖(构造函数注入):`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`ISnowflakeIdGenerator`、`IAuthService`。 **③ 应用层 —— 推送抽象** | 接口 | 方法(3) | 实现类(所在层) | 生命周期 | |------|----------|----------------|---------| | `INotificationPusher`(`Application/Services/Notice/INotificationPusher.cs`) | `bool IsOnline(long userId)`、`Task PushNewMessageAsync(long userId, NotificationMessageDto, CancellationToken)`、`Task PushUnreadCountAsync(long userId, int, int, CancellationToken)` | `NotificationPusher`(**Api 层**,`Api/Services/NotificationPusher.cs`) | `Program.cs`:`AddScoped()` | **④ 应用层 —— MediatR 命令(消息创建的接口实现)** | 类型 | 实现的接口 | 处理器 | 返回 | |------|-----------|--------|------| | `CreateNotificationCommand` | `IRequest` | `CreateNotificationCommandHandler : IRequestHandler` | 消息 ID | | `SendNotificationCommand`(record) | `IRequest` | `SendNotificationCommandHandler : IRequestHandler` | 是否成功(内部转调 Create) | 注册:`services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly))`。 **⑤ 表示层 —— SignalR 与控制器** | 类型 | 实现的接口/基类 | 职责 | 注册 | |------|---------------|------|------| | `NotificationHub` | `Hub`(`Microsoft.AspNetCore.SignalR`) | 连接生命周期 → 在线表登记与移除 | `AddSignalR()` + `MapHub("/hubs/notification")` | | `NotificationUserIdProvider` | `IUserIdProvider` | 用户标识 = JWT `"id"` 声明(决定 `Clients.User` 命中规则) | `AddSingleton()` | | `NotificationTypeController` | `ControllerBase`(`[Authorize]`) | 类型管理 REST | `AddControllers()` | | `NotificationController` | `ControllerBase`(`[Authorize]`) | 订阅/消息/待办/未读/触发/广播 REST | 依赖 `INotificationService`、`IMediator`、`IAuthService` | **⑥ 业务变更事件(多端同步)** | 类型 | 实现的接口 | 说明 | |------|-----------|------| | `BusinessChangedEvent` | `INotification`(MediatR 通知) | 业务状态变化事件 | | `BusinessChangedEventHandler` | `INotificationHandler` | 经 SignalR 推送 `BusinessChanged` 事件;注册 `AddScoped, BusinessChangedEventHandler>()` | **⑦ 依赖的框架级接口** | 接口 | 用途 | 实现/注册 | |------|------|----------| | `ISnowflakeIdGenerator` | 雪花 ID(消息/接收人/待办/权限单元主键) | `SnowflakeIdGenerator`,`AddSingleton` | | `IAuthService` | `GetUserPermissionsAsync(userId)` → 订阅权限校验 | `AuthService`,`AddScoped` | | `IJwtService` | 签发 access/refresh token(含 `"id"` 声明) | `JwtService`,`AddScoped` | --- ## 4. 模块设计(各模块如何实现) ### 4.1 领域层:实体与枚举 - 位置:`F9WebAiTest.Domain/Entities/Notice/` - 实现要点:每个实体独立文件、继承 `BaseEntity`(含 `Id`、审计字段与软删除);实体为**纯数据 POCO**(属性 + 默认值),状态变更由应用服务赋值(如 `receiver.ReadStatus = Read`)后经仓储 `UpdateAsync` 持久化。 - 字段映射:`[Table(Name = "...")]` + `[Index(...)]` 特性声明表名与索引;枚举统一映射为 `int`(`Infrastructure/DependencyInjection.cs` 的 `Aop.ConfigEntityProperty`)。 - 技术:纯 POCO,无框架依赖(仅 `FreeSql.DataAnnotations` 特性,领域层不引用基础设施)。 ### 4.2 应用层:消息创建命令 —— 投递核心 ★ - 位置:`F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs` - 类型: - `SendNotificationCommand`(**简洁命令**,基于 `NotificationTypeEnum`,业务侧推荐) - `CreateNotificationCommand`(**底层命令**,按字符串 Code,灵活控制) - `SendNotificationCommandHandler`:枚举名 → Code,转调底层命令(复用同一套投递逻辑) - `CreateNotificationCommandHandler`:**投递实现** **处理流程(一次请求内同步完成)**: ``` ① 校验消息类型:按 Code 查 NotificationType,不存在/已关闭订阅 → 抛 ServiceException ② 生成跳转路由:命令指定的 RoutePath 优先,否则用类型模板替换 {businessId} ③ 创建消息:状态直接置 NotificationStatus.Sent(无后台队列、无 Ready 中间态) ForceReceivers 序列化为 JSON 存入消息(空 = 走订阅广播) ④ 待办类消息(Category = Todo 且有 AssigneeIds):为每个处理人生成 TodoItem(Pending) ⑤ 投递 DeliverAsync: a. 确定接收人: ForceReceivers 非空 → 精确送达(绕过订阅) 否则 → 查该类型 IsActive 的订阅记录,取 UserId 集合 b. 幂等写接收人:按 MessageId + UserId 判重,仅写入新增的 c. 逐个推送(见 4.3/4.4),并按结果回写推送状态 ⑥ 返回消息 ID ``` **接收人回写状态(V2.0 明确区分三种结果)**: | 推送结果 | `SendStatus` | `IsOffline` | 说明 | |---------|-------------|------------|------| | 在线且推送成功 | `Success` + `SendTime` | `false` | 同时推送未读统计 | | 用户离线 | `NotSent` | `true` | 不推送,等待上线拉取 | | 推送抛异常(连接刚断开) | `Failed` + `ErrorMsg` | `true` | 消息已持久化,仍可拉取 | **分层实现要点**:命令处理器位于 **Application 层**,此处不能引用 Api 层的 `IHubContext`(SignalR 属于表示层),因此推送能力被抽象为 `INotificationPusher`(见 4.3)。 ### 4.3 应用层:推送抽象 `INotificationPusher` - 位置:`F9WebAiTest.Application/Services/Notice/INotificationPusher.cs` ```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); } ``` **为什么接口在应用层、实现在 Api 层**:SignalR 的 `IHubContext` 由 ASP.NET Core 提供,属于表示层;依赖方向必须是 `Api → Application`,若 Application 直接依赖 `IHubContext` 会形成循环依赖。抽象后,依赖倒置,Application 只依赖自己定义的接口,DI 容器在启动时把 Api 层实现注入进去。 ### 4.4 表示层:推送实现 `NotificationPusher` - 位置:`F9WebAiTest.Api/Services/NotificationPusher.cs` - 注册:`Program.cs` → `builder.Services.AddScoped()` ```csharp 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; // 离线:不推送,返回 false await _hubContext.Clients.User(userId.ToString()) .SendAsync("ReceiveNotification", message, ct); // 在线:定向推送 return true; } public Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default) => _hubContext.Clients.User(userId.ToString()) .SendAsync("UnreadCountChanged", new { unreadCount, todoCount }, ct); ``` **技术要点**: - `Clients.User(userId)` —— SignalR 按 **UserIdentifier** 定向推送,可一次命中该用户的**所有在线连接**(支持同一用户多端/多标签页同时在线)。 - **必须显式判在线**:SignalR 对"该用户当前无连接"的 `Clients.User(...)` 调用是**静默成功**的(不抛异常、无返回)。若只靠 try/catch,离线用户会被误记为 `SendStatus = Success`。因此推送前用在线表判定,离线直接返回 `false` 交由上层标记 `IsOffline`。 ### 4.5 表示层:SignalR Hub 与用户标识 - 位置:`F9WebAiTest.Api/Hubs/NotificationHub.cs` - 端点映射:`Program.cs` → `app.MapHub("/hubs/notification")` ```csharp public class NotificationHub : Hub { public override async Task OnConnectedAsync() { var userId = GetUserId(); if (userId > 0) OnlineUserRegistry.Add(userId); // 连接建立 → 登记在线 await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception exception) { var userId = GetUserId(); if (userId > 0) OnlineUserRegistry.Remove(userId); // 连接断开 → 移除在线 await base.OnDisconnectedAsync(exception); } private long GetUserId() // 用户ID来源:JWT 的 "id" 声明 => long.TryParse(Context.User?.FindFirst("id")?.Value, out var id) ? id : 0; } /// 自定义 UserIdentifier:决定 Clients.User(...) 用哪个标识匹配连接 public class NotificationUserIdProvider : IUserIdProvider { public string GetUserId(HubConnectionContext connection) => connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId; } ``` **用户标识如何统一**(这是定向推送能命中的前提): | 环节 | 取值方式 | |------|---------| | 登录签发 JWT | `JwtService.GenerateAccessToken`:`new Claim("id", user.Id.ToString())` | | REST 控制器 | `User.FindFirst("id").Value` | | SignalR 连接标识 | `NotificationUserIdProvider` → JWT `"id"` 声明 | | 推送目标 | `Clients.User(userId.ToString())` | 四者一致,因此"REST 里的当前用户"与"SignalR 里的连接"指向同一用户。 **WebSocket 携带 JWT**:WebSocket 无法自定义请求头,浏览器 SignalR 客户端自动把 token 拼到查询串 `?access_token=xxx`;后端在 `Program.cs` 的 `JwtBearerEvents.OnMessageReceived` 中针对 `/hubs` 路径从 query 读取: ```csharp options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) context.Token = accessToken; // 仅对 Hub 路径生效,不影响普通接口 return Task.CompletedTask; } }; ``` ### 4.6 用户在线 / 离线判定机制 ★ **判定方式:进程内在线登记表(内存)** ```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); } ``` | 问题 | 答案 | |------|------| | 在哪判定? | `NotificationPusher.IsOnline(userId)` → `OnlineUserRegistry.IsOnline(userId)` | | 何时登记? | `NotificationHub.OnConnectedAsync`(SignalR 连接建立时) | | 何时移除? | `NotificationHub.OnDisconnectedAsync`(连接断开、超时、客户端关闭浏览器/窗口时) | | 为何用 `ConcurrentDictionary`? | Hub 回调在多个连接线程并发执行,必须线程安全;`byte` 作为占位值(仅需键) | | 为何不查数据库? | 在线是**连接级瞬时状态**,不适合落库;内存表读取为 O(1),无 IO | | 为何不用 `Clients.User` 是否报错来判断? | SignalR 对无连接用户是**静默成功**,无法据此区分在线/离线(见 4.4) | | 一个用户多端在线? | `Add` 幂等(同一 userId 只登记一次);`Clients.User` 会推给该用户**全部**连接,任一端断开不影响其它端(断开即 `Remove`,若仍有其它端在线则出现偏差——见下方限制) | | 多实例部署? | ❌ **不适用**:在线表是进程内内存,A 实例无法知道用户在 B 实例上在线。需引入 Redis + SignalR 背板(见 9.3) | **已如实说明的实现限制**:`Remove` 在任一连接断开时直接移除,若用户有两个连接(如两个浏览器窗口),关闭其中一个会导致在线表短暂误判为离线,直到其余连接的心跳/重连重新登记。业务影响可控(该场景下消息仍会落库,用户重新触发任意连接动作或重连即恢复)。若需严格的多连接计数,可将 `_users` 的值由 `byte` 改为连接数 `int`(`Add` 时 `+1`、`Remove` 时 `-1`,归零再移除)——这是后续可选的优化点。 ### 4.7 未读统计推送(`UnreadCountChanged`) - 触发时机:新消息推送成功后(在线用户) - 载荷:`{ unreadCount, todoCount }` - 实现:`NotificationPusher.PushUnreadCountAsync`,未读数由 `IRepository.CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)` 统计(此时接收人已落库,数值包含本条) - 前端:`web/src/utils/notification.js` 目前通过 `ReceiveNotification` 自增 `unreadCount`;若需**服务端权威值**,可直接订阅 `UnreadCountChanged`(事件已就绪) ### 4.8 消息读取与已读 - 位置:`F9WebAiTest.Application/Services/Notice/NotificationService.cs` - 接口(REST):我的消息分页查询(按 `readStatus` / `category` 筛选)、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync` - 实现要点:**查询以接收人表为主表**(每人一条状态),联表消息与类型名;已读时写 `ReadStatus = Read` 与 `ReadTime` ### 4.9 待办机制 - 待办类消息(`Category = Todo`)在创建消息、生成待办后,走同一套"确定接收人 → 推送"逻辑(通常用 `ForceReceivers` 精确送审批人) - `TodoItem` 状态:`Pending → Processing → Completed / Cancelled` - 审批流转复用同一条消息与待办记录,避免多级审批产生冗余消息 ### 4.10 业务变更事件(多端同步) - 位置:`F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs` - 机制:业务变更时发布 `BusinessChangedEvent`(实现 `INotificationHandler` 的处理器),经 SignalR 推送 `BusinessChanged` 事件 - 前端:`onBusinessChanged(handler)` 订阅,某端操作后其它端刷新已打开的页面 - 与消息推送共用同一个 Hub 连接与用户标识 ### 4.11 前端模块 - 位置:`web/src/utils/notification.js`;页面:`web/src/views/notification/{messages,subscribe,type}/` ```javascript // 建立连接(幂等):token 由 accessTokenFactory 提供,自动重连 connection = new signalR.HubConnectionBuilder() .withUrl('/hubs/notification', { accessTokenFactory: () => getAccessToken() || '' }) .withAutomaticReconnect() .build(); connection.on('ReceiveNotification', (msg) => { unreadCount.value++; /* 弹窗 + 页面刷新 */ }); connection.on('BusinessChanged', (e) => { /* 多端同步刷新 */ }); connection.start().catch(() => {}); ``` | 导出 | 作用 | |------|------| | `unreadCount` | 全局未读数(Pinia 之外用 `ref` 共享,供角标与消息页使用) | | `initNotification(onMessage)` | 建立连接并注册回调(幂等,重复调用复用同一连接) | | `loadUnreadCount()` | 拉取未读数(首次进入、标记已读后校准) | | `openMessage(msg, router)` | 打开消息:非待办且未读 → 标记已读 → 按 `routePath` 跳转业务页 | | `onBusinessChanged(handler)` | 订阅业务变更(多端同步) | | `stopNotification()` | 登出时停止连接并清空状态 | **技术要点**: - `accessTokenFactory`:浏览器 WebSocket 不能带自定义头,SignalR 客户端自动以 `access_token` 查询串传递,后端 `OnMessageReceived` 已适配。 - `withAutomaticReconnect()`:网络抖动/服务重启后自动重连,重连成功后 Hub 会再次登记在线,恢复推送能力。 - 消息跳转:`openMessage` 读取消息自带 `routePath` 直达业务页面(待办类不在此处标记已读,保持提醒直到真正处理完成)。 --- ## 5. 关键流程(时序) ### 5.1 在线用户:创建 → 推送 → 展示 ``` 业务代码(如请假审批 ApproveLeave) │ mediator.Send(new SendNotificationCommand(LeaveResult, ...)) ▼ CreateNotificationCommandHandler ├─ 校验类型 / 生成路由 ├─ 写 NotificationMessage (Status = Sent) ├─ 待办类:写 TodoItem ├─ 确定接收人(ForceReceivers 或 订阅广播) ├─ 写 NotificationReceiver(幂等) └─ NotificationPusher.PushNewMessageAsync(userId, dto) ├─ OnlineUserRegistry.IsOnline(userId) == true ├─ Clients.User(userId).SendAsync("ReceiveNotification", dto) ← 实时 └─ 回写 receiver:SendStatus=Success, SendTime=now └─ PushUnreadCountAsync → "UnreadCountChanged" { unreadCount, todoCount } ▼ 前端 notification.js ├─ unreadCount++ → 顶部角标更新 ├─ 弹窗提示(标题 + 内容) └─ 消息页/待办页订阅回调刷新 ▼ 用户点击消息 → openMessage() ├─ PUT /api/notification/messages/{id}/read └─ router.push(msg.routePath) → 直达业务页面 ``` ### 5.2 离线用户:落库 + 拉取兜底 ``` 业务创建消息 → 接收人含离线用户 U └─ IsOnline(U) == false ├─ 不推送,receiver.IsOffline = true、SendStatus = NotSent └─ 消息与接收人记录均已持久化 ✅ 用户 U 之后登录 / 打开页面 └─ GET /api/notification/unread-count → 未读数正确(含离线期间的消息) └─ GET /api/notification/messages → 消息列表完整可见 └─ SignalR 连接建立 → OnlineUserRegistry.Add(U) → 后续消息恢复实时推送 ``` > **重要语义**:实时推送只在"消息产生那一刻收件人在线"时触达。离线期间的消息**不会在用户上线时补弹窗**,而是通过未读数与消息列表体现(避免上线瞬间弹出一堆历史消息)。如需"上线补推",可在 Hub 的 `OnConnectedAsync` 中查询未读并推送一次通知——属于可扩展项。 ### 5.3 待办闭环 ``` 提交请假 → SendNotificationCommand(LeaveApproval, ForceReceivers:[审批人], AssigneeIds:[审批人]) → 消息 + TodoItem(Pending) + 推送给审批人 审批人处理 → POST /api/notification/todos/{id}/complete → TodoItem → Completed,写 HandleResult → 有下一级:再发待办给下一审批人 → 流程结束:SendNotificationCommand(LeaveResult, ForceReceivers:[申请人]) ``` --- ## 6. 接口设计 ### 6.1 REST 接口 **消息类型管理**(`/api/notification/type`,需登录) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/notification/type` | 类型列表 | | POST | `/api/notification/type` | 创建类型 | | PUT | `/api/notification/type/{id}` | 更新类型 | | DELETE | `/api/notification/type/{id}` | 删除类型 | **用户端**(`/api/notification`,需登录) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/notification/subscribe` | 可订阅类型列表(含订阅状态与可订阅判定) | | POST | `/api/notification/subscribe` | 订阅 / 退订(`{ notificationTypeId, subscribe }`) | | GET | `/api/notification/messages` | 我的消息(`pageIndex`/`pageSize`/`readStatus`/`category`) | | GET | `/api/notification/todos` | 我的待办(`status` 可选) | | POST | `/api/notification/todos/{id}/complete` | 完成待办(`{ handleResult }`) | | PUT | `/api/notification/messages/{id}/read` | 标记已读 | | PUT | `/api/notification/messages/read-all` | 全部已读 | | GET | `/api/notification/unread-count` | 未读数量(返回 `{ count }`) | | POST | `/api/notification/trigger` | 触发测试消息(演示/联调用) | | POST | `/api/notification/broadcast` | 发送广播(需 `notification:broadcast` 权限) | 统一响应包装:`ApiResult`(`Ok` / `Fail`),分页为 `PageResult`。 ### 6.2 SignalR 事件契约 端点:`/hubs/notification`;鉴权:JWT(query `access_token`)。 | 事件 | 方向 | 载荷 | 说明 | |------|------|------|------| | `ReceiveNotification` | 服务端 → 客户端 | `NotificationMessageDto` | 新消息实时推送 | | `UnreadCountChanged` | 服务端 → 客户端 | `{ unreadCount, todoCount }` | 未读统计变化 | | `BusinessChanged` | 服务端 → 客户端 | `{ businessType, businessId, action }` | 业务数据变更(多端同步) | `NotificationMessageDto` 主要字段:`Id`、`NotificationTypeId`、`MessageTypeCode`、`TypeName`、`Title`、`Content`、`Priority`、`BusinessType`、`BusinessId`、`Category`、`RoutePath`、`CreatedTime`、`ReadStatus`。 ### 6.3 业务侧发送消息的两种命令 ```csharp // ① 简洁命令(推荐):枚举名即消息类型 Code await mediator.Send(new SendNotificationCommand( NotificationTypeEnum.LeaveApproval, BusinessId: leaveId.ToString(), Title: "请假审批", Content: "张三提交了请假申请", Priority: NotificationPriority.Important, ForceReceivers: new List { approverId }, // 精确送达;留空 = 按订阅广播 AssigneeIds: new List { approverId })); // 生成待办 // ② 底层命令:按字符串 Code,完全控制 await mediator.Send(new CreateNotificationCommand { NotificationTypeCode = "TeyeFound", BusinessType = "Xdjy", BusinessId = lsjlId.ToString(), Title = "发现特野", Content = "…", ForceReceivers = new List() }); ``` --- ## 7. 可靠性设计 ### 7.1 保障措施 | 风险 | 措施 | 实现位置 | |------|------|---------| | 消息丢失 | **先持久化再推送**:消息与接收人记录先落库,推送失败仍可拉取 | `CreateNotificationCommandHandler` | | 重复投递 | `MessageId + UserId` 唯一约束 + 投递前判重 | 实体约束 + `DeliverAsync` | | 推送异常 | 单接收人 try/catch 隔离,仅回写 `Failed`,不影响业务主流程 | `DeliverAsync` | | 用户离线 | `IsOffline` 标记 + 上线后 REST 拉取兜底 | `NotificationPusher` + 前端 | | 连接断开 | SignalR `withAutomaticReconnect()` 自动重连,重连后重新登记在线 | 前端 + Hub | | 订阅越权 | 订阅时校验消息类型的 `RequiredPermission` | `NotificationService.SubscribeAsync` | | 业务被消息拖累 | 消息发送失败不回滚业务(业务侧可自行选择 try/catch) | 业务调用处 | ### 7.2 与 V1.1 的可靠性差异(如实说明) | 能力 | V1.1 | V2.0 | |------|------|------| | 失败自动重试 | ✅ `RetryCount++`,超上限标记 `Failed` | ❌ 无(无后台任务) | | 过期撤销 | ✅ 扫描时检查 `ExpireTime` → `Revoked` | ❌ 未实现(字段保留) | | 部分成功统计 | ✅ `PartialSuccess` | ⚠️ 逐接收人状态已记录,消息级汇总未启用 | | 投递延迟 | 0~3 秒 | **0(同步即时)** | ### 7.3 V2.0 新增的已知限制 1. **无自动重试**:推送依赖 SignalR 连接,瞬时故障只记录 `Failed`,不做补偿。补偿手段:业务侧重发、或后续增加"失败消息人工重发"接口。 2. **在线表是进程内内存**:多实例部署下在线判定不准(见 4.6 / 9.3)。 3. **同步推送增加请求耗时**:推送为本地内存 + Socket 写入,开销在毫秒级;接收人很多(如全员广播)时建议改为后台队列/批量推送。 4. **推送不保证送达**:`Success` 仅代表"已写入该用户的在线连接",不代表客户端已渲染(浏览器后台标签页可能延迟处理)。 --- ## 8. 技术选型说明(技术 → 用途 → 原因) | 技术 | 用途 | 选型理由 | |------|------|---------| | **SignalR** | 实时推送通道 | 免手写 WebSocket 协议;自动协商传输(WebSocket → SSE → Long Polling)、自动重连、按用户/组定向推送、与 ASP.NET Core 鉴权无缝集成 | | **JWT** | 统一鉴权 | REST 与 Hub 共用一套 token;用户标识 `"id"` 声明贯穿两端,使 `Clients.User` 可精准命中 | | **MediatR** | 命令/查询分发 | 业务侧只需 `Send` 一条命令即可发消息;投递逻辑集中在命令处理器,便于统一维护 | | **FreeSql** | ORM / CodeFirst | 支持多数据库切换(SQLite/MySQL/PG)、CodeFirst 建表、`IRepository` 泛型仓储简化数据访问 | | **SQLite** | 数据库 | 单文件、零部署,适合当前规模;切换数据库只需改连接串与 `DataType` | | **ConcurrentDictionary** | 在线用户登记 | 读多写少、O(1) 判定、线程安全,Hub 并发回调下无锁竞争 | | **抽象接口(依赖倒置)** | 跨层推送能力 | 接口在 Application、实现在 Api,避免应用层反向依赖表示层 | | **Vue3 + @microsoft/signalr** | 前端实时接入 | 官方 JS 客户端;`accessTokenFactory` 自动处理 WebSocket token;组合式 API 便于共享 `unreadCount` | | **NSwag** | 接口文档 | 自动生成 OpenAPI + Swagger UI,便于联调与生成 TS 客户端 | --- ## 9. 扩展指南 ### 9.1 新增一种消息类型 1. `NotificationEnums.cs` 的 `NotificationTypeEnum` 增加枚举值(枚举名 = 类型 `Code`) 2. 配置消息类型(管理端「消息类型管理」页面,或 `DbSeeder` 种子):名称、分类、`RouteTemplate`(含 `{businessId}` 占位符)、订阅权限码(可选) 3. 业务侧一行发送: ```csharp await mediator.Send(new SendNotificationCommand( NotificationTypeEnum.YourNewType, BusinessId: bizId.ToString(), Title: "…", Content: "…", ForceReceivers: receivers)); // 留空 = 按订阅广播 ``` **无需改动投递与推送代码**(投递链路对类型无感知)。 ### 9.2 新增推送渠道(短信 / 企业微信 / 邮件) 实现 `INotificationPusher` 的多渠道组合(装饰器或策略): ```csharp public class CompositePusher : INotificationPusher { // 站内(SignalR)为主渠道;离线或紧急消息可扩展短信/企微 } ``` 或新增独立接口(如 `ISmsSender`)在 `CreateNotificationCommandHandler` 中并行调用——**投递编排集中在命令处理器**,扩展点清晰。 ### 9.3 多实例部署改造(重要) 当前 `OnlineUserRegistry` 为进程内内存表,多实例下在线判定失效。改造步骤: 1. 引入 **Redis** 与 **SignalR Redis 背板**(`Microsoft.AspNetCore.SignalR.StackExchangeRedis`): ```csharp builder.Services.AddSignalR().AddStackExchangeRedis("redis-connection"); ``` 背板使 `Clients.User(...)` 可跨实例路由到用户所在实例。 2. 在线表改用 Redis(`SET notice:online:{userId}` + 过期时间),或直接依赖背板的 `All` 通道做推送,不再自行判定在线(把 `IsOnline` 改为"始终返回 true,交由背板路由")。 3. 消息/接收人表迁移到 PostgreSQL 或 MySQL(SQLite 不适合多实例并发写)。 ### 9.4 恢复"后台投递 + 重试"能力 若业务量增长需要异步化:保留 `CreateNotificationCommandHandler` 的建消息部分,把 `DeliverAsync` 投递部分改为写入队列(本地 Channel / MQ),由消费者执行投递与重试——**接口 `INotificationPusher` 与 Hub 无需改动**。 --- ## 10. 附录 ### 10.1 关键文件索引 | 功能 | 文件 | |------|------| | 枚举定义 | `F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs` | | 实体(5 个) | `F9WebAiTest.Domain/Entities/Notice/{NotificationType, NotificationMessage, NotificationReceiver, TodoItem, UserNotificationSubscription}.cs` | | 消息创建命令 + 投递实现 ★ | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs` | | 推送抽象(接口) | `F9WebAiTest.Application/Services/Notice/INotificationPusher.cs` | | 推送实现 | `F9WebAiTest.Api/Services/NotificationPusher.cs` | | Hub + 在线表 + 用户标识 | `F9WebAiTest.Api/Hubs/NotificationHub.cs` | | 订阅/消息/待办服务 | `F9WebAiTest.Application/Services/Notice/NotificationService.cs` | | REST 控制器 | `F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs` | | 业务变更事件 | `F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs` | | 数据种子 | `F9WebAiTest.Infrastructure/Data/DbSeeder.cs` | | 启动装配(SignalR/Hub/推送器/JWT query) | `F9WebAiTest.Api/Program.cs` | | 前端实时客户端 | `web/src/utils/notification.js` | | 前端页面 | `web/src/views/notification/{messages, subscribe, type}/` | | JWT 声明(`id`) | `F9WebAiTest.Application/Services/Auth/JwtService.cs` | ### 10.2 端到端排查清单(联调时按此顺序) | 现象 | 检查点 | |------|--------| | 前端收不到任何推送 | ① 浏览器控制器 Network → WS 是否连上 `/hubs/notification`(101 Switching Protocols)② 连接 URL 是否带 `access_token` ③ 后端是否返回 401(token 无效) | | 消息创建了但没推送 | ① 消息 `ForceReceivers` 与订阅关系是否命中该用户 ② 该用户当时是否在线(`OnlineUserRegistry`)③ 接收人表是否有该用户记录、`SendStatus`/`IsOffline` 值 | | 在线却没收到 | ① JWT 的 `id` 声明是否存在(`NotificationUserIdProvider` 取不到会退化为 `ConnectionId`,导致 `Clients.User(用户ID)` 无法命中)② 推送时的 userId 与连接登记的 userId 是否一致 | | 偶发收不到 | 连接是否处于重连中(`withAutomaticReconnect` 期间 `OnDisconnectedAsync` 会移除在线登记) | ### 10.3 参考资料 - Microsoft ASP.NET Core SignalR 官方文档(Hubs / IUserIdProvider / 背板 / JWT 鉴权) - MediatR 官方文档 - FreeSql 官方文档(CodeFirst / 连接串配置) - IEEE 1016 软件设计描述标准 --- **文档结束**(V2.0,与 2026-09-15 代码实现一致)