# 曦寒开发框架 · 模块总览 共 67 页。索引见 https://framework.docs.xihanfun.com/llms.txt --- 来源:https://framework.docs.xihanfun.com/packages/ai-abstractions # XiHan.Framework.AI.Abstractions > 纯 AI 契约包:会话门面、多 provider 解析、可插拔配置源、智能体、技能、RAG、提示词库、护栏与管道横切开关的一组接口,零框架内部依赖。 - **NuGet**:`XiHan.Framework.AI.Abstractions` - **模块类**:—(无模块类,直接引用) - **所在层**:基础设施层 - **关键依赖**:仅第三方 **Microsoft.Extensions.AI.Abstractions**(`IChatClient` / `IEmbeddingGenerator`)+ **Microsoft.Agents.AI.Abstractions**(`AIAgent`);不引用任何 `XiHan.Framework.*` ## 概述 这个包只放 AI 相关的接口与契约,不含任何具体实现,也不依赖框架内部任何包。它把「一次/流式会话、按 provider 名选模型、provider 配置来源、智能体、命名技能、RAG 检索增强、提示词库、输入护栏、会话管道横切开关」抽成一组接口,让上层代码只依赖抽象。真正的实现由 `XiHan.Framework.AI` 提供;应用层(如把 provider 配置存进数据库、把提示词做成后台可维护的版本库)也能通过实现这些接口来替换默认行为。 设计上贯穿两条主线:其一是**薄封装**——`IXiHanAiService` / `IXiHanAgentFactory` 只在 `Microsoft.Extensions.AI` 与 Microsoft Agent Framework 之上加「按 provider 名选模型」这一层 XiHan 语义,会话、工具调用、结构化输出、`AIAgent` 全走原生类型不重造;其二是**可插拔 store**——`IAiProviderConfigStore` / `IAiPromptStore` 沿用「框架给抽象、应用给实现」的范式,默认走 Options 兜底,应用层可无缝换成 DB store。 ## 何时使用 - 你的库/模块需要引用 AI 会话、Agent 或 RAG 契约,但不想拉入具体实现与第三方 SDK - 你要提供自定义的 provider 配置源、提示词库或知识检索实现,替换框架默认项 - 想以 provider 名解耦模型调用,而不把某个厂商写死在业务里 - 不该用它的场景:只是想直接发起对话/构建 Agent——那应引用实现包 `XiHan.Framework.AI` 并 `[DependsOn(typeof(XiHanAIModule))]` ## 安装与启用 ```bash dotnet add package XiHan.Framework.AI.Abstractions ``` 无模块类。直接引用即可,无需 `[DependsOn]`。服务注册与默认实现由 `XiHan.Framework.AI` 的 `XiHanAIModule` 负责——通常业务只引用实现包即可间接得到本包的全部契约。 ## 核心能力 - **会话门面契约**:`IXiHanAiService` 定义一次对话 / 流式对话,薄封装 `Microsoft.Extensions.AI` 的 `IChatClient`,只加「多 provider 选择」这层语义;会话选项载体 `XiHanChatOptions` - **多 provider 解析**:`IAiChatClientResolver` / `IAiEmbeddingGeneratorResolver` 按 provider 名取已构建好的会话客户端 / 嵌入生成器,二者都带 `Invalidate` 以支持配置热切换 - **可插拔配置源**:`IAiProviderConfigStore` 抽象 provider 配置来源(默认读 Options,应用层可换 DB store),配置载体 `AiProviderOptions` / 根配置 `XiHanAiOptions`(配置节 `XiHan:AI`) - **智能体**:`IXiHanAgentFactory` 基于 Microsoft Agent Framework 从 provider 解析器构建原生 `AIAgent` - **技能**:`IAiSkill` / `IAiSkillRegistry` 把应用注册的命名能力暴露为对话工具(`AIFunction`)与 MCP tool - **RAG 契约**:`IChunkingStrategy`(切片)、`IKnowledgeIngestor`(摄取)、`IKnowledgeRetriever`(检索)、`IRagPromptAugmenter`(提示增强)及配套模型 `TextChunk` / `RetrievedChunk` / `KnowledgeIngestRequest` / `RetrievalFilter` / `ChunkingOptions` - **提示词库**:`IAiPromptStore` / `AiPromptTemplate` 抽象可版本化的提示模板来源 - **输入护栏**:`IAiGuardrail` 对入站消息做安全检查(敏感词/注入检测等),多护栏「全部放行」才通过,拦截即 fail-closed 拒绝;结果载体 `GuardrailResult`;配置 `AiGuardrailOptions`(配置节 `XiHan:AI:Guardrail`) - **管道横切开关**:`AiPipelineOptions`(配置节 `XiHan:AI:Pipeline`)统一开关护栏 / OpenTelemetry 遥测 / 响应分布式缓存,全部默认关,由部署方按需开启 ## 主要 API / 类型 ### 会话(Chat) | 类型 | 说明 | | --- | --- | | `IXiHanAiService` | AI 会话服务门面。`Task ChatAsync(IEnumerable messages, XiHanChatOptions? options = null, CancellationToken ct = default)`;`IAsyncEnumerable ChatStreamAsync(...)` | | `XiHanChatOptions` | 会话选项。`string? Provider`(null 用默认 provider);`ChatOptions? ChatOptions`(透传 M.E.AI 的工具/温度/模型覆盖) | ### Provider 解析与配置 | 类型 | 说明 | | --- | --- | | `IAiChatClientResolver` | `IChatClient Resolve(string? providerName = null)`;`void Invalidate(string? providerName = null)`(清缓存,实现配置热切换;空则清全部,否则清指定 provider 及默认槽) | | `IAiEmbeddingGeneratorResolver` | `IEmbeddingGenerator> Resolve(string? providerName = null)`;`void Invalidate(string? providerName = null)`。模型取 `AiProviderOptions.EmbeddingModel` | | `IAiProviderConfigStore` | provider 配置来源抽象。`Task GetAsync(string? providerName = null, CancellationToken ct = default)`(null 取默认 provider,无匹配返回 null 由调用方 fail-closed);`Task> GetAllAsync(...)` | | `AiProviderOptions` | 单个 provider 配置:`Provider` / `ApiKey` / `BaseUrl` / `Model` / `EmbeddingModel` / `MaxOutputTokens` / `Temperature` / `TimeoutSeconds` / `ExtraJson` | | `XiHanAiOptions` | 根配置(绑定配置节 `SectionName = "XiHan:AI"`):`string? DefaultProvider`;`IDictionary Providers`(键为 provider 名,大小写不敏感);`AiPipelineOptions Pipeline`(会话管道横切开关,非按 provider);`IList Prompts`(提示词模板 Options 兜底源) | ### 智能体(Agents) | 类型 | 说明 | | --- | --- | | `IXiHanAgentFactory` | `AIAgent Create(string? instructions = null, string? name = null, IList? tools = null, string? providerName = null)`。返回 MAF 原生 `AIAgent`,调用方直接用其 `RunAsync` / `RunStreamingAsync` / 多轮会话 API | ### 技能(Skills) | 类型 | 说明 | | --- | --- | | `IAiSkill` | 命名可复用 AI 能力。`string Name`(唯一,作工具名 / MCP tool 名);`string Description`;`AIFunction AsFunction()`(转为可供对话工具调用 / MCP 暴露的函数) | | `IAiSkillRegistry` | 技能注册表。`void Register(IAiSkill skill)`(同名覆盖);`IReadOnlyList All`;`IAiSkill? Find(string name)` | ### RAG(检索增强生成) | 类型 | 说明 | | --- | --- | | `IChunkingStrategy` | `IReadOnlyList Chunk(string text, ChunkingOptions options)` | | `ChunkingOptions` | `int MaxChunkSize`(默认 1000);`int Overlap`(相邻切片重叠字符数,默认 100) | | `IKnowledgeIngestor` | `Task IngestAsync(KnowledgeIngestRequest request, CancellationToken ct = default)`(返回切片数);`Task RemoveDocumentAsync(string documentId, int chunkCount, CancellationToken ct = default)`(按文档清理已入库向量) | | `KnowledgeIngestRequest` | 摄取请求:`DocumentId`(required)/ `Text`(required)/ `TenantId`(0=平台全局)/ `Title` / `Source` / `Provider`(嵌入 provider,null 用默认)/ `Chunking` | | `IKnowledgeRetriever` | `Task> RetrieveAsync(string query, int topK = 5, RetrievalFilter? filter = null, string? provider = null, CancellationToken ct = default)` | | `RetrievalFilter` | 向量库 pre-filter:`long TenantId`(平台为 0;检索恒限定在这一个租户内,不存在不限租户的检索);`string? DocumentId`(null 不限)。`filter` 为空即平台 | | `IRagPromptAugmenter` | `string Augment(string userPrompt, IReadOnlyList context)`(context 为空原样返回) | | `TextChunk` | 入库前切片:`DocumentId` / `Index` / `Text`(均 required)/ `TenantId` / `Title` / `Source` | | `RetrievedChunk` | 检索命中:`DocumentId` / `Index` / `Text`(均 required)/ `Title` / `Source` / `double? Score`(相似度,越大越相近;连接器量纲可能不同) | ### 提示词库(Prompts) | 类型 | 说明 | | --- | --- | | `IAiPromptStore` | `Task GetAsync(string name, string? version = null, CancellationToken ct = default)`(version 空取当前版本);`Task> ListAsync(...)` | | `AiPromptTemplate` | `Name`(唯一)/ `Content`(正文,可含占位/Scriban 变量)/ `Version`(空为当前)/ `Description` | ### 护栏(Guardrails) | 类型 | 说明 | | --- | --- | | `IAiGuardrail` | 输入护栏检查 seam。`string Name`(诊断用);`ValueTask InspectInputAsync(IEnumerable messages, CancellationToken ct = default)`。多护栏「全部放行」才通过,拦截即 fail-closed,不下发模型 | | `GuardrailResult` | 检查结果(私有构造,经静态工厂构建)。`bool IsBlocked`;`string? Reason`(放行时为 null);`static GuardrailResult Allow()`;`static GuardrailResult Block(string reason)` | | `AiGuardrailOptions` | 护栏配置(绑定配置节 `SectionName = "XiHan:AI:Guardrail"`):`IList BlockedKeywords`(敏感词黑名单,大小写不敏感子串匹配);`IList InjectionPatterns`(自定义注入检测正则);`bool UseBuiltInInjectionHeuristics`(是否启用内置提示注入启发式正则,默认 `true`);`string RefusalMessage`(拦截时的拒绝话术) | ### 管道横切开关(Pipeline) | 类型 | 说明 | | --- | --- | | `AiPipelineOptions` | 会话管道横切开关(绑定配置节 `XiHan:AI:Pipeline`,全部默认关):`bool EnableGuardrail`(管道最外层,fail-closed 拦截);`bool EnableTelemetry`(须另接 OTel `TracerProvider`/`MeterProvider` + 导出器方可收集);`bool EnableSensitiveTelemetry`(遥测是否记录 prompt/响应原文,默认关);`string TelemetrySourceName`(默认 `"XiHan.AI"`);`bool EnableResponseCache`(响应分布式缓存,慎用于创造性/高温调用) | ## 配置 配置载体在本包,但绑定与实现由 `XiHan.Framework.AI` 完成。三个配置节均定义在本包:根配置节 `XiHan:AI`(`XiHanAiOptions.SectionName`)、护栏配置节 `XiHan:AI:Guardrail`(`AiGuardrailOptions.SectionName`);管道开关 `AiPipelineOptions` 作为 `XiHanAiOptions.Pipeline` 属性随根配置节一并绑定(对应 `XiHan:AI:Pipeline`)。字段见上表。示例见 [XiHan.Framework.AI](./ai) 的「配置」小节。 ## 使用示例 以下示例只依赖本包的契约类型(实现由 `XiHan.Framework.AI` 注入)。 **注入门面发起一次对话:** ```csharp public sealed class MyChatUseCase(IXiHanAiService ai) { public async Task AskAsync(string question, CancellationToken ct) { var messages = new[] { new ChatMessage(ChatRole.User, question) }; // 指定 provider(null 用默认 provider) var options = new XiHanChatOptions { Provider = "DeepSeek" }; var response = await ai.ChatAsync(messages, options, ct); return response.Text; } } ``` **从解析器直接拿 `IChatClient`(需要用 M.E.AI 原生管道时):** ```csharp public sealed class MyLowLevel(IAiChatClientResolver resolver) { public IChatClient Client => resolver.Resolve(providerName: null); // 默认 provider } ``` **注册一个自定义技能(应用层):** ```csharp public sealed class SumSkill : IAiSkill { public string Name => "sum_numbers"; public string Description => "计算两个整数之和"; public AIFunction AsFunction() => AIFunctionFactory.Create((int a, int b) => a + b, Name, Description); } // DI 注册后 DefaultAiSkillRegistry 构造时自动收纳 services.AddSingleton(); ``` ## 扩展点 / 自定义 本包全是契约,扩展方式是「实现接口并在 DI 覆盖实现包的默认项」(实现包一律以 `TryAdd*` 注册): - **换 provider 配置源**:实现 `IAiProviderConfigStore`(如读数据库的 store),`TryAddSingleton` 之前 `AddSingleton` 覆盖,即可把 provider 配置从 appsettings 迁到 DB / 前端可维护;改配置后调 `IAiChatClientResolver.Invalidate` / `IAiEmbeddingGeneratorResolver.Invalidate` 热切换。 - **换提示词库**:实现 `IAiPromptStore`(store 化 + 版本)。 - **换 RAG 组件**:实现 `IChunkingStrategy` / `IKnowledgeIngestor` / `IKnowledgeRetriever` / `IRagPromptAugmenter` 任一,覆盖框架默认实现。 - **供给技能**:`AddSingleton()`,注册表构造时自动收纳,随后暴露为对话工具与 MCP tool。 - **叠加自定义护栏**:`AddSingleton()` 追加检查逻辑(如接入第三方内容安全服务),与内置护栏一起「全部放行」才通过;需先在 `AiPipelineOptions.EnableGuardrail` 打开管道开关方可生效。 ## 注意事项与最佳实践 - **fail-closed 语义**:`IAiProviderConfigStore.GetAsync` 无匹配返回 `null`,调用方(如解析器)应据此抛出明确异常而非静默降级——这是框架约定。 - **薄封装、勿重造**:会话/Agent 契约刻意贴近 `Microsoft.Extensions.AI` 与 MAF 原生类型(`ChatMessage` / `ChatResponse` / `AIAgent` / `AITool`),业务不必再包一层 DTO。 - **嵌入模型是可选项**:`AiProviderOptions.EmbeddingModel` 为空表示该 provider 不支持嵌入;RAG 摄取/检索前须确保所选 provider 配了嵌入模型。 - **RAG 向量库不属本抽象**:这些契约只描述「切片/摄取/检索/增强」的行为,**不涉及**具体向量数据库(Qdrant 等)——向量存储是实现包 + 应用层部署事项,见 [XiHan.Framework.AI](./ai)。 - **管道开关全部默认关**:`AiPipelineOptions` 的护栏/遥测/缓存三项均默认 `false`——护栏是有意开启的安全策略,缓存对高温创造性调用有重放同答的语义风险,遥测在未接 OTel 导出器前是静默空操作;是否开启由部署方按需在配置中打开。 ## 依赖模块 - 无框架内部依赖(不引用任何 `XiHan.Framework.*`) - 第三方核心:**Microsoft.Extensions.AI.Abstractions**(`IChatClient` / `IEmbeddingGenerator` / `ChatMessage` / `AIFunction` / `AITool`)、**Microsoft.Agents.AI.Abstractions**(`AIAgent`) ## 相关模块 - [XiHan.Framework.AI](./ai)(这些抽象的默认实现) - [XiHan.Framework.Http](./http)(实现包的传输依赖) --- 来源:https://framework.docs.xihanfun.com/packages/ai # XiHan.Framework.AI > AI 抽象包的默认实现:基于 Microsoft.Extensions.AI + Microsoft Agent Framework 的多 provider 会话、嵌入、智能体、RAG 与 MCP 工具桥接。 - **NuGet**:`XiHan.Framework.AI` - **模块类**:`XiHanAIModule`(`[DependsOn(typeof(XiHanHttpModule))]`) - **所在层**:基础设施层 - **关键依赖**:**Microsoft.Extensions.AI** / **Microsoft.Extensions.AI.OpenAI**(会话与嵌入,10.9.0)、**Microsoft.Agents.AI**(Agent,1.17.0)、**ModelContextProtocol**(MCP,2.2.0)、**Microsoft.Extensions.VectorData.Abstractions**(向量数据契约,10.9.0);框架内部依赖 AI.Abstractions / Core / Http ## 概述 这个包是 AI 抽象(`XiHan.Framework.AI.Abstractions`)的默认实现,基于 `Microsoft.Extensions.AI` 与 Microsoft Agent Framework(MAF)提供开箱即用的会话、嵌入、智能体与 RAG 能力。核心设计有四点: 1. **一个 OpenAI 兼容适配器打天下**:多数 provider(OpenAI / DeepSeek / Azure / Ollama / vLLM / 自训模型)都走 OpenAI 兼容协议,设 `BaseUrl` 指向端点 + `Model` + `ApiKey` 即可,无须每家写一个适配器。 2. **多 provider 解析 + 配置热切换**:解析器按 provider 名构建并缓存 `IChatClient` / 嵌入生成器,配置源改动后调 `Invalidate` 使缓存失效即完成热切换。 3. **可插拔配置源**:默认读 `XiHan:AI` 配置节兜底,应用层可 `AddSingleton` 覆盖为 DB store(store 化 + 多租户 + 前端可维护),对上层透明。 4. **横切管道可选叠加**:内容护栏 / OpenTelemetry 遥测 / 响应分布式缓存三件套挂在 `ChatClientBuilder` 管道上,由 `XiHan:AI:Pipeline` 开关逐项启用,默认全关,不改变既有调用方式。 此外,它能把应用侧注册的「技能」通过 MCP(Model Context Protocol)暴露给外部 AI 客户端(如 Claude Code / Cursor),并提供 RAG 的切片/摄取/检索/提示增强默认实现,以及一套可插拔的提示词库(`IAiPromptStore`)。 ## 何时使用 - 需要以统一门面调用大模型(一次对话 / 流式对话),并按 provider 名切换厂商 - 需要基于 provider 的嵌入生成 + RAG 检索增强(切片 / 摄取 / 检索 / 提示增强) - 想构建带工具调用与多轮记忆的智能代理(`AIAgent`) - 想把应用侧「技能」暴露为 MCP tools,供外部 AI 客户端调用 ## 安装与启用 ```bash dotnet add package XiHan.Framework.AI ``` ```csharp [DependsOn(typeof(XiHanAIModule))] public class MyModule : XiHanModule { } ``` `XiHanAIModule.ConfigureServices` 里调用 `AddXiHanAI()` 与 `AddXiHanRAG()`,注册结果(全部 `TryAdd*`,应用层可覆盖任一默认项): - **`AddXiHanAI()`**:绑定 `XiHanAiOptions`(配置节 `XiHan:AI`)与 `AiGuardrailOptions`(配置节 `XiHan:AI:Guardrail`);`IAiProviderConfigStore → OptionsAiProviderConfigStore`;默认护栏 `IAiGuardrail → KeywordBlocklistGuardrail`(`TryAddEnumerable`,应用层可再 `AddSingleton()` 追加);`OpenAiCompatibleChatClientFactory` + `IAiChatClientResolver → AiChatClientResolver`;`OpenAiEmbeddingGeneratorFactory` + `IAiEmbeddingGeneratorResolver → AiEmbeddingGeneratorResolver`;`IXiHanAiService → XiHanAiService`;`IAiPromptStore → OptionsAiPromptStore`;`IAiSkillRegistry → DefaultAiSkillRegistry`;`IXiHanAgentFactory → XiHanAgentFactory`。 - **`AddXiHanRAG()`**:`IChunkingStrategy → FixedWindowChunkingStrategy`;`IKnowledgeIngestor → DefaultKnowledgeIngestor`;`IKnowledgeRetriever → DefaultKnowledgeRetriever`;`IRagPromptAugmenter → DefaultRagPromptAugmenter`。 MCP 工具桥接 `AddXiHanMcpServerTools()` 不在模块内自动调用,须配合官方 `AddMcpServer()` 使用;HTTP 传输与端点暴露由 [XiHan.Framework.Web.Mcp](./web-mcp) 负责(见「MCP 工具桥接」)。 ## 工作原理 **会话链路**:`XiHanAiService.ChatAsync` → `IAiChatClientResolver.Resolve(provider)` 取 `IChatClient` → 透传 `GetResponseAsync` / `GetStreamingResponseAsync`。门面只做「选 provider + 透传」,工具调用、结构化输出全走 M.E.AI 原生;解析到的 `IChatClient` 本身是否带护栏/遥测/缓存,取决于 `OpenAiCompatibleChatClientFactory` 按 `Pipeline` 开关构建时是否叠加了对应中间件(见下文「会话管道」)。 **解析与缓存**:`AiChatClientResolver` 用 `ConcurrentDictionary` 按 provider 名(缺省用内部默认槽键)缓存 `IChatClient`。首次解析时从 `IAiProviderConfigStore.GetAsync` 读配置(无匹配则抛 `InvalidOperationException`),交 `OpenAiCompatibleChatClientFactory` 构建。`OpenAiEmbeddingGeneratorFactory` / `AiEmbeddingGeneratorResolver` 与之同构(模型取 `EmbeddingModel`)。 **热切换**:`Invalidate(providerName)` 从缓存移除并释放对应客户端——空则清全部,否则清指定 provider 及默认槽(默认可能指向它);下次 `Resolve` 按最新配置重建。这是应用层改了 DB 里 provider 的 key/baseUrl/model 后无重启生效的关键。 **工厂细节**:`OpenAiCompatibleChatClientFactory.Create` 用 `OpenAI.Chat.ChatClient`(`BaseUrl` 空则用官方端点,否则指向兼容端点),套 `.AsIChatClient().AsBuilder()` 按 `XiHan:AI:Pipeline` 开关逐项叠加中间件后 `.Build()`。`ApiKey` 为空时用占位符 `"no-key"`(本地/兼容端点常不校验 key)。 **会话管道(护栏 / 遥测 / 缓存)**:`AiPipelineOptions`(`XiHanAiOptions.Pipeline`,全局非按 provider)控制三个可选中间件,注册顺序=外→内(先见原始输入者最外层),**全部默认关**: 1. `EnableGuardrail`(且已注册至少一个 `IAiGuardrail`)→ `builder.UseContentGuardrail(...)` 挂 `GuardrailChatClient`,管道最外层,逐护栏跑 `InspectInputAsync`,任一拦截或护栏自身抛异常都直接回拒绝话术、不下发内层(fail-closed);仅检查 `ChatRole.User` 消息,流式/非流式行为一致,v1 只做输入侧防护,不做输出侧脱敏。 2. `EnableTelemetry` → `builder.UseOpenTelemetry(sourceName: Pipeline.TelemetrySourceName, ...)`,`EnableSensitiveTelemetry` 控制是否记录 prompt/响应原文;未接 `TracerProvider`/`MeterProvider` 及导出器时为静默空操作。 3. `EnableResponseCache`(且 DI 中有 `IDistributedCache`)→ `builder.UseDistributedCache(cache)`,置于工具调用之外(命中即跳过工具),相同请求会重放同答,慎用于高温创造性调用。 最内层始终是 `.UseFunctionInvocation()`,因此工具调用会自动执行、无人工批准。默认护栏实现 `KeywordBlocklistGuardrail` 是敏感词子串匹配 + 中英提示注入启发式正则(`AiGuardrailOptions.BlockedKeywords` / `InjectionPatterns` / `UseBuiltInInjectionHeuristics`),定位「第一道防线」,将来可挂真检测服务(如内容安全 API)而不动管道。 **RAG 链路**:摄取 = 切片 → 批量 embedding → upsert 向量库;检索 = query embedding → 向量检索 → 映射片段。二者都依赖 `Microsoft.Extensions.VectorData` 的 `VectorStore`,**该 `VectorStore` 由应用层注册**(框架不注册)。 **提示词库**:`OptionsAiPromptStore` 实现 `IAiPromptStore`,读 `XiHanAiOptions.Prompts`(`IList`,appsettings 兜底),按 `Name` + 可选 `Version` 查询;应用层可用 SysAiPrompt 等 DB 实现 `TryAddSingleton` 覆盖(store 化 + 后台维护 + 版本管理),对上层透明。 ## 核心能力 - **会话门面实现**:`XiHanAiService` 实现 `IXiHanAiService`,选 provider 后透传 M.E.AI,工具/结构化输出/中间件走原生 - **多 provider 解析 + 热切换**:`AiChatClientResolver` / `AiEmbeddingGeneratorResolver` 按 provider 名构建并缓存,`Invalidate` 失效重建 - **OpenAI 兼容适配**:`OpenAiCompatibleChatClientFactory` / `OpenAiEmbeddingGeneratorFactory` 一套工厂覆盖 OpenAI / DeepSeek / Azure / Ollama / vLLM 等端点 - **可插拔配置源**:默认 `OptionsAiProviderConfigStore` 读 `XiHan:AI` 兜底;应用层可换 DB store - **内容护栏**:默认 `KeywordBlocklistGuardrail`(敏感词 + 提示注入启发式),经 `GuardrailChatClient`(`DelegatingChatClient`)挂管道最外层,fail-closed;支持 `AddSingleton()` 叠加多护栏(全部放行才通过);由 `XiHan:AI:Pipeline.EnableGuardrail` 开关,默认关 - **遥测 / 响应缓存管道**:`XiHan:AI:Pipeline` 另两个可选开关——`EnableTelemetry`(OpenTelemetry)、`EnableResponseCache`(`IDistributedCache`),均默认关 - **提示词库**:`OptionsAiPromptStore` 读 `XiHanAiOptions.Prompts` 兜底,按名 + 版本查询;应用层可换 DB store(SysAiPrompt 等) - **智能体**:`XiHanAgentFactory` 从解析器取 `IChatClient`,经 `chatClient.AsAIAgent(...)` 产出 MAF 原生 `AIAgent`,支持工具与多轮会话 - **技能注册表**:`DefaultAiSkillRegistry` 构造时自动收纳 DI 里全部 `IAiSkill`,也支持运行时 `Register` - **RAG 默认实现**:`FixedWindowChunkingStrategy`(固定窗口 + 重叠切片)、`DefaultKnowledgeIngestor`、`DefaultKnowledgeRetriever`、`DefaultRagPromptAugmenter` - **MCP 工具桥接**:`AddXiHanMcpServerTools` 把技能注册表投影为 MCP server tools ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanAiService` | `IXiHanAiService` 默认实现(选 provider → 透传 M.E.AI 的 `GetResponseAsync` / `GetStreamingResponseAsync`) | | `AiChatClientResolver` | `IAiChatClientResolver` 实现。`ConcurrentDictionary` 缓存 `IChatClient`,`IDisposable` 释放;`Invalidate` 热切换 | | `AiEmbeddingGeneratorResolver` | `IAiEmbeddingGeneratorResolver` 实现(与会话解析器同构,模型取 `EmbeddingModel`) | | `OpenAiCompatibleChatClientFactory` | 由 `AiProviderOptions` 构建 `IChatClient`,按 `Pipeline` 开关叠加护栏/遥测/缓存,最内层套 `UseFunctionInvocation`;`Model` 空则抛异常 | | `OpenAiEmbeddingGeneratorFactory` | 由 `AiProviderOptions` 构建 `IEmbeddingGenerator>`;`EmbeddingModel` 空则抛异常 | | `OptionsAiProviderConfigStore` | 默认 provider 配置源,读 `IOptionsMonitor`(appsettings 兜底),键即 provider 名回填 | | `GuardrailChatClient` | `DelegatingChatClient`,管道最外层;逐护栏跑 `InspectInputAsync`,任一拦截或护栏抛异常即 fail-closed 回拒绝话术(流式/非流式一致) | | `KeywordBlocklistGuardrail` | 默认 `IAiGuardrail`:敏感词子串黑名单 + 中英提示注入启发式正则(内置 + 可配置扩展),只检 `ChatRole.User` 消息 | | `GuardrailChatClientBuilderExtensions.UseContentGuardrail` | `ChatClientBuilder` 扩展方法,把 `GuardrailChatClient` 挂入管道(应置最外层,先于遥测/缓存/工具调用) | | `OptionsAiPromptStore` | 默认 `IAiPromptStore` 实现,读 `IOptionsMonitor.Prompts`(appsettings 兜底),按 `Name` + 可选 `Version` 查询 | | `XiHanAgentFactory` | `IXiHanAgentFactory` 实现(解析器 → `IChatClient.AsAIAgent`) | | `DefaultAiSkillRegistry` | `IAiSkillRegistry` 实现,构造时收纳 DI 全部 `IAiSkill`,线程安全、同名覆盖 | | `SkillMcpToolsConfigurator` | `IConfigureOptions`,把技能 `AsFunction()` 经 `McpServerTool.Create` 并入 MCP 工具集;工具名冲突时抛 `InvalidOperationException` | | `FixedWindowChunkingStrategy` | 固定窗口 + 重叠切片;`MaxChunkSize` / `Overlap`,换行归一后按步长切分 | | `DefaultKnowledgeIngestor` | 切片 → 批量 embedding → upsert;`RemoveDocumentAsync` 按文档删向量。依赖注入的 `VectorStore` | | `DefaultKnowledgeRetriever` | query embedding → `VectorStore` 检索 → 映射 `RetrievedChunk`;`RetrievalFilter` 转 pre-filter 表达式 | | `DefaultRagPromptAugmenter` | 简单模板增强(约束 + 编号片段 + 问题);不走 Scriban,直接插值 | | `VectorStoreKnowledgeRecord` | 向量库记录模型(`Microsoft.Extensions.VectorData` 特性),见下文 | > 注册表与投影对重名的处理不同:`DefaultAiSkillRegistry` 按名索引、**同名覆盖**(后注册的胜出,这是注册表语义);而 `SkillMcpToolsConfigurator` 投影到 MCP 工具集时**撞名即失败**:工具集里同名只能留一个,静默丢弃会让技能凭空消失,且留下哪个不确定。 ### DI 入口扩展方法 | 扩展方法 | 作用 | | --- | --- | | `IServiceCollection.AddXiHanAI()` | 注册会话/嵌入/护栏/提示词库/Agent/技能全套(模块自动调用) | | `IServiceCollection.AddXiHanRAG()` | 注册 RAG 切片/摄取/检索/增强默认实现(模块自动调用) | | `IServiceCollection.AddXiHanMcpServerTools()` | 把技能注册表投影为 MCP server tools(须配合官方 `AddMcpServer()`;Web.Mcp 模块已代为调用) | ### VectorStoreKnowledgeRecord 约定 `DefaultKnowledgeIngestor` / `DefaultKnowledgeRetriever` 用固定的记录模型: - 集合名常量 `CollectionName = "default_knowledge"` - 主键 `Guid Id`,由 `MakeId(documentId, index)` 用 `MD5(documentId:index)` 确定性派生(同文档同序号恒等,重建即覆盖)。用 `Guid` 是因为 Qdrant 只支持 `Guid`/`ulong` 键,`Guid` 兼容各连接器 - 过滤字段 `DocumentId` / `TenantId` 标 `IsIndexed = true` 以支持 pre-filter - 向量维度编译期常量 `EmbeddingDimensions = 1536`(对齐 `text-embedding-3-small`);换维度的嵌入模型须改此常量并重建集合。距离函数 `CosineSimilarity`,索引 `Hnsw` ## 配置 配置节 `XiHan:AI`(`XiHanAiOptions.SectionName`)。字段: | 字段 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | | `DefaultProvider` | `string?` | null | 未显式指定 provider 时用的 provider 名 | | `Providers` | `IDictionary` | 空(键大小写不敏感) | 各 provider 配置,键为 provider 名 | | `Pipeline` | `AiPipelineOptions` | 见下表(全部默认关) | 会话管道横切开关(护栏/遥测/缓存;全局,非按 provider) | | `Prompts` | `IList` | 空 | 提示词模板(Options 兜底源;store 化由应用层 `IAiPromptStore` 覆盖) | 单个 `AiProviderOptions`: | 字段 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | | `Provider` | `string` | 空 | provider 名(键未显式填时由配置源回填) | | `ApiKey` | `string?` | null | API 密钥(空时工厂用占位符 `no-key`) | | `BaseUrl` | `string?` | null | 端点基址(空用 OpenAI 官方端点,否则指向兼容端点) | | `Model` | `string` | 空 | 会话模型名(空则会话工厂抛异常) | | `EmbeddingModel` | `string?` | null | 嵌入模型名(RAG 用;空则该 provider 不支持嵌入) | | `MaxOutputTokens` | `int?` | null | 最大输出 token | | `Temperature` | `float?` | null | 采样温度 | | `TimeoutSeconds` | `int?` | null | 请求超时秒 | | `ExtraJson` | `string?` | null | 扩展参数 JSON(新 provider 专属参数) | `AiPipelineOptions`(`XiHanAiOptions.Pipeline`;三者全部默认关闭,需部署方按需在 appsettings 打开): | 字段 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | | `EnableGuardrail` | `bool` | false | 是否启用内容护栏(管道最外层,fail-closed 拦截) | | `EnableTelemetry` | `bool` | false | 是否启用 OpenTelemetry 遥测(须另接 `TracerProvider`/`MeterProvider` + 导出器方可收集) | | `EnableSensitiveTelemetry` | `bool` | false | 遥测是否记录敏感数据(prompt/响应原文) | | `TelemetrySourceName` | `string` | `"XiHan.AI"` | 遥测 ActivitySource/Meter 名 | | `EnableResponseCache` | `bool` | false | 是否启用响应分布式缓存(相同请求命中同答,慎用于创造性/高温调用) | `AiPromptTemplate`(`XiHanAiOptions.Prompts` 元素): | 字段 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | | `Name` | `string` | 空 | 模板名(唯一标识) | | `Content` | `string` | 空 | 模板正文(可含占位/Scriban 变量,由渲染器填充) | | `Version` | `string?` | null | 版本(空为当前/最新) | | `Description` | `string?` | null | 说明 | 配置节 `XiHan:AI:Guardrail`(`AiGuardrailOptions.SectionName`): | 字段 | 类型 | 默认 | 含义 | | --- | --- | --- | --- | | `BlockedKeywords` | `IList` | 空 | 敏感词黑名单(大小写不敏感子串匹配,命中即拦截) | | `InjectionPatterns` | `IList` | 空 | 自定义注入检测正则(大小写不敏感,命中即拦截) | | `UseBuiltInInjectionHeuristics` | `bool` | true | 是否启用内置提示注入启发式正则(中英) | | `RefusalMessage` | `string` | `"抱歉,你的请求包含不被允许的内容,已被安全策略拦截。"` | 拦截时返回的拒绝话术 | 示例 appsettings.json: ```json { "XiHan": { "AI": { "DefaultProvider": "DeepSeek", "Providers": { "DeepSeek": { "ApiKey": "sk-xxx", "BaseUrl": "https://api.deepseek.com", "Model": "deepseek-chat", "EmbeddingModel": "text-embedding-3-small" }, "Ollama": { "BaseUrl": "http://localhost:11434/v1", "Model": "qwen2.5" } }, "Pipeline": { "EnableGuardrail": true, "EnableTelemetry": false, "EnableResponseCache": false }, "Guardrail": { "BlockedKeywords": ["敏感词示例"], "UseBuiltInInjectionHeuristics": true } } } } ``` ## 使用示例 **解析聊天客户端并发起对话(走门面):** ```csharp public sealed class ChatSample(IXiHanAiService ai) { public async Task AskAsync(string question, CancellationToken ct) { var messages = new[] { new ChatMessage(ChatRole.System, "你是简洁的助理。"), new ChatMessage(ChatRole.User, question), }; var options = new XiHanChatOptions { Provider = "DeepSeek" }; // null 用默认 provider var response = await ai.ChatAsync(messages, options, ct); return response.Text; } } ``` **流式对话:** ```csharp await foreach (var update in ai.ChatStreamAsync(messages, options, ct)) { Console.Write(update.Text); } ``` **注册 / 切换 provider(应用层用 DB store 覆盖 + 热切换):** ```csharp // 1) 覆盖默认配置源(在 AddXiHanAI 之后,TryAdd 不会顶掉先注册的显式实现) services.AddSingleton(); // 2) 运维改了某 provider 的 key/baseUrl/model 后,使缓存失效即热切换 public sealed class ProviderAdmin( IAiChatClientResolver chatResolver, IAiEmbeddingGeneratorResolver embedResolver) { public void OnProviderUpdated(string providerName) { chatResolver.Invalidate(providerName); embedResolver.Invalidate(providerName); } } ``` **构建智能体并运行(MAF `AIAgent`):** ```csharp public sealed class AgentSample(IXiHanAgentFactory factory, IAiSkillRegistry skills) { public async Task RunAsync(string task, CancellationToken ct) { var tools = skills.All.Select(s => (AITool)s.AsFunction()).ToList(); var agent = factory.Create( instructions: "你是 XiHan 项目助理。", name: "xihan-agent", tools: tools, providerName: null); var result = await agent.RunAsync(task, cancellationToken: ct); return result.Text; } } ``` ## 扩展点 / 自定义 所有默认实现均以 `TryAdd*` 注册,`AddSingleton` 覆盖任一即可(在 `AddXiHanAI` 之后或让 DI 顺序保证显式实现先注册): - **provider 配置源**:`IAiProviderConfigStore` → DB store(把 provider 从 appsettings 迁到数据库 + 前端可维护 + 多租户);应用层负责在写入后调解析器 `Invalidate` - **提示词库**:`IAiPromptStore` → DB store(如 SysAiPrompt,store 化 + 后台维护 + 版本),`TryAddSingleton` 覆盖 `OptionsAiPromptStore` - **内容护栏**:`AddSingleton()` 追加(`TryAddEnumerable` 注册的默认 `KeywordBlocklistGuardrail` 不会被顶掉,多护栏全部放行才通过;也可接 Azure Content Safety 等真检测服务) - **切片策略**:`IChunkingStrategy` → 语义/句子切分等 - **摄取 / 检索**:`IKnowledgeIngestor` / `IKnowledgeRetriever` → 自定义向量库交互 - **提示增强**:`IRagPromptAugmenter` → 自定义模板 - **技能供给**:`AddSingleton()`,注册表构造时自动收纳 - **MCP 工具**:依赖 [XiHan.Framework.Web.Mcp](./web-mcp) 的 `XiHanWebMcpModule`,技能即成为 MCP tool(HTTP 传输、端点映射与 key 鉴权都在该包内) ## 注意事项与最佳实践 - **向量库(Qdrant)不由本框架包注册**:`DefaultKnowledgeIngestor` / `DefaultKnowledgeRetriever` 构造依赖 `Microsoft.Extensions.VectorData` 的 `VectorStore`,而 `AddXiHanRAG` **不注册**任何具体 `VectorStore`。向量连接器与嵌入模型选择属**应用层部署事项**——应用(如 BasicApp)负责 `AddQdrantVectorStore(...)` 等登记,本框架包本身不提供 Qdrant 能力,也不引用其连接器包。若未注册 `VectorStore` 而调用 RAG 摄取/检索,会在解析这两个服务时因缺依赖失败。 - **嵌入维度硬编码**:`VectorStoreKnowledgeRecord.EmbeddingDimensions = 1536`。换用不同维度的嵌入模型必须改此常量并重建向量集合,否则 upsert/检索维度不匹配。 - **确定性主键**:切片主键由 `MD5(documentId:index)` 派生,重复摄取同一文档会覆盖旧切片(幂等);删除须传原 `chunkCount`(`RemoveDocumentAsync`)以枚举全部键。 - **解析器缓存与热切换**:改了配置源里的 provider 参数后,必须调 `Invalidate` 才生效——缓存不会自动感知外部 DB 变更。 - **工具自动执行**:`OpenAiCompatibleChatClientFactory` 套了 `UseFunctionInvocation`,MAF 的 `ChatClientAgent` 内部也套 `FunctionInvokingChatClient`——工具/技能会被自动调用、无人工批准。当前 v1 技能均只读(知识检索)安全;将来接入有副作用的技能须自行加批准/审计。 - **管道三开关默认全关**:护栏是有意为之的安全策略(默认关避免无感知拦截业务);缓存对高温创造性调用会重放同答,语义有风险;遥测在未接 OTel 导出器前是静默空操作。生产环境建议至少打开 `EnableGuardrail`,其余按需评估。 - **护栏仅做输入侧防护**:`GuardrailChatClient` v1 只检查 `ChatRole.User` 消息,不做模型输出脱敏;护栏自身抛异常也会被当作拦截处理(fail-closed),避免护栏故障时误放行。 - **RAG 提示增强不走 Scriban**:`DefaultRagPromptAugmenter` 直接字符串插值(与框架 `ITemplateService` 的 string 引擎一致,不解析 Scriban)。 ## 依赖模块 - [XiHan.Framework.AI.Abstractions](./ai-abstractions)(接口与契约) - [XiHan.Framework.Core](./core) - [XiHan.Framework.Http](./http)(`XiHanAIModule` 依赖 `XiHanHttpModule`) - 第三方核心:**Microsoft.Extensions.AI** / **Microsoft.Extensions.AI.OpenAI**(会话与嵌入)、**Microsoft.Agents.AI**(Agent)、**ModelContextProtocol**(MCP)、**Microsoft.Extensions.VectorData.Abstractions**(向量数据契约) ::: warning 部署边界 本包不注册具体 `VectorStore`。向量连接器(如 Qdrant)与嵌入模型选择属部署事项,由应用层登记(如 `AddQdrantVectorStore`)。框架 AI 包本身不是「Qdrant 能力」——它只依赖 `Microsoft.Extensions.VectorData` 的抽象 `VectorStore`。 ::: ## 相关模块 - [XiHan.Framework.AI.Abstractions](./ai-abstractions) - [XiHan.Framework.Http](./http) --- 来源:https://framework.docs.xihanfun.com/packages/analyzers # XiHan.Framework.Analyzers > 基于 Roslyn 的编译期规范分析器:检查 C# 源文件的曦寒标准版权文件头,并提供一键修复。 - **NuGet**:`XiHan.Framework.Analyzers` - **模块类**:—(作为 Analyzer 引用,编译期生效,无运行时模块类) - **所在层**:工具 / 分析器 - **关键依赖**:Microsoft.CodeAnalysis.\*(Roslyn,开发期依赖,`PrivateAssets="all"`) ## 概述 XiHan.Framework.Analyzers 是一套基于 Roslyn 的代码分析器与代码修复器,在**编译期**扫描 C# 源码是否符合框架规范,并在 IDE 中给出诊断提示和 Code Fix 一键修复。当前落地的规则是文件头版权声明检查(`XHFH001`)。它以 `netstandard2.0` 构建(Roslyn 分析器约定的目标框架),标记为开发期依赖(`DevelopmentDependency`),并按 analyzer 约定打包到 `analyzers/dotnet/cs`——因此只在开发/构建阶段生效,**不进入运行时**、不给消费方带来运行时依赖。 ## 何时使用 - 想在编码/编译阶段自动强制统一的文件头规范,把问题拦在提交与 CI 之前。 - 希望 IDE 直接给出诊断提示,并提供自动修复而不必手工补写文件头。 - 团队协作下保证所有 `.cs` 文件的版权/作者/GUID/创建时间声明一致。 ## 安装与启用 作为分析器引用,引用后编译期**自动生效**,无需 `[DependsOn]` 或任何运行时接入。 发布后可通过 NuGet 包接入: ```bash dotnet add package XiHan.Framework.Analyzers ``` 开发期也可通过项目引用以 analyzer 形式接入(推荐显式声明为 analyzer,避免把它当普通程序集引用): ```xml ``` 启用后你得到:一个 `DiagnosticAnalyzer`(逐文件语法树扫描并产出诊断)与一个 `CodeFixProvider`(一键补写/替换文件头,支持 IDE 的 Fix All 批量修复)。 ## 工作原理 - 分析器注册 `SyntaxTree` 级动作,逐个 C# 源文件检查,并启用并发执行;不分析生成代码(`GeneratedCodeAnalysisFlags.None`)。 - **跳过规则**:非 `.cs` 文件、`bin`/`obj` 目录下的文件、`*.g.cs` / `*.generated.cs` / `*.designer.cs`、以及文件开头含 ` 目前源码中仅定义此一条规则(`XiHanFileHeaderRule.cs`)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanFileHeaderAnalyzer` | `public sealed`,`[DiagnosticAnalyzer(LanguageNames.CSharp)]`,注册语法树动作产出 `XHFH001` 诊断 | | `XiHanFileHeaderCodeFixProvider` | `public sealed`,`[ExportCodeFixProvider]` + `[Shared]`,提供「添加曦寒标准版权文件头」修复,`GetFixAllProvider()` 返回 `BatchFixer` 支持批量修复 | > `XiHanFileHeaderRule`(诊断描述符/ID/类别)与 `XiHanFileHeader`(文件头校验、解析、生成的内部工具)均为 `internal`,不属于对外 API。 ## 配置 分析器无 Options / 配置节。诊断级别通过 `.editorconfig` 的 `dotnet_diagnostic..severity` 逐条调整,支持按目录/文件模式覆盖: ```ini # 默认作为警告 [*.cs] dotnet_diagnostic.XHFH001.severity = warning # 需要在 CI 阻断时改为 error # dotnet_diagnostic.XHFH001.severity = error # 对生成/迁移目录关闭 [**/Migrations/*.cs] dotnet_diagnostic.XHFH001.severity = none ``` ## 标准文件头格式 `XHFH001` 校验并由 Code Fix 生成的文件头就是固定的两行,不含任何逐文件字段: ```csharp // Copyright (c) 2021-Present XiHanFun and contributors. // Licensed under the MIT License. See LICENSE in the project root for license information. ``` ## 扩展点 / 自定义 - 增加新规则:新增一个 `DiagnosticAnalyzer`(及可选 `CodeFixProvider`),沿用 `XiHanFileHeaderRule` 的模式定义唯一 `DiagnosticId` 与 `DiagnosticDescriptor`。因项目启用了 `EnforceExtendedAnalyzerRules`,需遵循分析器编写的扩展规则(如 `SupportedDiagnostics` 使用不可变数组、避免在分析器中做 I/O 等)。 - 消费方无需扩展即可用;调级别/排除目录一律通过 `.editorconfig`,不改分析器源码。 ## 注意事项与最佳实践 - 只针对 C# 源文件;`bin`/`obj`、生成文件(`*.g.cs` / `*.generated.cs` / `*.designer.cs`、含 ` 应用层对外契约:DTO 基类体系、应用服务接口与统一响应模型(`ApiResponse` + `ApiResponseCodes`) - **NuGet**:`XiHan.Framework.Application.Contracts` - **模块类**:`XiHanApplicationContractsModule` - **所在层**:领域与应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Domain.Shared`、`XiHan.Framework.Core`、`XiHan.Framework.Utils`) ## 概述 这个包定义应用层的**对外契约**:DTO 基类、应用服务接口和统一返回模型。它只声明「有什么」,不含具体实现——实现放在 [XiHan.Framework.Application](./application) 包。把契约单独抽出来,客户端、网关或其它模块可以只引用这一层拿到接口签名与数据形状,而不必依赖服务的实现细节,从而保持契约层的纯净与稳定。 包内三块内容:一是 `Dtos` 目录下的 DTO 基类体系(读取 / 创建 / 更新 / 删除 / 审计五种场景的抽象基类)与批量请求/响应模型;二是 `Services` 目录下的应用服务标记接口 `IApplicationService` 与 CRUD/批量 CRUD 契约;三是 `ApiResponse` 统一响应信封与 `ApiResponseCodes` 统一返回码枚举。 ## 何时使用 - 定义应用服务接口与传输数据的 DTO,声明前后端约定 - 需要标准化的 CRUD 应用服务契约(`ICrudApplicationService<...>`)或批量版本(`IBatchCrudApplicationService<...>`) - 需要统一的 API 响应信封(`ApiResponse` / `ApiResponse`)与业务返回码(`ApiResponseCodes`) - 定义 DTO 时希望复用带主键、带审计字段的抽象基类 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Application.Contracts ``` ```csharp [DependsOn(typeof(XiHanApplicationContractsModule))] public class MyModule : XiHanModule { } ``` `XiHanApplicationContractsModule` 依赖 `XiHanDomainSharedModule`,`ConfigureServices` 中不注册任何服务——本包是纯契约层,不含运行时逻辑,启用它主要是把 `Domain.Shared`(分页 DTO)纳入模块依赖链。 ## DTO 基类体系 DTO 基类按「读取 / 创建 / 更新 / 删除 / 审计」五种场景分类,每类都有一个非泛型基类和一个带 `TKey` 主键约束(`where TKey : IEquatable`)的泛型基类。带主键的泛型基类统一用 `BasicId` 作为主键属性名。 | 基类 | 主键字段 | 说明 | | --- | --- | --- | | `DtoBase` | — | 所有 DTO 的根基类(空基类,仅作标记) | | `DtoBase` | `virtual TKey BasicId` | 读取/输出 DTO 基类,携带主键 | | `CreationDtoBase` / `CreationDtoBase` | 无字段 | 创建 DTO 基类(创建时通常无主键,泛型版仅约束 `TKey`) | | `UpdateDtoBase` / `UpdateDtoBase` | `virtual TKey BasicId` | 更新 DTO 基类,携带待更新实体主键 | | `DeletionDtoBase` / `DeletionDtoBase` | `virtual TKey BasicId` | 删除 DTO 基类,携带待删除主键 | | `FullAuditedDtoBase` / `FullAuditedDtoBase` | `virtual TKey BasicId` | 带完整审计字段的 DTO 基类 | `FullAuditedDtoBase`(非泛型)的审计字段: | 字段 | 类型 | 含义 | | --- | --- | --- | | `CreatedTime` | `DateTimeOffset[]?` | 创建时间 | | `ModifiedTime` | `DateTimeOffset[]?` | 修改时间 | | `IsDeleted` | `bool` | 软删除标记 | | `DeletedTime` | `DateTimeOffset[]?` | 删除时间 | > 说明:三个时间字段声明为 `DateTimeOffset[]?`(数组),用于承载「时间区间」查询/展示语义,而非单个时刻。 `FullAuditedDtoBase` 在此基础上追加 `BasicId` 及操作者信息:`CreatedId` / `CreatedBy`、`ModifiedId` / `ModifiedBy`、`DeletedId` / `DeletedBy`(`Id` 为 `TKey?`,`By` 为 `string?`)。 所有字段均为 `virtual`,子类可重写。 ## 应用服务接口 | 类型 | 说明 | | --- | --- | | `IApplicationService : IRemoteService` | 应用服务标记接口,实现它的服务会被动态 API 暴露为 REST 接口 | | `ICrudApplicationService` | 标准 CRUD 契约(创建/更新 DTO 分离) | | `IBatchCrudApplicationService<...>` | 在 CRUD 之上追加批量操作契约 | `IApplicationService` 本身没有成员,仅继承自 [XiHan.Framework.Core](./core) 的 `IRemoteService`(同样是空接口)——它是一个纯标记,用于让框架的动态 API 机制识别「哪些类要暴露成接口」。 `ICrudApplicationService<...>` 的泛型参数与约束: ```csharp public interface ICrudApplicationService : IApplicationService where TEntityDto : DtoBase where TKey : IEquatable where TCreateDto : CreationDtoBase where TUpdateDto : UpdateDtoBase where TPageRequestDto : PageRequestDtoBase ``` 方法: | 方法 | 签名 | 说明 | | --- | --- | --- | | 获取单个 | `Task GetByIdAsync(TKey id)` | 按主键查询,未找到返回 `null` | | 分页 | `Task> PageAsync(TPageRequestDto input)` | 分页查询 | | 创建 | `Task CreateAsync(TCreateDto input)` | 创建并返回结果 DTO | | 更新 | `Task UpdateAsync(TUpdateDto input)` | 更新并返回结果 DTO | | 删除 | `Task DeleteAsync(TKey id)` | 删除,返回是否成功 | `IBatchCrudApplicationService<...>` 继承 `ICrudApplicationService<...>`(相同泛型参数与约束),追加: | 方法 | 签名 | | --- | --- | | 批量获取 | `Task> BatchGetAsync(List ids)` | | 批量创建 | `Task> BatchCreateAsync(BatchOperationRequest request)` | | 批量更新 | `Task> BatchUpdateAsync(BatchUpdateRequest request)` | | 批量删除 | `Task> BatchDeleteAsync(BatchDeleteRequest request)` | > 分页请求/响应基类(`PageRequestDtoBase` / `PageResultDtoBase`)来自依赖包 [XiHan.Framework.Domain.Shared](./domain-shared),CRUD 契约的分页参数即基于它们。 ## 批量操作模型 批量请求/响应模型(位于 `Dtos`): | 类型 | 关键字段 | 说明 | | --- | --- | --- | | `BatchOperationRequest` | `List Items`、`bool ContinueOnError`(默认 `false`)、`bool UseTransaction`(默认 `true`) | 通用批量请求(用于创建) | | `BatchDeleteRequest` | `List Ids`、`ContinueOnError`、`UseTransaction`、`bool SoftDelete`(默认 `true`) | 批量删除请求 | | `BatchUpdateRequest` | `List> Items`、`ContinueOnError`、`UseTransaction` | 批量更新请求 | | `BatchUpdateItem` | `TUpdate Data` | 单个更新项 | | `BatchOperationResponse` | `SuccessCount` / `FailureCount` / `TotalCount`、`bool IsAllSuccess`(=`FailureCount == 0`)、`List> Results`、`List Errors` | 批量操作响应 | | `BatchOperationResult` | `Index`、`bool IsSuccess`、`T? Data`、`ErrorMessage` / `ErrorCode` | 单条结果 | ## 统一响应模型 `ApiResponse` 是所有接口的统一响应信封。 字段: | 字段 | 类型 | 默认值 / 语义 | | --- | --- | --- | | `Code` | `ApiResponseCodes` | 业务码,默认 `Success`;**序列化到 JSON 为 int** | | `Message` | `string` | 提示信息,默认取业务码的 `DescriptionAttribute` 描述 | | `Data` | `object?` | 成功时为业务数据,失败时可承载错误明细 | | `TraceId` | `string?` | 请求追踪 ID,用于跨日志/链路定位 | | `Timestamp` | `DateTimeOffset` | 服务端时间,默认 `DateTimeOffset.UtcNow` | | `IsSuccess` | `bool`(只读) | `Code` 落在 2xx(`>= 200 and < 300`)视为成功 | 推荐用静态工厂方法构造,保证 Code 与 Message 语义一致: - 成功类:`Success(object? data, string? traceId)`、`Created(...)`、`Continue()` - 客户端错误:`BadRequest(...)`、`Unauthorized(...)`、`Forbidden()`、`NotFound()`、`UnprocessableEntity(...)`、`TooManyRequests()` - 服务端错误:`InternalServerError(...)`、`ServiceUnavailable()` - 通用失败:`Failure(ApiResponseCodes code, string? errorMessage, string? traceId)`——按指定业务码构造(适用于 10000+ 业务码及未内置工厂的协议码) 泛型版 `ApiResponse : ApiResponse` 用 `new T? Data` 遮蔽父类 `Data`,提供强类型数据以便客户端代码生成与 OpenAPI 精确表达;提供 `Success(T? data, string? traceId)` 与 `InternalServerError(T? data, string? traceId)` 工厂。 ## 统一返回码 `ApiResponseCodes` 枚举 `ApiResponseCodes` 的每个成员带 `DescriptionAttribute` 中文描述,`Message` 默认即取自该描述。 `ApiResponse.Code` **属性**上标了 `[JsonConverter(typeof(NumericEnumConverter))]`(枚举类型上也标了同一个转换器),**强制序列化为 int**——即使 Web 管道全局启用了 `JsonStringEnumConverter`,`code` 仍输出数字,方便前端统一判断。 转换器标在属性上是必需的:System.Text.Json 的优先级为「属性特性 > `Converters` 集合 > 类型特性」,只标类型会被管道加进集合的 `JsonStringEnumConverter` 压过。 分两个区段: **协议状态(100~599)**:与 HTTP Status Code 一致,成员采用 HTTP 官方名称;该区段同样适用于微服务、消息队列、RPC 等非 HTTP 场景。 | 区段 | 成员 | 值 | 描述 | | --- | --- | --- | --- | | 1xx 信息 | `Continue` | 100 | 继续请求 | | | `SwitchingProtocols` | 101 | 切换协议 | | 2xx 成功 | `Success` | 200 | 请求成功 | | | `Created` | 201 | 资源创建成功(通常用于 POST 创建操作) | | | `Accepted` | 202 | 请求已接受但尚未处理完成(异步任务提交,如导出、批量作业,结果需另行查询) | | | `NoContent` | 204 | 无内容返回(删除操作或无需响应体的更新) | | 3xx 重定向 | `MultipleChoices` | 300 | 多种响应可选 | | | `MovedPermanently` | 301 | 永久重定向 | | | `Found` | 302 | 临时重定向 | | | `NotModified` | 304 | 资源未修改(配合 ETag / If-Modified-Since 条件请求) | | 4xx 客户端错误 | `BadRequest` | 400 | 请求错误(参数/格式错误或缺少必要参数) | | | `Unauthorized` | 401 | 未授权(未通过身份认证) | | | `Forbidden` | 403 | 禁止访问(已认证但无权限) | | | `NotFound` | 404 | 资源不存在 | | | `MethodNotAllowed` | 405 | 请求方法不允许 | | | `RequestTimeout` | 408 | 请求超时 | | | `Conflict` | 409 | 请求冲突(重复创建、乐观锁版本冲突、防重放校验失败等) | | | `Gone` | 410 | 资源已永久删除(区别于 404,明确曾经存在) | | | `UnsupportedMediaType` | 415 | 媒体类型不支持 | | | `UnprocessableEntity` | 422 | 请求语义错误(参数格式正确但业务语义校验未通过) | | | `TooManyRequests` | 429 | 请求过于频繁(限流/防刷) | | 5xx 服务端错误 | `InternalServerError` | 500 | 服务器内部错误 | | | `NotImplemented` | 501 | 功能未实现 | | | `BadGateway` | 502 | 网关错误(从上游服务收到无效响应) | | | `ServiceUnavailable` | 503 | 服务不可用(维护、过载或依赖服务不可用) | | | `GatewayTimeout` | 504 | 网关超时(等待上游服务响应超时) | **业务状态(10000~99999)**:表达更细粒度的业务语义,按千位分类留段: - `10xxx` 认证与授权:`LoginExpired = 10001`、`TokenInvalid = 10002`、`TokenExpired = 10003`、`PermissionDenied = 10004` - `11xxx` 数据校验:`ValidationFailed = 11000` - `12xxx` 业务处理:`BusinessFailed = 12000` - `13xxx` 数据访问:`DatabaseError = 13000` - `14xxx` 外部依赖:`ThirdPartyServiceError = 14000` > 业务码与协议码的取舍:`401` 表达「未认证」,而 `LoginExpired = 10001` 明确「曾登录、现已过期」,便于前端引导重新登录;`403` 表达「无权限」,而 `PermissionDenied = 10004` 面向按钮/字段级细粒度权限点。 ## 使用示例 定义一套 CRUD DTO 并声明契约: ```csharp using XiHan.Framework.Application.Contracts.Dtos; using XiHan.Framework.Application.Contracts.Services; using XiHan.Framework.Domain.Shared.Paging.Dtos; public class ProductDto : DtoBase { public string Name { get; set; } = string.Empty; public decimal Price { get; set; } } public class ProductCreateDto : CreationDtoBase { public string Name { get; set; } = string.Empty; public decimal Price { get; set; } } public class ProductUpdateDto : UpdateDtoBase // 自带 BasicId { public string Name { get; set; } = string.Empty; } public class ProductPageRequestDto : PageRequestDtoBase { } public interface IProductAppService : ICrudApplicationService { } ``` 统一响应(在自定义端点或过滤器中): ```csharp // 成功(泛型强类型) var ok = ApiResponse.Success(dto, traceId); // 业务失败(业务码 + 明细) var fail = ApiResponse.Failure(ApiResponseCodes.PermissionDenied, "缺少 product:delete 权限"); ``` ## 注意事项与最佳实践 - 创建/更新用**分离的 DTO**(`TCreateDto` / `TUpdateDto`),不要复用同一个 DTO——创建时不含主键,更新时 `BasicId` 必填,语义不同。 - `ApiResponse.Code` 永远是 int(属性级 `NumericEnumConverter` 保证),前端按数字判断即可;判定成功优先用 `IsSuccess`,它已按 `[200, 300)` 算好。 - 判断成功用 `IsSuccess`(2xx 区段),而非 `Code == 200` 单值——`Created`(201) 等也应视为成功。 - 业务失败优先用 10000+ 业务码而非直接套用 HTTP 4xx,以携带更明确的业务语义。 ## 依赖模块 仅依赖 [XiHan.Framework.Domain.Shared](./domain-shared)(复用分页查询 DTO `PageRequestDtoBase` / `PageResultDtoBase`)。`IRemoteService` 与 `NumericEnumConverter` 分别来自 [XiHan.Framework.Core](./core) 与 [XiHan.Framework.Utils](./utils)(经传递引用)。不含任何实现代码,保持契约层纯净。 ## 相关模块 - [XiHan.Framework.Application](./application) — 本契约的实现层(应用服务基类 + CRUD 基类 + 动态 API 特性) - [XiHan.Framework.Domain.Shared](./domain-shared) — 提供分页查询 DTO - [XiHan.Framework.Domain](./domain) — 领域层,DTO 通常由领域实体映射而来 - [动态 API](../guide/dynamic-api) — `IApplicationService` 如何被自动暴露为 REST 接口 --- 来源:https://framework.docs.xihanfun.com/packages/application # XiHan.Framework.Application > 应用层实现:应用服务基类、CRUD/批量 CRUD 基类(Mapster 映射 + 软删除感知 + DataAnnotations 校验)与动态 API 特性 `[DynamicApi]` - **NuGet**:`XiHan.Framework.Application` - **模块类**:`XiHanApplicationModule` - **所在层**:领域与应用层 - **关键依赖**:Mapster(对象映射,经 `ObjectMapping` 引入);其余为框架内部依赖 ## 概述 这个包是应用层的**实现落点**,也是框架「动态 API」的关键一环。你写一个继承 `ApplicationServiceBase` 的应用服务,框架就能把它的公共方法自动暴露成 REST 接口,无需手写 Controller(配合 `[DynamicApi]`)。它还提供了开箱即用的 CRUD 基类,把「实体 ↔ DTO」的映射、分页查询、软删除识别、入参校验都封装好了——你只关心业务字段,增删改查的样板由基类代劳。 包内三块内容:`Services/ApplicationServiceBase`(服务根基类)、`Services/CrudApplicationServiceBase` 与 `Services/BatchCrudApplicationServiceBase`(CRUD 与批量 CRUD 基类)、`Attributes/DynamicApiAttribute`(动态 API 配置特性)。 ## 何时使用 - 编写应用服务并希望它自动变成 REST API(配合 `[DynamicApi]`) - 快速实现一个标准 CRUD 服务,只关心业务、不想重复写增删改查样板 - 需要实体与 DTO 之间的自动映射(基于 Mapster `Adapt`) - 需要按 DataAnnotations 校验入参、以及基于软删除接口自动走软删除路径 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Application ``` ```csharp [DependsOn(typeof(XiHanApplicationModule))] public class MyModule : XiHanModule { } ``` `XiHanApplicationModule` 依赖 `XiHanLoggingModule`、`XiHanApplicationContractsModule`、`XiHanDomainModule`、`XiHanDistributedIdsModule`、`XiHanObjectMappingModule`。`ConfigureServices` 中不额外注册服务——`ApplicationServiceBase` 实现了 `ITransientDependency`,由框架的约定式扫描自动注册为瞬时服务,无需手写 DI。 ## 工作原理 - **自动注册**:`ApplicationServiceBase : IApplicationService, ITransientDependency`,其派生类被框架约定式装配为瞬时依赖。 - **动态 API**:应用服务实现的 `IApplicationService` 标记 + `[DynamicApi]` 特性,被框架的动态 API 机制识别并生成路由,替代手写 Controller。详见 [动态 API](../guide/dynamic-api)。 - **映射**:CRUD 基类内部用 Mapster 的 `Adapt` 在实体与 DTO 间转换,所有映射方法均 `virtual`,可重写定制。 - **软删除感知**:删除时若实体实现 `ISoftDelete` 且容器中存在对应的 `ISoftDeleteRepositoryBase`,则走软删除;否则物理删除。 - **校验**:创建/更新/分页入参统一经 `ValidateInputObject` 用 DataAnnotations 校验(`validateAllProperties: true`)。 ## 核心类型 | 类型 | 说明 | | --- | --- | | `ApplicationServiceBase` | 应用服务基类,实现 `IApplicationService, ITransientDependency`;属性注入 `ICachedServiceProvider ServiceProvider`,提供懒加载 `protected ILogger Logger` | | `CrudApplicationServiceBase` | 通用 CRUD 基类,封装映射、分页、软删除、校验 | | `BatchCrudApplicationServiceBase<...>` | 批量 CRUD 基类,追加批量增删改查 | | `DynamicApiAttribute`(`[DynamicApi]`) | 动态 API 配置特性,控制路由/名称/版本/分组/大小写等 | ### `ApplicationServiceBase` ```csharp public abstract class ApplicationServiceBase : IApplicationService, ITransientDependency { public ICachedServiceProvider ServiceProvider { get; set; } = null!; // 属性注入 protected ILogger Logger => LazyLogger.Value; // 懒加载日志 } ``` `Logger` 懒加载自 `ServiceProvider` 解析的 `ILogger`,解析不到时回退 `NullLogger`——因此在无日志环境下也不会抛异常。 ### `CrudApplicationServiceBase<...>` 泛型约束: ```csharp public abstract class CrudApplicationServiceBase : ApplicationServiceBase, ICrudApplicationService where TEntity : EntityBase where TEntityDto : DtoBase where TKey : IEquatable where TCreateDto : CreationDtoBase where TUpdateDto : UpdateDtoBase where TPageRequestDto : PageRequestDtoBase ``` 构造签名:`protected CrudApplicationServiceBase(IRepositoryBase repository)`——注入仓储即可,保存在 `protected readonly IRepositoryBase Repository`。 内置方法(全部 `virtual`,可重写): | 方法 | 行为 | | --- | --- | | `GetByIdAsync(TKey id)` | `Repository.GetByIdAsync` 取实体,`null` 直接返回 `null`,否则映射为 DTO | | `PageAsync(TPageRequestDto input)` | 校验入参 → 调 `BuildAdditionalFilterPredicate` 取额外谓词 → `Repository.GetPagedAutoAsync(input[, predicate])` → 映射为 `PageResultDtoBase`;`Filters`/`Sorts` 由仓储层自动处理 | | `CreateAsync(TCreateDto input)` | 校验 → `MapDtoToEntityAsync(create)` → `Repository.AddAsync` → 回映射为 DTO | | `UpdateAsync(TUpdateDto input)` | 校验 → 按 `input.BasicId` 取实体(找不到抛 `KeyNotFoundException`)→ `MapDtoToEntityAsync(update, entity)` 覆盖 → `Repository.UpdateAsync` → 回映射 | | `DeleteAsync(TKey id)` | 取实体,`null` 返回 `false`;若实体 `is ISoftDelete` 且能解析 `ISoftDeleteRepositoryBase<,>` 则软删除,否则 `Repository.DeleteAsync` 物理删除 | 扩展点(`protected virtual`): | 成员 | 用途 | | --- | --- | | `BuildAdditionalFilterPredicate(PageRequestDtoBase input)` | 返回 `Expression>?`,默认 `null`;子类叠加权限/租户等业务过滤 | | `MapEntityToDtoAsync` / `MapEntitiesToDtosAsync` | 实体→DTO(Mapster `Adapt`) | | `MapDtoToEntityAsync`(多重载) | DTO→新实体、DTO→现有实体、CreateDto→实体、UpdateDto→现有实体 | | `ValidateInputObject(object input)` | DataAnnotations 校验(`Validator.ValidateObject`,`validateAllProperties: true`) | | `EnsureNotNullMapping(...)`(`protected static`) | 映射结果为空时抛 `InvalidOperationException` | ### `BatchCrudApplicationServiceBase<...>` 继承 `CrudApplicationServiceBase<...>`(相同泛型参数与约束)并实现 `IBatchCrudApplicationService<...>`,构造签名同样是 `IRepositoryBase repository`。 - `BatchGetAsync(List ids)`:空列表返回空;否则 `Repository.GetByIdsAsync` 批量取后映射。 - `BatchCreateAsync` / `BatchUpdateAsync` / `BatchDeleteAsync`:委托给内部 `ExecuteBatchOperationAsync` 逐项执行——按 `ContinueOnError` 决定遇错是否继续,成功/失败计数与错误列表汇总进 `BatchOperationResponse`。批量删除同样识别 `SoftDelete && ISoftDelete` 走软删除路径。 ## 动态 API 特性 `[DynamicApi]` `DynamicApiAttribute`(命名空间 `XiHan.Framework.Application.Attributes`)标记类或方法以配置动态 API 行为。`[AttributeUsage(Class | Method, AllowMultiple = true)]`——可多次标注。 **合并优先级:全局配置 < 类级特性 < 方法级特性**;同一层级按 `Order` 升序合并(数值越大越后应用、优先级越高,后写入覆盖先写入)。 可配置属性: | 属性 | 类型 / 默认 | 语义 | 合并方式 | | --- | --- | --- | --- | | `IsEnabled` | `bool` = `true` | 是否启用动态 API | 单值;任一层级设 `false` 即禁用 | | `RouteTemplate` | `string` = `""` | 路由模板 | 单值,方法级优先、类级兜底 | | `Name` | `string` = `""` | API 名称 | 单值,方法级优先、类级兜底 | | `Version` | `string` = `""` | API 版本 | 可叠加(多次标注叠多版本) | | `Description` | `string` = `""` | API 描述 | 单值,方法级优先、类级兜底 | | `Tag` | `string` = `""` | API 标签 | 可叠加 | | `Group` | `string` = `""` | 分组(文档键,用于 OpenApi 文档路径/标识) | 可叠加 | | `GroupName` | `string` = `""` | 分组显示名(文档 UI 分组标题) | 单值 | | `Order` | `int` = `0` | 同层级合并顺序(越大越后应用) | 单值 | | `PreserveRoutePredicate` | `bool` = `false` | 是否保留路由谓词(Get/Create/Delete 前缀) | 单值,方法级优先→类级→全局回退 | | `UsePascalCaseRoutes` | `bool` = `true` | 是否使用 PascalCase 路由 | 单值,同上回退链 | | `UseLowercaseRoute` | `bool` = `false` | 是否使用小写路由 | 单值,同上回退链 | | `VisibleInApiExplorer` | `bool` = `true` | 是否在 API 浏览器中显示 | 单值;任一层级 `false` 即隐藏 | | `CustomProperties` | `string` = `""` | 自定义属性(建议 `key=value`,重复 key 后写覆盖前写) | 可叠加 | **关键陷阱**:`PreserveRoutePredicate` / `UsePascalCaseRoutes` / `UseLowercaseRoute` 三个布尔项内部用**可空后备字段**区分「未设置」与「设为默认值」。合并逻辑读取 `PreserveRoutePredicateOrNull` / `UsePascalCaseRoutesOrNull` / `UseLowercaseRouteOrNull`(`null` = 未显式设置,回退下一层级)——只为设 `Group`/`Tag` 而标注特性时,不会以编译期默认值意外覆盖全局配置(该陷阱曾导致整类路由 404)。 ## 使用示例 ### 示例一:`ApplicationServiceBase` + `[DynamicApi]` 继承基类并标注 `[DynamicApi]`,公共方法即自动暴露为 REST API: ```csharp using XiHan.Framework.Application.Attributes; using XiHan.Framework.Application.Services; [DynamicApi] public class WeatherAppService : ApplicationServiceBase { public Task GetForecastAsync(string city) { Logger.LogInformation("查询 {City} 天气", city); return Task.FromResult($"{city}: 晴"); } } ``` ### 示例二:`CrudApplicationServiceBase` 标准 CRUD 服务只需注入仓储,即可获得全套增删改查: ```csharp using XiHan.Framework.Application.Attributes; using XiHan.Framework.Application.Services; using XiHan.Framework.Domain.Repositories; [DynamicApi] public class ProductAppService : CrudApplicationServiceBase { public ProductAppService(IRepositoryBase repository) : base(repository) { } // 叠加租户/权限过滤:重写扩展点,基类分页会自动应用 protected override Expression>? BuildAdditionalFilterPredicate(PageRequestDtoBase input) => p => !p.IsInternal; } ``` > 动态 API 的工作原理与更多配置见 [动态 API](../guide/dynamic-api)。 ## 注意事项与最佳实践 - CRUD 基类要求 `TEntity : EntityBase`、DTO 继承对应基类(`DtoBase` / `CreationDtoBase` / `UpdateDtoBase`)、分页 DTO 继承 `PageRequestDtoBase`——约束不满足编译期即报错。 - 软删除路径依赖容器中注册了 `ISoftDeleteRepositoryBase`;若实体实现 `ISoftDelete` 但未注册软删除仓储,则回退为物理删除。 - 定制映射(如忽略字段、平铺嵌套)请重写 `MapXxxAsync` 系列 `virtual` 方法,而非在业务方法里手动改字段。 - `[DynamicApi]` 的三个可空布尔项要用 `...OrNull` 语义理解合并——不显式设置就不会覆盖全局;不要因为「看着默认值一样」而随手标注它们。 - 分页查询里 `Filters`/`Sorts` 由仓储层自动处理,`BuildAdditionalFilterPredicate` 只用于叠加额外业务过滤(权限、租户),不要重复实现前端已下发的过滤条件。 ## 依赖模块 - [XiHan.Framework.Application.Contracts](./application-contracts) — 应用契约(接口与 DTO 基类) - [XiHan.Framework.Domain](./domain) — 领域层(实体 `EntityBase`、仓储 `IRepositoryBase<,>` / `ISoftDeleteRepositoryBase<,>`) - [XiHan.Framework.DistributedIds](./distributed-ids) — 分布式 ID 生成 - [XiHan.Framework.Logging](./logging) — 日志 - [XiHan.Framework.ObjectMapping](./objectmapping) — 对象映射(Mapster) ## 相关模块 - [XiHan.Framework.Application.Contracts](./application-contracts) — 本包实现的契约层 - [XiHan.Framework.Domain](./domain) — 被编排的领域对象 - [动态 API](../guide/dynamic-api) — 应用服务如何自动变成 REST 接口 --- 来源:https://framework.docs.xihanfun.com/packages/auditing # XiHan.Framework.Auditing > 审计日志基础设施:6 类日志记录、Channel 异步队列 + 后台批量消费者、采集管道、敏感数据脱敏、写入器契约(默认空实现)。 - **NuGet**:`XiHan.Framework.Auditing` - **模块类**:`XiHanAuditingModule` - **所在层**:基础设施层(横切关注点) - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Security / MultiTenancy.Abstractions) ## 概述 这个包提供**与传输协议、ORM 都无关**的审计日志采集基础设施。它定义了 6 类日志记录模型、一套"采集管道 → 异步队列 → 后台消费者 → 写入器"的链路,以及落库用的写入器契约——但**它自己不落库**。所有 `IXxxLogWriter` 的默认实现都是空实现(`NullXxxLogWriter`),日志采集到就丢掉。 这是刻意的分层:框架负责"采集什么、怎么异步化、怎么脱敏",应用负责"存到哪张表"。应用侧实现各 `IXxxLogWriter` 与 `IEntityAuditContextProvider` 并注册进容器,日志才会真正落库。 框架内部有两个消费方: - [Web.Api](./web-api) 的中间件负责填充并调用访问 / 操作 / 异常 / 接口 / 登录日志管道 - [Data](./data) 的 `EntityChangeInterceptor` 与 `SqlSugarDiffLogAop` 负责实体变更日志 ## 何时使用 - 需要记录谁在什么时候访问了哪个接口、耗时多久、返回什么状态码 - 需要审计实体的字段级变更(改前 / 改后 / 变更了哪些字段) - 需要登录审计(成功、失败、设备、IP) - 需要把上述日志**异步批量**落库,避免拖慢主请求 - 需要请求体 / 查询串在落日志前自动脱敏(密码、令牌、身份证号等) ## 安装与启用 ```bash dotnet add package XiHan.Framework.Auditing ``` ```csharp [DependsOn(typeof(XiHanAuditingModule))] public class MyModule : XiHanModule { } ``` > 用 [Web.Api](./web-api) 或 [Data](./data) 时它已作为传递依赖被自动引入,通常不必单独安装。 `XiHanAuditingModule.ConfigureServices` 调用 `services.AddXiHanAuditing(config)`,注册: - 配置绑定:`XiHan:Auditing:LogQueue` → `XiHanAuditingLogQueueOptions` - 队列:`ILogQueue<>` → `ChannelLogQueue<>`(单例,有界 Channel) - 5 个后台消费者(`AddHostedService`):`AccessLogQueueWorker`、`OperationLogQueueWorker`、`ExceptionLogQueueWorker`、`ApiLogQueueWorker`、`LoginLogQueueWorker` - 5 个采集管道(`AddScoped`):`IAccessLogPipeline`、`IOperationLogPipeline`、`IExceptionLogPipeline`、`IApiLogPipeline`、`ILoginLogPipeline` - 5 个写入器契约(`TryAddScoped`,**默认空实现**):`IAccessLogWriter` → `NullAccessLogWriter` 等 - 实体变更审计(`TryAddScoped`):`IEntityAuditContextProvider` → `DefaultEntityAuditContextProvider`,`IEntityDiffLogWriter` → `NullEntityDiffLogWriter` ## 工作原理 ### 采集链路 ```text 中间件 / 拦截器 └→ IXxxLogPipeline.WriteAsync(record) ├ EnableXxxLogQueue = false(默认)→ 直接 await IXxxLogWriter.WriteAsync(record) ← 同步落库 └ EnableXxxLogQueue = true → 入队 ILogQueue └→ XxxLogQueueWorker 攒批 → IXxxLogWriter.WriteAsync 逐条写 ``` **队列默认全部关闭**(5 个 `EnableXxxLogQueue` 默认 `false`),此时管道**同步调用写入器**——注意它传的是 `CancellationToken.None`,请求取消也会把这条日志写完。 ### 后台消费者的攒批 `XxxLogQueueWorker` 继承 `BackgroundService`。若对应队列未启用,`ExecuteAsync` 直接返回(不占线程)。否则 `await foreach` 消费队列,满足任一条件即 flush: - 累计条数 ≥ `BatchSize` - 距上次 flush 已超过 `BatchDelayMilliseconds` flush 时新建一个 DI scope 解析 `IXxxLogWriter`,**逐条**调用 `WriteAsync`(不是批量 SQL——批量与否取决于你的写入器实现)。写入异常只 `LogWarning`,不会让 Worker 崩溃。host 停止时把残留批次写完。 ### 队列本身 `ChannelLogQueue` 包装一个 `Channel.CreateBounded`,容量 `QueueCapacity`,`FullMode` 固定为 `BoundedChannelFullMode.Wait`。于是队列暴露出两种语义明确的入队方式: - `TryEnqueue(record)`:不等待。入队成功返回 `true`;**队列满时返回 `false` 且记录未入队** - `EnqueueAsync(record, ct)`:队列满时等待空位,直到有空位或被取消 "满了丢不丢"是**管道层的策略**,由 `DropOnFull` 决定走哪个方法,而不是下沉给 Channel: - `DropOnFull = false`(默认)→ 管道调 `EnqueueAsync`:队列满时**入队方等待**(反压到请求线程) - `DropOnFull = true` → 管道调 `TryEnqueue`:队列满时丢弃当前记录,并记一条 `LogWarning` > 这里不能用 `BoundedChannelFullMode.DropWrite`——那个模式下 `TryWrite` 队列满时也返回 `true`(静默丢弃),管道便无从得知记录已被丢掉,警告日志永远不会触发。 ### 实体变更审计 `IEntityAuditContextProvider` 决定"审计谁"与"记录哪些上下文字段": - `CreateBaseRecord()`:默认实现从 `ICurrentUser` / `ICurrentTenant` 填 `UserId` / `UserName` / `TenantId` - `ShouldAudit(Type)`:默认排除 `XiHan.Framework.Auditing` 命名空间下的类型,以及类型全名含 `AuditLog` / `DiffLog` 的类型(**防止审计日志表自己被审计,导致无限递归**) - `ShouldAuditByName(string tableName)`:供 SQL 命令级拦截器按表名判断,规则同上(不区分大小写) ## 核心能力 - **6 类日志记录**:访问、操作、异常、接口、登录、实体变更 - **异步队列 + 攒批消费**:有界 Channel,可配容量 / 批大小 / 批间隔 / 满时策略 - **逐类开关**:5 个队列各自独立启用,关闭时退化为同步写入 - **敏感数据脱敏** `LogSanitizer`:静态类,键名命中敏感词整体掩码、身份证号按模式掩码 - **写入器契约与空实现**:`TryAddScoped` 注册,应用侧注册同接口实现即可覆盖 - **实体变更审计防递归**:默认上下文提供器排除审计日志自身 - **路径忽略**:`IgnoredPathPrefixes` 默认忽略 `/hubs`(SignalR 协商 / 长轮询 / 心跳高频且无审计价值) ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `AccessLogRecord` | 访问日志:`TraceId`、`UserId`、`TenantId`、`Method`、`Path`、`QueryString`、`RequestBody`、`StatusCode`、`RemoteIp`、`UserAgent`、`Referer`、`ElapsedMilliseconds`、`ResponseSize` 等 17 个字段 | | `OperationLogRecord` | 操作日志:在访问日志基础上带 `ControllerName` / `ActionName` / `RequestParams` / `ResponseResult` | | `ApiLogRecord` | 接口日志:最全,另含 `ClientId` / `AppId` / `IsSignatureValid` / `SignatureAlgorithm` / `RequestSize` / `IsSuccess` 等 25 个字段 | | `ExceptionLogRecord` | 异常日志:`ExceptionType` / `ExceptionMessage` / `ExceptionStackTrace` / `RequestHeaders` 等 | | `LoginLogRecord` | 登录日志:`LoginResult` / `Message` / `LoginIp` / `DeviceId` / `LoginTime` | | `EntityDiffLogRecord` | 实体变更日志:`OperationType` / `EntityType` / `EntityId` / `BeforeData` / `AfterData` / `ChangedFields` | 六类记录都带 `long? TenantId`(`null` 即平台):五类日志在记录产生时取请求所属租户,写入器据此落戳,不依赖写入时的环境上下文。 | `ILogQueue` | 日志队列契约:`Count` / `TryEnqueue` / `EnqueueAsync` / `DequeueAllAsync` | | `ChannelLogQueue` | 基于有界 `Channel` 的默认实现(单例,开放泛型注册) | | `IAccessLogPipeline` 等 5 个 | 采集管道契约(`Scoped`):`WriteAsync(record, ct)` | | `IAccessLogWriter` 等 5 个 | 写入器契约(`Scoped`,默认 `NullXxxLogWriter`)——**应用侧实现它才会落库** | | `IEntityAuditContextProvider` | 实体审计上下文:`CreateBaseRecord()` / `ShouldAudit(Type)` / `ShouldAuditByName(string)` | | `IEntityDiffLogWriter` | 实体差异日志写入器(默认 `NullEntityDiffLogWriter`) | | `LogSanitizer` | 静态脱敏器:`MaskSensitiveData(string?)` / `MaskQueryString(string?)`,掩码常量 `Mask = "***"` | | `XiHanAuditingLogQueueOptions` | 配置选项;配置节 `XiHan:Auditing:LogQueue` | ## 配置 - **配置节名**:`XiHan:Auditing:LogQueue`(来自 `XiHanAuditingLogQueueOptions.SectionName`) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `IgnoredPathPrefixes` | `string[]` | `["/hubs"]` | 不记录任何请求日志的路径前缀(不区分大小写) | | `EnableAccessLogQueue` | `bool` | `false` | 访问日志是否走异步队列 | | `EnableOperationLogQueue` | `bool` | `false` | 操作日志是否走异步队列 | | `EnableExceptionLogQueue` | `bool` | `false` | 异常日志是否走异步队列 | | `EnableApiLogQueue` | `bool` | `false` | 接口日志是否走异步队列 | | `EnableLoginLogQueue` | `bool` | `false` | 登录日志是否走异步队列 | | `QueueCapacity` | `int` | `10000` | 队列容量(有界 Channel) | | `DropOnFull` | `bool` | `false` | 队列满时丢弃(`true`)还是等待(`false`) | | `BatchSize` | `int` | `100` | 批处理大小 | | `BatchDelayMilliseconds` | `int` | `200` | 批处理间隔(毫秒) | ```json { "XiHan": { "Auditing": { "LogQueue": { "IgnoredPathPrefixes": ["/hubs", "/health"], "EnableAccessLogQueue": true, "EnableOperationLogQueue": true, "EnableExceptionLogQueue": true, "EnableApiLogQueue": true, "EnableLoginLogQueue": true, "QueueCapacity": 10000, "DropOnFull": false, "BatchSize": 100, "BatchDelayMilliseconds": 200 } } } } ``` ## 使用示例 ### 1. 实现写入器让日志真正落库(必做) 不实现写入器,日志采集了也会被 `NullXxxLogWriter` 丢掉。契约用 `TryAddScoped` 注册,因此**你注册同接口的实现就会覆盖默认空实现**: ```csharp public class OperationLogWriter : IOperationLogWriter, IScopedDependency { private readonly IRepositoryBase _repository; public OperationLogWriter(IRepositoryBase repository) { _repository = repository; } public async Task WriteAsync(OperationLogRecord record, CancellationToken cancellationToken = default) { await _repository.InsertAsync(new SysOperationLog { TraceId = record.TraceId, UserId = record.UserId, Path = record.Path, StatusCode = record.StatusCode, ElapsedMilliseconds = record.ElapsedMilliseconds }); } } ``` ### 2. 自定义实体审计上下文 默认提供器只填用户与租户。要补 HTTP 上下文(请求路径、IP、RequestId),或改写"审计哪些实体"的规则: ```csharp public class HttpEntityAuditContextProvider : IEntityAuditContextProvider, IScopedDependency { private readonly IHttpContextAccessor _accessor; public HttpEntityAuditContextProvider(IHttpContextAccessor accessor) => _accessor = accessor; public EntityDiffLogRecord CreateBaseRecord() { var http = _accessor.HttpContext; return new EntityDiffLogRecord { RequestPath = http?.Request.Path, RequestMethod = http?.Request.Method, OperationIp = http?.Connection.RemoteIpAddress?.ToString() }; } // 只审计业务实体 public bool ShouldAudit(Type entityType) => entityType.Namespace?.StartsWith("MyApp.Domain") == true; public bool ShouldAuditByName(string tableName) => !tableName.Contains("log", StringComparison.OrdinalIgnoreCase); } ``` > 覆盖 `ShouldAudit` / `ShouldAuditByName` 时,**务必把审计日志表自己排除掉**,否则写审计日志的 SQL 会再触发一次审计,无限递归。 ### 3. 落日志前脱敏 ```csharp record.RequestBody = LogSanitizer.MaskSensitiveData(httpRequestBody); record.QueryString = LogSanitizer.MaskQueryString(httpRequest.QueryString.Value); ``` 命中的敏感键名包括 `password` / `pwd` / `secret` / `token` / `authorization` / `otp` / `verifycode` / `bankcard` / `idcard` 等;值层面还会把 15/18 位身份证号按"保留首尾、掩码中段"处理。脱敏只作用于日志副本,不影响业务管道里的原始请求。 ## 扩展点 / 自定义 - **换队列实现**:`ILogQueue<>` 以开放泛型单例注册,可替换为自己的实现(如落 Redis 队列跨进程消费) - **换上下文提供器**:注册自己的 `IEntityAuditContextProvider` 覆盖 `DefaultEntityAuditContextProvider` - **批量写入**:Worker 是**逐条**调用 `WriteAsync` 的;想要一条 `INSERT` 写多行,在写入器内部自行缓冲,或替换 Worker ## 注意事项与最佳实践 - **不实现写入器 = 日志静默丢弃**。5 个 `IXxxLogWriter` 与 `IEntityDiffLogWriter` 的默认实现都是空实现,且用 `TryAddScoped` 注册——不报错、不告警,日志就是没了。排查"日志页面恒空"时,第一步确认你的写入器有没有被注册进容器。 - **队列默认全关,日志是同步写的**。5 个 `EnableXxxLogQueue` 默认 `false`,此时 `IXxxLogPipeline.WriteAsync` 直接 `await` 写入器——**落库耗时会计入请求响应时间**。生产环境建议逐个打开队列。 - **`DropOnFull = false`(默认)会反压请求线程**。队列满时 `EnqueueAsync` 等待,写入器慢就会拖慢接口。高流量下要么扩容写入器,要么改用 `DropOnFull = true` 接受丢日志。 - **`DropOnFull = true` 时丢弃是静默于业务、显式于日志的**。被丢的记录不会落库,但每丢一条都会记一条 `LogWarning`(含 `TraceId`)。看到这条警告说明写入器的吞吐已跟不上采集速度。 - **同步路径传的是 `CancellationToken.None`**。队列关闭时,客户端断开连接不会中止日志写入——这是有意为之(避免日志被请求取消打断),但意味着慢写入器会拖住线程。 - **审计日志表必须排除在审计之外**。默认 `ShouldAudit` / `ShouldAuditByName` 已排除类型全名或表名含 `AuditLog` / `DiffLog` 的目标;自定义提供器时若忘了这条,写审计日志的 SQL 会再次触发审计,无限递归直至栈溢出或磁盘写满。 - **`IgnoredPathPrefixes` 默认只忽略 `/hubs`**。健康检查、探针、静态资源等高频无价值路径建议一并加入,否则访问日志会被它们淹没。 - **Worker 的写入异常只记 `LogWarning`**。日志落库失败不会让应用崩溃,但也不会重试——这批日志就丢了。需要可靠性时在写入器内部自行重试或落本地文件兜底。 ## 依赖模块 - [XiHan.Framework.Security](./security)(`ICurrentUser`) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions)(`ICurrentTenant`) ## 相关模块 - [XiHan.Framework.Web.Api](./web-api)(中间件采集访问 / 操作 / 异常 / 接口 / 登录日志) - [XiHan.Framework.Data](./data)(`EntityChangeInterceptor`、`SqlSugarDiffLogAop` 采集实体变更) - [XiHan.Framework.Logging](./logging)(结构化运行日志,与审计日志是两回事) - [XiHan.Framework.Observability](./observability) --- 来源:https://framework.docs.xihanfun.com/packages/authentication # XiHan.Framework.Authentication > 认证:JWT 双令牌、OAuth2 外部登录(Google/GitHub/Gitee/QQ/微信/企业微信/飞书/钉钉,账号授权与扫码登录)、TOTP 两步验证、一次性验证码 - **NuGet**:`XiHan.Framework.Authentication` - **模块类**:`XiHanAuthenticationModule`(`DependsOn` `XiHanSecurityModule`) - **所在层**:基础设施层 - **关键依赖**:**Microsoft.AspNetCore.Authentication.JwtBearer**(唯一的 NuGet 依赖)+ ASP.NET Core 共享框架(`FrameworkReference`) ## 概述 这个包负责**认证**——确认“你是谁”。它在 [Security](./security) 基座之上提供:JWT 访问/刷新双令牌的签发与校验、OAuth2 第三方登录、TOTP 两步验证(2FA),以及邮箱/短信一次性验证码。认证通过后颁发携带身份声明的令牌,令牌里的声明再交给 [Authorization](./authorization) 判定“你能做什么”。 > 认证(Authentication)= 你是谁;授权(Authorization)= 你能做什么。两者分属不同的包。 ## 何时使用 - 需要基于 JWT 的登录与令牌刷新(Access + Refresh 双令牌,HMAC-SHA256 签名) - 需要接入 Google / GitHub / Gitee / QQ 第三方登录(OAuth2 外部登录) - 需要接入微信 / 企业微信 / 飞书 / 钉钉登录,且账号授权与扫码登录都要覆盖 - 需要两步验证(TOTP,兼容 Google Authenticator 等) - 需要邮箱/短信一次性验证码(登录码、换绑验证等,签发一次消费即销毁) - 需要账号密码认证编排(复用 Security 的哈希与策略,含失败锁定) ::: warning 能力范围 本包实现的第三方登录是标准 **OAuth2 外部登录**,**不包含** OIDC 联合登录,也**不包含 SSO 单点登录服务端**(本框架不充当 IdP/授权服务器)。请勿据模板化 README 假设存在 OIDC/SSO。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Authentication ``` ```csharp [DependsOn(typeof(XiHanAuthenticationModule))] public class MyModule : XiHanModule { } ``` 模块 `ConfigureServices` 调用 `AddXiHanAuthentication(config)`,完成: - **绑定配置**:`PasswordHasherOptions` / `PasswordPolicyOptions`(来自 Security)、`JwtOptions`、`OtpOptions`、`OAuthOptions` - **注册服务(`TryAdd`)**: - `IPasswordHasher` → `PasswordHasher`(Singleton) - `IRefreshTokenStore` → `DefaultRefreshTokenStore`(Singleton;最多 100000 条,写入期批量清理过期项) - `IJwtTokenService` → `JwtTokenService`(Singleton) - `IOtpService` → `OtpService`(Singleton) - `IOneTimeCodeService` → `DistributedOneTimeCodeService`(Singleton) - `IUserStore` → `DefaultUserStore`(Scoped) - `IAuthenticationService` → `DefaultAuthenticationService`(Scoped) - **动态挂载 OAuth**:`AddXiHanOAuth` 仅在 `OAuthOptions.Enabled` 且存在提供商时,才注册 `IExternalLoginStore`,并按 `Providers[]` 逐条 `AddOAuth` 挂上对应方案(`ClientId` 为空的条目跳过,未知提供商类型跳过) ## 工作原理 **JWT 双令牌**:`GenerateAccessToken(claims)` 用对称密钥 HMAC-SHA256 签发访问令牌,同时生成一个 64 字节随机刷新令牌,并把 `(refreshToken → subject, 过期时间)` 存入 `IRefreshTokenStore`。刷新时 `RefreshAccessToken(accessToken, refreshToken)` 会以**忽略生命周期**的参数校验旧访问令牌拿回声明,再核对刷新令牌与 subject 绑定,通过后签发新令牌并**移除旧刷新令牌**(一次性轮换)。`ValidateToken` 额外强制算法必须是 HmacSha256,防算法混淆攻击。subject 取值优先级为 `sub` → `NameIdentifier` → `XiHanClaimTypes.UserId`。 **第三方登录**:八家提供商都是 ASP.NET Core 的 `OAuthHandler` 实现,由框架自研而非引入 provider 包。共同部分收在基类 `XiHanOAuthHandler`:默认的 `CreateTicketAsync` 走「Bearer 令牌 GET 用户信息接口 → `RunClaimActions` → `AfterClaimActionsAsync` 钩子 → `Events.CreatingTicket`」,另提供 JSON/表单请求与字段读取助手。偏离通用约定的提供商各自覆写 `BuildChallengeUrl`(授权地址参数)、`ExchangeCodeAsync`(令牌请求与响应字段名)、`CreateTicketAsync`(额外跳数与错误判定)。登录标识统一手动写入 `NameIdentifier`,优先取跨应用唯一的 union 类标识。 **一次性验证码**:`DistributedOneTimeCodeService` 用加密安全随机数生成纯数字码,以 `IDistributedCache`(接入 Redis 即多实例水平扩展)按 `default:auth:otc:{purpose}:{target}` 为键存储,可携带一段 `payload`(如换绑场景暂存的新邮箱)。消费时**先删除再校验**(消费即销毁),码不存在/过期/不匹配都返回失败,杜绝重放与穷举;比较用恒定时间函数。 ## 核心能力 - **JWT 双令牌** `IJwtTokenService`:签发访问令牌 + 刷新令牌、校验、提取声明、判过期、刷新轮换;HMAC-SHA256;配置节 `XiHan:Authentication:Jwt` - **OAuth2 外部登录**:内置 Google / GitHub / Gitee / QQ / 微信 / 企业微信 / 飞书 / 钉钉,配置驱动按需注册;各家头像 JSON 字段统一映射到 `urn:xihan:avatar` Claim;`IExternalLoginStore` 映射外部身份到内部用户;配置节 `XiHan:Authentication:OAuth` - **账号授权与扫码登录**:微信 / 企业微信 / 飞书 / 钉钉用 `Mode` 选授权页与权限范围,同一提供商可用不同 `Name` + 相同 `Provider` 注册成两个 AuthenticationScheme - **零第三方 OAuth 依赖**:八家的处理器全部自研,共用基类 `XiHanOAuthHandler`;本包不引入任何 `AspNet.Security.OAuth.*` 或 `Microsoft.AspNetCore.Authentication.Google` 包 - **两步验证(TOTP/HOTP)** `IOtpService`:生成 TOTP 密钥与 `otpauth://` 二维码 URI、生成/校验动态码、HOTP 计数器变体、生成备用恢复码;配置节 `XiHan:Authentication:Otp` - **一次性验证码** `IOneTimeCodeService`:邮箱/短信验证码签发与一次性消费,基于 `IDistributedCache`,可携带 payload - **密码认证编排** `IAuthenticationService`:账号密码认证、改密/重置、2FA 启停与校验、恢复码、失败锁定,复用 Security 的哈希与策略 ## 主要 API / 类型 ### JWT | 类型 | 关键方法 / 说明 | | --- | --- | | `IJwtTokenService` / `JwtTokenService` | `JwtTokenResult GenerateAccessToken(List)`、`string GenerateRefreshToken()`、`ClaimsPrincipal? ValidateToken(string)`、`List? GetClaimsFromToken(string)`、`bool IsTokenExpired(string)`、`JwtTokenResult? RefreshAccessToken(string accessToken, string refreshToken)` | | `JwtTokenResult` | `AccessToken`、`RefreshToken`、`TokenType`(`"Bearer"`)、`ExpiresIn`(秒)、`IssuedAt`、`ExpiresAt` | | `JwtOptions` | JWT 配置(配置节 `XiHan:Authentication:Jwt`) | | `IRefreshTokenStore` / `DefaultRefreshTokenStore` | `void Save(string token, string? subject, DateTime expiresAt)`、`bool Validate(string token, string? subject = null)`、`void Remove(string token)`;默认实现最多 100000 条,写入期清理过期项,满载时拒绝新增 | ### OAuth2 | 类型 | 说明 | | --- | --- | | `OAuthOptions` | 全局配置:`Enabled`、`FrontendCallbackUrl`、`Providers`;常量 `AvatarClaimType = "urn:xihan:avatar"`(配置节 `XiHan:Authentication:OAuth`) | | `OAuthProviderConfig` | 单个提供商:`Name`(=scheme)、`Provider`(提供商类型,留空取 `Name`)、`Mode`、`DisplayName`、`Enabled`、`ClientId`、`ClientSecret`、`AgentId`、`LoadMemberProfile`、`CorpId`、`Scopes`、`AuthorizationEndpoint`、`AuthorizationParameters`、`CallbackPath`(默认 `/signin-{name}`);`ResolveProviderType()` 返回小写的提供商类型 | | `OAuthProviderNames` | 提供商类型常量:`google`、`github`、`gitee`、`qq`、`weixin`(别名 `wechat`)、`workweixin`(别名 `wecom`)、`feishu`(别名 `lark`)、`dingtalk` | | `OAuthLoginMode` | `QrCode`(默认)/ `Account`,只对微信、企业微信、飞书、钉钉生效;对微信系与钉钉只换授权页与权限范围,飞书是授权/令牌/用户信息三个接口成套切换 | | `OAuthProviderEndpoints` | 八家的全部接口地址常量,区分登录方式的按 `QrCode`/`Account` 各列一条;含微信系要求的 `#wechat_redirect` 锚点 | | `OAuthClaimTypes` | 八家的私有声明类型常量,`urn:{provider}:{field}` 命名;含钉钉的 `urn:dingtalk:corpid`(权限范围含 `corpid` 时才有) | | `XiHanOAuthServiceCollectionExtensions` | `AddXiHanOAuth(services, configuration)`;常量 `ExternalSignInScheme = "ExternalCookie"` | **提供商处理器(`XiHan.Framework.Authentication.OAuth.Handlers`,八家全部自研)** | 类型 | 说明 | | --- | --- | | `XiHanOAuthProviderOptions` | 提供商选项基类,屏蔽与框架 `OAuthOptions` 的同名冲突 | | `XiHanOAuthHandler` | 处理器基类:默认实现「Bearer 令牌 GET 用户信息 → 跑声明映射」,并提供 HTTP/JSON 助手与 `AfterClaimActionsAsync` 钩子。Google 直接用它,无需单独处理器 | | `GoogleAuthenticationOptions` | Google,启用 PKCE | | `GitHubAuthenticationOptions` / `GitHubAuthenticationHandler` | GitHub,邮箱私密时从 `user/emails` 补取主邮箱 | | `GiteeAuthenticationOptions` / `GiteeAuthenticationHandler` | Gitee,同上 | | `QQAuthenticationOptions` / `QQAuthenticationHandler` | QQ,`fmt=json` + 先换 openid 再取资料 | | `WeixinAuthenticationOptions` / `WeixinAuthenticationHandler` | 微信,`appid`/`secret` + `errcode` + `#wechat_redirect` + 账号授权的 state 搬运 | | `WorkWeixinAuthenticationOptions` / `WorkWeixinAuthenticationHandler` | 企业微信,企业凭证 → 成员身份 → 敏感资料三跳合并 | | `FeishuAuthenticationOptions` / `FeishuAuthenticationHandler` | 飞书,passport 与开放平台两套端点按 `Mode` 成套切换 | | `DingTalkAuthenticationOptions` / `DingTalkAuthenticationHandler` | 钉钉,JSON 令牌请求 + 小驼峰字段改写 + 私有请求头 | | `WeixinShortState`(internal) | 微信系网页授权页的 state 搬运与还原 | | `IExternalLoginStore` | `Task FindUserIdAsync(provider, providerKey, tenantId?)`、`Task CreateAsync(userId, ExternalLoginInfo, tenantId?)`、`Task RemoveAsync(userId, provider)`(业务层实现数据库持久化) | | `ExternalLoginInfo` | `Provider`、`ProviderKey`、`DisplayName`、`Email`、`AvatarUrl` | ### MFA / 一次性验证码 | 类型 | 关键方法 / 说明 | | --- | --- | | `IOtpService` / `OtpService` | `string GenerateTotpSecret()`、`string GenerateTotpUri(secret, issuer, account)`、`string GenerateTotpCode(secret)`、`bool VerifyTotpCode(secret, code)`、`GenerateHotpCode(secret, counter)` / `VerifyHotpCode(...)`、`List GenerateRecoveryCodes(int count = 10)` | | `OtpOptions` | TOTP 参数(配置节 `XiHan:Authentication:Otp`) | | `TwoFactorSetupResult` | `Secret`、`QrCodeUri`、`RecoveryCodes`、`ManualEntryKey` | | `IOneTimeCodeService` / `DistributedOneTimeCodeService` | `Task IssueAsync(purpose, target, payload?, OneTimeCodeOptions?, ct)`、`Task TryConsumeAsync(purpose, target, code, ct)` | | `OneTimeCodeOptions` | `CodeLength`(默认 6,允许 4–10)、`ExpiresInSeconds`(默认 600) | | `OneTimeCodeIssueResult` / `OneTimeCodeConsumeResult` | `record`:`(Code, ExpiresInSeconds)` / `(Succeeded, Payload)` | ### 密码认证编排 | 类型 | 说明 | | --- | --- | | `IAuthenticationService` / `DefaultAuthenticationService` | `AuthenticateAsync`、`ValidatePasswordStrengthAsync`、`ChangePasswordAsync`、`ResetPasswordAsync`、`EnableTwoFactorAuthenticationAsync`、`VerifyTwoFactorCodeAsync`、`DisableTwoFactorAuthenticationAsync`、`GenerateRecoveryCodesAsync`、`VerifyRecoveryCodeAsync`、`RecordFailedLoginAttemptAsync`、`ResetFailedLoginAttemptsAsync`、`IsAccountLockedAsync` | | `AuthenticationResult` | `Succeeded`、`UserId`、`Username`、`TokenResult`、`RequiresTwoFactor`、`IsLockedOut`、`LockoutEnd`、`ErrorMessage`;工厂 `Success` / `Failure` / `RequiresTwoFactorAuthentication` / `LockedOut` | | `IUserStore` / `DefaultUserStore` | 用户读写、失败次数与锁定时间读写(默认内存实现,生产替换为数据库实现) | | `UserInfo` | 用户数据载体:`UserId`、`Username`、`PasswordHash`、`Email`、`PhoneNumber`、`TwoFactorEnabled` / `TwoFactorSecret`、`RecoveryCodes`、`IsLocked` / `LockoutEnd` / `FailedLoginAttempts`、`LastLoginTime`、`PasswordChangedTime`、`IsActive` | ## 配置 **`JwtOptions`(节 `XiHan:Authentication:Jwt`)** | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `SecretKey` | `string` | `""` | 对称签名密钥(HMAC-SHA256,务必配置足够长度) | | `Issuer` / `Audience` | `string` | `""` | 发行者 / 受众 | | `AccessTokenExpirationMinutes` | `int` | `60` | 访问令牌有效期(分钟) | | `RefreshTokenExpirationDays` | `int` | `7` | 刷新令牌有效期(天) | | `ValidateIssuer` / `ValidateAudience` / `ValidateLifetime` | `bool` | `true` | 校验开关 | | `ClockSkewMinutes` | `int` | `5` | 允许时钟偏移(分钟) | **`OtpOptions`(节 `XiHan:Authentication:Otp`)** | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `SecretKeyLength` | `int` | `32` | 密钥字节长度 | | `Digits` | `int` | `6` | 动态码位数 | | `TimeStep` | `int` | `30` | 时间步长(秒) | | `AllowedSkew` | `int` | `1` | 允许前后偏移窗口数 | | `EnableRecoveryCodes` | `bool` | `true` | 是否启用恢复码 | | `RecoveryCodesCount` | `int` | `10` | 恢复码数量 | **`OAuthOptions`(节 `XiHan:Authentication:OAuth`)** ```json { "XiHan": { "Authentication": { "Jwt": { "SecretKey": "请替换为足够长的随机密钥", "Issuer": "XiHan", "Audience": "XiHanClient", "AccessTokenExpirationMinutes": 60, "RefreshTokenExpirationDays": 7 }, "OAuth": { "Enabled": true, "FrontendCallbackUrl": "https://app.example.com/oauth/callback", "Providers": [ { "Name": "github", "DisplayName": "GitHub", "Enabled": true, "ClientId": "xxx", "ClientSecret": "yyy", "Scopes": ["read:user"] } ] } } } } ``` > 提供商类型取自 `Provider`,留空时回退到 `Name`(大小写与首尾空白不敏感),未知类型跳过不注册;`Name` 始终作为 AuthenticationScheme 名。各 provider 会把自家头像字段(Google `picture`、GitHub / Gitee `avatar_url`、QQ `figureurl_qq_2`、微信 `headimgurl`、企业微信 `avatar`、飞书 `avatar_url`、钉钉 `avatarUrl`)映射到统一的 `urn:xihan:avatar` Claim,回调端只读这一个。 **微信 / 企业微信 / 飞书 / 钉钉** ```json "Providers": [ { "Name": "wechat-qr", "Provider": "wechat", "Mode": "QrCode", "ClientId": "开放平台网站应用 AppId", "ClientSecret": "..." }, { "Name": "wechat-mp", "Provider": "wechat", "Mode": "Account", "ClientId": "公众号 AppId", "ClientSecret": "..." }, { "Name": "wecom", "Provider": "wecom", "Mode": "QrCode", "ClientId": "CorpId", "ClientSecret": "应用 Secret", "AgentId": "1000002" }, { "Name": "feishu", "Provider": "feishu", "ClientId": "cli_xxx", "ClientSecret": "..." }, { "Name": "dingtalk", "Provider": "dingtalk", "Mode": "QrCode", "ClientId": "AppKey", "ClientSecret": "AppSecret", "CorpId": "可选" } ] ``` 各提供商在两种登录方式下的授权页: | 提供商 | `QrCode` | `Account` | | --- | --- | --- | | 微信 | `open.weixin.qq.com/connect/qrconnect`,`scope=snsapi_login` | `open.weixin.qq.com/connect/oauth2/authorize`,`scope=snsapi_userinfo`,带 `#wechat_redirect` | | 企业微信 | `login.work.weixin.qq.com/wwlogin/sso/login`,带 `login_type=CorpApp` 与 `agentid` | `open.weixin.qq.com/connect/oauth2/authorize`,`scope=snsapi_privateinfo`,带 `agentid` 与 `#wechat_redirect` | | 钉钉 | `login.dingtalk.com/oauth2/challenge.htm` | `login.dingtalk.com/oauth2/auth` | | 飞书 | `passport.feishu.cn/suite/passport/oauth/authorize`(不分 `Mode`) | 同左 | > 微信与企业微信的 `Scopes` 是**覆盖**语义——两种登录方式的 scope 互斥,不能在按登录方式推导出的范围上追加;其余提供商的 `Scopes` 是**追加**。`AuthorizationEndpoint` 可直接覆盖上表推导出的地址。 ## 使用示例 签发访问令牌: ```csharp public class LoginService(IJwtTokenService jwt) { public JwtTokenResult IssueToken(long userId, string userName) { var claims = new List { new(XiHanClaimTypes.UserId, userId.ToString()), new(XiHanClaimTypes.UserName, userName), }; return jwt.GenerateAccessToken(claims); // 内含 AccessToken + RefreshToken } public JwtTokenResult? Refresh(string accessToken, string refreshToken) => jwt.RefreshAccessToken(accessToken, refreshToken); // 失败返回 null } ``` 签发并消费一次性验证码(邮箱登录码): ```csharp public class EmailLoginService(IOneTimeCodeService otc /*, 你的邮件发送器 */) { public async Task SendAsync(long tenantId, string email) { var result = await otc.IssueAsync("auth:email-login", $"{tenantId}:{email}"); // 通过邮件通道下发 result.Code,有效 result.ExpiresInSeconds 秒 } public async Task VerifyAsync(long tenantId, string email, string code) { var r = await otc.TryConsumeAsync("auth:email-login", $"{tenantId}:{email}", code); return r.Succeeded; // 无论成败,该码此刻已销毁 } } ``` 启用 TOTP 两步验证: ```csharp public class MfaService(IOtpService otp) { public TwoFactorSetupResult Setup(string account) { var secret = otp.GenerateTotpSecret(); return new TwoFactorSetupResult { Secret = secret, ManualEntryKey = secret, QrCodeUri = otp.GenerateTotpUri(secret, "XiHan", account), // otpauth://... RecoveryCodes = otp.GenerateRecoveryCodes(10), }; } } ``` ## 扩展点 / 自定义 - **刷新令牌存储**:`DefaultRefreshTokenStore` 是有界进程内默认实现,可在不接 Redis 时直接使用;框架不提供 Redis 版本,多实例共享或数据库审计由应用层实现 `IRefreshTokenStore` 后覆盖。 - **用户存储**:`DefaultUserStore` 仅供开发/测试;生产必须实现 `IUserStore`(读写真实用户表、失败次数、锁定时间)覆盖它,否则 `IAuthenticationService` 无真实数据可依。 - **外部登录持久化**:`IExternalLoginStore` 需业务层实现,把 `(provider, providerKey)` 映射到内部用户并记录绑定。 - **新增 OAuth 提供商**:内置分支覆盖 google/github/gitee/qq/weixin/workweixin/feishu/dingtalk。接入其它家按偏离程度递增:只有端点与声明映射不同 → 只写一个继承 `XiHanOAuthProviderOptions` 的 Options,直接搭 `XiHanOAuthHandler`;要多调一次接口补声明 → 覆写 `AfterClaimActionsAsync`;令牌或用户信息形态不同 → 覆写 `ExchangeCodeAsync` / `CreateTicketAsync`。最后在 `RegisterProvider` 加一个 `case`。 - **改写某一家的授权地址构造**:覆写 `BuildChallengeUrl`,参考 `WeixinAuthenticationHandler`(锚点 + state 搬运)与 `WorkWeixinAuthenticationHandler`(两种登录方式两套参数)。 - **远端失败的落地方式**:授权码无效等错误由 `RemoteAuthenticationHandler` 抛出,需要跳错误页时在对应 provider 的 `Events.OnRemoteFailure` 里处理。 ## 注意事项与最佳实践 - **无向后兼容**:JWT/验证码格式为前向单一格式,异常输入一律 fail-closed(`ValidateToken`/`TryConsumeAsync` 返回 null/false 而非抛出)。 - **一次性验证码是“消费即销毁”**:`TryConsumeAsync` 读取后立即从缓存删除,校验失败也无法用同一枚码重试——UI 侧应引导重新发码。 - **多实例部署一次性验证码依赖分布式缓存**:宿主未接 Redis 时退化为进程内存,重启丢码、跨实例不共享;生产接 Redis。 - **`SecretKey` 必须配置且足够长**:为空或过短会导致签名不安全或运行期异常。 - **密码相关 Options 归属**:`PasswordHasherOptions` / `PasswordPolicyOptions` 类型在 [Security](./security),但配置绑定在本包完成,节名同为 `XiHan:Authentication:*`。 - **微信两种登录方式是两个应用**:扫码属于开放平台网站应用、账号授权属于公众号,`ClientId` / `ClientSecret` 不通用,必须写成两条配置。 - **企业微信要填 `AgentId`**:扫码页与应用内授权页都要带。资料走 `auth/getuserdetail`(凭 `user_ticket`,需 `snsapi_privateinfo`);姓名不在其中,要姓名得开 `LoadMemberProfile` 走 `cgi-bin/user/get`,读不到时姓名为空但**不影响登录**。 - **`Scopes` 默认是追加语义**:在提供商默认值之外追加,所以配置里只写增量(如 Gitee 只写 `user_info` 不会把默认的 `emails` 挤掉)。微信与企业微信例外:两种登录方式的 scope 互斥,配置非空时整体替换。 - **账号授权时微信授权地址里 `state=_oauthstate` 是正常的**:微信限制 `state` 长度,框架把真实状态挪进回调地址的 `_oauthstate` 参数,回调时还原。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.Security](./security)(密码哈希、当前用户、`XiHanClaimTypes`) - 第三方核心:**Microsoft.AspNetCore.Authentication.JwtBearer** `10.0.11`(唯一的 NuGet 依赖) - 其余 ASP.NET Core 能力(`OAuthHandler`、`IDistributedCache` 等)来自 `FrameworkReference Microsoft.AspNetCore.App`,因此本包只能被 ASP.NET Core 应用引用 - 八家 OAuth 提供商全部自研,不引入任何 `AspNet.Security.OAuth.*` 或 `Microsoft.AspNetCore.Authentication.Google` 包 ## 相关模块 - [XiHan.Framework.Authorization](./authorization)(消费令牌声明做授权判定) - [XiHan.Framework.Security](./security) --- 来源:https://framework.docs.xihanfun.com/packages/authorization # XiHan.Framework.Authorization > 授权:RBAC 角色权限、Policy 策略、ABAC 属性访问控制,并接入 ASP.NET Core 授权管道(实时校验) - **NuGet**:`XiHan.Framework.Authorization` - **模块类**:`XiHanAuthorizationModule`(`DependsOn` `XiHanAuthenticationModule`) - **所在层**:基础设施层 - **关键依赖**:`FrameworkReference` **Microsoft.AspNetCore.App**(ASP.NET Core 授权管道)+ 框架内部依赖 Core / Authentication ## 概述 这个包负责**授权**——判定“你能做什么”。它把三种主流授权模型整合到一起:**RBAC**(基于角色的权限)、**Policy**(策略)与 **ABAC**(基于属性的访问控制),并接入 ASP.NET Core 的授权管道,让你用一个 `[PermissionAuthorize(...)]` 特性就能触发“权限码 + 属性约束”的混合判定。 > 认证(Authentication)= 你是谁;授权(Authorization)= 你能做什么。本包处理后者,建立在 [Authentication](./authentication) 颁发的身份声明之上。 关键设计取向是**实时校验**:授权处理器不信任 JWT 里冻结的权限声明,而是每次都走 `IPermissionChecker` 查当前授权快照,确保授权/撤销、用户禁用、会话注销即时生效。 ## 何时使用 - 需要基于角色 / 权限码的访问控制(RBAC) - 需要在权限之上叠加属性约束(同租户、仅本人、时间、IP、资源属性等 ABAC) - 需要把权限判定接进 ASP.NET Core,用特性声明式地保护 Controller/Action - 需要“实时”授权:权限授予/回收、用户禁用即时生效,而非等令牌过期 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Authorization ``` ```csharp [DependsOn(typeof(XiHanAuthorizationModule))] public class MyModule : XiHanModule { } ``` 模块 `ConfigureServices` 调用 `AddXiHanAuthorization(config)`,用 `TryAdd` 注册整套存储 / 评估器 / 服务,以及 ASP.NET Core 集成件: - 存储:`IRoleStore` → `DefaultRoleStore`、`IPermissionStore` → `DefaultPermissionStore`、`IPolicyStore` → `DefaultPolicyStore`(均 Scoped) - 评估:`IPermissionChecker` → `DefaultPermissionChecker`、`IPolicyEvaluator` → `DefaultPolicyEvaluator`、`IAbacAttributeCollector` → `DefaultAbacAttributeCollector`、`IAbacEvaluator` → `DefaultAbacEvaluator`(均 Scoped) - 门面:`IAuthorizationService` → `DefaultAuthorizationService`(Scoped) - ASP.NET Core:`IAuthorizationPolicyProvider` → `HybridPermissionPolicyProvider`(Singleton)、`IAuthorizationHandler` += `HybridPermissionAuthorizationHandler`(Scoped,`TryAddEnumerable`) ## 工作原理 **混合策略名(约定核心)**:特性把“权限码 + ABAC 策略码”编码进一个 ASP.NET Core 策略名,格式: ```text xihan.hybrid:p={URL编码的权限码};a={URL编码的ABAC策略码} ``` `HybridPermissionPolicyProvider` 拦截以 `xihan.hybrid:` 开头的策略名,解析出两个编码后动态构建一条含 `HybridPermissionRequirement` 的策略(其余策略名回落到 `DefaultAuthorizationPolicyProvider`)。因此**无需预注册命名策略**,任意权限码/ABAC 组合即取即用。 **判定顺序(`HybridPermissionAuthorizationHandler`)**: 1. 从主体声明解析 userId(`NameIdentifier` / `sub` / `userid` / `user_id`);解析不到直接不通过。 2. 若有权限码 → `IPermissionChecker.IsGrantedAsync` 实时校验(超管的通配 `*` 也由检查器的快照负责);不通过则终止。 3. 若无 ABAC 策略码 → 直接 `Succeed`。 4. 若有 ABAC 策略码 → `IAbacAttributeCollector` 收集主体/资源/环境属性 → `IAbacEvaluator.EvaluateAsync` 评估 → 允许才 `Succeed`。 **RBAC 权限来源(`DefaultPermissionChecker`)**:`IsGrantedAsync` 先查用户直接权限,再遍历用户的启用角色查角色权限,任一命中(且 `IsEnabled`)即通过。`GetGrantedPermissionsAsync` 合并去重用户+角色权限。真实权限数据由 `IPermissionStore` / `IRoleStore` 提供(默认实现为线程安全的纯内存实现,进程重启即丢失且不预置任何数据,生产需业务层替换为读库实现)。 ## 核心能力 - **RBAC** `IRoleStore` + `RoleDefinition`:角色定义与用户-角色关系;`IAuthorizationService` 提供加入/移除角色、查询用户角色 - **权限检查** `IPermissionChecker`:`IsGrantedAsync`(单个)、`IsAnyGrantedAsync`(任一)、`IsAllGrantedAsync`(全部)、`GetGrantedPermissionsAsync`(列全部)、`PermissionExistsAsync` - **Policy 策略** `IPolicyEvaluator` + `PolicyDefinition`:可组合所需角色(任一)、权限(全部)、声明键值、自定义 `IAuthorizationRequirement`;支持 `EvaluateAll` / `EvaluateAny` - **ABAC 属性控制** `IAbacEvaluator` + `IAbacAttributeCollector`:评估上下文含主体 / 资源 / 环境三类属性(`AbacAttributeSet`);默认评估器内置策略词汇(见下) - **ASP.NET Core 集成**:`PermissionAuthorizeAttribute` / `AbacAuthorizeAttribute` + `HybridPermissionPolicyProvider` + `HybridPermissionAuthorizationHandler`,用标准 `[Authorize]` 管道跑混合判定 - **实时授权**:默认走 `IPermissionChecker` 实时校验,不信任 token 里冻结的权限声明;超管通配由检查器快照承载 ## 主要 API / 类型 ### 门面与结果 | 类型 | 说明 | | --- | --- | | `IAuthorizationService` / `DefaultAuthorizationService` | 授权总入口:`AuthorizeAsync`(权限)、`AuthorizePolicyAsync`(策略+资源)、`AuthorizeRoleAsync`、`AuthorizeAnyAsync` / `AuthorizeAllAsync`、`GetUserPermissionsAsync` / `GetUserRolesAsync`、`GrantPermissionAsync` / `RevokePermissionAsync`、`AddUserToRoleAsync` / `RemoveUserFromRoleAsync`;均返回 `AuthorizationResult` | | `AuthorizationResult` | `Succeeded`、`FailureReason`、`FailedRequirements`、`AdditionalData`;工厂 `Success()` / `Failure(...)` / `PermissionDenied(name)` / `RoleDenied(name)` | ### RBAC | 类型 | 关键方法 / 字段 | | --- | --- | | `IPermissionChecker` | `Task IsGrantedAsync(userId, permissionName, ct)`、`IsAnyGrantedAsync(userId, List, ct)`、`IsAllGrantedAsync(...)`、`Task> GetGrantedPermissionsAsync(userId, ct)`、`Task PermissionExistsAsync(name, ct)` | | `IPermissionStore` | 用户/角色权限读取、授予/撤销、`GetAllPermissionsAsync`、`GetPermissionByNameAsync` | | `PermissionDefinition` | `Name`(唯一)、`DisplayName`、`Description`、`ParentName`、`Tag`、`IsEnabled`、`Order`、`Properties` | | `IRoleStore` | `GetUserRolesAsync`、`IsInRoleAsync`、`AddUserToRoleAsync` / `RemoveUserFromRoleAsync`、`GetAllRolesAsync`、`GetRoleByName/IdAsync`、`Create/Update/DeleteRoleAsync`、`GetUsersInRoleAsync` | | `RoleDefinition` | `Id`、`Name`(唯一)、`DisplayName`、`Description`、`IsEnabled`、`IsDefault`、`IsStatic`、`Order`、`CreatedTime` / `LastModifiedTime`、`Properties` | ### Policy | 类型 | 关键方法 / 字段 | | --- | --- | | `IPolicyEvaluator` | `Task EvaluateAsync(userId, policyName, resource?, ct)`、`EvaluateAllAsync(...)`、`EvaluateAnyAsync(...)` | | `PolicyDefinition` | `Name`、`RequiredRoles`(任一)、`RequiredPermissions`(全部)、`RequiredClaims`(键值)、`CustomRequirements`(`List`)、`IsEnabled` | | `IAuthorizationRequirement` | 自定义要求:`string Name { get; }`、`Task EvaluateAsync(AuthorizationContext)` | | `AuthorizationContext` | `UserId`、`UserRoles`、`UserPermissions`、`UserClaims`、`Resource`、`PolicyName`、`AdditionalData` | > 注意:本包自有的 `IAuthorizationRequirement`(`XiHan.Framework.Authorization.Policies`)是 Policy 子系统的自定义要求接口,**不同于** ASP.NET Core 的同名接口 `Microsoft.AspNetCore.Authorization.IAuthorizationRequirement`(后者用于 `HybridPermissionRequirement`)。 ### ABAC | 类型 | 关键方法 / 字段 | | --- | --- | | `IAbacEvaluator` / `DefaultAbacEvaluator` | `Task EvaluateAsync(AbacEvaluationContext, ct)` | | `IAbacAttributeCollector` / `DefaultAbacAttributeCollector` | `Task CollectAsync(ClaimsPrincipal, resource, permissionCode, policyCode, ct)` | | `AbacAttributeSet` | `SubjectAttributes` / `ResourceAttributes` / `EnvironmentAttributes`(三个 `Dictionary`,键不区分大小写) | | `AbacEvaluationContext` | `UserId`、`PermissionCode`、`PolicyCode`、`Resource`、三类只读属性字典、`EvaluationTime` | | `AbacEvaluationResult` | `IsAllowed`、`Reason`;工厂 `Allow(reason?)` / `Deny(reason?)` | ### ASP.NET Core 集成 | 类型 | 说明 | | --- | --- | | `PermissionAuthorizeAttribute` | `[PermissionAuthorize("权限码")]` 或 `[PermissionAuthorize("权限码", "abac策略码")]`;构造时即算出 `Policy = xihan.hybrid:p=...;a=...` | | `AbacAuthorizeAttribute` | `[AbacAuthorize("abac策略码")]`——仅 ABAC,不校验 RBAC 权限码 | | `HybridPermissionRequirement` | `PermissionCode`、`AbacPolicyCode`(实现 ASP.NET Core `IAuthorizationRequirement`) | | `HybridPermissionPolicyProvider` | 拦截 `xihan.hybrid:` 前缀策略名,动态构建策略;其余回落默认提供器 | | `HybridPermissionAuthorizationHandler` | 混合判定处理器(先权限码实时校验,再 ABAC 评估) | ## 默认 ABAC 策略词汇 `DefaultAbacEvaluator` 内置一套开箱即用的策略码解析(`policyCode` 大小写不敏感): | 策略码 | 判定 | | --- | --- | | 空 | 允许(未配置 ABAC 策略) | | `allow` | 无条件允许 | | `same_tenant` / `tenant_match` | 主体 `tenant_id` 与资源的 `tenant_id` / `route.tenant_id` / `query.tenant_id` 任一相等 | | `self_only` / `owner_match` | 主体 `user_id` 与资源的 `user_id` / `owner_user_id` / `route.user_id` / `query.user_id` 任一相等 | | 比较表达式 | 形如 `subject.x == resource.y`、`environment.utc_hour != "0"`;操作符 `==` / `!=` / `=`,操作数支持 `subject.` / `resource.` / `environment.` 前缀取值或字面量(字符串/布尔/数字) | | 其它 | 拒绝(不支持的策略) | `DefaultAbacAttributeCollector` 收集的属性包括:主体 `user_id`/`tenant_id`/`roles`/`is_authenticated` 及所有 `claim.*`;资源 `resource_type` 与反射得到的公共属性,若资源能解析出 `HttpContext` 还含 `route.*`/`query.*`/`http.method`/`http.path`/`http.client_ip`;环境 `permission_code`/`policy_code`/`utc_now`/`utc_hour`/`day_of_week`(有 `HttpContext` 时另含 `client_ip`/`request_path`/`request_method`/`user_agent`)。 ## 使用示例 在服务里检查权限: ```csharp public class DocumentService(IPermissionChecker permissions) { public async Task DeleteAsync(long userId, long docId) { if (!await permissions.IsGrantedAsync(userId.ToString(), "document:delete")) throw new UnauthorizedAccessException(); // ... } } ``` 用特性声明式保护 Controller/Action(无需预注册命名策略): ```csharp // 仅权限码 [PermissionAuthorize("document:delete")] public IActionResult Delete(long id) => ...; // 权限码 + ABAC:既要有权限,又要同租户 [PermissionAuthorize("document:read", "same_tenant")] public IActionResult Get(long id) => ...; // 仅 ABAC:只能操作本人资源 [AbacAuthorize("self_only")] public IActionResult UpdateProfile(long userId) => ...; ``` 用策略门面判定(可带资源对象供 ABAC/Policy 使用): ```csharp public class ReportService(IAuthorizationService authz) { public async Task CanExportAsync(string userId, ReportEntity report) { var r = await authz.AuthorizePolicyAsync(userId, "report:export-policy", report); return r.Succeeded; } } ``` > `XiHan.BasicApp` 在此基础上落地了完整的权限码 `resource:action:scope`、数据范围与字段级脱敏(FLS),见 [BasicApp 权限模型](https://basicapp.docs.xihanfun.com/backend/permission)。 ## 扩展点 / 自定义 - **权限 / 角色 / 策略存储**:默认实现(`DefaultPermissionStore` / `DefaultRoleStore` / `DefaultPolicyStore`)基于 `ConcurrentDictionary` 的线程安全内存实现,可正常增删查(含各自的 `ClearAsync` 及 `AddRolesAsync` / `AddPermissionsAsync` / `AddPoliciesAsync` 等批量播种方法,便于开发/测试播种数据),但不持久化、不预置数据;生产必须实现 `IPermissionStore` / `IRoleStore` / `IPolicyStore` 读真实库,DI 覆盖(扩展方法用 `TryAddScoped`,先注册即生效)。 - **超管通配 `*`**:由你实现的 `IPermissionChecker` / `IPermissionStore` 快照负责(如超管权限集含 `*`),本包不硬编码超管身份。 - **自定义 ABAC 逻辑**:实现 `IAbacEvaluator` 覆盖 `DefaultAbacEvaluator`,接你自己的策略引擎(如 OPA/规则 DSL);或实现 `IAbacAttributeCollector` 补充领域特有的资源属性。 - **自定义 Policy 要求**:实现 `IAuthorizationRequirement`(Policies 版)放进 `PolicyDefinition.CustomRequirements`。 ## 注意事项与最佳实践 - **实时校验优先,不信任冻结声明**:`HybridPermissionAuthorizationHandler` 一律以 `IPermissionChecker` 为准,故授权/撤销、禁用、注销即时生效——不要退回到只读 token 里的权限声明。 - **策略名有 URL 编码**:权限码/ABAC 码里含 `;`、`=`、空格等会被 `Uri.EscapeDataString` 编码进策略名;直接手写策略名时须遵循 `xihan.hybrid:p=...;a=...` 格式(一般用特性即可,无需手写)。 - **ABAC 需要资源对象**:`same_tenant`/`self_only`/比较表达式依赖资源属性;Controller 上要让 ABAC 拿到 `HttpContext`(`route.*`/`query.*`)或显式把资源对象作为授权 `resource` 传入。 - **两个同名 `IAuthorizationRequirement`**:Policy 子系统的自定义要求 vs ASP.NET Core 的标记接口,命名空间不同、用途不同,勿混用。 - **默认存储不可用于生产**:默认内存存储不持久化、不预置数据;若不接真实 `IPermissionStore`/`IRoleStore` 且从未通过其 API 授予过数据,所有权限判定都会失败。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.Authentication](./authentication)(并传递依赖 [Security](./security)) - 框架引用:**Microsoft.AspNetCore.App**(ASP.NET Core 授权管道) ## 相关模块 - [XiHan.Framework.Authentication](./authentication) - [XiHan.Framework.Security](./security) - [BasicApp 权限模型](https://basicapp.docs.xihanfun.com/backend/permission)(权限码 / 数据范围 / 字段脱敏的应用层落地) --- 来源:https://framework.docs.xihanfun.com/packages/bot-dingtalk # XiHan.Framework.Bot.DingTalk > Bot 钉钉通道:钉钉群「自定义机器人」Webhook 提供者,纯 HTTP、无第三方 SDK。 - **NuGet**:`XiHan.Framework.Bot.DingTalk` - **模块类**:`XiHanBotDingTalkModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Bot`);**无第三方 SDK**,直接走 HTTP。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**钉钉通道子包**。它实现 `IBotProvider`(名为 `DingTalk`),通过钉钉群「自定义机器人」的 Webhook(`https://oapi.dingtalk.com/robot/send`)发送消息,把钉钉封装为 Bot 内核可调度的一个提供者。无第三方 SDK,用框架 `XiHan.Framework.Http` 的 `.AsHttp()` 直接 POST。 支持钉钉自定义机器人的三种安全设置:**加签**(`Secret`,HMAC-SHA256)、**安全关键字**(`KeyWord`,自动前缀)、**IP 白名单**(由钉钉侧配置)。连接配置由 `IDingTalkConfigStore` 提供,默认从 `IOptionsMonitor` 读取,应用层可覆盖为数据库来源。 ## 何时使用 - 需要向钉钉群推送通知(文本 / Markdown / ActionCard / FeedCard / Link)。 - 想让钉钉与其它 IM、邮件、短信通道走同一套发送策略与管道。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.DingTalk ``` ```csharp [DependsOn(typeof(XiHanBotDingTalkModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => bot.UseDingTalk(o => { o.AccessToken = "xxxxxxxx"; // 机器人 Webhook 的 access_token o.Secret = "SECxxxxxxxx"; // 加签密钥(启用加签时) o.KeyWord = "[告警]"; // 安全关键字(启用关键字时) })); } } ``` `XiHanBotDingTalkModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` 调用 `AddXiHanBotDingTalk()`。`AddXiHanBotDingTalk(this IServiceCollection, Action? configure = null)` 与 `UseDingTalk(this BotBuilder, Action configure)` 均注册: - `IDingTalkConfigStore` → `DefaultDingTalkConfigStore`(`TryAddSingleton`); - `IBotProvider` → `DingTalkBotProvider`(`TryAddEnumerable` Singleton)。 `UseDingTalk` 的 `configure` 必填;`AddXiHanBotDingTalk` 的可选。 ## 工作原理 `DingTalkBot` 构造时拼出请求 URL 与关键字前缀: ``` _url = WebHookUrl + "?access_token=" + AccessToken _keyWord = KeyWord == null ? null : KeyWord + "\n" // 自动前缀到标题/正文 ``` 发送时(`Send`)**无条件**计算签名并追加到 URL:`timestamp = 毫秒时间戳`,`sign = UrlEncode(HmacSha256(Secret, timestamp + "\n" + Secret))`,`url` 都会追加 `×tamp={ts}&sign={sign}`——即使未配置 `Secret`(此时以空字符串作密钥参与运算)也照样带上这两个查询参数;钉钉服务端是否据此校验,取决于该机器人是否启用了「加签」安全设置。然后 `url.AsHttp().SetJsonBody(payload).PostAsync()`,按 `ErrCode == 0 || ErrMsg == "ok"` 判成功。 `DingTalkBotProvider.SendAsync` 按 `message.Type` 路由:`Markdown` 走 `MarkdownMessage`;`Link` 从 `message.Data` 按 `DingTalkMessageDataKeys.DingTalkLink` 取出 `DingTalkLink` 走 `LinkMessage`;`Card` 依次尝试从 `Data` 取 `DingTalkActionCard`(键 `DingTalkMessageDataKeys.DingTalkActionCard`)走 `ActionCardMessage`,取不到再尝试 `DingTalkFeedCard`(键 `DingTalkMessageDataKeys.DingTalkFeedCard`)走 `FeedCardMessage`;以上分支若对应 `Data` 取不到值,或消息类型不属于以上三种,统一跌落到默认分支发送纯文本 `TextMessage`。@ 提及由 `message.Mentions` 构建 `DingTalkAt`(含 `"@all"` → `IsAtAll`)。 ## 核心能力 - **群机器人发送**:`DingTalkBot` 提供 `TextMessage` / `MarkdownMessage` / `LinkMessage` / `ActionCardMessage` / `FeedCardMessage`,均返回 `Task`。 - **三种安全设置**:加签(`Secret`)、关键字(`KeyWord`)、IP 白名单(钉钉侧)。 - **@ 提及**:`DingTalkAt` 支持 `AtMobiles` / `AtUserIds` / `IsAtAll`。 - **可插拔配置**:`IDingTalkConfigStore` 抽象配置来源。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `DingTalkBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.DingTalk`(`"DingTalk"`)。`Task SendAsync(BotMessage, BotContext)` | | `DingTalkBot` | Webhook 发送封装。构造 `DingTalkBot(DingTalkOptions)`;各消息类型方法 + 私有 `Send(object, CancellationToken)` | | `IDingTalkConfigStore` | `Task GetAsync(CancellationToken cancellationToken = default)` | | `DefaultDingTalkConfigStore` | 默认实现,包 `IOptionsMonitor`,`GetAsync` 返回 `CurrentValue` | | `DingTalkOptions` | 钉钉选项(见配置节) | | `DingTalkText` / `DingTalkMarkdown` / `DingTalkLink` / `DingTalkActionCard` / `DingTalkFeedCard` / `DingTalkAt` | 各消息体模型(`JsonPropertyName` 映射钉钉字段) | | `DingTalkMessageDataKeys` | 消息 `Data` 键常量(见下表) | | `DingTalkResultInfoDto` | 响应 DTO:`ErrCode`(`errcode`)/ `ErrMsg`(`errmsg`) | | `DingTalkResultErrCodeEnum` | 钉钉错误码枚举(如 `AccessTokenNotExist = 400101`、`SendingSpeedTooFast = 410100`);成功以 `ErrCode == 0` 判定,无独立成功枚举值 | ## 配置 `DingTalkOptions` 字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用该提供者 | | `WebHookUrl` | `string` | `"https://oapi.dingtalk.com/robot/send"` | Webhook 地址(惰性初始化) | | `AccessToken` | `string` | `""` | 机器人 access_token(拼到 URL) | | `Secret` | `string` | `""` | 加签密钥(HMAC-SHA256;为空则不加签) | | `KeyWord` | `string?` | `null` | 安全关键字(非空时自动前缀 `KeyWord + "\n"` 到标题/正文) | `DingTalkMessageDataKeys` 常量(写入 `BotMessage.Data` 传富消息体): | 常量 | 值 | 含义 | | --- | --- | --- | | `DingTalkLink` | `"DingTalk.Link"` | Link 消息体(`DingTalkLink`) | | `DingTalkActionCard` | `"DingTalk.ActionCard"` | ActionCard 消息体 | | `DingTalkFeedCard` | `"DingTalk.FeedCard"` | FeedCard 消息体 | ## 使用示例 ```csharp // Markdown 告警,@ 指定手机号 var msg = new BotMessage { Title = "服务告警", Content = "### node-01 CPU 95%\n> 持续 5 分钟", Type = BotMessageType.Markdown, Mentions = { "13800000000" } // "@all" 则 @ 全员 }; await bot.SendAsync(msg, new[] { BotProviderNames.DingTalk }); // ActionCard(带跳转):把卡片体放进 Data var card = new BotMessage { Type = BotMessageType.Card }; card.Data[DingTalkMessageDataKeys.DingTalkActionCard] = new DingTalkActionCard { Title = "发布确认", Text = "点击查看详情", SingleTitle = "查看", SingleUrl = "https://example.com/release" }; await bot.SendAsync(card, new[] { BotProviderNames.DingTalk }); ``` ## 扩展点 / 自定义 - **自定义配置源**:实现 `IDingTalkConfigStore`(如从数据库读多机器人配置),在启用之前 `services.AddSingleton()` 覆盖默认(`TryAddSingleton`,先注册者生效)。 ## 注意事项与最佳实践 - **加签与关键字可二选一或并用**:与钉钉机器人安全设置一致;`KeyWord` 非空则自动前缀(注意别让关键字被 Markdown 语法吞掉)。`timestamp`/`sign` 签名参数每次请求都会计算并追加到 URL,未配置 `Secret` 时以空字符串参与运算,钉钉服务端是否校验取决于该机器人是否启用了「加签」。 - **未配置 / 未启用返回 `BadRequest`**:`GetAsync` 返回 `null`、`Enabled=false` 或 `AccessToken` 为空时不发送。 - **限流**:钉钉自定义机器人有每分钟条数限制(错误码 `410100`),高频场景配合内核 `RateLimitPipeline` 使用。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - 无第三方 SDK,经 `XiHan.Framework.Http` 直接 HTTP 调用钉钉开放接口。 ## 相关模块 - [XiHan.Framework.Bot.Lark](./bot-lark) · [XiHan.Framework.Bot.WeCom](./bot-wecom) - [XiHan.Framework.Bot.Email](./bot-email) · [XiHan.Framework.Bot.Sms](./bot-sms) · [XiHan.Framework.Bot.Telegram](./bot-telegram) --- 来源:https://framework.docs.xihanfun.com/packages/bot-email # XiHan.Framework.Bot.Email > Bot 邮件通道:基于 MailKit 的 SMTP 发件提供者,把邮件接入统一的 `IBotClient` 调度。 - **NuGet**:`XiHan.Framework.Bot.Email` - **模块类**:`XiHanBotEmailModule` - **所在层**:基础设施层 - **关键依赖**:`MailKit`(4.17.0,SMTP 发信);框架内部依赖 `XiHan.Framework.Bot`。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**邮件通道子包**。它实现了 `IBotProvider`(名为 `Email`),内部用 `MailKit.Net.Smtp.SmtpClient` + `MimeKit` 连接 SMTP 服务器发信,从而让邮件成为 Bot 内核可调度的一个提供者:调用方仍只面对 `IBotClient`,用广播/主备/优先级等统一策略把消息发到邮件(以及其它通道)。 发件配置(SMTP 主机/端口/账号密码/收件人)通过 `EmailOptions` 表达,并经 `IEmailConfigStore` 提供当前生效配置——默认从 `IOptionsMonitor` 读取,应用层可注册自定义 store(如从数据库读取)覆盖。 ## 何时使用 - 需要把「邮件」纳入 Bot 统一发送体系,与钉钉/飞书/短信等一起编排。 - 需要按运行时配置(而非硬编码 appsettings)动态切换 SMTP 账号——注册自定义 `IEmailConfigStore`。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.Email ``` ```csharp [DependsOn(typeof(XiHanBotEmailModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { // Bot 构建器链式启用(UseEmail 的 configure 必填) context.Services.AddXiHanBot(bot => bot.UseEmail(o => { o.From.SmtpHost = "smtp.example.com"; o.From.SmtpPort = 587; o.From.FromMail = "noreply@example.com"; o.From.FromPassword = "***"; o.To.Add("ops@example.com"); })); } } ``` `XiHanBotEmailModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` 调用 `AddXiHanBotEmail()`。注册内容: - `IEmailConfigStore` → `DefaultEmailConfigStore`(`TryAddSingleton`); - `IBotProvider` → `EmailBotProvider`(`TryAddEnumerable` Singleton,加入调度提供者列表)。 `UseEmail(this BotBuilder, Action configure)` 做同样的注册并 `Configure(configure)` 绑定选项(`configure` 必填)。`AddXiHanBotEmail(this IServiceCollection, Action? configure = null)` 的 `configure` 可选。 ## 核心能力 - **SMTP 发信**:`EmailBot.SendMail(EmailToModel, CancellationToken)` 用 MailKit 连接、(可选)认证、发送 `MimeMessage`,支持 HTML 正文、抄送/密送、附件(`EmailToModel.AttachmentsPath`)。 - **按条覆盖收件人 / 正文类型**:消息 `Data` 上写 `EmailMessageDataKeys` 键,可覆盖默认 `To/Cc/Bcc` 与 `IsBodyHtml`。 - **可插拔配置源**:`IEmailConfigStore` 抽象当前生效 `EmailOptions`。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `EmailBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.Email`(`"Email"`)。`Task SendAsync(BotMessage message, BotContext context)`:读配置 → 校验 → 组装 `EmailToModel` → `EmailBot.SendMail` | | `EmailBot` | SMTP 发信封装。构造 `EmailBot(EmailFromModel)`;`Task SendMail(EmailToModel, CancellationToken)` | | `IEmailConfigStore` | 配置源抽象:`Task GetAsync(CancellationToken cancellationToken = default)`(返回 `null` 视为未配置) | | `DefaultEmailConfigStore` | 默认实现,包 `IOptionsMonitor`,`GetAsync` 返回 `CurrentValue` | | `EmailOptions` | 邮件选项(见配置节) | | `EmailFromModel` | 发件人/SMTP 配置(见下表) | | `EmailToModel` | 单封邮件:`Subject`、`Body`、`IsBodyHtml`、`ToMail`/`CcMail`/`BccMail`(`List`)、`AttachmentsPath`(`List`) | | `EmailMessageDataKeys` | 消息 `Data` 键常量(见下表) | ## 配置 `EmailOptions` 字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用该提供者(`false` 或未配置时 `SendAsync` 返回 `BadRequest`) | | `From` | `EmailFromModel` | `new()` | 发件人/SMTP 服务器配置 | | `To` | `List` | `[]` | 默认收件人 | | `Cc` | `List` | `[]` | 默认抄送 | | `Bcc` | `List` | `[]` | 默认密送 | | `IsBodyHtml` | `bool` | `true` | 是否 HTML 正文 | `EmailFromModel` 字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `SmtpHost` | `string` | `""` | SMTP 服务器(必填) | | `SmtpPort` | `int` | `587` | 端口 | | `UseSsl` | `bool` | `true` | 是否 SSL | | `FromMail` | `string` | `""` | 发件邮箱(必填) | | `FromPassword` | `string` | `""` | 发件密码 | | `FromUserName` | `string` | `""` | SMTP 认证登录名(为空则不认证;多数服务商即发件邮箱) | | `FromName` | `string` | `""` | 发件人显示名(为空回退发件邮箱) | | `Coding` | `Encoding` | `Encoding.UTF8` | 内容编码 | | `AcceptInvalidCertificate` | `bool` | `false` | 是否接受无效/自签 TLS 证书(仅开发环境放开,生产务必 `false`) | `EmailMessageDataKeys` 常量(写入 `BotMessage.Data` 覆盖默认): | 常量 | 值 | 含义 | | --- | --- | --- | | `EmailTo` | `"Email.To"` | 覆盖收件人 | | `EmailCc` | `"Email.Cc"` | 覆盖抄送 | | `EmailBcc` | `"Email.Bcc"` | 覆盖密送 | | `EmailIsBodyHtml` | `"Email.IsBodyHtml"` | 覆盖是否 HTML 正文(`bool`) | ## 使用示例 ```csharp // 用默认收件人发一封 HTML 邮件 await bot.SendAsync(new BotMessage { Title = "夜间巡检报告", // → 邮件主题(为空时用 "Notification") Content = "

全部服务正常

", Type = BotMessageType.Text }, new[] { BotProviderNames.Email }); // 按条覆盖收件人与正文类型 var msg = new BotMessage { Title = "对账单", Content = "纯文本正文" }; msg.Data[EmailMessageDataKeys.EmailTo] = new List { "a@x.com", "b@x.com" }; msg.Data[EmailMessageDataKeys.EmailIsBodyHtml] = false; await bot.SendAsync(msg, new[] { BotProviderNames.Email }); ``` ## 扩展点 / 自定义 - **自定义配置源**:实现 `IEmailConfigStore`(如从数据库读 SMTP 账号),在 `AddXiHanBotEmail`/`UseEmail` 之前 `services.AddSingleton()` 覆盖默认(默认以 `TryAddSingleton` 注册,先注册者生效)。 ## 注意事项与最佳实践 - **收件人为空即失败**:`To/Cc/Bcc` 全空时 `SendAsync` 返回 `BadRequest`;主题取 `message.Title`,为空回退 `"Notification"`。 - **未配置 = 未启用**:`GetAsync` 返回 `null`、`Enabled=false`、或 `SmtpHost`/`FromMail` 为空,都会被判为未配置并返回 `BadRequest`(不抛异常,符合 fail-closed)。 - **认证可选**:`FromUserName` 为空时不做 SMTP 认证(适配允许匿名中继的内网 SMTP)。 - **证书校验默认严格**:`AcceptInvalidCertificate` 默认 `false`,生产勿放开。 - **发送异常不抛出、取消例外**:`EmailBot.SendMail` 内 SMTP 连接/认证/发送失败会被捕获并记录日志(`LogHelper.Error`),返回 `false`(由 `EmailBotProvider` 转为 `BotResult.Failed`),不会抛出异常;但调用方主动取消触发的 `OperationCanceledException` 会原样上抛,不会被吞成"发送失败"。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - `MailKit` 4.17.0(SMTP 发信) ## 相关模块 - [XiHan.Framework.Bot.Sms](./bot-sms) · [XiHan.Framework.Bot.Telegram](./bot-telegram) - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.Lark](./bot-lark) · [XiHan.Framework.Bot.WeCom](./bot-wecom) --- 来源:https://framework.docs.xihanfun.com/packages/bot-lark # XiHan.Framework.Bot.Lark > Bot 飞书通道:飞书群「自定义机器人」Webhook + 图片上传提供者,纯 HTTP、无第三方 SDK。 - **NuGet**:`XiHan.Framework.Bot.Lark` - **模块类**:`XiHanBotLarkModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Bot`);**无第三方 SDK**,直接走 HTTP。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**飞书(Lark)通道子包**。它实现 `IBotProvider`(名为 `Lark`),通过飞书群「自定义机器人」Webhook(`https://open.feishu.cn/open-apis/bot/v2/hook`)发送消息,并提供图片上传接口(`https://open.feishu.cn/open-apis/im/v1/images`)。无第三方 SDK,用框架 `.AsHttp()` 直接 POST。 支持飞书自定义机器人的**加签**(`Secret`,HMAC-SHA256)与**安全关键字**(`KeyWord`,自动前缀)。连接配置由 `ILarkConfigStore` 提供,默认从 `IOptionsMonitor` 读取,应用层可覆盖为数据库来源。 ## 何时使用 - 需要向飞书群推送通知(文本 / 富文本 Post / 卡片 InterActive / 图片)。 - 想让飞书与其它 IM、邮件、短信通道走同一套发送策略与管道。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.Lark ``` ```csharp [DependsOn(typeof(XiHanBotLarkModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => bot.UseLark(o => { o.AccessToken = "xxxxxxxx"; // 机器人 Webhook token(拼到 URL 末尾) o.Secret = "xxxxxxxx"; // 加签密钥(启用加签时) o.KeyWord = "[告警]"; // 安全关键字(启用关键字时) })); } } ``` `XiHanBotLarkModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` 调用 `AddXiHanBotLark()`。`AddXiHanBotLark(this IServiceCollection, Action? configure = null)` 与 `UseLark(this BotBuilder, Action configure)` 均注册: - `ILarkConfigStore` → `DefaultLarkConfigStore`(`TryAddSingleton`); - `IBotProvider` → `LarkBotProvider`(`TryAddEnumerable` Singleton)。 `UseLark` 的 `configure` 必填;`AddXiHanBotLark` 的可选。 ## 工作原理 `LarkBotProvider.SendAsync` 读配置、校验 `Enabled`/`AccessToken` 后实例化 `LarkBot`,按 `message.Type` 路由(`Card` → InterActive、`Image` → 图片、其余 → Post/Text)。`LarkBot` POST 到 `WebHookUrl + "/" + AccessToken`: - **加签(可选)**:`Secret` 非空时带 `timestamp`(Unix 秒)与 `sign = Base64(HmacSha256(timestamp + "\n" + secret, 空内容))`。 - **关键字(可选)**:`KeyWord` 非空时自动前缀(`KeyWord + "\n"`)到文本内容 / Post 标题 / 卡片标题。 - **成功判定**:`Code == 0 || Msg == "success"`。 ## 核心能力 - **群机器人发送**:`LarkBotProvider` 经 `LarkBot` 发送文本 / Post(富文本)/ InterActive(卡片)/ Image。 - **图片上传**:`LarkOptions.UploadUrl` 指向飞书图片上传接口(`im/v1/images`),换取 `image_key` 供图片消息使用。 - **加签与关键字**:`LarkOptions.Secret` / `KeyWord`。 - **可插拔配置**:`ILarkConfigStore` 抽象配置来源。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `LarkBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.Lark`(`"Lark"`)。`Task SendAsync(BotMessage, BotContext)` | | `LarkBot` | Webhook 发送封装(`.AsHttp()`;可选加签/关键字) | | `ILarkConfigStore` | `Task GetAsync(CancellationToken cancellationToken = default)` | | `DefaultLarkConfigStore` | 默认实现,包 `IOptionsMonitor`,`GetAsync` 返回 `CurrentValue` | | `LarkOptions` | 飞书选项(见配置节) | | `LarkText` / `LarkPost` / `LarkImage` / `LarkInterActive` | 各消息体模型;Post/卡片用 `IPostTag`(`TagText`/`TagA`/`TagAt`/`TagImg`)与 `IInterActiveTag`(`TagMarkdown`/`TagDiv`/`TagAction`/`TagButton` 等)组织 | | `LarkMessageDataKeys` | 消息 `Data` 键常量(见下表) | | `LarkResultInfoDto` | 响应 DTO:`Code`(`code`)/ `Msg`(`msg`)/ `Data`(`data`) | | `LarkResultErrCodeEnum` | 飞书错误码枚举(如 `SignMatchFail = 19021`、`KeyWordsNotFound = 19024`);成功以 `Code == 0` 或 `Msg == "success"` 判定 | ## 配置 `LarkOptions` 字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用该提供者 | | `WebHookUrl` | `string` | `"https://open.feishu.cn/open-apis/bot/v2/hook"` | Webhook 基址(惰性初始化;实际 POST 到 `WebHookUrl + "/" + AccessToken`) | | `UploadUrl` | `string` | `"https://open.feishu.cn/open-apis/im/v1/images"` | 图片上传接口(惰性初始化) | | `AccessToken` | `string` | `""` | 机器人 Webhook token | | `Secret` | `string` | `""` | 加签密钥(HMAC-SHA256;为空则不加签) | | `KeyWord` | `string?` | `null` | 安全关键字(非空时自动前缀到内容/标题) | `LarkMessageDataKeys` 常量(写入 `BotMessage.Data` 传富消息体): | 常量 | 值 | 含义 | | --- | --- | --- | | `LarkPost` | `"Lark.Post"` | 富文本 Post 消息体(`LarkPost`) | | `LarkInterActive` | `"Lark.InterActive"` | 卡片 InterActive 消息体 | | `LarkImage` | `"Lark.Image"` | 图片消息体(`LarkImage`,需先上传得到 `image_key`) | ## 使用示例 ```csharp // 纯文本告警 await bot.SendAsync(new BotMessage { Content = "构建失败:release-2026.7", Type = BotMessageType.Text }, new[] { BotProviderNames.Lark }); // 富文本 Post:把 Post 体放进 Data var post = new BotMessage { Type = BotMessageType.Markdown }; post.Data[LarkMessageDataKeys.LarkPost] = new LarkPost { Title = "发布公告", Content = new() { new() { new TagText { Text = "版本 " }, new TagA { Text = "release-2026.7", Href = "https://example.com" } } } }; await bot.SendAsync(post, new[] { BotProviderNames.Lark }); ``` ## 扩展点 / 自定义 - **自定义配置源**:实现 `ILarkConfigStore`(如从数据库读多机器人配置),在启用之前 `services.AddSingleton()` 覆盖默认(`TryAddSingleton`,先注册者生效)。 ## 注意事项与最佳实践 - **图片消息需先上传**:图片类消息要先经 `UploadUrl` 上传得到 `image_key`,再放进 `LarkImage`。 - **加签时间敏感**:飞书校验 `timestamp` 与服务器时间偏差(错误码 `19021`),确保系统时间同步。 - **未配置 / 未启用返回 `BadRequest`**:`GetAsync` 返回 `null`、`Enabled=false` 或 `AccessToken` 为空时不发送。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - 无第三方 SDK,经 `XiHan.Framework.Http` 直接 HTTP 调用飞书开放接口。 ## 相关模块 - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.WeCom](./bot-wecom) - [XiHan.Framework.Bot.Email](./bot-email) · [XiHan.Framework.Bot.Sms](./bot-sms) · [XiHan.Framework.Bot.Telegram](./bot-telegram) --- 来源:https://framework.docs.xihanfun.com/packages/bot-sms # XiHan.Framework.Bot.Sms > Bot 短信通道:阿里云 / 腾讯云双网关短信提供者,内部模板码经映射转服务商模板码后发送。 - **NuGet**:`XiHan.Framework.Bot.Sms` - **模块类**:`XiHanBotSmsModule` - **所在层**:基础设施层 - **关键依赖**:`AlibabaCloud.SDK.Dysmsapi20170525`(4.4.0)、`TencentCloudSDK.Sms`(3.0.1435);框架内部依赖 `XiHan.Framework.Bot`。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**短信通道子包**。它实现 `IBotProvider`(名为 `Sms`),内置**阿里云**与**腾讯云**两家云短信网关,把短信封装为 Bot 内核可调度的一个提供者。 短信按云厂商**模板**发送,本包的关键设计是**内部模板码映射**:业务方在消息里带一个「内部模板码」(如 `auth-sms-login-code`),网关客户端按配置的 `TemplateMap` 把它转成服务商真实模板码(如阿里云 `SMS_123456` / 腾讯云 `TemplateId`),并按厂商差异组织参数(阿里云用命名 JSON 参数,腾讯云用 `ParamOrder` 排出的位置数组)。 短信凭证(云厂商 AccessKeySecret)不宜放配置文件,因此 `ISmsConfigStore` 默认实现**恒返回 `null`**,须由应用层注册数据库 store 实现覆盖(并在其中完成凭证解密)才能真正发信。 ## 何时使用 - 需要通过阿里云 / 腾讯云发送模板短信(验证码、通知等),并与邮件/IM 通道走同一套策略与管道。 - 需要在阿里云与腾讯云之间按配置热切换(改数据库配置即换网关,无需重启)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.Sms ``` ```csharp [DependsOn(typeof(XiHanBotSmsModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => bot.UseSms()); // 关键:注册应用层配置 store(内含凭证解密)覆盖默认空实现 context.Services.AddSingleton(); } } ``` `XiHanBotSmsModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` 调用 `AddXiHanBotSms()`。`AddXiHanBotSms()` 与 `UseSms(this BotBuilder)` 均注册(`TryAdd` 语义): - `ISmsConfigStore` → `DefaultSmsConfigStore`(`TryAddSingleton`,恒返回 `null`); - `ISmsGatewayResolver` → `SmsGatewayResolver`(`TryAddSingleton`); - `IBotProvider` → `SmsBotProvider`(`TryAddEnumerable` Singleton)。 `UseSms()` **不接收 configure 委托**(凭证不走选项绑定);凭证由 `ISmsConfigStore` 应用层实现提供。 ## 工作原理 ``` SmsBotProvider.SendAsync(message, context) 1. 从 message.Data 取手机号(Sms.PhoneNumbers)、内部模板码(Sms.TemplateCode)、参数 JSON(Sms.TemplateParams) 2. ISmsGatewayResolver.ResolveAsync() → 当前生效的 ISmsGatewayClient(无启用配置返回 null) 3. gateway.SendAsync(SmsGatewayRequest) → 客户端内按 TemplateMap 映射模板码、组织参数、调 SDK ``` `SmsGatewayResolver` 每次 `ResolveAsync` 读 `ISmsConfigStore.GetAsync()` 当前配置,以 `ConfigId` 为键在 `ConcurrentDictionary` 中缓存已构建的客户端,并用**配置指纹**(ConfigId | Provider | AccessKeyId | AccessKeySecret | SdkAppId | SignName | Region | TemplateMap | IsEnabled)判断是否需要重建;指纹变化即重建客户端,实现**热切换**。按 `config.Provider` 分发:`Aliyun` → `AliyunSmsGatewayClient`,`TencentCloud` → `TencentCloudSmsGatewayClient`(`AccessKeySecret` 为空直接抛异常;腾讯云还须 `SdkAppId`/`Region`,缺任一同样抛异常)。 ## 核心能力 - **多云网关**:`SmsProviderType` 覆盖阿里云 / 腾讯云;`AliyunSmsGatewayClient`(`dysmsapi.aliyuncs.com`)/ `TencentCloudSmsGatewayClient`(`sms.tencentcloudapi.com`,V20210111)分别对接对应 SDK。 - **网关解析与缓存**:`ISmsGatewayResolver` 按配置指纹构建并缓存 `ISmsGatewayClient`,配置变即重建。 - **模板码映射**:`SmsChannelConfig.TemplateMap`(JSON)把内部模板码映射为服务商模板码 + 参数序。 - **可插拔配置**:`ISmsConfigStore` 抽象渠道配置来源(默认空实现,应用层须提供 DB 实现)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `SmsBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.Sms`(`"Sms"`)。`Task SendAsync(BotMessage, BotContext)` | | `ISmsConfigStore` | `Task GetAsync(CancellationToken cancellationToken = default)`(返回 `null` 视为未配置,fail-closed) | | `DefaultSmsConfigStore` | 默认实现,`GetAsync` 恒返回 `null`(须应用层覆盖) | | `ISmsGatewayResolver` | `Task ResolveAsync(CancellationToken cancellationToken = default)`(无启用配置返回 `null`) | | `ISmsGatewayClient` | `SmsProviderType Provider { get; }`、`Task SendAsync(SmsGatewayRequest request, CancellationToken)` | | `SmsGatewayClientBase` | 网关客户端基类(模板映射解析 `ResolveMapping` / 参数解析 `ParseParams`) | | `AliyunSmsGatewayClient` / `TencentCloudSmsGatewayClient` | 两家网关实现(`Provider` 分别为 `Aliyun` / `TencentCloud`) | | `SmsProviderType` | `Aliyun = 0` / `TencentCloud = 1` | | `SmsChannelConfig` | 渠道配置(见下表) | | `SmsMessageDataKeys` | 消息 `Data` 键常量(见下表) | 关键 record: - `SmsGatewayRequest(IReadOnlyList PhoneNumbers, string? TemplateCode, string? TemplateParamsJson, string Content)` - `SmsGatewaySendResult(bool IsSuccess, string? ProviderMessageId, string? ErrorMessage)` - `SmsTemplateMapping(string TemplateCode, string[]? ParamOrder)` ## 配置 `SmsChannelConfig` 字段(由应用层 `ISmsConfigStore` 提供,通常来自数据库): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ConfigId` | `long` | `0` | 配置标识(解析器按此键缓存网关客户端) | | `Provider` | `SmsProviderType` | `Aliyun` | 短信服务商 | | `AccessKeyId` | `string` | `""` | 阿里云 AccessKeyId / 腾讯云 SecretId | | `AccessKeySecret` | `string` | `""` | 阿里云 AccessKeySecret / 腾讯云 SecretKey(已解密明文,由 store 负责解密) | | `SdkAppId` | `string?` | `null` | 腾讯云 SmsSdkAppId(腾讯云必填;阿里云不用) | | `SignName` | `string` | `""` | 短信签名(控制台审核通过的签名名称) | | `Region` | `string?` | `null` | 腾讯云地域(如 `ap-guangzhou`,必填;阿里云不用) | | `TemplateMap` | `string?` | `null` | 模板映射 JSON:内部模板码 → 服务商模板码 + 参数序 | | `IsEnabled` | `bool` | `true` | 是否启用 | `TemplateMap` 形如:`{"auth-sms-login-code":{"templateCode":"SMS_123456","paramOrder":["code","minutes"]}}`。阿里云按命名 JSON 参数发送(参数名须与服务商模板变量名一致),`paramOrder` 供腾讯云位置参数数组使用。 `SmsMessageDataKeys` 常量(写入 `BotMessage.Data`): | 常量 | 值 | 含义 | | --- | --- | --- | | `PhoneNumbers` | `"Sms.PhoneNumbers"` | 接收手机号(逗号分隔 `string` 或 `IEnumerable`) | | `TemplateCode` | `"Sms.TemplateCode"` | 内部模板码(经 `TemplateMap` 映射为服务商模板码) | | `TemplateParams` | `"Sms.TemplateParams"` | 模板参数 JSON(键须与服务商模板变量名一致) | ## 使用示例 ```csharp var msg = new BotMessage { Content = "登录验证码" }; msg.Data[SmsMessageDataKeys.PhoneNumbers] = "13800000000,13900000000"; msg.Data[SmsMessageDataKeys.TemplateCode] = "auth-sms-login-code"; // 内部模板码 msg.Data[SmsMessageDataKeys.TemplateParams] = "{\"code\":\"8421\",\"minutes\":\"5\"}"; await bot.SendAsync(msg, new[] { BotProviderNames.Sms }); ``` ## 扩展点 / 自定义 - **必做:注册应用层 `ISmsConfigStore`**。默认 `DefaultSmsConfigStore` 恒返回 `null`(fail-closed,不会误发),须实现从数据库读取 `SmsChannelConfig`(并解密 `AccessKeySecret`)的 store,`services.AddSingleton()` 覆盖默认。 - 腾讯云手机号会自动规范化为 E.164:以 `+` 开头保持原样,否则前置 `+86`。 ## 注意事项与最佳实践 - **未配置即不发**:`ISmsConfigStore` 默认空实现导致 `ResolveAsync` 返回 `null`,`SendAsync` 返回 `BadRequest`——这是刻意的 fail-closed,防止凭证缺失时静默误发。 - **密钥不可为空**:`AccessKeySecret` 为空会在构建网关客户端时直接抛异常(两家服务商均如此),被 `SmsBotProvider` 折叠为 `Failed`。 - **腾讯云需完整配置**:缺 `SdkAppId` 或 `Region` 会在解析时抛异常。 - **模板码必须映射**:`TemplateCode` 找不到对应 `TemplateMap` 条目会抛异常(`ResolveMapping`);腾讯云还要求 `ParamOrder` 里每个键都能在 `TemplateParams` 找到值。 - **失败不外抛**:SDK 层错误在 provider 内转成 `BotResult.Failed`,不影响其它提供者。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - `AlibabaCloud.SDK.Dysmsapi20170525` 4.4.0、`TencentCloudSDK.Sms` 3.0.1435 ## 相关模块 - [XiHan.Framework.Bot.Email](./bot-email) · [XiHan.Framework.Bot.Telegram](./bot-telegram) - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.Lark](./bot-lark) · [XiHan.Framework.Bot.WeCom](./bot-wecom) --- 来源:https://framework.docs.xihanfun.com/packages/bot-telegram # XiHan.Framework.Bot.Telegram > Bot Telegram 通道:两层能力——统一调度用的单发 `IBotProvider`,与一套可托管多机器人的交互平台(Webhook/长轮询双模、命令/回调/会话路由、发送门面、去重/审计)。 - **NuGet**:`XiHan.Framework.Bot.Telegram` - **模块类**:`XiHanBotTelegramModule` - **所在层**:基础设施层 - **关键依赖**:`Telegram.Bot`(22.10.2.1,官方 SDK)+ `Microsoft.AspNetCore.App`(`FrameworkReference`,Webhook 端点需要);框架内部依赖 `XiHan.Framework.Bot`。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**Telegram 通道子包**,也是 Bot 家族里**最厚**的一个。它有两层能力: 1. **单发层**:`TelegramBotProvider` 实现 `IBotProvider`(名为 `Telegram`),接入统一 Bot 调度,纯用于往某个 chat 发通知——调用方仍只面对 `IBotClient`。 2. **多机器人交互平台层**:一套完整的运行时(`TelegramBotManager` + `BotRegistry` + `TelegramBotHostedService`),支持**同时托管多个机器人**、**Webhook/长轮询双模**接入、**命令/回调/消息/内联查询/回复/会话状态**路由、`ITelegramNotifier` 主动发送门面(内建 429/5xx/超时重试退避 + 出站审计),以及会话状态、更新去重、消息审计三类可插拔 store。 平台层**默认不启用**:由配置节 `XiHan:Bot:Telegram:Platform` 的 `Settings.Enabled=true` 或应用层 store 开启;未启用时 manager 空转。所有处理器**显式注册、不做程序集扫描**。 ## 何时使用 - 只想往 Telegram 发通知:通过统一 `IBotClient` 走 `TelegramBotProvider`(单发层)。 - 想构建可交互机器人:处理用户命令、内联按钮回调、多轮会话,并可同时托管多个机器人实例(平台层)。 - 需要 Webhook 接收更新(ASP.NET Core 端点,含 secret token 校验)或长轮询两种接入方式。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.Telegram ``` ```csharp [DependsOn(typeof(XiHanBotTelegramModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { // 单发层:Bot 构建器上启用(configure 必填) context.Services.AddXiHanBot(bot => bot.UseTelegram(o => { o.Token = "123456:ABC-DEF..."; o.ChatId = "-1001234567890"; o.ParseMode = "Markdown"; })); // 平台层:注册业务处理器(显式,无扫描) context.Services.AddTelegramBotBuiltinHandlers(); // /start /help /myid context.Services.AddTelegramBotHandler(); } } ``` `XiHanBotTelegramModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` **同时**注册单发通道(`AddXiHanBotTelegram()`)与多机器人平台(绑定 `TelegramBotPlatformOptions`(配置节 `XiHan:Bot:Telegram:Platform`)后 `AddXiHanBotTelegramPlatform()`)。 Webhook 模式还需在应用管道里 `app.UseTelegramBotWebhook()`(映射 Webhook 中间件)。 ## 工作原理 ### 传输模式选择(Webhook vs 长轮询) 由 `TelegramBotSettings.WebhookBaseUrl` 决定:**非空 = Webhook**,**空 = 长轮询**。 - **Webhook**:`TelegramBotManager` 调 `ITelegramBotClient.SetWebhook(url, secretToken)`;`TelegramBotWebhookMiddleware` 在 `{WebhookRoutePrefix}/{botName}` 接收 POST,校验 `X-Telegram-Bot-Api-Secret-Token` 头(**fail-closed,secret 必须非空**,定长比较防时序攻击),反序列化为 `Update` 后**入队后台分发**并立即回 200(避免 Telegram 重试风暴)。 - **长轮询**:`TelegramBotManager` 调 `ITelegramBotClient.StartReceiving()` 拉取更新;遇 409(getUpdates 冲突)停轮询、下轮刷新重试。 ### Manager 刷新循环 `TelegramBotManager` 按 `ManagerRefreshSeconds` 轮询 `ITelegramBotConfigStore` 与 `ITelegramBotSettingsStore`,diff 配置:新增机器人启动、变更重启、删除移除;传输模式切换或网络配置(代理/BaseUrl/超时)变更时重建实例;并把命令菜单同步到 Telegram(`SetMyCommands`/`DeleteMyCommands`)。`BotRegistry` 无锁热路径查找,替换/删除的旧实例**延迟 150 秒**再 `Dispose`(避免打断在途请求)。 ### 更新分发管线 `TelegramUpdateDispatcher.DispatchAsync` 固定顺序:群/频道白名单守卫(fail-closed)→ 更新去重(幂等)→ 内联查询 → 会话状态 → 回调 → `/start` 深链 → 命令(token 或正则)→ 回复 → 消息 → 兜底回复。未捕获异常回 `InternalErrorReply`;处理被取消则回滚去重标记(at-least-once)。 ## 核心能力 - **单发通道**:`TelegramBotProvider`(`Name = "Telegram"`)接入统一调度;chat/parseMode 可被消息 `Data` 覆盖。 - **主动发送门面**:`ITelegramNotifier` 按机器人名发文本/Markdown/图片/文件、编辑消息与内联键盘、`SendToAdminsAsync` 广播管理员;内建重试退避(429 用 `RetryAfter`,否则指数退避)与出站审计。 - **多机器人托管**:`TelegramBotManager` / `BotRegistry` / `TelegramBotHostedService`(`IHostedService`,启动/停止不阻塞应用)。 - **处理器扩展点**:`IBotCommandHandler` / `IBotCallbackHandler` / `IBotMessageHandler` / `IBotReplyHandler` / `IBotInlineQueryHandler` / `IBotStartPayloadHandler` / `IBotStateHandler` + `[BotCommand]` / `[BotCallback]` 特性。 - **会话状态/去重/审计**:`IConversationStateStore`(多轮会话,默认内存存储,TTL 由调用方传入、非正数时取 10 分钟)、`ITelegramUpdateDeduplicator`(更新去重,默认内存 TTL 30 分钟)、`ITelegramMessageAuditStore`(默认 No-op),均可换 store。 - **内联键盘 DSL**:`TelegramKeyboardBuilder`(`AddButton` / `AddUrlButton` / `AddRow` / `Build`,静态 `ConfirmCancel` / `Single`)。 ## 主要 API / 类型 ### 单发层 | 类型 | 说明 | | --- | --- | | `TelegramBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.Telegram`(`"Telegram"`)。读配置→解析 ChatId/ParseMode→`TelegramBotClient.SendMessage` | | `TelegramOptions` | 单发选项(见配置节) | | `ITelegramConfigStore` / `DefaultTelegramConfigStore` | `Task GetAsync(CancellationToken)`;默认读 `IOptionsMonitor` | | `BotBuilderTelegramExtensions.UseTelegram` | `UseTelegram(this BotBuilder, Action configure)` | ### 平台层 · 运行时 | 类型 | 说明 | | --- | --- | | `TelegramBotManager` | 多机器人运行时:`StartAsync` / `StopAsync` / `GetStatus` / `RefreshNowAsync` / `DispatchAsync` / `QueueDispatch` | | `BotRegistry` | 无锁实例表:`AddOrUpdate` / `TryGet` / `GetRequired` / `GetAll` / `Remove`(旧实例延迟 150s 释放) | | `BotInstance` | 单机器人实例(`Name` / `Token` / `Config` / `Client` / `BotId` / `Username`;`IsAdmin` / `IsGroupAllowed`) | | `TelegramBotHostedService` | `IHostedService`,`StartAsync`/`StopAsync` 调 manager(异常吞并记日志,不阻塞启动) | | `TelegramUpdateDispatcher` | 更新分发(固定管线) | | `TelegramKeyboardBuilder` | 内联键盘构建器 | ### 平台层 · 发送门面(`ITelegramNotifier`) | 方法 | 说明 | | --- | --- | | `SendTextAsync(botName, chatId, text, replyToMessageId?, replyMarkup?, ct)` | 发文本 | | `SendMarkdownAsync(...)` / `SendByParseModeAsync(..., parseMode?, ...)` | 发 Markdown / 指定 parseMode(`None`/`Markdown`/`MarkdownV2`/`Html`,忽略大小写) | | `SendPhotoAsync(..., imageBytes, caption?, ...)` / `SendDocumentAsync(..., fileBytes, fileName, ...)` | 发图片 / 文件 | | `EditMessageTextAsync(...)` / `EditMessageReplyMarkupAsync(...)` | 编辑消息文本 / 内联键盘 | | `SendToAdminsAsync(botName, text, parseMode?, ct)` | 广播机器人所有管理员(逐个发,单个失败不中断) | 默认实现 `TelegramNotifier` 所有发送包裹 `ExecuteWithRetryAsync`(429 用 `RetryAfter`,5xx/网络/超时指数退避,`MaxRetries` 默认 3,最终失败可选通知管理员),并经 `ITelegramMessageAuditStore.AppendAsync` 审计。 ### 平台层 · 处理器扩展点 | 接口 | 关键方法 | | --- | --- | | `IBotCommandHandler` | `Task HandleAsync(TelegramBotContext, string[] args, CancellationToken)`;配 `[BotCommand]` | | `IBotCallbackHandler` | `Task HandleAsync(TelegramBotContext, string data, CancellationToken)`;配 `[BotCallback]` | | `IBotMessageHandler` / `IBotReplyHandler` | `int Order`、`bool CanHandle(ctx)`、`Task HandleAsync(ctx, ct)`(按 `Order` 排序,首个 `CanHandle` 命中即止) | | `IBotInlineQueryHandler` | `bool CanHandle(ctx, query)`、`Task> HandleAsync(ctx, query, ct)` | | `IBotStartPayloadHandler` | `Task HandleAsync(ctx, string payload, ct)`(处理 `/start` 深链,返回 `true` 消费) | | `IBotStateHandler` | `bool CanHandle(ctx, string stateStep)`、`Task HandleAsync(ctx, stateStep, statePayload?, ct)` | `[BotCommand(command)]`:`Command` / `Description` / `AdminOnly` / `Aliases` / `Pattern`(正则匹配非命令文本,编译时带 100ms 超时防 ReDoS)。`[BotCallback(action)]`:`Action` / `AdminOnly`。`TelegramBotContext` 统一封装一次更新(`Bot` / `Update` / `Message` / `Callback` / `ChatId` / `UserId` / `Text` / `IsCommand` / `IsCallback` / `IsReply` / `IsGroup` / `IsChannel` / `IsAdmin` / `LanguageCode`(Telegram 客户端语言,未识别回退 `zh-CN`)等 + `GetCommandToken` / `GetCommandArgs` / `GetCallbackAction` / `SetCallbackAnswer` / `MarkCallbackAnswered`)。 ### 平台层 · 可插拔 store | 接口 | 默认实现 | 说明 | | --- | --- | --- | | `ITelegramBotConfigStore` | `DefaultTelegramBotConfigStore` | `Task> GetBotConfigsAsync(ct)`;默认读选项 `.Bots` | | `ITelegramBotSettingsStore` | `DefaultTelegramBotSettingsStore` | `Task GetSettingsAsync(ct)`;默认读选项 `.Settings` | | `IConversationStateStore` | `DefaultConversationStateStore` | 多轮会话状态(TTL 由调用方传入、非正数时取 10 分钟;最多 50000 条,满载抛 `InvalidOperationException`) | | `ITelegramUpdateDeduplicator` | `DefaultTelegramUpdateDeduplicator` | 更新去重(TTL 30 分钟,最多 100000 条,满载抛 `InvalidOperationException`) | | `ITelegramMessageAuditStore` | `NoOpTelegramMessageAuditStore` | 出站审计(`AppendAsync(TelegramMessageAuditRecord, ct)`) | `ConversationState`:`Step`(当前步骤)/ `Payload`(上下文 JSON)/ `CreateTime`。 ### DI 入口 | 方法 | 说明 | | --- | --- | | `AddXiHanBotTelegram(this IServiceCollection, Action? configure = null)` | 注册单发层:`DefaultTelegramConfigStore`(TryAdd)+ `TelegramBotProvider`(TryAddEnumerable) | | `AddXiHanBotTelegramPlatform(this IServiceCollection, Action? configure = null)` | 注册平台层全套(stores/routers/catalog/dispatcher/notifier/registry/manager,均 TryAdd + `TelegramBotHostedService` 托管) | | `AddTelegramBotHandler(this IServiceCollection)` | 注册处理器(transient),并加入 `TelegramBotHandlerOptions.Handlers` | | `AddTelegramBotBuiltinHandlers(this IServiceCollection)` | 注册内置 `/start` `/help` `/myid` 处理器 | | `UseTelegramBotWebhook(this IApplicationBuilder)` | 挂载 `TelegramBotWebhookMiddleware` | ## 配置 ### `TelegramOptions`(单发层) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用该提供者 | | `Token` | `string` | `""` | Bot 令牌(必填) | | `ChatId` | `string` | `""` | 默认会话 ID 或用户名 | | `ParseMode` | `string?` | `null` | 默认解析模式(`Html`/`Markdown`/`MarkdownV2`/`None`) | | `DisableNotification` | `bool` | `false` | 是否静默(不提示音) | `TelegramMessageDataKeys`:`TelegramChatId = "Telegram.ChatId"`、`TelegramParseMode = "Telegram.ParseMode"`(写入 `BotMessage.Data` 按条覆盖)。 ### `TelegramBotPlatformOptions`(平台层,配置节 `XiHan:Bot:Telegram:Platform`) 由 `Settings`(`TelegramBotSettings`)、`Bots`(`List`)、`Retry`(`TelegramBotRetryOptions`)、`Texts`(`TelegramBotTexts`)组成。 `TelegramBotSettings` 关键字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `false` | 是否启用整个平台(`false` 时 manager 空转) | | `ManagerRefreshSeconds` | `int` | `5` | 配置/设置变更轮询间隔(≤0 关闭刷新) | | `WebhookBaseUrl` | `string` | `""` | Webhook 基址(非空=Webhook;空=长轮询) | | `WebhookRoutePrefix` | `string` | `"/api/telegram-bot/webhook"` | Webhook 路由前缀 | | `WebhookSecretToken` | `string` | `""` | Webhook 校验 secret(Webhook 模式下 fail-closed,必须非空) | | `ConfigCacheSeconds` | `int` | `5` | 配置列表缓存 TTL(应用层 DB store 用) | | `EnableFallbackReply` | `bool` | `false` | 全局兜底回复(与单机器人设置 OR 组合) | | `Network` | `TelegramBotNetworkOptions` | `new()` | 代理 / 自定义 BaseUrl / 超时 | `TelegramBotConfig`(单机器人):`Id`(数据库来源配置 Id,仅用于变更检测与审计定位)、`Name`(业务名,唯一,忽略大小写)、`Token`(必填)、`AdminUsers`(`long[]`)、`AllowedGroupChatIds`(`long[]`,**fail-closed:空=拒绝所有群与频道**;私聊不受限;白名单同时覆盖群组与频道,频道 ChatId 与超级群同为 -100 前缀)、`AllowedCommands`(`string[]`,空=不限)、`EnableFallbackReply`、`Remark`。`IsSameAs` 逐字段比对(含 Name/Token/Id/AdminUsers/AllowedGroupChatIds/AllowedCommands/EnableFallbackReply),任一变化 Manager 即重启该机器人。 `TelegramBotNetworkOptions`:`ProxyUrl`(空=直连,支持 `user:pass@host:port` 形式的代理凭证)、`BaseUrl`(空=官方 api.telegram.org,可指向自建 Bot API Server)、`TimeoutSeconds`(默认 100,`<=0` 按 100 处理)。 `TelegramBotRetryOptions`:`MaxRetries`(3,不含首次发送)、`BaseDelayMs`(500,首次重试延迟,之后翻倍)、`MaxDelayMs`(10000)、`NotifyAdminOnFinalFailure`(true)。 `TelegramBotPlatformConsts`:`SecretTokenHeaderName = "X-Telegram-Bot-Api-Secret-Token"`、`CallbackDataSeparator = ':'`、`StartCommand = "/start"`、`DefaultWebhookRoutePrefix = "/api/telegram-bot/webhook"`。 `TelegramBotTexts`(平台文案,带中文默认值,应用层可整体覆盖): | 字段 | 默认值 | 用途 | | --- | --- | --- | | `InternalErrorReply` | "系统处理异常,已记录日志,请稍后重试或联系管理员。" | 分发异常兜底回复 | | `CommandDisabledReply` | "该命令未开启。" | 命令不在白名单 | | `AdminOnlyCommandReply` | "该命令仅管理员可用。" | 非管理员执行管理员命令 | | `AdminOnlyCallbackReply` | "该操作仅管理员可用。" | 非管理员点击管理员按钮 | | `UnhandledMessageReply` | "暂不支持处理该消息,发送 /help 查看可用命令。" | 无处理器命中且兜底回复开启 | | `SendFailureAdminNotifyTitle` | "消息发送失败告警" | 发送最终失败通知管理员的标题 | | `StartReply` | "你好,欢迎使用 {botUsername}!发送 /help 查看可用命令。" | 内置 `/start` 欢迎语(支持 `{botUsername}` 占位符) | | `HelpHeader` | "可用命令:" | 内置 `/help` 列表头部 | | `MyIdReply` | "ChatId:{chatId}\nUserId:{userId}" | 内置 `/myid` 回复(支持 `{chatId}` / `{userId}` 占位符) | ## 使用示例 ### 1. 单发通知(走统一 `IBotClient`) ```csharp var msg = new BotMessage { Content = "*部署完成*", Type = BotMessageType.Markdown }; // 可按条覆盖目标 chat 与解析模式 msg.Data[TelegramMessageDataKeys.TelegramChatId] = "-1009876543210"; msg.Data[TelegramMessageDataKeys.TelegramParseMode] = "MarkdownV2"; await bot.SendAsync(msg, new[] { BotProviderNames.Telegram }); ``` ### 2. 多机器人平台:命令处理器 + 主动发送 ```csharp // 声明命令处理器(显式注册,无扫描) [BotCommand("/ping", Description = "健康检查")] public sealed class PingCommandHandler : IBotCommandHandler { public async Task HandleAsync(TelegramBotContext ctx, string[] args, CancellationToken ct) => await ctx.Client.SendMessage(ctx.ChatId, "pong", cancellationToken: ct); } // 注册:services.AddTelegramBotHandler(); // 业务里用 ITelegramNotifier 主动推送(带重试与审计) public class ReleaseNotifier(ITelegramNotifier notifier) { public Task NotifyAsync(CancellationToken ct) => notifier.SendMarkdownAsync("ops-bot", -1001234567890, "*release-2026.7* 已上线", cancellationToken: ct); } ``` Webhook 模式下别忘了在管道里 `app.UseTelegramBotWebhook();`。 ## 扩展点 / 自定义 - **业务处理器**:实现相应 `IBot*Handler` 接口并加特性,用 `AddTelegramBotHandler()` 显式注册(**不做程序集扫描**;`TelegramBotHandlerCatalog` 在构建路由表时对缺特性、重复命令/动作、未实现接口**快速失败**)。 - **换 store**:会话状态/去重/审计/配置四类 store 均以 `TryAdd` 注册,多实例部署应把内存实现换成分布式实现(如 Redis),配置/设置换成数据库 store。 - **单发 vs 平台**:只发通知用单发层即可;要交互/多机器人再启用平台层(`Settings.Enabled=true`)。 ## 注意事项与最佳实践 - **Webhook secret 必须配**:Webhook 模式下 `WebhookSecretToken` 为空会拒绝请求(fail-closed,401)。 - **群/频道白名单 fail-closed**:`AllowedGroupChatIds` 为空时拒绝所有群组与频道消息(私聊与内置命令 `/start` `/myid` `/id` `/help` `/h` 例外,但仍受命令白名单/AdminOnly 约束)。 - **平台默认关**:不设 `Settings.Enabled=true` 平台层不工作。 - **多实例慎用内存 store**:内存去重/会话/审计仅进程内有效,横向扩展需换分布式实现。 - **旧实例延迟释放**:配置热更后旧 `BotInstance` 保活 150 秒再释放,避免打断在途 Webhook 分发/重试。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - `Telegram.Bot` 22.10.2.1(官方 SDK)、`Microsoft.AspNetCore.App`(Webhook 端点) ## 相关模块 - [XiHan.Framework.Bot.Email](./bot-email) · [XiHan.Framework.Bot.Sms](./bot-sms) - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.Lark](./bot-lark) · [XiHan.Framework.Bot.WeCom](./bot-wecom) --- 来源:https://framework.docs.xihanfun.com/packages/bot-wecom # XiHan.Framework.Bot.WeCom > Bot 企业微信通道:企业微信群机器人 Webhook + 媒体上传提供者,纯 HTTP、无第三方 SDK。 - **NuGet**:`XiHan.Framework.Bot.WeCom` - **模块类**:`XiHanBotWeComModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Bot`);**无第三方 SDK**,直接走 HTTP。 ## 概述 本包是 [XiHan.Framework.Bot](./bot) 的**企业微信(WeCom)通道子包**。它实现 `IBotProvider`(名为 `WeCom`),通过企业微信群机器人 Webhook(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send`)发送消息,并提供媒体上传接口(`https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media`)。无第三方 SDK,用框架 `.AsHttp()` 直接 POST。 企业微信群机器人的鉴权只需一个 `Key`(机器人 Webhook key)。连接配置由 `IWeComConfigStore` 提供,默认从 `IOptionsMonitor` 读取,应用层可覆盖为数据库来源。 ## 何时使用 - 需要向企业微信群推送通知(文本 / Markdown / 图片 / 文件 / 图文 / 模板卡片)。 - 想让企业微信与其它 IM、邮件、短信通道走同一套发送策略与管道。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot.WeCom ``` ```csharp [DependsOn(typeof(XiHanBotWeComModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => bot.UseWeCom(o => { o.Key = "693axxxx-7aoc-4bc4-97a0-0ec2sifa5aaa"; // 机器人 Webhook key })); } } ``` `XiHanBotWeComModule` 依赖 `XiHanBotModule`,其 `ConfigureServices` 调用 `AddXiHanBotWeCom()`。`AddXiHanBotWeCom(this IServiceCollection, Action? configure = null)` 与 `UseWeCom(this BotBuilder, Action configure)` 均注册: - `IWeComConfigStore` → `DefaultWeComConfigStore`(`TryAddSingleton`); - `IBotProvider` → `WeComBotProvider`(`TryAddEnumerable` Singleton)。 `UseWeCom` 的 `configure` 必填;`AddXiHanBotWeCom` 的可选。 ## 工作原理 `WeComBot` 构造时拼出发送与上传 URL: ``` _messageUrl = WebHookUrl + "?key=" + Key _uploadUrl = UploadUrl + "?key=" + Key // 发送时再追加 &type=file|voice ``` `WeComBotProvider.SendAsync` 读配置、校验 `Enabled` 与 `Key` 后按 `message.Type` 路由:`Markdown`、`Image`(`Data[WeCom.Image]`)、`File`(`Data[WeCom.File]`)、`Card`(模板卡片 text_notice / news_notice)、`Link`(图文 `Data[WeCom.News]`),其余按 `Text`(应用 `Mentions`)。所有发送 POST 到 `_messageUrl`,按 `ErrCode == 0 || ErrMsg == "ok"` 判成功。媒体上传(`UploadFile`)POST 到 `_uploadUrl + "&type=" + uploadType`,成功返回 `media_id`(有效期 3 天)。 ## 核心能力 - **群机器人发送**:`WeComBot` 提供 `TextMessage` / `MarkdownMessage` / `ImageMessage` / `NewsMessage` / `FileMessage` / `VoiceMessage` / `TextNoticeMessage` / `NewsNoticeMessage`,均返回 `Task`。 - **媒体上传**:`UploadFile(FileStream, WeComUploadType, CancellationToken)` 上传 file / voice,得 `media_id`(3 天有效)。 - **@ 提及**:`WeComText`(继承 `WeComAt`)支持 `Mentions` / `MentionedMobiles`。 - **可插拔配置**:`IWeComConfigStore` 抽象配置来源。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `WeComBotProvider` | `IBotProvider` 实现,`Name => BotProviderNames.WeCom`(`"WeCom"`)。`Task SendAsync(BotMessage, BotContext)` | | `WeComBot` | Webhook 发送 + 媒体上传封装。构造 `WeComBot(WeComOptions)` | | `IWeComConfigStore` | `Task GetAsync(CancellationToken cancellationToken = default)` | | `DefaultWeComConfigStore` | 默认实现,包 `IOptionsMonitor`,`GetAsync` 返回 `CurrentValue` | | `WeComOptions` | 企业微信选项(见配置节) | | `WeComText` / `WeComMarkdown` / `WeComImage` / `WeComNews` / `WeComFile` / `WeComVoice` / `WeComTemplateCardTextNotice` / `WeComTemplateCardNewsNotice` | 各消息体模型 | | `WeComMsgTypeEnum` / `WeComTemplateCardType` / `WeComUploadType` | 消息类型 / 卡片类型 / 上传类型(`File` / `Voice`)枚举 | | `WeComMessageDataKeys` | 消息 `Data` 键常量(见下表) | | `WeComResultInfoDto` | 响应 DTO:`ErrCode`(`errcode`)/ `ErrMsg`(`errmsg`)/ `Type` / `MediaId` / `CreatedAt`;成功以 `ErrCode == 0 || ErrMsg == "ok"` 判定 | | `WeComUploadResultDto` | 上传结果:`Message` / `MediaId` / `Type` | ## 配置 `WeComOptions` 字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用该提供者 | | `WebHookUrl` | `string` | `"https://qyapi.weixin.qq.com/cgi-bin/webhook/send"` | 发送 Webhook 地址(惰性初始化) | | `UploadUrl` | `string` | `"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media"` | 媒体上传接口(惰性初始化) | | `Key` | `string` | `""` | 机器人 Webhook key(拼到 URL;必填) | `WeComMessageDataKeys` 常量(写入 `BotMessage.Data` 传富消息体): | 常量 | 值 | 含义 | | --- | --- | --- | | `WeComNews` | `"WeCom.News"` | 图文消息体(`WeComNews`) | | `WeComImage` | `"WeCom.Image"` | 图片消息体(`WeComImage`,含 md5/base64) | | `WeComFile` | `"WeCom.File"` | 文件消息体(`WeComFile`,含 media_id) | | `WeComVoice` | `"WeCom.Voice"` | 语音消息体(`WeComVoice`,含 media_id) | | `WeComTemplateCardTextNotice` | `"WeCom.TemplateCardTextNotice"` | 文本通知模板卡片 | | `WeComTemplateCardNewsNotice` | `"WeCom.TemplateCardNewsNotice"` | 图文展示模板卡片 | ## 使用示例 ```csharp // Markdown 通知 await bot.SendAsync(new BotMessage { Content = "**发布成功** release-2026.7 已上线", Type = BotMessageType.Markdown }, new[] { BotProviderNames.WeCom }); // 图文(News):把图文体放进 Data var news = new BotMessage { Type = BotMessageType.Link }; news.Data[WeComMessageDataKeys.WeComNews] = new WeComNews { Articles = new() { new WeComArticle { Title = "版本发布", Description = "点击查看", Url = "https://example.com", PicUrl = "https://example.com/1.png" } } }; await bot.SendAsync(news, new[] { BotProviderNames.WeCom }); ``` ## 扩展点 / 自定义 - **自定义配置源**:实现 `IWeComConfigStore`(如从数据库读多机器人配置),在启用之前 `services.AddSingleton()` 覆盖默认(`TryAddSingleton`,先注册者生效)。 - **发文件/语音前先上传**:文件/语音消息用 `media_id`,先 `WeComBot.UploadFile(...)` 取 `media_id`(3 天有效),再发 `WeComFile`/`WeComVoice`。 ## 注意事项与最佳实践 - **`Key` 必填**:`GetAsync` 返回 `null`、`Enabled=false` 或 `Key` 为空时 `SendAsync` 返回 `BadRequest`,不发送。 - **媒体 3 天有效**:上传得到的 `media_id` 仅 3 天有效,别缓存过久。 - **图片走 base64+md5**:`WeComImage` 需 `md5` 与 `base64`(非 media_id),与文件/语音的 media_id 机制不同。 ## 依赖模块 - [XiHan.Framework.Bot](./bot)(内核,`IBotProvider` / `BotResult` / `BotMessage`) - 无第三方 SDK,经 `XiHan.Framework.Http` 直接 HTTP 调用企业微信开放接口。 ## 相关模块 - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.Lark](./bot-lark) - [XiHan.Framework.Bot.Email](./bot-email) · [XiHan.Framework.Bot.Sms](./bot-sms) · [XiHan.Framework.Bot.Telegram](./bot-telegram) --- 来源:https://framework.docs.xihanfun.com/packages/bot # XiHan.Framework.Bot > 机器人 / 消息通知内核:统一消息模型 + 多提供者调度,把「往哪些渠道发」与「用哪个厂商发」解耦。 - **NuGet**:`XiHan.Framework.Bot` - **模块类**:`XiHanBotModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Http`、`XiHan.Framework.Templating`);核心包**不含任何第三方平台 SDK**,具体平台 SDK 由各通道子包各自引入。 ## 概述 本包是「机器人 / 消息通知」的**核心抽象与调度内核**。它定义了统一的消息模型(`BotMessage`)、提供者抽象(`IBotProvider`)、调度器(`BotDispatcher`)、发送策略(`IBotStrategy`)与横切管道(`IBotPipeline`),让你用一致的方式往多个渠道(邮件、短信、Telegram、钉钉、飞书、企业微信……)发消息。 核心包本身**不包含任何具体通道**——每个通道是一个独立子包(`XiHan.Framework.Bot.Email` / `.Sms` / `.Telegram` / `.DingTalk` / `.Lark` / `.WeCom`),各自实现 `IBotProvider` 并以 `UseXxx` 扩展方法接入。本包只负责**编排**它们:解析渠道、选提供者、跑管道、执行策略、聚合结果。 设计上把两件事解耦:**渠道(Channel)** 是逻辑名(如 `"ops-alert"`),**提供者(Provider)** 是真正干活的厂商实现(如 `"DingTalk"`)。一个渠道可映射到一组提供者,调用方只按渠道名发送,底层用哪几个厂商由配置决定。 ## 何时使用 - 需要向多个平台**统一发送通知**,且希望调用方只面对一个 `IBotClient`。 - 需要**广播 / 主备 / 优先级**等发送策略,或**重试 / 限流 / 环境过滤**等横切能力。 - 需要按**模板**渲染消息(`SendTemplateAsync`),或**延迟 / 批量**发送。 - 想让「往哪些渠道发」与「用哪个厂商发」解耦,通过渠道映射灵活配置、按环境灰度。 不该用它的场景:只发单一渠道、无需策略/管道编排时,可直接引用对应子包的 `XxxBotProvider`。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot # 再按需添加通道子包,例如: dotnet add package XiHan.Framework.Bot.DingTalk ``` ```csharp [DependsOn( typeof(XiHanBotModule), typeof(XiHanBotDingTalkModule) // 想启用哪个通道就依赖哪个子模块 )] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => { bot.Configure(o => o.DefaultStrategy = BotStrategyNames.Broadcast) .UseDingTalk() // 由子包提供的 Use* 扩展 .AddChannel("ops-alert", "DingTalk"); // 逻辑渠道 → 提供者 }); } } ``` `XiHanBotModule` 依赖 `XiHanHttpModule` 与 `XiHanTemplatingModule`,其 `ConfigureServices` 只调用一次 `AddXiHanBot()`。`AddXiHanBot` 注册(均为单例): - `BotProviderManager`、`BotDispatcher`、`IBotClient` → `BotClient`、`IBotTemplateEngine` → `BotTemplateEngine`(`TryAddSingleton`); - 三个策略 `IBotStrategy`:`BroadcastStrategy` / `FailoverStrategy` / `PriorityStrategy`(`TryAddEnumerable`); - 四个管道 `IBotPipeline`:`LoggingPipeline` / `EnvironmentFilterPipeline` / `RetryPipeline` / `RateLimitPipeline`(`TryAddEnumerable`,注册顺序即执行顺序); - 同时 `AddXiHanTemplating()` 与 `AddOptions()`。 `AddXiHanBot(Action? configure)` 的委托拿到 `BotBuilder`,用它配置选项、登记渠道/模板,并调用各子包的 `UseXxx()`。 ## 工作原理 一次发送的完整链路(`BotClient` → `BotDispatcher.DispatchAsync`): ``` BotClient.SendAsync(message, channels, ct) │ ▼ BotDispatcher.DispatchAsync 1. 规范化 channels(去空白/去空项) 2. new BotContext(message, channels, ct),StrategyName ← 消息 Data["Strategy"] 或 null 3. BotProviderManager.ResolveProviders(channels) → 选中的 IBotProvider 列表 • channels 为空/null → 返回全部提供者(广播) • 命中 Options.Channels 里的渠道 → 展开为其 Providers 名单 • 未命中 → 按提供者名直接匹配 4. 无提供者 →(ThrowWhenNoProvider ? 抛异常 : 返回 BotDispatchResult.NoProvider) 5. ResolveStrategy(StrategyName ?? DefaultStrategy),找不到回退 Broadcast 6. 管道包裹策略:pipelines.Reverse().Aggregate(finalStep, ...)(洋葱模型) Logging → EnvironmentFilter → Retry → RateLimit → [策略执行] 7. 策略对每个 provider 调 SendAsync,结果写入 context.Results │ ▼ BotDispatchResult.From(context.Results, context.IsSkipped) → 聚合成败 + 各提供者明细 ``` 约定与要点: - **管道是洋葱式中间件**:`IBotPipeline.InvokeAsync(context, next)`,通过 `_pipelines.Reverse().Aggregate` 组成责任链;每个管道读 `XiHanBotOptions` 上自己的开关,关闭时直接 `await next()` 透传。 - **提供者异常不外抛**(策略内 `SafeSendAsync` 捕获):转成 `BotResult.Failed(ex.Message, provider.Name)` 记入结果,保证一个提供者挂掉不影响其它(除非 `ContinueOnError=false`)。 - **RetryPipeline 只重试失败的提供者**:每轮 `ClearResults` 后仅对上一轮失败的提供者重发;`RetryCount<=1` 或关闭时跳过;耗尽后若有 `LastException` 则抛出。 - **模板渲染走 `ITemplateService`**:`BotTemplateEngine` 在独立 scope 内取 `ITemplateService.RenderAsync(content, model)` 渲染 `Content`/`Title`。 - **fail-closed 判定看 `BotDispatchResult.IsSuccess`**:被环境过滤跳过(`IsSkipped=true`)、无提供者、任一提供者失败,`IsSuccess` 均为 `false`——调用方据此决定是否兜底。 ## 核心能力 - **统一客户端入口**:`IBotClient` 提供发送 / 指定渠道发送 / 模板发送 / 批量发送 / 延迟发送,全部返回 `BotDispatchResult`(整体成败 + 各提供者明细)。 - **提供者抽象**:`IBotProvider` 定义「一个通道怎么发一条消息」,各子包实现它并以 `TryAddEnumerable` 注册,可多提供者共存。 - **调度器**:`BotDispatcher` 解析渠道 → 选提供者 → 应用管道 → 执行策略,统一聚合成 `BotDispatchResult`。 - **发送策略**:`Broadcast`(广播全部)、`Failover`(主备,成功即止)、`Priority`(仅发第一个),名称常量见 `BotStrategyNames`;可在消息 `Data["Strategy"]` 上按条覆盖默认策略。 - **横切管道**:日志(`LoggingPipeline`)、环境过滤(`EnvironmentFilterPipeline`)、重试(`RetryPipeline`)、限流(`RateLimitPipeline`),经 `XiHanBotOptions` 开关配置。 - **渠道映射**:`BotChannel` 把逻辑渠道名映射到一组提供者,`BotBuilder.AddChannel(name, ...providers)` 登记。 - **消息模板**:`IBotTemplateEngine` + `BotTemplate`,按模板名渲染出 `BotMessage`。 - **流式告警构建器**:`IBotClient.Alert()` 扩展返回 `BotAlertBuilder`,链式设置标题/内容/类型/@ 提及/渠道后 `SendAsync`。 ## 主要 API / 类型 ### 客户端与调度 | 类型 | 说明 | | --- | --- | | `IBotClient` | 客户端入口。`Task SendAsync(BotMessage, CancellationToken)`;`SendAsync(BotMessage, IReadOnlyList? channels, CancellationToken)`;`SendTemplateAsync(string templateName, object? model, IReadOnlyList? channels, CancellationToken)`;`SendBatchAsync(IEnumerable, ...)` → `IReadOnlyList`;`SendDelayedAsync(BotMessage, TimeSpan delay, ...)` | | `BotClient` | `IBotClient` 默认实现,委托 `BotDispatcher`;模板方法先经 `IBotTemplateEngine` 渲染 | | `BotDispatcher` | 调度器。`Task DispatchAsync(BotMessage, IReadOnlyList? channels, CancellationToken)` | | `BotProviderManager` | 提供者管理。`IReadOnlyList GetAllProviders()`;`ResolveProviders(IReadOnlyList? channels)` | | `BotContext` | 单次调度上下文:`Message` / `Channels` / `CancellationToken` / `Items` / `Providers` / `Results` / `StrategyName` / `LastException` / `IsSkipped` / `IsSuccess` / `HasFailures`;`SetProviders(...)` / `AddResult(providerName, result)` / `ClearResults()` | ### 提供者、策略、管道 | 类型 | 说明 | | --- | --- | | `IBotProvider` | 通道提供者抽象:`string Name { get; }`、`Task SendAsync(BotMessage message, BotContext context)`。各子包实现 | | `IBotStrategy` | 发送策略:`string Name { get; }`、`Task ExecuteAsync(BotContext context, IReadOnlyList providers)` | | `BroadcastStrategy` / `FailoverStrategy` / `PriorityStrategy` | 内置三策略实现 | | `BotStrategyNames` | 策略名常量:`Broadcast` / `Failover` / `Priority`(值同名字符串) | | `IBotPipeline` | 横切管道:`Task InvokeAsync(BotContext context, Func next)` | | `LoggingPipeline` / `EnvironmentFilterPipeline` / `RetryPipeline` / `RateLimitPipeline` | 内置四管道 | ### 消息与结果模型 | 类型 | 说明 | | --- | --- | | `BotMessage` | 统一消息:`string? Title`、`string Content`、`BotMessageType Type`(默认 `Text`)、`List Mentions`、`Dictionary Data`(大小写不敏感,承载提供者扩展数据/策略覆盖等) | | `BotMessageType` | 消息类型枚举:`Text` / `Markdown` / `Card` / `Image` / `File` / `Link` | | `BotResult` | 单提供者结果:`BotResultCodes Code`(默认 `Success`)、`string? Message`、`object? Data`、`bool IsSuccess`(`Code==Success`)、`string? Provider`;静态工厂 `Success` / `BadRequest` / `Failed` / `From` | | `BotResultCodes` | `Success=200` / `BadRequest=400` / `Failed=500` | | `BotDispatchResult` | 聚合结果(`sealed`):`bool IsSuccess`、`bool IsSkipped`、`string? ErrorMessage`、`IReadOnlyList Results`;静态工厂 `NoProvider(errorMessage)` / `From(results, isSkipped)` | | `BotChannel` | 渠道定义:`string Name`、`List Providers`、`string? Description` | ### 配置、构建器、模板、辅助 | 类型 | 说明 | | --- | --- | | `XiHanBotOptions` | Bot 配置项(见下节);`AddChannel(BotChannel)` / `AddTemplate(BotTemplate)` | | `BotBuilder` | 构建器(`sealed`):`IServiceCollection Services`、`Configure(Action)`、`AddChannel(string name, params string[] providers)`、`AddTemplate(BotTemplate)`。子包在其上以扩展方法加 `UseXxx()` | | `BotAlertBuilder` | 流式告警构建器(`sealed`):`Title` / `Content` / `Type` / `Mention(params string[])` / `SendTo(params string[])` / `SendAsync(CancellationToken)` | | `BotClientExtensions` | `BotAlertBuilder Alert(this IBotClient)` | | `IBotTemplateEngine` | 模板引擎:`Task RenderAsync(string templateName, object? model)`、`RenderAsync(BotTemplate template, object? model)` | | `BotTemplate` | 模板定义:`string Name`、`string? Title`、`string Content`、`BotMessageType Type`(默认 `Markdown`)、`Dictionary Data` | | `BotMessageHelper` | `static bool TryGetData(BotMessage message, string key, out T? value)` | | `BotMessageDataKeys` | 内核通用 Data 键常量:`Strategy = "Strategy"`(专属键由各子包的 `{X}MessageDataKeys` 提供) | | `BotProviderNames` | 提供者名常量:`DingTalk` / `Lark` / `WeCom` / `Telegram` / `Email` / `Sms` | ## 配置 `XiHanBotOptions` 通过 `AddOptions()` 注册,一般用 `BotBuilder.Configure(...)` 或 `Configure(...)` 代码配置(渠道/模板为运行时字典,通常代码登记)。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `DefaultStrategy` | `string` | `BotStrategyNames.Broadcast`(`"Broadcast"`) | 默认发送策略名 | | `ContinueOnError` | `bool` | `true` | 广播时某提供者失败后是否继续发其它 | | `ThrowWhenNoProvider` | `bool` | `false` | 无可用提供者时是否抛异常(否则返回 `NoProvider` 结果) | | `RetryCount` | `int` | `3` | 重试次数(`<=1` 时重试管道不生效) | | `RetryDelay` | `TimeSpan` | `1s` | 重试间隔 | | `RateLimitPerSecond` | `int` | `5` | 每秒最多发送条数(`<=0` 关闭限流) | | `EnableLoggingPipeline` | `bool` | `true` | 是否启用日志管道 | | `EnableRetryPipeline` | `bool` | `true` | 是否启用重试管道 | | `EnableRateLimitPipeline` | `bool` | `true` | 是否启用限流管道 | | `EnableEnvironmentFilter` | `bool` | `false` | 是否启用环境过滤 | | `AllowedEnvironments` | `List` | `[]` | 允许发送的环境名列表(启用过滤且非空时,`IHostEnvironment.EnvironmentName` 不在其中则整次调度被跳过) | | `Channels` | `Dictionary` | 空(大小写不敏感) | 渠道映射(只读字典,用 `AddChannel` 登记) | | `Templates` | `Dictionary` | 空(大小写不敏感) | 模板映射(只读字典,用 `AddTemplate` 登记) | ## 使用示例 ### 1. 注册通道 + 发送消息 ```csharp // 模块里注册内核 + 通道 + 渠道映射 context.Services.AddXiHanBot(bot => { bot.UseDingTalk() // 由 XiHan.Framework.Bot.DingTalk 提供 .UseEmail() // 由 XiHan.Framework.Bot.Email 提供 // 逻辑渠道 "ops-alert" 同时映射到钉钉与邮件两个提供者 .AddChannel("ops-alert", BotProviderNames.DingTalk, BotProviderNames.Email); }); // 业务里注入 IBotClient 发送 public class AlarmService(IBotClient bot) { public async Task NotifyAsync(CancellationToken ct) { var message = new BotMessage { Title = "磁盘告警", Content = "node-01 磁盘使用率 92%", Type = BotMessageType.Markdown }; // 只发 "ops-alert" 渠道(展开为钉钉 + 邮件);渠道传 null 则广播全部提供者 var result = await bot.SendAsync(message, new[] { "ops-alert" }, ct); if (!result.IsSuccess) { // fail-closed:逐提供者查看失败明细 foreach (var r in result.Results.Where(x => !x.IsSuccess)) { // r.Provider / r.Message } } } } ``` ### 2. 流式告警构建器 ```csharp await bot.Alert() .Title("部署完成") .Content("**release-2026.7** 已上线") .Type(BotMessageType.Markdown) .Mention("13800000000") .SendTo("ops-alert") // 不调 SendTo 则广播全部提供者 .SendAsync(ct); ``` ### 3. 模板发送与按条覆盖策略 ```csharp // 登记模板 context.Services.AddXiHanBot(bot => { bot.AddTemplate(new BotTemplate { Name = "order-paid", Title = "订单已支付", Content = "订单 {{OrderNo}} 金额 {{Amount}} 已支付", // 走 ITemplateService 渲染 Type = BotMessageType.Markdown }); }); // 按模板名 + 模型渲染并发送 await bot.SendTemplateAsync("order-paid", model: new { OrderNo = "SO-1001", Amount = 99.00m }, channels: new[] { "ops-alert" }, cancellationToken: ct); // 单条覆盖默认策略:在消息 Data 上写 BotMessageDataKeys.Strategy var msg = new BotMessage { Content = "主备发送" }; msg.Data[BotMessageDataKeys.Strategy] = BotStrategyNames.Failover; await bot.SendAsync(msg, ct); ``` ## 扩展点 / 自定义 - **新增通道提供者**:实现 `IBotProvider`(`Name` + `SendAsync`),以 `services.TryAddEnumerable(ServiceDescriptor.Singleton())` 注册即被 `BotProviderManager` 纳入调度;建议同时提供 `UseMyChannel()` 的 `BotBuilder` 扩展方法。参考六个官方通道子包的实现。 - **自定义策略 / 管道**:分别 `TryAddEnumerable` 注册 `IBotStrategy` / `IBotPipeline`。策略按 `Name` 匹配(消息可用 `Data["Strategy"]` 指定);管道按注册顺序组成洋葱链,务必自行处理开关与 `next()` 透传。 - **替换核心服务**:`IBotClient` / `BotDispatcher` / `IBotTemplateEngine` 均以 `TryAddSingleton` 注册,可在 `AddXiHanBot` 之前注册自定义实现覆盖默认。 - **渠道 vs 提供者**:`ResolveProviders` 先查 `Options.Channels`(逻辑渠道 → 提供者名单),未命中再当作提供者名直接匹配——因此渠道名与提供者名可混用于 `channels` 参数。 ## 注意事项与最佳实践 - **成败判定统一看 `BotDispatchResult.IsSuccess`**:无提供者、被环境过滤跳过、任一提供者失败都为 `false`;需要精确定位时遍历 `Results`(每项 `BotResult.Provider` 标识来源)。 - **提供者异常不会冒泡**:被策略内 `SafeSendAsync` 捕获成 `Failed` 结果,因此别指望 `SendAsync` 抛异常来感知失败——只有「耗尽重试且有 `LastException`」或 `ThrowWhenNoProvider=true` 无提供者时才抛。 - **限流是进程内单例**:`RateLimitPipeline` 用进程内滑动窗口,多实例部署时按实例各自限流,非全局。 - **环境过滤依赖 `IHostEnvironment`**:`EnableEnvironmentFilter=true` 且 `AllowedEnvironments` 非空时才生效;命中不允许的环境会将整次调度标记 `IsSkipped` 并直接返回(不发送、不算成功)。 - **凭据不放 `appsettings`**:各通道子包的 `IXxxConfigStore` 默认是空实现,凭据/Webhook 由应用层从数据库覆盖(详见各子包文档)。 ## 依赖模块 - [XiHan.Framework.Http](./http)(通道适配的 HTTP 调用基础) - [XiHan.Framework.Templating](./templating)(消息模板渲染,`ITemplateService`) 无第三方 SDK 依赖;具体平台 SDK 由各通道子包引入。 ## 相关模块 - [XiHan.Framework.Bot.Email](./bot-email) - [XiHan.Framework.Bot.Sms](./bot-sms) - [XiHan.Framework.Bot.Telegram](./bot-telegram) - [XiHan.Framework.Bot.DingTalk](./bot-dingtalk) · [XiHan.Framework.Bot.Lark](./bot-lark) · [XiHan.Framework.Bot.WeCom](./bot-wecom) --- 来源:https://framework.docs.xihanfun.com/packages/caching # XiHan.Framework.Caching > 框架统一缓存层:基于 .NET `HybridCache` 的两级混合缓存 + 泛型分布式缓存 + AOP 声明式缓存 + 租户感知键;分布式锁与延迟队列默认是有界进程内实现,启用 Redis 后升级为跨实例实现,并额外提供 Stream 可靠队列。 - **NuGet**:`XiHan.Framework.Caching` - **模块类**:`XiHanCachingModule` - **所在层**:基础设施层 - **关键依赖**:**Microsoft.Extensions.Caching.Hybrid**(两级缓存)+ **Microsoft.Extensions.Caching.StackExchangeRedis** / **StackExchange.Redis**(Redis) ## 概述 本包在 .NET 内置的 `HybridCache` 与分布式缓存之上,封装出框架统一的缓存能力: - 用泛型接口 `IHybridCache` / `IDistributedCache` 让你以类型安全的方式缓存业务对象,屏蔽序列化与键拼接细节。 - 键经 `IDistributedCacheKeyNormalizer` 规范化,默认按当前租户加前缀,天然实现多租户隔离。 - 未配置 Redis 时全部回落到进程内内存实现(内存缓存 + 内存分布式缓存 + `DefaultDistributedLock` + `DefaultDelayQueue`),零外部依赖即可跑;配置 Redis 后自动换成 Redis 实现,锁与延迟队列跨实例生效,并额外提供 Stream 队列。 - 通过 AOP 拦截器支持 `[Cacheable]` 声明式缓存方法结果,并用 `[CacheEvict]` 在方法成功后清除缓存。 设计思路是"接口稳定、实现可切换":业务只依赖泛型接口,本地/Redis 的差异由 DI 装配期决定。 ## 何时使用 - 需要"内存 + Redis"两级缓存,降低热点数据的 Redis 往返。 - 想在多租户环境下缓存,且要求缓存自动按租户隔离、避免跨租户串数据。 - 想用特性声明式地缓存方法结果(`[Cacheable]`)。 - 需要基于 Redis 的分布式锁(`IDistributedLock`)、延迟队列(`IRedisDelayQueue`)或 Stream 可靠消息队列(`IRedisStreamQueue`)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Caching ``` ```csharp [DependsOn(typeof(XiHanCachingModule))] public class MyModule : XiHanModule; ``` 模块在 `ConfigureServices` 里调用 `AddXiHanCaching(config)`,一次性注册: - `AddMemoryCache()` + `AddDistributedMemoryCache()`(内存两级基座)。 - `IDistributedCacheKeyNormalizer` → `DefaultDistributedCacheKeyNormalizer`(租户感知键)。 - `IDistributedCacheSerializer` → `JsonDistributedCacheSerializer`(JSON 序列化)。 - 开放泛型 `IDistributedCache<>` / `IDistributedCache<,>` → `DistributedCache<>` / `DistributedCache<,>`。 - `AddHybridCache()` + 开放泛型 `IHybridCache<>` / `IHybridCache<,>` → `XiHanHybridCache<>` / `XiHanHybridCache<,>`。 - `IDistributedLock` → `DefaultDistributedLock`(`TryAdd`,最多 10000 个锁条目,满载时获取失败)。 - 开放泛型 `IRedisDelayQueue<>` → `DefaultDelayQueue<>`(最多 100000 条,满载时拒绝入队)。 - `XiHanDistributedCacheOptions`(全局条目默认 `SlidingExpiration = 20min`)与 `XiHanRedisCacheOptions`(绑定配置节)。 随后 `OnRegistered(CacheInterceptorRegistrar.RegisterIfNeeded)` 挂上缓存拦截器(为带 `[Cacheable]` 的类型织入 `CacheInterceptor`)。 动态代理只包裹接口注册,HTTP 请求进来的控制器实例不经过它。[XiHan.Framework.Web.Api](./web-api) 的 `XiHanCacheFilter` 补上这条路径:MVC 动作外层按同一套 `CacheAspect` 语义处理 `[Cacheable]` / `[CacheEvict]`,动态 API 的动作先经 `OriginalMethodAttribute` 回查原始应用服务方法再读取特性。它排在工作单元过滤器之外,命中缓存不开启事务、清除缓存发生在事务提交之后。 若 `XiHan:Caching:Redis:IsEnabled = true`,`AddXiHanCaching` 继续:调用 `AddStackExchangeRedisCache`,先 `RemoveAll()` 再注册 `XiHanRedisCache`;有连接串时 `TryAdd` 出 `IConnectionMultiplexer`、`IRedisStreamQueue<>`,并把 `IRedisDelayQueue<>` / `IDistributedLock` 分别 `Replace` 为 `RedisDelayQueue<>` / `RedisDistributedLock`。 ## 工作原理 **两级混合缓存**:`IHybridCache` 是对 .NET `HybridCache` 的泛型薄封装。读取时优先命中本地内存(L1),未命中回落分布式层(L2,内存或 Redis),再未命中执行工厂并回填两级;两级一致失效。`GetOrCreateAsync` 的 `optionsFactory` 可按调用定制 `HybridCacheEntryOptions`(本地/分布式过期时间)。 **键规范化(租户隔离)**:所有分布式缓存键先经 `DefaultDistributedCacheKeyNormalizer.NormalizeKey` 处理,产出形如 `{tenantId}:{cacheName}:{key}`。租户段取当前 `ICurrentTenantAccessor.Current.TenantId`;无租户上下文时用 `0`——平台就是 0 号租户,`null` 与 `0` 落同一个键段。`cacheName` 由 `CacheNameAttribute.GetCacheName()` 推断(有 `[CacheName]` 用其值,否则用类型全名去掉 `CacheItem` 后缀)。若某条查询要跨租户共享,可通过 `DistributedCacheKeyNormalizeArgs.IgnoreMultiTenancy` 跳过租户段。 **Redis 装配切换**:`IDistributedCache<>` 的模式匹配 / Lua 等高级能力,依赖底层 `IDistributedCache` 是否实现三个"能力"标记接口 —— `ICacheSupportsMultipleItems`、`ICacheSupportsKeyPattern`、`ICacheSupportsLuaScript`。`XiHanRedisCache`(继承自框架 `RedisCache`)实现了全部三者,因此启用 Redis 后 `GetKeys` / `RemoveByPattern` / `ScriptEvaluate` 才真正生效;内存分布式缓存不实现这些接口,相应方法在纯内存模式下不可用(会退化/抛错)。 **`considerUow` 与工作单元**:多数方法带 `bool considerUow = false`。传 `true` 时缓存写入会挂到当前工作单元,UoW 提交后才实际生效,读时也会考虑 UoW 内的暂存变更,保证事务内读到自己写的缓存。 ## 核心能力 - **两级混合缓存**:`IHybridCache` / `IHybridCache`,泛型封装 .NET `HybridCache`。 - **泛型分布式缓存**:`IDistributedCache` / `IDistributedCache`,同步/异步、单条/批量、GetOrAdd、Refresh、Exists、按模式查/删、Lua 脚本一应俱全。 - **租户感知键**:`DefaultDistributedCacheKeyNormalizer`,`TenantId` 前缀(平台用 `0`)。 - **AOP 声明式缓存**:`[Cacheable]` 缓存方法结果;`[CacheEvict]` 标注失效意图;`[CacheName]` 指定缓存名。 - **Redis 分布式锁**:`IDistributedLock`(`SET NX PX` + 释放校验持有者),带 `WithLockAsync` / `AcquireAsync`(等待重试)便捷扩展。 - **Redis 延迟队列**:`IRedisDelayQueue`,基于 Sorted Set,到期原子领取。 - **Redis Stream 可靠队列**:`IRedisStreamQueue`,消费组 + ACK + 崩溃重投(at-least-once)。 - **原生连接暴露**:Redis 启用后可直接注入 `IConnectionMultiplexer` 做定制操作,另有 `XiHanRedisExtensions` 提供批量 Hash 读取扩展。 ## 主要 API / 类型 ### 混合缓存 `IHybridCache`(键为 `string`)/ `IHybridCache` `TCacheItem` 需为 `class`。核心方法(均异步): | 方法 | 说明 | | --- | --- | | `Task GetOrCreateAsync(key, Func> factory, Func? optionsFactory = null, bool? hideErrors = null, bool considerUow = false, CancellationToken token = default)` | 命中返回;未命中执行工厂并回填两级 | | `Task SetAsync(key, TCacheItem value, HybridCacheEntryOptions? options = null, ...)` | 写入缓存 | | `Task RemoveAsync(key, ...)` | 移除单条 | | `Task RemoveManyAsync(IEnumerable keys, ...)` | 批量移除 | `IHybridCache` 额外暴露 `InternalCache`(底层 `IHybridCache`)。 ### 分布式缓存 `IDistributedCache` / `IDistributedCache` `TCacheItem` 需为 `class`,方法几乎都有同步 + `...Async` 两版,且带 `hideErrors` / `considerUow` 参数。关键方法: | 分组 | 方法 | | --- | --- | | 读取 | `TCacheItem? Get(key, ...)` · `Task GetAsync(...)` · `GetMany(keys, ...)` · `GetManyAsync(...)` | | 读或建 | `TCacheItem? GetOrAdd(key, Func factory, Func? optionsFactory = null, ...)` · `GetOrAddAsync(...)` · `GetOrAddMany(...)` · `GetOrAddManyAsync(...)` | | 写入 | `void Set(key, value, DistributedCacheEntryOptions? options = null, ...)` · `SetAsync(...)` · `SetMany(...)` · `SetManyAsync(...)` | | 刷新 | `void Refresh(key, ...)` · `RefreshAsync(...)` · `RefreshMany(...)` · `RefreshManyAsync(...)` | | 移除 | `void Remove(key, ...)` · `RemoveAsync(...)` · `RemoveMany(...)` · `RemoveManyAsync(...)` | | 存在性 | `bool Exists(key, ...)` · `Task ExistsAsync(...)` | | 模式操作(仅字符串键 + 支持的实现) | `TCacheKey[] GetKeys(string pattern = "*", ...)` · `GetKeysAsync(...)` · `long RemoveByPattern(string pattern = "*", ...)` · `RemoveByPatternAsync(...)` | | Lua 脚本(仅支持的实现) | `RedisResult? ScriptEvaluate(string script, IEnumerable? keys = null, IEnumerable? values = null, ...)` · `ScriptEvaluateAsync(...)` | ### 分布式锁 `IDistributedLock` ```csharp Task TryAcquireAsync(string resourceKey, TimeSpan expiry, CancellationToken cancellationToken = default); ``` 单次尝试,拿不到返回 `null`(不阻塞、不重试)。`IDistributedLockHandle`(`IDisposable` + `IAsyncDisposable`)暴露 `ResourceKey` / `LockId` / `IsReleased`,方法 `Task ReleaseAsync()`(幂等,仅删自己持有的)与 `Task ExtendAsync(TimeSpan expiry, ...)`(续期)。便捷扩展 `DistributedLockExtensions`: - `Task WithLockAsync(this IDistributedLock, resourceKey, expiry, Func action, ...)` —— 拿到就执行并自动释放,拿不到返回 `false`。 - `Task<(bool Acquired, TResult? Result)> WithLockAsync(..., Func> func, ...)` —— 带返回值版本。 - `Task AcquireAsync(..., TimeSpan wait, TimeSpan? pollInterval = null, ...)` —— 等待重试直到拿到或超时(默认轮询 100ms)。 ### Redis 延迟队列 `IRedisDelayQueue` 基于 Sorted Set,`score = 到期时间戳(ms)`,每个消息类型 `T` 独占一个 ZSET(键由类型派生)。单例,一封闭类型一实例。 | 方法 | 说明 | | --- | --- | | `Task EnqueueAsync(T item, TimeSpan delay, ...)` | 延迟 `delay` 后可被取出 | | `Task EnqueueAtAsync(T item, DateTimeOffset dueTime, ...)` | 到 `dueTime` 后可被取出 | | `Task> DequeueDueAsync(int count, ...)` | 原子领取已到期消息(多消费者不重复),领取即移除 | | `Task CountAsync(...)` | 队列总数(含未到期) | 延迟精度 ≈ 消费者轮询周期(到期项不主动唤醒,需定期 `DequeueDueAsync`)。 ### Redis Stream 可靠队列 `IRedisStreamQueue` 基于 Redis Streams + 消费组,at-least-once。每个 `T` 独占一个 Stream。 | 方法 | 说明 | | --- | --- | | `Task EnqueueAsync(T item, ...)` | 入队并广播唤醒,返回条目 ID | | `Task>> ReadAsync(string consumer, int count, ...)` | 消费组读一批新消息(进入 PEL 待确认) | | `Task AckAsync(IEnumerable messageIds, ...)` | 确认处理完成(移出 PEL) | | `Task>> ClaimStaleAsync(string consumer, TimeSpan minIdle, int count, ...)` | 认领空闲超阈值的待确认消息(崩溃重投),用于重试 | | `Task CountAsync(...)` | Stream 当前长度(积压估算) | | `Task WaitForSignalAsync(TimeSpan timeout, ...)` | 阻塞等待"有新消息",被入队唤醒或超时返回;替代空转轮询 | `RedisStreamMessage` 为 `readonly record struct`,含 `Id` / `Value` / `DeliveryCount`(首次为 1,每次认领重投 +1,可据此转死信)。 ### AOP 特性 | 特性 | 作用域 | 可配置项 | | --- | --- | --- | | `[Cacheable]` | 方法(`AllowMultiple = false`) | `string Key`(键模板,支持 `{paramName}` 占位)、`int ExpireSeconds`(默认 300;本地过期取 `min(ExpireSeconds, 60)`) | | `[CacheEvict]` | 方法(`AllowMultiple = true`) | `string Key`(键模板) | | `[CacheName]` | 类 / 接口 / 结构 | 构造参数 `name`,指定缓存名(覆盖类型名推断) | 键模板由 `CacheKeyBuilder.Build(template, invocation)` 渲染:把 `{参数名}` 替换为对应实参 `ToString()`(`null` → `"null"`)。占位符正则为 `\{[a-zA-Z_]\w*\}`。 读写语义由 `CacheAspect` 承载,`CacheInterceptor`(进程内)与 `XiHanCacheFilter`(HTTP)共用它,两条入口的键构建、过期时间与命中判定一致。目标方法成功执行后渲染每个 `[CacheEvict]` 的键模板,并调用底层 `HybridCache.RemoveAsync`,同时清除 L1 与 L2。目标方法抛异常时既不写入缓存也不执行清理;同一方法可声明多个 `[CacheEvict]`。 ## 配置 配置节:`XiHan:Caching:Redis`(`XiHanRedisCacheOptions.SectionName`)。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `IsEnabled` | `bool` | `false` | 是否启用 Redis(关闭时全部回落内存实现) | | `Configuration` | `string` | `""` | StackExchange.Redis 连接串,如 `127.0.0.1:6379,password=...,defaultDatabase=0` | | `InstanceName` | `string?` | `null` | 键前缀(实例名) | | `ConnectTimeout` | `int` | `5000` | 连接超时(ms) | | `SyncTimeout` | `int` | `5000` | 同步超时(ms) | | `AsyncTimeout` | `int` | `5000` | 异步超时(ms) | | `AllowAdmin` | `bool` | `false` | 是否允许管理员操作 | | `UseSsl` | `bool` | `false` | 是否使用 SSL | | `AbortOnConnectFail` | `bool` | `false` | 连接失败是否中止 | > 注意:当前 `AddXiHanCaching` 在装配 `AddStackExchangeRedisCache` 时只读取 `Configuration` 与 `InstanceName`;`ConnectTimeout` / `SyncTimeout` / `AllowAdmin` 等字段应写在连接串里生效,或供上层自行读取,装配代码本身未单独消费它们。全局条目默认滑动过期为 20 分钟(来自 `XiHanDistributedCacheOptions.GlobalCacheEntryOptions`)。 示例 `appsettings.json`: ```json { "XiHan": { "Caching": { "Redis": { "IsEnabled": true, "Configuration": "127.0.0.1:6379,password=mypassword,defaultDatabase=0,abortConnect=false", "InstanceName": "Default:" } } } } ``` ## 使用示例 ### 泛型混合缓存:读或建 ```csharp public class ProfileService(IHybridCache cache) { public Task GetProfileAsync(string userId) { // 命中即返回;未命中执行工厂并写入两级缓存(键已按当前租户隔离) return cache.GetOrCreateAsync( userId, () => LoadFromDbAsync(userId), optionsFactory: () => new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10), LocalCacheExpiration = TimeSpan.FromMinutes(1) }); } } ``` ### 声明式缓存 ```csharp public interface IConfigAppService { [Cacheable(Key = "config:{tenantId}:{key}", ExpireSeconds = 300)] Task GetAsync(string tenantId, string key); [CacheEvict(Key = "config:{tenantId}:{key}")] Task UpdateAsync(string tenantId, string key, string value); } ``` ### 分布式锁(拿到才执行) ```csharp public class ReportJob(IDistributedLock distributedLock) { public async Task RunAsync(CancellationToken ct) { var ran = await distributedLock.WithLockAsync( resourceKey: "report:daily", expiry: TimeSpan.FromMinutes(5), action: async token => await GenerateReportAsync(token), cancellationToken: ct); if (!ran) { // 未抢到锁,说明已有实例在跑,跳过 } } } ``` ### 延迟队列(提交即入队、后台拉取消费) ```csharp // 生产:30 分钟后再处理 await delayQueue.EnqueueAsync(new OrderTimeoutMessage(orderId), TimeSpan.FromMinutes(30), ct); // 消费(后台服务循环):取出已到期的 var due = await delayQueue.DequeueDueAsync(count: 50, ct); ``` ## 扩展点 / 自定义 - **替换键规范化策略**:`IDistributedCacheKeyNormalizer` 用 `AddSingleton`(非 `TryAdd`)注册默认实现;如需改键格式,在你自己的模块里再次注册覆盖即可。 - **替换分布式锁**:`IDistributedLock` 默认用 `TryAddSingleton` 注册进程内实现,Redis 启用时框架用 `Replace` 升级为 Redis 实现;你也可自行 `Replace` 成自定义实现。 - **自定义序列化**:`IDistributedCacheSerializer` 默认 `JsonDistributedCacheSerializer`,可覆盖。 - **能力标记接口**:自定义 `IDistributedCache` 实现若想支持按模式/Lua,需实现 `ICacheSupportsMultipleItems` / `ICacheSupportsKeyPattern` / `ICacheSupportsLuaScript`。 - **原生 Redis**:Redis 启用后可直接注入 `IConnectionMultiplexer` 做框架未封装的操作。 ## 注意事项与最佳实践 - **纯内存模式的能力边界**:未启用 Redis 时,`GetKeys` / `RemoveByPattern` / `ScriptEvaluate` 等依赖能力接口的方法不可用;分布式锁仅进程内有效(多实例部署无跨实例互斥,最多 10000 个锁条目,满载时获取返回 `null`);延迟队列同样只在进程内(最多 100000 条,满载入队抛 `InvalidOperationException`)。这些能力务必在启用 Redis 后使用。 - **`[CacheEvict]` 只在方法成功后清理**:键模板必须与读取侧一致;事务写入若要求“提交后再失效”,应继续使用带 `considerUow` 的缓存接口或应用侧提交后失效机制,避免数据库回滚后缓存已被提前清除。 - **租户隔离是默认行为**:键自动带租户前缀;要跨租户共享缓存需显式走 `IgnoreMultiTenancy`,否则不同租户天然隔离。 - **`hideErrors`**:多数方法支持隐藏分布式缓存异常(缓存故障不影响主流程);对一致性敏感的场景应显式传 `false` 让异常冒泡。 - **后台队列消费**:延迟/Stream 队列建议配合框架后台服务基类拉取消费(提交后入队、原子领取、启动恢复),避免自行轮询。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions)(租户感知键) - [XiHan.Framework.Serialization](./serialization)(JSON 序列化) - [XiHan.Framework.Threading](./threading) - [XiHan.Framework.Uow](./uow)(工作单元感知 `considerUow`) - 第三方核心:`Microsoft.Extensions.Caching.Hybrid`、`Microsoft.Extensions.Caching.StackExchangeRedis`、`StackExchange.Redis` ## 相关模块 - [XiHan.Framework.Uow](./uow) - [XiHan.Framework.Data](./data) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions) --- 来源:https://framework.docs.xihanfun.com/packages/castle # XiHan.Framework.Castle > AOP 动态代理底座:集成 Castle DynamicProxy,把框架的异步拦截器链 `IXiHanInterceptor[]` 适配到 Castle 的同步 `IInterceptor`,在服务注册完成后自动为登记了拦截器的接口服务生成代理。 - **NuGet**:`XiHan.Framework.Castle` - **模块类**:`XiHanCastleModule` - **所在层**:基础设施层 - **关键依赖**:**Castle.Core**(`Castle.DynamicProxy`);框架内部仅依赖 Core ## 概述 XiHan.Framework.Castle 是框架的 AOP(面向切面)实现层。拦截器的抽象契约(`IXiHanInterceptor`、`IXiHanMethodInvocation`、抽象基类 `XiHanInterceptor`)定义在 [Core](./core),本包只提供把这套「框架自有、天然异步」的拦截模型落到 Castle DynamicProxy 上的具体实现。 它做两件事:一是在服务注册全部完成后(`PostConfigureServices` 阶段),遍历 DI 容器,为满足条件的接口服务用「代理版本」替换原注册;二是在代理运行时,用 `CastleInterceptorAdapter` 把框架的异步拦截器链适配为 Castle 要求的同步 `IInterceptor`,并正确处理 `void` / `Task` / `Task` 三种返回形态。工作单元(Uow)、缓存(Caching)等模块的方法级切面能力,正是建立在它之上。 ## 何时使用 - 需要给服务方法透明地织入横切逻辑(事务、缓存、审计等),而不侵入业务代码。 - 你的模块要注册自定义拦截器,让被拦截的接口服务在启动时自动被代理。 - 依赖 Uow / Caching 等基于拦截器的能力时,需要引入这个 AOP 前置包(它们的模块通常已 `[DependsOn]` 它)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Castle ``` ```csharp [DependsOn(typeof(XiHanCastleModule))] public class MyModule : XiHanModule { } ``` 模块在 `PostConfigureServices` 阶段调用 `services.AddCastleDynamicProxy()`。选在 `PostConfigureServices` 是关键:此时所有模块的 `ConfigureServices` 都已跑完,容器里的服务描述符与各模块登记的拦截器回调都齐了,代理化才能一次性覆盖全部目标服务。 ## 工作原理 拦截器的「登记」与「织入」是分离的两步: 1. **登记(各业务模块做)**:模块在 `ConfigureServices` 里调用 `services.OnRegistered(callback)` 注册一个回调。该回调对每个待检查的服务收到一个 `IOnServiceRegistredContext`(接口暴露 `ImplementationType`、可写的 `Interceptors` 列表;本包内部构造的具体实现类 `OnServiceRegistredContext` 还额外带一个 `ServiceType` 属性,但回调签名只声明为接口类型),按需 `context.Interceptors.TryAdd()`。例如 Uow 模块:`services.OnRegistered(UnitOfWorkInterceptorRegistrar.RegisterIfNeeded)`,其内部对带 `[UnitOfWork]` 的服务 `TryAdd()`。 2. **织入(本包在 `AddCastleDynamicProxy()` 里做)**: - 若 `services.IsClassInterceptorsDisabled()` 为真,或没有任何登记回调,直接返回(不代理)。 - 遍历所有服务描述符,筛选「`ServiceType` 是接口 + 有 `ImplementationType` + 不在 `DynamicProxyIgnoreTypes` 忽略名单」的项,对每项构造 `OnServiceRegistredContext` 并回放全部登记回调;只要最终 `Interceptors.Count > 0`,就记入待代理集合。 - 对每个待代理项,用代理版描述符替换原描述符:解析器里先创建原始实例(走 `ImplementationInstance` / `ImplementationFactory` / `ActivatorUtilities.CreateInstance` 三种来源之一),解析出拦截器实例数组,包进 `CastleInterceptorAdapter`,再用共享的 `ProxyGenerator.CreateInterfaceProxyWithTarget(serviceType, target, adapter)` 生成接口代理;生命周期沿用原描述符的 `Lifetime`。 **运行时链式执行**(`CastleInterceptorAdapter.Intercept`): - 捕获 `IInvocation.CaptureProceedInfo()`,包成 `CastleXiHanMethodInvocation`。 - 按方法返回类型分派:`void` → 同步执行拦截链(内部对异步链 `GetAwaiter().GetResult()`);`Task` → 返回执行链的 `Task`;`Task` → 反射 `MakeGenericMethod` 走 `HandleAsyncWithResult` 拿回结果。 - 拦截链按登记顺序递归执行:第 `i` 个拦截器拿到一个 `ChainedMethodInvocation`(其 `ProceedAsync()` 触发第 `i+1` 个),到链尾则调用 `CastleXiHanMethodInvocation.ProceedAsync()` → `IInvocationProceedInfo.Invoke()` 调真实目标方法,若返回 `Task` 则 `await`。 ## 核心能力 - **Castle DynamicProxy 集成**:基于 `Castle.Core` 生成接口代理(`CreateInterfaceProxyWithTarget`),全局复用一个 `ProxyGenerator` 实例。 - **异步拦截器链适配**:`CastleInterceptorAdapter` 把框架的 `IXiHanInterceptor[]` 链适配为 Castle 同步 `IInterceptor`,正确处理 `void` / `Task` / `Task`(含泛型结果)。 - **方法调用上下文包装**:`CastleXiHanMethodInvocation` 把 Castle 的 `IInvocation` 适配为框架的 `IXiHanMethodInvocation`(参数数组、参数名字典、泛型参数、目标对象、`MethodInfo`、可读写返回值、`ProceedAsync`)。 - **DI 自动代理化**:`AddCastleDynamicProxy()` 在注册阶段自动识别需代理的接口服务并原地替换描述符,无需逐个手工配置;支持全局开关与忽略名单。 - **拦截器登记回调机制**:各模块通过 `services.OnRegistered(...)` + `context.Interceptors.TryAdd()` 把拦截器加入拦截链,按登记顺序执行。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanCastleModule` | 模块类,`PostConfigureServices` 阶段调用 `AddCastleDynamicProxy()` 启动代理化 | | `ServiceCollectionCastleExtensions` | DI 扩展,含 `IServiceCollection AddCastleDynamicProxy(this IServiceCollection services)` | | `CastleInterceptorAdapter` | 实现 Castle `IInterceptor`,构造参数 `IXiHanInterceptor[]`,适配异步拦截链 | | `CastleXiHanMethodInvocation` | 实现 `IXiHanMethodInvocation`,适配 Castle 的 `IInvocation` + `IInvocationProceedInfo` | > 拦截器抽象本身定义在 [XiHan.Framework.Core](./core):`IXiHanInterceptor`(`Task InterceptAsync(IXiHanMethodInvocation)`)、`IXiHanMethodInvocation`、抽象基类 `XiHanInterceptor`;以及登记侧的 `IOnServiceRegistredContext`、`OnRegistered(...)`、`DynamicProxyIgnoreTypes`、`IsClassInterceptorsDisabled()`。本包只提供 Castle 实现,不重复定义这些抽象。 ## 使用示例 写一个自定义拦截器(继承 Core 的抽象基类),在方法前后打点: ```csharp public class TimingInterceptor : XiHanInterceptor { private readonly ILogger _logger; public TimingInterceptor(ILogger logger) => _logger = logger; public override async Task InterceptAsync(IXiHanMethodInvocation invocation) { var sw = Stopwatch.StartNew(); await invocation.ProceedAsync(); // 继续链 / 调真实方法 sw.Stop(); _logger.LogInformation("{Method} 耗时 {Ms}ms", invocation.Method.Name, sw.ElapsedMilliseconds); } } ``` 在模块里登记:注册拦截器本身,并用 `OnRegistered` 决定哪些服务被它拦截: ```csharp [DependsOn(typeof(XiHanCastleModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { var services = context.Services; // 拦截器需可被 DI 解析(适配器运行时用 GetRequiredService 取实例) services.AddTransient(); // 对满足条件的接口服务登记该拦截器 services.OnRegistered(ctx => { if (typeof(IMyService).IsAssignableFrom(ctx.ImplementationType)) { ctx.Interceptors.TryAdd(); } }); } } ``` `XiHanCastleModule` 的 `PostConfigureServices` 会在最后把 `IMyService` 的注册替换成带 `TimingInterceptor` 的代理,无需其它手工代码。 ## 扩展点 / 自定义 - **自定义拦截器**:继承 `XiHanInterceptor`(或实现 `IXiHanInterceptor`),实现 `InterceptAsync`;务必调用 `invocation.ProceedAsync()` 才会继续后续拦截器与真实方法。 - **控制拦截范围**:全靠 `OnRegistered` 回调里的判断(按接口、`ImplementationType`、特性等)决定 `TryAdd` 哪个拦截器;顺序即执行顺序。 - **忽略某些实现**:把类型加入 Core 的 `DynamicProxyIgnoreTypes`,代理化会跳过它。 - **全局关闭类拦截**:`services.DisableClassInterceptors()`(Core)会让 `AddCastleDynamicProxy()` 直接短路。 ## 注意事项与最佳实践 - **只代理接口服务**:仅对「`ServiceType` 是接口且有具体 `ImplementationType`」的注册生效;用 `ImplementationInstance`/工厂注册的仍会被包装原实例,但纯类型(非接口)服务不被代理。 - **拦截器必须可解析**:适配器在运行时用 `GetRequiredService(interceptorType)` 取拦截器,所以拦截器类型必须已注册到 DI,否则解析代理实例时抛异常。 - **时机固定在 `PostConfigureServices`**:代理化是一次性、在所有 `ConfigureServices` 之后完成;运行时再往容器加服务不会被自动代理。 - **异步优先**:拦截器天然是 `Task` 语义;对返回 `void` 的方法,适配器会同步阻塞等待异步链完成(`GetAwaiter().GetResult()`),横切逻辑里避免长阻塞/死锁风险。 - **顺序敏感**:多个拦截器按 `OnRegistered` 登记(`TryAdd`)顺序形成链,前者先进后出地包裹后者。 ## 依赖模块 - 内部依赖:仅 [XiHan.Framework.Core](./core)(提供拦截器抽象、`IOnServiceRegistredContext`、`OnRegistered`、忽略名单等契约)。 - 第三方核心:**Castle.Core**(`Castle.DynamicProxy`)。 ## 相关模块 - [XiHan.Framework.Uow](./uow) — `UnitOfWorkInterceptor` 依赖本包织入 `[UnitOfWork]` 事务切面(`XiHanUowModule` 用 `OnRegistered(UnitOfWorkInterceptorRegistrar.RegisterIfNeeded)` 登记)。 - [XiHan.Framework.Caching](./caching) — 缓存拦截器依赖本包实现方法级缓存切面。 --- 来源:https://framework.docs.xihanfun.com/packages/core # XiHan.Framework.Core > 框架的模块化引擎与运行时内核:定义 `XiHanModule` 基类、`[DependsOn]` 依赖声明、模块描述符与拓扑排序加载、7 阶段生命周期钩子、应用引导工厂、约定式依赖注入、选项模式与统一异常体系。上层所有模块都直接或间接依赖它。 - **NuGet**:`XiHan.Framework.Core` - **模块类**:—(它本身提供 `XiHanModule` 基类与整套模块系统,无独立模块类) - **所在层**:核心层 - **关键依赖**:`Microsoft.Extensions.Hosting`、`Microsoft.Extensions.Localization`(其余为 .NET 原生 `Microsoft.Extensions.DependencyInjection/Options/Configuration/Logging` + 框架内部 `XiHan.Framework.Metadata` / `XiHan.Framework.Utils`) ## 概述 XiHan.Framework.Core 是整个框架的引擎。它把"应用"抽象为一组**模块**:每个功能包都是一个继承 `XiHanModule` 的类,用 `[DependsOn(typeof(...))]` 声明它依赖哪些模块。框架从**启动模块**出发递归发现所有依赖,做拓扑排序,然后按序回调每个模块的生命周期钩子——先是服务注册三阶段(Pre / Configure / Post),再是(应用初始化时)初始化三阶段与关机钩子。 在这套模块流程之上,Core 还提供:约定式依赖注入(实现 `ITransientDependency` / `IScopedDependency` / `ISingletonDependency` 标记接口即自动注册)、`AddApplication()` 引导入口、选项模式增强(`PreConfigure` / 动态选项管理器)、统一异常体系(`XiHanException` / `BusinessException` / `UserFriendlyException`),以及反射、状态检查、动态代理抽象、追踪等基础设施抽象。 Core 只依赖 `Metadata` 与 `Utils`,不引用任何 Web / 数据 / 领域包,因此可以独立引用于任何 .NET Host(控制台、Worker、ASP.NET Core)。 ## 何时使用 - 编写自己的功能模块:继承 `XiHanModule`,用 `[DependsOn]` 声明依赖,在 `ConfigureServices` 里注册服务、在 `OnApplicationInitialization` 里挂载中间件。 - 需要在应用启动、初始化、关机的特定阶段挂载逻辑(7 阶段钩子)。 - 需要约定式服务注册(标记接口/特性),或 `IServiceCollection.AddApplication` 引导整个应用。 - 需要选项模式的预配置(`PreConfigure`)或运行时可覆盖的动态选项。 - 需要框架统一的业务异常 / 用户友好异常体系。 > 说明:`ApplicationInitializationContext` 在 Core 里**只暴露 `ServiceProvider`**。常用的 `GetApplicationBuilder()` / `GetConfiguration()` / `GetEnvironment()` 是 **Web 层扩展方法**(定义在 `XiHan.Framework.Web.Core` 的 `ApplicationInitializationContextExtensions`),仅在 Web 应用中可用;纯 Core 场景请从 `context.ServiceProvider` 自行解析。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Core ``` Core 提供 `XiHanModule` 基类本身。业务模块通过继承它并用 `[DependsOn]` 声明依赖来接入模块系统: ```csharp [DependsOn( typeof(XiHanAuthorizationModule), typeof(XiHanCachingModule))] public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { // 注册服务;context.Services 即 IServiceCollection } public override void OnApplicationInitialization(ApplicationInitializationContext context) { // 应用初始化时执行(Web 下通常在这里挂中间件) } } ``` 启用后(即被 `AddApplication` 引导时)Core 会自动注册以下核心服务(见 `InternalServiceCollectionExtensions.AddCoreServices`): - `AddOptions()` / `AddLogging()` / `AddLocalization()`(.NET 原生基础设施)。 - `IModuleLoader`(默认 `ModuleLoader`)、`IAssemblyFinder`(`AssemblyFinder`)、`ITypeFinder`(`TypeFinder`)、`IInitLoggerFactory`(`DefaultInitLoggerFactory`)—— 均以 `TryAddSingleton` 注册,可替换。 - `IXiHanApplication` / `IApplicationInfoAccessor` / `IModuleContainer` / `IXiHanHostEnvironment` 指向应用实例本身。 - `AutowiredServiceHandler`(属性/字段注入处理器)。 - `ISimpleStateCheckerManager<>`(简单状态检查,Transient)。 - 四个默认模块生命周期贡献者(`XiHanModuleLifecycleOptions.Contributors`,见下文"工作原理")。 - 若未注册 `IConfiguration`,则用 `XiHanConfigurationBuilderOptions` 构建一份并 `ReplaceConfiguration`。 ## 工作原理 ### 应用引导流程 以 Web Host 为典型(`AddApplicationAsync` → `InitializeApplicationAsync`): ```text 1. Services.AddApplication(options) └─ XiHanApplicationFactory.Create → new XiHanApplicationWithExternalServiceProvider ├─ 注册自身为 IXiHanApplication / IApplicationInfoAccessor / IModuleContainer / IXiHanHostEnvironment ├─ AddCoreServices():Options/Logging/Localization + 模块系统服务 + 生命周期贡献者 ├─ LoadModules():IModuleLoader.LoadModules → 发现全部模块 + 拓扑排序 └─ ConfigureServices()(同步;异步入口会 SkipConfigureServices=true 延后) ├─ PreConfigureServices (每个模块,按依赖序) ├─ ConfigureServices (AddAssembly 约定式注册 + 每模块 ConfigureServices) └─ PostConfigureServices (每个模块) 2. app.InitializeApplicationAsync() → application.InitializeAsync(app.ApplicationServices) ├─ SetServiceProvider(sp):把真实根容器写回 ObjectAccessor └─ IModuleManager.InitializeModulesAsync():在一个新 scope 内依次跑生命周期贡献者 ├─ OnPreApplicationInitialization (每个模块) ├─ OnApplicationInitialization (每个模块) └─ OnPostApplicationInitialization (每个模块) 3. 应用关闭:IModuleManager.ShutdownModulesAsync() → OnApplicationShutdown(模块逆序) ``` 要点: - **拓扑排序**:`ModuleLoader.SortByDependency` 用 `SortByDependencies(m => m.Dependencies)` 排序,保证被依赖模块先于依赖方;随后把**启动模块移到最后**(`MoveItem(... , count-1)`),确保它的钩子最后执行。 - **服务配置阶段** vs **初始化阶段**:前者在容器构建**之前**(只能操作 `IServiceCollection`),后者在容器构建**之后**(可以从 `ServiceProvider` 解析服务、挂中间件)。 - **每个模块类是单例**:`ModuleLoader.CreateAndRegisterModule` 用 `Activator.CreateInstance` 建实例并 `AddSingleton(moduleType, instance)`,同一 `IXiHanModule` 实例贯穿服务配置与初始化。 - **异常隔离**:任一模块在服务配置阶段抛错,包装为 `InitializationException`;初始化/关闭阶段抛错,分别包装为 `InitializationException` / `ShutdownException`,消息含出错模块的 `AssemblyQualifiedName`。 ### 生命周期贡献者机制 模块的初始化/关闭并非直接调用,而是通过**贡献者**(`IModuleLifecycleContributor`)分派。`AddCoreServices` 默认注册四个贡献者到 `XiHanModuleLifecycleOptions.Contributors`: | 贡献者 | 对应钩子接口 | 阶段 | | --- | --- | --- | | `OnPreApplicationInitializationModuleLifecycleContributor` | `IOnPreApplicationInitialization` | 初始化前 | | `OnApplicationInitializationModuleLifecycleContributor` | `IOnApplicationInitialization` | 初始化 | | `OnPostApplicationInitializationModuleLifecycleContributor` | `IOnPostApplicationInitialization` | 初始化后 | | `OnApplicationShutdownModuleLifecycleContributor` | `IOnApplicationShutdown` | 关闭 | `ModuleManager` 对每个贡献者、每个模块做双重循环:初始化时按模块拓扑序、关闭时**逆序**(`Modules.Reverse()`)。你可以向 `Contributors` 追加自定义贡献者来插入新的生命周期阶段。 ### 约定式依赖注入 `ConfigureServices` 阶段,Core 对每个模块的 `AllAssemblies` 调用 `Services.AddAssembly(assembly)`,由 `DefaultConventionalRegistrar` 扫描其中的具体类并自动注册。生命周期解析优先级(`ConventionalRegistrarBase.GetLifeTimeOrNull`): 1. `[Dependency(ServiceLifetime.X)]` 特性显式指定; 2. 类层次实现的标记接口:`ITransientDependency` → Transient,`ISingletonDependency` → Singleton,`IScopedDependency` → Scoped; 3. 默认返回 `null` → **不注册**(既没特性也没标记接口的类会被跳过)。 暴露的服务类型由 `[ExposeServices]` / `[ExposeKeyedService]` 或默认约定(接口名去掉前导 `I` 后是类名后缀,如 `FooService : IFooService`)决定。`[Dependency]` 上的 `TryRegister` / `ReplaceServices` 决定用 `TryAdd` 还是 `Replace`(默认 `Add`)。用 `[DisableConventionalRegistration]` 可让某类退出约定扫描。 ## 核心能力 - **模块化系统**:`XiHanModule` 基类 + `[DependsOn]`,`XiHanModuleHelper` 递归发现模块(并打印目录树日志),`ModuleLoader` 建描述符并拓扑排序,`ModuleManager` 驱动生命周期。详见 [模块化](../guide/modularity)。 - **7 阶段生命周期**:服务注册三阶段 + 初始化三阶段 + 关机,每阶段同步/异步双版本。详见 [生命周期](../guide/lifecycle)。 - **应用引导**:`IServiceCollection.AddApplication()` / `AddApplicationAsync()` 从启动模块引导;`XiHanApplicationFactory` 支持"外部容器"(Web/Host)与"内部容器"(独立自建 `ServiceCollection`)两种模式。 - **远程/集成服务标记**:`IRemoteService` 空标记接口——`XiHan.Framework.Application.Contracts` 的 `IApplicationService` 即继承自它,是动态 API 自动暴露为 REST 的判定基础(`XiHan.Framework.Web.Api` 的 `TypeHelper` 用 `IApplicationService.IsAssignableFrom(type)` 识别应用服务类型);配套的细粒度控制特性 `RemoteServiceAttribute`(`IsEnabled` / `IsMetadataEnabled` / `Name` 分组名 + `IsExplicitlyEnabledFor` 等静态判定)、`IntegrationServiceAttribute`(标记集成服务,`IsDefinedOrInherited` 支持接口继承判定)、`ApplicationServiceTypes`(`[Flags]` 枚举:`ApplicationServices` / `IntegrationServices` / `All`)、`DisableXiHanFeaturesAttribute`(按类禁用拦截器 / 中间件 / MVC 过滤器)均定义在 Core,供上层按需读取。 - **约定式依赖注入**:标记接口 / `[Dependency]` / `[ExposeServices]` / `[ExposeKeyedService]` / `[DisableConventionalRegistration]`,可插拔 `IConventionalRegistrar`。详见 [依赖注入](../guide/dependency-injection)。 - **缓存式服务提供器**:`ICachedServiceProvider`(scoped,缓存含瞬态在内的所有已解析服务)/ `ITransientCachedServiceProvider` / `IRootServiceProvider`。 - **属性/字段注入**:`[AutowiredService]` + `AutowiredServiceHandler.Autowired(this)`(推荐构造函数注入,此为特殊情况兜底)。 - **选项模式增强**:`PreConfigure` 预配置、`XiHanDynamicOptionsManager`(运行时可覆盖选项)、`XiHanOptionsFactory`。 - **异常体系**:`XiHanException`、`BusinessException`、`UserFriendlyException`、`InitializationException` / `ShutdownException`,异常语义接口 `IHasErrorCode` / `IHasErrorDetails` / `IHasHttpStatusCode` / `IHasLogLevel` / `ILocalizeErrorMessage`,及 `IExceptionNotifier` / `IExceptionSubscriber` 通知链。 - **反射与类型发现**:`IAssemblyFinder` / `ITypeFinder`。 - **简单状态检查**:`ISimpleStateChecker` / `ISimpleStateCheckerManager`(权限/特性开关等布尔判定的通用框架)。 - **动态代理抽象**:`IXiHanInterceptor` / `IXiHanMethodInvocation` / `ProxyHelper`(Castle 代理的 `UnProxy` / `GetUnProxiedType`)。 - **追踪**:`ICorrelationIdProvider` / `DefaultCorrelationIdProvider`(分布式关联唯一标识);`XiHanActivitySources`(框架共享 `ActivitySource` 名与实例:`App`/`Data`/`EventBus`/`Grpc`/`Cache`/`Ai` 六个源常量 + 对应实例,`All` 数组供 `XiHan.Framework.Observability` 一次性 `AddSource` 批量注册)。 - **横切关注点去重**:`XiHanCrossCuttingConcerns`(静态门面:`Applying`/`AddApplied`/`RemoveApplied`/`IsApplied`/`GetApplieds`)+ `IAvoidDuplicateCrossCuttingConcerns`(`AppliedCrossCuttingConcerns` 列表),用于标记某对象已应用过某横切逻辑,避免嵌套调用中重复执行(如拦截器判定"是否已处理过");`XiHan.Framework.Web.Api` 的 `XiHanController` 已实现该接口,为其派生控制器提供去重挂载点。 - **异步与锁工具**:`AsyncHelper`(`RunSync` 同步等待异步方法、`UnwrapTask` 拆 `Task`/`Task` 真实返回类型)、`AsyncLock`(信号量实现的异步友好独占锁,`LockAsync()`/`LockAsync(timeout)`/`Lock()`)、`SemaphoreSlimExtensions`(`SemaphoreSlim.LockAsync()`/`Lock()` 系列,返回 `IDisposable` 自动释放)、`LockExtensions`(`object.Locking(...)` 简化 `lock` 语句)、`AsyncExtensions`(`MethodInfo.IsAsync()` / `Type.IsTaskOrTaskOfT()` / `IsTaskOfT()` 反射判定)。 - **插件源**:`FolderPlugInSource` / `FilePlugInSource` / `TypePlugInSource`,从目录、程序集或类型加载额外模块。 ## 主要 API / 类型 ### 模块系统 | 类型 | 说明 | | --- | --- | | `XiHanModule`(abstract) | 所有模块基类,实现全部钩子接口的虚方法;提供 `Configure/PreConfigure/PostConfigure`;`SkipAutoServiceRegistration` 可关闭本模块的约定扫描 | | `IXiHanModule` | 最小契约:`ConfigureServices` / `ConfigureServicesAsync` | | `DependsOnAttribute`(`[AttributeUsage(Class, AllowMultiple=true)]`) | `DependsOn(params Type[])`,实现 `IDependedTypesProvider.GetDependedTypes()` 驱动依赖发现 | | `AdditionalAssemblyAttribute` | 声明模块的附加程序集(`IAdditionalModuleAssemblyProvider`),一并纳入约定扫描 | | `IModuleDescriptor` / `XiHanModuleDescriptor` | 模块描述符:`Type` / `Assembly` / `AllAssemblies` / `Instance`(单例)/ `IsLoadedAsPlugIn` / `Dependencies` | | `IModuleLoader` / `ModuleLoader` | `IModuleDescriptor[] LoadModules(IServiceCollection, Type startupModuleType, PlugInSourceList)` | | `IModuleManager` / `ModuleManager` | `InitializeModules(Async)(ApplicationInitializationContext)` / `ShutdownModules(Async)(ApplicationShutdownContext)` | | `IModuleContainer` | `IReadOnlyList Modules` | | `XiHanModuleHelper`(static) | `FindAllModuleTypes` / `FindDependedModuleTypes` / `GetAllAssemblies` / `IsXiHanModule` | | `IModuleLifecycleContributor` / `ModuleLifecycleContributorBase` | 生命周期分派器基类 | | `XiHanModuleLifecycleOptions` | `ITypeList Contributors` | ### 生命周期钩子接口(均含同步 + 异步双方法) | 阶段 | 接口 | 方法 | | --- | --- | --- | | 服务注册前 | `IPreConfigureServices` | `PreConfigureServices(Async)(ServiceConfigurationContext)` | | 服务注册 | `IXiHanModule` | `ConfigureServices(Async)(ServiceConfigurationContext)` | | 服务注册后 | `IPostConfigureServices` | `PostConfigureServices(Async)(ServiceConfigurationContext)` | | 初始化前 | `IOnPreApplicationInitialization` | `OnPreApplicationInitialization(Async)(ApplicationInitializationContext)` | | 初始化 | `IOnApplicationInitialization` | `OnApplicationInitialization(Async)(ApplicationInitializationContext)` | | 初始化后 | `IOnPostApplicationInitialization` | `OnPostApplicationInitialization(Async)(ApplicationInitializationContext)` | | 关闭 | `IOnApplicationShutdown` | `OnApplicationShutdown(Async)(ApplicationShutdownContext)` | `XiHanModule` 已实现全部接口的空虚方法:默认异步版本调用同步版本(`XxxAsync → Xxx; return Task.CompletedTask`),你只覆盖需要的方法即可;覆盖异步版本时不必再调基类同步版本。 ### 应用引导 | 类型 | 说明 | | --- | --- | | `ServiceCollectionApplicationExtensions`(static) | `AddApplication(this IServiceCollection, Action?)` 及 `Type` 重载、`...Async` 重载;辅助 `GetApplicationName` / `GetApplicationInstanceId` / `GetXiHanHostEnvironment` | | `XiHanApplicationFactory`(static) | `Create/CreateAsync(...)`:无 `services` 参数 → 内部容器(`IXiHanApplicationWithInternalServiceProvider`);带 `services` → 外部容器(`IXiHanApplicationWithExternalServiceProvider`)。`Async` 版本会先置 `SkipConfigureServices=true` 再 `await ConfigureServicesAsync()` | | `IXiHanApplication` | 应用契约:`StartupModuleType` / `Services` / `ServiceProvider` / `ConfigureServicesAsync()` / `Shutdown(Async)()`;继承 `IModuleContainer` / `IApplicationInfoAccessor` / `IDisposable` | | `IXiHanApplicationWithExternalServiceProvider` | 由外部(Host)构建容器:`SetServiceProvider(sp)` / `Initialize(Async)(IServiceProvider)`(Web 场景用) | | `IXiHanApplicationWithInternalServiceProvider` | 自建容器:`CreateServiceProvider()` / `Initialize(Async)()`(控制台/测试用) | | `XiHanApplicationBase` | 上述实现基类,含 `ConfigureServices(Async)` 三阶段编排与 `Shutdown(Async)` | | `XiHanApplicationCreationOptions` | 见下"配置" | | `IApplicationInfoAccessor` | `ApplicationName` / `InstanceId`(`InstanceId` 为每次启动生成的 Guid) | | `IXiHanHostEnvironment` / `XiHanHostEnvironment` | `EnvironmentName`(未设置时默认回落 `Production`) | | `IRemoteService` | 空标记接口:标识某类型可作为远程服务 | | `RemoteServiceAttribute` | `[AttributeUsage(Interface\|Class\|Method)]`;`IsEnabled` / `IsMetadataEnabled` / `Name`(分组名);`IsExplicitlyEnabledFor(Type/MethodInfo)` 等静态判定方法 | | `IntegrationServiceAttribute` | `[AttributeUsage(Class\|Interface)]`;`IsDefinedOrInherited()` 判定类型或其接口是否标注 | | `ApplicationServiceTypes`(`[Flags]` 枚举) | `ApplicationServices` / `IntegrationServices` / `All` | | `DisableXiHanFeaturesAttribute` | `[AttributeUsage(Class)]`;`DisableInterceptors` / `DisableMiddleware` / `DisableMvcFilters`(均默认 `true`) | ### 上下文 | 类型 | 关键成员 | | --- | --- | | `ServiceConfigurationContext` | `IServiceCollection Services`;`IDictionary Items` + 索引器 `this[key]`(模块间在服务注册阶段共享数据) | | `ApplicationInitializationContext` | `IServiceProvider ServiceProvider`(实现 `IServiceProviderAccessor`)。`GetApplicationBuilder()`/`GetConfiguration()`/`GetEnvironment()` 属 **Web.Core 扩展**,非本包 | | `ApplicationShutdownContext` | `IServiceProvider ServiceProvider` | | `LocalizationContext` | `ServiceProvider` / `IStringLocalizerFactory LocalizerFactory` | ### 依赖注入 | 类型 | 说明 | | --- | --- | | `ITransientDependency` / `IScopedDependency` / `ISingletonDependency` | 标记接口,实现即按对应生命周期自动注册(命名空间 `...DependencyInjection.ServiceLifetimes`) | | `DependencyAttribute` | `[Dependency(ServiceLifetime)]`:`Lifetime` / `TryRegister` / `ReplaceServices` | | `ExposeServicesAttribute` | `[ExposeServices(params Type[])]`:`IncludeDefaults` / `IncludeSelf`;控制暴露哪些服务类型 | | `ExposeKeyedServiceAttribute` | 键控服务暴露 | | `DisableConventionalRegistrationAttribute` | 让类退出约定扫描 | | `DisablePropertyInjectionAttribute` | 禁用属性注入 | | `IConventionalRegistrar` / `ConventionalRegistrarBase` / `DefaultConventionalRegistrar` | 约定注册器,可 `services.AddConventionalRegistrar(...)` 追加自定义 | | `ICachedServiceProvider` / `ITransientCachedServiceProvider` | 缓存已解析服务(含瞬态);`GetService(defaultValue)` / `GetService(factory)` 等 | | `IRootServiceProvider` / `RootServiceProvider` | 访问根容器(避开 scope 生命周期) | | `IObjectAccessor` / `ObjectAccessor` | 延迟对象持有者(引导期占位、后填值),见 `IServiceCollection.TryAddObjectAccessor` | | `AutowiredServiceAttribute` / `AutowiredServiceHandler` | 属性/字段注入 | | `IServiceProviderAccessor` / `IClientScopeServiceProviderAccessor` | 服务提供器访问抽象 | | `IOnServiceRegistredContext` / `IOnServiceExposingContext` / `IOnServiceActivatedContext` | 注册/暴露/激活拦截扩展点 | ### 反射 / 集合 / 状态检查 / 代理 / 追踪 / 横切关注点 / 异步锁 / 异常 | 类型 | 说明 | | --- | --- | | `IAssemblyFinder` / `AssemblyFinder`、`ITypeFinder` / `TypeFinder` | 从已加载模块发现程序集与类型 | | `ITypeList` / `ITypeList` / `TypeList` | 类型安全的 `IList`(生命周期贡献者列表等用它) | | `ISimpleStateChecker` / `ISimpleBatchStateChecker` / `ISimpleStateCheckerManager` / `SimpleStateCheckerContext` / `SimpleStateCheckerResult` | 布尔状态判定框架(权限、特性开关等) | | `IHasSimpleStateCheckers` / `SimpleStateCheckerOptions` | 状态检查器聚合与配置 | | `IXiHanInterceptor` / `IXiHanMethodInvocation` / `XiHanInterceptor` | 框架级拦截器抽象(由 `XiHan.Framework.Castle` 落地到 Castle) | | `ProxyHelper`(static) | `IsProxy(obj)` / `UnProxy(obj)` / `GetUnProxiedType(obj)`;识别 `Castle.Proxies` 命名空间 | | `DynamicProxyIgnoreTypes` | 不做动态代理的类型登记 | | `ICorrelationIdProvider` / `DefaultCorrelationIdProvider` | `string? Get()` / `IDisposable Change(string?)`(作用域内临时切换关联唯一标识) | | `XiHanActivitySources`(static) | 框架共享 `ActivitySource`:`App`/`Data`/`EventBus`/`Grpc`/`Cache`/`Ai` 六个源名常量 + `AppSource`/`DataSource`/`EventBusSource`/`GrpcSource`/`CacheSource` 五个实例(`Ai` 无独立实例字段,其值须与 AI 管道的 `AiPipelineOptions.TelemetrySourceName` 一致);`All` 汇总全部六个源名供 OTel 一次性批量 `AddSource`;置于 Core.Tracing 而非 Observability,避免 Data/EventBus/Http/Web 反向依赖 Observability | | `XiHanCrossCuttingConcerns`(static) | `Applying(obj, concerns)` 返回 `IDisposable`(作用域内标记+自动移除)、`AddApplied` / `RemoveApplied` / `IsApplied` / `GetApplieds` | | `IAvoidDuplicateCrossCuttingConcerns` | `List AppliedCrossCuttingConcerns { get; }`;`XiHan.Framework.Web.Api` 的 `XiHanController` 实现此接口 | | `AsyncHelper`(static) | `RunSync(Func>)` / `RunSync(Func)`(同步阻塞等待)、`UnwrapTask(Type)` | | `AsyncLock` | `LockAsync()` / `LockAsync(TimeSpan)`(超时抛 `TimeoutException`)/ `Lock()`,均返回释放用的 `IDisposable` | | `SemaphoreSlimExtensions`(static) | `SemaphoreSlim` 的 `LockAsync(...)` / `Lock(...)` 系列重载(支持超时、`CancellationToken`),返回 `IDisposable` 自动 `Release` | | `LockExtensions`(static) | `object.Locking(Action)` / `Locking(Action)` / `Locking(Func)` 等,封装 `lock` 语句 | | `AsyncExtensions`(static) | `MethodInfo.IsAsync()`、`Type.IsTaskOrTaskOfT()` / `IsTaskOfT()` | | `NameValue` / `NameValue` | 通用名称/值对承载类型(`Timing`、`Settings` 等包用于承载时区名、设置值) | | `NamedTypeSelector` | `Name` + `Predicate` 组成的命名类型断言选择器 | | `XiHanException` | 框架根异常;构造时把消息以 `Error` 级写入 `LogHelper` | | `BusinessException` | 实现 `IBusinessException` / `IHasErrorCode` / `IHasErrorDetails` / `IHasLogLevel`;`Code` / `Details` / `LogLevel`(默认 `Warning`)/ `LocalizableMessage`(弱类型承载可本地化消息)/ `WithData(name, value)` | | `UserFriendlyException` | `BusinessException` 子类,可直接呈现给终端用户;支持 `(message, code, details, ...)` 与 `(localizableMessage, fallbackMessage, ...)` 两种构造 | | `InitializationException` / `ShutdownException` | 生命周期错误包装(消息前缀"程序初始化异常/关闭过程异常",构造时记 `Error` 日志) | | `IExceptionNotifier` / `ExceptionNotifier` / `IExceptionSubscriber` / `ExceptionNotificationContext` | 异常通知链:`NotifyAsync` 遍历所有 `IExceptionSubscriber.HandleAsync`,单个订阅者抛错只记警告不中断 | | `ConnectionStrings` | `Dictionary` 子类,`const DefaultConnectionStringName = "Default"` + `Default` 属性 | ### 插件源 | 类型 | 说明 | | --- | --- | | `IPlugInSource` / `PlugInSourceList` | 插件源集合,`GetAllModules(logger)` 汇总所有插件模块类型 | | `FolderPlugInSource` | 从目录扫描程序集加载模块 | | `FilePlugInSource` | 从指定程序集文件加载 | | `TypePlugInSource` | 直接以类型登记插件模块 | 插件模块通过 `XiHanApplicationCreationOptions.PlugInSources` 配置,`ModuleLoader.FillModules` 会把它们并入模块图(`IsLoadedAsPlugIn = true`),与依赖声明的模块一同排序、执行生命周期。 ## 配置 `XiHanApplicationCreationOptions`(通过 `AddApplication(options => ...)` 配置): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Services` | `IServiceCollection` | 传入 | 服务容器(只读) | | `PlugInSources` | `PlugInSourceList` | 空 | 插件源列表 | | `Configuration` | `XiHanConfigurationBuilderOptions` | 新建 | 仅当未注册 `IConfiguration` 时用于构建配置 | | `SkipConfigureServices` | `bool` | `false` | 是否跳过服务配置三阶段(异步引导路径会置为 `true`,随后显式 `await ConfigureServicesAsync()`) | | `ApplicationName` | `string?` | `null` | 应用名(未设则读配置 `ApplicationName`,再回落入口程序集名) | | `Environment` | `string?` | `null` | 环境名(未设则回落 `Production`) | `XiHanConfigurationBuilderOptions`(`Configuration` 子对象,节选真实字段——用于在未提供 `IConfiguration` 时构建):控制 `appsettings.json` / 用户机密 / 环境变量 / 命令行参数等来源;`HostingHostBuilderExtensions.AddAppSettingsSecretsJson()` 额外支持 `appsettings.secrets.json`(`const AppSettingsSecretJsonPath`)。 > Core 本身不定义业务配置节;配置项以模块各自的 `Options` 为准。 ## 使用示例 ### 编写并声明一个模块 ```csharp [DependsOn(typeof(XiHanCachingModule))] public class MyFeatureModule : XiHanModule { public override void PreConfigureServices(ServiceConfigurationContext context) { // 预配置:为后续模块提供可被 PostConfigure 覆盖的基线 context.Services.PreConfigure(o => o.Enabled = true); } public override void ConfigureServices(ServiceConfigurationContext context) { Configure(o => o.Timeout = TimeSpan.FromSeconds(30)); // 具体类实现 ITransientDependency/IScopedDependency/ISingletonDependency 即自动注册, // 这里通常只写需要显式配置或第三方库的注册。 } public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) { var svc = context.ServiceProvider.GetRequiredService(); await svc.WarmupAsync(); } } ``` ### 约定式注册 ```csharp // 无需在模块里手写注册,实现标记接口即可: public class OrderService : IOrderService, IScopedDependency { /* ... */ } // 或用特性精确控制暴露类型与语义: [Dependency(ServiceLifetime.Singleton, ReplaceServices = true)] [ExposeServices(typeof(ICacheProvider))] public class RedisCacheProvider : ICacheProvider { /* ... */ } ``` ### 在 ASP.NET Core Host 引导(Web 场景) ```csharp var builder = WebApplication.CreateBuilder(args); // Web.Core 扩展,内部调用 Core 的 AddApplicationAsync(外部容器模式) await builder.AddApplicationAsync(); var app = builder.Build(); // Web.Core 扩展,内部调用 application.InitializeAsync(app.Services) → 跑初始化三阶段 await app.InitializeApplicationAsync(); app.Run(); ``` ### 独立 Host / 测试(内部容器模式) ```csharp using var app = await XiHanApplicationFactory.CreateAsync(); app.Initialize(); // 建容器 + 跑初始化钩子 var sp = app.ServiceProvider; // ... 使用服务 ... app.Shutdown(); // 逆序跑关机钩子 ``` ## 扩展点 / 自定义 - **替换核心基础服务**:`IModuleLoader` / `IAssemblyFinder` / `ITypeFinder` / `IInitLoggerFactory` 都以 `TryAddSingleton` 注册,在更早注册同类型即可覆盖默认实现。 - **自定义约定注册器**:实现 `IConventionalRegistrar`(或继承 `ConventionalRegistrarBase`),`services.AddConventionalRegistrar(new MyRegistrar())`。 - **新增生命周期阶段**:实现 `IModuleLifecycleContributor`,在模块的 `PreConfigureServices` 里 `PreConfigure(o => o.Contributors.Add())`。 - **插件模块**:通过 `options.PlugInSources.AddFolder(path)` / `AddFile(...)` / `AddTypes(...)` 引入编译期未引用的模块。 - **注册/暴露/激活拦截**:通过 `IOnServiceRegistredContext` / `IOnServiceExposingContext` / `IOnServiceActivatedContext` 相关的 action list 扩展(如统一为服务加拦截器)。 - **异常订阅**:实现 `IExceptionSubscriber` 并注册,即可接入 `IExceptionNotifier.NotifyAsync` 通知链(做告警/上报)。 ## 注意事项与最佳实践 - **`ServiceConfigurationContext` 只在服务配置三阶段可用**:`XiHanModule.ServiceConfigurationContext` 在配置完成后会被置空,初始化阶段访问会抛 `XiHanException`。初始化阶段请用 `ApplicationInitializationContext.ServiceProvider`。 - **服务注册阶段不要解析服务**:此时容器尚未构建,只能操作 `IServiceCollection`;需要实例的逻辑放到 `OnApplicationInitialization`。 - **异步引导必须成对**:调用 `ConfigureServicesAsync` 前必须先把 `SkipConfigureServices` 置 `true`(`XiHanApplicationFactory` 的 `Async` 工厂已自动处理),否则会因重复配置抛 `InitializationException`。不要在同一应用上既走同步又走异步配置。 - **约定式注册的隐性规则**:既无 `[Dependency]` 也未实现三种标记接口的具体类**不会被注册**(默认生命周期为 `null`)。若第三方类型需要注册,用特性或手写注册。 - **`SkipAutoServiceRegistration`**:模块置此为 `true` 会跳过它自身程序集的约定扫描(适合纯抽象/占位模块),但仍会执行其生命周期钩子。 - **`GetApplicationBuilder/GetConfiguration/GetEnvironment` 属 Web 层**:纯 Core 应用没有这些便捷方法,别按 Web 文档习惯直接调用。 - **`[DependsOn]` 只需声明直接依赖**:传递依赖由框架递归发现;重复声明由 `AddIfNotContains` 去重,`XiHanModuleHelper` 日志会标注 `[已跳过-重复加载]`。 - **关机为逆序**:`OnApplicationShutdown` 按模块**逆拓扑序**执行,释放资源时可依赖"我依赖的模块尚未关闭"这一顺序保证。 ## 依赖模块 - [XiHan.Framework.Metadata](./metadata) — 框架元数据(启动 Logo / 版本摘要)。 - [XiHan.Framework.Utils](./utils) — 通用工具库(`Guard`、集合扩展、`LogHelper` 等)。 - 第三方:`Microsoft.Extensions.Hosting`(主机集成)、`Microsoft.Extensions.Localization`(本地化基座);隐式使用 `Microsoft.Extensions.DependencyInjection/Options/Configuration/Logging`。 ## 相关模块 - [模块化概念](../guide/modularity) · [生命周期概念](../guide/lifecycle) · [依赖注入概念](../guide/dependency-injection) - [XiHan.Framework.Castle](./castle) — 基于 Castle 的动态代理落地(实现本包的 `IXiHanInterceptor`)。 - [XiHan.Framework.Web.Core](./web-core) — 提供 Web Host 引导(`AddApplicationAsync` / `InitializeApplicationAsync`)与 `ApplicationInitializationContext` 的 `GetApplicationBuilder/GetConfiguration/GetEnvironment` 扩展。 - [XiHan.Framework.Utils](./utils) · [XiHan.Framework.Metadata](./metadata) --- 来源:https://framework.docs.xihanfun.com/packages/data # XiHan.Framework.Data > 基于 SqlSugar 的数据访问基础设施:仓储模式、工作单元事务接入、多租户数据隔离、审计/软删除全局过滤、启动自动建库建表与种子数据 - **NuGet**:`XiHan.Framework.Data` - **模块类**:`XiHanDataModule` - **所在层**:基础设施层 - **关键依赖**:**SqlSugarCore**(`5.1.4.217`,ORM,直接对接数据库) ## 概述 这个包是框架的数据访问基础设施,用 SqlSugar ORM 落地领域层定义的仓储契约。它统一注册 SqlSugar 客户端、提供多套开箱即用的仓储实现(只读 / 读写 / 软删除 / 审计 / 聚合根),并把主键生成、审计字段注入、多租户数据隔离、软删除过滤、事务接入全部下沉到 AOP 与全局过滤器里。 设计上遵循"仓储只管纯持久化、横切关注点交给基础设施":租户连接选择由 `ISqlSugarClientResolver` 承担;租户行级过滤与软删过滤由 SqlSugar 全局 `QueryFilter` AOP 注入;创建/修改/删除审计字段与雪花主键由 `DataExecuting` AOP 自动填充;事务边界由 `XiHan.Framework.Uow` 接管。你在业务里只需注入仓储接口读写实体,其它都在幕后完成。 仓储**接口契约定义在 `XiHan.Framework.Domain`**(`IRepositoryBase` 等),本包提供 SqlSugar 实现并以开放泛型 `TryAddScoped` 注册——业务层可以注册自己的实现覆盖默认行为。 ## 何时使用 - 需要用仓储模式访问数据库,而不想手写 SqlSugar 客户端注册、连接管理与事务 - 需要多租户数据隔离:行级租户过滤(`TenantId=0` 视为全局模板)、跨库/跨连接路由 - 需要软删除、审计字段(创建/修改/删除时间与操作人、租户 Id、TraceId)自动注入 - 需要应用启动时自动建库、`CodeFirst` 建表、写入种子数据 - 需要对多个数据库(`ConfigId` 多连接、主从读写分离)统一管理 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Data ``` ```csharp [DependsOn(typeof(XiHanDataModule))] public class MyModule : XiHanModule { } ``` `XiHanDataModule` 依赖 `XiHanDomainModule`、`XiHanUowModule`、`XiHanDistributedIdsModule`、`XiHanMultiTenancyModule`、`XiHanSecurityModule`、`XiHanAuditingModule`。它在 `ConfigureServices` 里调用 `AddXiHanDataSqlSugar(config)` 注册全部服务;在 `OnApplicationInitializationAsync` 里创建作用域、取 `IDbInitializer` 执行 `InitializeAsync()` 完成数据库初始化(初始化失败会抛出中断启动)。 启用后你得到(均为 `TryAdd` 语义,可被业务覆盖): - `SqlSugarScope`(单例,线程安全)与按当前租户解析的 `ISqlSugarClient`(Scoped) - 仓储开放泛型:`IReadOnlyRepositoryBase<,>`、`IRepositoryBase<,>`、`ISoftDeleteRepositoryBase<,>`、`IAuditedRepository<,>`、`IAggregateRootRepository<,>` - `ISqlSugarClientResolver` / `ISqlSugarTenantConnectionResolver` / `ISqlSugarConnectionConfigurator` - `SqlSugarDataExecutingHandler`(主键/审计/租户/TraceId 注入) - `IDatabaseMetadataProvider`(库表元数据) - `IEntityAuditContextProvider`(默认从当前用户/请求填充)、`IEntityDiffLogWriter`(默认 `Null` 零开销)——这两个契约的默认实现随依赖的 [XiHan.Framework.Auditing](./auditing) 模块一并注册,本包直接复用 - `IDbInitializer`(建库/建表/种子) - `SqlSugarSlaveHealthCheckService`(后台从库健康探针,始终注册;由 `EnableSlaveHealthCheck` 开关决定是否实际探测,未开启时空转零成本) ## 工作原理 一次仓储读写背后的横切链路: ``` 业务调用仓储方法 └─ 仓储通过 ISqlSugarClientResolver.GetClientForEntity(实体类型) 取客户端 ├─ 实体标了 [ModuleDataSource("Erp")] → 取当前布局下派生的模块库(模块分库,未配置则 fail-closed) ├─ 否则若存在租户连接提供器且处于租户上下文 → 解析租户独立连接(库隔离,fail-closed) ├─ 否则 → ISqlSugarTenantConnectionResolver 解析 ConfigId(行/字段隔离) └─ 若当前存在事务型工作单元 → 把该连接的事务登记进 UoW(GetOrAddTransactionApi) └─ SqlSugar 执行 SQL ├─ QueryFilter 全局过滤器(AOP):自动 AND 软删过滤 + 租户过滤 ├─ DataExecuting AOP:Insert 注入雪花主键+创建审计+TraceId;Update/Delete 注入修改/删除审计 └─ (可选)OnDiffLogEvent AOP:生成 before/after 快照 → IEntityDiffLogWriter 落库 ``` 关键约定: - **连接选择每次访问实时解析**:`DbClient` 每次访问都重新解析——实体声明了模块数据源固定落该模块库,未声明则随租户上下文切换即时生效。 - **静态连接与运行时动态连接一致**:`SqlSugarConnectionConfigurator` 复用同一套过滤器/AOP 装配逻辑,保证运行时新注册的租户连接与启动期静态连接行为完全一致。 - **事务不在仓储内开启**:仓储只做 before 预读保证实体在当前租户范围内,事务边界统一由工作单元接管(见 [XiHan.Framework.Uow](./uow))。 - **写操作前的租户安全校验**:`UpdateAsync`/`DeleteAsync`/批量操作会先按当前过滤器读取实体,读不到即视为"不存在或不在当前租户范围内"并抛 `InvalidOperationException`,防止越租户改删。预读统一经受保护的 `GetForWriteAsync` / `CreateWritePreReadQueryable`(恢复与清除经 `CreateWritePreReadWithDeletedQueryable`),`TenantWriteGuard.Suppress()` 作用域内会清除租户过滤;自定义仓储覆写写方法时,预读也要走这几个方法,否则豁免作用域内会把用户自有行误判为不存在。 - **SQL 执行自动上报链路追踪**:每条 SQL 执行完成(或异常)后,`SetSugarAop` 按已知耗时回溯生成一个 `db.query`(`ActivityKind.Client`)的 OpenTelemetry Activity,挂在当前请求 Activity 之下,携带 `db.system`/`db.statement` 标签,异常时记录状态与堆栈;基于 `XiHanActivitySources.DataSource`,无监听者时直接跳过、零开销。 ## 核心能力 - **多套仓储实现**:只读、读写、软删除、审计、聚合根,全部开放泛型注册,注入接口即用。 - **多租户数据隔离**:全局 `QueryFilter` 自动过滤租户数据(平台就是 0 号租户:无租户上下文只看 `TenantId=0`;租户上下文看"本租户数据 OR 全局模板 `TenantId=0`";严格隔离实体只看本作用域);跨租户读取只能显式 `ClearTenantFilter()`;连接层支持按 `ConfigId`/前缀/名称/自定义委托解析,或由业务提供 `ISqlSugarTenantConnectionProvider` 做库级隔离。 - **模块分库**:实体标 `[ModuleDataSource("Erp")]` 即固定落在当前布局的 Erp 模块库上,租户上下文照常统一;建表初始化同步收窄到该库(详见下文)。 - **软删除过滤**:实现 `ISoftDelete` 的实体自动过滤 `IsDeleted`;`ISoftDeleteRepositoryBase<,>` 额外提供 `SoftDelete`/`Restore`/`GetDeleted`/`GetAllWithDeleted` 等。 - **审计字段自动注入**:通过 SqlSugar `DataExecuting` AOP 注入雪花主键、创建/修改/删除时间与操作人、`TenantId`、`TraceId`,业务与仓储都无需手填。 - **实体差异日志**:可选启用(`EnableDiffLog`),基于 SqlSugar 原生 `OnDiffLogEvent` AOP 生成 before/after 快照,交 `IEntityDiffLogWriter` 落库。 - **分页 / 规约 / 自动查询**:内置多种分页重载(`pageIndex/pageSize`、`PageRequestDtoBase`、规约 `ISpecification`、`GetPagedAutoAsync` 按 DTO 自动构建条件)。 - **数据库初始化**:三段式——全部连接建库建表(自动处理 MySQL utf8mb4 归一化、识别分表 `SplitTables`)→ 执行已注册的 `IDbSchemaUpgrader` 把存量表升级到当前结构 → 全部连接按 `Order` 顺序执行 `IDataSeeder`;建哪些表、跑哪些种子可用特性/配置圈定或整体接管。 - **雪花 ID**:接入 `XiHan.Framework.DistributedIds`,通过 `StaticConfig.CustomSnowFlakeFunc` 作为 SqlSugar 全局主键生成器。 - **SQL 日志与慢查询**:可开启 SQL/异常/慢 SQL 日志,慢 SQL 阈值可配。 - **主从读写分离**:SqlSugar 原生主从能力完整放出,appsettings 声明从库即可分担读;差异化权重与更多原生定制走代码钩子;可选从库健康探针自动摘除/回填权重(详见下文)。 - **SQL 执行链路追踪**:每条 SQL 执行完成/异常自动上报 `db.query` OpenTelemetry Client Span,挂在当前请求 Activity 下;无监听者时零开销,不需要额外配置。 ## 主要 API / 类型 ### 仓储契约与实现 | 契约(Domain 定义) | 实现(本包) | 说明 | | --- | --- | --- | | `IReadOnlyRepositoryBase` | `SqlSugarReadOnlyRepository<,>` | 只读:查询/统计/存在性/分页 | | `IRepositoryBase` | `SqlSugarRepositoryBase<,>` | 读写:在只读之上增删改 | | `ISoftDeleteRepositoryBase` | `SqlSugarSoftDeleteRepository<,>` | 软删除 / 恢复 / 查询已删记录 | | `IAuditedRepository` | `SqlSugarAuditedRepository<,>` | 审计仓储 | | `IAggregateRootRepository` | `SqlSugarAggregateRepository<,>` | 聚合根仓储,写操作登记领域事件到工作单元 | `SqlSugarRepositoryBase` 约束 `where TEntity : class, IEntityBase, new()`、`where TKey : IEquatable`。 只读仓储关键方法(`SqlSugarReadOnlyRepository<,>`): | 方法 | 说明 | | --- | --- | | `Task GetByIdAsync(TKey id, ...)` | 按主键取单条 | | `Task> GetByIdsAsync(IEnumerable ids, ...)` | 按主键集合取 | | `Task GetFirstAsync(Expression> predicate, ...)` | 条件取首个;另有规约重载 | | `Task> GetListAsync(Expression> predicate, ...)` | 条件列表;另有排序 / 规约 / `PageRequestDtoBase` 重载 | | `Task> GetAllAsync(...)` | 取全部 | | `Task CountAsync(...)` | 统计(无参 / 条件 / 规约) | | `Task AnyAsync(Expression> predicate, ...)` | 存在性判断(条件 / 规约) | | `Task> GetPagedAsync(int pageIndex, int pageSize, ...)` | 分页;多种重载(条件、条件+排序、规约、`PageRequestDtoBase`) | | `Task> GetPagedAutoAsync(object queryDto, ...)` | 按查询 DTO 自动构建条件分页 | 读写仓储关键方法(`SqlSugarRepositoryBase<,>`): | 方法 | 说明 | | --- | --- | | `Task AddAsync(TEntity entity, ...)` | 新增,返回持久化后实体 | | `Task AddReturnIdAsync(TEntity entity, ...)` | 新增并返回主键 | | `Task> AddRangeAsync(IEnumerable entities, ...)` | 批量新增 | | `Task UpdateAsync(TEntity entity, ...)` | 按主键更新(先租户可见性预读) | | `Task UpdateAsync(Expression> columns, Expression> where, ...)` | 按条件更新指定列 | | `Task> UpdateRangeAsync(IEnumerable entities, ...)` | 批量更新(校验全部可见) | | `Task AddOrUpdateAsync(TEntity entity, ...)` | 按 `IsTransient()` 决定新增/更新;另有批量重载 | | `Task DeleteAsync(TEntity entity, ...)` | 按实体删除;另有 `DeleteByIdAsync`、按条件、批量重载 | > 软删除过滤开启时,对实现 `ISoftDelete` 的实体,`Delete*` 走的是全局过滤器语义;`SqlSugarSoftDeleteRepository` 的 `SoftDeleteAsync` 是把 `IsDeleted=true`(并置 `DeletedTime`)走更新落库,`RestoreAsync` 反之。 ### 客户端与租户解析 | 类型 | 说明 | | --- | --- | | `ISqlSugarClientResolver` | `GetCurrentClient()` 按当前租户解析并把连接登记进事务型 UoW;`GetClientForEntity(entityType)` / `GetClientForEntity()` 按实体模块数据源解析;`GetClient(configId)`、`GetAllConfigIds()`、`GetAllClients()`(初始化/种子遍历各库,含派生出的模块库)、`GetCurrentLayoutConfigIds()`(当前这一套布局有哪些库)、`AsTenant()` | | `ModuleDataSourceAttribute` | 实体上声明所属模块数据源:`[ModuleDataSource("Erp")]`,标在基类上对派生实体生效;SqlSugar 原生 `[Tenant("Erp")]` 同样被识别 | | `PlatformDataSourceAttribute` | 实体上声明固定落平台库:`[PlatformDataSource]`,库隔离租户下不随独立库切换;与 `[ModuleDataSource]` 互斥 | | `IEntityModuleDataSourceResolver` | 实体落点解析器:`ResolveModuleDataSource(entityType)` 返回实体声明的模块名,未声明返回 `null`;`IsPlatformPlaced(entityType)` 判断是否固定落平台库;默认实现读特性并缓存,可 `Replace` | | `IModuleDataSourceConnectionResolver` | 模块数据源连接解析器:`ResolveClient(moduleDataSource, parentConfigId)` 按「模块名 + 当前布局」定连接,两条维度在此交汇 | | `ISqlSugarTenantConnectionResolver` | `ResolveCurrentConfigId()` / `ResolveConfigId(long? tenantId, string? name)` / `GetConfigIds()`——解析顺序:自定义委托 → `tenantId` 直配 → `前缀+tenantId` → 租户名 → 默认 | | `ISqlSugarConnectionConfigurator` | `Configure(provider)` 装配过滤器+AOP;`EnsureTenantConnection(tenant, descriptor)` 运行时幂等注册租户独立连接(缺连接串 fail-closed) | | `ISqlSugarTenantConnectionProvider` | 业务实现的租户库隔离扩展点:`Resolve(tenantId, name)` 返回连接描述符;返回 `null` 退化为 `ConfigId` 解析,抛异常则 fail-closed | | `SqlSugarTransactionApi` | 工作单元事务适配器,`ITransactionApi`+`ISupportsRollback`,包装 `Ado.BeginTran/CommitTran/RollbackTran` | ### 初始化与种子 | 类型 | 说明 | | --- | --- | | `IDbInitializer` | `InitializeAsync()`(遍历全部库的完整流程)/ `InitializeCurrentLayoutAsync()`(只初始化当前租户这一套布局,租户开通时用)/ `CreateDatabaseAsync()` / `CreateTablesAsync()` / `SeedDataAsync()` | | `IDbSchemaUpgrader` | 存量库表结构升级器:`UpgradeAsync(DbSchemaUpgradeContext context, CancellationToken ct = default)`。建表只建缺失的表、不改已存在的表,存量表的新列由它补齐;在全部连接建表之后、任何播种之前执行,失败即中断初始化。只在 `EnableTableInitialization` 开启时调用 | | `DbSchemaUpgradeContext` | 本次初始化的建表结果:`FreshConfigIds` 为从零建出全部实体表的连接,`IsFresh(configId)` 判断某个连接是否新库。新库本就是最新结构,升级器应把它登记为最新版本(如 `IUpgradeEngine.BaselineAsync`),不补跑历史脚本 | | `IDataSeeder` | 种子契约:`int Order`(越小越先)/ `string Name` / `Task SeedAsync()` | | `DataSeederBase` | 种子基类:提供 `DbClient`、`DbClientFor()`(按实体模块数据源解析)、`HasDataAsync(predicate)`、`BulkInsertAsync(list)` 等辅助 | | `IDbEntityTypeProvider` | 建表实体提供器:`GetEntityTypes(context)` 决定当前库建哪些表,默认实现按特性+选项筛选,可 `Replace` | | `IDataSeederSelector` | 种子选取器:`Select(seeders, context)` 决定当前库跑哪些种子,默认实现按特性+选项筛选,可 `Replace` | | `TableInitializationAttribute` | 实体上声明建表方式:`Enabled` / `Group` / `Target` / `ConnectionConfigIds` | | `DataSeedingAttribute` | 种子上声明播种方式:`Enabled` / `Group` / `Target` / `ConnectionConfigIds` | | `DbInitializationContext` | 当前库上下文:`ConnectionConfigId` / `TenantId` / `IsTenantDatabase` / `Target` | | `IDatabaseMetadataProvider` | 库表结构元数据读取 | ### 审计 | 类型 | 说明 | | --- | --- | | `SqlSugarDataExecutingHandler` | `DataExecuting` AOP 处理器:Insert 注入雪花主键+创建审计+`TraceId`;Update 注入修改审计(含软删);Delete 注入删除审计 | | `IEntityAuditContextProvider` | 审计上下文来源,默认 `DefaultEntityAuditContextProvider`(从 `ICurrentUser`/HTTP 填充),可注册覆盖 | | `IEntityDiffLogWriter` | 差异日志落库器,默认 `NullEntityDiffLogWriter`(零开销);启用 `EnableDiffLog` 时须替换为真实实现 | ### SqlSugar 实体基类 本包提供带 `SugarColumn` 映射(snake_case 列名)的实体基类,供业务实体继承: - `SugarEntity` / `SugarEntityWithIdentity` — 基础实体 - `SugarCreationEntity` / `SugarModificationEntity` / `SugarDeletionEntity` / `SugarFullAuditedEntity` — 审计实体(`Created_Time`/`Modified_Time`/`Is_Deleted`/`Deleted_Time` 等映射 + `Row_Version` 并发标识) - `SugarMultiTenantEntity` 及其审计变体 — 多租户实体(含 `Tenant_Id` 列,`0`=平台/全局模板) - `SugarAggregateRoot` / `SugarMultiTenantAggregateRoot` — 聚合根基类,公共列与上面各基类同名(`Basic_Id`、`Tenant_Id`、`Created_Time`、`Is_Deleted` 等) ## 配置 配置节:`XiHan:Data:SqlSugarCore`(`XiHanSqlSugarCoreOptions.SectionName`)。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ConnectionConfigs` | `List` | `[]` | 连接配置集合 | | `DefaultConfigId` | `string` | `"Default"` | 默认连接标识 | | `TenantConfigIdPrefix` | `string` | `"Tenant_"` | 租户连接标识前缀(解析 `前缀+tenantId`) | | `ThrowIfTenantConnectionNotFound` | `bool` | `false` | 租户有值但找不到连接时是否抛异常 | | `EnableTenantModuleDatabaseConvention` | `bool` | `true` | 库隔离租户按默认布局镜像出自带的整套模块库(库名从租户主库名派生) | | `ResolveConnectionConfigId` | `Func?` | `null` | 自定义租户连接解析委托 | | `EnableTenantFilter` | `bool` | `true` | 启用租户行级过滤器 | | `EnableSoftDeleteFilter` | `bool` | `true` | 启用软删除过滤器 | | `EnableAutoUpdateQueryFilter` | `bool` | `true` | 更新时自动附加查询过滤条件 | | `EnableAutoDeleteQueryFilter` | `bool` | `true` | 删除时自动附加查询过滤条件 | | `GlobalFilters` | `Dictionary` | `{}` | 额外全局过滤器(仅代码注册,推荐 `AddGlobalFilter`) | | `ConfigureDbAction` | `Action?` | `null` | 客户端自定义配置钩子 | | `EnableSqlLog` | `bool` | `false` | SQL 执行日志 | | `EnableSqlErrorLog` | `bool` | `true` | SQL 异常日志 | | `EnableSlowSqlLog` | `bool` | `true` | 慢 SQL 日志 | | `SlowSqlThresholdMilliseconds` | `int` | `10000` | 慢 SQL 阈值(毫秒),纯观测用途、不影响语句执行 | | `CommandTimeoutSeconds` | `int` | `300` | ADO 命令执行超时(秒);0/负值不覆盖,沿用 SqlSugar 默认 300 秒 | | `EnableDiffLog` | `bool` | `false` | 实体差异日志(需实现 `IEntityDiffLogWriter`/`IEntityAuditContextProvider`) | | `EnableDbInitialization` | `bool` | `false` | 启动自动建库总开关 | | `EnableTableInitialization` | `bool` | `false` | `CodeFirst` 建表 | | `EnableDataSeeding` | `bool` | `false` | 执行种子数据 | | `TableInitialization` | `TableInitializationOptions` | 全量 | 建表选取规则(哪些实体真正建表),见[选择初始化范围](#选择初始化范围) | | `DataSeeding` | `DataSeedingOptions` | 全量 | 种子选取规则(哪些种子真正执行),见[选择初始化范围](#选择初始化范围) | `SqlSugarConnectionConfigOptions` 字段:`ConfigId`(默认 `"Default"`)、`ConnectionString`、`DbType`(SqlSugar 枚举)、`IsAutoCloseConnection`(默认 `true`)、`InitKeyType`(默认 `InitKeyType.Attribute`)、`MoreSettings`、`DbLinkName`、`LanguageType`、`IndexSuffix`、`SlaveConnectionConfigs`(主从读写分离,详见下文)。 `DbType` 取 SqlSugar 的 `DbType` 枚举值,支持 **PostgreSQL / MySql / MariaDB** 等(`DbInitializer` 对 `MySql`/`MySqlConnector` 会自动把库字符集归一化为 utf8mb4)。 真实示例(取自 `XiHan.BasicApp.WebHost/appsettings.Development.json`): ```json { "XiHan": { "Data": { "SqlSugarCore": { "ConnectionConfigs": [ { "ConfigId": "1", "ConnectionString": "Server=127.0.0.1;Database=XiHanBasicApp;Username=postgres;Password=postgres;TrustServerCertificate=true;", "DbType": "PostgreSQL", "IsAutoCloseConnection": true, "SlaveConnectionConfigs": [] } ], "EnableSqlLog": false, "EnableSqlErrorLog": true, "EnableSlowSqlLog": true, "SlowSqlThresholdMilliseconds": 10000, "EnableDbInitialization": true, "EnableTableInitialization": true, "EnableDataSeeding": true } } } } ``` ## 主从读写分离 框架把 SqlSugar 的原生主从能力**完整放出来**,遵循「想改就改、不改吃默认」:SqlSugar 自动把 `SELECT` 按权重路由到从库、`INSERT/UPDATE/DELETE` 与事务走主库,无需业务感知。配置有两个通道。 ### 通道 A:appsettings 声明从库(够用即可) 在连接里填 `SlaveConnectionConfigs` 即可让读走从库: ```json { "ConfigId": "1", "ConnectionString": "主库连接串", "DbType": "PostgreSQL", "SlaveConnectionConfigs": [ { "ConnectionString": "从库1连接串" }, { "ConnectionString": "从库2连接串" } ] } ``` ::: warning HitRate 无法经 appsettings 设置(重要) SqlSugar 的 `SlaveConnectionConfig.HitRate`(读权重)是**字段**而非属性,.NET 配置绑定器只绑属性、不绑字段,所以 appsettings 里写的 `HitRate` **绑不上、恒为 0**,会导致从库权重为 0 而**永远收不到读流量**。 框架已对此兜底:构建时把 `HitRate <= 0` 的从库**归一化**为 `DefaultSlaveHitRate`(默认 10),保证 appsettings 声明的从库能真正分担读。**要给从库设置差异化权重,请用下面的通道 B。** ::: ### 通道 B:代码钩子完整定制原生配置 `XiHanSqlSugarCoreOptions.ConfigureConnectionConfigs` 在 `SqlSugarScope` 构建前,把已填好框架默认值的**原生** `List` 交给你,任何原生能力都能改——差异化 `HitRate`、`ConfigureExternalServices`(如可空列处理)、自定义主从探活等: ```csharp services.Configure(options => { options.ConfigureConnectionConfigs = configs => { foreach (var config in configs.Where(c => Equals(c.ConfigId, "1"))) { // HitRate 只能在代码里设(字段赋值不受配置绑定限制) config.SlaveConnectionConfigs = [ new SlaveConnectionConfig { HitRate = 30, ConnectionString = "从库1连接串" }, new SlaveConnectionConfig { HitRate = 10, ConnectionString = "从库2连接串" } ]; } }; }); ``` 该钩子对运行时动态注册的租户连接同样生效(以单元素列表触发,请按 `ConfigId` 分支处理并保证幂等)。库隔离租户也可在 `SqlSugarTenantConnection.SlaveConnectionConfigs` 直接带从库。 > 追加 `DataExecuting` 逻辑请用 `AppendDataExecuting`,**切勿**在 `ConfigureDbAction` 里直接给 `Aop.DataExecuting` 赋值——该事件是单次赋值,直接赋值会整体冲掉框架的雪花主键/审计/租户注入。核心注入焊死、只许追加。 ### 可选:从库健康探针(默认关闭) 打开 `EnableSlaveHealthCheck` 后,框架后台周期性探测从库连通性:不可用的从库自动摘除读权重(`HitRate=0`),恢复后按 `SlaveFailureCooldownSeconds` 冷却窗口回填原始权重。默认关闭、零成本;仅覆盖 appsettings 静态连接,运行时租户连接不在探测范围。 | 选项 | 默认 | 说明 | | --- | --- | --- | | `DefaultSlaveHitRate` | `10` | 通道 A 从库权重归一化默认值 | | `EnableSlaveHealthCheck` | `false` | 是否启用从库健康探针 | | `SlaveHealthCheckIntervalSeconds` | `30` | 探测周期(秒) | | `SlaveFailureCooldownSeconds` | `120` | 故障冷却窗口(秒),避免抖动 | ## 使用示例 ### 1. 应用服务里注入仓储读写 仓储接口在 `XiHan.Framework.Domain.Repositories` 中定义,本包提供实现,直接注入即可: ```csharp public class ProductService { private readonly IRepositoryBase _repository; public ProductService(IRepositoryBase repository) { _repository = repository; } public async Task CreateAsync(Product product) { // 雪花主键、租户 Id、创建审计字段由 DataExecuting AOP 自动注入 return await _repository.AddAsync(product); } public async Task GetAsync(long id) { // 自动附加当前租户过滤 + 软删除过滤 return await _repository.GetByIdAsync(id); } public async Task> QueryAsync(int pageIndex, int pageSize, string keyword) { return await _repository.GetPagedAsync( pageIndex, pageSize, p => p.Name.Contains(keyword), p => p.CreatedTime, isAscending: false); } } ``` ### 2. 软删除仓储 ```csharp public class ArticleService(ISoftDeleteRepositoryBase repo) { public Task RemoveAsync(long id) => repo.SoftDeleteAsync(id); // 置 IsDeleted=true public Task RestoreAsync(long id) => repo.RestoreAsync(id); // 恢复 public Task> RecycleBinAsync() // 已删列表(跳过软删过滤) => repo.GetDeletedAsync(); } ``` ### 3. 注册种子数据 ```csharp public class RoleSeeder : DataSeederBase { public RoleSeeder(ISqlSugarClientResolver r, ILogger l, IServiceProvider sp) : base(r, l, sp) { } public override int Order => 100; public override string Name => "系统角色"; protected override async Task SeedInternalAsync() { if (await HasDataAsync(x => x.Code == "admin")) return; await BulkInsertAsync(new List { new() { Code = "admin", Name = "管理员" } }); } } // 模块 ConfigureServices 中注册(须配合 EnableDataSeeding=true) services.AddDataSeeder(); // 或批量:services.AddDataSeeders(typeof(RoleSeeder), typeof(MenuSeeder)); ``` ## 模块分库:模块数据源 同一套应用里挂多个业务模块、各模块一个库时(主应用一个库,别人基于框架搭的 Erp、Mes 各自一个库),在实体上标一次模块数据源即可: ```csharp // 主应用实体:不标特性,跟着租户上下文解析连接(主库) [SugarTable("sys_user")] public class SysUser : SugarMultiTenantEntity { } // Erp 模块实体:固定落在 Erp 模块库 [SugarTable("erp_order")] [ModuleDataSource("Erp")] public class ErpOrder : SugarMultiTenantEntity { } ``` 模块库挂在主连接下面声明——条目只写模块名和连接串,其余字段留空即继承主连接: ```json { "XiHan": { "Data": { "SqlSugarCore": { "DefaultConfigId": "Default", "ConnectionConfigs": [ { "ConfigId": "Default", "DbType": "MySql", "ConnectionString": "...", "ModuleDataSourceConfigs": [ { "ModuleDataSource": "Erp", "ConnectionString": "..." }, { "ModuleDataSource": "Mes", "DbType": "PostgreSQL", "ConnectionString": "..." } ] } ] } } } } ``` 之后 `IRepositoryBase` 的读写全部走 Erp 模块库,业务代码不用切连接、不用自己拿 `ISqlSugarClient`: ```csharp public class ErpOrderAppService(IRepositoryBase orderRepository, IRepositoryBase userRepository) { // orderRepository 走 Erp 模块库,userRepository 走主库,两者在同一次请求里各取各的连接 } ``` 规则: - **模块名不是连接标识**:模块库的 `ConfigId` 由父连接派生(`{父连接}_{模块名}`),上例即 `Default_Erp`。因此模块名不占用顶层 `ConfigId` 命名空间,跟租户连接标识撞不上。 - **两条维度正交**:租户维度先定「用哪一套布局」(主库加它下面的模块库),模块维度再在布局内选库。库隔离租户的模块表落 `Tenant_{租户Id}_Erp`,字段隔离租户落共享的 `Default_Erp`,实体一个字都不用改。 - **库隔离租户自带整套模块库**:租户连接描述符没声明模块库时,框架按默认布局逐条镜像给它——默认布局给某模块分了库,该租户也分,库名由租户主库名派生成 `{租户库名}_{模块名}`(SQLite 是库文件名);默认布局那条连接串留空(该模块不分库)时租户同样不分,模块表落它自己的主库。于是「租户声明了库隔离」就意味着它的数据都在自己的库里,不会有一部分悄悄落回公共模块库。开关是 `EnableTenantModuleDatabaseConvention`(默认 `true`),关掉即退回旧行为(模块表回落共享模块库,按 `TenantId` 行过滤区分)。派生不出库名(如 Oracle 这类连接串里没有库名字段)时抛异常,且抛在主库注册之前——整套布局要么一起落地要么都不落地。 - **连接串留空表示不分库**:模块条目写了名字、连接串留空,即该模块直接用父连接的主库;条目整条缺失才是未配置。 - **fail-closed**:实体声明的模块数据源在当前布局与默认布局里都没有配置时直接抛异常,绝不回退主库造成跨库串写。 - **建表口径一致**:声明了模块数据源的实体只在自己的模块库建表(每套布局各建一份);未声明的实体不进模块库,需要模块库里也建框架公共表时把该 `ConfigId` 列入 `TableInitialization.SharedConnectionConfigIds`。 - **模块库跟着启动一起建**:启动时的建库建表遍历的是全量连接标识(含派生出的模块库),模块库不存在会被自动创建;库隔离租户开通时走 `IDbInitializer.InitializeCurrentLayoutAsync()`,把该租户的主库与它自带的模块库一起建出来。 - **种子按连接圈定**:模块自带的种子标 `[DataSeeding(ConnectionConfigIds = ["Default_Erp"])]`,避免在遍历每个库时重复执行。 - **兼容 SqlSugar 原生特性**:实体已标了 SqlSugar 的 `[Tenant("Erp")]` 时同样被识别,此时声明的是连接标识本身,按相等匹配。 - **跨库写不是一个事务**:同一工作单元跨多个库写入时,每个 `ConfigId` 各开一个本地事务,框架不提供跨库分布式事务;需要强一致时把跨库步骤拆成可补偿的流程。 要把某个租户的某个模块库指到别处(另一台机器、另一种库),在它的租户连接描述符里显式写出来即可——显式的优先,上面的约定只补它没提到的模块: ```csharp return descriptor with { ModuleDataSourceConfigs = [new SqlSugarModuleDataSourceConfigOptions { ModuleDataSource = "Erp", ConnectionString = "..." }] }; ``` 需要从别处(模块清单、配置表)决定实体归属时,替换解析器即可,仓储路由与建表初始化会同步跟着变: ```csharp services.Replace(ServiceDescriptor.Singleton()); ``` ### 固定落平台库:`[PlatformDataSource]` 库隔离部署下,租户的业务与运行数据落各自的独立库;而平台目录、账号、成员关系与读共享的模板,要跨租户读取、或与平台数据在同一条 SQL 里关联,只能集中在平台库(默认布局的主库)。给这类实体标 `[PlatformDataSource]`: ```csharp [SugarTable("sys_user_tenant")] [PlatformDataSource] public class SysUserTenant : SugarMultiTenantEntity { } ``` - **运行期**:无论当前租户是否库隔离,读写都走平台库并登记进工作单元;行级租户过滤照常生效(`TenantId` 仍在行上)。字段隔离的租户本就在平台库,标注对它们没有影响。 - **建表**:只在平台库建,不进租户独立库与模块库。 - **与模块数据源互斥**:同时声明 `[ModuleDataSource]` 视为配置错误,解析即抛异常。 - **跨库写不是一个事务**:同一工作单元同时写平台库与租户独立库时,每个连接各开一个本地事务。 `[TableInitialization(Target = DbInitializationTarget.Platform)]` 只管「表建在哪」,运行期仍跟着当前租户的布局走;要读写也固定在平台库,用 `[PlatformDataSource]`。 ## 选择初始化范围 `EnableTableInitialization` / `EnableDataSeeding` 是总开关,**选哪些表建、哪些种子跑**由两组选取规则决定。默认全量(扫描到的实体都建、注册的种子都跑),与总开关打开时的历史行为一致。 ### 特性:在实体/种子上声明 ```csharp // 这张表由 DBA 维护,框架不要碰 [SugarTable("sys_partitioned_log")] [TableInitialization(false)] public class SysPartitionedLog : SugarEntity { } // 平台库独有,租户独立库不建 [SugarTable("sys_tenant")] [TableInitialization(Target = DbInitializationTarget.Platform)] public class SysTenant : SugarEntity { } // 只在归档库建表 [SugarTable("sys_archive")] [TableInitialization(ConnectionConfigIds = ["Archive"])] public class SysArchive : SugarEntity { } // 整组种子归一个分组,配置里按组开关 [DataSeeding(Group = "Demo")] public abstract class DemoSeederBase : DataSeederBase { } ``` | 成员 | 说明 | | --- | --- | | `Enabled` | `false` 表示不参与,该表/该种子交由开发者自己维护 | | `Group` | 分组名,配合选项里的 `IncludedGroups`/`ExcludedGroups` 按组开关 | | `Target` | `Platform` / `Tenant` / `All`(默认)。租户库指 `ConfigId` 以 `TenantConfigIdPrefix` 开头的运行时连接 | | `ConnectionConfigIds` | 仅在这些连接上建表/播种,为空表示不限连接 | 特性标在基类上对派生类型同样生效(`Inherited = true`)。 ### 选项:在配置里圈定范围 ```json { "XiHan": { "Data": { "SqlSugarCore": { "EnableTableInitialization": true, "TableInitialization": { "Mode": "All", "ExcludedTables": [ "sys_diff_log", "XiHan.BasicApp.Cms.*" ] }, "EnableDataSeeding": true, "DataSeeding": { "ExcludedGroups": [ "Demo" ] } } } } } ``` - `Mode`:`All`(默认,扫到的都参与,标了 `Enabled = false` 的除外)或 `OptIn`(只有显式标了特性的才参与)。 - `IncludedGroups` / `ExcludedGroups`:按特性上的 `Group` 圈定。 - `IncludedTables` / `ExcludedTables`(种子对应 `IncludedSeeders` / `ExcludedSeeders`):支持 `*` `?` 通配;表按**实体类名、实体全名、表名**任一匹配,种子按 **`Name`、类名、类全名**任一匹配。 - `SharedConnectionConfigIds`(仅建表):模块库中额外允许建「未声明模块数据源实体」的连接名单,支持通配。 - `Filter`:`Func?` / `Func?` 代码钩子,只能在 `Configure` 里设置。 判定顺序:模块数据源 → 特性 → 模式 → 分组 → 名称 → 自定义委托,任一环节否决即不参与。 ### 接管:自己实现选取逻辑 想完全自己决定(例如从清单文件、数据库配置表读建表范围)时替换默认实现: ```csharp services.Replace(ServiceDescriptor.Singleton()); services.Replace(ServiceDescriptor.Singleton()); ``` 两个接口都拿到 `DbInitializationContext`(当前连接标识、租户标识、是否租户独立库),据此对不同库返回不同范围。默认实现的 `ShouldInitialize` / `ShouldSeed` 是 `protected virtual`,只想改一条规则时继承默认实现覆写即可。 ## 扩展点 / 自定义 所有核心服务用 `TryAdd*` 注册,业务层可注册自定义实现覆盖: - **审计上下文**:注册自定义 `IEntityAuditContextProvider` 替换默认从 `ICurrentUser`/HTTP 的填充逻辑。 - **差异日志落库**:启用 `EnableDiffLog` 后注册真实 `IEntityDiffLogWriter`(默认 `Null` 不产生记录)。 - **租户库隔离**:注册 `ISqlSugarTenantConnectionProvider`,`Resolve` 返回租户独立连接描述符即启用库级隔离;返回 `null` 退化为 `ConfigId`(行/字段)隔离。 - **自定义连接解析**:设置 `Options.ResolveConnectionConfigId` 委托,或用 `TenantConfigIdPrefix`/`DefaultConfigId` 约定映射。 - **额外全局过滤器**:调用 `Options.AddGlobalFilter(expr)` 注册表达式树(须可翻译为 SQL 的成员访问/标量比较,勿调用外部方法;构建期 fail-fast 校验,非法表达式启动即报错)。 - **初始化范围**:用 `[TableInitialization]`/`[DataSeeding]` 特性与 `TableInitialization`/`DataSeeding` 选项圈定,或 `Replace` 掉 `IDbEntityTypeProvider`/`IDataSeederSelector` 自己实现,见[选择初始化范围](#选择初始化范围)。 - **模块数据源**:实体标 `[ModuleDataSource("Erp")]` 声明所属模块库,或 `Replace` 掉 `IEntityModuleDataSourceResolver` 从别处决定归属,见[模块分库(模块数据源)](#模块分库-模块数据源)。 - **客户端级配置**:`Options.ConfigureDbAction` 钩子拿到 `ISqlSugarClient` 做自定义装配。 ## 注意事项与最佳实践 - **默认不建库**:`EnableDbInitialization`/`EnableTableInitialization`/`EnableDataSeeding` 默认全 `false`;本机/首次部署需在配置里显式打开。表已存在会跳过(不做迁移,遵循"重建库、无向后兼容"约定)。 - **总开关之外还有范围**:打开建表/种子开关后默认全量参与;要挑表挑种子(含平台库与租户独立库分开建)见[选择初始化范围](#选择初始化范围)。 - **事务靠工作单元**:仓储内不开事务;需要多写原子提交时给应用服务方法打 `[UnitOfWork(isTransactional: true)]`,`ISqlSugarClientResolver` 会自动把连接登记进 UoW 事务。见 [XiHan.Framework.Uow](./uow)。 - **跨库不是一个事务**:同一工作单元跨多个 `ConfigId`(模块分库、租户独立库)写入时,每个连接各开本地事务,框架不提供跨库分布式事务;有强一致要求就把跨库步骤拆成可补偿流程。 - **越租户写会被拒**:`Update/Delete` 前的可见性预读若读不到实体(不在当前作用域/已软删),抛 `InvalidOperationException`;每个作用域只能改写本作用域的行——租户不能改 `TenantId=0` 的全局行,平台也不能改租户行,平台上下文插入预置了租户标识的行同样被拒。写某个租户的数据须先 `ICurrentTenant.Change(tenantId)` 切入该租户。这是安全边界,不是 bug。 - **跨租户/含软删查询**:仓储内部提供 `CreateNoTenantQueryable()`(清租户过滤,读共享与严格隔离一并清除)/`CreateWithDeletedQueryable()`(清软删过滤);仓储外的查询用 `ClearTenantFilter()` / `ClearTenantAndSoftDeleteFilter()` 扩展,不要直接写 `ClearFilter()`(清不掉严格隔离实体的过滤)。仅用于确需跨租户的场景且须自行做权限校验。 - **`TenantId=0` 是全局模板**:多租户实体的 `TenantId` 非空,`0` 表示平台/全局记录,对所有租户可见(配合 `UNIQUE(TenantId, Code)` 复合唯一索引对全局记录生效)。 - **审计字段勿手填**:`TenantId`、创建/修改/删除时间与操作人由 `DataExecuting` AOP 注入,业务侧手填会被覆盖或引发不一致。 - **雪花主键**:主键由 `IDistributedIdGenerator` 通过 `StaticConfig.CustomSnowFlakeFunc` 全局生成;实体基类的 `BasicId` 映射为非自增主键。 - **慢 SQL 阈值与命令超时相互独立**:`SlowSqlThresholdMilliseconds` 只用于慢 SQL 日志判定;命令执行超时由 `CommandTimeoutSeconds` 单独配置(默认 300s),且应明显大于慢 SQL 阈值,否则慢 SQL 会先被驱动杀掉、慢日志记不到。 ## 依赖模块 - [XiHan.Framework.Domain](./domain)(仓储接口 / 实体与聚合根抽象来源) - [XiHan.Framework.Uow](./uow)(事务边界接管,`SqlSugarTransactionApi` 接入) - [XiHan.Framework.MultiTenancy](./multitenancy)(`ICurrentTenant`/`ICurrentTenantAccessor` 租户上下文) - [XiHan.Framework.Security](./security)(`ICurrentUser` 审计上下文) - [XiHan.Framework.DistributedIds](./distributed-ids)(雪花 ID 生成器) - [XiHan.Framework.Auditing](./auditing)(`IEntityAuditContextProvider`/`IEntityDiffLogWriter` 默认实现来源) - [XiHan.Framework.Core](./core)(模块化基础设施,含链路追踪 `XiHanActivitySources`) - 第三方核心:**SqlSugarCore** `5.1.4.217` ## 相关模块 - [XiHan.Framework.Uow](./uow) - [XiHan.Framework.Domain](./domain) - [XiHan.Framework.MultiTenancy](./multitenancy) - [XiHan.Framework.Auditing](./auditing) - [XiHan.Framework.Caching](./caching) --- 来源:https://framework.docs.xihanfun.com/packages/devtools # XiHan.Framework.DevTools > 开发工具库。当前实际落地内容是一套从零实现、零第三方依赖的命令行(CommandLine)框架。 - **NuGet**:`XiHan.Framework.DevTools` - **模块类**:`XiHanDevToolsModule`(当前为空类骨架,见下文) - **所在层**:基础设施层 - **关键依赖**:仅框架内部 `XiHan.Framework.Core`(无任何第三方 `PackageReference`);命令行框架完全自研 ## 概述 XiHan.Framework.DevTools 定位为框架的开发工具库。就目前源码而言,它落地的核心是 `CommandLine/` 目录下一套**不依赖任何第三方 CLI 库**的命令行框架:把参数解析、命令注册与分发、强类型绑定、参数校验、帮助生成和交互式模式封装成一组可直接使用的类型,方便你为工具程序、脚手架或调试小工具快速搭出规范的命令行入口。 > **模块类现状**:包内 `XiHanDevToolsModule` 目前是一个**空的普通类**——它既未继承 `XiHanModule`,也没有 `ConfigureServices`。因此本包实际是一个「直接引用即可」的工具库,而非需要 `[DependsOn]` 挂载的功能模块;仓库内 README 里给出的 `[DependsOn(typeof(XiHanDevToolsModule))]` 用法**当前并不成立**(该 README 的「目录结构」也是模板化的,未反映真实的 `CommandLine` 子系统,以源码为准)。 ## 何时使用 - 需要给控制台程序或工具快速加上命令、子命令、选项与位置参数解析,又不想引入外部 CLI 库。 - 想把命令行参数直接绑定到强类型对象(`Parse` / `CommandLineBinder`)。 - 需要内置的 `--help` / `--version` 输出,或一个简单的交互式(REPL 式)命令行模式。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.DevTools ``` `XiHanDevToolsModule` 为空骨架,无需也无法通过它挂载功能。直接引用本包,使用 `CommandLine` 命名空间下的类型即可: ```csharp using XiHan.Framework.DevTools.CommandLine; var app = new CommandApp { Description = "示例工具" }; app.DiscoverCommands(); // 自动发现带 [Command] 特性的命令 return await app.RunAsync(args); ``` ## 工作原理 `CommandApp.RunAsync` 的处理链路:`CommandLineParser` 解析原始参数 → 检查 `--help` / `--version` 内置选项 → 按命令名(含子命令)定位 `CommandDescriptor` → `CommandLineBinder` 将参数绑定到命令对象(含必填校验与自定义校验)→ 依命令实现的接口调用 `ExecuteAsync`(`ICommand`)或 `Execute`(`ISyncCommand`),返回退出码。命令通过 `[Command]` 特性声明、`AddCommand()` 显式注册或 `DiscoverCommands()` 反射发现。 ## 核心能力 - **命令应用宿主(`CommandApp`)**:`AddCommand()` / `AddCommand(Type)` 注册、`DiscoverCommands(assembly?)` 按 `[Command]` 特性自动发现、`Run` / `RunAsync` 执行,自动处理 `--help` / `--version`,支持默认命令与隐藏命令(`GetVisibleCommands`)。 - **参数解析(`CommandLineParser` / `CommandLineParserFactory`)**:支持长选项(`--name`)、短选项(`-n`)、POSIX 组合短选项(`-abc`)、键值对(`key=value` / `key:value`)与 `--` 停止解析标记;`CommandLineParserFactory` 提供静态 `Parse` / `Parse` / `TryParse` / `TryParse` 便捷入口。 - **强类型绑定(`CommandLineBinder`)**:静态 `Bind(ParsedArguments)` 以及扩展 `Bind(this CommandLineBinder, Type, ParsedArguments)`,把解析结果绑定到标注了选项/参数特性的属性或字段,并在绑定时执行必填与自定义校验。 - **命令定义**:实现 `ICommand`(异步)或 `ISyncCommand`(同步),配合 `[Command]`、`[CommandOption]`、`[CommandArgument]`、`[SubCommand]` 声明元数据,支持子命令层级。 - **参数校验**:内置 `[Range]`、`[FileExists]`、`[DirectoryExists]` 校验特性(均继承本包的 `ValidationAttribute`)及对应 `IValidator` 实现。 - **帮助与交互**:`HelpGenerator` 生成帮助文本;`RunInteractiveAsync` / `RunInteractive` 扩展方法提供交互式命令行模式(`InteractiveMode`)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `CommandApp` | 命令行应用宿主,负责注册、发现、解析与执行命令,含 `--help` / `--version` 处理 | | `CommandLineParser` / `CommandLineParserFactory` | 参数解析器(实例式 / 静态工厂式),产出 `ParsedArguments` 或强类型对象 | | `CommandLineBinder` | 将 `ParsedArguments` 绑定到强类型命令对象(`Bind` / `Bind(Type, ...)`) | | `ICommand` / `ISyncCommand` | 异步 `Task ExecuteAsync(CommandContext)` / 同步 `int Execute(CommandContext)` | | `CommandContext` / `CommandDescriptor` | 命令执行上下文与命令描述符(含子命令、隐藏、默认命令等元数据) | | `ParsedArguments` / `ArgumentToken` | 解析结果与单个参数词元 | | `CommandAttribute` / `CommandOptionAttribute` / `CommandArgumentAttribute` / `SubCommandAttribute` | 声明命令、选项、位置参数与子命令的特性 | | `ValidationAttribute` / `RangeAttribute` / `FileExistsAttribute` / `DirectoryExistsAttribute` | 校验特性基类与内置校验特性 | | `IValidator` / `RangeValidator` / `FileExistsValidator` / `DirectoryExistsValidator` | 校验器接口与内置校验器 | | `ParseOptions` | 解析行为配置(见下) | | `HelpGenerator` / `InteractiveMode` | 帮助文本生成与交互式模式 | ## 配置(`ParseOptions`) `ParseOptions` 是解析行为配置对象(通过 `new CommandApp(parseOptions)` 或 `CommandLineParserFactory.Parse(args, options)` 传入),**非 appsettings 配置节**: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `AllowUnknownOptions` | `bool` | `false` | 是否允许未知选项 | | `CaseSensitive` | `bool` | `false` | 命令/选项名是否大小写敏感 | | `EnablePosixStyle` | `bool` | `true` | 是否启用 POSIX 组合短选项(`-abc`) | | `AutoGenerateHelp` | `bool` | `true` | 是否自动处理帮助选项 | | `AutoGenerateVersion` | `bool` | `true` | 是否自动处理版本选项 | | `HelpOptions` | `string[]` | `["help", "h"]` | 帮助选项名 | | `VersionOptions` | `string[]` | `["version", "v"]` | 版本选项名 | | `ValueSeparators` | `char[]` | `['=', ':']` | 键值对分隔符 | | `StopParsingMarker` | `string` | `"--"` | 停止解析标记 | ## 使用示例 ```csharp using XiHan.Framework.DevTools.CommandLine; using XiHan.Framework.DevTools.CommandLine.Attributes; using XiHan.Framework.DevTools.CommandLine.Commands; [Command("greet", Description = "打招呼命令")] public class GreetCommand : ICommand { [CommandOption("name", "n", Description = "名字")] public string Name { get; set; } = "World"; public Task ExecuteAsync(CommandContext context) { Console.WriteLine($"Hello, {Name}!"); return Task.FromResult(0); } } // 入口 var app = new CommandApp(); app.AddCommand(); return await app.RunAsync(args); // 例如: greet --name XiHan ``` 一次性把参数解析为强类型对象: ```csharp var options = CommandLineParserFactory.Parse(args); ``` ## 扩展点 / 自定义 - **自定义命令**:实现 `ICommand` / `ISyncCommand` 并标注 `[Command]`,用 `AddCommand()` 或 `DiscoverCommands()` 接入。 - **自定义校验**:继承 `ValidationAttribute` 或实现 `IValidator`,绑定阶段会自动执行。 - **调整解析行为**:通过 `ParseOptions` 定制大小写敏感、POSIX 风格、值分隔符、帮助/版本选项等。 ## 注意事项与最佳实践 - **模块类是空骨架**:不要指望 `[DependsOn(typeof(XiHanDevToolsModule))]` 生效——本包按普通工具库直接引用使用。 - **绑定要求**:强类型绑定目标类型需可无参构造(`Parse` 约束 `T : new()`);`CommandApp` 内部通过 `CommandDescriptor.CreateInstance()` 创建命令实例再复制绑定值。 - **以源码为准**:包内 README 是模板化文档,其 `[DependsOn]` 用法与「目录结构」均与真实实现不符。 ## 依赖模块 - [XiHan.Framework.Core](./core) — 唯一的 `ProjectReference`;无任何第三方 `PackageReference`。 - 运行期还会用到 `XiHan.Framework.Utils` 的 `ConsoleTools`(着色输出,如 `ConsoleColorWriter`),随依赖链间接引入。 ## 相关模块 - [XiHan.Framework.Core](./core) — 核心库,本包的唯一直接依赖。 - [XiHan.Framework.Utils](./utils) — 通用工具库,其中 `ConsoleTools` 为本包提供控制台输出支持。 --- 来源:https://framework.docs.xihanfun.com/packages/distributed-ids # XiHan.Framework.DistributedIds > 分布式唯一标识生成:自研 Snowflake / NanoId / SequentialGuid 生成器 + Sqids 短码编码,统一在 `IDistributedIdGenerator` 接口下,零第三方依赖。 - **NuGet**:`XiHan.Framework.DistributedIds` - **模块类**:`XiHanDistributedIdsModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖([XiHan.Framework.Core](./core))。所有算法均为框架自研,不引入任何第三方 ID 库。 ## 概述 XiHan.Framework.DistributedIds 用于在分布式环境下生成不重复的标识。它自研实现了三种 `IDistributedIdGenerator` 生成器——Snowflake(雪花漂移/传统雪花,`long`)、NanoId(随机字符串,但对外仍以 `long` 键接口暴露)、SequentialGuid(时间有序 `Guid`)——并额外提供 Sqids 短码编码方案。所有算法都不依赖外部 NuGet 包,便于版本控制与定制。 设计上把「生成器」与「编码器」区分开:**生成器**实现统一接口 `IDistributedIdGenerator`(`TKey` 为 `long` 或 `Guid`),可被 DI 注入;**Sqids** 则是把一个/多个非负整数双向编解码为短字符串的 `SqidsEncoder`,**不实现** `IDistributedIdGenerator`。此外还有一个更基础的 **`GuidHelper`** 静态工具类,提供通用 Guid 生成/校验/格式转换能力,同样不实现生成器接口,与 `SequentialGuidGenerator` 相互独立。 ## 何时使用 - 需要跨库、跨服务、无中心协调地生成不重复主键或业务编号。 - 高并发下想要有序、可从中提取时间戳/机器信息的 `long` 型 ID(Snowflake)。 - 需要数据库索引友好的时间有序 `Guid`(SequentialGuid)——框架实体主键的默认选择。 - 需要 URL 友好、加密安全随机的短字符串 ID(NanoId)。 - 需要把自增整数编码成不可预测的短码对外展示、隐藏真实顺序/数量(Sqids)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.DistributedIds ``` ```csharp [DependsOn(typeof(XiHanDistributedIdsModule))] public class MyModule : XiHanModule { } ``` 模块在 `ConfigureServices` 中调用 `AddXiHanDistributedIds(config)`,它做两件事: 1. 绑定四个 Options 到配置节(`SnowflakeIdOptions` / `SequentialGuidOptions` / `NanoIdOptions` / `SqidsOptions`)。 2. 以单例注册两个默认生成器: - `IDistributedIdGenerator` → `SequentialGuidGenerator`(默认 `Default()`,即 `SequentialAtEnd` 末尾形式) - `IDistributedIdGenerator` → `SnowflakeIdGenerator`(默认 `HighWorkload`,`SeqBitLength=12`、`WorkerIdBitLength=6`) > 注意:默认只注册了 `Guid` 与 `long` 两个生成器。NanoId 与 Sqids 不在默认 DI 注册中,需要时通过 `IdGeneratorFactory` / `SqidsEncoder` / 扩展方法自行构造。 ## 核心能力 - **多算法生成器**:Snowflake(`long`)、NanoId(字符串,键接口为 `long`)、SequentialGuid(`Guid`),统一 `IDistributedIdGenerator` 接口。 - **Sqids 短码编码**:`SqidsEncoder`(`T : INumber`)与 `SqidsExtensions` 扩展方法,可逆、顺序混淆、内置屏蔽词过滤。 - **零第三方依赖**:全部算法自研,便于审计与定制。 - **信息可提取**:接口提供 `ExtractTime` / `ExtractWorkerId` / `ExtractSequence` / `ExtractDataCenterId`(主要对 Snowflake 有意义)。 - **工厂预置场景**:`IdGeneratorFactory` 提供低/中/高并发、短 ID、前缀、经典雪花、多种 NanoId 字符集、多种 SequentialGuid 排序等大量预置构造方法。 - **同步/异步与批量**:接口提供 `NextId()` / `NextIdString()` / `NextIds(count)` 及对应 `...Async` 变体。 - **数据库友好**:`SequentialGuidGenerator` 支持字符串/二进制/末尾三种排序模式以适配不同数据库索引。 - **Guid 工具箱**:`GuidHelper`(静态)提供标准/加密安全/时间有序/确定性(SHA1、MD5)Guid 生成,以及有效性校验、N/D/B/P/X 格式转换、字节数组/Base64 互转、版本位与变体位解析等,独立于生成器接口体系,不参与 DI 注册。 ## 主要 API / 类型 ### 统一生成器接口 `IDistributedIdGenerator` 关键成员(`TKey` 为 `long` 或 `Guid`): | 成员 | 说明 | | --- | --- | | `TKey NextId()` | 生成下一个唯一标识 | | `string NextIdString()` | 生成下一个唯一标识(字符串形式,Snowflake 会带上前缀等) | | `TKey[] NextIds(int count)` | 批量生成 | | `string[] NextIdStrings(int count)` | 批量生成(字符串形式) | | `Task NextIdAsync()` 等 | 上述方法的异步变体(`NextIdStringAsync` / `NextIdsAsync` / `NextIdStringsAsync`) | | `DateTime ExtractTime(TKey id)` | 从 ID 提取时间戳 | | `int ExtractWorkerId(TKey id)` | 从 ID 提取工作机器标识 | | `int ExtractSequence(TKey id)` | 从 ID 提取序列号 | | `int ExtractDataCenterId(TKey id)` | 从 ID 提取数据中心标识 | | `string GetGeneratorType()` | 生成器类型名 | | `Dictionary GetStats()` | 生成器运行状态 | ### 工厂 `IdGeneratorFactory`(静态) | 方法 | 返回 | 说明 | | --- | --- | --- | | `CreateSnowflakeIdGenerator(SnowflakeIdOptions)` | `IDistributedIdGenerator` | 自定义选项 | | `CreateSnowflakeIdGenerator_LowWorkload(ushort workerId = 1)` | `IDistributedIdGenerator` | 低并发(`SeqBitLength=6`) | | `CreateSnowflakeIdGenerator_MediumWorkload(ushort workerId = 1)` | `IDistributedIdGenerator` | 中并发(`SeqBitLength=10`) | | `CreateSnowflakeIdGenerator_HighWorkload(ushort workerId = 1)` | `IDistributedIdGenerator` | 高并发(`SeqBitLength=12`,默认注册用它) | | `CreateSnowflakeIdGenerator_ShortId(ushort workerId = 1)` | `IDistributedIdGenerator` | 短 ID(`IdLength=10`) | | `CreateSnowflakeIdGenerator_PrefixedId(string prefix, ushort workerId = 1)` | `IDistributedIdGenerator` | 带前缀 | | `CreateSnowflakeIdGenerator_Classic(ushort workerId = 1, byte dataCenterId = 1)` | `IDistributedIdGenerator` | 传统 Twitter Snowflake 兼容 | | `CreateNanoIdGenerator(NanoIdOptions)` | `IDistributedIdGenerator` | 自定义字符集/长度 | | `CreateNanoIdGenerator_Numeric/_Lowercase/_Uppercase/_UrlSafe/_Safe/_Hex/_Custom(...)` | `IDistributedIdGenerator` | 各预置字符集 | | `CreateSequentialGuidGenerator(SequentialGuidOptions)` | `IDistributedIdGenerator` | 自定义排序模式 | | `CreateSequentialGuidGenerator_Default/_AsString/_AsBinary/_AtEnd()` | `IDistributedIdGenerator` | 各排序模式,`_Default` 即 `_AtEnd` | > NanoId 生成器虽然产出随机字符串,但接口签名仍是 `IDistributedIdGenerator`;取字符串结果请用 `NextIdString()`,`NextId()` 返回的是内部数值。 ### Sqids 编码(非生成器) | 类型 | 说明 | | --- | --- | | `SqidsEncoder where T : INumber` | 泛型编码器。`string Encode(params T[] numbers)` 编码、`T[] Decode(string id)` 解码。构造可传 `SqidsOptions` | | `SqidsEncoder`(非泛型,`: SqidsEncoder`) | 面向 `int` 的便捷子类 | | `SqidsExtensions`(静态扩展) | `int/uint/long/ulong` 的 `ToSqid()`,以及 `string.FromSqidToInt32/…Int32Array/…Int64/…Int64Array/…UInt32/…UInt64()` | ### Guid 通用工具 `GuidHelper`(静态,非生成器) `XiHan.Framework.DistributedIds.Guids.GuidHelper` 是独立于 `IDistributedIdGenerator` 体系之外的 Guid 工具类,覆盖生成、校验、格式转换与位域解析: | 分类 | 方法 | 说明 | | --- | --- | --- | | 生成 | `NewGuid()` | 标准随机 Guid(等价 `Guid.NewGuid()`) | | 生成 | `NewCryptoGuid()` | 使用 `RandomNumberGenerator` 生成的加密安全随机 Guid,版本位设为 4 | | 生成 | `NewTimeBasedGuid()` | 基于 `DateTime.UtcNow.Ticks` + 随机字节拼装的时间有序 Guid,版本位设为 1 | | 生成 | `NewDeterministicGuid(string input, Guid? namespaceGuid = null)` | SHA1 命名空间确定性 Guid(版本 5),相同输入必得相同输出 | | 生成 | `NewDeterministicGuidMd5(string input, Guid? namespaceGuid = null)` | MD5 命名空间确定性 Guid(版本 3) | | 生成 | `GenerateMultiple(int count, bool useCrypto = false)` | 批量生成 `List` | | 校验 | `IsValidGuid` / `IsValidStandardGuid`(带连字符)/ `IsValidGuidNoDash`(无连字符) | 格式校验 | | 解析 | `TryParse` / `Parse` | 包装 `Guid.TryParse` / `Guid.Parse` | | 格式化 | `ToString(Guid, format = "D")` / `ToUpperString` / `ToLowerString` | 支持 N/D/B/P/X 格式 | | 格式转换 | `ToStringNoDash` / `ToStandardFormat` / `ToNoDashFormat` | 标准格式与无连字符格式互转 | | 字节/Base64 | `ToByteArray` / `FromByteArray` / `ToBase64String` / `FromBase64String` | 与字节数组、Base64 字符串互转 | | 位域解析 | `GetVersion(Guid)` / `GetVariant(Guid)` | 提取 RFC 4122 版本号(1-5)与变体位 | | 判空/比较 | `IsEmpty` / `IsNotEmpty` / `AreEqual` / `GetHashCode(Guid)` | 基础判断 | | 诊断 | `GetFormatExamples(Guid? guid = null)` | 返回 N/D/B/P/X/Base64 六种格式示例的字典 | > `GuidHelper` 与 `SequentialGuidGenerator` 相互独立:前者是通用 Guid 工具箱(静态方法,不实现 `IDistributedIdGenerator`,不参与 DI 注册),后者是专门产出「末尾/字符串/二进制」三种排序模式有序 Guid 的生成器实例。`GuidHelper.NewTimeBasedGuid()` 虽然也时间有序,但字节布局与算法完全不同于 `SequentialGuidGenerator`,两者生成的 Guid 不可互换、不可用同一套 `ExtractTime` 逻辑解析。 ## 配置 四个 Options 各自绑定独立配置节,前缀均为 `XiHan:DistributedIds:*`。 | Options | 配置节(`SectionName`) | | --- | --- | | `SnowflakeIdOptions` | `XiHan:DistributedIds:SnowflakeId` | | `SequentialGuidOptions` | `XiHan:DistributedIds:SequentialGuid` | | `NanoIdOptions` | `XiHan:DistributedIds:NanoId` | | `SqidsOptions` | `XiHan:DistributedIds:Sqids` | ### `SnowflakeIdOptions` 关键字段 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `WorkerId` | `ushort` | `0` | 机器码,需全局唯一,上限受 `WorkerIdBitLength` 约束 | | `WorkerIdBitLength` | `byte` | `6` | 机器码位长(1–15),与 `SeqBitLength` 之和 ≤ 22 | | `SeqBitLength` | `byte` | `6` | 序列号位长(3–21),与 `WorkerIdBitLength` 之和 ≤ 22 | | `SnowflakeIdType` | `SnowflakeIdTypes` | `SnowFlakeMethod` | `SnowFlakeMethod`(=1 雪花漂移) / `ClassicSnowFlakeMethod`(=2 传统雪花) | | `TimestampType` | `TimestampTypes` | `Milliseconds` | `Seconds` / `Milliseconds` | | `DataCenterId` / `DataCenterIdBitLength` | `byte` | `0` / `5` | 传统雪花的数据中心码及位长(1–10) | | `BaseTime` | `DateTime` | `2026-01-01 UTC` | 纪元起点,不能超过当前系统时间 | | `UseCustomEpoch` | `bool` | `true` | 是否用 `BaseTime` 作为时间起点 | | `MaxSeqNumber` / `MinSeqNumber` | `int` | `63` / `5` | 最大/最小序列号 | | `TopOverCostCount` | `int` | `2000` | 最大漂移次数(0–10000) | | `LoopedSequence` | `bool` | `false` | 序列号到顶时循环回最小值而非等待下一时间片 | | `MaxBackwardToleranceMs` | `int` | `10000` | 时钟回拨最大容忍毫秒(0–60000) | | `IdLength` | `byte` | `0` | ID 输出长度(0=默认,或 10–20),会截断/填充 | | `IdPrefix` | `string` | 空 | ID 字符串前缀 | | `GeneratorId` | `string` | 随机 GUID(N) | 生成器实例标识 | > `SnowflakeIdOptions` 另提供静态工厂 `LowWorkload/MediumWorkload/HighWorkload/ShortId/PrefixedId/Classic` 及 `FromJson/ToJson`。多数属性带范围校验,越界会抛 `ArgumentException`。 ### `NanoIdOptions` 关键字段 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Size` | `int` | `21` | ID 长度(1–128) | | `Alphabet` | `string` | 62 字符 URL 安全集 | 字符集,须 ≥2 且字符唯一 | | `StartTime` | `DateTime` | `2020-01-01 UTC` | 时间提取起点 | | `TimestampType` | `TimestampTypes` | `Milliseconds` | 时间戳粒度 | 内置字符集常量:`DefaultAlphabet` / `NumbersAlphabet` / `LowercaseAlphabet` / `UppercaseAlphabet` / `UrlSafeAlphabet` / `SafeAlphabet`(无相似字符)/ `HexAlphabet`;对应静态工厂 `OnlyNumbers/OnlyLowercase/OnlyUppercase/UrlSafe/Safe/Hex`。 ### `SequentialGuidOptions` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `DefaultSequentialGuidType` | `SequentialGuidType?` | `null`(回退 `SequentialAtEnd`) | 排序模式 | `SequentialGuidType` 取值:`SequentialAsString`(字符串比较排序)、`SequentialAsBinary`(二进制排序)、`SequentialAtEnd`(末尾形式,推荐用于 SQL Server 聚集索引,也是框架默认)。静态工厂:`AsString/AsBinary/AtEnd/Default`。 ### `SqidsOptions` | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Alphabet` | `string` | 62 字符集 | 编码字母表,须 ≥3 且字符唯一 | | `MinLength` | `int` | `5` | 生成短码最小长度 | | `BlockList` | `HashSet` | 内置屏蔽词表 | 命中则重排字母表重试,避免生成不雅词 | ### 示例 appsettings.json ```json { "XiHan": { "DistributedIds": { "SnowflakeId": { "WorkerId": 1, "SeqBitLength": 12, "WorkerIdBitLength": 6, "TimestampType": "Milliseconds" }, "SequentialGuid": { "DefaultSequentialGuidType": "SequentialAtEnd" }, "NanoId": { "Size": 21 }, "Sqids": { "MinLength": 8 } } } } ``` ## 使用示例 ### 依赖注入(默认注册的生成器) ```csharp public class OrderService( IDistributedIdGenerator longGenerator, IDistributedIdGenerator guidGenerator) { public long NewOrderNo() => longGenerator.NextId(); // Snowflake long public Guid NewRowId() => guidGenerator.NextId(); // SequentialGuid(末尾形式) public async Task BatchAsync() => await longGenerator.NextIdsAsync(100); } ``` ### 工厂按场景构造(NanoId / 前缀 Snowflake) ```csharp // 高并发 Snowflake var sf = IdGeneratorFactory.CreateSnowflakeIdGenerator_HighWorkload(workerId: 1); long id = sf.NextId(); // URL 安全的 NanoId(21 位),取字符串结果 var nano = IdGeneratorFactory.CreateNanoIdGenerator_UrlSafe(21); string shortId = nano.NextIdString(); ``` ### Sqids 短码编解码(编码方案,非生成器) ```csharp // 扩展方法(最简) string code = 1234L.ToSqid(); // 例如 "xY7pQ" long back = code.FromSqidToInt64(); // 1234 // 编码器(可传自定义 SqidsOptions,支持多值) var encoder = new SqidsEncoder(new SqidsOptions { MinLength = 8 }); string packed = encoder.Encode(10, 20, 30); long[] nums = encoder.Decode(packed); // [10, 20, 30] ``` > Sqids 不能编码负数(会抛 `ArgumentException`),且相同选项下编码是确定的(字母表按选项 hash 洗牌)。 ### Guid 通用工具(`GuidHelper`,静态,非生成器) ```csharp Guid id = GuidHelper.NewCryptoGuid(); // 加密安全随机 Guid Guid ordered = GuidHelper.NewTimeBasedGuid(); // 时间有序(与 SequentialGuidGenerator 算法不同) Guid stable = GuidHelper.NewDeterministicGuid("order-1024"); // 相同输入恒定输出 bool ok = GuidHelper.IsValidGuid("not-a-guid"); // false string noDash = GuidHelper.ToStringNoDash(id); // 32 位无连字符 int version = GuidHelper.GetVersion(id); // 4 ``` ## 扩展点 / 自定义 - **替换默认生成器**:模块以 `AddSingleton` 直接注册具体实例(非 `TryAdd`)。若要换成自定义生成器,在你的模块 `ConfigureServices` 里用 `services.Replace(...)` 覆盖对应的 `IDistributedIdGenerator` / `IDistributedIdGenerator` 注册。 - **自定义算法**:实现 `IDistributedIdGenerator` 后注册即可,接口本身与内置实现解耦。 - **自定义字符集/字母表**:NanoId 用 `NanoIdOptions.Alphabet`、Sqids 用 `SqidsOptions.Alphabet`;均要求字符唯一。 ## 注意事项与最佳实践 - **Snowflake `WorkerId` 必须全局唯一**:同一 `WorkerId` 在多实例部署下会产生重复风险;容器/多副本环境需为每个实例分配不同 `WorkerId`(如通过配置或注册中心)。 - **`BaseTime` 不能晚于当前时间**:设置越界会抛异常。默认纪元为 `2026-01-01 UTC`。 - **位长约束**:`WorkerIdBitLength + SeqBitLength ≤ 22`,越界抛 `ArgumentException`。 - **NanoId 键类型是 `long`**:需要字符串短码时用 `NextIdString()`,别误用 `NextId()`。 - **`ExtractXxx` 主要面向 Snowflake**:对 NanoId/SequentialGuid 的语义有限,按需使用。 - **Sqids ≠ 加密**:它只是混淆顺序、生成短码,不提供机密性;也不能编码负数。 - **默认只注册 `Guid`/`long`**:NanoId、Sqids 需自行构造,不能直接注入。 ## 依赖模块 - [XiHan.Framework.Core](./core) — 唯一的框架内部依赖,提供模块化与依赖注入基座。 - 第三方核心依赖:无(所有算法自研)。 ## 相关模块 - [XiHan.Framework.Domain](./domain) — 实体主键常用分布式 ID 生成。 - [XiHan.Framework.Data](./data) — 数据层可结合时间有序 `Guid` / Snowflake 优化主键与索引。 --- 来源:https://framework.docs.xihanfun.com/packages/domain-shared # XiHan.Framework.Domain.Shared > 领域层共享包:跨领域层与应用层复用的分页查询子系统(声明式过滤/排序/关键字 + 分页 DTO),以及少量共享枚举与常量。 - **NuGet**:`XiHan.Framework.Domain.Shared` - **模块类**:`XiHanDomainSharedModule`(`ConfigureServices` 为空,不注册任何服务;仅作依赖占位) - **所在层**:领域与应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Core` / `XiHan.Framework.Utils`) ## 概述 这个包放的是「领域层」与「应用层」都要用到的共享定义,核心是一整套 **分页查询子系统**:请求里怎么声明式地描述过滤、排序、关键字搜索与分页参数,响应里怎么组织分页结果与元数据。因为它同时被 `Domain`、`Application.Contracts`、`Application` 引用,所以放在这个 **不依赖任何持久化 / Web** 的共享包里,避免各层重复定义、互相耦合。 设计上把「查什么」(`QueryConditions`,永远可安全进入 `Expression`)与「怎么查」(`QueryBehavior`,只影响 `IQueryable` 管道、不进入表达式)拆开,是这套查询协议的关键约定。包里同时内置了一组基于 `IQueryable` / `IEnumerable` 的扩展方法,能把这些声明式条件直接翻译成 LINQ 查询,因此它既是「协议契约」,也是「内存 / 表达式层的默认执行器」。 ## 何时使用 - 需要一个统一的分页请求/响应模型,让前后端约定一致的查询协议 - 想用「过滤条件 + 排序 + 关键字」这种声明式方式描述查询,而不是给每个接口手写一堆参数 - 定义应用服务/仓储的分页接口时,需要 `PageRequestDtoBase` / `` `PageResultDtoBase` `` 作为基类 - 想从一个普通查询 DTO(按属性命名约定)自动构建查询条件(`AutoQueryBuilder` / `AutoBuild`) - 需要在内存集合 / `IQueryable` 上就地套用这套条件(`PageExtensions` 的 `ApplyPageRequest` / `ApplyFilters` / `ApplySorts` / `ApplyKeywordSearch`) ## 安装与启用 ```bash dotnet add package XiHan.Framework.Domain.Shared ``` ```csharp [DependsOn(typeof(XiHanDomainSharedModule))] public class MyModule : XiHanModule { } ``` `XiHanDomainSharedModule.ConfigureServices` 目前是空实现——本包不注册任何服务、不接任何中间件,纯粹是共享类型 + 扩展方法的集合。通常你不会直接依赖它,而是通过依赖 `XiHan.Framework.Domain` 间接引入。 ## 工作原理 分页请求由三块正交的部分组成,理解它们的边界是用好本包的关键: ```text PageRequestDtoBase ├─ Conditions : QueryConditions —— “查什么”,可安全翻译为 Expression │ ├─ Filters : List 多字段过滤(AND 关系) │ ├─ Sorts : List 多字段排序(按 Priority 升序,值越小越优先) │ └─ Keyword : QueryKeyword? 关键字 + 参与搜索的字段(字段间 OR 关系) ├─ Behavior : QueryBehavior —— “怎么查”,只影响 Queryable 管道,永不进入 Expression │ (DisablePaging / DisableDefaultSort / IgnoreTenant / IgnoreSoftDelete / EnableSplitQuery / QueryTimeout) └─ Page : PageRequestMetadata —— PageIndex / PageSize(带边界钳制) ``` `PageExtensions.ApplyPageRequest` 的执行顺序是:先套 `Filters`(AND 聚合),再套关键字搜索(对每个字段 `Contains` 做 OR 合并),再按优先级套多字段排序(首个用 `OrderBy`/`OrderByDescending`,其余用 `ThenBy`/`ThenByDescending`),最后在 `Behavior.DisablePaging == false` 时套 `Skip/Take` 分页。 > 注意:本包内的表达式实现是「通用内存/`IQueryable` 版」。在真正的仓储层(`XiHan.Framework.Data`,基于 SqlSugar),过滤/排序/关键字/软删除/多租户/分表等会由持久化实现重新翻译,`QueryBehavior` 的 `IgnoreTenant`/`IgnoreSoftDelete`/`EnableSplitQuery`/`QueryTimeout` 等字段才真正生效。 ## 核心能力 - **声明式分页请求**:`PageRequestDtoBase` 承载 `Conditions` / `Behavior` / `Page`,并提供 `WithFilter` / `WithSort` / `WithKeyword` / `WithPage` / `WithoutPaging` 链式方法 - **结构化查询条件**:`QueryConditions` 聚合多字段过滤、多字段排序、关键字搜索,并有 `AddFilter/AddSort/SetKeyword/RemoveFilter/Clear/Clone/IsValid` 等操作 - **丰富的查询操作符**:`QueryOperator` 覆盖等值/比较、`Contains`/`StartsWith`/`EndsWith`、`In`/`NotIn`、`Between`、`IsNull`/`IsNotNull` - **分页结果封装**:`` `PageResultDtoBase` `` 组织数据项、`PageResultMetadata`(总数/总页数/上一页下一页等派生属性)与可选 `ExtendDatas` - **两套构建器**:手工链式的 `QueryBuilder`;以及按 DTO 属性 + 命名约定 **自动构建** 条件的 `AutoQueryBuilder` - **命名约定**:`QueryConvention` 定义「哪些属性后缀是范围/列表/关键字」「字符串默认模糊还是精确」等推断规则 - **就地执行扩展**:`PageExtensions` 在 `IQueryable`/`IEnumerable`/`List` 上直接套过滤、排序、关键字、分页并产出 `` `PageResultDtoBase` `` - **基于特性的校验**:`QueryFieldAttribute` / `KeywordSearchAttribute` / `QueryOperatorSupportAttribute` 标注实体属性,配合 `AttributePageExtensions` 做白名单式校验(`ValidateRequest` / `EnsureValid` / `TryValidate`) - **面向实体的执行引擎**:`` `PageQueryExecutor` ``(`Paging.Executors`)是 `AttributePageExtensions` / `AutoQueryExtensions` 背后的真正实现——按 `QueryFieldAttribute` 解析字段别名、按 `QueryOperatorSupportAttribute` 校验操作符、按 `KeywordSearchAttribute` 回填默认关键字字段,再执行过滤/关键字/排序/分页,全程带白名单校验(可关闭) ## 主要 API / 类型 ### 请求 / 结果 DTO | 类型 | 说明 | | --- | --- | | `PageRequestDtoBase` | 分页请求基类。属性 `Conditions` / `Behavior` / `Page`;链式方法 `WithFilter(field, value, op)` / `WithSort(field, dir)` / `WithKeyword(keyword, params fields)` / `WithPage(index, size)` / `WithoutPaging()`;静态 `Default()` | | `` `PageResultDtoBase` `` | 分页响应基类。属性 `Items` / `Page` / `ExtendDatas?`;静态 `Empty(...)` / `Create(...)`;实例 `` `Map(Func)` `` | ### 查询条件模型(Models) | 类型 | 说明 | | --- | --- | | `QueryConditions` | 条件容器:`Filters` / `Sorts` / `Keyword`;方法 `AddFilter` / `AddSort` / `SetKeyword` / `RemoveFilter` / `RemoveSort` / `Clear` / `IsValid` / `Clone` | | `QueryFilter` | 单条过滤:`Field`(空白抛 `ArgumentException`)/ `Value` / `Values`(`In`/`Between` 多值)/ `Operator`;静态工厂 `Equal/NotEqual/GreaterThan/GreaterThanOrEqual/LessThan/LessThanOrEqual/Contains/StartsWith/EndsWith/In/NotIn/Between/IsNull/IsNotNull`;`IsValid()` 按操作符校验值 | | `QuerySort` | 单条排序:`Field` / `Direction` / `Priority`(越小越优先,默认 0);静态 `Ascending/Descending`;`IsValid()` | | `QueryKeyword` | 关键字搜索:`Value?` / `Fields`(`List`)/ `MatchMode`;方法 `AddField` / `AddFields` / `CleanEmptyFields` / `ClearFields` / `Clone` | | `QueryBehavior` | 查询行为:`DisablePaging` / `DisableDefaultSort` / `IgnoreTenant` / `IgnoreSoftDelete` / `EnableSplitQuery` / `QueryTimeout?`(秒),均默认关闭 | | `PageRequestMetadata` | 分页入参:`PageIndex`(<1 钳为 1)/ `PageSize`(<`MinPageSize` 回退默认、>`MaxPageSize` 钳到上限);常量 `DefaultPageIndex=1` / `DefaultPageSize=20` / `MinPageSize=1` / `MaxPageSize=500` | | `PageResultMetadata` | 分页出参:`PageIndex` / `PageSize` / `TotalCount`;派生只读 `TotalPages` / `HasPrevious` / `HasNext` / `IsFirstPage` / `IsLastPage` / `StartRecord` / `EndRecord` / `CurrentPageCount` | ### 枚举(Enums) | 枚举 | 值(`= 数值`,带 `[Description]`) | | --- | --- | | `QueryOperator` | `Equal=1000` `NotEqual=1001` `GreaterThan=1002` `GreaterThanOrEqual=1003` `LessThan=1004` `LessThanOrEqual=1005` / `Contains=2000` `StartsWith=2001` `EndsWith=2002` / `In=3000` `NotIn=3001` / `Between=4000` / `IsNull=5000` `IsNotNull=5001` | | `SortDirection` | `Ascending=1000` `Descending=1001` | | `KeywordMatchMode` | `Contains=1000` `StartsWith=1001` `EndsWith=1002` `Exact=1003` | ### 构建器 / 约定(Builders / Conventions) | 类型 | 说明 | | --- | --- | | `QueryBuilder` | 手工链式构建器。静态 `Create()` / `FromRequest(request)`;`WhereEqual/WhereNotEqual/WhereGreaterThan/WhereLessThan/WhereContains/WhereIn/WhereBetween/WhereNull/WhereNotNull`、`OrderBy/OrderByDescending`、`SetKeyword/AddKeywordField/SetKeywordSearch`、`SetPaging`;`Build()` 产出 `PageRequestDtoBase` | | `AutoQueryBuilder` | 反射 DTO 属性 + 命名约定自动构建条件。静态 `BuildFrom(dto)` / `FromDto(dto)`;`Build()`。规则:字符串默认 `Contains`;`*Range` 且数组长度 2 → `Between`;集合/`*List`/`*Ids` → `In`;带 `Search`/`Key`/`Keyword` 命名的属性作关键字输入 | | `QueryConvention` | 自动推断规则配置。`RangeSuffixes=["Range"]` / `ListSuffixes=["List","Ids","s"]` / `KeywordPatterns=["Search","Key","Keyword"]` / `StringDefaultContains=true` / `StringDefaultKeywordSearch=false` 等;`Default` 单例;`InferOperators(type,name)` 按类型推断合法操作符集合 | ### 特性(Attributes,标注在实体属性上) | 特性 | 说明 | | --- | --- | | `QueryFieldAttribute` | 标记属性可过滤/排序。`Alias`(前端字段名→属性名)/ `AllowFilter=true` / `AllowSort=true` / `Priority=0` | | `KeywordSearchAttribute` | 标记属性参与关键字搜索。`Enabled=true` / `MatchMode=Contains` / `Priority=0` / `IncludeInDefault=true` / `Alias` | | `QueryOperatorSupportAttribute` | 白名单:`SupportedOperators`(构造入参 `params QueryOperator[]`);`IsSupported(op)` / `GetSupportedOperatorsDescription()`,用于拦截非法操作符 | ### 执行引擎 / 反射解析 / 校验器(Executors / Reflection / Validators) 这一组类型是 `AttributePageExtensions` 与 `AutoQueryExtensions`(当 `validate: true`)背后真正的执行者,属于文档此前未展开的一层: | 类型 | 命名空间 | 说明 | | --- | --- | --- | | `` `PageQueryExecutor` `` | `Paging.Executors` | 面向实体 `T` 的分页查询执行器。构造时通过 `AttributeReader.GetQueryFields()` 建立字段信息表;`Execute(query, request, validate=true)` / `ExecuteAsync(...)`:先按 `validate` 调 `AttributeBasedValidator.ValidatePageRequest`(失败抛 `InvalidOperationException`),再解析 `Filter`/`Sort` 的字段别名到真实属性名、按 `KeywordSearchAttribute` 回填未指定字段时的默认关键字搜索字段,最后执行过滤→关键字→排序→分页;另有 `GetQueryFields()` / `GetDefaultKeywordFields()` | | `AttributeReader` | `Paging.Reflection` | 静态反射工具:`GetQueryFields()` / `GetQueryFields(Type)` 读取每个公开属性上的 `QueryFieldAttribute` / `KeywordSearchAttribute` / `QueryOperatorSupportAttribute`,产出 `Dictionary`(属性名与 `Alias` 都作为键,大小写不敏感);`GetDefaultKeywordFields()` / `(Type)` 按 `KeywordSearchAttribute.Priority` 排序返回参与默认关键字搜索的属性;`IsFilterAllowed` / `IsSortAllowed` / `IsOperatorSupported`(无特性时按属性类型推断默认支持的操作符) | | `QueryFieldInfo` | `Paging.Reflection` | `AttributeReader` 的产出模型:`PropertyName` / `PropertyType` / `Alias` / `AllowFilter` / `AllowSort` / `Priority` / `KeywordSearchEnabled` / `KeywordMatchMode` / `KeywordPriority` / `IncludeInDefaultKeywordSearch` / `KeywordAlias` / `SupportedOperators` | | `PageValidator` | `Paging.Validators` | 不依赖特性的基础校验:`ValidatePageRequest` / `ValidatePageMetadata` / `ValidateFilter` / `ValidateSort`,均返回 `ValidationResult` | | `AttributeBasedValidator` | `Paging.Validators` | 基于实体特性的校验,是 `AttributePageExtensions.ValidateRequest/EnsureValid/TryValidate` 与 `PageQueryExecutor` 内部校验的实现:`ValidatePageRequest(request)` / `ValidatePageRequest(entityType, request)`(先套 `PageValidator` 基础校验,再逐条检查字段是否存在/允许过滤或排序/操作符是否受支持、关键字字段是否为字符串类型);`GetFieldValidationInfo(fieldName)` / `(entityType, fieldName)` 输出单个字段的可读校验信息(调试/错误提示用) | ### 扩展方法(Extensions) | 静态类 | 关键方法 | | --- | --- | | `PageExtensions` | `` `ApplyFilter` `` / `` `ApplyFilters` `` / `` `ApplySort` `` / `` `ApplySorts` `` / `` `ApplyKeywordSearch` `` / `` `ApplyPaging` `` / `` `ApplyPageRequest` `` / `` `ToPageResult` `` / `` `ToPageResultAsync` ``(`IQueryable`/`IEnumerable`/`List`) | | `AttributePageExtensions` | `` `ToPageResultWithValidation` `` / `` `ToPageResultWithValidationAsync` ``(内部委托给 `` `PageQueryExecutor` ``)/ `` `ValidateRequest` `` / `` `EnsureValid` ``(校验失败抛 `InvalidOperationException`)/ `` `TryValidate` ``(均委托给 `AttributeBasedValidator`) | | `AutoQueryExtensions` | `AutoBuild(this object dto)` / `` `ToPageResultAuto` `` / `` `ToPageResultAutoAsync` ``(`validate: true` 时同样走 `` `PageQueryExecutor` ``) | | `PageConverter` | `ToMetadata` / `ToDto` / `` `ConvertItems` `` / `ConvertItemsAsync` / `Clone` / `Merge`(第二个请求覆盖第一个)/ `CreateEmptyResult` | | `ValidationResult` | 校验结果:`IsValid` / `Errors`;静态 `Success()` / `Failure(params errors)`;`GetErrorMessage()` | ## 使用示例 ### 手工构造分页请求 ```csharp // 按状态过滤 + 关键字搜索 + 排序 + 取第 1 页 10 条 var request = new PageRequestDtoBase() .WithFilter("Status", 1, QueryOperator.Equal) .WithKeyword("张三", "Name", "Remark") .WithSort("CreationTime", SortDirection.Descending) .WithPage(1, 10); ``` ### 用 QueryBuilder 组装复杂条件 ```csharp var request = QueryBuilder.Create() .WhereEqual("TenantId", 1) .WhereContains("Name", "曦寒") .WhereBetween("Age", 18, 60) .OrderByDescending("CreatedTime") .SetPaging(pageIndex: 1, pageSize: 20) .Build(); ``` ### 在内存/IQueryable 上就地执行 ```csharp // source 可以是 IQueryable / IEnumerable / List PageResultDtoBase result = source.ToPageResult(request); Console.WriteLine($"{result.Page.PageIndex}/{result.Page.TotalPages},共 {result.Page.TotalCount} 条"); ``` ### 由普通查询 DTO 自动构建条件 ```csharp public class UserQueryDto { public string? Keyword { get; set; } // 命中关键字约定 → 作关键字输入 public string? Name { get; set; } // 字符串 → 默认 Contains public int? Status { get; set; } // 值类型 → Equal public List? RoleIds { get; set; } // *Ids/集合 → In("RoleId", ...) public DateTime[]? CreatedTimeRange { get; set; } // *Range 且长度2 → Between("CreatedTime", start, end) public PageRequestMetadata Page { get; set; } = new(); } var request = queryDto.AutoBuild(); // 反射 + 约定 → PageRequestDtoBase var page = query.ToPageResultAuto(queryDto); // 一步到位(默认带校验) ``` ### 基于实体特性做白名单校验 + 别名解析 ```csharp public class User { [QueryField(Priority = 1)] public long Id { get; set; } [QueryField("userName")] // 前端传 userName,映射到 Name 属性 [KeywordSearch] public string Name { get; set; } = string.Empty; [QueryOperatorSupport(QueryOperator.Equal, QueryOperator.In)] // 只允许 Equal / In public int Status { get; set; } } // 前端字段名用别名 userName,实体属性是 Name var request = new PageRequestDtoBase() .WithFilter("userName", "张", QueryOperator.Contains) .WithFilter("Status", 5, QueryOperator.GreaterThan); // 未在 QueryOperatorSupport 白名单中 // 校验失败会抛 InvalidOperationException(Status 不支持 GreaterThan) var page = query.ToPageResultWithValidation(request); ``` ## 扩展点 / 自定义 - **调整自动推断约定**:构造自定义 `QueryConvention`(改 `RangeSuffixes`/`ListSuffixes`/`KeywordPatterns`/`StringDefaultContains` 等)以适配你的 DTO 命名习惯;`AutoQueryBuilder` 默认使用 `QueryConvention.Default`。 - **字段白名单与别名**:在实体属性上加 `QueryFieldAttribute`(`AllowFilter`/`AllowSort`/`Alias`)与 `QueryOperatorSupportAttribute` 限制可用操作符,再用 `AttributePageExtensions` 的校验方法拦截越权/非法查询。 - **自定义关键字参与字段**:用 `KeywordSearchAttribute` 精细控制哪些属性进入关键字搜索、匹配模式与是否计入默认搜索集。 - **结果映射**:`` `PageResultDtoBase.Map` `` 或 `PageConverter.ConvertItems/ConvertItemsAsync` 把实体分页结果映射为 DTO 分页结果,保留分页元数据。 ## 注意事项与最佳实践 - **`Conditions` 与 `Behavior` 边界**:`Conditions` 永远可安全翻译为 `Expression`;`Behavior`(分页开关、忽略租户/软删除、拆分查询、超时)只作用于查询管道,别把它当过滤条件。二者混用会破坏这套协议的安全性假设。 - **`QueryFilter.Field` 不能为空**:赋空白字符串会立即抛 `ArgumentException`;`QuerySort.Field` 同理。 - **`IsValid()` 的语义**:`IsNull`/`IsNotNull` 不需要值;`In`/`NotIn` 需要 `Values` 非空;`Between` 需要 `Values` 恰好 2 个;其余操作符需要 `Value` 非空。无效的过滤/排序会在 `ApplyFilters`/`ApplySorts` 中被静默跳过。 - **`PageSize` 会被钳制**:入参 `PageSize` 超过 `MaxPageSize=500` 会被截断,小于 `MinPageSize=1` 会回退到默认 20;`PageIndex` 小于 1 会被钳为 1。 - **本包表达式实现的局限**:`PageExtensions.ApplyFilter` 只处理到 `Equal/NotEqual/比较/Contains/StartsWith/EndsWith/IsNull/IsNotNull`,`In/NotIn/Between` 在这里会走 `default` 分支被忽略——它面向内存/通用 `IQueryable`;真正的数据库翻译在 `XiHan.Framework.Data` 仓储层完成,务必以仓储实现为准。**`` `PageQueryExecutor` ``(`AttributePageExtensions`/`AutoQueryExtensions` 的底层执行器)的过滤实现是同一套逻辑,同样不处理 `In/NotIn/Between`**——它带来的增量只是字段别名解析与基于特性的白名单校验,不是更强的表达式翻译能力。 - **关键字搜索是 OR、过滤是 AND**:多个 `Filters` 之间是 AND,关键字对多个字段之间是 OR;排序按 `Priority` 升序稳定应用。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.Utils](./utils) 不引用任何持久化 / Web 相关包,保持共享层的轻量与稳定。 ## 相关模块 - [XiHan.Framework.Domain](./domain) — 领域层,直接依赖本包(仓储抽象大量复用 `PageRequestDtoBase` / `` `PageResultDtoBase` ``) - [XiHan.Framework.Application.Contracts](./application-contracts) — 应用契约层,复用本包的分页 DTO - [XiHan.Framework.Application](./application) — 应用层实现 - [XiHan.Framework.Data](./data) — 基础设施层,提供分页查询协议在数据库层的真实翻译 --- 来源:https://framework.docs.xihanfun.com/packages/domain # XiHan.Framework.Domain > DDD 领域层:实体/聚合根基类、审计与多租户变体、持久化无关的仓储抽象、领域服务、领域事件、规约、业务规则、值对象与领域异常。 - **NuGet**:`XiHan.Framework.Domain` - **模块类**:`XiHanDomainModule`(`[DependsOn(typeof(XiHanDomainSharedModule))]`,`ConfigureServices` 为空) - **所在层**:领域与应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(唯一 `ProjectReference` 是 `XiHan.Framework.Domain.Shared`) ## 概述 这个包提供领域驱动设计(DDD)的一整套战术构件:实体、聚合根、值对象、领域服务、领域事件、规约、业务规则与领域异常。它 **只定义抽象与基类**,不涉及任何具体数据库实现——仓储接口的落地由基础设施层(`XiHan.Framework.Data`,基于 SqlSugar)完成。 你在建模业务领域时继承这里的基类,就能获得主键、并发版本、创建/修改/删除审计、多租户、领域事件收集等通用能力;用 `` `IRepositoryBase` `` 之类的抽象接口把业务与持久化解耦;把复杂查询封装为规约(`` `ISpecification` ``),把业务约束显式化为业务规则(`IBusinessRule`)。 `XiHanDomainModule.ConfigureServices` 是空实现——本包不注册服务、不接中间件。领域服务的自动注册来自 `IDomainService : ITransientDependency` 这个标记接口(由 Core 的约定注册扫描接管),仓储的具体实现与注册在 `Data` 包里完成。 ## 何时使用 - 用 DDD 方式建模业务:需要聚合根、实体、值对象的标准基类 - 需要 **持久化无关** 的仓储抽象(`` `IRepositoryBase` `` 及其只读/软删除/审计/聚合根变体)来解耦业务与数据库 - 想把复杂查询或业务约束封装为规约(`Specification`)或业务规则(`IBusinessRule`) - 需要在聚合根内收集本地/分布式领域事件,交由发件箱/事件总线后续派发 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Domain ``` ```csharp [DependsOn(typeof(XiHanDomainModule))] public class MyModule : XiHanModule { } ``` 启用后你得到的是一批 **可继承的基类与可实现的契约**(外加领域服务标记接口带来的自动注册约定)。真正把仓储接口绑定到实现,需要引用并启用 `XiHan.Framework.Data`。 ## 工作原理 ### 实体与审计接口的继承体系 审计能力被切成正交的小接口,按需组合: ```text IEntityBase —— RowVersion(并发版本,long) IEntityBase —— BasicId(主键)+ IsTransient() ICreationEntity / —— CreatedTime (+ CreatedId / CreatedBy) IModificationEntity / —— ModifiedTime (+ ModifiedId / ModifiedBy) ISoftDelete → IDeletionEntity / —— IsDeleted / DeletedTime (+ DeletedId / DeletedBy) IFullAuditedEntity / —— 创建 + 修改 + 删除 全审计(组合以上三者) IMultiTenantEntity —— TenantId(long,非空,0=平台租户,业务租户从 1 起) ITraceableEntity —— TraceId(W3C/HttpContext 链路追踪串联) ISplitTableEntity : ICreationEntity —— 分表标记(框架按 CreatedTime 时间范围分表) ``` 对应的具体基类(可直接继承):`EntityBase` / `` `EntityBase` ``、`CreationEntityBase`、`ModificationEntityBase`、`DeletionEntityBase`、`FullAuditedEntityBase` / `` `FullAuditedEntityBase` `` 及它们的 `MultiTenant*` 变体。审计字段的自动填充由基础设施/拦截层负责,本包只定义结构。 配套的 `ITraceIdProvider`(`` string? GetCurrentTraceId() ``)为不依赖 ASP.NET Core 的层(领域服务、后台任务等)提供获取当前链路追踪 ID 的抽象,与 `ITraceableEntity` 搭配使用:中间件/拦截器可借此把 `TraceId` 自动写入实现了 `ITraceableEntity` 的实体。 ### 主键相等性与临时实体 `` `EntityBase` `` 用 `BasicId` 做相等性判定并重载了 `== / !=`:类型不同、或任一方 `BasicId` 为默认值(临时实体)都判为不相等;`IsTransient()` 判断实体是否尚未持久化;`GetHashCode()` 对临时实体退回 `base.GetHashCode()`,对持久化实体用 `HashCode.Combine(GetType(), BasicId)`。 ### 聚合根与领域事件 `AggregateRootBase` / `` `AggregateRootBase` `` 在全审计实体基础上内嵌一个 `DomainEventsManagerBase`,区分 **本地事件** 与 **分布式事件** 两条队列(线程安全 `ConcurrentQueue`)。聚合内用受保护的 `AddLocalEvent` / `AddDistributedEvent` 收集事件,外部通过 `GetLocalEvents` / `GetDistributedEvents` / `ClearLocalEvents` / `ClearDistributedEvents` 读取与清空。事件按全局递增的 `EventOrder`(`EventOrderGenerator`)排序,`DomainEventRecord` 封装 `EventData + EventOrder`。 ## 核心能力 - **实体基类体系**:`EntityBase`(`RowVersion`)/ `` `EntityBase` ``(`BasicId`、基于主键的相等性、`IsTransient()`),派生出创建/修改/删除/全审计及其多租户变体 - **聚合根**:`IAggregateRoot`(= 全审计 + 领域事件管理器)/ `AggregateRootBase` / `` `AggregateRootBase` `` / `` `MultiTenantAggregateRootBase` `` - **仓储抽象**:`IReadOnlyRepositoryBase` 提供查询/分页/规约;`IRepositoryBase` 增补增删改;另有 `ISoftDeleteRepositoryBase`(软删除/恢复/查已删)、`IAuditedRepository`(按创建/修改/删除人与时间段查)、`IAggregateRootRepository`(聚合根一致性 + 事件) - **领域服务**:`DomainService` 基类,内置业务规则检查、结构化日志与性能监控辅助方法 - **领域事件**:`IDomainEvent` / `DomainEventBase`(`EventId` + `OccurredOn`)、`IDomainEventsManager` / `DomainEventsManagerBase`、`AggregateRootExtensions`(聚合事件统计/快照/批处理) - **审计事件**:`AuditEvent` 抽象基类(`Entity`/`EntityType`/`EntityId`/`Timestamp`)及其派生的 `EntityCreatedEvent` / `EntityModifiedEvent`(另含 `OriginalEntity`)/ `EntityDeletedEvent`,用于描述实体生命周期中的关键操作;它们不实现 `IDomainEvent`,是独立于领域事件总线的审计专用事件类型 - **规约模式**:`` `ISpecification` `` / `` `Specification` ``(`And`/`Or`/`Not` + 运算符 `& | !` + 隐式转表达式)/ `` `AsyncSpecification` ``(异步判定 + 短路组合) - **业务规则**:`IBusinessRule`(`Message` + `IsBroken()`)、`BusinessRuleExtensions`(`CheckRule`/`CheckRules`/`Validate`)、`EntityExtensions`(实体上直接 `CheckRule`) - **值对象**:`ValueObject`(按 `GetEqualityComponents()` 判等)与 `` `SingleValueObject` ``(单值 + 隐式转基础类型) - **领域异常**:`DomainException`(`Code` / `Details`)与 `BusinessRuleValidationException`(携带被违反的 `BrokenRule`) - **审计特性**:`AuditableAttribute` / `NoAuditAttribute` / `IgnoreAuditAttribute` 控制实体是否参与各类审计与事件 ## 主要 API / 类型 ### 实体与聚合根 | 类型 | 说明 | | --- | --- | | `EntityBase` | 非泛型实体基类,含并发版本 `RowVersion`(`long`,构造置 0) | | `` `EntityBase` `` | 泛型主键实体:`BasicId`(`protected set`)、`== / != / Equals / GetHashCode`(基于主键)、`IsTransient()`;约束 `` where TKey : IEquatable `` | | `FullAuditedEntityBase` / `` | 全审计基类:`CreatedTime` / `ModifiedTime?` / `IsDeleted` / `DeletedTime?`;泛型版另含 `CreatedId/By`、`ModifiedId/By`、`DeletedId/By` | | `AggregateRootBase` / `` | 聚合根基类,= 全审计实体 + 内嵌领域事件管理器;受保护 `AddLocalEvent/AddDistributedEvent`、公开 `GetLocalEvents/GetDistributedEvents/Clear*` | | `` `MultiTenantAggregateRootBase` `` | 多租户聚合根,实现 `IMultiTenantEntity`(`TenantId`,`long`,0=平台租户) | ### 仓储抽象(Repositories) | 接口 | 关键方法签名(节选) | | --- | --- | | `` `IReadOnlyRepositoryBase` `` | `` Task GetByIdAsync(TKey id, …) `` / `` Task> GetByIdsAsync(IEnumerable, …) `` / `` Task GetFirstAsync(Expression>, …) `` 及规约重载 / `GetAllAsync` / `GetListAsync`(表达式 / 表达式+排序 / 规约 / `PageRequestDtoBase` 条件)/ `CountAsync` / `AnyAsync` / 一组 `GetPagedAsync(...)`(页码页大小 / +谓词 / +排序 / +规约 / `PageRequestDtoBase`)/ `GetPagedAutoAsync(object queryDto, …)`(自动构建条件) | | `` `IRepositoryBase` `` | 继承只读接口,增补:`` Task AddAsync(TEntity, …) `` / `` Task AddReturnIdAsync(…) `` / `AddRangeAsync` / `` Task UpdateAsync(TEntity, …) `` / `` Task UpdateAsync(Expression> columns, Expression> where, …) `` / `UpdateRangeAsync` / `AddOrUpdateAsync(Range)` / `DeleteAsync(entity)` / `DeleteByIdAsync(id)` / `DeleteRangeAsync(entities/ids)` / `DeleteAsync(predicate)` | | `` `ISoftDeleteRepositoryBase` `` | 约束实体实现 `ISoftDelete`;`SoftDeleteAsync`/`SoftDeleteRangeAsync`(实体/主键/谓词/规约)/ `RestoreAsync`/`RestoreRangeAsync` / `GetAllWithDeletedAsync` / `GetDeletedAsync`(无参/谓词/规约) | | `` `IAuditedRepository` `` | 约束实体 `` IFullAuditedEntity ``;`GetByCreatorAsync/GetByModifierAsync/GetByDeleterAsync` / `GetCreatedBetweenAsync/GetModifiedBetweenAsync/GetDeletedBetweenAsync` / `` GetByAuditAsync(AuditQueryOptions, …) `` | | `` `IAggregateRootRepository` `` | 约束 `` IAggregateRoot, new() ``;`new AddAsync/UpdateAsync/DeleteAsync`(触发聚合根领域事件)/ `SaveAggregateAsync` / `GetAggregateAsync` / `GetWithEventsAsync` | | `` `AuditQueryOptions` `` | 审计查询参数:`CreatedId/ModifiedId/DeletedId`、`Created/Modified/DeletedTimeStart/End`、`IncludeSoftDeleted`、`OnlySoftDeleted`(`init` 只读) | > 仓储方法约定:查询集合统一返回 `` `IReadOnlyList` ``;不存在的单实体返回 `null`;`Count` 返回 `long`;所有异步方法带 `CancellationToken`。分页统一产出 `` `PageResultDtoBase` ``(来自 Domain.Shared)。 ### 领域服务 / 规约 / 事件 / 规则 | 类型 | 说明 | | --- | --- | | `IDomainService` | 领域服务标记接口,继承 `ITransientDependency`(自动瞬态注册) | | `DomainService` | 领域服务基类。`CheckBusinessRule(s)` / `CheckBusinessRule(s)Async`、`LogDomainOperation(Completed)`、`ExecuteWithPerformanceMonitoring[Async]`;日志经 `ITransientCachedServiceProvider` 惰性解析 | | `` `ISpecification` `` | 规约契约:`ToExpression()` / `IsSatisfiedBy(entity)` / `And` / `Or` / `Not` | | `` `Specification` `` | 规约抽象基类:实现 `And/Or/Not`(内部 `AndSpecification/OrSpecification/NotSpecification`,用 `ParameterReplacer` 合并参数),重载 `& \| !`,隐式转 `` Expression> `` | | `` `IAsyncSpecification` `` / `` `AsyncSpecification` `` | 异步规约:`IsSatisfiedByAsync` / `AndAsync/OrAsync/NotAsync`(短路求值) | | `IDomainEvent` / `DomainEventBase` | 领域事件契约与基类:`Guid EventId` + `DateTimeOffset OccurredOn`(基类构造自动填充) | | `IDomainEventsManager` / `DomainEventsManagerBase` | 事件管理器:本地/分布式双队列,`Add*/Get*/Clear*/HasPendingEvents/MarkEventsAsCommitted` | | `DomainEventRecord` | 事件记录包装:`EventData`(`IDomainEvent`)+ `EventOrder`(`long`) | | `AuditEvent` / `EntityCreatedEvent` / `EntityModifiedEvent` / `EntityDeletedEvent` | 审计事件基类与三个派生类:`Entity`(`object`)/ `EntityType` / `EntityId` / `Timestamp`(UTC);`EntityModifiedEvent` 额外携带可选的 `OriginalEntity`。用于审计管道记录创建/修改/删除,独立于 `IDomainEvent` 体系 | | `IBusinessRule` | 业务规则契约:`string Message` + `bool IsBroken()` | | `BusinessRuleExtensions` | `CheckRule`/`CheckRules`(违反抛 `BusinessRuleValidationException`)/ `CheckRuleAsync`/`CheckRulesAsync` / `Validate`/`ValidateAll`(返回 `BusinessRuleValidationResult`) | | `EntityExtensions` | 实体上直接校验:`CheckRule`/`CheckRules`/`TryCheckRule`/`ValidateRules` | | `AggregateRootExtensions` | 聚合事件辅助:`GetAllEvents`/`ClearAllEvents`/`` `GetEventsOfType` ``/`` `HasEventOfType` ``/`GetEventStatistics`/`ProcessAllEventsAsync`/`CreateSnapshot` | ### 值对象与异常 | 类型 | 说明 | | --- | --- | | `ValueObject` | 值对象基类,按 `GetEqualityComponents()`(子类实现)判等;重载 `== / != / Equals / GetHashCode / ToString`;延迟求值 + 早退优化 | | `` `SingleValueObject` `` | 单值值对象(`` where T : notnull ``):`Value` 只读、隐式转 `T`、`ToString()` 转发到值 | | `DomainException` | 领域异常基类:`Code?` / `Details?`;静态 `Create(message, code?, details?)` | | `BusinessRuleValidationException` | 业务规则验证异常,继承 `DomainException`;携带被违反的 `IBusinessRule? BrokenRule` | ## 使用示例 ### 定义聚合根并收集领域事件 ```csharp public class Order : AggregateRootBase { public string Status { get; private set; } = "Created"; public void Pay() { // 业务约束显式化 this.CheckRule(new OrderMustBeUnpaidRule(Status)); Status = "Paid"; AddLocalEvent(new OrderPaidEvent(BasicId)); // 收集,交由后续发件箱/事件总线派发 } } public sealed class OrderPaidEvent(long orderId) : DomainEventBase { public long OrderId { get; } = orderId; } ``` ### 业务规则 ```csharp public sealed class OrderMustBeUnpaidRule(string status) : IBusinessRule { public string Message => "订单已支付,不能重复支付"; public bool IsBroken() => status == "Paid"; } ``` ### 规约组合并交给仓储 ```csharp public sealed class ActiveUserSpec : Specification { public override Expression> ToExpression() => u => !u.IsDeleted; } // 组合:活跃 且 属于租户 1 var spec = new ActiveUserSpec().And(new TenantSpec(1)); var users = await userRepository.GetListAsync(spec, cancellationToken); var page = await userRepository.GetPagedAsync(1, 20, spec, cancellationToken); ``` ### 领域服务中的性能监控与规则校验 ```csharp public class TransferService : DomainService { public Task TransferAsync(Account from, Account to, decimal amount, CancellationToken ct) => ExecuteWithPerformanceMonitoringAsync("Transfer", async token => { await CheckBusinessRuleAsync(new SufficientBalanceRule(from, amount), cancellationToken: token); from.Withdraw(amount); to.Deposit(amount); }, new { amount }, ct); } ``` ## 扩展点 / 自定义 - **选择合适的实体基类**:只需并发控制用 `` `EntityBase` ``;需要全审计用 `` `FullAuditedEntityBase` ``;需要租户隔离用 `MultiTenant*` 变体;聚合根用 `` `AggregateRootBase` `` / `` `MultiTenantAggregateRootBase` ``。 - **实现自定义仓储**:面向业务定义继承 `IRepositoryBase`/`ISoftDeleteRepositoryBase`/`IAggregateRootRepository` 的专用接口,具体实现与 DI 绑定由 `XiHan.Framework.Data` 提供(本包不含实现)。 - **自定义规约**:继承 `` `Specification` `` 实现 `ToExpression()` 即可获得 `And/Or/Not` 组合与运算符重载;需要异步判定时继承 `` `AsyncSpecification` `` 并重写 `IsSatisfiedByAsync`。 - **领域服务自动注册**:让服务实现 `IDomainService`(间接是 `ITransientDependency`)即被约定扫描注册为瞬态;`DomainService` 基类提供 `ITransientCachedServiceProvider` 惰性解析日志等依赖。 - **审计开关**:用 `AuditableAttribute`(`EnableCreation/Modification/DeletionAudit`、`EmitEvents`)/ `NoAuditAttribute` / `IgnoreAuditAttribute` 精细控制实体的审计行为与属性级忽略。 ## 注意事项与最佳实践 - **临时实体不相等**:`BasicId` 为默认值时 `Equals` 恒返回 `false`,两个未持久化的实体永远不相等——不要依赖内存中未落库实体的相等性做去重。 - **`BasicId` 是 `protected set`**:主键只能在实体内部/构造时赋值,外部不可随意改写,避免破坏相等性契约。 - **领域事件不会自动派发**:`AddLocalEvent`/`AddDistributedEvent` 只是把事件排入聚合内的队列;真正的发布(发件箱/事件总线)由基础设施在保存聚合时读取并 `MarkEventsAsCommitted`。本包只负责收集与排序。 - **`CheckRuleAsync` 是包装同步**:`BusinessRuleExtensions.CheckRuleAsync` 通过 `Task.Run` 包装同步 `CheckRule`,并非真正的异步 I/O——规则里别做重 I/O,重逻辑请在领域服务里处理。 - **多租户 `TenantId` 非空约定**:`TenantId` 为 `long` 非空,`0` 表示平台/全局租户、业务租户从 `1` 起;这样 `UNIQUE(TenantId, XxCode)` 复合唯一索引对全局记录也成立(NULL 在 MySQL/PG 唯一约束中不视为相等)。 - **规约到 SQL 的可翻译性**:`` `Specification.ToExpression()` `` 会被仓储下推到数据库,务必写成 provider 可翻译的表达式(避免闭包外的 CLR 方法调用);`IsSatisfiedBy` 走 `Compile()` 在内存执行,二者场景不同别混用。 - **仓储实现来自 Data 包**:本包只有接口,直接依赖它无法工作——运行期必须引用 `XiHan.Framework.Data` 才能拿到 SqlSugar 仓储实现。 ## 依赖模块 - [XiHan.Framework.Domain.Shared](./domain-shared) — 唯一直接依赖,复用其分页查询契约(`PageRequestDtoBase` / `` `PageResultDtoBase` ``) ## 相关模块 - [XiHan.Framework.Domain.Shared](./domain-shared) — 领域共享的分页/查询模型 - [XiHan.Framework.Data](./data) — 基础设施层,提供本包仓储抽象的 SqlSugar 具体实现与注册 - [XiHan.Framework.Application](./application) — 应用层,编排领域对象并暴露为 API - [XiHan.Framework.MultiTenancy](./multitenancy) — 多租户上下文,与 `IMultiTenantEntity` 协同做租户过滤 --- 来源:https://framework.docs.xihanfun.com/packages/eventbus-abstractions # XiHan.Framework.EventBus.Abstractions > 事件总线抽象包:只定义发布/订阅契约、本地与分布式处理器接口、可靠投递(Outbox/Inbox)契约,不含任何实现。 - **NuGet**:`XiHan.Framework.EventBus.Abstractions` - **模块类**:`XiHanEventBusAbstractionsModule`(`[DependsOn(typeof(XiHanObjectMappingModule))]`) - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Core / ObjectMapping / MultiTenancy.Abstractions / Timing) ## 概述 这个包只定义事件总线的接口契约,不含具体实现。它规定了"事件怎么发布、处理器怎么订阅、处理器长什么样",把本地事件(进程内解耦)和分布式事件(跨进程/跨服务)的契约都摆出来;对分布式事件,还定义了 Outbox(发件箱)/ Inbox(收件箱)这套可靠投递契约。真正的实现由 [XiHan.Framework.EventBus](./eventbus) 提供。业务代码与实现层都面向本包接口编程,从而彼此解耦。 ## 何时使用 - 你要在领域层/应用层声明事件与事件处理器,但不想耦合具体的事件总线实现 - 你要区分本地事件(同进程解耦)与分布式事件(跨服务通信)两种订阅契约 - 你要为分布式事件定义可靠投递的发件箱/收件箱(Outbox/Inbox)契约 - 一般不单独引用本包,而是引用实现包 `XiHan.Framework.EventBus`(它已依赖本包) ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus.Abstractions ``` ```csharp [DependsOn(typeof(XiHanEventBusAbstractionsModule))] public class MyModule : XiHanModule { } ``` `XiHanEventBusAbstractionsModule.ConfigureServices` 本身不注册任何服务(只取了 `IConfiguration`),它只承担"引入抽象契约与依赖模块(ObjectMapping)"的角色;真正的服务注册在实现包完成。 ## 核心能力 - **统一事件总线契约** `IEventBus`:定义发布(`PublishAsync`)、订阅(多个 `Subscribe` 重载)、注销(多个 `Unsubscribe` / `UnsubscribeAll` 重载) - **本地事件契约**:`ILocalEventBus`(进程内事件总线,继承 `IEventBus`)+ `ILocalEventHandler`(本地事件处理器) - **分布式事件契约**:`IDistributedEventBus`(继承 `IEventBus`,发布额外带 `useOutbox` 参数)+ `IDistributedEventHandler` - **处理器基础接口** `IEventHandler`:所有处理器的间接基接口,实际需实现 `ILocalEventHandler<>` 或 `IDistributedEventHandler<>`,不要直接实现它 - **可靠投递契约**:`IEventOutbox`(发件箱)、`IEventInbox`(收件箱),以及出/入站事件信息 `IOutgoingEventInfo` / `IIncomingEventInfo`(及其实现 `OutgoingEventInfo` / `IncomingEventInfo`) - **事件盒配置**:`OutboxConfig` / `InboxConfig` 及其字典 `OutboxConfigDictionary` / `InboxConfigDictionary`,`ISupportsEventBoxes` 声明对象支持事件盒机制 - **处理器工厂与调用**:`IEventHandlerFactory`、`IEventHandlerInvoker`、`EventTypeWithEventHandlerFactories` 等抽象,供实现层构建处理管道 - **事件命名与租户**:`IEventNameProvider`(事件名提供器)、`IEventDataMayHaveTenantId`(事件可能携带租户) - **泛型参数可继承**:`IEventDataWithInheritableGenericArgument`,让 `EventData` 这类泛型事件在 `TEntity` 有基类时,连带触发 `EventData` - **分布式事件遥测钩子**:`DistributedEventSent` / `DistributedEventReceived`(配合 `DistributedEventSource` 来源枚举),实现包在直接发送/直接接收/走 Outbox/走 Inbox 时会把这两类"元事件"发布到本地事件总线,供观测代码订阅 - **顺序控制**:`LocalEventHandlerOrderAttribute` 标注本地处理器执行顺序 ## 主要 API / 类型 ### 根契约 `IEventBus` | 方法 | 说明 | | --- | --- | | `Task PublishAsync(TEvent eventData, bool onUnitOfWorkComplete = true) where TEvent : class` | 触发事件;`onUnitOfWorkComplete = true` 时在当前工作单元完成后再发布(若存在 UoW) | | `Task PublishAsync(Type eventType, object eventData, bool onUnitOfWorkComplete = true)` | 非泛型发布 | | `IDisposable Subscribe(Func action)` | 用委托订阅 | | `IDisposable Subscribe() where THandler : IEventHandler, new()` | 用处理器类型订阅(每次新建实例) | | `IDisposable Subscribe(Type eventType, IEventHandler handler)` | 用同一处理器实例订阅 | | `IDisposable Subscribe(IEventHandlerFactory factory)` / `Subscribe(Type, IEventHandlerFactory)` | 用工厂订阅(管理处理器生命周期) | | `void Unsubscribe(Func)` / `Unsubscribe(ILocalEventHandler)` / `Unsubscribe(Type, IEventHandler)` / `Unsubscribe(IEventHandlerFactory)` / `Unsubscribe(Type, IEventHandlerFactory)` | 注销指定订阅 | | `void UnsubscribeAll()` / `UnsubscribeAll(Type)` | 注销某事件类型的所有处理器 | 订阅方法都返回 `IDisposable`,`Dispose()` 即取消该次订阅。 ### 本地 `ILocalEventBus : IEventBus` | 方法 | 说明 | | --- | --- | | `IDisposable Subscribe(ILocalEventHandler handler)` | 用给定的本地处理器实例订阅(所有事件复用同一实例) | | `List GetEventHandlerFactories(Type eventType)` | 取某事件类型对应的处理器工厂列表 | ### 本地处理器 `ILocalEventHandler : IEventHandler` ```csharp Task HandleEventAsync(TEvent eventData); ``` ### 分布式 `IDistributedEventBus : IEventBus` | 方法 | 说明 | | --- | --- | | `IDisposable Subscribe(IDistributedEventHandler handler)` | 用给定的分布式处理器实例订阅 | | `Task PublishAsync(TEvent eventData, bool onUnitOfWorkComplete = true, bool useOutbox = true)` | 发布分布式事件;`useOutbox = true` 时先落发件箱再由后台可靠投递 | | `Task PublishAsync(Type eventType, object eventData, bool onUnitOfWorkComplete = true, bool useOutbox = true)` | 非泛型分布式发布 | 分布式处理器 `IDistributedEventHandler : IEventHandler` 同样定义 `Task HandleEventAsync(TEvent eventData)`。 ### 处理器基础与工厂 | 类型 | 说明 | | --- | --- | | `IEventHandler` | 所有处理器的间接基接口(空接口,勿直接实现) | | `IEventHandlerFactory` | 处理器工厂:`IEventHandlerDisposeWrapper GetHandler()` + `bool IsInFactories(List)` | | `IEventHandlerDisposeWrapper` | 处理器释放包装(继承 `IDisposable`):`IEventHandler EventHandler { get; }`,`Dispose()` 负责释放该次 `GetHandler()` 取得的处理器实例 | | `IEventHandlerInvoker` | 处理器调用器:`Task InvokeAsync(IEventHandler eventHandler, object eventData, Type eventType)` | | `EventTypeWithEventHandlerFactories` | 事件类型与其处理器工厂列表的配对(`EventType` + `EventHandlerFactories`) | | `LocalEventHandlerOrderAttribute` | `[AttributeUsage(Class)]`,构造入参 `int order`,暴露 `Order` 属性;标注本地处理器执行顺序(小的先执行,默认 0) | ### 可靠投递(Outbox / Inbox)契约 | 类型 | 关键方法 | | --- | --- | | `IEventOutbox` | `Task EnqueueAsync(OutgoingEventInfo)`、`Task> GetWaitingEventsAsync(int maxCount, Expression>? filter = null, CancellationToken)`、`Task DeleteAsync(Guid)`、`Task DeleteManyAsync(IEnumerable)` | | `IEventInbox` | `Task EnqueueAsync(IncomingEventInfo)`、`Task> GetWaitingEventsAsync(...)`、`Task MarkAsProcessedAsync(Guid)`、`Task RetryLaterAsync(Guid id, int retryCount, DateTime? nextRetryTime)`、`Task MarkAsDiscardAsync(Guid)`、`Task ExistsByMessageIdAsync(string messageId)`、`Task DeleteOldEventsAsync()` | | `IOutgoingEventInfo` | 出站事件信息:`Guid Id`、`string EventName`、`byte[] EventData`、`DateTime CreatedTime`(继承 `IHasExtraProperties`) | | `IIncomingEventInfo` | 入站事件信息:在出站字段基础上多一个 `string MessageId`(用于去重) | | `OutgoingEventInfo` / `IncomingEventInfo` | 上述接口的具体实现类;构造函数校验事件名非空且不超过 `MaxEventNameLength`(静态属性,默认 256);均提供 `SetCorrelationId(string)` / `GetCorrelationId()`,内部借助 `ExtraProperties` 与 `EventBusConsts.CorrelationIdHeaderName` 读写关联标识 | | `ISupportsEventBoxes` | 声明对象(通常是分布式事件总线)支持事件盒:`PublishFromOutboxAsync` / `PublishManyFromOutboxAsync` / `ProcessFromInboxAsync` | ### 事件盒配置 | 类型 | 说明 | | --- | --- | | `OutboxConfig` | 发件箱配置:`Name`(构造入参,非空校验)、`DatabaseName`、`ImplementationType`(实现类型)、`Func? Selector`(事件筛选)、`bool IsSendingEnabled = true` | | `InboxConfig` | 收件箱配置:`Name`、`DatabaseName`、`ImplementationType`、`Func? EventSelector`、`Func? HandlerSelector`、`bool IsProcessingEnabled = true` | | `OutboxConfigDictionary` / `InboxConfigDictionary` | 以名称为键的配置字典(`Dictionary`),提供 `Configure(Action)`(默认名 `"Default"`)与 `Configure(string name, Action)` 便捷方法 | ### 分布式事件遥测(发送 / 接收)钩子 | 类型 | 说明 | | --- | --- | | `DistributedEventSent` | 分布式事件发送信息:`DistributedEventSource Source`、`string EventName`、`object EventData` | | `DistributedEventReceived` | 分布式事件接收信息:字段与 `DistributedEventSent` 相同 | | `DistributedEventSource` | 事件来源枚举:`Direct`(直接发送/接收)、`Inbox`(经收件箱)、`Outbox`(经发件箱) | 这三个类型本身只是数据载体,不含收发逻辑:`XiHan.Framework.EventBus` 实现包的 `DistributedEventBusBase` 在每次直接发送/直接接收、以及经由 Outbox/Inbox 处理事件时,会分别构造 `DistributedEventSent` / `DistributedEventReceived` 并调用 `TriggerDistributedEventSentAsync` / `TriggerDistributedEventReceivedAsync`——两者内部都是把这个"元事件"以 `onUnitOfWorkComplete: false` 发布到**本地事件总线**(并吞掉异常,不影响主流程)。因此只要在业务代码里实现 `ILocalEventHandler` / `ILocalEventHandler`,即可零侵入地观测分布式事件的收发轨迹(写日志、打点、追踪来源是 Outbox 还是直接发送等),无需修改具体的分布式事件处理器。 ### 泛型可继承事件数据 | 类型 | 说明 | | --- | --- | | `IEventDataWithInheritableGenericArgument` | `object[] GetConstructorArgs()`;用于形如 `EventData` 的单泛型参数事件类。若该泛型事件类实现此接口,当发布 `EventData`(`Student` 继承自 `Person`)时,实现包会额外用 `GetConstructorArgs()` 返回的构造参数构造并发布一份 `EventData`,从而让订阅基类事件的处理器也能收到 | ### 其它辅助契约 | 类型 | 说明 | | --- | --- | | `IEventNameProvider` | `string GetName(Type eventType)`;实现包的 `EventNameAttribute` 实现此接口,用于给分布式事件命名 | | `IEventDataMayHaveTenantId` | `bool IsMultiTenant(out long? tenantId)`;事件数据可选携带租户上下文 | | `EventBusConsts` | 常量:`CorrelationIdHeaderName = "X-Correlation-Id"`(关联标识请求头名) | ## 使用示例 定义事件与本地处理器(只面向抽象编程,具体实现由 EventBus 包提供): ```csharp // 事件(普通引用类型即可) public class OrderCreatedEvent { public long OrderId { get; set; } } // 本地事件处理器(框架注册管线会自动登记,无需手动订阅) public class OrderCreatedHandler : ILocalEventHandler { public Task HandleEventAsync(OrderCreatedEvent eventData) { // 处理逻辑 return Task.CompletedTask; } } ``` 控制多个处理器的执行顺序: ```csharp [LocalEventHandlerOrder(10)] public class AuditHandler : ILocalEventHandler { /* ... */ } [LocalEventHandlerOrder(20)] public class NotifyHandler : ILocalEventHandler { /* ... */ } // Order 越小越先执行;未标注默认 0 ``` ## 注意事项与最佳实践 - **不要直接实现 `IEventHandler`**:它只是间接基接口,实现 `ILocalEventHandler` 或 `IDistributedEventHandler`。 - **发布默认跟随工作单元**:`PublishAsync` 的 `onUnitOfWorkComplete` 默认 `true`,事件会在当前 UoW 提交后才真正发布,避免"事务回滚了但事件已发出"。无 UoW 时立即发布。 - **分布式发布默认走 Outbox**:`IDistributedEventBus.PublishAsync` 的 `useOutbox` 默认 `true`,先落发件箱、后台再投递。 - 本包只是契约,"处理器怎样被登记与订阅"由实现包决定,见 [XiHan.Framework.EventBus](./eventbus) 的注意事项。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.ObjectMapping](./objectmapping)(模块 `DependsOn` 依赖;`IHasExtraProperties` 来自此包) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions)(事件可携带租户上下文) - [XiHan.Framework.Timing](./timing) ## 相关模块 - [XiHan.Framework.EventBus](./eventbus)(本抽象的实现) - [XiHan.Framework.Messaging](./messaging) - [XiHan.Framework.Uow](./uow) --- 来源:https://framework.docs.xihanfun.com/packages/eventbus-kafka # XiHan.Framework.EventBus.Kafka > 分布式事件总线的 Kafka 提供程序:所有事件写入同一主题,以事件名作为消息 Key,消费者组竞争消费、手动提交偏移。 - **NuGet**:`XiHan.Framework.EventBus.Kafka` - **模块类**:`XiHanKafkaEventBusModule` - **所在层**:基础设施层 - **关键依赖**:`Confluent.Kafka` 2.15.0 + [XiHan.Framework.EventBus](./eventbus) ## 概述 [XiHan.Framework.EventBus](./eventbus) 默认的 `IDistributedEventBus` 实现只在进程内分发。这个包提供基于 Kafka 的跨进程实现 `KafkaDistributedEventBus`:**所有事件写入同一个主题**,事件名作为消息 Key、序列化后的事件数据作为 Value、`messageId` / `correlationId` 放进消息 Header;消费端以**同一消费者组**竞争消费,保证一条分布式事件在集群中只被处理一次。 装上并 `[DependsOn]` 之后业务代码无需改动:照旧注入 `IDistributedEventBus` 发布、照旧实现 `IDistributedEventHandler<>` 消费。 ## 何时使用 - 已有 Kafka 基础设施,希望事件与其它数据流统一在 Kafka 上治理 - 需要高吞吐、可回溯(按偏移重放)的事件管道 - 需要事件在集群中只被处理一次(消费者组竞争消费) 对吞吐要求不高、只想要一个轻量 MQ 时,[RabbitMQ](./eventbus-rabbitmq) 或 [Redis](./eventbus-redis) 提供程序部署成本更低。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus.Kafka ``` ```csharp [DependsOn(typeof(XiHanKafkaEventBusModule))] public class MyModule : XiHanModule { } ``` `XiHanKafkaEventBusModule` 自身 `[DependsOn(typeof(XiHanEventBusModule))]`。它做两件事: - `ConfigureServices`:绑定配置节 `XiHan:EventBus:Kafka` 到 `XiHanKafkaEventBusOptions` - `OnApplicationInitializationAsync`:解析 `KafkaDistributedEventBus` 并调用 `InitializeAsync()`(确保主题、建生产者/消费者、启动消费循环) ### 它是怎么"替换"掉默认实现的 `KafkaDistributedEventBus` 标注 `[ExposeServices(typeof(IDistributedEventBus), typeof(KafkaDistributedEventBus))]` 且实现 `ISingletonDependency`,走**常规注册**(`services.Add`);默认的 `LocalDistributedEventBus` 标注 `[Dependency(TryRegister = true)]`,走 `services.TryAdd`。模块按拓扑序加载,`EventBus` 先注册、本包随后追加,DI 解析单个服务取**最后一条**注册,因此 Kafka 实现胜出。 ## 工作原理 ### 初始化(`InitializeAsync`) 在应用初始化阶段执行,双检锁保证只跑一次: 1. 若 `EnsureTopicExists`(默认 `true`),用 `AdminClient` 建主题(分区数 `TopicPartitionCount`、副本数 `TopicReplicationFactor`);主题已存在则忽略 `TopicAlreadyExists`,其它异常仅 `LogWarning` 不中断启动 2. 建生产者:`Acks = All` + `EnableIdempotence = true`(幂等生产者,避免重试导致的重复写入) 3. 建消费者:`GroupId` = 配置值,**`EnableAutoCommit = false`**(手动提交),`AutoOffsetReset` 由 `AutoOffsetReset` 配置解析(`latest` / `error` / 其余一律 `earliest`) 4. 以 `TaskCreationOptions.LongRunning` 起一个后台消费循环 ### 发布路径 `PublishToBrokerAsync` → `producer.ProduceAsync(TopicName, new Message { Key = 事件名, Value = body, Headers = [...] })`: - Header `messageId`:用于收件箱幂等去重 - Header `X-Correlation-Id`(`EventBusConsts.CorrelationIdHeaderName`):链路关联标识 - 若返回的 `PersistenceStatus != Persisted`,记 `LogWarning`(**不抛异常**) 发布默认先落发件箱(`useOutbox: true`),由后台 `EventBoxOutboxSenderHostedService` 拉取后逐条投递。 ### 消费路径 消费循环 `ConsumeLoopAsync`:`Subscribe(TopicName)` → 阻塞 `Consume(cancellationToken)` → 基类 `ProcessIncomingMessageAsync(messageId, message.Key, correlationId, message.Value)`: 1. 按事件名(消息 Key)查 `EventTypes`,**查不到直接返回**(本实例没订阅该事件) 2. 反序列化 JSON 为事件对象 3. 用 `correlationId`(若为合法 32 位十六进制)作为 W3C trace id 开一个 `eventbus.consume {事件名}` 的 Consumer Span 4. **若配置了收件箱**:写入收件箱(按 `messageId` 去重)后返回,由收件箱后台处理器异步触发处理器并重试 5. **未配置收件箱**:当前上下文直接触发处理器;失败则抛出 处理成功 → `Commit(consumeResult)`。处理抛异常 → 记 `LogError` 后**仍然 `Commit`**,避免毒消息卡死分区。 ## 核心能力 - **跨进程分布式事件**:`KafkaDistributedEventBus`(`IDistributedEventBus` 单例),覆盖默认的进程内实现 - **竞争消费**:同一 `GroupId` 内一条事件只被一个实例处理 - **幂等生产者**:`Acks = All` + `EnableIdempotence = true` - **手动提交偏移**:处理完才提交,配合收件箱可做到至少一次投递 - **自动建主题**:`EnsureTopicExists` 开关,生产环境常关闭(broker 侧禁用自动建主题时) - **链路贯通**:`correlationId` 经消息 Header 传播,消费端归入同一 trace - **优雅释放**:`IAsyncDisposable`,取消消费循环 → `Close`/`Dispose` 消费者 → `Flush(5s)` 生产者 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanKafkaEventBusModule` | 模块类;`[DependsOn(XiHanEventBusModule)]`,初始化阶段调用 `InitializeAsync()` | | `KafkaDistributedEventBus` | 事件总线实现,继承 `BrokerDistributedEventBusBase`;`ISingletonDependency` + `IAsyncDisposable` | | `XiHanKafkaEventBusOptions` | 配置选项;配置节 `XiHan:EventBus:Kafka` | | `BrokerDistributedEventBusBase` | 三个 Broker 的公共基类(位于 `XiHan.Framework.EventBus`):事件类型映射、订阅委派本地总线、发件箱投递、入站幂等/重试、Consumer Span | ## 配置 - **配置节名**:`XiHan:EventBus:Kafka`(来自 `XiHanKafkaEventBusOptions.SectionName`) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `BootstrapServers` | `string` | `localhost:9092` | 集群地址(逗号分隔,如 `host1:9092,host2:9092`) | | `TopicName` | `string` | `Default.EventBus` | 主题名称(所有事件写入同一主题) | | `GroupId` | `string` | `Default.EventBus` | 消费者组 Id(同组内竞争消费) | | `AutoOffsetReset` | `string` | `earliest` | 首次消费的偏移策略(`earliest` / `latest` / `error`) | | `EnsureTopicExists` | `bool` | `true` | 初始化时是否自动建主题 | | `TopicPartitionCount` | `int` | `1` | 自动建主题时的分区数 | | `TopicReplicationFactor` | `short` | `1` | 自动建主题时的副本数 | ```json { "XiHan": { "EventBus": { "Kafka": { "BootstrapServers": "localhost:9092", "TopicName": "MyApp.EventBus", "GroupId": "MyApp.EventBus", "AutoOffsetReset": "earliest", "EnsureTopicExists": false, "TopicPartitionCount": 3, "TopicReplicationFactor": 2 } } } } ``` ## 使用示例 业务代码与单机时完全一致: ```csharp [EventName("MyApp.Orders.OrderShipped")] // 事件名 = Kafka 消息 Key public class OrderShippedEvent { public long OrderId { get; set; } } public class OrderShippedHandler : IDistributedEventHandler, ITransientDependency { public Task HandleEventAsync(OrderShippedEvent eventData) { // 同一消费者组内只有一个实例会执行到这里 return Task.CompletedTask; } } await _distributedEventBus.PublishAsync(new OrderShippedEvent { OrderId = 1001 }); ``` ## 扩展点 / 自定义 - **分区与顺序**:事件名作为消息 Key,同名事件落同一分区 → **同一事件类型的消息全局有序**,不同事件类型之间不保证顺序。需要按业务键(如订单号)保序时,须自行扩展 `PublishToBrokerAsync` 改写 Key - **换发件箱为持久化实现**:见 [EventBus · 扩展点](./eventbus#扩展点-自定义) - **启用收件箱**:配置 `XiHanDistributedEventBusOptions.Inboxes` 后,入站消息先落收件箱去重,再由后台处理器带重试地触发处理器 ## 注意事项与最佳实践 - **不同应用必须用不同的 `GroupId`**。消费者组是竞争消费单元:两个不同的应用若共用同一 `GroupId`,会互相抢走对方的消息;抢到的实例因 `EventTypes` 里没有该事件名会**静默丢弃**(查不到类型直接 return,随后 Commit)。不报错、极难排查。共用同一主题但需各自全量消费时,务必分配不同 `GroupId`。 - **处理失败仍会提交偏移**。异常时记 `LogError` 后照样 `Commit`——刻意如此,避免毒消息永久阻塞分区。**可靠重试必须依赖收件箱(Inbox)**;未配置收件箱时,处理失败的事件就真的没了。 - **`EnsureTopicExists` 在生产环境建议关掉**。多数生产集群禁用了自动建主题;即便建主题失败,本包也只 `LogWarning` 不中断启动,随后的发布才会真正报错——看到"主题不存在"类错误时先回头看启动日志的这条警告。 - **单主题承载全部事件**。分区数决定并行度上限:`TopicPartitionCount = 1`(默认)时,同一消费者组内实质只有一个实例在消费。生产环境请按吞吐设置分区数。 - **发布是异步的**。`useOutbox` 默认 `true`,`PublishAsync` 返回时消息可能还在发件箱里。要同步直投传 `useOutbox: false`(会失去"事务提交后才发事件"的保证)。 - **默认发件箱是内存实现**,进程重启即丢。生产环境的"事件不丢"承诺需要把 `IEventOutbox` 换成持久化实现。 - **`PersistenceStatus != Persisted` 只记警告不抛异常**。极端情况下消息可能未真正落盘而调用方毫无察觉,关键链路请自行监控这条日志。 - **事件名就是消息 Key**,务必用 `[EventName("稳定名字")]` 显式指定;不加时回退 `Type.FullName`,重构改名会静默破坏生产者与消费者的契约。 ## 依赖模块 - [XiHan.Framework.EventBus](./eventbus)(基类、发件箱/收件箱、本地事件总线) ## 相关模块 - [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions) - [XiHan.Framework.EventBus.RabbitMQ](./eventbus-rabbitmq) - [XiHan.Framework.EventBus.Redis](./eventbus-redis) --- 来源:https://framework.docs.xihanfun.com/packages/eventbus-rabbitmq # XiHan.Framework.EventBus.RabbitMQ > 分布式事件总线的 RabbitMQ 提供程序:以事件名作为路由键投递到 direct 交换机,同一队列竞争消费。 - **NuGet**:`XiHan.Framework.EventBus.RabbitMQ` - **模块类**:`XiHanRabbitMQEventBusModule` - **所在层**:基础设施层 - **关键依赖**:`RabbitMQ.Client` 7.2.1 + [XiHan.Framework.EventBus](./eventbus) ## 概述 [XiHan.Framework.EventBus](./eventbus) 默认的 `IDistributedEventBus` 实现是 `LocalDistributedEventBus`——它只在进程内分发,"分布式"只是接口语义。这个包提供真正跨进程的实现 `RabbitMQDistributedEventBus`:发布时把事件序列化为 UTF-8 JSON、以**事件名作为路由键**投递到 direct 交换机;消费时同一应用的多个实例**共享同一队列**形成竞争消费,保证一条分布式事件在集群中只被处理一次。 装上并 `[DependsOn]` 之后,你的业务代码**一行都不用改**——照旧注入 `IDistributedEventBus` 发布、照旧实现 `IDistributedEventHandler<>` 消费,事件自动跨进程流转。 ## 何时使用 - 应用需要**多实例部署**,且分布式事件必须只被其中一个实例处理 - 需要跨服务(不同应用、不同语言)传递事件,且已有 RabbitMQ 基础设施 - 需要交换机级别的路由能力(按事件名绑定队列,实例只收自己订阅的事件) 不需要跨进程时不必装:默认的 `LocalDistributedEventBus` 已能满足单机场景。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus.RabbitMQ ``` ```csharp [DependsOn(typeof(XiHanRabbitMQEventBusModule))] public class MyModule : XiHanModule { } ``` `XiHanRabbitMQEventBusModule` 自身 `[DependsOn(typeof(XiHanEventBusModule))]`,所以你不用再单独依赖 `EventBus`。它做两件事: - `ConfigureServices`:绑定配置节 `XiHan:EventBus:RabbitMQ` 到 `XiHanRabbitMQEventBusOptions` - `OnApplicationInitializationAsync`:解析 `RabbitMQDistributedEventBus` 并调用 `InitializeAsync()`(建连接、声明交换机/队列、绑定路由键、启动消费者) ### 它是怎么"替换"掉默认实现的 `RabbitMQDistributedEventBus` 标注 `[ExposeServices(typeof(IDistributedEventBus), typeof(RabbitMQDistributedEventBus))]` 且实现 `ISingletonDependency`,走**常规注册**(`services.Add`);而默认的 `LocalDistributedEventBus` 标注 `[Dependency(TryRegister = true)]`,走 `services.TryAdd`。模块按拓扑序加载,`EventBus` 先于本包注册,因此默认实现先落位、本包随后**追加**一条 `IDistributedEventBus` 注册。DI 解析单个服务时取**最后一条**注册,所以 `RabbitMQDistributedEventBus` 胜出。 ## 工作原理 ### 初始化(`InitializeAsync`) 在应用初始化阶段(此时所有事件处理器已注册完毕)执行,双检锁保证只跑一次: 1. 用 `ConnectionFactory` 建连接(`AutomaticRecoveryEnabled = true`;若配了 `Uri` 则覆盖 Host/Port/凭据等单项配置),连接名取 `ClientProvidedName` 2. 开两个 channel:一个专用于发布、一个专用于消费 3. `ExchangeDeclare(ExchangeName, ExchangeType, durable: true)` 4. `QueueDeclare(QueueName, durable: true, exclusive: false, autoDelete: false)` 5. **遍历 `EventTypes.Keys` 逐个 `QueueBind`**——`EventTypes` 是基类 `BrokerDistributedEventBusBase` 在构造时根据 `XiHanDistributedEventBusOptions.Handlers` 填充的"事件名 → 事件类型"映射 6. `BasicQos(prefetchCount: PrefetchCount)`,挂 `AsyncEventingBasicConsumer`,`BasicConsume(autoAck: false)` ### 发布路径 `PublishToBrokerAsync` 把事件推入交换机:`DeliveryMode = Persistent`(消息持久化),`messageId` 写入 `BasicProperties.MessageId`、`correlationId` 写入 `BasicProperties.CorrelationId`,`routingKey` = 事件名。发布用信号量串行化(`_publishLock`),因为 channel 非线程安全。 注意发布**默认先落发件箱**(`useOutbox: true`):`PublishAsync` → `AddToOutboxAsync` → 后台 `EventBoxOutboxSenderHostedService` 拉取 → `PublishManyFromOutboxAsync` → 逐条 `PublishToBrokerAsync`。 ### 消费路径 消费者回调 `HandleReceivedAsync` → 基类 `ProcessIncomingMessageAsync(messageId, routingKey, correlationId, body)`: 1. 按事件名查 `EventTypes`,**查不到直接返回**(本实例没订阅该事件) 2. 反序列化 JSON 为事件对象 3. 用 `correlationId`(若为合法 32 位十六进制)作为 W3C trace id 开一个 `eventbus.consume {事件名}` 的 Consumer Span,让消费端日志/审计与上游同 trace 4. **若配置了收件箱**:写入收件箱(按 `messageId` 幂等去重)后返回,由收件箱后台处理器异步触发处理器并负责重试 5. **未配置收件箱**:当前上下文直接触发处理器;失败则抛出 回调成功 → `BasicAck`;抛异常 → 记 `LogError` 并 `BasicNack(requeue: false)`。 ## 核心能力 - **跨进程分布式事件**:`RabbitMQDistributedEventBus`(`IDistributedEventBus` 单例),覆盖默认的进程内实现 - **竞争消费**:同一 `QueueName` 的多个实例共享队列,一条事件只被一个实例处理 - **按事件名路由**:direct 交换机 + 事件名路由键,实例只收自己 `[DependsOn]` 链上注册了处理器的事件 - **消息持久化**:交换机/队列 `durable: true`,消息 `DeliveryModes.Persistent` - **链路贯通**:`correlationId` 经 `BasicProperties.CorrelationId` 传播,消费端归入同一 trace - **幂等与重试**:复用框架收件箱(Inbox)机制,按 `messageId` 去重 - **优雅释放**:`IAsyncDisposable`,依次关闭消费 channel、发布 channel、连接 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanRabbitMQEventBusModule` | 模块类;`[DependsOn(XiHanEventBusModule)]`,初始化阶段调用 `InitializeAsync()` | | `RabbitMQDistributedEventBus` | 事件总线实现,继承 `BrokerDistributedEventBusBase`;`ISingletonDependency` + `IAsyncDisposable` | | `XiHanRabbitMQEventBusOptions` | 配置选项;配置节 `XiHan:EventBus:RabbitMQ` | | `BrokerDistributedEventBusBase` | 三个 Broker 的公共基类(位于 `XiHan.Framework.EventBus`):事件类型映射、订阅委派本地总线、发件箱投递、入站幂等/重试、Consumer Span | ## 配置 - **配置节名**:`XiHan:EventBus:RabbitMQ`(来自 `XiHanRabbitMQEventBusOptions.SectionName`) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Uri` | `string?` | `null` | 完整连接串(`amqp://user:pass@host:5672/vhost`);**设置后优先于下方单项配置** | | `HostName` | `string` | `localhost` | 主机名 | | `Port` | `int` | `5672` | 端口 | | `UserName` | `string` | `guest` | 用户名 | | `Password` | `string` | `guest` | 密码 | | `VirtualHost` | `string` | `/` | 虚拟主机 | | `ExchangeName` | `string` | `Default` | 交换机名称 | | `ExchangeType` | `string` | `direct` | 交换机类型(`direct` / `topic` / `fanout`),空值回退 `direct` | | `QueueName` | `string` | `Default.EventBus` | 队列名称(同一应用的多实例共享 → 竞争消费) | | `PrefetchCount` | `ushort` | `50` | 消费者预取数量(QoS) | | `ClientProvidedName` | `string` | `Default.EventBus` | 客户端连接名(便于在管理台识别) | ```json { "XiHan": { "EventBus": { "RabbitMQ": { "HostName": "localhost", "Port": 5672, "UserName": "guest", "Password": "guest", "VirtualHost": "/", "ExchangeName": "MyApp", "ExchangeType": "direct", "QueueName": "MyApp.EventBus", "PrefetchCount": 50, "ClientProvidedName": "MyApp" } } } } ``` ## 使用示例 业务代码与单机时**完全一致**——这正是这个包的价值: ```csharp // 1) 事件:跨服务务必用 [EventName] 固定事件名(它就是路由键) [EventName("MyApp.Orders.OrderShipped")] public class OrderShippedEvent { public long OrderId { get; set; } } // 2) 处理器:实现 IDistributedEventHandler<> 即被自动登记 public class OrderShippedHandler : IDistributedEventHandler, ITransientDependency { public Task HandleEventAsync(OrderShippedEvent eventData) { // 集群中只有一个实例会执行到这里 return Task.CompletedTask; } } // 3) 发布:默认先落发件箱,后台异步投递到 RabbitMQ await _distributedEventBus.PublishAsync(new OrderShippedEvent { OrderId = 1001 }); ``` ## 扩展点 / 自定义 - **换交换机类型**:`ExchangeType` 改 `topic` 可做通配路由;但队列绑定仍按事件名精确绑定,通配需自行扩展 `InitializeAsync` - **换发件箱为持久化实现**:见 [EventBus · 扩展点](./eventbus#扩展点-自定义),默认内存发件箱进程重启即丢 - **启用收件箱**:配置 `XiHanDistributedEventBusOptions.Inboxes` 后,入站消息先落收件箱去重,再由后台处理器带重试地触发处理器 ## 注意事项与最佳实践 - **队列绑定只在 `InitializeAsync` 时做一次**。绑定的路由键来自 `EventTypes.Keys`,而 `EventTypes` 在总线构造时由 `XiHanDistributedEventBusOptions.Handlers` 填充。**应用初始化之后才动态 `Subscribe` 的事件,队列上没有对应绑定,消息永远不会投递到本实例。** 事件处理器请走常规的模块注册管线(实现 `IDistributedEventHandler<>` + 被约定扫描注册)。 - **不同应用必须用不同的 `QueueName`**。队列是竞争消费单元:两个不同的应用若共用同一队列名,会互相抢走对方的消息;而抢到的实例因 `EventTypes` 里没有该事件名,会**静默丢弃**(`ProcessIncomingMessageAsync` 查不到类型直接 return,随后 Ack)。这类问题不报错、极难排查。 - **失败即丢弃,没有死信队列**。消费抛异常时 `BasicNack(requeue: false)`——刻意不重投,避免毒消息无限循环。**可靠重试必须依赖收件箱(Inbox)**;未配置收件箱时,处理失败的事件就真的没了。生产环境请配置收件箱,或自行为队列挂 DLX。 - **发布是异步的**。`useOutbox` 默认 `true`,`PublishAsync` 返回时消息可能还躺在发件箱里。要同步直投传 `useOutbox: false`(但会失去"事务提交后才发事件"的保证)。 - **默认发件箱是内存实现**,进程重启即丢。生产环境的"事件不丢"承诺需要把 `IEventOutbox` 换成持久化实现。 - **事件名就是路由键**,务必用 `[EventName("稳定名字")]` 显式指定。不加时回退到 `Type.FullName`,一次命名空间重构就会让生产者与消费者对不上,且不会有任何编译期错误。 - **`Uri` 会覆盖单项配置**。同时配了 `Uri` 和 `HostName` 时,只有 `Uri` 生效——排查"连错环境"时先看这条。 ## 依赖模块 - [XiHan.Framework.EventBus](./eventbus)(基类、发件箱/收件箱、本地事件总线) ## 相关模块 - [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions) - [XiHan.Framework.EventBus.Kafka](./eventbus-kafka) - [XiHan.Framework.EventBus.Redis](./eventbus-redis) --- 来源:https://framework.docs.xihanfun.com/packages/eventbus-redis # XiHan.Framework.EventBus.Redis > 分布式事件总线的 Redis Streams 提供程序:`XADD` 写入同一 Stream,消费者组 `XREADGROUP` 竞争消费、处理后 `XACK`。 - **NuGet**:`XiHan.Framework.EventBus.Redis` - **模块类**:`XiHanRedisEventBusModule` - **所在层**:基础设施层 - **关键依赖**:`StackExchange.Redis` 3.0.11 + [XiHan.Framework.EventBus](./eventbus) ## 概述 [XiHan.Framework.EventBus](./eventbus) 默认的 `IDistributedEventBus` 实现只在进程内分发。这个包基于 **Redis Streams** 提供跨进程实现 `RedisDistributedEventBus`:发布时用 `XADD` 把事件写入同一个 Stream(字段含事件名 / `messageId` / `correlationId` / 数据);消费时通过**消费者组** `XREADGROUP` 竞争消费,处理完 `XACK`,保证一条分布式事件在集群中只被处理一次。 它是三个 Broker 提供程序里**部署成本最低**的一个——多数项目本来就有 Redis。总线使用**独立连接**(与缓存各连各的),并带近似长度裁剪防止 Stream 无限增长。 ## 何时使用 - 已有 Redis,不想为事件总线再引入 RabbitMQ / Kafka - 事件量中等、可接受轮询延迟(默认无消息时 1 秒轮询一次) - 需要跨进程竞争消费,但不需要 Kafka 那样的回溯与高吞吐 对可靠性要求极高(事件绝不能丢)时,优先考虑 [RabbitMQ](./eventbus-rabbitmq) 或 [Kafka](./eventbus-kafka),并务必配置收件箱与持久化发件箱。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus.Redis ``` ```csharp [DependsOn(typeof(XiHanRedisEventBusModule))] public class MyModule : XiHanModule { } ``` `XiHanRedisEventBusModule` 自身 `[DependsOn(typeof(XiHanEventBusModule))]`。它做两件事: - `ConfigureServices`:绑定配置节 `XiHan:EventBus:Redis` 到 `XiHanRedisEventBusOptions` - `OnApplicationInitializationAsync`:解析 `RedisDistributedEventBus` 并调用 `InitializeAsync()`(建连接、创建消费者组、启动消费循环) ### 它是怎么"替换"掉默认实现的 `RedisDistributedEventBus` 标注 `[ExposeServices(typeof(IDistributedEventBus), typeof(RedisDistributedEventBus))]` 且实现 `ISingletonDependency`,走**常规注册**(`services.Add`);默认的 `LocalDistributedEventBus` 标注 `[Dependency(TryRegister = true)]`,走 `services.TryAdd`。模块按拓扑序加载,`EventBus` 先注册、本包随后追加,DI 解析单个服务取**最后一条**注册,因此 Redis 实现胜出。 ## 工作原理 ### 初始化(`InitializeAsync`) 在应用初始化阶段执行,双检锁保证只跑一次: 1. `ConnectionMultiplexer.ConnectAsync(Configuration)` 建**独立连接**(不复用缓存的连接) 2. `EnsureConsumerGroupAsync()`:`XGROUP CREATE`(`StreamPosition.NewMessages`、`createStream: true`);已存在则捕获 `BUSYGROUP` 忽略 3. 以 `TaskCreationOptions.LongRunning` 起一个后台消费循环 消费者名固定为 `{机器名}:{每进程一个新 Guid}`。 ### 发布路径 `PublishToBrokerAsync` → `XADD`,写入四个字段: | 字段 | 内容 | | --- | --- | | `event` | 事件名 | | `mid` | `messageId`(收件箱幂等去重用) | | `cid` | `correlationId`(链路关联标识) | | `data` | UTF-8 JSON 序列化的事件数据 | `MaxStreamLength > 0`(默认 `100000`)时按**近似最大长度**裁剪(`useApproximateMaxLength: true`);`<= 0` 则不裁剪。 发布默认先落发件箱(`useOutbox: true`),由后台 `EventBoxOutboxSenderHostedService` 拉取后逐条投递。 ### 消费路径 消费循环 `ConsumeLoopAsync`: 1. `XREADGROUP`(`StreamPosition.NewMessages`,批量 `ReadBatchSize`) 2. 读取失败 → `LogError` → 等 `PollIntervalMilliseconds` 后重试;无消息 → 等 `PollIntervalMilliseconds` 后再读 3. 逐条取出字段,调基类 `ProcessIncomingMessageAsync(mid, event, cid, data)`: - 按事件名查 `EventTypes`,**查不到直接返回**(本实例没订阅该事件) - 反序列化 JSON 为事件对象 - 用 `correlationId`(若为合法 32 位十六进制)作为 W3C trace id 开一个 `eventbus.consume {事件名}` 的 Consumer Span - **若配置了收件箱**:写入收件箱(按 `messageId` 去重)后返回,由收件箱后台处理器异步触发处理器并重试 - **未配置收件箱**:当前上下文直接触发处理器 4. 处理抛异常 → `LogError`;**无论成功与否,`finally` 里都执行 `XACK`**(避免毒消息滞留 PEL) ## 核心能力 - **跨进程分布式事件**:`RedisDistributedEventBus`(`IDistributedEventBus` 单例),覆盖默认的进程内实现 - **竞争消费**:同一 `ConsumerGroup` 内一条事件只被一个实例处理 - **独立连接**:与缓存的 Redis 连接分离,互不影响 - **自动裁剪**:`MaxStreamLength` 近似裁剪,防止 Stream 无限增长撑爆内存 - **链路贯通**:`correlationId` 经 Stream 字段传播,消费端归入同一 trace - **优雅释放**:`IAsyncDisposable`,取消消费循环 → 关闭并释放连接 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanRedisEventBusModule` | 模块类;`[DependsOn(XiHanEventBusModule)]`,初始化阶段调用 `InitializeAsync()` | | `RedisDistributedEventBus` | 事件总线实现,继承 `BrokerDistributedEventBusBase`;`ISingletonDependency` + `IAsyncDisposable` | | `XiHanRedisEventBusOptions` | 配置选项;配置节 `XiHan:EventBus:Redis` | | `BrokerDistributedEventBusBase` | 三个 Broker 的公共基类(位于 `XiHan.Framework.EventBus`):事件类型映射、订阅委派本地总线、发件箱投递、入站幂等/重试、Consumer Span | ## 配置 - **配置节名**:`XiHan:EventBus:Redis`(来自 `XiHanRedisEventBusOptions.SectionName`) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Configuration` | `string` | `localhost:6379` | StackExchange.Redis 连接串(独立于缓存连接) | | `StreamKey` | `string` | `Default:EventBus:Stream` | 事件流键(所有事件写入同一 Stream) | | `ConsumerGroup` | `string` | `Default.EventBus` | 消费者组名称(同组内竞争消费) | | `ReadBatchSize` | `int` | `10` | 单次读取批量大小 | | `PollIntervalMilliseconds` | `int` | `1000` | 无消息时的轮询间隔(毫秒) | | `MaxStreamLength` | `int` | `100000` | Stream 近似最大长度,自动裁剪;`<= 0` 表示不裁剪 | ```json { "XiHan": { "EventBus": { "Redis": { "Configuration": "localhost:6379", "StreamKey": "MyApp:EventBus:Stream", "ConsumerGroup": "MyApp.EventBus", "ReadBatchSize": 10, "PollIntervalMilliseconds": 1000, "MaxStreamLength": 100000 } } } } ``` ## 使用示例 业务代码与单机时完全一致: ```csharp [EventName("MyApp.Orders.OrderShipped")] // 事件名写入 Stream 的 event 字段 public class OrderShippedEvent { public long OrderId { get; set; } } public class OrderShippedHandler : IDistributedEventHandler, ITransientDependency { public Task HandleEventAsync(OrderShippedEvent eventData) { // 同一消费者组内只有一个实例会执行到这里 return Task.CompletedTask; } } await _distributedEventBus.PublishAsync(new OrderShippedEvent { OrderId = 1001 }); ``` ## 扩展点 / 自定义 - **换发件箱为持久化实现**:见 [EventBus · 扩展点](./eventbus#扩展点-自定义) - **启用收件箱**:配置 `XiHanDistributedEventBusOptions.Inboxes` 后,入站消息先落收件箱去重,再由后台处理器带重试地触发处理器 - **降低延迟**:调小 `PollIntervalMilliseconds`(代价是空转时的 Redis 请求变多) ## 注意事项与最佳实践 - **不同应用必须用不同的 `ConsumerGroup`(或不同的 `StreamKey`)**。消费者组是竞争消费单元:两个不同的应用若共用同一组名,会互相抢走对方的消息;抢到的实例因 `EventTypes` 里没有该事件名会**静默丢弃**(查不到类型直接 return,随后 `XACK`)。不报错、极难排查。 - **处理失败仍会 `XACK`**。异常时记 `LogError` 后照样在 `finally` 里确认——刻意如此,避免毒消息滞留 PEL。**可靠重试必须依赖收件箱(Inbox)**;未配置收件箱时,处理失败的事件就真的没了。 - **崩溃残留的消息不会被自动认领**。消费循环只读 `StreamPosition.NewMessages`(即 `>`),没有 `XAUTOCLAIM` / `XPENDING` 回收逻辑;进程若在 `XREADGROUP` 之后、`XACK` 之前崩溃,那批消息会永久留在 PEL 里无人处理(消费者名每进程都是新 Guid,重启后也不会认领旧条目)。需要这层保障时请启用收件箱,或自行补一个认领任务。 - **消费者组从"新消息"开始**。`XGROUP CREATE` 用的是 `StreamPosition.NewMessages`,因此**组创建之前已写入 Stream 的事件永远不会被消费**。首次部署时先启动消费端、再开始发布。 - **近似裁剪会丢未消费的事件**。`MaxStreamLength` 默认 `100000` 且按近似长度裁剪:写入速度远超消费速度时,老消息会被裁掉——哪怕还没被消费。事件积压时优先扩容消费端,别指望 Stream 兜底。 - **发布是异步的**。`useOutbox` 默认 `true`,`PublishAsync` 返回时消息可能还在发件箱里。要同步直投传 `useOutbox: false`(会失去"事务提交后才发事件"的保证)。 - **默认发件箱是内存实现**,进程重启即丢。生产环境的"事件不丢"承诺需要把 `IEventOutbox` 换成持久化实现。 - **空闲时有固定轮询开销**。无消息时每 `PollIntervalMilliseconds` 发一次 `XREADGROUP`;实例多时注意 Redis 的 QPS 基线。 - **事件名务必用 `[EventName("稳定名字")]` 显式指定**;不加时回退 `Type.FullName`,重构改名会静默破坏生产者与消费者的契约。 ## 依赖模块 - [XiHan.Framework.EventBus](./eventbus)(基类、发件箱/收件箱、本地事件总线) ## 相关模块 - [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions) - [XiHan.Framework.EventBus.RabbitMQ](./eventbus-rabbitmq) - [XiHan.Framework.EventBus.Kafka](./eventbus-kafka) - [XiHan.Framework.Caching](./caching)(Redis 缓存,与本包各用各的连接) --- 来源:https://framework.docs.xihanfun.com/packages/eventbus # XiHan.Framework.EventBus > 事件总线实现:进程内本地事件总线 + 分布式事件总线(Outbox/Inbox 可靠投递),落地 EventBus.Abstractions 的全部契约。 - **NuGet**:`XiHan.Framework.EventBus` - **模块类**:`XiHanEventBusModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Abstractions / Messaging / Uow / DistributedIds / Core) ## 概述 这个包是事件总线的具体实现,落地了 [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions) 里的接口。它提供进程内的本地事件总线 `LocalEventBus`,以及带可靠投递能力的分布式事件总线 `LocalDistributedEventBus`(发件箱 Outbox / 收件箱 Inbox 模式,默认内存实现)。你用它把业务解耦成"发布事件 → 一个或多个处理器响应",并可让事件跟随工作单元(UoW)提交后再真正发布。核心亮点是**处理器自动登记**:只要你的处理器类实现了 `ILocalEventHandler<>` / `IDistributedEventHandler<>` 并走框架的模块注册管线,就会被自动订阅,无需手写 `Subscribe`。 ## 何时使用 - 一处动作要触发多处副作用(如"下单成功"后同时发通知、扣库存、写日志),用事件解耦 - 希望事件在数据库事务/工作单元提交后再发布,避免"事务回滚了但事件已发出去" - 需要分布式事件的可靠投递:先落发件箱、后台再异步发送;消费端用收件箱去重与失败重试 - 需要控制同一事件多个处理器的执行顺序(`LocalEventHandlerOrderAttribute`) ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus ``` ```csharp [DependsOn(typeof(XiHanEventBusModule))] public class MyModule : XiHanModule { } ``` `XiHanEventBusModule` 在 `PreConfigureServices` 里调用 `services.AddXiHanEventBus(config)` 完成注册,启用后你会得到: - `ILocalEventBus` → `LocalEventBus`(单例,通过 `[ExposeServices]` 暴露) - `IDistributedEventBus` → `LocalDistributedEventBus`(单例,`[Dependency(TryRegister = true)]`,可被替换) - `IEventOutbox` / `IEventInbox` → 默认 `DefaultEventOutbox` / `DefaultEventInbox`(单例,`TryAddSingleton`) - 两个后台托管服务:`EventBoxOutboxSenderHostedService`(发送发件箱)、`EventBoxInboxProcessorHostedService`(处理收件箱) - `IUnitOfWorkEventPublisher` → `UnitOfWorkEventPublisher`(`[Dependency(ReplaceServices = true)]`,让事件在 UoW 完成后发布) - 通过 `OnRegistered` 钩子把所有事件处理器类型自动收集进 `XiHanLocalEventBusOptions.Handlers` / `XiHanDistributedEventBusOptions.Handlers` ## 工作原理 ### 处理器自动登记(关键机制) `AddXiHanEventBus` 内部调用 `AddEventHandlers`,注册了 `services.OnRegistered(...)` 钩子:**每当有服务被注册进容器**,钩子检查其 `ImplementationType` 是否 `IsAssignableToGeneric(typeof(ILocalEventHandler<>))` 或 `IDistributedEventHandler<>`,命中就收集到本地/分布式处理器列表;随后通过 `services.Configure` / `Configure` 把这些类型 `AddIfNotContains` 进各自的 `Handlers`(`ITypeList`)。 到运行期:`LocalEventBus` 构造时对 `Options.Handlers` 逐个 `SubscribeHandlers`;`LocalDistributedEventBus` 构造时对 `Options.Handlers` 逐个 `Subscribe`(按接口泛型参数取事件类型,用 `IocEventHandlerFactory` 从 DI 解析处理器实例)。**这条链路是订阅的唯一来源**——处理器必须进入 `Options.Handlers` 才会被订阅(详见「注意事项」)。 ### 发布路径 - **本地**:`PublishAsync(eventData, onUnitOfWorkComplete)` → 若有当前 UoW 且 `onUnitOfWorkComplete = true`,事件记录进 UoW(`AddOrReplaceLocalEvent`),UoW 完成后由 `UnitOfWorkEventPublisher` 发布;否则立即 `TriggerHandlersAsync`,按 `LocalEventHandlerOrderAttribute.Order` 升序调用各处理器。 - **分布式**:`PublishAsync(eventData, onUnitOfWorkComplete, useOutbox)` → 有 UoW 则记录进 UoW(`AddOrReplaceDistributedEvent`);否则若 `useOutbox` 则先 `AddToOutboxAsync` 落发件箱并返回,由后台服务异步投递;不走 Outbox 时直接投递并转交 `LocalEventBus`。 ### 发布/接收观测钩子 分布式总线每次真正投递或收到事件时(直投、走发件箱、走收件箱),都会顺带把一条 `DistributedEventSent` / `DistributedEventReceived` 作为**本地事件**再发布一次(`TriggerDistributedEventSentAsync` / `TriggerDistributedEventReceivedAsync`,`onUnitOfWorkComplete: false`,异常被吞掉不影响主流程)。想做分布式事件的统一日志、审计或指标采集,实现 `ILocalEventHandler` / `ILocalEventHandler` 订阅即可拿到 `Source`(`Direct` / `Outbox` / `Inbox`)、`EventName`、`EventData`,无需侵入每个业务处理器。 ### 发件箱/收件箱后台循环 - `EventBoxOutboxSenderHostedService`:循环拉取每个已启用发件箱的等待事件(`GetWaitingEventsAsync`,批量 `OutboxBatchSize`),调用 `ISupportsEventBoxes.PublishManyFromOutboxAsync` 投递,成功后 `DeleteManyAsync` 清理。 - `EventBoxInboxProcessorHostedService`:循环拉取每个已启用收件箱的等待事件,逐个 `ProcessFromInboxAsync` 处理;成功 `MarkAsProcessedAsync`,失败进重试计数——达到 `MaxInboxRetryCount` 则 `MarkAsDiscardAsync` 丢弃,否则 `RetryLaterAsync` 延后(`InboxRetryDelaySeconds` 秒后);每轮末尾 `DeleteOldEventsAsync` 清理过期(默认实现 `DefaultEventInbox` 只清理非等待中、且最后修改时间超过 7 天的记录)。 - 两者轮询间隔 = `PollingIntervalMilliseconds`(下限 200ms)。 ## 核心能力 - **本地事件总线** `LocalEventBus`(`ILocalEventBus`,单例):进程内发布/订阅,接入工作单元与当前租户上下文;处理器按 `Order` 排序执行;支持事件继承(父类型处理器也会被基于子类型的发布触发) - **分布式事件总线** `LocalDistributedEventBus`(`IDistributedEventBus`,单例,`TryRegister`):在本地事件总线之上叠加 Outbox/Inbox 与事件名映射,可被真正的 MQ 实现替换;另有空实现 `NullDistributedEventBus`(`Instance` 单例) - **Outbox / Inbox 模式**:有界默认实现 `DefaultEventOutbox` / `DefaultEventInbox` 各最多 100000 条,满载时拒绝新增而不静默丢事件;可由应用替换为持久化实现 - **后台托管服务**:`EventBoxOutboxSenderHostedService`、`EventBoxInboxProcessorHostedService`(均为 `BackgroundService`,以 `TryAddEnumerable` 注册为 `IHostedService`) - **处理器工厂体系**:`IocEventHandlerFactory`(从 DI 解析,支持依赖注入)、`TransientEventHandlerFactory` / `TransientEventHandlerFactory`(每次新建瞬时实例)、`SingleInstanceHandlerFactory`(复用同一实例)、`ActionEventHandler`(把委托包装成处理器) - **工作单元集成** `UnitOfWorkEventPublisher`:实现 `IUnitOfWorkEventPublisher`,让本地/分布式事件跟随 UoW 完成后发布 - **事件命名**:`EventNameAttribute`(实现 `IEventNameProvider`)/ `GenericEventNameAttribute` 为分布式事件指定名称,未标注时回退到 `Type.FullName` ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `LocalEventBus` | 本地事件总线实现(单例);注入 `ILocalEventBus` 使用;`Subscribe/Unsubscribe/UnsubscribeAll` + `GetEventHandlerFactories` | | `LocalDistributedEventBus` | 分布式事件总线实现(单例,可替换);在 `LocalEventBus` 之上做 Outbox/Inbox 与事件名→类型映射 | | `BrokerDistributedEventBusBase` | 面向真实消息中间件的分布式事件总线基类;抽取三个 Broker Provider 的公共逻辑(事件类型映射、订阅委派本地总线、发件箱投递、入站幂等/重试、Consumer Span)。Provider 只需实现 `InitializeAsync` 与 `PublishToBrokerAsync` | | `NullDistributedEventBus` | 分布式事件总线空实现(`Instance` 单例),所有发布/订阅均为空操作 | | `NullLocalEventBus` | 本地事件总线空实现(`Instance` 单例) | | `XiHanLocalEventBusOptions` | 本地事件总线选项:`ITypeList Handlers`(处理器类型列表) | | `XiHanDistributedEventBusOptions` | 分布式事件总线选项:`Handlers`、`OutboxConfigDictionary Outboxes`、`InboxConfigDictionary Inboxes` | | `EventBoxProcessingOptions` | 发件箱/收件箱后台处理选项;配置节 `XiHan:EventBus:EventBoxes` | | `DefaultEventOutbox` / `DefaultEventInbox` | 默认进程内事件盒实现,各最多 100000 条;满载时拒绝新增 | | `EventNameAttribute` | 为分布式事件类型指定事件名;静态 `GetNameOrDefault()` / `GetNameOrDefault(Type)` 取名或回退 `FullName` | | `GenericEventNameAttribute` | 为泛型事件类型(如 `EntityCreatedEventData`)按其唯一泛型参数动态生成事件名,可配 `Prefix` / `Postfix`;泛型参数不唯一时抛 `XiHanException` | | `DistributedEventSent` / `DistributedEventReceived` | 分布式事件发送/接收的观测型本地事件(`Source` 取 `Direct`/`Outbox`/`Inbox`),发布/接收路径上自动触发,用于旁路日志、审计、指标采集 | | `IocEventHandlerFactory` / `TransientEventHandlerFactory` / `SingleInstanceHandlerFactory` | 三种处理器工厂 | | `UnitOfWorkEventPublisher` | 工作单元事件发布者 | ## 配置 ### 事件盒后台处理 `EventBoxProcessingOptions` - **配置节名**:`XiHan:EventBus:EventBoxes`(来自 `EventBoxProcessingOptions.SectionName`) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `PollingIntervalMilliseconds` | `int` | `2000` | 后台轮询间隔(毫秒,实际下限 200ms) | | `OutboxBatchSize` | `int` | `100` | 发件箱单批处理数量 | | `InboxBatchSize` | `int` | `100` | 收件箱单批处理数量 | | `MaxInboxRetryCount` | `int` | `5` | 收件箱最大重试次数,超过则丢弃 | | `InboxRetryDelaySeconds` | `int` | `10` | 收件箱重试延迟秒数 | ```json { "XiHan": { "EventBus": { "EventBoxes": { "PollingIntervalMilliseconds": 2000, "OutboxBatchSize": 100, "InboxBatchSize": 100, "MaxInboxRetryCount": 5, "InboxRetryDelaySeconds": 10 } } } } ``` ### 事件盒实现与选择器 `XiHanDistributedEventBusOptions` 发件箱/收件箱默认名 `"Default"`、默认 `DatabaseName = "Default"`、默认实现 `DefaultEventOutbox` / `DefaultEventInbox`(`AddXiHanEventBus` 里兜底填充)。要替换实现或做事件筛选,用 `Configure`: ```csharp Configure(options => { options.Outboxes.Configure(config => { config.ImplementationType = typeof(MyDbEventOutbox); // 替换持久化实现 config.Selector = type => type.Namespace!.StartsWith("MyApp.Orders"); // 只有这些事件走此发件箱 }); options.Inboxes.Configure(config => { config.ImplementationType = typeof(MyDbEventInbox); }); }); ``` ## 使用示例 ### 1. 定义事件 + 处理器 + 发布(完整链路) ```csharp // 1) 事件 public class OrderCreatedEvent { public long OrderId { get; set; } } // 2) 处理器:实现 ILocalEventHandler<> 即被框架自动登记与订阅,无需手动 Subscribe public class OrderCreatedNotifier : ILocalEventHandler, ITransientDependency { private readonly INotifier _notifier; public OrderCreatedNotifier(INotifier notifier) => _notifier = notifier; public async Task HandleEventAsync(OrderCreatedEvent eventData) { await _notifier.SendAsync($"订单 {eventData.OrderId} 已创建"); } } // 3) 发布(注入 ILocalEventBus) public class OrderAppService { private readonly ILocalEventBus _localEventBus; public OrderAppService(ILocalEventBus localEventBus) => _localEventBus = localEventBus; public async Task CreateAsync() { // onUnitOfWorkComplete 默认 true:在当前工作单元完成后再发布 await _localEventBus.PublishAsync(new OrderCreatedEvent { OrderId = 1001 }); } } ``` ### 2. 分布式事件(走 Outbox 可靠投递) ```csharp [EventName("MyApp.Orders.OrderShipped")] // 指定稳定的事件名(跨服务) public class OrderShippedEvent { public long OrderId { get; set; } } public class OrderShippedHandler : IDistributedEventHandler, ITransientDependency { public Task HandleEventAsync(OrderShippedEvent eventData) => Task.CompletedTask; } // 发布:useOutbox 默认 true —— 先落发件箱,后台服务异步投递 await _distributedEventBus.PublishAsync(new OrderShippedEvent { OrderId = 1001 }); ``` ## 扩展点 / 自定义 - **替换分布式事件总线为真正的 MQ**:框架已提供三个开箱即用的 Broker 提供程序——[EventBus.RabbitMQ](./eventbus-rabbitmq)、[EventBus.Kafka](./eventbus-kafka)、[EventBus.Redis](./eventbus-redis),装上并 `[DependsOn]` 对应模块即可,业务代码无需改动。原理:默认 `LocalDistributedEventBus` 用 `[Dependency(TryRegister = true)]` 注册,Provider 走常规注册并在其后追加,DI 解析取最后一条注册,故 Provider 胜出。自己实现 `IDistributedEventBus` 同理。 - **替换发件箱/收件箱为持久化实现**:默认内存实现用 `TryAddSingleton` 注册;把你的 `IEventOutbox` / `IEventInbox`(如落 SqlSugar/DB)注册进容器,并在 `XiHanDistributedEventBusOptions.Outboxes/Inboxes` 的 `ImplementationType` 指向它即可。 - **事件命名**:跨服务事件建议加 `[EventName("稳定名字")]`,避免依赖 `Type.FullName`(重构改名会破坏契约)。 - **事件筛选/多事件盒**:用 `OutboxConfigDictionary.Configure("名字", ...)` 配多个命名事件盒,配 `Selector` 做事件类型路由。 ## 注意事项与最佳实践 - **本地/分布式处理器必须进入 `Options.Handlers` 才会被订阅(最重要)**。运行期 `LocalEventBus` 构造时对 `Options.Handlers` 逐个 `SubscribeHandlers`、`LocalDistributedEventBus` 逐个 `Subscribe`;而 `Handlers` 的填充来自 `AddXiHanEventBus` 里 `services.OnRegistered(...)` 钩子——它在服务注册阶段识别实现了 `ILocalEventHandler<>` / `IDistributedEventHandler<>` 的类型并 `AddIfNotContains` 进 `Handlers`。这意味着处理器要**走框架的模块注册管线**才会被登记(例如实现 `ITransientDependency`/被约定扫描注册,或以能触发 `OnRegistered` 的方式加入 `IServiceCollection`)。**若绕过这条链路——例如手动裸 `AddTransient(具体处理器类型)` 而未被 `OnRegistered` 捕获,或在钩子注册之后才加入——它不会进入 `Handlers`,也就不会被订阅,事件会静默无人处理、且不报错。** 排查"处理器没触发"时,第一步就是确认它有没有进 `Handlers`。 - **发布默认跟随工作单元**:有当前 UoW 时,事件先记录进 UoW,直到 UoW 成功提交才由 `UnitOfWorkEventPublisher` 真正发布;UoW 回滚则事件不发。要"立即发布、不等 UoW",显式传 `onUnitOfWorkComplete: false`。 - **分布式默认走 Outbox**:`useOutbox` 默认 `true`,发布是异步的(先入发件箱,后台再投递),不要期望调用返回后处理器已执行完。要同步直投可传 `useOutbox: false`。 - **默认实现是内存版**:`DefaultEventOutbox` / `DefaultEventInbox` 进程重启即丢,仅适合单机/开发;生产的"不丢/去重"承诺需替换为持久化实现。 - **处理器执行顺序**:同一事件的多个本地处理器按 `LocalEventHandlerOrderAttribute.Order` 升序执行,未标注默认 0;顺序相同则不保证稳定顺序。 - **事件继承会被触发**:基于子类型发布时,订阅了其父类型/接口的处理器也会被触发(`ShouldTriggerEventForHandler` 用 `IsAssignableFrom` 判断)。 ## 依赖模块 - [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions)(接口契约) - [XiHan.Framework.Messaging](./messaging) - [XiHan.Framework.Uow](./uow)(工作单元集成) - [XiHan.Framework.DistributedIds](./distributed-ids)(事件/关联 Id 生成) - [XiHan.Framework.Core](./core) ## 相关模块 - [XiHan.Framework.EventBus.Abstractions](./eventbus-abstractions) - [XiHan.Framework.EventBus.RabbitMQ](./eventbus-rabbitmq)(分布式事件走 RabbitMQ) - [XiHan.Framework.EventBus.Kafka](./eventbus-kafka)(分布式事件走 Kafka) - [XiHan.Framework.EventBus.Redis](./eventbus-redis)(分布式事件走 Redis Streams) - [XiHan.Framework.Messaging](./messaging) - [XiHan.Framework.Uow](./uow) --- 来源:https://framework.docs.xihanfun.com/packages/http # XiHan.Framework.Http > 基于 `IHttpClientFactory` 与 Polly 的高级 HTTP 客户端:统一结果封装、韧性策略(重试/超时/熔断)、字符串链式请求、上传下载、批量并发与代理池。 - **NuGet**:`XiHan.Framework.Http` - **模块类**:`XiHanHttpModule` - **所在层**:基础设施层 - **关键依赖**:**Polly**(经 `Microsoft.Extensions.Http.Polly`,提供重试/超时/熔断策略);框架内部依赖 Core / Serialization ## 概述 这个包在 `IHttpClientFactory` 之上封装了一套「开箱即用」的 HTTP 客户端。它替你把命名客户端的注册、超时、请求/响应日志、以及基于 **Polly** 的重试、超时、熔断策略都配置好,所有调用都返回统一的 `HttpResult`(成功/失败、状态码、数据、耗时、异常一体)。 对外提供两种入口:一是注入高级服务 `IAdvancedHttpService` 直接发各类请求(全 HTTP 动词、JSON/表单/字节/流、文件上传下载带进度、批量并发);二是字符串链式扩展 `"url".AsHttp()...`,无需注入即可流式构建并发送请求。此外内置可选的代理池管理与后台健康检查。 ## 何时使用 - 需要调用外部 API,又不想手写 `HttpClient` 注册、超时、重试、熔断这些样板。 - 需要开箱即用的韧性:瞬时错误自动重试、失败过多自动熔断,且能按单次请求临时关闭。 - 需要统一的请求/响应日志、文件上传下载(带进度)、批量并发请求。 - 需要通过代理池发请求,并对代理做可用性校验与健康检查。 - 需要在没有构造函数注入的静态/工具代码里,用 `"url".AsHttp()` 快速发请求。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Http ``` ```csharp [DependsOn(typeof(XiHanHttpModule))] public class MyModule : XiHanModule { } ``` 模块启用后: - `ConfigureServices` 调用 `AddXiHanHttpModule(configuration)`:绑定 `XiHanHttpClientOptions`(配置节 `XiHan:Http`)和 `XiHanProxyPoolOptions`(配置节 `XiHan:Http:ProxyPool`);注册 `IProxyValidator`/`IProxyPoolManager`(Singleton)、`IAdvancedHttpService`(Scoped);配置 `Remote`、`Local` 及配置里声明的自定义命名客户端;当代理池同时启用 `Enabled` 与 `EnableHealthCheck` 时注册后台服务 `ProxyPoolHealthCheckService`。 - `OnApplicationInitialization` 调用 `StringHttpExtensions.Initialize(context.ServiceProvider)`,让字符串链式扩展 `AsHttp()` 可用(内部缓存 `IAdvancedHttpService` 实例)。 > `XiHanHttpModule` 依赖 `XiHanSerializationModule`,会随之一并启用。 ## 工作原理 - **命名客户端与分组**:模块为 `HttpGroupEnum.Remote`(默认,纯远程调用)和 `HttpGroupEnum.Local`(`BaseAddress = http://127.0.0.1`,本地服务间调用)各注册一个命名 `HttpClient`,再为 `XiHanHttpClientOptions.Clients` 里声明的每个自定义客户端注册一个。发请求时默认用 `Remote`;若请求选项里带了 `ClientName` 标签(由 `UseClient(name)` 设置),则改用该命名客户端。 - **韧性策略(Polly)**:每个客户端按顺序挂三段策略—— - **重试**:`HttpPolicyExtensions.HandleTransientHttpError().Or().WaitAndRetryAsync(delays)`,延迟序列来自 `RetryDelaySeconds`(如 `[1,5,10]` 即重试 3 次,间隔 1/5/10 秒)。 - **超时**:`Policy.TimeoutAsync(2 * DefaultTimeoutSeconds)`。 - **熔断**:`HandleTransientHttpError().CircuitBreakerAsync(CircuitBreakerFailureThreshold, TimeSpan.FromSeconds(CircuitBreakerDurationOfBreakSeconds), ...)`。 - **按请求关闭策略**:重试与熔断用「动态策略选择器」注册——它读取 `HttpRequestMessage.Options` 里的 `EnableRetry`/`EnableCircuitBreaker` 标志,若为 `false` 则退化成 `Policy.NoOpAsync`。因此 `WithoutRetry()` / `WithoutCircuitBreaker()` 能在不改全局配置的前提下,对单次请求关闭对应策略。 - **日志中间件**:每个客户端挂一个 `HttpLoggingMiddleware`(`DelegatingHandler`),按 `EnableRequestLogging`/`EnableResponseLogging` 记录请求/响应,响应体按 `MaxResponseContentLength` 截断;`Authorization`、`Cookie`、`X-API-Key` 等敏感头默认脱敏为 `***`,仅当 `LogSensitiveData=true` 时明文输出。请求级 `LogRequest`/`LogResponse` 可覆盖全局开关。 - **代理**:请求选项可直接指定 `Proxy`,或设 `UseProxyPool=true` 从 `IProxyPoolManager` 取下一个代理(需注入了代理池管理器)。 ## 核心能力 - **高级 HTTP 服务** `IAdvancedHttpService`:GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS,JSON/表单/字节/流,文件上传(单/多,带附加字段)、下载(带进度回调)、批量并发请求,统一返回 `HttpResult`。 - **字符串链式扩展** `AsHttp()` + `HttpRequestBuilder`:`"https://api...".AsHttp().SetHeader(...).SetJsonBody(...).PostAsync()`,无需注入即可发请求。 - **请求选项流式扩展**(`HttpServiceExtensions`):`WithAuthorization` / `WithBasicAuth` / `AsJson` / `AsXml` / `AsForm` / `WithUserAgent` / `WithCorrelationId` / `WithLanguage` / `WithoutRetry` / `WithoutCircuitBreaker` / `WithoutCache` 等。 - **结果扩展**:`GetDataOrThrow` / `GetDataOrDefault` / `IsSuccessStatusCode` / `IsClientError` / `IsServerError` / `GetHeader` / `GetContentType` / `GetContentLength`。 - **便捷方法**(`HttpServiceConvenienceExtensions`):`QuickGetAsync` / `QuickPostAsync` / `QuickPutAsync` / `QuickDeleteAsync` / `GetPagedAsync`(可选传 `token` 自动加授权头)。 - **Polly 韧性策略**:重试 + 超时 + 熔断,可按请求上下文关闭。 - **命名客户端分组** `HttpGroupEnum`:`Remote` / `Local` 及自定义客户端,各自独立超时/请求头/策略。 - **代理池**:`IProxyPoolManager` / `IProxyValidator` + `ProxyPoolHealthCheckService` 后台健康检查,支持多种选择策略。 ## 主要 API / 类型 ### 高级服务 `IAdvancedHttpService` 所有方法均可传可选的 `XiHanHttpRequestOptions? options` 与 `CancellationToken`。 | 方法 | 说明 | | --- | --- | | `Task> GetAsync(string url, ...)` | GET,反序列化为 `T` | | `Task> GetStringAsync(string url, ...)` | GET,返回原始字符串 | | `Task> GetBytesAsync(string url, ...)` | GET,返回字节数组 | | `Task> GetStreamAsync(string url, ...)` | GET,返回流 | | `Task> PostAsync(string url, TRequest request, ...)` | POST 对象(JSON 序列化) | | `Task> PostJsonAsync(string url, string jsonContent, ...)` | POST 原始 JSON 字符串 | | `Task> PostFormAsync(string url, Dictionary formData, ...)` | POST 表单 | | `Task> PutAsync(string url, TRequest request, ...)` | PUT | | `Task> PatchAsync(string url, TRequest request, ...)` | PATCH | | `Task> DeleteAsync(string url, ...)` / `Task DeleteAsync(string url, ...)` | DELETE(有/无响应体) | | `Task HeadAsync(string url, ...)` / `Task OptionsAsync(string url, ...)` | HEAD / OPTIONS | | `Task> UploadFileAsync(string url, Stream fileStream, string fileName, string fieldName = "file", Dictionary? additionalData = null, ...)` | 上传单文件 | | `Task> UploadFilesAsync(string url, IEnumerable files, Dictionary? additionalData = null, ...)` | 上传多文件 | | `Task DownloadFileAsync(string url, string destinationPath, IProgress? progress = null, ...)` | 下载到文件,带进度回调 | | `Task>> BatchRequestAsync(IEnumerable requests, int maxConcurrency = 10, ...)` | 批量并发请求 | ### 结果封装 `HttpResult` / `HttpResult` | 成员 | 说明 | | --- | --- | | `bool IsSuccess` | 是否成功 | | `HttpStatusCode StatusCode` | HTTP 状态码 | | `T? Data` | 反序列化后的数据 | | `string? RawDataString` | 原始响应字符串 | | `string? ErrorMessage` / `Exception? Exception` | 错误消息 / 异常 | | `Dictionary> Headers` | 响应头 | | `long ElapsedMilliseconds` | 请求耗时(毫秒) | | `string? RequestUrl` / `string? RequestMethod` | 请求 Url / 方法 | | `static HttpResult Success(T data, ...)` / `static HttpResult Failure(string errorMessage, ...)` | 工厂方法 | `HttpResult`(无泛型)继承 `HttpResult`,用于无响应体的 HEAD/OPTIONS/DELETE 等。 ### 字符串链式 `HttpRequestBuilder`(由 `AsHttp()` 创建) - 头/查询:`SetHeader` / `SetHeaders` / `SetQuery` / `SetQueries` - 认证:`SetAuthorization(token, scheme = "Bearer")` / `SetBasicAuth(username, password)` - 请求体:`SetBody(object, contentType)` / `SetBodyContent(string, contentType)` / `SetJsonBody(object)` / `SetFormData(Dictionary)` - 控制:`SetTimeout(TimeSpan/int)` / `UseClient(clientName)` / `WithoutRetry()` / `WithoutCircuitBreaker()` / `WithoutCache()` / `WithVerboseLogging()` / `WithoutLogging()` / `ValidateSslCertificate(bool)` / `IgnoreSslErrors()` - 元数据:`SetRequestId` / `SetCorrelationId` / `SetUserAgent` / `SetLanguage` / `AddTag` - 发送:`GetAsync` / `GetStringAsync` / `GetDynamicAsync` / `GetBytesAsync` / `PostAsync` / `PostStringAsync` / `PostDynamicAsync` / `PutAsync` / `PatchAsync` / `DeleteAsync` / `DeleteAsync` / `HeadAsync` / `OptionsAsync` / `DownloadAsync` > `string` 上也有一批直达扩展(`StringHttpExtensions`),如 `"url".GetAsync()`、`"url".PostAsync(body)`、`"url".PutAsync(body)`、`"url".DeleteAsync()`、`"url".DownloadAsync(path)`,以及返回 `HttpRequestBuilder` 的 `SetHeader`/`SetQuery`/`SetJsonBody`/`SetFormData`/`SetTimeout`/`UseClient` 等,用于继续链式。 ### 代理池 | 类型 | 关键成员 | | --- | --- | | `IProxyPoolManager` | `GetNextProxy()`、`GetAvailableProxies()`、`Task AddProxyAsync(ProxyConfiguration)`、`RemoveProxy(string)`、`RecordProxyResult(string, bool, long)`、`MarkProxyUnavailable/Available(string)`、`GetProxyStatistics/GetAllStatistics`、`Task StartHealthCheckAsync(...)`、`StopHealthCheck()`、`GetPoolSize()`、`GetAvailableCount()` | | `IProxyValidator` | `Task ValidateAsync(ProxyConfiguration, string testUrl, int timeoutSeconds = 10, ...)`、`Task> ValidateBatchAsync(..., int maxConcurrency = 10, ...)` | | `ProxyPoolHealthCheckService` | `BackgroundService`,按 `HealthCheckIntervalSeconds` 周期校验代理健康(仅在启用时注册) | | `ProxyConfiguration` | 代理配置:`Host`/`Port`/`Type`/`Username`/`Password`/`Priority`/`Enabled` 等,`GetProxyAddress()`/`GetProxyUri()`/`Validate()` | | `ProxySelectionStrategy` | `RoundRobin` / `Random` / `LeastUsed` / `FastestResponse` / `Priority` | | `ProxyType` | `Http` / `Https` / `Socks4` / `Socks4A` / `Socks5` | ## 配置 主配置节 `XiHan:Http`(`XiHanHttpClientOptions`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `DefaultTimeoutSeconds` | `int` | `60` | 默认超时(秒),范围 1–300;超时策略取其 2 倍 | | `RetryCount` | `int` | `3` | 重试次数(范围 0–10) | | `RetryDelaySeconds` | `int[]` | `[1, 5, 10]` | 每次重试的间隔序列(秒),决定实际重试次数 | | `CircuitBreakerFailureThreshold` | `int` | `5` | 熔断失败阈值(范围 1–100) | | `CircuitBreakerSamplingDurationSeconds` | `int` | `60` | 熔断采样窗口(秒) | | `CircuitBreakerMinimumThroughput` | `int` | `10` | 熔断最小吞吐量 | | `CircuitBreakerDurationOfBreakSeconds` | `int` | `10` | 熔断断开持续时间(秒) | | `EnableRequestLogging` | `bool` | `true` | 是否记录请求日志 | | `EnableResponseLogging` | `bool` | `true` | 是否记录响应日志 | | `LogSensitiveData` | `bool` | `false` | 是否记录敏感数据(否则敏感头脱敏) | | `MaxResponseContentLength` | `int` | `4096` | 日志中响应内容最大长度 | | `ClientLifetimeMinutes` | `int` | `5` | 命名客户端处理器生存期(分钟) | | `IgnoreSslErrors` | `bool` | `false` | 是否忽略 SSL 证书错误 | | `DefaultHeaders` | `Dictionary` | 空 | 所有客户端的默认请求头 | | `Clients` | `Dictionary` | 空 | 自定义命名客户端集合 | 每个自定义客户端 `HttpClientConfiguration`:`BaseAddress`、`TimeoutSeconds?`、`Headers`、`EnableRetry`(默认 `true`)、`EnableCircuitBreaker`(默认 `true`)、`IgnoreSslErrors?`。 代理池配置节 `XiHan:Http:ProxyPool`(`XiHanProxyPoolOptions`):`Enabled`(默认 false)、`SelectionStrategy`、`Proxies`、`EnableHealthCheck`(默认 true)、`HealthCheckIntervalSeconds`(60)、`HealthCheckTimeoutSeconds`(10)、`HealthCheckUrl`(默认 `https://www.google.com`)、`MaxRetryCount`(3)、`FailureThreshold`(5)、`RecoveryTimeSeconds`(300)、`ValidateOnStartup`(true)、`AutoRemoveFailedProxy`(false)、`StatisticsRetentionSeconds`(86400) 等。仅当 `Enabled && EnableHealthCheck` 时才注册后台健康检查服务。 ### 示例 appsettings.json ```json { "XiHan": { "Http": { "DefaultTimeoutSeconds": 60, "RetryDelaySeconds": [1, 5, 10], "CircuitBreakerFailureThreshold": 5, "CircuitBreakerDurationOfBreakSeconds": 10, "EnableRequestLogging": true, "LogSensitiveData": false, "IgnoreSslErrors": false, "DefaultHeaders": { "User-Agent": "XiHan/1.0" }, "Clients": { "github": { "BaseAddress": "https://api.github.com", "TimeoutSeconds": 30, "Headers": { "Accept": "application/vnd.github+json" }, "EnableRetry": true, "EnableCircuitBreaker": true } }, "ProxyPool": { "Enabled": false, "SelectionStrategy": "RoundRobin", "EnableHealthCheck": true, "HealthCheckIntervalSeconds": 60, "Proxies": [] } } } } ``` ## 使用示例 注入 `IAdvancedHttpService` 发请求,并对单次请求关闭重试: ```csharp public class WeatherClient { private readonly IAdvancedHttpService _http; public WeatherClient(IAdvancedHttpService http) { _http = http; } public async Task GetAsync(string city) { var options = new XiHanHttpRequestOptions() .WithAuthorization("your-token") // 加 Bearer 授权头 .WithoutRetry(); // 仅本次关闭重试 var result = await _http.GetAsync( $"https://api.example.com/weather/{city}", options); return result.GetDataOrDefault(); // 失败返回 null } } ``` 字符串链式扩展(应用初始化后可用),指定自定义客户端并发 POST: ```csharp var result = await "/repos/owner/name/issues" .AsHttp() .UseClient("github") // 用 appsettings 里声明的命名客户端 .SetJsonBody(new { title = "bug", body = "..." }) .PostAsync(); if (result.IsSuccess) { var issue = result.Data; } ``` 下载文件并上报进度: ```csharp var progress = new Progress(bytes => Console.WriteLine($"已下载 {bytes} 字节")); await "https://example.com/large.zip" .AsHttp() .DownloadAsync(@"D:\downloads\large.zip", progress); ``` ## 扩展点 / 自定义 - **替换默认实现**:`IProxyValidator` / `IProxyPoolManager` / `IAdvancedHttpService` 均以接口注册,可在模块中 `Replace` 为自定义实现。 - **新增命名客户端**:往 `XiHan:Http:Clients` 配置节增加条目即可(含独立 `BaseAddress`、超时、请求头、重试/熔断开关),调用侧用 `UseClient(name)` 选用;无需改代码。 - **按请求覆盖行为**:通过 `XiHanHttpRequestOptions` 及其扩展在单次请求上覆盖超时、日志、重试/熔断、认证、代理等,而不动全局配置。 ## 注意事项与最佳实践 - **`AsHttp()` 需先初始化**:字符串链式扩展依赖 `StringHttpExtensions.Initialize(...)`(由模块 `OnApplicationInitialization` 自动完成)。在应用完成初始化前调用会抛 `InvalidOperationException`。因此单元测试或极早期启动代码里优先注入 `IAdvancedHttpService`。 - **超时是「策略超时」的 2 倍**:Polly 超时策略取 `2 * DefaultTimeoutSeconds`,而 `HttpClient.Timeout` 本身取 `DefaultTimeoutSeconds`;排查超时时两者都要看。 - **重试次数以 `RetryDelaySeconds` 长度为准**:`RetryCount` 字段存在但实际重试次数由 `RetryDelaySeconds` 数组长度决定。 - **敏感头默认脱敏**:`Authorization`、`Cookie`、`X-API-Key`、`Proxy-Authorization` 等在日志中显示为 `***`,仅 `LogSensitiveData=true` 才明文;请求体也仅在该开关开启时记录。 - **代理池默认关闭**:`UseProxyPool=true` 仅在容器中存在可用的 `IProxyPoolManager` 且代理池已配置代理时才生效;后台健康检查仅在 `Enabled && EnableHealthCheck` 时注册。 - **HTTPS 代理需 `Local`/`Remote` 之外的处理**:SOCKS 代理类型由 `ProxyType` 表达,实际连通性以 `IProxyValidator.ValidateAsync` 校验为准。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.Serialization](./serialization)(请求/响应 JSON 及动态对象序列化) - 第三方核心:**Polly**(经 `Microsoft.Extensions.Http.Polly`) ## 相关模块 - [XiHan.Framework.Serialization](./serialization) - [XiHan.Framework.Core](./core) --- 来源:https://framework.docs.xihanfun.com/packages/ # 模块总览 XiHan.Framework 由一组**可独立引用的 NuGet 包**组成,按分层组织。本册是**参考手册**——每个包一页,写完整的 API 清单与配置项全表。 ::: tip 先看指南还是先看这里 文档分两册,解决的问题不同: | 你的问题 | 去哪 | | --- | --- | | 「**我要做 X**」——数据访问、事务、认证、缓存、多租户… | [开发指南](../guide/modularity)(38 章,按能力域组织) | | 「**这个包有什么**」——完整 API、全部配置项、类型清单 | **本册** | 指南章节末尾都会链到对应的包页。不确定从哪开始就先读[快速上手](../quickstart)。 ::: > 命名约定:`XiHan.Framework.[模块名]` 为通用类库,`XiHan.Framework.Web.[模块名]` 为 Web 功能。安装即 `dotnet add package <包名>`。 ## 如何选择要装哪些包 - 只想跑个 Web API?→ `Web.Api` + `Web.Docs`(依赖会自动带上 Core 等底层) - 要读写数据库?→ 加 `Data` - 要缓存/认证/授权?→ 按需加 `Caching` / `Authentication` / `Authorization` 大多数包无需单独安装——它们会作为上层包的 `[DependsOn]` 依赖被自动引入。 --- ## 1. 公共与核心 框架的地基,几乎所有上层模块都依赖它们。 | 模块 | 说明 | | --- | --- | | [Utils](./utils) | 零依赖通用工具库:字符串、加密、异步、序列化、集合、反射、网络、文件 IO、数学、时间等 | | [Metadata](./metadata) | 框架元数据:名称、版本、作者、组织、支持平台等静态信息 | | [Core](./core) | 模块化引擎核心:`XiHanModule`、`[DependsOn]`、拓扑排序加载、生命周期钩子、DI 扩展、选项模式、异常处理 | | [Analyzers](./analyzers) | Roslyn 代码分析器:编译期规范检查 | ## 2. 领域与应用 DDD 分层与应用服务契约。 | 模块 | 说明 | | --- | --- | | [Domain.Shared](./domain-shared) | 领域共享模型:基础实体类型、枚举、常量、值对象、异常 | | [Domain](./domain) | DDD 领域层:聚合根、实体、领域服务、领域事件、规约、仓储抽象、业务规则引擎 | | [Application.Contracts](./application-contracts) | 应用服务契约:DTO 定义、应用服务接口 | | [Application](./application) | 应用层实现:应用服务基类、CRUD 基类、DTO 映射、动态 API 特性 | ## 3. 数据与持久化 | 模块 | 说明 | | --- | --- | | [Data](./data) | SqlSugar 数据访问:仓储模式、工作单元集成、多租户数据隔离、启动自动建表 | | [Uow](./uow) | 工作单元:AOP 拦截器自动管理事务边界 | | [Caching](./caching) | 混合缓存:HybridCache(内存 + Redis)、缓存拦截器、租户感知 | ## 4. 安全 · 认证 · 授权 | 模块 | 说明 | | --- | --- | | [Security](./security) | 安全与加密:BouncyCastle 企业级密码学、密钥管理、密码哈希、数据保护 | | [Authentication](./authentication) | 认证:JWT / OAuth2 / OIDC、令牌工厂、MFA、SSO | | [Authorization](./authorization) | 授权:RBAC、策略授权、声明授权 | ## 5. 多租户 · 配置 · 校验 | 模块 | 说明 | | --- | --- | | [MultiTenancy.Abstractions](./multitenancy-abstractions) | 多租户抽象:租户上下文接口、解析链 | | [MultiTenancy](./multitenancy) | 多租户:租户解析中间件、数据隔离、租户配置管理、生命周期 | | [Settings](./settings) | 设置管理:设置定义提供者模式、动态配置、多来源(租户级别) | | [Validation.Abstractions](./validation-abstractions) | 校验抽象:校验异常与错误承载接口 | | [Validation](./validation) | 数据校验:当前为薄占位,核心类型在抽象包 | ## 6. 事件 · 消息 · 通信 | 模块 | 说明 | | --- | --- | | [EventBus.Abstractions](./eventbus-abstractions) | 事件总线抽象:发布/订阅接口、事件处理管道 | | [EventBus](./eventbus) | 事件总线:本地/分布式事件、Outbox 模式、事件存储 | | [EventBus.RabbitMQ](./eventbus-rabbitmq) | 分布式事件总线的 RabbitMQ 提供程序:direct 交换机 + 事件名路由键、队列竞争消费 | | [EventBus.Kafka](./eventbus-kafka) | 分布式事件总线的 Kafka 提供程序:单主题 + 事件名作 Key、消费者组竞争消费 | | [EventBus.Redis](./eventbus-redis) | 分布式事件总线的 Redis Streams 提供程序:`XADD` / `XREADGROUP` 消费者组竞争消费 | | [Messaging](./messaging) | 消息处理:消息代理抽象(发布/消费/路由) | | [Http](./http) | HTTP 客户端:Polly 韧性策略(重试/熔断)、请求管道 | ## 7. 通用基础设施 | 模块 | 说明 | | --- | --- | | [Serialization](./serialization) | 序列化:基于 System.Text.Json 的动态 JSON 操作、序列化选项组合 | | [ObjectMapping](./objectmapping) | 对象映射:Mapster 集成 | | [Localization.Abstractions](./localization-abstractions) | 国际化抽象:`IStringLocalizer` 抽象层 | | [Localization](./localization) | 国际化:多语言资源文件、动态文化切换 | | [Logging](./logging) | 结构化日志:Serilog 集成、文件/控制台输出、异步写入 | | [Auditing](./auditing) | 审计日志:6 类日志记录、Channel 异步队列 + 批量消费者、脱敏器、写入器契约(默认空实现) | | [Castle](./castle) | AOP 动态代理:Castle DynamicProxy 集成、服务拦截器注册 | | [Threading](./threading) | 并发辅助:取消令牌提供者、基于 AsyncLocal 的环境作用域 | | [Timing](./timing) | 时间策略:时区管理、时间抽象 | | [DistributedIds](./distributed-ids) | 分布式 ID:Snowflake / NanoId / SequentialGuid / Sqids 多方案 | ## 8. 存储 · 模板 · 任务 · 治理 | 模块 | 说明 | | --- | --- | | [ObjectStorage](./object-storage) | 对象存储:统一抽象 + 本地 / 阿里云 OSS / MinIO / 腾讯 COS 适配 | | [VirtualFileSystem](./virtual-file-system) | 虚拟文件系统:本地目录 + 程序集嵌入资源统一挂载、内存版本快照 | | [Templating](./templating) | 模板渲染:默认简单占位替换引擎 + 可选 Scriban、模板注册表 | | [Tasks](./tasks) | 定时任务:调度引擎(Cron/间隔/延迟)、后台服务基类、多租户感知 | | [Traffic](./traffic) | 流量治理:灰度路由(百分比/用户/租户/请求头)、限流与熔断策略接口 | | [Upgrade](./upgrade) | 升级引擎:版本存储、迁移执行、分布式锁、启动自动检查 | | [Script](./script) | 脚本引擎:基于 Roslyn 的 C# 动态脚本、编译校验与超时 | | [Workflow.Abstractions](./workflow-abstractions) | 工作流抽象:流程定义模型、活动契约、书签与存储端口 | | [Workflow](./workflow) | 工作流引擎:图执行、17 个内置活动、人工任务(审批)、表达式、定时器 | | [SearchEngines.Abstractions](./search-engines-abstractions) | 搜索抽象:索引/文档/检索契约,零第三方依赖 | | [SearchEngines](./search-engines) | 搜索默认实现:进程内引擎(开发与测试兜底) | | [SearchEngines.Elasticsearch](./search-engines-elasticsearch) | 搜索实现:Elasticsearch(生产推荐) | | [Observability](./observability) | 可观测性:OpenTelemetry、指标采集、性能监控、运行时诊断与内存健康检查 | | [DevTools](./devtools) | 开发工具:自研命令行(CLI)框架,开发期辅助 | ## 9. AI 与机器人 | 模块 | 说明 | | --- | --- | | [AI.Abstractions](./ai-abstractions) | AI 抽象:AI 服务接口与契约 | | [AI](./ai) | AI 集成:LLM 接入(OpenAI 兼容)、智能代理、MCP 协议、RAG | | [Bot](./bot) | 机器人核心:多平台消息接入的统一抽象 | | [Bot.Email](./bot-email) | 邮件通道:MailKit 集成 | | [Bot.Sms](./bot-sms) | 短信通道 | | [Bot.Telegram](./bot-telegram) | Telegram 机器人 | | [Bot.DingTalk](./bot-dingtalk) | 钉钉机器人 | | [Bot.Lark](./bot-lark) | 飞书机器人 | | [Bot.WeCom](./bot-wecom) | 企业微信机器人 | ## 10. Web 层 | 模块 | 说明 | | --- | --- | | [Web.Core](./web-core) | Web 基础设施:托管环境、中间件管道、CORS、IP 地理定位(ip2region)、UA 解析 | | [Web.Api](./web-api) | 动态 API:自动 API 发现与注册、OpenAPI 安全、完整中间件管道 | | [Web.Docs](./web-docs) | API 文档:Scalar UI + Swagger UI、动态 API 分组发现 | | [Web.Gateway](./web-gateway) | API 网关:灰度路由(依赖 Traffic)、请求追踪、限流熔断 | | [Web.Grpc](./web-grpc) | gRPC 服务集成 | | [Web.Mcp](./web-mcp) | MCP Server:把 AI 技能暴露为 HTTP MCP tools,并以 API Key fail-closed 鉴权 | | [Web.RealTime](./web-realtime) | 实时通信:SignalR 集成、JSON 序列化 | --- ## 依赖关系速览 核心依赖链(从底层到上层): ```text Utils(零依赖) └ Metadata └ Core ├ Serialization ├ Security → Authentication → Authorization │ └ Auditing(+ MultiTenancy.Abstractions) ├ Threading / Timing / DistributedIds ├ VirtualFileSystem → Localization ├ Uow → Caching(+Redis) / EventBus │ └ EventBus.RabbitMQ / EventBus.Kafka / EventBus.Redis ├ Domain.Shared → Domain → Data(SqlSugar) │ └ Application.Contracts → Application ├ MultiTenancy.Abstractions → MultiTenancy → Tasks / Traffic / Upgrade │ └ Workflow.Abstractions → Workflow(+Caching/EventBus/Script/Timing) ├ SearchEngines.Abstractions → SearchEngines → SearchEngines.Elasticsearch ├ Http(+Polly) → AI(Agents.AI + MCP) → Bot(MailKit + Telegram) └ Web.Core → Web.Api → Web.Docs / Web.Gateway / Web.Grpc / Web.Mcp / Web.RealTime ``` > 你只需在应用模块上 `[DependsOn]` 顶层包,整条依赖链会被框架自动装配。详见 [核心概念 · 模块系统](../guide/modularity)。 --- 来源:https://framework.docs.xihanfun.com/packages/localization-abstractions # XiHan.Framework.Localization.Abstractions > 国际化抽象层:可本地化字符串与枚举本地化的接口契约,实现由 XiHan.Framework.Localization 提供。 - **NuGet**:`XiHan.Framework.Localization.Abstractions` - **模块类**:`XiHanLocalizationAbstractionsModule` - **所在层**:基础设施层 - **关键依赖**:`Microsoft.Extensions.Localization.Abstractions`(复用其 `IStringLocalizerFactory`、`IStringLocalizer`、`LocalizedString` 标准抽象)+ 框架内部依赖 `XiHan.Framework.Core` ## 概述 本包是国际化能力的**薄抽象层**:只定义接口和数据模型(可本地化字符串、枚举本地化查询与结果),不含任何资源加载、文化解析、JSON 解析等具体逻辑。它建立在微软标准的 `Microsoft.Extensions.Localization.Abstractions` 之上——`ILocalizableString.Localize` 直接接受微软的 `IStringLocalizerFactory` 并返回 `LocalizedString`,因此与 ASP.NET Core 原生本地化基础设施天然互通。真正的实现(JSON 优先加载、请求文化中间件、枚举本地化服务)由 [XiHan.Framework.Localization](./localization) 提供。上层模块(如 [XiHan.Framework.ObjectMapping](./objectmapping))只依赖这个薄抽象包,即可在契约层描述"可本地化文本"而不牵连完整实现。 ## 何时使用 - 你的模块只想引用本地化的**接口契约**,而不想直接依赖具体实现包。 - 需要用一个可能带多语言的文本占位,用 `ILocalizableString` 表达(延迟到有 `IStringLocalizerFactory` 时再解析)。 - 定义"标识名 + 可本地化显示名"分离的对象(`IHasNameWithLocalizableDisplayName`),例如权限、菜单、功能项。 - 需要枚举字段级本地化的**查询契约**(`IEnumLocalizationService`),而实现放在下游。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Localization.Abstractions ``` ```csharp [DependsOn(typeof(XiHanLocalizationAbstractionsModule))] public class MyModule : XiHanModule { } ``` `XiHanLocalizationAbstractionsModule.ConfigureServices` **不注册任何具体服务**(仅作为纯抽象声明入口存在)。要实际取值,必须引入实现包 [XiHan.Framework.Localization](./localization)——它提供 `IEnumLocalizationService` 与 `IStringLocalizerFactory` 的真实实现。 ## 核心能力 - **可本地化字符串抽象**:`ILocalizableString` 定义单一契约 `Localize(IStringLocalizerFactory) -> LocalizedString`,把"如何取值"延迟到运行期。 - **两种内置实现**:`ResourceLocalizableString`(按资源类型/资源名 + 键解析,支持格式化参数)与 `FixedLocalizableString`(固定文本,直接原样返回,`ResourceNotFound=false`)。 - **名称与显示名分离**:`IHasNameWithLocalizableDisplayName` 约定不本地化的标识名 `Name` 与可本地化的显示名 `DisplayName`。 - **取值/回退扩展**:`LocalizableStringExtensions` 提供缺失资源时的降级取值与显示名便捷方法。 - **枚举本地化契约与模型**:`IEnumLocalizationService` 及 `EnumLocalizationQuery` / `LocalizedEnumItem` / `LocalizedEnumDefinition` 描述枚举字段级本地化的查询参数与结果结构。 ## 主要 API / 类型 ### 可本地化字符串 | 类型 | 说明 | | --- | --- | | `ILocalizableString` | 核心契约。方法 `LocalizedString Localize(IStringLocalizerFactory stringLocalizerFactory)` | | `ResourceLocalizableString` | 按资源解析。两种构造:`(Type resourceType, string name, params object[] arguments)` 与 `(string resourceName, string name, params object[] arguments)`;属性 `ResourceType`、`ResourceName`、`Name`、`Arguments` | | `FixedLocalizableString` | 固定文本实现。构造 `(string value)`,属性 `Value`;`Localize` 返回 `new LocalizedString(Value, Value, resourceNotFound: false)` | | `IHasNameWithLocalizableDisplayName` | 分离契约:`string Name { get; }`、`ILocalizableString? DisplayName { get; }` | `LocalizableStringExtensions`(静态扩展): | 方法 | 签名与说明 | | --- | --- | | `LocalizeOrFallback` | `string LocalizeOrFallback(this ILocalizableString? localizableString, IStringLocalizerFactory factory, string? fallback = null)`。源为 `null` 返回 `fallback ?? string.Empty`;命中但 `ResourceNotFound` 且有 `fallback` 则用 `fallback`,否则返回本地化值 | | `GetLocalizedDisplayName` | `string GetLocalizedDisplayName(this IHasNameWithLocalizableDisplayName source, IStringLocalizerFactory factory)`。优先本地化 `DisplayName`,缺失时回退到 `source.Name` | | `ToFixedLocalizableString` | `ILocalizableString ToFixedLocalizableString(this string value)`。把普通字符串包装为固定文本本地化字符串 | ### 枚举本地化契约 `IEnumLocalizationService`(实现见 [localization](./localization) 的 `EnumLocalizationService`): | 方法 | 说明 | | --- | --- | | `LocalizedEnumDefinition Get(Type enumType, EnumLocalizationQuery? query = null)` | 按类型读取 | | `LocalizedEnumDefinition Get(string enumTypeName, EnumLocalizationQuery? query = null)` | 按类型名读取(支持短名或完整名) | | `IReadOnlyDictionary GetMany(IEnumerable enumTypeNames, EnumLocalizationQuery? query = null)` | 批量读取,键为传入的类型名 | | `bool TryGet(string enumTypeName, out LocalizedEnumDefinition? result, EnumLocalizationQuery? query = null)` | 容错读取,未找到返回 `false` | 枚举本地化数据模型(命名空间 `XiHan.Framework.Localization.Abstractions.Enums`): | 类型 | 关键字段 | | --- | --- | | `EnumLocalizationQuery` | `string? CultureName`(未指定用当前 UI 文化)、`bool IncludeHidden`、`bool Ordered = true` | | `LocalizedEnumItem` | `Name`、`Value`(`object`)、`ValueText`、`Label`(优先本地化)、`Description`、`Theme?`、`Icon?`、`Order`、`Hidden`、`Disabled`、`ResourceName?`、`LocalizationKey?`、`Extra`(`IReadOnlyDictionary?`) | | `LocalizedEnumDefinition` | `EnumName`、`FullName`、`DisplayName`、`CultureName`、`IsFlags`、`UnderlyingTypeName`、`ResourceName?`、`Items`(`IReadOnlyList`) | ## 使用示例 用 `ILocalizableString` 描述一个"标识名 + 可本地化显示名"的对象,取值时再传入工厂: ```csharp using XiHan.Framework.Localization.Abstractions; using Microsoft.Extensions.Localization; public sealed class MenuItem : IHasNameWithLocalizableDisplayName { public string Name { get; } = "Dashboard"; // 按资源名 + 键,延迟解析 public ILocalizableString? DisplayName { get; } = new ResourceLocalizableString("Menu", "Menu.Dashboard"); } // 有了 IStringLocalizerFactory(由实现包注入)后: public string Render(MenuItem item, IStringLocalizerFactory factory) => item.GetLocalizedDisplayName(factory); // 缺失时回退到 item.Name ``` 固定文本包装(无需资源文件): ```csharp ILocalizableString title = "订单中心".ToFixedLocalizableString(); ``` ## 依赖模块 - 内部依赖:[XiHan.Framework.Core](./core)。 - 第三方核心:`Microsoft.Extensions.Localization.Abstractions`。 ## 相关模块 - [XiHan.Framework.Localization](./localization) — 本抽象层的具体实现包(JSON 资源加载、请求文化中间件、枚举本地化服务)。 - [XiHan.Framework.ObjectMapping](./objectmapping) — 依赖本抽象包以表达扩展属性的可本地化显示名。 --- 来源:https://framework.docs.xihanfun.com/packages/localization # XiHan.Framework.Localization > 国际化实现:JSON 优先(ResourceManager 回退)的字符串本地化 + 按请求头动态切换文化的中间件 + 枚举字段级本地化服务。 - **NuGet**:`XiHan.Framework.Localization` - **模块类**:`XiHanLocalizationModule` - **所在层**:基础设施层 - **关键依赖**:`Microsoft.Extensions.Localization` + `Microsoft.AspNetCore.App`(中间件)+ 框架内部依赖(Localization.Abstractions / VirtualFileSystem / Threading / Settings / Utils) ## 概述 本包是 [XiHan.Framework.Localization.Abstractions](./localization-abstractions) 的具体实现。取值策略为 **JSON 优先、ResourceManager(.resx)回退**:`XiHanStringLocalizerFactory` 产出的 `IStringLocalizer` 先查 JSON 资源存储,miss 才回退到微软标准的 `ResourceManagerStringLocalizerFactory`;当资源没有 backing 程序集(如纯 JSON 的 `ApiResponse` / `Errors`)时以 `NullStringLocalizer` 兜底,避免因加载程序集失败而整体崩溃。JSON 资源经**虚拟文件系统**加载,支持磁盘与嵌入式资源,并可监听文件变化**动态热重载**。文化解析由 `XiHanRequestCultureMiddleware` 在请求管线中完成:按 `X-Language` 请求头 > `Accept-Language` > 默认文化的优先级设置当前请求的 `CultureInfo`。枚举本地化由 `EnumLocalizationService` 提供,把枚举类型解析为带 `Label`、`Description`、`Icon` 等元数据的展示定义。 ## 何时使用 - 应用需要多语言支持,用 JSON 文件维护各语言文案(推荐),或复用既有 .resx 资源(回退)。 - 需要按请求头(默认 `X-Language`)或标准 `Accept-Language` 动态切换语言,并支持父文化 / 默认文化回退。 - 需要把枚举值本地化为带标签、图标、主题、排序等元数据的展示项(供前端下拉、标签渲染)。 - 需要在开发期改 JSON 文案即时生效(动态热重载)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Localization ``` ```csharp [DependsOn(typeof(XiHanLocalizationModule))] public class MyModule : XiHanModule { } ``` 模块 `ConfigureServices` 调用 `AddXiHanLocalization(config)`,从配置节 `XiHan:Localization` 绑定 `XiHanLocalizationOptions`,并完成如下注册(见 `XiHanLocalizationServiceCollectionExtensions`): - `services.AddLocalization()`(微软基础设施)+ `AddOptions()`; - `ResourceManagerStringLocalizerFactory` 作为**兜底**工厂(`TryAddSingleton`); - `JsonLocalizationResourceStore`(`TryAddSingleton`,JSON 资源存储); - `IEnumLocalizationService -> EnumLocalizationService`(`TryAddSingleton`); - 用 **`Replace`** 把 `IStringLocalizerFactory` 替换为 `XiHanStringLocalizerFactory`(JSON 优先)。 模块本身**不自动接入中间件**——文化切换需要在 Web 宿主的请求管线里显式调用 `app.UseXiHanRequestCulture()`。 > 依赖模块:`XiHanLocalizationModule` 上标注 `[DependsOn]` 引入 `XiHanLocalizationAbstractionsModule`、`XiHanVirtualFileSystemModule`、`XiHanThreadingModule`、`XiHanSettingsModule`。 ## 工作原理 **取值链(`XiHanJsonStringLocalizer`)**:以 `CurrentUICulture`(或固定文化)为准 → 查 `JsonLocalizationResourceStore` → miss 则查回退 `IStringLocalizer`(ResourceManager 或 `NullStringLocalizer`)→ 仍 miss 返回 `LocalizedString(name, name, resourceNotFound: true)`。 **JSON 资源解析(`JsonLocalizationResourceStore`)**: - 从 `ResourcesPath`(默认 `/Localization`)**递归**枚举 `*.json`,用虚拟文件系统读取; - 资源名与文化码解析顺序:JSON 内的 `resource`/`culture` 字段 > 文件名 `{Resource}.{culture}.json` > 目录名 / 纯文化码文件名; - 文本节点从 `texts` 或 `resources` 子对象读取(没有则取根),并**扁平化嵌套键**为 `a.b.c` 形式;数组按原文本、数字/布尔转字符串; - 缓存结构为 `资源名 -> 文化码 -> 键 -> 文本` 三层字典; - 取值时构建**文化回退链**:当前文化 → 父文化(`FallbackToParentCultures`)→ 默认文化(`FallbackToDefaultCulture`),并在指定资源 miss 时回落到 `DefaultResourceName`。 **动态热重载**:监听虚拟文件系统的 `OnFileChanged` 与 `IOptionsMonitor` 变更;当 `EnableDynamicJsonReload=true` 且变化文件在 `ResourcesPath` 下且为 `.json` 时重置缓存并递增 `Version`(可供上层缓存失效)。 **请求文化解析(`XiHanRequestCultureMiddleware`)**: ```text X-Language 头(可配) ──命中受支持文化?──▶ 用它 └─miss─▶ Accept-Language(按 q 权重排序) ──命中?──▶ 用它 └─miss─▶ 默认文化(DefaultCulture) ``` `SupportedCultures` 非空时仅接受列表内文化(大小写不敏感);为空则不限制、只要求是合法文化。中间件在请求结束的 `finally` 中**还原线程文化**,避免线程池复用残留;解析后的文化名写入 `HttpContext.Items["__XiHanCulture"]`(常量 `XiHanRequestCultureMiddleware.CultureItemKey`)。 ## 核心能力 - **JSON 优先、ResourceManager 回退**:`XiHanStringLocalizerFactory` 按类型或(baseName, location)创建 `IStringLocalizer`,无 backing 程序集时容错为 `NullStringLocalizer`(仅 JSON)。 - **基于虚拟文件系统的 JSON 存储**:磁盘 + 嵌入式资源统一加载,键扁平化,多层文化回退。 - **动态热重载**:文件或选项变化即失效缓存,`Version` 递增。 - **请求文化中间件**:`X-Language` > `Accept-Language` > 默认文化,支持受支持文化白名单与父/默认文化回退。 - **枚举本地化**:`EnumLocalizationService` 按类型/类型名解析枚举,产出 `LocalizedEnumDefinition`(含每项 `Label`、`Icon`、`Theme`、`Order`、`Hidden`、`Disabled` 等)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanLocalizationModule` | 模块入口,`ConfigureServices` 调 `AddXiHanLocalization` | | `XiHanStringLocalizerFactory` | `IStringLocalizerFactory` 实现(JSON 优先,`Replace` 覆盖默认)。方法 `Create(Type)`、`Create(string baseName, string location)` | | `XiHanJsonStringLocalizer` | `IStringLocalizer` 实现(JSON + 回退)。索引器 `this[name]`、`this[name, args]`;`GetAllStrings(bool)`;`WithCulture(CultureInfo)` | | `JsonLocalizationResourceStore` | JSON 资源存储/加载器。`bool TryGetString(string resourceName, CultureInfo culture, string name, out string value)`、`IReadOnlyList GetAllStrings(...)`、`long Version` | | `EnumLocalizationService` | `IEnumLocalizationService` 实现,反射构建枚举短名/完整名索引 | | `XiHanRequestCultureMiddleware` | 请求文化解析中间件,常量 `CultureItemKey = "__XiHanCulture"` | | `XiHanLocalizationOptions` | 配置选项(配置节 `XiHan:Localization`) | | `XiHanLocalizationServiceCollectionExtensions` | DI 入口 `AddXiHanLocalization(IServiceCollection, IConfiguration?)` | | `XiHanRequestCultureApplicationBuilderExtensions` | 中间件入口 `UseXiHanRequestCulture(IApplicationBuilder)` | ## 配置 配置节:`XiHan:Localization`(`XiHanLocalizationOptions.SectionName`)。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ResourcesPath` | `string` | `/Localization` | 本地化资源根目录(虚拟路径) | | `EnableDynamicJsonReload` | `bool` | `true` | 是否启用 JSON 动态热重载 | | `DefaultResourceName` | `string` | `Default` | 默认资源名(指定资源 miss 时回落) | | `EnumResourceName` | `string` | `Enums` | 枚举本地化默认资源名 | | `EnumLocalizationKeyPrefix` | `string` | `""` | 枚举本地化默认键前缀(空表示不加前缀) | | `DefaultCulture` | `string` | `zh-CN` | 默认文化 | | `SupportedCultures` | `IList` | `["zh-CN", "en-US"]` | 受支持文化白名单;为空表示不限制 | | `CultureHeaderName` | `string` | `X-Language` | 请求文化解析使用的请求头名 | | `FallbackToParentCultures` | `bool` | `true` | 是否回退到父文化 | | `FallbackToDefaultCulture` | `bool` | `true` | 是否回退到默认文化 | ```json { "XiHan": { "Localization": { "ResourcesPath": "/Localization", "EnableDynamicJsonReload": true, "DefaultResourceName": "Default", "EnumResourceName": "Enums", "DefaultCulture": "zh-CN", "SupportedCultures": ["zh-CN", "en-US"], "CultureHeaderName": "X-Language", "FallbackToParentCultures": true, "FallbackToDefaultCulture": true } } } ``` ## 使用示例 ### 1. 注入 `IStringLocalizer` 取值 ```csharp using Microsoft.Extensions.Localization; public class GreetingService(IStringLocalizer localizer) { // 先查 JSON(资源名取类型短名 GreetingService),miss 回退 ResourceManager public string Hello() => localizer["HelloWorld"]; // 带格式化参数 public string Welcome(string name) => localizer["Welcome", name]; } ``` 对应 JSON(`/Localization/GreetingService.en-US.json` 或用 `resource`/`culture` 字段声明): ```json { "resource": "GreetingService", "culture": "en-US", "texts": { "HelloWorld": "Hello World", "Welcome": "Welcome, {0}!" } } ``` ### 2. 在请求管线接入文化中间件 ```csharp // 建议放在管线早期(路由/MVC 之前,紧跟 TraceId 等),使后续管线在请求文化下执行 app.UseXiHanRequestCulture(); ``` 客户端发 `X-Language: en-US` 请求头即可切到英文(须在 `SupportedCultures` 内)。 ### 3. 枚举本地化 ```csharp public class EnumApi(IEnumLocalizationService enumLocalization) { public LocalizedEnumDefinition GetOrderStatus() // 按类型名解析(短名或完整名皆可),未指定文化时用当前 UI 文化 => enumLocalization.Get("OrderStatus", new EnumLocalizationQuery { Ordered = true }); } ``` ## 扩展点 / 自定义 - **替换字符串本地化工厂**:DI 用 `Replace` 覆盖了 `IStringLocalizerFactory`;如需自定义可在本模块之后再次 `Replace`。 - **纯 JSON 资源(无 .resx)**:直接放 JSON 即可,工厂对无 backing 程序集的资源会容错为 `NullStringLocalizer`,取值仍走 JSON。 - **切换资源目录 / 关闭热重载**:改 `ResourcesPath` / `EnableDynamicJsonReload`(`IOptionsMonitor` 感知变更并重建缓存)。 - **自定义文化来源头**:改 `CultureHeaderName`(默认 `X-Language`)。 ## 注意事项与最佳实践 - **中间件不自动注册**:仅引模块不接管线不会切换文化,务必显式 `UseXiHanRequestCulture()`,且放在需要文化的中间件之前。 - **线程文化会被还原**:中间件在请求结束还原 `CurrentCulture`/`CurrentUICulture`,不要依赖请求后线程仍保持请求文化。 - **JSON 键会被扁平化**:嵌套对象展开为 `a.b.c`,取值键需与扁平化结果一致。 - **枚举短名冲突**:`EnumLocalizationService` 构建全域枚举索引时,同名短名(不同命名空间)会被判为二义而从短名索引移除——此时须用完整名解析。 - **默认资源回落**:指定资源 miss 会回落 `DefaultResourceName`;把通用文案放默认资源可减少重复。 ## 依赖模块 - 内部依赖:[XiHan.Framework.Localization.Abstractions](./localization-abstractions)、[XiHan.Framework.VirtualFileSystem](./virtual-file-system)、[XiHan.Framework.Threading](./threading)、[XiHan.Framework.Settings](./settings)、[XiHan.Framework.Utils](./utils)。 - 第三方核心:`Microsoft.Extensions.Localization`;中间件依赖 `Microsoft.AspNetCore.App`。 ## 相关模块 - [XiHan.Framework.Localization.Abstractions](./localization-abstractions) — 本包实现的接口契约层。 - [XiHan.Framework.VirtualFileSystem](./virtual-file-system) — 提供 JSON 资源的统一加载与变更监听。 --- 来源:https://framework.docs.xihanfun.com/packages/logging # XiHan.Framework.Logging > 结构化日志:Serilog 集成、控制台/异步文件双路输出、按天/按大小滚动、结构化与性能日志、多租户日志上下文。 - **NuGet**:`XiHan.Framework.Logging` - **模块类**:`XiHanLoggingModule` - **所在层**:基础设施层 - **关键依赖**:`Serilog.AspNetCore`(含 Serilog 核心与 Console/File Sink 传递依赖)、`Serilog.Sinks.Async`(异步文件写入) ## 概述 XiHan.Framework.Logging 在 Serilog 之上封装了一套开箱即用的日志基础设施。启用后从配置节 `XiHan:Logging` 绑定选项,配置控制台与异步文件双路输出:文件借助 `Serilog.Sinks.Async` 异步写入、按天滚动并可按大小滚动、限制保留文件数量。除通用日志外,它还额外提供三类专用能力:结构化日志 `IStructuredLogger`(事件/业务动作)、性能监控日志 `IPerformanceLogger`(API/数据库/内存/CPU + `using` 计时器),以及多租户日志上下文 `ILogContext`(UserId/TenantId/TraceId 等,支持作用域)。 ## 何时使用 - 想一行 `[DependsOn]` 就获得控制台 + 文件的完整 Serilog 配置,无需手写引导代码。 - 需要日志文件异步写入、按天滚动、限制单文件大小与保留数量。 - 需要记录结构化数据、业务事件,或对 API / 数据库调用做性能计时。 - 需要在日志中自动携带 UserId、TenantId、TraceId 等请求上下文。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Logging ``` ```csharp [DependsOn(typeof(XiHanLoggingModule))] public class MyModule : XiHanModule { } ``` 模块启用后: - `PreConfigureServices` 先给 `XiHanLoggingOptions` 设默认值(`IsEnabled = true`、`MinimumLevel = Information`)。 - `ConfigureServices` 调用 `AddXiHanLogging(config)`,从配置节 `XiHan:Logging` 绑定 `XiHanLoggingOptions`,并完成以下注册: - `IXiHanLoggerFactory` → `XiHanLoggerFactory`(Singleton) - `IXiHanLogger` → `XiHanLogger`、`IXiHanLogger<>` → `XiHanLogger<>`(Transient) - `IStructuredLogger` → `StructuredLogger`、`IPerformanceLogger` → `PerformanceLogger`(Singleton) - `ILogContext` → `LogContext`(Scoped) - 通过 `AddSerilog(...)` 接入 Serilog:按 `XiHanLoggingOptions` 中的最小级别、输出模板、滚动策略等配置 Console Sink 与异步 File Sink,并启用 `Enrich.FromLogContext()`。 > 注意:日志写出走标准 `Serilog` 管道(`AddSerilog`),因此注入原生的 `Microsoft.Extensions.Logging.ILogger` 也会经由本模块配置的 Serilog 输出;`IXiHanLogger` 等接口是在其上提供的更贴合业务的封装。 ## 工作原理 - **运行期 Serilog 装配**:DI 扩展的私有方法 `AddXiHanSerilog` 在 `AddSerilog` 回调里读取 `XiHanLoggingOptions`,把 `MinimumLevel`(经 `LogLevel` → `LogEventLevel` 映射)、`ConsoleOutputTemplate`、`FileOutputPath/Template`、`RollingInterval`、`RetainedFileCountLimit`、`FileSizeLimitBytes`、`RollOnFileSizeLimit` 应用到 Console Sink 与 `WriteTo.Async(a => a.File(...))` 异步文件 Sink,并统一 `Enrich.FromLogContext()` 与固定属性 `Application = XiHanFramework`。 - **独立配置构建器**:`XiHanLoggerBuilder` / `XiHanLoggerConfigurationBuilder` 是一套**独立的 Fluent 构建器**,用于手动构造 `Serilog.Core.Logger`(例如宿主启动早期的引导日志),不参与模块 DI 装配。其 `BuildDefault()` 提供一套分级、多目录的默认策略(见下)。 ## 核心能力 - **Serilog 集成**:从配置节绑定选项后统一配置最小级别、上下文增强、控制台与异步文件输出。 - **异步文件输出**:借助 `Serilog.Sinks.Async` 异步写入,按天滚动 + 可按大小滚动,限制保留文件数与单文件大小。 - **控制台输出**:使用可配置的输出模板。 - **结构化日志**:`IStructuredLogger` 支持信息/警告/错误、事件(`LogEvent`)与业务动作(`LogBusiness`)的结构化记录。 - **性能监控**:`IPerformanceLogger` 记录操作 / API 调用 / 数据库查询 / 内存 / CPU;`IPerformanceTimer` 支持 `using` 自动计时。 - **多租户上下文**:`ILogContext`(Scoped)携带 UserId、UserName、TenantId、RequestId、TraceId、SessionId、IpAddress、UserAgent 及自定义属性,支持属性作用域(`CreateScope`)隔离与还原。 ## 主要 API / 类型 ### 日志器与工厂 | 类型 | 说明 | | --- | --- | | `IXiHanLogger` / `IXiHanLogger` | 通用日志接口与泛型版本。方法:`LogTrace/LogDebug/LogInfo/LogWarn/LogError/LogCritical(string, params object[])`、`LogError(Exception, string, ...)`、`LogStructured(LogLevel, string, object)`、`LogPerformance(string, TimeSpan, object?)`、`IsEnabled(LogLevel)`、`BeginScope(TState)` | | `IXiHanLoggerFactory` | 工厂:`IXiHanLogger CreateLogger(string categoryName)`、`IXiHanLogger CreateLogger()`、`IStructuredLogger CreateStructuredLogger(string)`、`IPerformanceLogger CreatePerformanceLogger(string)` | ### 结构化日志 | 类型 | 说明 | | --- | --- | | `IStructuredLogger` | `LogInformation/LogWarning/LogError(string message, object data)`、`LogError(Exception, string, object)`、`Log(LogLevel, string, object)`、`LogEvent(string eventName, object eventData)`、`LogBusiness(string businessAction, object businessData)` | ### 性能日志 | 类型 | 说明 | | --- | --- | | `IPerformanceLogger` | `LogOperation(string, TimeSpan, object?)`、`LogApiCall(string, TimeSpan, int statusCode, object?)`、`LogDatabaseQuery(string, TimeSpan, int recordCount, object?)`、`LogMemoryUsage(string, long, long)`、`LogCpuUsage(string, double, TimeSpan)`、`IPerformanceTimer StartTimer(string operationName)` | | `IPerformanceTimer : IDisposable` | 属性 `OperationName`、`Stopwatch`、`AdditionalData`;方法 `Stop()`。释放时自动记录耗时 | ### 日志上下文 | 类型 | 说明 | | --- | --- | | `ILogContext`(Scoped) | 上下文属性 `UserId/UserName/TenantId/RequestId/TraceId/SessionId/IpAddress/UserAgent`、`Properties`;方法 `SetProperty(string, object)`、`GetProperty(string)`、`RemoveProperty(string)`、`Clear()`、`CreateScope(Dictionary)`、`CreateScope(string, object)` | ### 提供器与构建器 | 类型 | 说明 | | --- | --- | | `XiHanFileLoggerProvider` / `XiHanConsoleLoggerProvider` | 实现 `Microsoft.Extensions.Logging.ILoggerProvider` 的文件 / 控制台提供器,可通过 `AddXiHanFileLogger()` / `AddXiHanConsoleLogger()` 挂到 `ILoggingBuilder` | | `XiHanLoggerBuilder` | 独立日志器构建入口:`Logger CreateLogger(IConfiguration)`、`Logger CreateLogger()`、`Logger CreateLoggerDefault()` | | `XiHanLoggerConfigurationBuilder` | Serilog `LoggerConfiguration` 的 Fluent 构建器:`MinimumLevel(...)`、`Override(source, level)`、`EnrichWithProperty(...)`、`EnrichFromLogContext()`、`WriteToConsole(level, template)`、`WriteToFile(level, path, template)`,以及对应的 `*Default()` 与 `Build()/BuildDefault()` | ### DI 扩展方法(`XiHanLoggingServiceCollectionExtensions`) | 方法 | 说明 | | --- | --- | | `AddXiHanLogging(IConfiguration)` | 从配置节 `XiHan:Logging` 绑定选项并装配全部日志服务 + Serilog(模块内部调用) | | `AddXiHanLogging()` / `AddXiHanLogging(Action)` | 以代码方式配置选项并装配 | | `AddXiHanFileLogger(Action?)` | 向 `ILoggingBuilder` 追加自定义文件日志提供器 | | `AddXiHanConsoleLogger(Action?)` | 向 `ILoggingBuilder` 追加自定义控制台日志提供器 | ## 配置 主配置节 `XiHan:Logging`(`XiHanLoggingOptions.SectionName`)。核心字段: | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `IsEnabled` | `bool` | `true` | 是否启用日志 | | `MinimumLevel` | `LogLevel` | `Information` | 最小日志级别(映射为 Serilog `LogEventLevel`) | | `ConsoleOutputTemplate` | `string` | `[{Timestamp:HH:mm:ss} {Level:u3}] [{TraceId}] {SourceContext}: {Message:lj}{NewLine}{Exception}` | 控制台输出模板(链路 ID 紧跟级别之后) | | `FileOutputPath` | `string` | `logs/xihan-.log` | 文件输出路径(Serilog 会在文件名中插入日期后缀) | | `FileOutputTemplate` | `string` | `[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{TraceId} {SpanId}] {SourceContext}: {Message:lj}{NewLine}{Exception}` | 文件输出模板(链路 ID 紧跟级别之后) | | `RollingInterval` | `RollingInterval` | `Day` | 文件滚动间隔 | | `RetainedFileCountLimit` | `int?` | `31` | 保留文件数(`null` 表示永久保留) | | `FileSizeLimitBytes` | `long?` | `104857600`(100MB) | 单文件大小上限 | | `RollOnFileSizeLimit` | `bool` | `true` | 达到大小上限时是否滚动新文件 | | `EnableStructuredLogging` | `bool` | `true` | 是否启用结构化日志 | | `EnableAsyncLogging` | `bool` | `true` | 是否启用异步日志 | | `AsyncBufferSize` | `int` | `10000` | 异步日志缓冲区大小 | | `BlockWhenFull` | `bool` | `false` | 缓冲区满时是否阻塞 | | `ContextProperties` | `Dictionary` | `[]` | 附加日志上下文属性 | | `EnablePerformanceCounters` | `bool` | `false` | 是否启用性能计数器 | | `EnableRequestLogging` | `bool` | `true` | 是否启用请求日志 | | `RequestLoggingExcludePaths` | `string[]` | `/health`、`/metrics`、`/favicon.ico`、`/swagger` | 请求日志排除路径 | | `Filters` | `Dictionary` | `[]` | 分类日志级别过滤 | 自定义提供器的独立配置节(仅在使用 `AddXiHanFileLogger` / `AddXiHanConsoleLogger` 时相关): - `XiHan:Logging:File`(`XiHanFileLoggerOptions`):`FilePath`、`FileSizeLimit`(默认 10MB)、`RetainedFileCountLimit`(默认 31)、`BufferSize`、`FlushPeriod`、`MinLevel`、`IncludeScopes`、`LogFormat`、`EnableAsyncWrite`、`Encoding`。 - `XiHan:Logging:Console`(`XiHanConsoleLoggerOptions`):`MinLevel`、`IncludeScopes`、`EnableColors`、`EnableRainbow`、`LogFormat`、`TimestampFormat`、`ShowCategoryName/Timestamp/LogLevel`、`LogLevelColors`、`SingleLine`、`UseStdErrorForErrors`。 示例 `appsettings.json`: ```json { "XiHan": { "Logging": { "IsEnabled": true, "MinimumLevel": "Information", "FileOutputPath": "logs/xihan-.log", "RollingInterval": "Day", "RetainedFileCountLimit": 31, "FileSizeLimitBytes": 104857600, "RollOnFileSizeLimit": true, "RequestLoggingExcludePaths": [ "/health", "/metrics", "/favicon.ico", "/swagger" ] } } } ``` ## 使用示例 ### 结构化日志与业务事件 ```csharp public class OrderService { private readonly IStructuredLogger _logger; public OrderService(IStructuredLogger logger) => _logger = logger; public void CreateOrder(Guid orderId, decimal amount) { _logger.LogBusiness("OrderCreated", new { OrderId = orderId, Amount = amount }); _logger.LogEvent("PaymentRequested", new { OrderId = orderId }); } } ``` ### 性能计时(`using` 自动记录) ```csharp public class ReportService { private readonly IPerformanceLogger _perf; public ReportService(IPerformanceLogger perf) => _perf = perf; public void GenerateReport() { using var timer = _perf.StartTimer("GenerateReport"); // ... 耗时操作,timer 释放时自动写出耗时 } } ``` ### 日志上下文(携带 UserId / TenantId) ```csharp public class AuditMiddleware { private readonly ILogContext _logContext; // Scoped public AuditMiddleware(ILogContext logContext) => _logContext = logContext; public void SetContext(string userId, string tenantId) { _logContext.UserId = userId; _logContext.TenantId = tenantId; using var scope = _logContext.CreateScope("Feature", "Billing"); // 作用域内的日志将携带 Feature=Billing,退出后自动移除 } } ``` ## 扩展点 / 自定义 - **替换日志实现**:`IXiHanLogger`、`IStructuredLogger`、`IPerformanceLogger`、`ILogContext` 均以 `TryAdd*` 注册,可在应用侧提前注册自定义实现覆盖默认行为。 - **追加 M.E.Logging 提供器**:在配置 `ILoggingBuilder` 时调用 `AddXiHanFileLogger()` / `AddXiHanConsoleLogger()` 挂上本包提供的 `ILoggerProvider`,各自读取 `XiHan:Logging:File` / `XiHan:Logging:Console` 配置节。 - **手动构造引导日志**:宿主启动早期可用 `new XiHanLoggerBuilder().CreateLoggerDefault()` 得到一个分级、多目录(Debug/Info/Waring/Error/Fatal 各自子目录)的 Serilog `Logger`,用于 DI 就绪前的引导日志。 ## 注意事项与最佳实践 - `ILogContext` 是 **Scoped**,仅在有请求作用域(如 Web 请求)时才是每请求隔离;后台任务中若无作用域请自行创建 DI Scope 再解析。 - `IStructuredLogger` / `IPerformanceLogger` 是 **Singleton**,`IXiHanLogger` 是 **Transient**。 - 文件路径中的日期后缀由 Serilog 依据 `RollingInterval` 自动插入(如 `xihan-20260705.log`);`FileOutputPath` 只需给出基名。 - 模块 DI 装配走 `XiHanLoggingOptions`;`XiHanLoggerConfigurationBuilder` 的 `*Default()` 模板与目录约定是**独立**的另一套默认策略,二者不要混淆。 ## 依赖模块 - 内部依赖:仅 [XiHan.Framework.Core](./core)。 - 第三方核心:`Serilog.AspNetCore`、`Serilog.Sinks.Async`。 ## 相关模块 - [XiHan.Framework.Observability](./observability) — 可观测性(追踪 / 指标)与日志互补。 - [XiHan.Framework.Timing](./timing) — 时钟与时区,日志时间戳的规范化基础。 --- 来源:https://framework.docs.xihanfun.com/packages/messaging # XiHan.Framework.Messaging > 很薄的消息路由抽象层:定义"消息信封 → 按通道路由 → 交给发送器投递"的统一模型与接口,本身只负责路由,不含任何具体通道实现。 - **NuGet**:`XiHan.Framework.Messaging` - **模块类**:`XiHanMessagingModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(仅 Core)——不引入任何第三方通道 SDK ## 概述 这个包是一层**很薄的消息发送抽象**。它定义"一条消息(信封 `MessageEnvelope`)→ 按 `Channel` 路由到某个发送器 `IMessageSender` → 逐个接收人投递"的统一模型与接口,本身**只负责路由**,不含任何具体通道实现(邮件、短信、站内信等)。真正的发送器由业务层或专用包(如 Bot 系列)提供并注册进来;本包内置的只有一个"未配置兜底发送器" `NotConfiguredMessageSender`,用于在没有可用发送器时给出明确失败结果。 ## 何时使用 - 你要一套统一的消息模型(信封、接收人、发送结果),把不同通道的差异藏在同一个接口后 - 你要按 `Channel` 把消息路由到不同的发送器(email / sms / site 等),而调用方无需感知具体实现 - 你在实现自定义消息通道,需要一个标准的 `IMessageSender` 契约去接入路由 - 不该用它的场景:本包不做"后台异步发送/发件箱/重试",那属于上层实现(业务层或 Bot 包)的职责 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Messaging ``` ```csharp [DependsOn(typeof(XiHanMessagingModule))] public class MyModule : XiHanModule { } ``` `XiHanMessagingModule.ConfigureServices` 调用 `services.AddXiHanMessaging()`,启用后注册: - `IMessageDispatcher` → `DefaultMessageDispatcher`(`TryAddSingleton`) - `IMessageSender` 集合追加一个 `NotConfiguredMessageSender`(`TryAddEnumerable` 单例)——它是兜底项,不遮蔽你注册的真实发送器 ## 工作原理 调度路由逻辑在 `DefaultMessageDispatcher.DispatchAsync` 中: 1. 校验:`envelope` 非空、`Channel` 非空白;接收人为空则直接返回空结果。 2. 选发送器:**优先选真实发送器**——先在所有 `IMessageSender` 里找"不是 `NotConfiguredMessageSender` 且 `CanHandle(channel)`"的,找不到再退回任意 `CanHandle(channel)` 的(即兜底)。因为 `NotConfiguredMessageSender.CanHandle` 恒为 `true`,这个两段式选择确保它只在"没有真实发送器"时才生效,不会遮蔽真实发送器。 3. 无发送器时:按 `ThrowWhenNoSender` 决定抛异常还是给每个接收人返回失败结果。 4. 逐接收人调用 `sender.SendAsync`,回填结果里缺省的 `MessageId/Channel/RecipientAddress`;单个失败或抛异常时,按 `ContinueOnError` 决定继续还是中断。 ## 核心能力 - **消息信封模型** `MessageEnvelope`:承载 `MessageId`、`Channel`、`Subject`、`Content`、模板编码/参数、元数据、接收人集合、计划/过期时间、租户与关联追踪 Id 等 - **消息调度器** `IMessageDispatcher`:`DispatchAsync(envelope, ct)` 把消息路由到匹配通道并逐接收人投递,返回每个接收人的发送结果集合 - **消息发送器契约** `IMessageSender`:`CanHandle(channel)` 声明支持的通道 + `SendAsync(envelope, recipient, ct)` 投递单条消息 - **路由约定**:调度器按 `Channel` 选发送器,真实发送器优先、兜底发送器最后 - **可配置行为** `XiHanMessagingOptions`:`ContinueOnError`(单接收人失败是否继续)、`ThrowWhenNoSender`(无发送器时是否抛异常) ## 主要 API / 类型 | 类型 | 说明 / 关键方法 | | --- | --- | | `IMessageDispatcher` | `Task> DispatchAsync(MessageEnvelope envelope, CancellationToken ct = default)` | | `IMessageSender` | `bool CanHandle(string channel)` + `Task SendAsync(MessageEnvelope envelope, MessageRecipient recipient, CancellationToken ct = default)` | | `MessageEnvelope` | 消息信封(消息主体与元数据),见下表字段 | | `MessageRecipient` | 接收人:`ReceiverId?`、`Address`(邮箱/手机号/用户 Id)、`DisplayName?` | | `MessageSendResult` | 单次发送结果:`MessageId`、`Channel`、`RecipientAddress`、`IsSuccess`、`ErrorMessage?`、`ProviderMessageId?`(第三方消息 Id)、`DispatchedAt`(默认 `DateTimeOffset.UtcNow`) | | `XiHanMessagingOptions` | 消息模块行为配置 | | `DefaultMessageDispatcher` | 默认调度器实现(`IMessageDispatcher`) | | `NotConfiguredMessageSender` | 未配置通道时的兜底发送器(`CanHandle` 恒 `true`,`SendAsync` 恒返回失败结果) | ### `MessageEnvelope` 字段 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `MessageId` | `string` | `Guid.NewGuid().ToString("N")` | 消息唯一标识 | | `Channel` | `string` | `"default"` | 消息通道(如 email/sms/site) | | `TenantId` | `string?` | `null` | 租户 Id | | `SenderId` | `string?` | `null` | 发送者 Id | | `Subject` | `string` | `""` | 主题 | | `Content` | `string?` | `null` | 内容 | | `TemplateCode` | `string?` | `null` | 模板编码 | | `TemplateParams` | `Dictionary` | `[]` | 模板参数 | | `Metadata` | `Dictionary` | `[]` | 扩展元数据 | | `Recipients` | `IReadOnlyList` | `[]` | 接收人集合 | | `ScheduledTime` | `DateTimeOffset?` | `null` | 计划发送时间 | | `ExpireTime` | `DateTimeOffset?` | `null` | 过期时间 | | `CorrelationId` | `string?` | `null` | 关联追踪 Id | ## 配置 `XiHanMessagingOptions`(无独立配置节,通过 `AddXiHanMessaging(configure)` 或 `services.Configure(...)` 设置): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ContinueOnError` | `bool` | `true` | 单个接收人失败后是否继续给后续接收人发送 | | `ThrowWhenNoSender` | `bool` | `false` | 找不到发送器时是否抛异常(`false` 则返回失败结果集合) | ## 使用示例 ### 1. 实现并注册一个自定义发送器 ```csharp public class SiteMessageSender : IMessageSender { public bool CanHandle(string channel) => string.Equals(channel, "site", StringComparison.OrdinalIgnoreCase); public async Task SendAsync( MessageEnvelope envelope, MessageRecipient recipient, CancellationToken ct = default) { // 站内信落库逻辑…… return new MessageSendResult { MessageId = envelope.MessageId, Channel = envelope.Channel, RecipientAddress = recipient.Address, IsSuccess = true }; } } // 注册(追加到 IMessageSender 集合,与兜底发送器共存,路由时优先命中) services.AddSingleton(); ``` ### 2. 通过调度器发送 ```csharp public class NoticeService { private readonly IMessageDispatcher _dispatcher; public NoticeService(IMessageDispatcher dispatcher) => _dispatcher = dispatcher; public async Task NotifyAsync(string userId) { var envelope = new MessageEnvelope { Channel = "site", Subject = "系统通知", Content = "您有一条新消息", Recipients = new[] { new MessageRecipient { ReceiverId = userId, Address = userId } } }; IReadOnlyList results = await _dispatcher.DispatchAsync(envelope); // 逐接收人查看 results[i].IsSuccess / ErrorMessage } } ``` ## 扩展点 / 自定义 - **接入新通道**:实现 `IMessageSender`(`CanHandle` 声明通道、`SendAsync` 投递),注册到 `IMessageSender` 集合即可被路由;真实发送器总是优先于兜底发送器。 - **替换调度器**:`DefaultMessageDispatcher` 以 `TryAddSingleton` 注册,先注册你自己的 `IMessageDispatcher` 即可覆盖默认路由策略。 - **后台异步/发件箱**:本包不做异步发送,"提交后入队、后台拉取发送"等能力由业务层实现(参考项目内基于 Redis 延迟队列的发件箱做法)。 ## 注意事项与最佳实践 - **本包只提供路由骨架**:要真正发某个通道的消息,先提供并注册对应 `IMessageSender`,否则会命中 `NotConfiguredMessageSender` 得到"未配置发送器"的失败结果(`ThrowWhenNoSender = true` 时改为抛异常)。 - **兜底发送器不会遮蔽真实发送器**:`NotConfiguredMessageSender.CanHandle` 恒为 `true`,但调度器做了两段式选择——真实发送器优先、它只在没有真实发送器时兜底。 - **同步、无重试**:`DispatchAsync` 是即时逐接收人投递,不含重试/持久化;可靠性(重试、发件箱、去重)需在上层实现。 - **`Channel` 大小写**:`DefaultMessageDispatcher` 会 `Trim()` 通道名后匹配,但大小写敏感与否取决于各 `IMessageSender.CanHandle` 的实现(建议自实现里用不区分大小写比较)。 ## 依赖模块 - [XiHan.Framework.Core](./core) - 仅依赖 Core 与 BCL,不引入任何第三方通道 SDK。 ## 相关模块 - [XiHan.Framework.EventBus](./eventbus)(事件总线依赖本包) - [XiHan.Framework.Bot](./bot)(消息通道实现方向) --- 来源:https://framework.docs.xihanfun.com/packages/metadata # XiHan.Framework.Metadata > 框架"身份信息"的单一事实源:名称、版本、作者、组织、许可证、支持平台等静态元数据。 - **NuGet**:`XiHan.Framework.Metadata` - **模块类**:—(无模块类,直接引用) - **所在层**:元数据层 - **关键依赖**:仅 .NET 原生(`System.Reflection`),无框架内部依赖 ## 概述 XiHan.Framework.Metadata 把整个框架的元信息集中到一个静态类 `XiHanMetadata` 中:框架名称、显示名、版权、作者与邮箱、组织与仓库/文档地址、许可证、关键词、支持的 .NET 版本与平台。这些身份信息大多是编译期常量,同时也提供从运行时程序集读取的版本与入口程序集信息。它是框架最底层的包之一,无任何内部依赖,任何模块都能安全引用它来获得统一的框架元信息,避免在各处硬编码名称与版本字符串。 ## 何时使用 - 启动横幅、日志、诊断信息需要打印统一的框架名称与版本。 - 关于页 / 健康检查 / API 元信息需要展示作者、组织、仓库地址、许可证等。 - 运行时需要读取入口程序集(宿主应用)的名称与版本。 - 需要框架 ASCII Logo 或一键格式化的框架信息摘要。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Metadata ``` 纯元数据库,**无模块类**,无需 `[DependsOn]`。直接引用后读取 `XiHanMetadata` 上的静态成员即可。 ## 核心能力 - 集中维护框架身份常量:`Name`、`DisplayName`、`Copyright`、`Author`、`Organization`、`OrganizationUrl`、`RepositoryUrl`、`DocumentationUrl`、`License`、`LicenseUrl`、`Description`。 - 数组型元信息:`Keywords`、`SupportedFrameworks`(当前目标框架)、`SupportedPlatforms`(`Windows` / `Linux` / `MacOS`)。 - 版本信息(从 `XiHanMetadata` 所在程序集读取):`Version`、`FullVersion`、`MajorVersion`、`MinorVersion`、`PatchVersion`。 - 运行时入口信息(从入口程序集读取):`EntryAssemblyName`、`EntryAssemblyVersion`。 - 展示用文本:`Logo`(ASCII 艺术字)、`SendWord`(框架寄语)。 - 格式化输出:`GetSummary()`(名称/版本/描述/寄语/入口程序集)与 `GetDetails()`(在摘要基础上追加作者、组织、仓库、文档、许可证)。 ## 主要 API / 类型 只有一个静态类 `XiHanMetadata`,成员如下。 ### 常量与元信息(静态字段) | 成员 | 类型 | 值 / 说明 | | --- | --- | --- | | `Name` | `string` | `"XiHan.Framework"` | | `DisplayName` | `string` | `"曦寒框架"` | | `Copyright` | `string` | `"Copyright (c) 2021-Present XiHanFun and contributors."` | | `Author` | `string` | `"XiHanFun"` | | `Organization` | `string` | `"XiHanFun"` | | `OrganizationUrl` | `string` | `https://github.com/XiHanFun` | | `RepositoryUrl` | `string` | `https://github.com/XiHanFun/XiHan.Framework` | | `DocumentationUrl` | `string` | `https://docs.xihanfun.com` | | `License` | `string` | `"MIT"` | | `LicenseUrl` | `string` | 指向仓库 `LICENSE` | | `Description` | `string` | `"快速、轻量、高效、用心的 .NET 现代模块化开发框架。基于 {当前 .NET 版本} 构建。"`,版本由运行时解析 | | `Keywords` | `string[]` | `dotnet`、`aspnetcore`、`csharp`、`web`、`webapp`、`xihan`、`framework`、`zhaifanhua`、`xihanfun`、`modular`、`extensible`(共 11 项) | | `SupportedFrameworks` | `string[]` | 只含当前目标框架,即 `TargetFramework`(如 `["net10.0"]`) | | `SupportedPlatforms` | `string[]` | `["Windows", "Linux", "MacOS"]` | ### 版本与运行时(只读属性) | 成员 | 类型 | 说明 | | --- | --- | --- | | `Version` | `string` | 框架程序集版本字符串,读取失败回退 `0.0.0` | | `FullVersion` | `Version` | 框架完整 `Version` 对象 | | `MajorVersion` / `MinorVersion` / `PatchVersion` | `int` | 主 / 次 / 修订版本号 | | `EntryAssemblyName` | `string` | 入口(宿主)程序集名称,无入口时为空串 | | `EntryAssemblyVersion` | `string` | 入口程序集版本字符串 | | `Logo` | `string` | 框架 ASCII Logo | | `SendWord` | `string` | 框架寄语 | ### 摘要方法 | 方法 | 签名 | 说明 | | --- | --- | --- | | `GetSummary()` | `static string GetSummary()` | 名称、显示名、版本、描述、寄语、入口程序集名与版本 | | `GetDetails()` | `static string GetDetails()` | 在摘要基础上追加作者、组织、仓库、文档、许可证 | > 版本相关成员依赖程序集的 `AssemblyVersion` 元数据;`Version`/`MajorVersion` 等读取的是 `XiHanMetadata` 所在的 Metadata 程序集,而 `EntryAssemblyName`/`EntryAssemblyVersion` 读取的是运行时入口程序集(宿主应用),两者不同。 ## 使用示例 ```csharp using XiHan.Framework.Metadata; // 启动横幅:打印 Logo + 摘要 Console.WriteLine(XiHanMetadata.Logo); Console.WriteLine(XiHanMetadata.GetSummary()); // 关于页 / 诊断:完整详细信息 var details = XiHanMetadata.GetDetails(); // 单独读取名称与版本 var name = XiHanMetadata.Name; // "XiHan.Framework" var version = XiHanMetadata.Version; // 如 "3.5.0.0"(随框架统一版本号 version.props 更新) var major = XiHanMetadata.MajorVersion; // 如 3 // 读取宿主应用(入口程序集)信息 var host = XiHanMetadata.EntryAssemblyName; var hostVer = XiHanMetadata.EntryAssemblyVersion; ``` ## 注意事项与最佳实践 - 身份常量以静态字段暴露,运行时可被赋值覆盖;正常使用应视为只读,不要修改,以免污染全局元信息。 - `Version` 系列反映的是**框架程序集**版本;要展示宿主应用版本请用 `EntryAssemblyName` / `EntryAssemblyVersion`。 - 无入口程序集(如某些测试宿主)时,入口相关属性返回空串,展示前请做好空值兜底。 - 需要统一框架名称/版本字符串时,一律读取本包成员,不要在业务代码里硬编码。 ## 依赖模块 无框架内部依赖;仅使用 BCL 的 `System.Reflection` 读取程序集版本与入口程序集信息。 ## 相关模块 - [XiHan.Framework.Core](./core) — 核心库,可读取 Metadata 提供的框架元信息。 - [XiHan.Framework.Utils](./utils) — 通用工具库,同为最底层基础包。 --- 来源:https://framework.docs.xihanfun.com/packages/multitenancy-abstractions # XiHan.Framework.MultiTenancy.Abstractions > 多租户抽象:当前租户上下文、多租户实体标记、租户解析链契约与 URL 提供者接口 - **NuGet**:`XiHan.Framework.MultiTenancy.Abstractions` - **模块类**:`XiHanMultiTenancyAbstractionsModule` - **所在层**:应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(`XiHan.Framework.Core`) ## 概述 本包是多租户能力的**抽象层**,只定义接口与基础模型,不含任何实现逻辑。它约定了三件核心的事:「当前请求属于哪个租户」(`ICurrentTenant`)、「哪些实体归属租户」(`IMultiTenant`)、「如何从请求中解析出租户」(`ITenantResolveContributor` / `ITenantResolveContext`)。上层领域/应用代码依赖这些接口而非具体实现,从而与落地方式解耦。真正的实现由 [XiHan.Framework.MultiTenancy](./multitenancy) 提供,二者通过依赖倒置组合。 模块类 `XiHanMultiTenancyAbstractionsModule` 本身是**空实现**——它不注册任何服务,只作为模块依赖锚点存在,让实现包与上层模块通过 `[DependsOn]` 统一引入抽象契约。 ## 何时使用 - 你的领域/应用代码想读取当前租户信息,但不想耦合具体实现——注入 `ICurrentTenant` 即可 - 你要定义支持租户隔离的实体——实现 `IMultiTenant`(暴露 `long? TenantId`) - 你要编写自定义的租户解析逻辑——实现 `ITenantResolveContributor` - 你需要标记某段代码/实体忽略多租户过滤——`IgnoreMultiTenancyAttribute` - 你需要按租户生成/解析 URL——实现或依赖 `IMultiTenantUrlProvider` 通常无需单独引用本包:引用实现包 [XiHan.Framework.MultiTenancy](./multitenancy) 时会自动带上这些抽象。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.MultiTenancy.Abstractions ``` ```csharp [DependsOn(typeof(XiHanMultiTenancyAbstractionsModule))] public class MyModule : XiHanModule { } ``` 模块类不注册任何服务;真正的服务注册(当前租户访问器、租户存储、解析链等)在实现包 [XiHan.Framework.MultiTenancy](./multitenancy) 中完成。 ## 核心能力 - **当前租户契约**:`ICurrentTenant` 提供 `Id`(`long?`)、`Name`、`IsAvailable`,以及用 `using` 临时切换租户的 `Change(...)` - **租户信息载体与访问器**:`BasicTenantInfo` 承载租户 Id/Name,`ICurrentTenantAccessor` 读写「当前」`BasicTenantInfo` - **多租户实体标记**:`IMultiTenant` 暴露 `long? TenantId`,`null` 与 `0` 同义,表示平台(0 号租户)/公共数据 - **租户解析链契约**:`ITenantResolveContributor` + `ITenantResolveContext`,把请求中的标识/名称解析成租户 - **忽略多租户**:`IgnoreMultiTenancyAttribute` - **租户 URL 契约**:`IMultiTenantUrlProvider` 按租户模板生成 URL ## 主要 API / 类型 ### 当前租户上下文 | 类型 | 说明 | | --- | --- | | `ICurrentTenant` | 当前租户上下文。属性 `bool IsAvailable`、`long? Id`、`string? Name`;方法 `IDisposable Change(long? id, string? name = null)`(临时切换,`Dispose` 时恢复上一层) | | `ICurrentTenantAccessor` | 当前租户信息读写访问器。属性 `BasicTenantInfo? Current { get; set; }` | | `BasicTenantInfo` | 只读租户信息载体。构造 `BasicTenantInfo(long? tenantId, string? name = null)`;属性 `long? TenantId`、`string? Name` | ### 多租户实体与解析链 | 类型 | 说明 | | --- | --- | | `IMultiTenant` | 多租户实体接口,暴露 `long? TenantId { get; }`(`null` 与 `0` 同义:平台 / 公共数据) | | `ITenantResolveContributor` | 租户解析贡献者。属性 `string Name`;方法 `Task ResolveAsync(ITenantResolveContext context)` | | `ITenantResolveContext` | 解析上下文(继承 `IServiceProviderAccessor`)。属性 `string? TenantIdOrName { get; set; }`、`bool Handled { get; set; }` | | `IMultiTenantUrlProvider` | 按租户生成 URL 的契约。方法 `Task GetUrlAsync(string templateUrl)` | | `IgnoreMultiTenancyAttribute` | 标记忽略多租户过滤(`AttributeUsage = AttributeTargets.All`,无成员) | > `ITenantResolveContext` 继承自 `XiHan.Framework.Core.DependencyInjection.IServiceProviderAccessor`,因此解析贡献者可通过 `context.ServiceProvider` 取到当前请求的服务(如 `IHttpContextAccessor`、`ICurrentUser`)来完成解析。 ## 使用示例 ### 读取与临时切换当前租户 ```csharp public class ReportService { private readonly ICurrentTenant _currentTenant; public ReportService(ICurrentTenant currentTenant) { _currentTenant = currentTenant; } public void Run(long tenantId) { // 临时切换到指定租户,using 结束后自动恢复到上一层 using (_currentTenant.Change(tenantId, "Acme")) { var id = _currentTenant.Id; // 当前作用域内为 tenantId var available = _currentTenant.IsAvailable; // tenantId 大于 0 时为 true;Change(0) 即平台,为 false } } } ``` ### 定义多租户实体 ```csharp public class Order : IMultiTenant { public long Id { get; set; } // null 与 0 同义:平台(0 号租户)/ 公共数据;大于 0 表示归属某业务租户 public long? TenantId { get; set; } } ``` ## 扩展点 / 自定义 - **自定义租户解析贡献者**:实现 `ITenantResolveContributor`(或继承实现包提供的 `TenantResolveContributorBase`),在 `ResolveAsync` 里把解析出的标识写入 `context.TenantIdOrName` 并置 `context.Handled = true` 短路后续贡献者。 - **自定义 URL 策略**:实现 `IMultiTenantUrlProvider`,按租户把模板 URL 渲染成最终地址(如 `{tenant}.example.com`)。 ## 注意事项与最佳实践 - **平台就是 0 号租户**:`ICurrentTenant.Id` 为 `long?`,`null` 与 `0` 同义,都表示平台;多租户实体上平台行一律落 `TenantId = 0`,数据过滤、写边界、连接解析、缓存键都按这一口径处理。 - **`IsAvailable` 语义**:表示「当前处于某个业务租户」,实现包中等价于 `Id is > 0`;平台(`null` 或 `0`)下为 `false`。 - **抽象包无副作用**:本包只有接口/模型,模块类不注册服务;不要指望单独引用它就能获得当前租户,必须引用实现包。 ## 依赖模块 - [XiHan.Framework.Core](./core)(模块化与 DI 抽象,`IServiceProviderAccessor` 来源) 仅依赖 Core 与 BCL,不引入任何第三方库。 ## 相关模块 - [XiHan.Framework.MultiTenancy](./multitenancy)(本抽象的实现包) - [XiHan.Framework.Settings](./settings)(租户级设置来源) - [XiHan.Framework.Data](./data)(按租户过滤/路由数据) --- 来源:https://framework.docs.xihanfun.com/packages/multitenancy # XiHan.Framework.MultiTenancy > 多租户实现:当前租户上下文(AsyncLocal)、租户解析链、租户配置存储、租户级设置隔离与功能开关 - **NuGet**:`XiHan.Framework.MultiTenancy` - **模块类**:`XiHanMultiTenancyModule` - **所在层**:应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Abstractions / Settings / Security / Core) ## 概述 本包是多租户能力的**实现层**,为 [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions) 定义的接口提供落地实现。它用 `AsyncLocal` 维护当前请求的租户上下文(跨 `async/await` 流转)、提供可扩展的**租户解析链**、把「租户级设置」接入设置体系、并提供基于设置存储的**租户功能开关**。你只需引用它并注入 `ICurrentTenant`,上下文的存取与恢复都在幕后完成。 需要强调的边界:本包只负责**上下文、存储、解析链的贡献者与选项**。真正在 HTTP 管线里「执行解析链、把结果写入当前租户」的中间件 `XiHanTenantResolveMiddleware`,以及基于 Header / QueryString 的解析贡献者,都在 [XiHan.Framework.Web.Api](./web-api) 中——本包只负责在解析链首位插入 `CurrentUserTenantResolveContributor`。 ## 何时使用 - 你的系统需要区分多个租户,并按租户隔离数据与配置 - 你需要在请求管线里自动识别当前租户(登录用户的令牌、`X-Tenant-Id` 头、`tenantId` 查询串——后两者由 Web.Api 提供贡献者,只对未认证请求生效) - 你需要租户级设置(同一设置项对不同租户取不同值) - 你需要从存储中按 Id 或名称查询租户配置(连接串、是否激活、版本等) - 你需要按租户判断某功能是否启用(`ITenantFeatureChecker`) ## 安装与启用 ```bash dotnet add package XiHan.Framework.MultiTenancy ``` ```csharp [DependsOn(typeof(XiHanMultiTenancyModule))] public class MyModule : XiHanModule { } ``` 模块 `[DependsOn]` 了 `XiHanMultiTenancyAbstractionsModule`、`XiHanSettingsModule`、`XiHanSecurityModule`,并在 `ConfigureServices` 里调用 `AddXiHanMultiTenancy(config)`,完成: - 注册 `ICurrentTenantAccessor` 为 `AsyncLocalCurrentTenantAccessor.Instance`(单例) - 绑定 `XiHanDefaultTenantStoreOptions`(配置节 `XiHan:MultiTenancy:DefaultStore`),`TryAddSingleton()` - 向 `XiHanSettingOptions.ValueProviders` 在 `GlobalSettingValueProvider` **之后**插入 `TenantSettingValueProvider`(提供者名 `"T"`) - 向 `XiHanTenantResolveOptions.TenantResolvers` 的**首位**(`Insert(0, ...)`)插入 `CurrentUserTenantResolveContributor` - 注册 `ITenantFeatureChecker` → `TenantFeatureChecker`(Transient) > `CurrentTenant`(`ICurrentTenant` 实现)标记了 `ITransientDependency`,通过框架的按接口约定注册自动生效,无需在扩展方法里手动登记。 ## 工作原理 ### 当前租户上下文(AsyncLocal) `AsyncLocalCurrentTenantAccessor` 用 `AsyncLocal` 保存当前租户,注册为进程单例(`Instance`)。`CurrentTenant.Change(id, name)` 的实现:先记住父级 `Current`,写入新的 `BasicTenantInfo`,返回一个 `DisposeAction`——`Dispose` 时把 `Current` 还原为父级。因此 `Change` 支持**嵌套**,且能安全地跨异步边界流转、在 `using` 结束时精确恢复到上一层。`IsAvailable` 等价于 `Id is > 0`:平台就是 0 号租户,`null` 与 `0` 都不算业务租户。 ### 租户解析链(贡献者 + 中间件) 解析链由 `XiHanTenantResolveOptions.TenantResolvers`(一个有序 `List`)承载。实际执行发生在 Web.Api 的 `XiHanTenantResolveMiddleware`(位于 `UseAuthentication` 之后、`UseAuthorization` 之前): ```text 请求进入 → 遍历 TenantResolvers(有序): 逐个 ResolveAsync(context),谁把 context.Handled = true 就短路 ↓ 得到 TenantIdOrName(若为空且未 Handled,回退到 FallbackTenant) ↓ 为空或为 "0" → 直接 next()(平台,即 0 号租户) ↓ ITenantStore 按 TenantIdOrName 查 TenantConfiguration (先按数字 Id 试 FindAsync(long),再按名称 FindAsync(string)) ↓ 来自令牌(CurrentUser 贡献者短路): 命中 → Change(cfg.Id, cfg.Name) 未命中但为数字 → Change(数字, key)(令牌已签名,租户目录由应用自管时仍以令牌为准) 来自外部输入(请求头 / 查询串 / FallbackTenant / 其余贡献者): 命中且 IsActive → Change(cfg.Id, cfg.Name) 否则 → 400 拒绝,不进入 next() ↓ 把用户与租户定型写回 IRequestContextAccessor,在租户作用域内执行 next() ``` 链的组成(默认顺序): 1. `CurrentUserTenantResolveContributor`(本包,插在首位)——已认证请求以令牌为准:带租户声明即该租户,不带即平台,两种情况都短路解析链,请求头与查询串无法替已认证身份另选租户;未认证请求跳过,交给后续贡献者 2. `HeaderTenantResolveContributor`(Web.Api 追加)——按 `HeaderKeys` 读请求头 3. `QueryStringTenantResolveContributor`(Web.Api 追加)——按 `QueryStringKeys` 读查询串 要新增/调整解析来源,`Configure` 增删 `TenantResolvers` 即可(见「扩展点」)。 ### 租户级设置隔离 `TenantSettingValueProvider`(提供者名 `"T"`)继承设置包的 `SettingValueProvider`,读值时以「当前租户键」作为 ProviderKey 调设置存储。租户键只取 `ICurrentTenant.Id`(大于 0 时)的字符串形式,平台(0 号租户)没有租户级取值;不用租户名称定键,因为同一租户在请求管道里带名称切入、在后台按标识切入,按名称定键会落出两套设置。它被插入到设置值提供者链的 `GlobalSettingValueProvider`(`"G"`)之后,从而实现「先看全局、再被租户覆盖」的语义。详见 [Settings](./settings)。 ### 租户功能开关 `TenantFeatureChecker` 复用设置存储:把功能名加前缀 `Feature:` 组成设置键,以租户标识作 ProviderKey(与租户级设置同一口径)、`"T"` 作 ProviderName 读值;平台(0 号租户)没有租户级功能值。`IsEnabledAsync` 把 `1/true/yes/on`(忽略大小写)视为启用,其它/空值返回传入的 `defaultValue`。 ## 核心能力 - **当前租户上下文**:`CurrentTenant`(`ICurrentTenant`,Transient)+ `AsyncLocalCurrentTenantAccessor`(单例),`Change(...)` 支持嵌套临时切换并在释放时恢复 - **租户解析链**:`ITenantResolveContributor` 组成有序链,内置 `CurrentUserTenantResolveContributor`;`TenantResolveContributorBase` 是自定义贡献者基类 - **租户配置存储**:`ITenantStore` 按 Id / 名称查询 `TenantConfiguration`,默认实现 `DefaultTenantStore` 从配置节读取 - **租户级设置隔离**:`TenantSettingValueProvider`(`"T"`),从设置存储按当前租户键取值 - **租户功能开关**:`ITenantFeatureChecker` / `TenantFeatureChecker` 基于设置存储判断功能是否启用 ## 主要 API / 类型 ### 上下文与解析 | 类型 | 说明 | | --- | --- | | `CurrentTenant` | `ICurrentTenant` 实现(`ITransientDependency`),委托给 `ICurrentTenantAccessor`;`IsAvailable => Id is > 0` | | `AsyncLocalCurrentTenantAccessor` | 基于 `AsyncLocal` 的访问器,单例 `Instance`,私有构造 | | `TenantResolveContributorBase` | 自定义租户解析贡献者的抽象基类(`abstract string Name` / `abstract Task ResolveAsync(...)`) | | `CurrentUserTenantResolveContributor` | 内置贡献者,`Name = "CurrentUser"`(常量 `ContributorName`);已认证请求取 `ICurrentUser.TenantId`(为空即平台)并短路解析链,未认证请求跳过 | | `XiHanTenantResolveOptions` | 解析选项(配置节 `XiHan:MultiTenancy:Resolve`),见下表 | ### 存储与功能 | 类型 | 说明 | | --- | --- | | `TenantConfiguration` | 租户配置模型:`long Id`、`string Name`、`string NormalizedName`、`ConnectionStrings? ConnectionStrings`、`bool IsActive`(默认 `true`)、`Guid? EditionId` | | `ITenantStore` | 租户存储契约:`Task FindAsync(long id, ...)`、`Task FindAsync(string name, ...)`、`Task> GetListAsync(bool includeInactive = true, ...)` | | `DefaultTenantStore` | 默认实现,从 `XiHanDefaultTenantStoreOptions.Tenants` 读取(`IOptionsMonitor` 热更新);`FindAsync(string)` 支持纯数字按 Id、否则按 `Name`/`NormalizedName` 不区分大小写匹配 | | `XiHanDefaultTenantStoreOptions` | 配置节 `XiHan:MultiTenancy:DefaultStore`,字段 `TenantConfiguration[] Tenants`(默认空数组) | | `TenantSettingValueProvider` | 租户级设置值提供者,`ProviderName = "T"`;租户键只取租户标识,平台没有租户级取值 | | `ITenantFeatureChecker` / `TenantFeatureChecker` | `Task IsEnabledAsync(string featureName, bool defaultValue = false)`、`Task GetValueOrNullAsync(string featureName)`;功能键前缀 `Feature:` | ## 配置 配置节 `XiHan:MultiTenancy:Resolve`(`XiHanTenantResolveOptions.SectionName`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `TenantResolvers` | `List` | 空(运行时由各模块插入) | 有序解析链,代码组装、不从配置绑定 | | `EnableHeaderResolve` | `bool` | `true` | 是否启用 Header 解析(供 Web.Api 的 Header 贡献者判断) | | `HeaderKeys` | `string[]` | `["X-Tenant-Id", "x-tenant-id", "TenantId"]` | Header 租户键(按优先级) | | `EnableQueryStringResolve` | `bool` | `true` | 是否启用 QueryString 解析 | | `QueryStringKeys` | `string[]` | `["tenantId", "tenant"]` | QueryString 租户键(按优先级) | | `FallbackTenant` | `string?` | `null` | 解析链未命中且未 Handled 时的回退租户标识;与请求头、查询串一样须在 `ITenantStore` 中存在且激活,否则请求以 400 拒绝 | 配置节 `XiHan:MultiTenancy:DefaultStore`(`XiHanDefaultTenantStoreOptions.SectionName`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Tenants` | `TenantConfiguration[]` | `[]` | `DefaultTenantStore` 的租户清单来源 | 示例 appsettings.json: ```json { "XiHan": { "MultiTenancy": { "Resolve": { "EnableHeaderResolve": true, "HeaderKeys": [ "X-Tenant-Id" ], "EnableQueryStringResolve": true, "QueryStringKeys": [ "tenantId" ], "FallbackTenant": null }, "DefaultStore": { "Tenants": [ { "Id": 1, "Name": "acme", "NormalizedName": "ACME", "IsActive": true } ] } } } } ``` ## 使用示例 ### 注入并读取当前租户 ```csharp public class OrderService(ICurrentTenant currentTenant) { public long? CurrentTenantId => currentTenant.Id; // null 与 0 同义:平台(0 号租户) } ``` ### 自定义租户解析贡献者并加入解析链 ```csharp public class ApiKeyTenantResolveContributor : TenantResolveContributorBase { public override string Name => "ApiKey"; public override Task ResolveAsync(ITenantResolveContext context) { // 通过 context.ServiceProvider 取请求级服务,解析出租户标识/名称 context.TenantIdOrName = "acme"; context.Handled = true; // 短路后续贡献者 return Task.CompletedTask; } } // 在模块 ConfigureServices 里插入(越靠前优先级越高) context.Services.Configure(options => { options.TenantResolvers.Insert(0, new ApiKeyTenantResolveContributor()); }); ``` ### 按租户读取功能开关 ```csharp public class FeatureGate(ITenantFeatureChecker featureChecker) { public Task CanExportAsync() => featureChecker.IsEnabledAsync("Report.Export", defaultValue: false); } ``` ## 扩展点 / 自定义 - **替换租户存储**:`ITenantStore` 用 `TryAddSingleton` 注册,上层可注册自己的实现(如查数据库)覆盖默认的 `DefaultTenantStore`。BasicApp 即用数据库实现替换。 - **扩展解析链**:`Configure` 增删 `TenantResolvers`;`Insert(0, ...)` 提高优先级,`Add(...)` 追加为兜底。 - **自定义功能检查**:`ITenantFeatureChecker` 为 Transient 注册,可按需替换实现。 ## 注意事项与最佳实践 - **平台就是 0 号租户**:`ICurrentTenant.Id` 为 `long?`,`null` 与 `0` 同义,都是平台;实体上 `TenantId = 0` 的行是平台 / 全局数据,业务租户从 1 开始。数据过滤、写边界、租户级设置与功能、租户解析统一按这一口径,平台同样只看、只写自己的数据,见 [多租户](../guide/multi-tenancy)。 - **外部输入的租户必须登记**:请求头、查询串与 `FallbackTenant` 给出的租户在 `ITenantStore` 查不到或未激活时,请求以 400 拒绝。靠匿名请求头识别租户的应用(如租户专属登录页),须把租户登记进 `ITenantStore`。 - **解析中间件不在本包**:`XiHanTenantResolveMiddleware` 与 Header/QueryString 贡献者在 [Web.Api](./web-api)。只引用本包不会自动解析请求中的租户,需要 Web.Api 模块接入中间件。 - **中间件在认证之后**:解析链首位的 `CurrentUserTenantResolveContributor` 依赖 `ICurrentUser`,因此中间件排在 `UseAuthentication()` 之后才能拿到已认证用户的 `TenantId`。 - **默认存储是内存快照**:`DefaultTenantStore` 每次查询会克隆配置快照(含连接串)返回,适合小规模静态租户;生产多租户建议替换为数据库版 `ITenantStore`。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions)(接口与模型来源) - [XiHan.Framework.Settings](./settings)(把租户级设置提供者接入设置链、承载功能开关) - [XiHan.Framework.Security](./security)(`ICurrentUser`,用于按当前用户解析租户) ## 相关模块 - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions) - [XiHan.Framework.Settings](./settings) - [XiHan.Framework.Web.Api](./web-api)(解析中间件与 Header/QueryString 贡献者) - [XiHan.Framework.Data](./data)(按租户过滤/路由数据) --- 来源:https://framework.docs.xihanfun.com/packages/object-storage # XiHan.Framework.ObjectStorage > 对象存储统一抽象:把「文件存到哪里」抽象成 `IFileStorageProvider`,本地磁盘与阿里云 OSS / MinIO / 腾讯云 COS 四种后端实现同一套上传、下载、删除、预签名 URL 等操作,并支持多提供程序管理与业务路由。 - **NuGet**:`XiHan.Framework.ObjectStorage` - **模块类**:`XiHanObjectStorageModule` - **所在层**:基础设施层 - **关键依赖**:第三方存储 SDK `Aliyun.OSS.SDK.NetCore`(阿里云 OSS)、`Minio`(MinIO)、`Tencent.QCloud.Cos.Sdk`(腾讯云 COS);框架内部仅依赖 `XiHan.Framework.Core`。 ## 概述 XiHan.Framework.ObjectStorage 把「文件存到哪里」抽象成统一接口 `IFileStorageProvider`:无论底层是本地磁盘、阿里云 OSS、MinIO 还是腾讯云 COS,业务代码都用同一套上传、下载、删除、存在性检查、元数据读取、复制/移动、目录列举、预签名 URL 与分片上传方法。四种后端各自封装对应的第三方 SDK,统一继承抽象基类 `FileStorageProviderBase`。 在此之上,`IFileStorageProviderManager` 负责「按名称取出提供程序」(带缓存),`IFileStorageRouter` 负责「按业务路由键选择提供程序」。二者组合,让你可以同时启用多个后端,并把不同类型的文件(如头像、附件)路由到不同存储,本地开发用磁盘、上线切云端只改配置不改代码。 ## 何时使用 - 需要文件上传/下载能力,但不想把代码绑死在某一家云厂商上。 - 本地开发用磁盘存储、上线切换到 OSS/COS/MinIO,只改配置不改代码。 - 需要分片上传、断点续传、预签名临时访问 URL、文件复制/移动、目录列举等常见对象存储操作。 - 需要按业务把不同文件路由到不同存储桶或不同提供程序。 - 与 [XiHan.Framework.VirtualFileSystem](./virtual-file-system) 的区别:VirtualFileSystem 面向「本地物理目录 + 程序集嵌入资源」的统一虚拟路径**只读访问**(配置、模板、静态资源),不含云存储;ObjectStorage 才是面向对象存储/云存储的文件**读写与生命周期管理**。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.ObjectStorage ``` ```csharp [DependsOn(typeof(XiHanObjectStorageModule))] public class MyModule : XiHanModule { } ``` 模块在 `ConfigureServices` 中调用 `services.AddXiHanObjectStorage(config)`,据此完成: - 绑定五个配置节到对应 Options:`XiHan:ObjectStorage`(全局)、`:Local`、`:Minio`、`:AliyunOss`、`:TencentCos`。 - `TryAddSingleton` 注册 `IFileStorageProviderManager`(默认 `DefaultFileStorageProviderManager`)与 `IFileStorageRouter`(默认 `DefaultFileStorageRouter`)。 - 据全局配置的 `EnabledProviders` + `DefaultProvider` 自动注册被启用的 Provider(`TryAddSingleton` 具体类型 + 登记到 `XiHanObjectStorageProviderOptions`)。若两者都为空,回退到本地存储(`Local`)。 > Provider 是惰性构造并缓存的:`DefaultFileStorageProviderManager` 首次 `GetProvider(name)` 时从容器解析出实例并放入 `ConcurrentDictionary` 缓存,后续复用。 ## 工作原理 - **注册阶段**(`RegisterConfiguredProviders`):把 `EnabledProviders` 与 `DefaultProvider` 汇成一个大小写不敏感集合(空则加 `Local`),逐个按名称(`LOCAL` / `MINIO` / `ALIYUNOSS` / `TENCENTCOS`,其它值抛 `InvalidOperationException`)调用对应 `AddXxxFileStorageProvider` 扩展。每个扩展 `TryAddSingleton` 并把 `providerName → typeof(TProvider)` 登记进 `XiHanObjectStorageProviderOptions`。 - **解析阶段**(`IFileStorageProviderManager.GetProvider`):入参 provider 名为空时回退到 `XiHanObjectStorageOptions.DefaultProvider`;查 `ProviderTypes` 拿到类型,未注册则抛异常;用 `ConcurrentDictionary.GetOrAdd` 从容器解析并缓存实例。 - **路由阶段**(`IFileStorageRouter.ResolveProviderName`):显式 `providerName` 优先级最高;否则按 `routeKey` 查 `RouteProviderMappings`(大小写不敏感)命中即用;未命中时——`StrictRouteMatch=true` 抛异常,否则回退 `DefaultProvider`。 - **上传统一计时**:`FileStorageProviderBase.UploadAsync` 包裹子类 `UploadCoreAsync`,统一计时(`DurationMs`)并把异常收敛为 `FileUploadResult { Success=false, ErrorMessage=... }`(不抛出)。 ## 核心能力 - **统一存储抽象**:`IFileStorageProvider` 定义上传、批量上传、下载、删除、批量删除、存在性检查、获取元数据、复制、移动、目录列举、预签名 URL 与分片上传全套操作,四种后端实现同一接口。 - **能力自描述**:每个 Provider 通过 `SupportChunkedUpload` / `SupportResumableUpload` 声明是否支持分片/断点续传。基类默认二者为 `false`,不支持的方法调用会抛 `NotSupportedException`。 - **多提供程序管理**:`IFileStorageProviderManager` 按名称取出提供程序(带缓存),可 `TryGetProvider` 安全获取、`GetRegisteredProviderNames` 查询已注册名单。 - **业务路由**:`IFileStorageRouter` 按业务路由键(如 `avatar`、`attachment`)解析并选择目标提供程序,支持严格匹配开关 `StrictRouteMatch`。 - **分片与断点续传**:`InitiateChunkedUploadAsync` / `UploadChunkAsync` / `CompleteChunkedUploadAsync` / `AbortChunkedUploadAsync` 覆盖大文件分片上传全流程;Local / OSS / COS 会在新上传开始时回收超过 24 小时无活动的进程内会话,本地实现同时删除临时分片目录。 - **预签名 URL**:`GeneratePresignedUrlAsync(path, expiresIn)` 生成带过期时间的临时访问链接(云存储提供真实签名;本地存储直接返回静态可访问 URL)。 - **本地存储直链**:本地提供程序默认落在 `wwwroot/Uploads` 下,配合 Web.Api 静态文件服务可直接通过 URL 前缀访问(见「配置」)。 ## 主要 API / 类型 ### 契约与基类 | 类型 | 说明 | | --- | --- | | `IFileStorageProvider` | 存储提供程序接口,全部读写操作的统一契约 | | `FileStorageProviderBase` | 抽象基类:统一上传计时/异常收敛、默认「不支持分片/预签名」实现、`NormalizePath`/`GetFileExtension`/`ComputeFileHashAsync`(MD5)等工具 | | `IFileStorageProviderManager` / `DefaultFileStorageProviderManager` | 按名称获取提供程序(带缓存),支持 `TryGetProvider`、`GetRegisteredProviderNames` | | `IFileStorageRouter` / `DefaultFileStorageRouter` | 按业务路由键选择提供程序,`ResolveProviderName` / `Route` | | `ObjectStorageProviderNames` | 提供程序名称常量:`Local="Local"`、`Minio="MinIO"`、`AliyunOss="AliyunOSS"`、`TencentCos="TencentCOS"` | ### `IFileStorageProvider` 关键方法签名 | 方法 | 说明 | | --- | --- | | `string ProviderName { get; }` | 提供程序名称 | | `bool SupportChunkedUpload { get; }` / `bool SupportResumableUpload { get; }` | 能力声明 | | `Task UploadAsync(FileUploadRequest request, ...)` | 上传文件 | | `Task> UploadManyAsync(IReadOnlyList requests, ...)` | 批量上传,结果与请求顺序一一对应,单个失败不影响其余文件 | | `Task InitiateChunkedUploadAsync(ChunkedUploadInitRequest request, ...)` | 初始化分片上传,返回 uploadId | | `Task UploadChunkAsync(ChunkUploadRequest request, ...)` | 上传单个分片 | | `Task CompleteChunkedUploadAsync(ChunkedUploadCompleteRequest request, ...)` | 合并完成分片上传 | | `Task AbortChunkedUploadAsync(string uploadId, ...)` | 取消分片上传 | | `Task DownloadAsync(string path, ...)` | 下载文件为流 | | `Task DeleteAsync(string path, ...)` / `Task DeleteAsync(string path, string? bucketName, ...)` | 删除文件(可指定桶) | | `Task DeleteManyAsync(IReadOnlyList paths, string? bucketName = null, ...)` | 批量删除,全部尝试后把失败路径汇总为 `AggregateException` | | `Task ExistsAsync(string path, ...)` / `(string path, string? bucketName, ...)` | 存在性检查 | | `Task GetMetadataAsync(string path, ...)` / `(string path, string? bucketName, ...)` | 读取元数据 | | `Task GeneratePresignedUrlAsync(string path, TimeSpan expiresIn, ...)` | 生成预签名 URL | | `Task CopyAsync(string sourcePath, string destinationPath, ...)` | 复制文件 | | `Task MoveAsync(string sourcePath, string destinationPath, ...)` | 移动文件 | | `Task> ListFilesAsync(string path, bool recursive = false, ...)` | 列出目录下文件 | ### 提供程序实现与能力对照 | 实现类 | `ProviderName` | 分片上传 | 断点续传 | 预签名 URL | | --- | --- | --- | --- | --- | | `LocalFileStorageProvider` | `"Local"` | ✅ | ✅ | 返回静态直链(`expiresIn` 被忽略) | | `AliyunOssStorageProvider` | `"AliyunOSS"` | ✅ | ✅ | ✅(真实签名) | | `MinioFileStorageProvider` | `"MinIO"` | ❌ | ❌ | ✅(真实签名) | | `TencentCosStorageProvider` | `"TencentCOS"` | ✅ | ✅ | ✅(真实签名) | > 以源码为准:`MinioFileStorageProvider` 当前**不声明**分片/断点续传(`SupportChunkedUpload => false`),但覆盖了四个分片方法作为占位:初始化返回随机 GUID、上传分片返回假成功(数据被丢弃)、完成返回 `Success=false`、取消为空操作,不会抛 `NotSupportedException`。上传大文件前请先检查目标 Provider 的 `SupportChunkedUpload`。 ### 数据模型 | 类型 | 关键字段 | | --- | --- | | `FileUploadRequest` | `FileStream`、`FileName`、`StoragePath`、`ContentType?`、`BucketName?`、`Overwrite`、`AccessControl`(默认 `"private"`)、`Metadata?`、`CacheControl?`、`ProgressCallback?` | | `FileUploadResult` | `Success`、`Path?`、`FullPath?`、`Url?`、`FileSize`、`ETag?`、`DurationMs`、`ErrorMessage?`、`Extra?` | | `FileMetadata` | `Name?`、`Path?`、`Size`、`ContentType?`、`LastModified?`、`ETag?`、`IsDirectory`、`Url?`、`Metadata?` | | `ChunkedUploadInitRequest` | `FileName`、`StoragePath`、`TotalSize`、`ChunkSize`(默认 5MB)、`ContentType?`、`BucketName?`、`AccessControl`、`Metadata?` | | `ChunkUploadRequest` | `UploadId`、`StoragePath`、`ChunkNumber`(从 1 起)、`ChunkData`、`ChunkSize`、`TotalSize`、`TotalChunks`、`ChunkMd5?`、`BucketName?` | | `ChunkUploadResult` | `Success`、`ChunkNumber`、`ETag?`、`ErrorMessage?` | | `ChunkedUploadCompleteRequest` / `ChunkInfo` | `UploadId`、`StoragePath`、`ChunkInfos`(`ChunkNumber` + `ETag?`)、`BucketName?` | ## 配置 全局配置节 `XiHan:ObjectStorage`(`XiHanObjectStorageOptions.SectionName`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `DefaultProvider` | `string` | `"Local"` | 默认存储提供程序名称 | | `EnabledProviders` | `string[]` | `["Local"]` | 启用的存储提供程序列表 | | `RouteProviderMappings` | `Dictionary` | 空(大小写不敏感) | 业务路由键 → 提供程序名 | | `StrictRouteMatch` | `bool` | `false` | 有路由键但未命中映射时是否抛异常 | 本地存储 `XiHan:ObjectStorage:Local`(`LocalStorageOptions`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `RootPath` | `string` | `"wwwroot/Uploads"` | 本地存储根目录(默认置于 Web 根 `wwwroot` 下,便于静态托管与直链) | | `UrlPrefix` | `string` | `"/uploads"` | 生成访问 URL 时的路径前缀 | > 本地存储配置节 `XiHan:ObjectStorage:Local`(`RootPath` / `UrlPrefix`)也被 **Web.Api** 用于挂载静态文件服务:`XiHanWebApiModule` 在鉴权中间件之前经 `IConfiguration` 读取这两个字段,以 `UseStaticFiles` + `PhysicalFileProvider` 把该目录映射到 `UrlPrefix` 请求路径,使本地存储返回的静态 URL(头像、公开文件等)可匿名直链访问。请显式配置 `RootPath`:对象存储默认值是 `wwwroot/Uploads`,而 Web.Api 完全无配置时仍回退 `wwwroot/uploads`;大小写敏感系统会把它们视为不同目录。 云存储 `XiHan:ObjectStorage:AliyunOss`(`AliyunOssStorageOptions`):`AccessKeyId`、`AccessKeySecret`、`Endpoint`、`DefaultBucket`、`CdnDomain?`、`UseInternal`(默认 `false`)。 云存储 `XiHan:ObjectStorage:Minio`(`MinioStorageOptions`):`Endpoint`、`AccessKey`、`SecretKey`、`DefaultBucket`、`UseSSL`(默认 `false`)、`Region?`。 云存储 `XiHan:ObjectStorage:TencentCos`(`TencentCosStorageOptions`):`SecretId`、`SecretKey`、`AppId`、`Region`、`DefaultBucket`、`CdnDomain?`。 示例 `appsettings.json`: ```json { "XiHan": { "ObjectStorage": { "DefaultProvider": "Local", "EnabledProviders": [ "Local", "AliyunOSS" ], "RouteProviderMappings": { "avatar": "Local", "attachment": "AliyunOSS" }, "StrictRouteMatch": false, "Local": { "RootPath": "wwwroot/Uploads", "UrlPrefix": "/uploads" }, "AliyunOss": { "AccessKeyId": "your-access-key-id", "AccessKeySecret": "your-access-key-secret", "Endpoint": "oss-cn-hangzhou.aliyuncs.com", "DefaultBucket": "your-bucket", "CdnDomain": null, "UseInternal": false }, "Minio": { "Endpoint": "minio.example.com:9000", "AccessKey": "minioadmin", "SecretKey": "minioadmin", "DefaultBucket": "your-bucket", "UseSSL": false, "Region": null }, "TencentCos": { "SecretId": "your-secret-id", "SecretKey": "your-secret-key", "AppId": "your-app-id", "Region": "ap-guangzhou", "DefaultBucket": "your-bucket-appid", "CdnDomain": null } } } } ``` > 配置节大小写:全局节键为 `AliyunOss` / `Minio` / `TencentCos`(与各 Options 的 `SectionName` 一致),而提供程序**名称**常量是 `AliyunOSS` / `MinIO` / `TencentCOS`(用于 `EnabledProviders`、`DefaultProvider`、`RouteProviderMappings` 的值)。二者写法不同,别混用。 ## 使用示例 ### 上传文件并取得访问 URL ```csharp public class AvatarService { private readonly IFileStorageRouter _router; public AvatarService(IFileStorageRouter router) => _router = router; public async Task UploadAvatarAsync(Stream fileStream, string fileName) { // 按业务路由键 "avatar" 选择提供程序(据 RouteProviderMappings,未命中回退默认) var provider = _router.Route(routeKey: "avatar"); var result = await provider.UploadAsync(new FileUploadRequest { FileStream = fileStream, FileName = fileName, StoragePath = $"avatars/{Guid.NewGuid():N}/{fileName}", ContentType = "image/png", AccessControl = "public-read", Overwrite = true }); // result.Url 即可用于前端展示;本地存储下为 /uploads/... 静态直链 return result.Success ? result.Url : null; } } ``` ### 按名称取用指定提供程序 + 生成预签名 URL ```csharp public class DownloadService { private readonly IFileStorageProviderManager _manager; public DownloadService(IFileStorageProviderManager manager) => _manager = manager; public async Task GetTempLinkAsync(string path) { // 显式取阿里云 OSS 提供程序(名称用 ObjectStorageProviderNames.AliyunOss) var provider = _manager.GetProvider(ObjectStorageProviderNames.AliyunOss); // 生成 15 分钟有效的临时访问链接 return await provider.GeneratePresignedUrlAsync(path, TimeSpan.FromMinutes(15)); } } ``` ## 扩展点 / 自定义 - **自定义提供程序**:实现 `IFileStorageProvider`(建议继承 `FileStorageProviderBase` 复用计时/异常收敛/工具方法),用 `services.AddFileStorageProvider("MyStorage")` 注册(`TryAddSingleton` 具体类型 + 登记名称→类型),随后即可用 `GetProvider("MyStorage")` 取用,或在 `RouteProviderMappings` 中把某业务路由到它。 - **替换核心服务**:`IFileStorageProviderManager` / `IFileStorageRouter` 均以 `TryAddSingleton` 注册,可在模块中先行注册自定义实现以覆盖默认行为。 - **单后端便捷注册**:也可绕开配置驱动,直接调用 `AddLocalFileStorageProvider` / `AddMinioFileStorageProvider` / `AddAliyunOssFileStorageProvider` / `AddTencentCosFileStorageProvider`(各带可选 `Action` 配置委托)显式注册。 ## 注意事项与最佳实践 - **上传不抛异常**:`UploadAsync` 内部把异常收敛为 `FileUploadResult { Success=false, ErrorMessage=... }`。调用方务必检查 `result.Success`,不要只 `try/catch`。 - **多个文件用批量方法**:一次处理多个文件时调用 `UploadManyAsync` / `DeleteManyAsync`,不要在调用方循环单条方法。批量上传逐项检查 `Success`,取消会抛出而不会被收成失败结果;批量删除会尝试每个路径,失败项在结束后汇总抛出。基类默认逐个转调单条方法,支持原生批量接口的 Provider 可覆写。 - **分片能力先检查**:调用分片方法前先看目标 Provider 的 `SupportChunkedUpload`;MinIO 的分片方法是占位实现,调用不会报错但数据不会落盘,大文件改用 `UploadAsync`。 - **云端未完成分片仍需生命周期策略**:框架会回收 24 小时无活动的进程内 OSS/COS 会话,但不会在请求线程批量调用远端 Abort;生产桶应配置清理未完成 Multipart Upload 的生命周期规则。 - **本地预签名无时效**:`LocalFileStorageProvider.GeneratePresignedUrlAsync` 只是返回静态直链,`expiresIn` 被忽略,也没有鉴权。需要真实时效/鉴权控制请改用云存储 Provider。 - **提供程序名称大小写**:Manager/Router 内部用大小写不敏感比较,但常量值有固定拼写(`MinIO`/`AliyunOSS`/`TencentCOS`),配置里建议直接用 `ObjectStorageProviderNames` 的常量拼写以免误配。 - **未注册即用会抛异常**:只有出现在 `EnabledProviders`/`DefaultProvider` 里(或经 `AddFileStorageProvider` 显式注册)的 Provider 才可被解析;`GetProvider` 未注册名称会抛 `InvalidOperationException`,需要静默判断请用 `TryGetProvider`。 ## 依赖模块 - [XiHan.Framework.Core](./core) — 唯一的框架内部依赖,提供模块化与依赖注入基础。 第三方存储 SDK 为该包核心依赖:`Aliyun.OSS.SDK.NetCore`(阿里云 OSS)、`Minio`(MinIO)、`Tencent.QCloud.Cos.Sdk`(腾讯云 COS)。 ## 相关模块 - [XiHan.Framework.VirtualFileSystem](./virtual-file-system) — 虚拟文件系统,面向本地物理目录与程序集嵌入资源的统一虚拟路径只读访问;不含云存储适配,与本包定位互补。 - [XiHan.Framework.Web.Api](./web-api) — 消费本地存储配置节挂载静态文件服务,实现上传文件的匿名直链访问。 --- 来源:https://framework.docs.xihanfun.com/packages/objectmapping # XiHan.Framework.ObjectMapping > 对象映射:集成 Mapster 注册 `IMapper`,并内置一套"对象扩展属性(动态属性)"管理体系(存储 + 验证 + 访问策略)。 - **NuGet**:`XiHan.Framework.ObjectMapping` - **模块类**:`XiHanObjectMappingModule` - **所在层**:基础设施层 - **关键依赖**:`Mapster`(对象映射引擎)+ 框架内部依赖 `XiHan.Framework.Core` / `Localization.Abstractions` / `Validation.Abstractions` ## 概述 本包负责把一个对象的数据映射到另一个对象(如实体 ↔ DTO ↔ 视图模型)。它集成高性能映射库 **Mapster**,把 `MapsterMapper.IMapper` 注册进 DI 供全框架注入使用(生命周期 **Transient**)。除映射外,它还内置一套"**对象扩展属性**"机制:在不改动类定义的前提下,为任意实现 `IHasExtraProperties` 的对象动态挂载额外属性(`ExtraProperties`),并为这些动态属性配置**验证器**(对接 [Validation.Abstractions](./validation-abstractions))与三层**访问策略**(全局功能 / 功能开关 / 权限)。可本地化显示名等能力依赖 [Localization.Abstractions](./localization-abstractions)。 ## 何时使用 - 需要在实体、DTO、视图模型之间做批量属性映射,又不想手写赋值代码 → 注入 `IMapper`。 - 想为已有类型**动态追加属性**(`ExtraProperties`)并参与序列化/持久化。 - 需要给动态扩展属性配置验证规则、UI/查找配置,或按功能/权限门控其可见性。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.ObjectMapping ``` ```csharp [DependsOn(typeof(XiHanObjectMappingModule))] public class MyModule : XiHanModule { } ``` 模块 `ConfigureServices` 调用 `AddXiHanMapster()`,其中 `services.AddTransient()` 把 Mapster 的 `IMapper` 注册为 **Transient**。`ExtensionPropertyPolicyChecker` 实现 `ITransientDependency`,由框架约定自动注册。`ObjectExtensionManager` 是**静态单例**(`ObjectExtensionManager.Instance`),不走 DI。 > 依赖模块:`XiHanObjectMappingModule` 上标注 `[DependsOn]` 引入 `XiHanLocalizationAbstractionsModule`、`XiHanValidationAbstractionsModule`。 ## 核心能力 - **Mapster 集成**:注册 `IMapper`,提供高性能对象映射引擎(映射规则用 Mapster 原生 API 配置)。 - **对象扩展管理**:`ObjectExtensionManager` 单例集中管理各类型的动态扩展属性、类型级配置字典与对象级验证器。 - **动态属性存储**:`IHasExtraProperties` + `ExtraPropertyDictionary` 为对象提供可序列化的键值对额外属性存储。 - **属性读写扩展**:`HasExtraPropertiesExtensions` 提供类型安全的 `GetProperty` / `SetProperty` / `RemoveProperty` 等便捷方法(含验证钩子)。 - **扩展属性验证**:设置值时按扩展属性定义的验证规则校验并收集错误(`ObjectExtensionValidationContext` / `ObjectExtensionPropertyValidationContext`)。 - **三层访问策略**:`ExtensionPropertyPolicyChecker` 按全局功能、功能开关、权限校验扩展属性可访问性,配合 `GetPropertiesAndCheckPolicyAsync` 过滤可见属性。 ## 主要 API / 类型 ### 映射 | 类型 | 说明 | | --- | --- | | `MapsterMapper.IMapper` | Mapster 映射器,DI 注入使用(Transient)。常用 `TDestination Map(object source)` / `Map(...)` | | `XiHanObjectMappingServiceCollectionExtensions` | DI 入口 `AddXiHanMapster(IServiceCollection)` | ### 动态扩展属性 | 类型 | 说明 | | --- | --- | | `IHasExtraProperties` | 定义 `ExtraPropertyDictionary ExtraProperties { get; }`,用于动态属性存储 | | `ExtraPropertyDictionary` | 额外属性字典,继承 `Dictionary`(可序列化) | | `ObjectExtensionManager` | 扩展管理器单例(`Instance`)。`AddOrUpdate(...)` / `AddOrUpdate(Type, ...)` / `AddOrUpdate(Type[], ...)`;`GetOrNull()`;`GetExtendedObjects()` | | `ObjectExtensionInfo` | 特定类型的扩展信息:`Type`、`Configuration`、对象级 `Validators`、属性字典与查询方法 | | `ObjectExtensionPropertyInfo` | 扩展属性信息,含 UI 配置、查找配置、`Policy`(策略配置)、默认值 | | `IBasicObjectExtensionPropertyInfo` | 扩展属性信息的基础契约 | `HasExtraPropertiesExtensions`(静态扩展,作用于 `IHasExtraProperties`): | 方法 | 说明 | | --- | --- | | `bool HasProperty(this IHasExtraProperties source, string name)` | 是否存在该额外属性 | | `object? GetProperty(this IHasExtraProperties source, string name, object? defaultValue = null)` | 取值(弱类型) | | `TProperty? GetProperty(this IHasExtraProperties source, string name, TProperty? defaultValue = default)` | 取值(强类型,仅支持原始/枚举/`Guid`;非原始类型抛 `XiHanException`) | | `TSource SetProperty(this TSource source, string name, object? value, bool validate = true)` | 设值(`validate=true` 时先走 `ExtensibleObjectValidator.CheckValue`),链式返回 | | `TSource RemoveProperty(this TSource source, string name)` | 移除,链式返回 | | `TSource SetDefaultsForExtraProperties(this TSource source, Type? objectType = null)` | 按扩展定义填充缺省值 | | `void SetExtraPropertiesToRegularProperties(this IHasExtraProperties source)` | 把同名额外属性回填到常规属性并移除 | | `bool HasSameExtraProperties(this IHasExtraProperties source, IHasExtraProperties other)` | 比较两对象额外属性是否相同 | `ObjectExtensionManagerExtensions`(静态扩展,作用于 `ObjectExtensionManager`):`AddOrUpdateProperty(name, configure?)`(及 `Type` / `Type[]` 重载)、`GetPropertyOrNull(name)`、`GetProperties()`、`GetPropertiesAndCheckPolicyAsync(IServiceProvider)`(按策略过滤可见属性)。 `ExtraPropertyDictionaryExtensions`:`T ToEnum(this ExtraPropertyDictionary dict, string key) where T : Enum` 与非泛型重载 `object ToEnum(this ExtraPropertyDictionary dict, string key, Type enumType)`(值已是目标枚举类型时直接返回,否则原地 `Enum.Parse` 并写回字典);`bool HasSameItems(this ExtraPropertyDictionary dict, ExtraPropertyDictionary other)` 按键逐一比较(值用 `ToString()` 比较),供 `HasSameExtraProperties` 内部调用。 ### 验证与策略 | 类型 | 说明 | | --- | --- | | `ExtensibleObjectValidator` | 扩展对象验证器:`CheckValue(...)` 校验单个属性值、聚合对象级/属性级验证 | | `ObjectExtensionValidationContext` | 对象级验证上下文,聚合扩展信息与错误集合 | | `ObjectExtensionPropertyValidationContext` | 属性级验证上下文,含属性值、错误集合与 `IServiceProvider` | | `ExtensionPropertyPolicyChecker` | `ITransientDependency`。`Task CheckPolicyAsync(ExtensionPropertyPolicyConfiguration policy)`,按全局功能→功能开关→权限依次校验 | | `ExtensionPropertyPolicyConfiguration` | 策略聚合:`GlobalFeatures` / `Features` / `Permissions`(各自含名称集合 + `RequiresAll`) | | `ExtensionPropertyGlobalFeaturePolicyConfiguration` / `ExtensionPropertyFeaturePolicyConfiguration` / `ExtensionPropertyPermissionPolicyConfiguration` | 三类策略的具体配置 | | `ExtensionPropertyLookupConfiguration` | 扩展属性的查找/字典配置 | | `ExtensionPropertyHelper` | 静态帮助类:`GetDefaultAttributes(Type)` 按属性类型自动生成默认验证特性——非可空基元类型或枚举类型自动追加 `RequiredAttribute`,枚举类型再追加 `EnumDataTypeAttribute`;`GetDefaultValue(propertyType, defaultValueFactory, defaultValue)` 按 `defaultValueFactory > defaultValue > 类型默认值` 优先级解析。`ObjectExtensionPropertyInfo` 构造时会自动调用前者填充其 `Attributes` | | `ObjectExtensionPropertyInfoExtensions` | `GetValidationAttributes(this ObjectExtensionPropertyInfo)`:从 `Attributes` 中筛出 `ValidationAttribute[]`,供 `ExtensibleObjectValidator` 校验流程使用 | ## 使用示例 ### 1. `IMapper` 映射 DTO ↔ 实体 ```csharp using MapsterMapper; public class UserAppService(IMapper mapper) { // 实体 -> DTO public UserDto ToDto(UserEntity entity) => mapper.Map(entity); // DTO -> 实体(写入已有实例) public UserEntity Apply(UpdateUserDto dto, UserEntity entity) => mapper.Map(dto, entity); } ``` > 字段名/类型不一致的自定义映射规则用 Mapster 原生 API(`TypeAdapterConfig`)配置,本包只负责注册 `IMapper`。 ### 2. 定义并读写动态扩展属性 ```csharp using XiHan.Framework.ObjectMapping; using XiHan.Framework.ObjectMapping.Extensions; using XiHan.Framework.ObjectMapping.Extensions.Data; public class Customer : IHasExtraProperties { public ExtraPropertyDictionary ExtraProperties { get; } = new(); } // 启动时为 Customer 注册一个扩展属性(可挂验证/策略) ObjectExtensionManager.Instance .AddOrUpdateProperty("VipLevel"); // 运行时读写(SetProperty 默认触发验证) var customer = new Customer(); customer.SetProperty("VipLevel", "Gold"); var level = customer.GetProperty("VipLevel"); // "Gold" ``` ### 3. 按访问策略过滤可见扩展属性 ```csharp // 在有 IServiceProvider 的上下文中,仅返回通过全局功能/功能/权限检查的属性 var visible = await ObjectExtensionManager.Instance .GetPropertiesAndCheckPolicyAsync(serviceProvider); ``` ## 扩展点 / 自定义 - **自定义映射规则**:用 Mapster `TypeAdapterConfig` 在应用启动时注册。 - **扩展属性验证器**:在 `AddOrUpdateProperty` 的 `configure` 委托里配置属性验证;对象级验证挂 `ObjectExtensionInfo.Validators`。 - **访问策略门控**:在 `ObjectExtensionPropertyInfo.Policy` 上配置全局功能/功能/权限,用 `GetPropertiesAndCheckPolicyAsync` 过滤。 ## 注意事项与最佳实践 - **`IMapper` 是 Transient**:不要缓存为长生命周期单例字段;按需注入。 - **`GetProperty` 类型限制**:仅支持原始类型、枚举、`Guid`(内部走 `Convert.ChangeType` / `Enum.Parse` / `TypeConverter`);复杂类型请用弱类型 `GetProperty` 再自行转换,否则抛 `XiHanException`。 - **`SetProperty` 默认验证**:`validate=true` 会走 `ExtensibleObjectValidator.CheckValue`,未注册扩展属性或值不合规会抛错;确定跳过时显式传 `validate: false`。 - **`ObjectExtensionManager` 是静态单例**:扩展属性定义通常在应用启动阶段一次性注册,运行期只读写值。 - **非可空基元/枚举类型会自动挂 `[Required]`**:`AddOrUpdateProperty` 若 `TProperty` 是非可空基元类型(如 `int`、`bool`)或枚举,`ExtensionPropertyHelper.GetDefaultAttributes` 会自动追加 `RequiredAttribute`(枚举再加 `EnumDataTypeAttribute`);如需允许空值,请改用可空类型(如 `int?`)或在 `configureAction` 中自行调整 `Attributes`。 ## 依赖模块 - 内部依赖:[XiHan.Framework.Core](./core)、[XiHan.Framework.Localization.Abstractions](./localization-abstractions)、[XiHan.Framework.Validation.Abstractions](./validation-abstractions)。 - 第三方核心:`Mapster`。 ## 相关模块 - [XiHan.Framework.Validation.Abstractions](./validation-abstractions) — 扩展属性验证依赖其验证抽象。 - [XiHan.Framework.Localization.Abstractions](./localization-abstractions) — 扩展属性可本地化显示名等能力的抽象来源。 --- 来源:https://framework.docs.xihanfun.com/packages/observability # XiHan.Framework.Observability > 可观测性基础库:健康检查、指标采集、性能监控、运行时诊断,并内置可选的 OpenTelemetry 装配(链路追踪 / 指标导出),默认零运行时开销、按需开启。 - **NuGet**:`XiHan.Framework.Observability` - **模块类**:`XiHanObservabilityModule` - **所在层**:基础设施层 - **关键依赖**:`Microsoft.Extensions.Diagnostics.HealthChecks`、`System.Diagnostics.PerformanceCounter`、`OpenTelemetry.Extensions.Hosting` 系列包(Tracing/Metrics/OTLP/Console 导出器);框架内部仅依赖 `XiHan.Framework.Core` ## 概述 XiHan.Framework.Observability 把「应用当前健康吗、跑得快不快、进程内部状态如何」这类运行时观察能力封装成一组可注入服务,覆盖四个方向:健康检查、指标采集、性能监控、运行时诊断。性能监控与运行时诊断全部基于 BCL(`GC`、`Process`、`ThreadPool`、`Stopwatch` 等)与 `Microsoft.Extensions.Diagnostics.HealthChecks` 实现,采集结果保存在进程内内存中,供上层应用自行读取、展示或转发。 包内同时内置了 OpenTelemetry SDK 的**可选**装配:由 `XiHanObservabilityOptions.Enabled` 总开关控制(默认 `false`),关闭时保持「装配即孤儿、零运行时行为」;开启后可选择性启用链路追踪(Tracing,基于 `XiHan.Framework.Core.Tracing.XiHanActivitySources` 汇集的框架内置 `ActivitySource`)与指标导出(Metrics,`IMetricsCollector` 底层 `Meter` 直出),支持 OTLP 与控制台导出器。**不内置** Prometheus/Jaeger/Grafana 等具体后端,只负责按标准协议把数据导出去。 ## 何时使用 - 想在代码里记录计数器、测量值(Gauge)、直方图,或对一段操作计时——底层直接对接 `System.Diagnostics.Metrics.Meter`,可选导出到 OTel 后端。 - 需要监控关键操作耗时、汇总 P50/P95/P99 统计、筛出慢操作(进程内存,不依赖 OTel)。 - 想读取进程的系统、运行时、内存、线程等诊断信息,或按需触发 GC。 - 需要一个基于内存阈值的健康检查(`MemoryHealthCheck`)接入 ASP.NET Core 健康检查端点。 - 需要把框架内置的链路 Span(`XiHan.Data`/`XiHan.EventBus`/`XiHan.Grpc`/`XiHan.Cache`/`XiHan.AI`/`XiHan.App`)与自定义指标导出到 OTLP 兼容后端(如 Jaeger、Tempo、Prometheus 网关)。 - 不适用:需要具体 APM 产品的深度集成、指标长期持久化或自带可视化面板时,本包只负责标准化采集/导出,不提供,需要另行接入相应生态。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Observability ``` ```csharp [DependsOn(typeof(XiHanObservabilityModule))] public class MyModule : XiHanModule { } ``` 模块在 `ConfigureServices` 中调用扩展方法 `AddXiHanObservability(configuration)`,它依次做: 1. 绑定配置节 `XiHan:Observability` 到 `XiHanObservabilityOptions`(同时 `services.Configure`); 2. 调用 `services.AddHealthChecks()` 注册健康检查基础设施(`HealthCheckService`); 3. 以**单例**注册 `IMetricsCollector` → `MetricsCollector`; 4. 以**单例**注册 `IPerformanceMonitor` → `PerformanceMonitor` 与 `IDiagnosticsService` → `DiagnosticsService`; 5. 若 `options.Enabled` 为 `false`(默认值),到此为止直接返回,**不装配** OpenTelemetry SDK; 6. 若 `options.Enabled` 为 `true`,调用 `services.AddOpenTelemetry()`,配置 `Resource`(`service.name`/`service.version`),并按 `EnableTracing`/`EnableMetrics` 分别装配 `WithTracing(...)` / `WithMetrics(...)`(见「配置项」一节)。 > 注意:`AddXiHanObservability` **不会**注册任何具体的 `IHealthCheck`(包括 `MemoryHealthCheck`)。健康检查项需要你在应用侧显式 `AddCheck` 接入(见「使用示例」)。OpenTelemetry 的 Logging(OTLP 日志导出)目前仅有 `EnableLogging` 配置项占位,扩展方法尚未接入实际的 `WithLogging(...)` 装配(源码注释标注「后续增量」),暂不要依赖它导出日志。 ## 配置项(`XiHanObservabilityOptions`) 绑定配置节 `XiHan:Observability`: | 属性 | 默认值 | 说明 | | --- | --- | --- | | `Enabled` | `false` | OpenTelemetry 总开关;关闭则不装配 SDK,仅保留自研诊断/指标/性能与健康检查 | | `ServiceName` | `"XiHan.App"` | 写入 OTel `Resource` 的 `service.name` | | `ServiceVersion` | `null` | 写入 OTel `Resource` 的 `service.version` | | `EnableTracing` | `true` | 是否启用链路追踪(`WithTracing`) | | `EnableMetrics` | `false` | 是否启用指标导出(`WithMetrics`) | | `EnableLogging` | `false` | 是否启用日志导出(OTLP Logs);**当前仅占位,扩展方法未接入实际装配** | | `SamplingRatio` | `1.0` | 采样率(0~1),`ParentBased(TraceIdRatioBased)` | | `OtlpEndpoint` | `null` | OTLP 导出端点(如 `http://localhost:4317`);为空则不启用 OTLP 导出器 | | `ConsoleExporter` | `false` | 是否额外输出到控制台导出器(dev 友好,prod 建议关闭) | | `AdditionalSources` | `[]` | 应用自定义要监听的 `ActivitySource` 名列表;框架内置源由 `XiHanActivitySources.All` 自动纳入,无需在此重复声明 | `Enabled=true` 且 `EnableTracing=true` 时,追踪装配会固定加上 `AddSource(XiHanActivitySources.All)`、`AddAspNetCoreInstrumentation()`、`AddHttpClientInstrumentation()`;`EnableMetrics=true` 时指标装配固定 `AddMeter(MetricsCollector.MeterName)`(即 `"XiHan.Metrics"`)。`ConsoleExporter`/`OtlpEndpoint` 对 Tracing 与 Metrics 是否导出各自独立生效。 ## 核心能力 - **指标采集(`IMetricsCollector` / `MetricsCollector`)**:记录计数器、测量值、直方图,`BeginTimer` 返回可释放计时器(`Dispose` 时以 `{name}.duration` 记录耗时直方图)。底层基于 `System.Diagnostics.Metrics.Meter`(`MeterName = "XiHan.Metrics"`)直出 OTel 指标管道——`RecordMeasurement` 内部委托给 `RecordHistogram`(无 pull 型 Gauge 回调上下文);**不再内存留存**,`GetMetrics()` 恒返回空列表、`Clear()` 为空操作(仅为接口向后兼容保留)。是否真正被导出取决于「配置项」一节的 `Enabled`/`EnableMetrics` 是否开启。 - **性能监控(`IPerformanceMonitor` / `PerformanceMonitor`)**:`BeginOperation` 返回 `IPerformanceTracker`,支持打标签与记录检查点;记录保存在独立进程内队列并只保留最近 10000 条,汇总为 `PerformanceStatistics`(含成功/失败计数、平均/最小/最大耗时与 P50/P95/P99),并可按阈值筛出慢操作。与 OTel 装配无关,不受 `Enabled` 影响。 - **运行时诊断(`IDiagnosticsService` / `DiagnosticsService`)**:读取系统、运行时、内存、线程信息,强制触发 GC,或一次性生成 `DiagnosticsReport` 完整报告。 - **健康检查(`IHealthCheck` 实现)**:`MemoryHealthCheck` 为真实实现——按内存阈值判定 `Healthy`/`Degraded` 并附带 GC 明细;读数取 `GC.GetGCMemoryInfo()` 的 `HeapSizeBytes - FragmentedBytes`,是最近一次 GC 后的托管堆实际占用,首次 GC 前为 0。 - **链路追踪源(`XiHan.Framework.Core.Tracing.XiHanActivitySources`)**:框架共享的 `ActivitySource` 名与实例,定义在 `Core` 而非本包(避免 Data/EventBus/Http/Web 等既依赖 Core 又要发 Span 却不能反向依赖 Observability);内置 `App`/`Data`/`EventBus`/`Grpc`/`Cache`/`Ai` 六个源,`All` 汇总供 OTel `AddSource` 一次性注册。 ## 主要 API / 类型 ### 指标采集 | 类型 | 说明 | | --- | --- | | `IMetricsCollector` | `void RecordCounter(string name, long value = 1, Dictionary? tags = null)`、`void RecordMeasurement(string, double, ...)`、`void RecordHistogram(string, double, ...)`、`IDisposable BeginTimer(string, ...)`、`IReadOnlyList GetMetrics()`(恒空)、`void Clear()`(空操作) | | `MetricsCollector.MeterName` | `const string` = `"XiHan.Metrics"`;`WithMetrics().AddMeter(MetricsCollector.MeterName)` 时使用 | | `MetricData` | 单条指标模型:`Name` / `Type`(`MetricType`)/ `Value` / `Tags` / `Timestamp` / `Unit`(当前仅作为 `GetMetrics()` 的返回元素类型保留,不再被实际填充) | | `MetricType` | 枚举:`Counter`、`Gauge`、`Histogram`、`Summary` | ### 性能监控 | 类型 | 说明 | | --- | --- | | `IPerformanceMonitor` | `IPerformanceTracker BeginOperation(string operationName)`、`PerformanceStatistics GetStatistics()`、`IReadOnlyList GetSlowOperations(double thresholdMs = 1000)`、`void Clear()` | | `IPerformanceTracker` | `IDisposable`;`string OperationName`、`void AddTag(string,string)`、`void Checkpoint(string)` | | `PerformanceRecord` | 单次操作记录:起止时间、`DurationMs`、`Tags`、`Checkpoints`、`Success`、`Exception` | | `PerformanceStatistics` | 汇总统计:总数/成功/失败、平均/最小/最大耗时、P50/P95/P99、按操作名分组的 `OperationStatistics` | ### 运行时诊断 | 类型 | 说明 | | --- | --- | | `IDiagnosticsService` | `SystemInfo GetSystemInfo()`、`RuntimeInfo GetRuntimeInfo()`、`MemoryInfo GetMemoryInfo()`(`AllocatedBytes` 与 `MemoryHealthCheck` 同口径:最近一次 GC 后的托管堆实际占用)、`ThreadInfo GetThreadInfo()`、`void ForceGarbageCollection()`、`DiagnosticsReport GetDiagnosticsReport()` | | `DiagnosticsReport` | 汇总 `SystemInfo` / `RuntimeInfo` / `MemoryInfo`(含 `GCInfo`)/ `ThreadInfo` 与生成时间 | ### 健康检查 | 类型 | 说明 | | --- | --- | | `MemoryHealthCheck` | **真实实现**。构造参数 `thresholdMb`(默认 1024MB);超阈值返回 `Degraded`,否则 `Healthy`,均附带 GC 明细数据;读数为最近一次 GC 后的托管堆实际占用,首次 GC 前为 0 | 数据库、Redis 与向量库检查不属于本包。应用应针对自己实际使用的客户端实现 `IHealthCheck`,避免通用占位检查给出错误的健康状态。 ### 链路追踪源(`XiHan.Framework.Core.Tracing`) | 类型 | 说明 | | --- | --- | | `XiHanActivitySources` | 静态类,位于 `XiHan.Framework.Core`。常量 `App`/`Data`/`EventBus`/`Grpc`/`Cache`/`Ai` 为源名字符串;对应 `AppSource`/`DataSource`/`EventBusSource`/`GrpcSource`/`CacheSource` 为可直接 `StartActivity` 的 `ActivitySource` 实例;`All`(`string[]`)汇总六个源名,供 `WithTracing().AddSource(XiHanActivitySources.All)` 一次性注册 | ## 使用示例 指标与计时: ```csharp // 计数与耗时直方图(BeginTimer 在 Dispose 时记录 {name}.duration) metricsCollector.RecordCounter("orders.created"); using (metricsCollector.BeginTimer("order.handle")) { // 业务处理 } ``` 性能监控与慢操作: ```csharp using (var tracker = performanceMonitor.BeginOperation("PlaceOrder")) { tracker.AddTag("channel", "web"); tracker.Checkpoint("validated"); // ... 处理 ... } var stats = performanceMonitor.GetStatistics(); // P50/P95/P99 等 var slow = performanceMonitor.GetSlowOperations(500); // 超过 500ms 的操作 ``` 接入健康检查端点(需在应用侧显式注册检查项): ```csharp // 模块 AddXiHanObservability 已调用 AddHealthChecks(),此处补充具体检查项 services.AddHealthChecks() .AddCheck("memory", new MemoryHealthCheck(thresholdMb: 512)); // Program.cs app.MapHealthChecks("/health"); ``` 启用 OpenTelemetry 装配(appsettings.json,开发环境示例——链路追踪 + 控制台导出): ```json { "XiHan": { "Observability": { "Enabled": true, "ServiceName": "XiHan.BasicApp", "EnableTracing": true, "EnableMetrics": true, "SamplingRatio": 1.0, "ConsoleExporter": true, "OtlpEndpoint": "http://localhost:4317", "AdditionalSources": ["MyApp.Custom"] } } } ``` 框架内置源自动纳入追踪,业务代码可直接用 `XiHanActivitySources` 打点,或通过 `AdditionalSources` 声明自定义源: ```csharp using var activity = XiHanActivitySources.AppSource.StartActivity("PlaceOrder"); activity?.SetTag("order.id", orderId); ``` ## 扩展点 / 自定义 - 三个核心服务均以 `AddSingleton`(非 `TryAdd`)注册;如需替换 `IMetricsCollector` / `IPerformanceMonitor` / `IDiagnosticsService` 实现,在本模块之后再次注册你的实现覆盖即可。 - 健康检查采用 `Microsoft.Extensions.Diagnostics.HealthChecks` 标准机制,可自定义 `IHealthCheck` 后通过 `AddCheck` / `AddCheck` 接入。 - 应用层可通过 `XiHanObservabilityOptions.AdditionalSources` 声明自定义 `ActivitySource` 名,随框架内置源一并被 `WithTracing().AddSource(...)` 采集,无需自行装配 OTel `TracerProvider`。 ## 注意事项与最佳实践 - **OpenTelemetry 默认关闭**:`XiHanObservabilityOptions.Enabled` 默认 `false`,`AddXiHanObservability` 不装配 OTel SDK,`IMetricsCollector` 的 `Meter` 调用是「装配即孤儿」——数据既不落内存也不导出,唯一开销是 API 调用本身;需要链路追踪/指标导出时在 `XiHan:Observability` 节显式开启。 - **指标不再内存留存**:`MetricsCollector` 改为直接对接 `System.Diagnostics.Metrics.Meter`,`GetMetrics()` 恒返回空列表、`Clear()` 为空操作——若需要查看指标数据,必须开启 OTel 装配并接入导出器(控制台/OTLP),不能再通过 `GetMetrics()` 在进程内读取。 - **性能/诊断仍是内存存储、非持久**:`PerformanceMonitor` 只保留最近 10000 条记录,进程重启即丢失;仍可调用 `Clear()` 主动清空。此行为与 OTel 开关无关。 - **依赖检查由应用实现**:本包不提供数据库或 Redis 检查;应使用应用实际注册的数据库、缓存与向量库客户端做真实探测。 - **健康检查需手动注册**:模块只注册了基础设施,不含任何检查项——不 `AddCheck` 则 `/health` 端点不会体现内存/依赖状态。 - **OTLP Logs 尚未接入**:`EnableLogging` 配置项存在,但扩展方法当前没有对应的 `WithLogging(...)` 装配代码,开启该项目前不会产生任何日志导出效果。 ## 依赖模块 - [XiHan.Framework.Core](./core) — 唯一的框架内部 `ProjectReference`(模块化、DI 基础,以及 `XiHanActivitySources` 共享追踪源)。 - 第三方:`Microsoft.Extensions.Diagnostics.HealthChecks`、`System.Diagnostics.PerformanceCounter`;`OpenTelemetry.Extensions.Hosting`、`OpenTelemetry.Instrumentation.AspNetCore`、`OpenTelemetry.Instrumentation.Http`、`OpenTelemetry.Exporter.OpenTelemetryProtocol`、`OpenTelemetry.Exporter.Console`(均按 `Enabled` 开关条件装配,未开启时不产生运行时行为)。**不内置** Prometheus/Jaeger 等具体后端组件,只做标准协议导出。 ## 相关模块 - [XiHan.Framework.Logging](./logging) — 日志能力,与可观测性同属运行时观察范畴。 - [XiHan.Framework.Timing](./timing) — 时间与计时基础,可配合性能监控使用。 --- 来源:https://framework.docs.xihanfun.com/packages/script # XiHan.Framework.Script > C# 脚本引擎:基于 Roslyn(`Microsoft.CodeAnalysis.CSharp`)在内存中动态编译并执行 C# 脚本,附带编译缓存、执行超时与编译后静态安全校验。 - **NuGet**:`XiHan.Framework.Script` - **模块类**:`XiHanScriptModule`(不注册额外服务,仅占位) - **所在层**:基础设施层 - **关键依赖**:`Microsoft.CodeAnalysis.CSharp`(Roslyn)+ 框架内部依赖([XiHan.Framework.Core](./core))。 ## 概述 XiHan.Framework.Script 让你在运行时把一段 C# 源码字符串编译成程序集并执行,得到强类型结果。底层用 Roslyn 在内存中编译,支持表达式、语句块、方法、类、完整程序等多种脚本形态,并内置编译缓存、执行超时、安全校验与执行统计,适合规则计算、动态公式、可配置逻辑这类"代码即配置"的场景。 > **仅支持 C#**。源码与依赖里没有 JavaScript / Python 引擎——请勿据此声称支持其它语言。 > **关于「沙箱」的措辞**:本包的安全机制是"编译成程序集后用反射做静态检查(禁止的命名空间/类型、不安全代码、危险方法名)+ 执行超时",**不是 OS/进程级隔离**。脚本运行在宿主进程内,能通过反射拦截的黑名单是有限集合;不要把它当作运行不可信任意代码的安全边界。 ## 何时使用 - 需要在运行期执行配置/用户提供的 C# 表达式或脚本(动态规则、计算公式)。 - 想把易变的业务逻辑外置为脚本,改逻辑无需重新发布。 - 需要对脚本执行做超时、命名空间/类型黑名单等约束。 - 需要缓存已编译脚本、复用编译结果以提升重复执行性能。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Script ``` ```csharp [DependsOn(typeof(XiHanScriptModule))] public class MyModule : XiHanModule { } ``` `XiHanScriptModule.ConfigureServices` **当前不注册任何服务**。使用不依赖 DI——直接用静态门面 `XiHanScript`、工厂 `ScriptEngineFactory` 或构建器 `ScriptEngineBuilder` 即可。 ## 工作原理 1. **包装**:按 `ScriptOptions.ScriptType` 把源码包进一个 `ScriptClass`。`Statement` 会注入 `object result = null;`,末尾 `return result;`——所以语句脚本里给 `result` 赋值即为返回值;`Expression` 直接 `return {表达式};`;`Method` / `Class` 原样嵌入;`Program` 不包装。 2. **编译**:Roslyn 用 `CompilerOptions`(语言版本、优化级别、调试信息等)编译到内存程序集;失败返回带诊断的 `CompilationResult`。 3. **缓存**:按脚本内容与选项生成缓存键,命中则复用已编译程序集(结果 `FromCache=true`)。 4. **安全校验**:若 `SecurityOptions.EnableSecurityChecks`,对编译出的程序集 `assembly.GetTypes()` 做反射检查——命中禁止命名空间/类型/不安全代码/危险方法名则抛 `ScriptSecurityException`。 5. **执行 + 超时**:反射调用入口方法,执行任务与 `Task.Delay(TimeoutMs)` 竞速,超时后 `ExecuteAsync` 返回失败结果(`Exception` 为 `ScriptTimeoutException`),脚本线程仍在后台跑完;结果封装为 `ScriptResult` / `ScriptResult`(含执行/编译耗时、内存分配量、诊断)。其中 `MemoryUsage.AllocatedBytes` 是执行期间的分配字节数,恒为非负;因取自进程级累计计数,并发执行的脚本会互相计入对方的分配量。 ## 核心能力 - **动态执行**:`ExecuteAsync` / `EvaluateAsync` 执行脚本或求值表达式,`ExecuteFileAsync` 执行脚本文件,`CompileAsync` 只编译,`CreateInstanceAsync` 创建脚本类实例。 - **多种脚本形态**:`ScriptType` = `Statement`(默认)/ `Expression` / `Method` / `Class` / `Program`。 - **强类型结果**:`ScriptResult`(`IsSuccess` / `Value` / `ErrorMessage` / `Exception` / `Diagnostics` / `ExecutionTimeMs` / `CompilationTimeMs` / `FromCache`)。 - **编译缓存**:内容+选项键,命中复用,`ClearCache()` 清理,`GetStatistics()` 取命中率等。 - **超时控制**:`TimeoutMs`(默认 30000ms)→ `ScriptTimeoutException`。 - **静态安全校验**:黑名单命名空间/类型 + 危险关键字 + 不安全代码检测;`SecurityOptions` 提供 `Strict` / `Permissive` / `Disabled` 预设。 - **构建与工厂**:`ScriptEngineBuilder` 提供 `AddReference/AddImport/AddGlobal/WithTimeout/WithOptimization/WithUnsafe/DisableCache` 链式方法;`ScriptEngineFactory` 创建默认/命名引擎并管理生命周期与统计。**已知限制**见下方「使用示例」。 - **扩展方法**(`ScriptEngineExtensions`):同步门面 `Execute`/`Execute`/`ExecuteFile`/`ExecuteFile`/`Compile`/`CreateInstance`/`Evaluate`/`Evaluate`;失败即抛异常的 `ExecuteOrThrowAsync`/`ExecuteOrThrowAsync`/`CompileOrThrowAsync`;批量执行 `ExecuteBatchAsync`(可选并行);基准测试 `BenchmarkAsync`(`iterations` 须大于 0,否则在预热前抛出 `ArgumentOutOfRangeException`;预热 5 次后统计,返回 `PerformanceStatistics`;`TotalElapsedTime` 与 `MemoryUsageBytes` 只覆盖正式执行,前者保留完整精度,只读的 `TotalTimeMs` 是它截断到整毫秒的值,`ExecutionsPerSecond`(`double?`)按完整精度计算、耗时为零时为 `null`;`MemoryUsageBytes` 是分配字节数,口径同 `MemoryUsage.AllocatedBytes`);`ExecuteWithTimeoutAsync`;全异常兜底 `ExecuteSafelyAsync`/`ExecuteFileSafelyAsync`(把各类 `ScriptException` 转换成失败态 `ScriptResult`,不再抛出);启发式安全扫描 `ValidateSecurityAsync`(按危险/网络/文件关键字字符串匹配打分,返回 `SecurityValidationResult` + `SecurityRiskLevel`,与执行期反射安全校验是两套独立机制,仅供预检参考);纯编译语法检查 `ValidateSyntaxAsync`(返回 `SyntaxValidationResult`)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `IScriptEngine` / `ScriptEngine` | 引擎接口与 Roslyn 实现。`ExecuteAsync`/`ExecuteAsync`、`ExecuteFileAsync`/`ExecuteFileAsync`、`CompileAsync`、`CreateInstanceAsync`、`EvaluateAsync`/`EvaluateAsync`、`ClearCache`、`GetStatistics` | | `XiHanScript`(静态门面) | `RunAsync`/`Run`、`RunAsync`/`Run`、`EvalAsync`/`Eval`、`EvalAsync`/`Eval`、`RunFileAsync`/`RunFile`、`CreateBuilder()`;`Engine` 属性为惰性默认引擎 | | `ScriptEngineFactory`(静态) | `CreateDefault()`、`Create(Action?)`、`GetOrCreate(name, ...)`、`Release/ReleaseAll`、`GetStatistics/GetAllStatistics`、`ClearAllCaches/ClearCache` | | `ScriptEngineBuilder` | 流式:`AddReference(...)`、`AddImport(...)`、`AddGlobal(...)`、`WithTimeout(...)`、`WithOptimization()`、`WithUnsafe()`、`DisableCache()`、`Configure(...)`、`Build()`。**注意**:目前只有 `Configure(Action)` 会真正作用于 `Build()` 出的引擎,其余链式方法收集的配置不会自动生效,见下方示例 | | `ScriptEngineExtensions`(静态扩展方法) | 同步门面 + `ExecuteOrThrowAsync`/`CompileOrThrowAsync` + `ExecuteBatchAsync` + `BenchmarkAsync` + `ExecuteWithTimeoutAsync` + `ExecuteSafelyAsync`/`ExecuteFileSafelyAsync` + `ValidateSecurityAsync`/`ValidateSyntaxAsync` | | `ScriptOptions` | 执行选项(见下) | | `CompilerOptions` | 编译选项:`LanguageVersion`(默认 Latest)、`WarningLevel`(4)、`TreatWarningsAsErrors`、`GenerateDebugInfo`、`DebugInformationFormat` 等 | | `SecurityOptions` | 安全选项(见下) | | `ScriptResult` / `ScriptResult` / `CompilationResult` | 执行结果与编译结果 | | `ScriptType`(enum) | `Statement` / `Expression` / `Class` / `Method` / `Program` | | `ScriptMonitor` + `ScriptMonitorOptions` | 独立的执行日志/性能监控组件(见「扩展点 / 自定义」) | | `ScriptTemplateManager` + `ScriptTemplate` | 参数化脚本模板管理(见「扩展点 / 自定义」) | | `ScriptException` 及子类 | `ScriptCompilationException` / `ScriptExecutionException`(`ScriptTimeoutException` 继承自它)/ `ScriptSecurityException` / `ScriptLoadException` | ## 配置 `ScriptOptions` 需要显式构造(或用 `ScriptOptions.Default` + 链式方法)后作为参数传入每次 `Execute*/Evaluate*` 调用,**不绑定 appsettings 配置节**(无 `SectionName`);参见上方「`ScriptEngineBuilder` 的已知限制」,链式配置不能只经构建器组装就指望生效。 ### `ScriptOptions` 关键字段 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `ScriptType` | `ScriptType` | `Statement` | 脚本形态 | | `Imports` | `List` | System / Collections.Generic / Linq / Text / Threading.Tasks | 默认导入的命名空间 | | `References` / `ReferencePaths` | `List` / `List` | 空 | 附加程序集引用 | | `Globals` | `Dictionary` | 空 | 全局变量——仅在反射调用入口方法时按**参数名**匹配注入实参,需配合 `ScriptType.Method` 声明同名参数;`Statement`/`Expression`/`Class` 脚本内不能以 `Globals[...]` 形式直接访问,详见下方示例 | | `EnableCache` / `CacheKey` | `bool` / `string?` | `true` / `null` | 编译缓存开关与自定义键 | | `TimeoutMs` | `int` | `30000` | 执行超时(毫秒)| | `AllowUnsafe` | `bool` | `false` | 允许不安全代码 | | `OptimizationLevel` | `OptimizationLevel` | `Debug` | 优化级别 | | `CompilerOptions` | `CompilerOptions` | 见上 | 编译器细项 | | `SecurityOptions` | `SecurityOptions` | 见下 | 安全约束 | 链式方法:`AddReference/AddImport/AddGlobal`、`WithScriptType/WithTimeout/WithCacheKey`、`DisableCache/WithOptimization/WithUnsafe`、`WithSecurity/WithStrictSecurity/DisableSecurity`。 ### `SecurityOptions` 关键字段 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `EnableSecurityChecks` | `bool` | `true` | 总开关,关闭则跳过所有校验 | | `EnableStrictMode` | `bool` | `false` | 严格模式 | | `AllowFileSystemAccess` / `AllowNetworkAccess` / `AllowReflectionAccess` | `bool` | `true` | 各类访问许可 | | `AllowProcessOperations` / `AllowRegistryAccess` | `bool` | `false` | 进程/注册表操作 | | `AllowEnvironmentAccess` | `bool` | `true` | 环境变量访问 | | `MaxFileSize` | `long` | 10MB | 脚本文件大小上限 | | `AllowedFileExtensions` | `List` | `.cs/.csx/.txt` | 允许的脚本文件扩展名 | | `ForbiddenNamespaces` | `List` | `Reflection.Emit` / `InteropServices` / `Security.Permissions` / `Diagnostics.Process` | 禁止命名空间 | | `ForbiddenTypes` | `List` | `Process` / `Registry` / `Environment` | 禁止类型 | | `DangerousKeywords` | `List` | `unsafe`/`fixed`/`stackalloc`/`DllImport`/`Marshal`/`Assembly.Load`/`Activator.CreateInstance`/`Process.Start`/`Environment.Exit` | 危险关键字/方法名 | 预设:`SecurityOptions.Strict()`(全面收紧)、`Permissive()`(放开)、`Disabled()`(关闭检查)。 ## 使用示例 ### 求值表达式与执行语句 ```csharp // 求值表达式(自动切到 Expression 类型) int sum = await XiHanScript.EvalAsync("1 + 2 * 3"); // 7 // 执行语句块:给内置的 result 变量赋值即为返回值 var result = await XiHanScript.RunAsync("result = 10 * 20;"); if (result.IsSuccess) { Console.WriteLine(result.Value); // 200 } ``` ### 用 `ScriptOptions` 定制超时等执行参数 > **`ScriptEngineBuilder` 的已知限制**:`AddReference`/`AddImport`/`AddGlobal`/`WithTimeout`/`WithOptimization`/`WithUnsafe`/`DisableCache` 只是把配置写进构建器内部持有的一份 `ScriptOptions`,但 `Build()` 目前**不会**把这份 `ScriptOptions` 传给新建的引擎——`Build()` 内部就是 `new ScriptEngine()` 后执行通过 `Configure(Action)` 注册的委托(而 `IScriptEngine` 本身也没有暴露可设置引用/导入/全局变量的成员)。也就是说,链式调用这些方法目前**不会**对后续的 `Execute*/Evaluate*` 生效。要让引用、导入、全局变量、超时真正生效,请直接构造/复用 `ScriptOptions` 并在每次调用时传入: ```csharp var options = new ScriptOptions().WithTimeout(5000); var engine = XiHanScript.CreateBuilder().Build(); // 等价于 ScriptEngineFactory.CreateDefault() var r = await engine.ExecuteAsync("result = 10 * 2;", options); ``` ### 用 `ScriptType.Method` 接收全局变量 `Globals` 不是脚本内可直接访问的字典,而是执行引擎在反射调用入口方法时按**参数名**匹配、逐个填充成实参。要让全局变量真正传入脚本,脚本必须是 `ScriptType.Method`,并自己声明一个名为 `Execute` 的 `public static` 方法、用参数名对应 `Globals` 的键: ```csharp var options = new ScriptOptions() .WithScriptType(ScriptType.Method) .AddGlobal("factor", 3); var result = await XiHanScript.RunAsync( "public static object Execute(int factor) { return 10 * factor; }", options); // result.Value == 30 ``` ### 收紧安全策略 ```csharp var options = ScriptOptions.Default .WithStrictSecurity() // 目前实际生效的只有 AllowUnsafe=false;EnableStrictMode 与 Allow* 标志引擎尚未读取 .WithTimeout(2000); var res = await XiHanScript.RunAsync("result = System.Environment.MachineName;", options); // 不会被拦截,res.IsSuccess 仍为 true:安全检查只看脚本「声明」的类型与方法名,不看调用。 // 要拦截某类写法,往 ForbiddenNamespaces / ForbiddenTypes / DangerousKeywords 里加,并按上面的检查口径设计 ``` ## 扩展点 / 自定义 - **命名引擎复用**:`ScriptEngineFactory.GetOrCreate("rules")` 复用同名引擎(含其编译缓存),`Release/ReleaseAll` 释放。 - **自定义安全策略**:构造 `SecurityOptions` 或用 `Strict/Permissive/Disabled` 预设,再经 `ScriptOptions.WithSecurity(...)` 调整黑名单。 - **附加引用/导入**:需要脚本访问业务类型时用 `ScriptOptions` 的 `AddReference(typeof(X))` / `AddImport("Your.Namespace")`(注意上面「已知限制」,不要经 `ScriptEngineBuilder` 配置后误以为已生效)。 - **脚本模板**:`ScriptTemplateManager` 构造时自动加载 3 个内置系统模板(`HelloWorld`/`MathCalculator`/`DataProcessor`,`IsSystem=true`,不可删除/覆盖保存),并从模板目录(默认 `AppDomain.CurrentDomain.BaseDirectory/Templates`,可在构造函数传入)加载 `*.json` 自定义模板;`ScriptTemplate.Code` 用 `#{参数名}` 占位符,`GenerateCode(parameters)` 做纯字符串替换(不是 Scriban/正则模板引擎),`ValidateParameters` 按 `TemplateParameter`(`Required`/`MinValue`/`MaxValue`/`Pattern`/`Options` 等)做必填、数值范围、正则、枚举取值校验。 - **执行监控**:`ScriptMonitor`(配 `ScriptMonitorOptions`,预设 `Default`/`HighPerformance`/`Verbose`;`MemoryUsageBytes` 与 `HighMemoryUsageThresholdBytes`(默认 100MB)按单次执行的分配字节数计)是独立组件,**不会**被 `ScriptEngine`/`XiHanScript` 自动调用——需要调用方自己在执行后调 `monitor.LogExecution(result, scriptCode, scriptPath)` 记录;随后可用 `GetExecutionLogs`/`GetStatistics()`(`ScriptExecutionStatistics`,含成功率、缓存命中率、最近一小时执行数、最慢脚本等)/`GetPerformanceInfo()`、订阅 `ScriptExecuted`/`PerformanceWarning` 事件,或 `ExportLogsAsync` 导出 JSON/CSV/XML(`LogExportFormat`)。 - **调试选项(预留,未接入引擎)**:`DebugOptions`(含 `Verbose()`/`Production()` 预设)、`Breakpoint`、`DebugLevel`、`HitCountCondition` 是独立的公开数据模型,当前未被 `ScriptOptions`/`ScriptEngine` 引用或消费——即设置断点/调试级别目前不会对脚本执行产生任何实际效果,不要据此设计依赖真实调试能力的功能。 ## 注意事项与最佳实践 - **不是安全沙箱**:静态黑名单可被绕过(如经字符串拼接/间接调用),且脚本在宿主进程内运行。**不要用它执行不可信的任意代码**;确需运行不可信代码请用进程/容器级隔离。 - **`Statement` 脚本的返回值**:给内置 `result` 赋值;`Expression` 则直接是表达式的值。 - **超时只保护调用方**:到 `TimeoutMs` 即返回超时失败,但中断不了正在运行的脚本线程,纯 CPU 死循环会一直占用线程池线程。 - **首次编译有开销**:重复执行相同脚本请保持 `EnableCache=true` 并复用引擎实例,以命中编译缓存。 - **模块不注册服务**:不能注入 `IScriptEngine`;通过 `XiHanScript` / `ScriptEngineFactory` 使用。 - **基准测试的耗时读 `TotalElapsedTime`**:快速脚本或迭代次数很少时 `TotalTimeMs` 截断为 0,不代表没有耗时。`TotalTimeMs` 已改为只读、`ExecutionsPerSecond` 已改为 `double?`(二进制不兼容,引用它们的程序集需重新编译):自行构造统计对象时把 `TotalTimeMs = n` 改为 `TotalElapsedTime = TimeSpan.FromMilliseconds(n)`,读取速率处按 `is { } rate` 判空;序列化结果新增 `TotalElapsedTime`,反序列化只回读它。 ## 依赖模块 - [XiHan.Framework.Core](./core) — 唯一的框架内部依赖,提供模块化与依赖注入基座。 - 第三方核心依赖:`Microsoft.CodeAnalysis.CSharp`(Roslyn)。 ## 相关模块 - [XiHan.Framework.Templating](./templating) — 模板渲染,与脚本可配合做动态内容生成。 - [XiHan.Framework.Tasks](./tasks) — 任务调度,常见于承载动态脚本化的后台逻辑。 --- 来源:https://framework.docs.xihanfun.com/packages/search-engines-abstractions # XiHan.Framework.SearchEngines.Abstractions > 搜索引擎抽象包:索引、文档、检索请求与结果的统一契约。**不含任何搜索引擎实现,零第三方依赖。** - **NuGet**:`XiHan.Framework.SearchEngines.Abstractions` - **模块类**:无(纯契约包,直接引用即可) - **所在层**:应用层(抽象) - **关键依赖**:无 ## 概述 `ISearchEngine` 是框架的搜索契约。它按「**Elasticsearch 与 PostgreSQL 全文检索都能落地**」的交集设计——签名中**不出现任一后端的类型**,因此换实现不需要改调用方。 投递语义:索引与删除均为**幂等的按标识覆盖/移除**;写入对检索可见的延迟由实现决定,需要立即可见时调 `RefreshAsync`。 ## 何时使用 - 业务代码只想依赖检索能力,不想把 Elasticsearch 客户端类型渗进去。 - 想自己接一个后端(PostgreSQL 全文检索、Meilisearch、OpenSearch…):实现 `ISearchEngine` 一个接口即可。 - 测试里想用进程内实现替换真实引擎。 ## 安装 ```bash dotnet add package XiHan.Framework.SearchEngines.Abstractions ``` 契约包没有模块类,引用后直接用类型即可。要拿到可注入的 `ISearchEngine`,引 [XiHan.Framework.SearchEngines](./search-engines)(进程内实现)或 [XiHan.Framework.SearchEngines.Elasticsearch](./search-engines-elasticsearch)。 ## 主要 API / 类型 ### `ISearchEngine` | 方法 | 说明 | | --- | --- | | `IndexExistsAsync(index)` | 索引是否存在 | | `CreateIndexAsync(definition)` | 创建索引,已存在时不做任何事;返回**本次是否实际创建** | | `DeleteIndexAsync(index)` | 删除索引,不存在时不做任何事;返回**本次是否实际删除** | | `IndexAsync(index, document)` | 写入单个文档,标识已存在时**整体覆盖** | | `IndexManyAsync(index, documents)` | 批量写入,返回实际写入数 | | `DeleteAsync(index, id)` | 按标识删除文档 | | `GetAsync(index, id)` | 按标识取文档,不存在返回 `null` | | `SearchAsync(request)` | 检索,返回 `SearchResult` | | `RefreshAsync(index)` | 使此前的写入**立即对检索可见** | ### 索引定义 | 类型 | 说明 | | --- | --- | | `SearchIndexDefinition` | 索引定义:`Name`、`Fields`、`Language`(分词语言,可选) | | `SearchFieldDefinition` | 字段定义(record):`Name`、`Type`、`Searchable`(默认 `false`)、`Sortable`(默认 `false`) | | `SearchFieldType` | `Text`(分词全文)/ `Keyword`(不分词精确)/ `Integer` / `Double` / `Boolean` | ### 文档 | 类型 | 说明 | | --- | --- | | `SearchDocument` | 「标识 + 文档体」的载体,索引与批量索引都用它 | ### 检索请求 `SearchRequest`(构造时传索引名,其余用 `init` 属性): | 属性 | 说明 | | --- | --- | | `Index` | 索引名 | | `Keyword` | 关键字,为空时**只按过滤条件检索** | | `Fields` | 关键字检索的字段范围 | | `Filters` | `SearchFilter[]` 过滤条件 | | `Sorts` | `SearchSort[]` 排序 | | `HighlightFields` | 需要高亮的字段 | | `Skip` / `Take` | 分页 | `SearchFilter(field, op, value, values)`,`SearchFilterOperator` 取值:`Equal` / `NotEqual` / `In` / `GreaterThan` / `GreaterThanOrEqual` / `LessThan` / `LessThanOrEqual` / `Exists`。 > `In` 必须用 `values` 传集合,且不能为空——构造函数会直接校验并抛异常。 `SearchSort(field, direction)`,方向为 `SearchSortDirection.Ascending` / `Descending`;字段取 `SearchSort.ScoreField`(`"_score"`)表示按相关度排序,`SearchSort.ByScore` 是「相关度降序」的现成实例。 ### 结果 | 类型 | 说明 | | --- | --- | | `SearchResult` | `Hits`(命中列表)+ `TotalCount`(总数),另有现成的 `Empty` | | `SearchHit` | 单条命中(record):`Id`、`Document`、`Score`(**未按相关度检索时为 0**)、`Highlights`(键为字段名) | ## 注意事项 - 契约是**交集设计**:能力更强的后端(如 Elasticsearch 的聚合、复杂 DSL)不在这层暴露,需要时直接用该后端的客户端。 - `CreateIndexAsync` / `DeleteIndexAsync` / `DeleteAsync` 都返回「本次是否实际发生变化」,方便写幂等的初始化代码。 - 排序字段的可用性由后端决定,见 [Elasticsearch 实现](./search-engines-elasticsearch#与契约的已知差异)。 ## 相关模块 - [XiHan.Framework.SearchEngines](./search-engines) — 进程内实现(默认兜底)。 - [XiHan.Framework.SearchEngines.Elasticsearch](./search-engines-elasticsearch) — Elasticsearch 实现。 --- 来源:https://framework.docs.xihanfun.com/packages/search-engines-elasticsearch # XiHan.Framework.SearchEngines.Elasticsearch > 搜索引擎契约的 **Elasticsearch 实现**:连接与认证交给官方客户端传输层,请求体与响应解析以 JSON 直接构造。 - **NuGet**:`XiHan.Framework.SearchEngines.Elasticsearch` - **模块类**:**无**(只提供实现类与选项,注册方式见下) - **所在层**:基础设施层 - **关键依赖**:`Elastic.Clients.Elasticsearch`(9.x,Elasticsearch 官方 .NET 客户端);框架内部 `XiHan.Framework.SearchEngines` ## 概述 `ElasticsearchSearchEngine` 实现 [`ISearchEngine`](./search-engines-abstractions#isearchengine),是生产环境的推荐实现。 设计上的一个刻意取舍:**连接、认证与重试交给官方客户端的传输层,但请求体与响应解析由本类以 JSON 直接构造**,不使用客户端的强类型查询 DSL。原因是那套 DSL 在大版本间变动频繁,把它焊进实现会让本包的可用性跟着客户端版本走;而 Elasticsearch 的 REST 请求体格式在同一大版本内是稳定契约。 ## 何时使用 - 生产环境需要真正的全文检索、分词与相关度排序。 - 已有 Elasticsearch 集群,希望业务代码只依赖 `ISearchEngine` 契约。 ## 安装与注册 ```bash dotnet add package XiHan.Framework.SearchEngines.Elasticsearch ``` ::: warning 需要自行注册 本包**不含模块类,也没有 `AddXxx` 扩展方法**。引用后要自己绑定选项并覆盖 `ISearchEngine` 的注册——`XiHanSearchEnginesModule` 已用 `TryAdd` 注册了进程内实现,**必须用 `Replace` 覆盖**,`TryAdd` 会被静默忽略。 ::: ```csharp using Microsoft.Extensions.DependencyInjection.Extensions; using XiHan.Framework.SearchEngines.Abstractions; using XiHan.Framework.SearchEngines.Elasticsearch; using XiHan.Framework.SearchEngines.Elasticsearch.Options; public override void ConfigureServices(ServiceConfigurationContext context) { var services = context.Services; var configuration = services.GetConfiguration(); services.Configure( configuration.GetSection(ElasticsearchOptions.SectionName)); services.TryAddSingleton(); services.Replace(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); } ``` 模块需要 `[DependsOn(typeof(XiHanSearchEnginesModule))]`,且你的模块在依赖图里排在它之后(依赖它即可保证),`Replace` 才能覆盖到它的注册。 ## 配置 配置节 `XiHan:SearchEngines:Elasticsearch`(`ElasticsearchOptions.SectionName`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Uri` | `string` | `"http://localhost:9200"` | 集群地址 | | `UserName` / `Password` | `string?` | `null` | 基础认证 | | `ApiKey` | `string?` | `null` | API Key 认证(与用户名密码二选一) | | `IndexPrefix` | `string` | `""` | 索引名前缀,多环境/多租户共用集群时隔离用 | | `RequestTimeoutSeconds` | `int` | `30` | 请求超时 | | `AllowUntrustedCertificate` | `bool` | `false` | 是否允许不受信任的证书(**仅限开发环境**) | | `NumberOfShards` | `int` | `1` | 创建索引时的分片数 | | `NumberOfReplicas` | `int` | `1` | 创建索引时的副本数 | ```json { "XiHan": { "SearchEngines": { "Elasticsearch": { "Uri": "http://127.0.0.1:9200", "ApiKey": "从环境变量或密钥库注入", "IndexPrefix": "prod-", "NumberOfShards": 3, "NumberOfReplicas": 1 } } } } ``` ## 与契约的已知差异 契约是「Elasticsearch 与 PostgreSQL 全文检索的交集」,落到 Elasticsearch 上有两处行为差异,写代码时要知道: 1. **排序字段须为 `Keyword` / 数值 / 日期 / 布尔**。`Text` 字段在 Elasticsearch 上默认**不可排序**——需要排序的文本字段请定义成 `SearchFieldType.Keyword`。 2. **关键字检索走 `multi_match`**,其相关度模型与进程内实现的计数式打分不同。用进程内实现验证链路可以,验证排序效果不行。 ## 使用示例 业务代码与用进程内实现时**完全一致**——只依赖 `ISearchEngine`: ```csharp public class ArticleSearchService(ISearchEngine search) { public Task> SearchAsync(string keyword) => search.SearchAsync
(new SearchRequest("articles") { Keyword = keyword, Fields = ["title", "content"], Sorts = [SearchSort.ByScore], HighlightFields = ["title"], Take = 20, }); } ``` 索引定义时注意上面第 1 条差异: ```csharp await search.CreateIndexAsync(new SearchIndexDefinition("articles", [ new SearchFieldDefinition("title", SearchFieldType.Text, Searchable: true), // 需要排序 → 用 Keyword,不要用 Text new SearchFieldDefinition("tag", SearchFieldType.Keyword, Sortable: true), new SearchFieldDefinition("views", SearchFieldType.Integer, Sortable: true), ]) { Language = "zh" }); ``` ## 注意事项 - 写入对检索可见有延迟(Elasticsearch 的 refresh 间隔),需要立即可见时显式调 `RefreshAsync(index)`——**不要在高频写入路径上每条都刷**,代价很高。 - `IndexPrefix` 在实现内部统一拼接,业务代码传的索引名是**不带前缀**的逻辑名。 - `AllowUntrustedCertificate` 只应在开发环境打开。 - 生产建议用 `ApiKey` 而非用户名密码,且从环境变量或密钥库注入,不要写进 `appsettings.json`。 ## 依赖模块 - [XiHan.Framework.SearchEngines](./search-engines) — 默认实现包(本包覆盖其 `ISearchEngine` 注册)。 - [XiHan.Framework.SearchEngines.Abstractions](./search-engines-abstractions) — 检索契约(经上者传递引用)。 - 第三方:`Elastic.Clients.Elasticsearch` 9.x。 ## 相关模块 - [XiHan.Framework.AI](./ai) — 知识库 RAG 走的是向量库(Qdrant),与全文检索是两条不同的链路。 --- 来源:https://framework.docs.xihanfun.com/packages/search-engines # XiHan.Framework.SearchEngines > 搜索引擎默认实现包:提供**进程内**的 `ISearchEngine` 实现作为兜底。接入真实搜索引擎请引用对应的实现包。 - **NuGet**:`XiHan.Framework.SearchEngines` - **模块类**:`XiHanSearchEnginesModule` - **所在层**:基础设施层 - **关键依赖**:`XiHan.Framework.Core`、`XiHan.Framework.SearchEngines.Abstractions`。**无第三方依赖** ## 概述 本包做两件事: 1. 注册 `DefaultSearchEngine` 作为 `ISearchEngine` 的**默认兜底实现**——开发期不装 Elasticsearch 也能跑通检索链路,测试里不需要真实引擎。 2. 让契约始终有**第二个实现**来校验自己——只有一个实现的抽象无法证明自己没有泄漏该实现的概念。 ::: warning 进程内实现的定位 `DefaultSearchEngine` 面向**单机开发与自动化测试**:最多 100 个索引、每个索引 100000 条文档,超限抛 `InvalidOperationException`(批量写入超限时整批拒绝,不会部分写入);数据只存在于当前进程,**重启即丢**,且**不做分词与相关度模型**。生产环境必须由应用替换为具体搜索引擎实现。 ::: ## 何时使用 - 开发/测试环境想跑通检索链路又不想起一个 Elasticsearch。 - 作为兜底注册,让业务代码可以无条件注入 `ISearchEngine`。 - 需要 `ISearchEngine` 的契约测试基线。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.SearchEngines ``` ```csharp [DependsOn(typeof(XiHanSearchEnginesModule))] public class MyModule : XiHanModule { } ``` 模块的 `ConfigureServices` 只做两行注册(**`TryAdd` 语义**): ```csharp context.Services.TryAddSingleton(); context.Services.TryAddSingleton(sp => sp.GetRequiredService()); ``` 接入真实引擎时,由实现包(或你自己)以 **`Replace`** 覆盖 `ISearchEngine` 的注册——`TryAdd` 一个新实现会被静默忽略。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `XiHanSearchEnginesModule` | 模块入口,注册进程内实现作为兜底 | | `DefaultSearchEngine` | 有界进程内 `ISearchEngine` 实现:最多 100 个索引、每索引 100000 条文档 | 检索契约本身(`ISearchEngine`、`SearchRequest`、`SearchFilter`、`SearchIndexDefinition` 等)在 [Abstractions 包](./search-engines-abstractions)。 ## 使用示例 ```csharp using XiHan.Framework.SearchEngines.Abstractions; using XiHan.Framework.SearchEngines.Abstractions.Documents; using XiHan.Framework.SearchEngines.Abstractions.Indexing; using XiHan.Framework.SearchEngines.Abstractions.Querying; using XiHan.Framework.SearchEngines.Abstractions.Results; public class ArticleSearchService(ISearchEngine search) { private const string Index = "articles"; public async Task EnsureIndexAsync() { // 幂等:已存在时返回 false,不做任何事 await search.CreateIndexAsync(new SearchIndexDefinition(Index, [ new SearchFieldDefinition("title", SearchFieldType.Text, Searchable: true), new SearchFieldDefinition("tag", SearchFieldType.Keyword, Sortable: true), new SearchFieldDefinition("views", SearchFieldType.Integer, Sortable: true), ]) { Language = "zh" }); } public async Task IndexAsync(Article article) { // 标识已存在时整体覆盖 await search.IndexAsync(Index, new SearchDocument
(article.Id.ToString(), article)); // 需要写入立即可见时刷新 await search.RefreshAsync(Index); } public Task> SearchAsync(string keyword) { return search.SearchAsync
(new SearchRequest(Index) { Keyword = keyword, Fields = ["title"], Filters = [new SearchFilter("tag", SearchFilterOperator.In, values: ["dotnet", "vue"])], Sorts = [SearchSort.ByScore], HighlightFields = ["title"], Take = 20, }); } } ``` ## 注意事项 - 进程内实现**不分词**:关键字检索是计数式匹配,相关度与真实引擎不同。用它验证链路,不要用它验证搜索效果。 - 多实例部署下每个进程各有一份索引,互不可见。 - 切换到真实引擎时业务代码**一行不用改**——这正是契约设计的目的。 ## 依赖模块 - [XiHan.Framework.SearchEngines.Abstractions](./search-engines-abstractions) — 检索契约。 - [XiHan.Framework.Core](./core) — 模块化与依赖注入基础。 ## 相关模块 - [XiHan.Framework.SearchEngines.Elasticsearch](./search-engines-elasticsearch) — 生产用的 Elasticsearch 实现。 - [XiHan.Framework.Data](./data) — 数据访问基础设施,通常与索引同步配合使用。 --- 来源:https://framework.docs.xihanfun.com/packages/security # XiHan.Framework.Security > 安全基座:当前用户/客户端/主体上下文、密码哈希与策略、BouncyCastle 加密算法 - **NuGet**:`XiHan.Framework.Security` - **模块类**:`XiHanSecurityModule` - **所在层**:基础设施层 - **关键依赖**:**BouncyCastle.Cryptography**(Blowfish / 国密 SM2)+ 框架内部依赖 `XiHan.Framework.Core` ## 概述 这个包是框架认证授权的公共基座,聚合三类能力:**主体上下文**(在任意服务里安全地读取“当前是谁 / 哪个客户端”)、**密码安全**(PBKDF2-SHA256 哈希、强度策略、历史复用校验)、以及**加密算法**(基于 BouncyCastle 的 Blowfish 对称加密与国密 SM2 椭圆曲线签名)。 它本身不做“登录”或“鉴权判定”,而是提供二者共同依赖的原语:统一的 `ClaimsPrincipal` 访问方式、统一的声明类型常量 `XiHanClaimTypes`,以及可注入的密码服务。[Authentication](./authentication)(你是谁)与 [Authorization](./authorization)(你能做什么)都 `DependsOn` 它。 ## 何时使用 - 需要在任意服务里读取当前登录用户的身份、声明、租户(`ICurrentUser`)或客户端(`ICurrentClient`) - 需要临时以另一主体身份执行一段逻辑(`ICurrentPrincipalAccessor.Change`,如模拟登录、后台任务附身) - 需要对密码做安全哈希与校验、执行密码强度评分与历史复用检查 - 需要 Blowfish 对称加密或国密 SM2 签名/验签 - 构建自定义认证/授权逻辑时,复用统一的主体访问与声明类型常量 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Security ``` ```csharp [DependsOn(typeof(XiHanSecurityModule))] public class MyModule : XiHanModule { } ``` 模块 `ConfigureServices` 调用 `AddXiHanSecurityServices()`: - `Configure` / `Configure`——绑定配置节(节名沿用历史前缀 `XiHan:Authentication:*`,但绑定逻辑已归属本包,见下方“配置”一节说明) - `IPasswordHasher` → `PasswordHasher`(`TryAddSingleton`) - `IPasswordPolicyService` → `PasswordPolicyService`(`TryAddScoped`) - `IPasswordHistoryStore` → `DefaultPasswordHistoryStore`(`TryAddScoped`,内存实现,生产需替换) `ICurrentUser` / `ICurrentClient` / `ICurrentPrincipalAccessor` 的实现类通过框架的**标记接口约定**自动注册(见下方“工作原理”),无需在扩展方法里手写。[Authentication](./authentication) 包 `DependsOn` 本包、先加载后消费,不再重复注册密码哈希器或绑定 `Options`,只在其之上构建登录/令牌等能力。 ## 工作原理 **主体访问(`ICurrentPrincipalAccessor`)**:抽象基类 `CurrentPrincipalAccessorBase` 用一个 `AsyncLocal` 保存“临时覆盖的主体”;`Principal` 属性优先返回该覆盖值,否则回落到抽象方法 `GetClaimsPrincipal()`。`Change(principal)` 设置覆盖值并返回一个 `IDisposable`,释放时自动恢复上一个主体——因此可以用 `using` 安全地做主体切换: ```text using (accessor.Change(otherPrincipal)) { // 此作用域内 ICurrentUser 看到的是 otherPrincipal } // 离开作用域自动还原 ``` 框架默认实现 `ThreadCurrentPrincipalAccessor`(`ISingletonDependency`)从 `Thread.CurrentPrincipal` 取默认主体;Web 宿主通常另行提供基于 `HttpContext.User` 的实现覆盖它。`CurrentPrincipalAccessorBase.Principal` 在覆盖值与 `GetClaimsPrincipal()` 都为空时兜底返回一个匿名空 `ClaimsPrincipal`,保证 `Principal` 永不为 `null`,避免下游 `ICurrentUser` 在匿名请求下抛异常。`CurrentPrincipalAccessorExtensions` 还提供 `Change(Claim)` / `Change(IEnumerable)` / `Change(ClaimsIdentity)` 三个便捷重载,内部统一包装成 `ClaimsPrincipal` 后调用 `Change`。 **声明读取**:`CurrentUser` / `CurrentClient` 都只依赖 `ICurrentPrincipalAccessor`,把 `Principal.Claims` 按 `XiHanClaimTypes` 里的类型解析成强类型属性(内部通过 `XiHanClaimsIdentityExtensions` 的 `FindUserId` / `FindTenantId` / `FindClientId` 等 `ClaimsPrincipal` 扩展方法完成,`UserId`/`TenantId` 解析为 `long?`,`EditionId`/模仿者用户标识解析为 `Guid?`)。这些扩展方法本身是 public 的,也可以在业务代码里直接对任意 `ClaimsPrincipal` / `IIdentity` 调用。因此“临时切换主体”会立即反映到 `ICurrentUser` 的所有属性。 **密码格式**:`PasswordHasher` 输出自描述的字符串 `version:iterations:hashAlgorithm:salt:hash`(后两段 Base64)。校验时从串里解析出当时的参数重算比对,用 `CryptographicOperations.FixedTimeEquals` 做恒定时间比较防时序攻击。`NeedsRehash` 比较存储参数与当前 `Options`,参数升级后可在下次登录成功时无感重哈希。 ## 核心能力 - **当前用户上下文** `ICurrentUser`:`IsAuthenticated`、`UserId`(`long?`)、`UserName`、`Name`、`SurName`、`PhoneNumber` / `PhoneNumberVerified`、`Email` / `EmailVerified`、`TenantId`(`long?`)、`Roles`(`string[]`),以及 `FindClaim` / `FindClaims` / `GetAllClaims` / `IsInRole` - **当前客户端上下文** `ICurrentClient`:`Id`、`IsAuthenticated`(从 `client_id` 声明解析) - **主体访问器** `ICurrentPrincipalAccessor`:`Principal` 读取,`Change(ClaimsPrincipal)` 临时切换(基于 `AsyncLocal`,`using` 作用域内生效) - **统一声明类型** `XiHanClaimTypes`:20+ 声明常量(用户、角色、权限、租户、版本、客户端、模拟登录、会话、设备指纹等) - **密码哈希** `IPasswordHasher`:PBKDF2-SHA256,随机盐 + 可配置迭代(默认 60 万),自描述哈希格式支持版本平滑升级 - **密码策略** `IPasswordPolicyService`:长度/字符类型校验、弱密码黑名单(内置 30 个 + 自定义)、重复/连续字符检测、0–100 强度评分,以及历史密码复用检查 - **加密算法**:`BlowfishHelper`(Blowfish 对称,CBC + PKCS 填充,密钥 ≤ 56 字节)、`Sm2Helper`(国密 SM2 密钥对生成 / SM3WITHSM2 签名验签) ## 主要 API / 类型 ### 主体与声明 | 类型 | 说明 | | --- | --- | | `ICurrentUser` / `CurrentUser` | 当前登录用户上下文;`Claim? FindClaim(string)`、`Claim[] FindClaims(string)`、`Claim[] GetAllClaims()`、`bool IsInRole(string)` | | `ICurrentClient` / `CurrentClient` | 当前客户端;`string? Id`、`bool IsAuthenticated` | | `ICurrentPrincipalAccessor` | `ClaimsPrincipal Principal { get; }`、`IDisposable Change(ClaimsPrincipal principal)` | | `CurrentPrincipalAccessorBase` | 抽象基类,子类实现 `ClaimsPrincipal GetClaimsPrincipal()` | | `ThreadCurrentPrincipalAccessor` | 默认实现,从 `Thread.CurrentPrincipal` 取主体 | | `XiHanClaimTypes` | 声明类型常量集合(见下表) | | `CurrentUserExtensions` | `ICurrentUser` 扩展:`FindClaimValue`、`FindClaimValue`、`GetUserId`、`FindImpersonator*`、`FindPicture`、`GetSessionId` / `FindSessionId` | | `XiHanClaimsIdentityExtensions` | `ClaimsPrincipal` / `IIdentity` 扩展:`FindUserId`、`FindTenantId`(均 `long?`)、`FindClientId`(`string?`)、`FindEditionId`、`FindImpersonatorUserId`(均 `Guid?`)、`FindImpersonatorTenantId`(`long?`)、`FindSessionId`;另有 `ClaimsIdentity` 编辑助手 `AddIfNotContains` / `RemoveAll` / `AddOrReplace` 与 `ClaimsPrincipal.AddIdentityIfNotContains` | | `CurrentPrincipalAccessorExtensions` | `ICurrentPrincipalAccessor` 扩展:`Change(Claim)` / `Change(IEnumerable)` / `Change(ClaimsIdentity)` 便捷重载 | `XiHanClaimTypes` 关键常量(均为可 `set` 的静态属性,默认值如下): | 常量 | 默认值 | | --- | --- | | `UserId` | `ClaimTypes.NameIdentifier` | | `UserName` | `ClaimTypes.Name` | | `Name` / `SurName` | `ClaimTypes.GivenName` / `ClaimTypes.Surname` | | `Role` | `ClaimTypes.Role` | | `Permission` | `"permission"` | | `Email` / `EmailVerified` | `ClaimTypes.Email` / `"email_verified"` | | `PhoneNumber` / `PhoneNumberVerified` | `"phone_number"` / `"phone_number_verified"` | | `TenantId` / `EditionId` | `"tenantid"` / `"editionid"` | | `ClientId` | `"client_id"` | | `ImpersonatorTenantId` / `ImpersonatorUserId` | `"impersonator_tenantid"` / `"impersonator_userid"` | | `ImpersonatorTenantName` / `ImpersonatorUserName` | `"impersonator_tenantname"` / `"impersonator_username"` | | `Picture` / `RememberMe` | `"picture"` / `"remember_me"` | | `SessionId` / `DeviceFingerprint` | `"session_id"` / `"device_fingerprint"` | ### 密码 | 类型 | 说明 | | --- | --- | | `IPasswordHasher` / `PasswordHasher` | `string HashPassword(string)`、`bool VerifyPassword(string hashed, string provided)`、`bool NeedsRehash(string hashed)` | | `PasswordHasherOptions` | 哈希参数(配置节 `XiHan:Authentication:PasswordHasher`) | | `IPasswordPolicyService` / `PasswordPolicyService` | `PasswordValidationResult Validate(string)`、`Task IsPasswordReusedAsync(string newPassword, long userId, int historyCount, CancellationToken)` | | `PasswordPolicyOptions` | 策略选项(配置节 `XiHan:Authentication:PasswordPolicy`) | | `PasswordValidationResult` | `IsValid`、`Score`(0–100)、`Message`、`Errors`;工厂 `Success(score)` / `Failure(...)` | | `IPasswordHistoryStore` / `DefaultPasswordHistoryStore` | 接口仅 `Task> GetRecentPasswordHashesAsync(long userId, int count, CancellationToken)`;默认实现另有 `static void RecordPassword(long userId, string passwordHash, int maxHistoryCount = 10)` 用于写入内存历史(非接口成员,框架不会自动调用,见下方“扩展点”) | ### 加密 | 类型 | 关键方法 | | --- | --- | | `BlowfishHelper` | `string Encrypt(string data, string key)` / `string Decrypt(string, string)`;字节版 `EncryptBytes` / `DecryptBytes`(CBC + PKCS 填充,密钥 ≤ 56 字节抛 `ArgumentException`) | | `Sm2Helper` | `(string publicKey, string privateKey) GenerateKeys()`、`string SignData(string data, string privateKey)`、`bool VerifyData(string data, string signature, string publicKey)`(曲线 `sm2p256v1`,`SM3WITHSM2`,密钥/签名 Base64) | ## 配置 密码哈希与策略的 `Options` 定义、DI 注册与配置绑定均在本包完成(`AddXiHanSecurityServices` 里 `Configure`);配置节前缀沿用历史命名 `XiHan:Authentication:*`(保留该前缀是为了不破坏已部署的配置文件,并不代表绑定逻辑仍在 Authentication 包)。 **`PasswordHasherOptions`(节 `XiHan:Authentication:PasswordHasher`)** | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Version` | `int` | `1` | 哈希算法版本号,用于平滑升级 | | `Iterations` | `int` | `600000` | PBKDF2 迭代次数(OWASP 推荐 SHA256 ≥ 60 万) | | `SaltSize` | `int` | `32` | 盐字节数 | | `HashSize` | `int` | `32` | 输出哈希字节数 | | `HashAlgorithm` | `HashAlgorithmName` | `SHA256` | 摘要算法 | **`PasswordPolicyOptions`(节 `XiHan:Authentication:PasswordPolicy`)** | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `MinimumLength` / `MaximumLength` | `int` | `8` / `128` | 长度上下限 | | `RequireUppercase` / `RequireLowercase` | `bool` | `true` / `true` | 需大写 / 小写 | | `RequireDigit` / `RequireSpecialCharacter` | `bool` | `true` / `true` | 需数字 / 特殊字符 | | `PasswordExpirationDays` | `int` | `0` | 密码过期天数(0 = 不过期) | | `PasswordHistoryCount` | `int` | `5` | 禁止复用的历史密码条数 | | `CustomBlacklist` | `List` | `[]` | 自定义禁用词 | | `MaxFailedAccessAttempts` | `int` | `5` | 允许的最大连续失败次数 | | `LockoutDurationMinutes` | `int` | `30` | 锁定时长(分钟) | ```json { "XiHan": { "Authentication": { "PasswordHasher": { "Iterations": 600000, "HashAlgorithm": "SHA256" }, "PasswordPolicy": { "MinimumLength": 10, "RequireSpecialCharacter": true, "PasswordHistoryCount": 5, "MaxFailedAccessAttempts": 5, "LockoutDurationMinutes": 30 } } } } ``` ## 使用示例 在任意服务里读取当前用户: ```csharp public class OrderService(ICurrentUser currentUser) { public void PlaceOrder() { if (!currentUser.IsAuthenticated) throw new InvalidOperationException("未登录"); var userId = currentUser.UserId; // long? var tenantId = currentUser.TenantId; // long? var isAdmin = currentUser.IsInRole("admin"); } } ``` 临时以另一主体身份执行(模拟登录 / 后台附身): ```csharp public class ImpersonationService(ICurrentPrincipalAccessor accessor) { public void RunAs(ClaimsPrincipal target, Action action) { using (accessor.Change(target)) // 离开 using 自动恢复原主体 { action(); } } } ``` 哈希、校验与策略: ```csharp public class AccountService(IPasswordHasher hasher, IPasswordPolicyService policy) { public async Task RegisterAsync(long userId, string password) { var check = policy.Validate(password); // 强度校验 + 评分 if (!check.IsValid) throw new InvalidOperationException(string.Join(";", check.Errors)); if (await policy.IsPasswordReusedAsync(password, userId, historyCount: 5)) throw new InvalidOperationException("不能复用最近使用过的密码"); return hasher.HashPassword(password); // "1:600000:SHA256:...:..." } } ``` ## 扩展点 / 自定义 - **主体来源**:Web 宿主一般会注册基于 `HttpContext.User` 的 `ICurrentPrincipalAccessor` 覆盖默认的 `ThreadCurrentPrincipalAccessor`。 - **密码历史持久化**:`DefaultPasswordHistoryStore` 是**进程内存实现**(静态 `ConcurrentDictionary`),仅适合开发/测试。生产务必自实现 `IPasswordHistoryStore`(读数据库最近 N 条哈希)并在 DI 里覆盖——因扩展方法用 `TryAddScoped`,只要你先注册就会生效。 - **默认实现是只读的**:`IPasswordHistoryStore` 接口只定义了读取方法,写入靠 `DefaultPasswordHistoryStore` 上的静态方法 `RecordPassword(userId, passwordHash, maxHistoryCount = 10)`——框架不会在密码修改成功后自动调用它,若使用默认内存实现,需应用层显式调用该静态方法落库,否则历史队列永远为空、`IsPasswordReusedAsync` 恒返回 `false`。 - **声明类型定制**:`XiHanClaimTypes` 的常量是可 `set` 的静态属性,可在应用启动早期改写以对齐外部身份提供方的声明命名(全局生效,务必在读取任何主体前设置)。 ## 注意事项与最佳实践 - **`UserId`/`TenantId` 是 `long?`**:源自框架实体主键类型。读取时判空或用 `CurrentUserExtensions.GetUserId()`(内部 `Debug.Assert` 非空)。 - **历史复用检查传明文**:`IsPasswordReusedAsync` 入参是**新密码明文**——因为历史里存的是加盐哈希,同一明文每次哈希都不同,必须用 `VerifyPassword(历史哈希, 明文)` 逐条比对,不能直接比哈希字符串。 - **`NeedsRehash` 用于平滑升级**:调高 `Iterations` 或换 `HashAlgorithm` 后,旧密码仍可校验通过;在登录成功且 `NeedsRehash` 为真时用新参数重哈希并落库。 - **Blowfish 密钥长度**:> 56 字节(448 位)会抛 `ArgumentException`。Blowfish 属老算法,新业务优先选 AES;此处保留主要用于兼容既有数据。 - **加密辅助类是静态方法**:`BlowfishHelper` / `Sm2Helper` 不走 DI,直接静态调用;密钥管理由调用方负责。 ## 依赖模块 - [XiHan.Framework.Core](./core) - 第三方核心:**BouncyCastle.Cryptography** `2.7.0`(Blowfish / SM2 / SM3) ## 相关模块 - [XiHan.Framework.Authentication](./authentication)(在此之上实现 JWT / OAuth2 / MFA / 一次性验证码) - [XiHan.Framework.Authorization](./authorization)(RBAC / Policy / ABAC 授权) --- 来源:https://framework.docs.xihanfun.com/packages/serialization # XiHan.Framework.Serialization > 序列化辅助库:核心是一套基于 `System.Text.Json` 的"动态 JSON"操作体系与 `JsonSerializerOptions` 组合工具。 - **NuGet**:`XiHan.Framework.Serialization` - **模块类**:`XiHanSerializationModule`(当前不注册任何服务) - **所在层**:基础设施层 - **关键依赖**:.NET 内置 `System.Text.Json`;无第三方 NuGet 依赖 ## 概述 本包核心交付物是一套**动态 JSON** 类型:`DynamicJsonObject` / `DynamicJsonArray` / `DynamicJsonValue` / `DynamicJsonProperty`,底层完全基于 .NET 内置 `System.Text.Json.Nodes`(`JsonObject`/`JsonArray`/`JsonValue`)承载。它们继承自 `DynamicObject`,支持动态成员访问与索引,让你在不定义强类型 DTO 的前提下解析、构建、查询、合并 JSON。 围绕这些类型还提供了 `DynamicJsonHelper`(静态辅助:序列化/反序列化/路径查询/合并/扁平化)、`DynamicJsonFactory`(工厂 + 流式构建器)、`DynamicJsonExtensions`(快捷扩展方法)以及 `JsonSerializerOptionsHelper`(组合 `JsonSerializerOptions`)。 ## 何时使用 - 需要处理结构不固定的 JSON(配置片段、第三方响应),不想为每种形状定义 DTO。 - 想用点分路径(如 `user.profile.name`)读写嵌套属性。 - 需要对两份 JSON 做深度合并、深度克隆或结构比较。 - 需要在运行时动态增删 `JsonConverter` 来派生 `JsonSerializerOptions`。 > 若只需常规强类型 JSON/XML/YAML 序列化,`XiHan.Framework.Utils` 的 `Serialization` 目录(`JsonHelper` 等)已够用——本包实际也复用了 `Utils` 的 `JsonHelper`。本包的价值在"动态、免 DTO"的场景。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Serialization ``` ```csharp [DependsOn(typeof(XiHanSerializationModule))] public class MyModule : XiHanModule; ``` `XiHanSerializationModule.ConfigureServices` 当前是空实现(仅取了一次 `IConfiguration`,未注册任何服务)。因此本包能力**全部通过静态 `Helper` / `Factory` / 扩展方法直接调用**,无需 DI,也无需依赖模块类即可使用这些静态类型(`[DependsOn]` 主要用于纳入模块图与传递依赖)。 ## 核心能力 - **动态 JSON 类型**:`DynamicJsonObject` / `DynamicJsonArray` / `DynamicJsonValue` / `DynamicJsonProperty`,动态成员访问 + 索引器。 - **解析与序列化**:字符串解析、对象互转、文件读写、同步 + 异步 + `Try*` 无异常版本。 - **路径查询**:`SelectToken` / `SetToken` 以点分路径读写嵌套属性(数组下标也可用)。 - **结构化操作**:`Merge`(深度合并)、`DeepClone`(深度克隆)、`DeepEquals`(深度比较)、`Flatten` / `Unflatten`(扁平化互转)。 - **构建器模式**:`DynamicJsonFactory.Object()` / `.Array()` 流式构建,支持嵌套 `AddObject` / `AddArray`。 - **序列化选项管理**:`JsonSerializerOptionsHelper` 以基础 `JsonSerializerOptions` 为模板,移除指定/匹配的 `JsonConverter` 并追加新的(去重)。 ## 主要 API / 类型 | 类型 | 说明 | | --- | --- | | `DynamicJsonObject` | 动态 JSON 对象;继承 `DynamicObject`,支持动态成员/索引、`Properties`、`ContainsKey`、`GetValue`/`SetValue` | | `DynamicJsonArray` | 动态 JSON 数组,实现 `IList`;另提供 `First`/`Last`、`Values()`、`Objects()`/`Arrays()`(按类型筛选子元素)、`FirstObject(predicate?)`、`SelectTokens(propertyName)` 等 LINQ 风格辅助方法 | | `DynamicJsonValue` | 动态 JSON 值;继承 `DynamicObject`,实现 `IEquatable`,暴露 `ValueKind`、`ToObject()`、`TryGetValue(out T?)`,并带大量隐式/显式转换运算符 | | `DynamicJsonProperty` | 动态 JSON 属性(键值对);暴露 `Name`/`Value`、`DynamicValue`(转为 `DynamicJsonValue`)、`AsObject`/`AsArray`(值为对象/数组时的强类型视图) | | `DynamicJsonHelper` | 静态辅助:`Serialize`/`Deserialize`(+ `Async`/`Try*`/文件版本)、`FromObject`/`ToObject`、`SelectToken`/`SetToken`、`DeepClone`、`Merge`、`DeepEquals`、`Flatten`/`Unflatten`、`IsValid`/`IsEmpty`/`HasProperty` 等 | | `DynamicJsonFactory` | 静态工厂:`CreateObject`/`CreateArray`/`CreateString`/`CreateNumber`/…、`Parse`/`TryParse`、`FromObject`,以及 `Object()`/`Array()` 返回的 `ObjectBuilder`/`ArrayBuilder` | | `DynamicJsonExtensions` | 扩展方法:`ToDynamic`/`ToDynamicJsonObject`/`ToDynamicJsonArray`(`string`/`object`/`JsonNode`/字典/集合)、`ToDictionary`/`ToList`、`SelectToken`/`SetToken`、`DeepMerge`、`Flatten`、`HasPath`、`IsDeepEmpty` | | `JsonSerializerOptionsHelper` | 静态:`Create(baseOptions, JsonConverter removeConverter, params JsonConverter[] addConverters)` 与 `Create(baseOptions, Func removeConverterPredicate, params JsonConverter[] addConverters)` | ### `DynamicJsonHelper` 关键方法(节选) | 方法 | 说明 | | --- | --- | | `string Serialize(object? dynamicJson, bool indented = true)` / `Task SerializeAsync(...)` | 序列化动态 JSON(识别四种动态类型,其余走 `JsonHelper`) | | `dynamic? Deserialize(string json)` / `DeserializeAsync(...)` / `T? DeserializeAs(string json)` | 反序列化为动态对象;空串抛 `ArgumentException`,格式非法抛 `JsonException` | | `dynamic? DeserializeFromFile(string filePath)` / `void SerializeToFile(object?, string, bool)`(+ Async) | 文件读写(UTF-8,自动建目录) | | `dynamic? FromObject(object? obj)` / `T? ToObject(object? dynamicJson)` | 普通对象 ↔ 动态 JSON 互转 | | `dynamic? SelectToken(object?, string path, char separator = '.')` | 点分路径查询(支持数组下标) | | `void SetToken(object?, string path, object? value, char separator = '.', bool createPath = true)` | 点分路径写入,`createPath` 控制是否自动建中间层 | | `dynamic? DeepClone(object?)` | 序列化再反序列化实现深克隆 | | `DynamicJsonObject? Merge(DynamicJsonObject? target, DynamicJsonObject? source, bool overwrite = true)` | 递归深度合并 | | `bool DeepEquals(object? left, object? right)` | 结构化深度比较 | | `Dictionary Flatten(object?, string separator = ".")` / `DynamicJsonObject? Unflatten(...)` | 扁平化互转(数组用 `key[i]` 形式) | | `bool IsValid(...)` / `bool IsType(object?)` / `bool IsEmpty(...)` / `bool HasProperty(object?, string)` | 校验类辅助 | | `string Format(object?, bool indent = true)` / `string Compress(object?)` | 借助 `JsonHelper.FormatJson` 格式化/压缩;`Compress` 等价于 `Format(json, false)`;序列化异常时回退到 `ToString()` | | `string GetTypeInfo(object?)` | 返回类型描述字符串(如 `DynamicJsonValue(String)`),便于日志/调试 | | `(int PropertyCount, int TotalSize) GetSizeInfo(object?)` | 粗粒度体积估算(非精确字节数) | `Try*` 系列(`TrySerialize` / `TryDeserialize` / `TryDeserializeAs` / `TryFromObject` / `TryToObject` / `TryDeserializeFromFile` / `TrySerializeToFile`)为不抛异常版本,以 `bool` + `out` 返回结果。 ## 使用示例 ### 解析、路径读写与序列化 ```csharp using XiHan.Framework.Serialization.Dynamic; dynamic? json = DynamicJsonHelper.Deserialize("""{ "user": { "profile": { "name": "Ann" } } }"""); // 点分路径读 var name = DynamicJsonHelper.SelectToken(json, "user.profile.name"); // Ann // 点分路径写(自动创建中间层) DynamicJsonHelper.SetToken(json, "user.profile.age", 18); var text = DynamicJsonHelper.Serialize(json, indented: true); ``` ### 流式构建 ```csharp var obj = DynamicJsonFactory.Object() .Add("id", 1001) .AddString("name", "Ann") .AddArray("tags", a => a.AddString("admin").AddString("vip")) .AddObject("profile", p => p.AddNumber("age", 18)) .Build(); ``` ### 深度合并与比较 ```csharp var merged = DynamicJsonHelper.Merge(target, source, overwrite: true); var same = DynamicJsonHelper.DeepEquals(a, b); ``` ### 组合 `JsonSerializerOptions` ```csharp // 以既有 options 为基础,移除某个转换器并追加新的(不重复添加) var options = JsonSerializerOptionsHelper.Create( baseOptions, removeConverterPredicate: c => c is JsonStringEnumConverter, addConverters: new MyDateConverter()); ``` ## 注意事项与最佳实践 - **无 DI 注册**:模块类不注册服务,所有能力经静态类型调用;不要期望从容器解析本包的服务。 - **底层是 `System.Text.Json.Nodes`**:动态类型基于 `JsonObject`/`JsonArray`/`JsonValue`,`DynamicJsonValue.ValueKind` 即 `JsonValueKind`。 - **异步是包装同步**:`SerializeAsync` / `DeserializeAsync` 等以 `Task.Run` 包裹同步实现,本质是 CPU 工作转线程池,非真正的 IO 异步。 - **`Deserialize` 的异常语义**:空字符串抛 `ArgumentException`,JSON 非法抛 `JsonException`;不想处理异常时用对应 `Try*` 版本。 - **`SelectToken`/`SetToken` 的入参约束**:仅当根为 `DynamicJsonObject` 时生效(`SelectToken` 支持路径中出现数组下标)。 ## 依赖模块 - 内部依赖:仅 [XiHan.Framework.Core](./core)(另运行期复用 `XiHan.Framework.Utils` 的 `JsonHelper`)。 - 第三方核心:无;序列化引擎为 .NET 内置 `System.Text.Json`。 ## 相关模块 - [XiHan.Framework.Utils](./utils) — 其 `Serialization` 目录提供 JSON/XML/YAML 的基础序列化封装(`JsonHelper` 等),本包的动态类型即建立其上。 - [XiHan.Framework.Core](./core) --- 来源:https://framework.docs.xihanfun.com/packages/settings # XiHan.Framework.Settings > 设置管理:设置定义提供者模式、多来源取值链、按作用域(全局/租户/用户/会话)读写、加密与变更事件 - **NuGet**:`XiHan.Framework.Settings` - **模块类**:`XiHanSettingsModule` - **所在层**:应用层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Core / Security / MultiTenancy.Abstractions;加密用 `Utils` 的 `AesHelper`) ## 概述 本包提供统一的**设置(Setting)管理**能力。设置项由代码集中「定义」(名称、默认值、分组、是否加密、校验器等),运行时按一条**值提供者链**从多个来源取值(默认值 → 配置文件 → 全局存储 → 用户存储;引入多租户后再插入租户存储),并支持按作用域读写、加密敏感值、监听变更事件。它与 appsettings 的区别是:设置可在运行时按用户/租户维度动态变化并写回持久化存储,而 appsettings 通常是只读的静态配置。 设计上分三层:**定义**(`ISettingDefinitionProvider` / `SettingDefinition`)声明有哪些设置项;**取值**(`ISettingValueProvider` 链)决定从哪取值、按什么优先级;**存储**(`ISettingStore`)承载实际持久化。本包只提供空存储 `NullSettingStore`,真正的数据库持久化由上层替换。 ## 何时使用 - 你需要一批可在运行时修改的配置项,而非重启才生效的 appsettings - 你需要同一设置项对不同用户/租户取不同值(分作用域) - 你需要用「定义提供者」集中声明设置项元数据(默认值、分组、是否加密、校验) - 你需要敏感设置加密存储,以及可插拔的取值来源与持久化 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Settings ``` ```csharp [DependsOn(typeof(XiHanSettingsModule))] public class MyModule : XiHanModule { } ``` 模块 `[DependsOn(typeof(XiHanSecurityModule))]`,并: - 在 `PreConfigureServices` 里通过 `services.OnRegistered(...)` **自动发现**所有 `ISettingDefinitionProvider` 实现,登记进 `XiHanSettingOptions.DefinitionProviders` - 在 `ConfigureServices` 里调用 `AddXiHanSettings(config)`,向 `XiHanSettingOptions.ValueProviders` 依次注册默认的四个值提供者,并绑定 `XiHanAesOptions`(配置节 `XiHan:Settings:Aes`) 默认注册的值提供者链(注册顺序即优先级): ```text DefaultValueSettingValueProvider (D) → 取 SettingDefinition.DefaultValue ConfigurationSettingValueProvider (C) → 取 IConfiguration["Settings:{name}"] GlobalSettingValueProvider (G) → 取 ISettingStore(全局,无 providerKey) UserSettingValueProvider (U) → 取 ISettingStore(当前用户 UserId 作 providerKey) ``` > 引入 [XiHan.Framework.MultiTenancy](./multitenancy) 后,`TenantSettingValueProvider`(`"T"`)会被插入到 `G` 之后,形成 `D → C → G → T → U`。 ## 工作原理 ### 定义提供者自动发现 模块启动时用 `OnRegistered` 回调扫描所有注册类型,凡实现 `ISettingDefinitionProvider` 的都收集起来放进 `XiHanSettingOptions.DefinitionProviders`。`SettingDefinitionContext`(`ISettingDefinitionContext`,单例)提供 `Add`/`GetOrNull`/`GetAll`,`Add` 遇重名 `Name` 会抛 `XiHanException`。 > ⚠️ 核对源码后需特别指出:本包**没有任何代码遍历 `XiHanSettingOptions.DefinitionProviders` 并调用 `Define(context)`**,`SettingDefinitionContext` 不会被自动填充;`SettingManager` 的构造函数也只注入 `ILogger`/`ISettingStore`/`IServiceProvider`,既不读取 `XiHanSettingOptions`,也不读取 `ISettingDefinitionContext`——它自己的定义表 `_definitions` 是该 `SettingManager` 实例私有的空 `ConcurrentDictionary`。也就是说,写一个 `ISettingDefinitionProvider` 实现并被 DI 登记,**并不会**让对应设置自动可用;`GetOrNullAsync`/`SetValueAsync` 在定义未注册时会抛 `XiHanException($"Setting '{name}' is not defined.")`。要让设置真正生效,调用方必须自行拿到目标 `ISettingManager` 实例并显式调用 `AddDefinition(...)`(见下方使用示例)。截至目前,`XiHan.Framework` 与 `XiHan.BasicApp` 仓库内均未见把 `DefinitionProviders` 桥接到 `SettingManager` 的代码,接入前请以源码为准评估,不要假设「实现了 Provider 就自动可用」。 ### 取值链与作用域 `SettingManager.GetOrNullAsync(name, scope)` 的真实行为需注意: 1. 按 `name` 从内部定义表取 `SettingDefinition`,未定义抛 `XiHanException` 2. **遍历该定义自身的 `SettingDefinition.Providers` 列表**(不是遍历全局链),取到第一个非 `null` 值即停 3. 仍为空 → 回退到 `ISettingStore.GetOrNullAsync(name, "G", null)`,再回退到 `DefaultValue` 4. 若定义标记 `IsEncrypted` 且值非空 → 解密后返回 > ⚠️ 注意:`GetOrNullAsync` 的 `scope` 参数在当前实现里**读取时并未参与**取值路径——取值由 `definition.Providers` 与全局回退决定。作用域主要影响**写入**(见下)。要让某设置从多来源取值,需在其 `SettingDefinition` 上 `AddProvider(...)` 挂上对应提供者,或依赖全局存储回退。 > > ⚠️ 同理,`AddXiHanSettings`(及多租户模块)注册进 `XiHanSettingOptions.ValueProviders` 的默认值提供者链(`D → C → G → U`,多租户下插入 `T`)也**未被 `SettingManager` 读取**——`SettingManager` 构造函数不注入 `IOptions`。这份列表目前只是一份「已注册值提供者类型」的清单,真正参与取值的只有各 `SettingDefinition.Providers`(需显式 `AddProvider`)与全局存储 / 默认值回退。若要让 `ValueProviders` 实际生效,需要自行编写消费逻辑(例如启动时解析 `IOptions`,实例化后逐个 `AddProvider` 挂到相应定义上)。 `SetValueAsync(name, value, scope)` 按作用域解析出 `(providerName, providerKey)` 再写存储: | `SettingScope` | ProviderName | ProviderKey | 说明 | | --- | --- | --- | --- | | `Application` | `G` | `null` | 全局,无键 | | `Tenant` | `T` | `ICurrentTenant.Id` | 写入当前租户作用域(与读取口径一致);平台(`null` 或 `0`)没有租户级设置,抛 `XiHanException`,平台的设置写全局级 | | `User` | `U` | `ICurrentUser.UserId` | 无用户则抛 `XiHanException` | | `Session` | `U` | `ICurrentUser.UserId` | **与 `User` 同键**(会话级未单列独立存储键) | 写入前若挂了 `Validator` 且校验失败抛 `XiHanException`;`IsEncrypted` 会先加密;`value` 为空白则改为 `DeleteAsync`(视为清除)。写入后触发 `OnSettingChanged` 事件。 ### 加密 `SettingManager` 的加解密走 `Utils` 的 `AesHelper`。⚠️ 当前实现中密钥是**硬编码常量**,`XiHanAesOptions`(`Key`/`Iv`,配置节 `XiHan:Settings:Aes`)虽被绑定,但 `SettingManager.EncryptValue/DecryptValue` 并未读取它。若要正式使用加密设置,需以此为准评估并接入 `XiHanAesOptions`。 ## 核心能力 - **设置定义提供者模式(自动发现未接线到读写路径)**:实现 `ISettingDefinitionProvider.Define(...)` 声明设置项;模块启动时自动发现该实现类型并收集进 `XiHanSettingOptions.DefinitionProviders`,但当前源码没有消费者据此调用 `Define()`——定义仍需调用方显式对目标 `ISettingManager` 调用 `AddDefinition(...)` 才会生效(见「工作原理」) - **多来源值提供者链**:`D → C → G → U`(+ 多租户 `T`),逐个取第一个非空值 - **作用域读写**:`SettingScope` 分 `Application`(`"G"`)/ `Tenant`(`"T"`)/ `User`(`"U"`)/ `Session`(同 `"U"`);用户键来自 `ICurrentUser`,租户键来自 `ICurrentTenant` - **加密与校验**:定义可标记 `IsEncrypted` 走 AES;可挂 `Validator` 校验写入值 - **变更事件**:`SettingManager.OnSettingChanged` 在写入后触发 `SettingChangedEventArgs` - **可插拔存储**:`ISettingStore` 承载持久化,默认 `NullSettingStore`(`TryRegister`),上层替换为数据库实现 ## 主要 API / 类型 ### 管理与定义 | 类型 | 说明 | | --- | --- | | `ISettingManager` / `SettingManager` | 设置读写入口(`SettingManager` 为 `IScopedDependency`):`void AddDefinition(SettingDefinition)`、`Task GetOrNullAsync(string name, SettingScope scope = Application)`、`Task SetValueAsync(string name, string? value, SettingScope scope = Application)`;额外公开 `event OnSettingChanged`、`GetGroupSettings(group)`、`GetAllValuesAsync(scope)` | | `ISettingDefinitionProvider` | 设置定义提供者,实现 `void Define(ISettingDefinitionContext context)` | | `ISettingDefinitionContext` / `SettingDefinitionContext` | 定义上下文(单例):`void Add(SettingDefinition)`、`SettingDefinition? GetOrNull(string)`、`Dictionary GetAll()` | | `SettingDefinition` | 设置定义模型(见下) | | `SettingValue` | 名值对(继承 `NameValue`),`Name` + `Value` | | `SettingScope` | 作用域枚举:`Application` / `Tenant` / `User` / `Session` | `SettingDefinition` 构造参数与属性:`Name`(唯一)、`DefaultValue`、`DisplayName`、`Description`、`Group`(默认 `"General"`)、`IsVisibleToClients`(默认 `false`)、`IsEncrypted`(默认 `false`)、`Validator`(`Func?`);实例方法 `AddProvider(ISettingValueProvider)` 追加该定义的取值提供者。 ### 值提供者 | 类型 | ProviderName | 取值来源 | | --- | --- | --- | | `ISettingValueProvider` / `SettingValueProvider` | — | 契约与抽象基类(`Name` + `GetOrNullAsync` + `GetAllAsync`,`Transient`) | | `DefaultValueSettingValueProvider` | `D` | `SettingDefinition.DefaultValue` | | `ConfigurationSettingValueProvider` | `C` | `IConfiguration["Settings:{name}"]`(前缀 `Settings:`) | | `GlobalSettingValueProvider` | `G` | `ISettingStore`(无 providerKey) | | `UserSettingValueProvider` | `U` | `ISettingStore`(`ICurrentUser.UserId` 作 providerKey) | | `SettingValueProviderContext` | — | 提供者上下文(`Setting` / `Scope` / `ServiceProvider`) | ### 存储 | 类型 | 说明 | | --- | --- | | `ISettingStore` | 存储契约:`GetOrNullAsync(name, providerName, providerKey)`、`GetAllAsync(names, providerName, providerKey)`、`SetAsync(name, value, providerName, providerKey)`、`DeleteAsync(name, providerName, providerKey)` | | `NullSettingStore` | 空实现(`[Dependency(TryRegister = true)]`,单例),读返回 `null`、写/删空操作;上层替换持久化 | ## 配置 配置节 `XiHan:Settings`(`XiHanSettingOptions.SectionName`,代码组装为主,多数字段不从配置绑定): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `DefinitionProviders` | `ITypeList` | 空(自动发现填充) | 设置定义提供者集合(⚠️ 当前无代码读取此列表,未接入 `SettingManager`,见「工作原理」) | | `ValueProviders` | `ITypeList` | 空(`AddXiHanSettings` 填充默认四项) | 值提供者链(⚠️ 当前无代码读取此列表,`SettingManager` 不注入 `XiHanSettingOptions`) | | `DeletedSettings` | `HashSet` | 空 | 标记删除的设置名(⚠️ 同样未被 `SettingManager` 读取) | | `ReturnOriginalValueIfDecryptFailed` | `bool` | `true` | 解密失败时是否返回原始值(⚠️ 同样未被 `SettingManager` 读取) | > ⚠️ `SettingManager` 的构造函数不注入 `IOptions`,因此以上四个字段目前均不参与 `SettingManager` 的实际读写逻辑,仅作为可被自定义代码消费的选项容器存在。 配置节 `XiHan:Settings:Aes`(`XiHanAesOptions.SectionName`): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Key` | `string` | `""` | AES 密钥 | | `Iv` | `string` | `""` | AES 初始化向量 | > 该配置节会被绑定,但当前 `SettingManager` 的加解密使用硬编码密钥、未读取此选项,接入前请核对源码。 示例(通过 `Settings:` 前缀让 `ConfigurationSettingValueProvider` 取值): ```json { "Settings": { "App.PageSize": "20" }, "XiHan": { "Settings": { "Aes": { "Key": "", "Iv": "" } } } } ``` ## 使用示例 ### 定义设置项 ```csharp public class MySettingDefinitionProvider : ISettingDefinitionProvider { public void Define(ISettingDefinitionContext context) { context.Add(new SettingDefinition( name: "App.PageSize", defaultValue: "20", group: "General")); context.Add(new SettingDefinition( name: "App.SmtpPassword", group: "Mail", isEncrypted: true, validator: v => !string.IsNullOrWhiteSpace(v))); } } ``` > 上面的 `MySettingDefinitionProvider` 会被自动收集进 `XiHanSettingOptions.DefinitionProviders`,但如前所述本包不会自动调用其 `Define(...)`。要让 `SettingManager` 真正认识这些定义,需要自己构造好 `SettingDefinition` 后对目标 `ISettingManager` 调用 `AddDefinition(...)`(或自行编写逻辑:解析 `IOptions` 拿到 `DefinitionProviders`,实例化后调用 `Define(context)`,再把 `context.GetAll()` 逐条喂给 `AddDefinition`)。 ### 读写设置 ```csharp public class MyService(ISettingManager settingManager) { public async Task DemoAsync() { // 读:走该定义的 Providers 链 + 全局存储 + 默认值回退 var pageSize = await settingManager.GetOrNullAsync("App.PageSize"); // 写:作用域决定写到哪个 providerName/providerKey await settingManager.SetValueAsync("App.PageSize", "50", SettingScope.User); await settingManager.SetValueAsync("App.PageSize", "30", SettingScope.Application); } } ``` ### 让某设置支持多来源取值 ```csharp // 在定义时给该设置挂上取值提供者(否则读取仅靠全局存储 + 默认值回退) var def = new SettingDefinition("App.PageSize", defaultValue: "20") .AddProvider(defaultValueProvider) .AddProvider(globalProvider) .AddProvider(userProvider); ``` ## 扩展点 / 自定义 - **替换存储**:实现 `ISettingStore`(如查数据库)并注册;默认 `NullSettingStore` 用 `TryRegister`,你的实现会覆盖它。 - **自定义值提供者**:继承 `SettingValueProvider` 或实现 `ISettingValueProvider`,给出唯一 `Name`;真正生效的接入方式是在具体 `SettingDefinition` 上 `AddProvider(...)`。通过 `Configure` 加入 `ValueProviders` 目前只是登记类型清单,本包没有代码据此自动挂载到定义上,需自行编写消费逻辑。 - **监听变更**:订阅 `SettingManager.OnSettingChanged`,在设置写回后做缓存失效/通知等。 ## 注意事项与最佳实践 - **定义 / 值提供者的自动发现未接线到读写路径**:`ISettingDefinitionProvider` 自动发现只是把类型收集进 `DefinitionProviders`,`AddXiHanSettings` 注册的默认值提供者链只是把类型收集进 `ValueProviders`——本包没有代码消费这两份列表,`SettingManager` 也不注入 `IOptions`。设置定义必须显式调用 `ISettingManager.AddDefinition(...)` 才会被 `GetOrNullAsync`/`SetValueAsync` 认识,否则抛 `XiHanException`。 - **读取按定义的 Providers、不按 scope**:`GetOrNullAsync` 遍历的是 `SettingDefinition.Providers`,`scope` 参数不参与读取路径。若某设置需要多来源/分级取值,务必在定义上 `AddProvider`,或依赖全局存储回退。 - **`Session` 与 `User` 同键**:写入 `Session` 作用域实际用 `"U"` + `UserId`,与 `User` 无区分。 - **加密密钥硬编码**:`XiHanAesOptions` 目前未被 `SettingManager` 使用,加密走硬编码密钥;生产使用加密设置前需评估。 - **写空即删**:`SetValueAsync` 传入空白值等价于删除该设置的存储项。 - **定义重名会抛异常**:`SettingDefinitionContext.Add` 与 `SettingManager.AddDefinition` 均以 `Name` 去重,重复添加抛 `XiHanException`。 ## 依赖模块 - [XiHan.Framework.Core](./core) - [XiHan.Framework.Security](./security)(`ICurrentUser`,解析用户级设置的 providerKey) - [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions)(包引用,不声明模块依赖;`ICurrentTenant` 决定租户级设置写入哪个租户) ## 相关模块 - [XiHan.Framework.MultiTenancy](./multitenancy)(注入 `TenantSettingValueProvider` 实现租户级设置隔离、承载租户功能开关) - [XiHan.Framework.Security](./security) --- 来源:https://framework.docs.xihanfun.com/packages/tasks # XiHan.Framework.Tasks > 任务库:自研调度引擎(Cron/Interval/Delay)+ 一次性后台作业队列(BackgroundJobs)+ 后台常驻服务基类,执行期自动切换租户上下文。 - **NuGet**:`XiHan.Framework.Tasks` - **模块类**:`XiHanTasksModule` - **所在层**:基础设施层 - **关键依赖**:仅 .NET 原生 + 框架内部依赖(Caching / MultiTenancy / Timing)。**Cron 解析、重试策略、下次触发时间计算全部自研,未引入 Quartz / Cronos / Hangfire 等第三方调度库。** ## 概述 XiHan.Framework.Tasks 提供三条相互独立的能力线: 1. **定时任务调度(ScheduledJobs)**:以 `CompositeJobScheduler` 为核心的自研调度引擎,支持 Cron 表达式、固定间隔(Interval)、一次性延迟(Delay)三种自动触发方式,外加手动触发(Manual)。任务经过一条可插拔的中间件管道(日志 → 超时 → 锁 → 重试 → 指标)执行,并在正确的租户上下文中运行。 2. **后台作业队列(BackgroundJobs)**:`IBackgroundJobManager.EnqueueAsync` 提供一次性、fire-and-forget 的异步作业入队门面——入队仅落库并立即返回,执行完全交给轮询的 `BackgroundJobWorker` 驱动,天然具备持久化、崩溃恢复、失败指数退避重试能力,与触发它的业务请求解耦。 3. **后台常驻服务(BackgroundServices)**:`XiHanBackgroundServiceBase` 抽象基类,封装「循环拉取 → 并发处理 → 异常重试 → 优雅停机 → 运行统计」的通用骨架,子类只需实现「取任务」和「处理任务」两个方法即可拥有一个生产级后台 worker。 三者都在模块启用后自动接入 .NET 托管服务生命周期(`IHostedService` / `BackgroundService`)。 ## 何时使用 - 需要按 Cron 表达式、固定时间间隔或一次性延迟运行的定时作业(如每日报表、定时清理、延迟提醒)。 - 需要把一次性、耗时的操作从请求线程甩出去异步执行(如发送邮件/短信、生成文件、调用第三方接口),且要求失败自动退避重试、进程重启不丢失——用 `IBackgroundJobManager.EnqueueAsync`。 - 需要一个常驻后台服务,持续从 Redis / 数据库 / 消息队列拉取并**并发**处理任务(如发件箱、导出任务消费)。 - 希望任务执行自带日志、超时、分布式锁、重试、指标等横切能力,而不必自己拼装。 - 多租户应用中,希望定时任务/后台作业在正确的租户上下文里执行(自动 `ICurrentTenant.Change`)。 选型提示:**「周期性/定时触发」用 ScheduledJobs;「一次性 fire-and-forget、入队即返回」用 `IBackgroundJobManager`;「持续拉队列消费」用 `XiHanBackgroundServiceBase`。** 三者可在同一应用中并存。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Tasks ``` ```csharp [DependsOn(typeof(XiHanTasksModule))] public class MyModule : XiHanModule { } ``` `XiHanTasksModule.ConfigureServices` 调用 `AddXiHanTasks(config)`,从配置节 `XiHan:Tasks:ScheduledJobs`(`XiHanJobOptions.SectionName`)绑定选项,并注册(`TryAdd` 语义,均可被业务侧替换): - `IJobStore` → `DefaultJobStore`(实例最多 20000 条、终态实例最近 10000 条、历史最多 100000 条) - `IJobLockProvider` → `CachingJobLockProvider`(复用 Caching 的统一分布式锁) - `IJobScheduler` → `CompositeJobScheduler`(复合调度器) - `IJobExecutor` → `JobExecutor`(执行器) - `JobMetricsProvider`、`IJobEventPublisher` → `DefaultJobEventPublisher` 同时按固定顺序注册五个中间件 `IJobMiddleware`:`LoggingMiddleware` → `TimeoutMiddleware` → `LockMiddleware` → `RetryMiddleware` → `MetricsMiddleware`,并注册托管服务 `JobHostedService`(应用启动时自动 `StartAsync` 调度器)。 与 ScheduledJobs 不同,**BackgroundJobs(一次性作业队列)在 `XiHanTasksModule.PreConfigureServices` 阶段就已通过 `AddXiHanBackgroundJobs(config)` 自动注册**(早于业务模块的 `ConfigureServices`,以便挂载作业处理器自动发现钩子)。注册内容(同样 `TryAdd` 语义,均可被业务侧替换): - `IBackgroundJobSerializer` → `BackgroundJobSerializer`(基于 `System.Text.Json`) - `IBackgroundJobStore` → `DefaultBackgroundJobStore`(最多 100000 条;成功或放弃后删除) - `IBackgroundJobManager` → `BackgroundJobManager` - `IBackgroundJobExecuter` → `BackgroundJobExecuter` - 托管服务 `BackgroundJobWorker`(轮询驱动执行) 同时通过 `services.OnRegistered` 钩子自动收集所有实现 `IAsyncBackgroundJob` 的非抽象类型,登记进 `BackgroundJobOptions` 注册表——继承 `AsyncBackgroundJob` 基类的作业处理器会被约定注册为瞬时服务并自动纳入发现;若直接实现接口而不继承基类,需自行带生命周期标记或手动注册。 > 说明:`XiHanBackgroundServiceBase` 及其依赖的 `XiHanBackgroundServiceOptions`、`IDynamicServiceConfig` 属于 BackgroundServices 命名空间,**不由 `AddXiHanTasks` / `AddXiHanBackgroundJobs` 自动注册**——你的具体后台服务需自行 `services.AddHostedService()` 并按需 `Configure(...)`。 ## 工作原理 ### 调度引擎(CompositeJobScheduler) - 调度器内部持有 `JobRegistry`(任务登记表)与 `JobTriggerManager`(触发状态:下次触发时间、触发次数、暂停标记、上次触发时间)。 - `StartAsync` 启动后,一个 `Timer` **每 1 秒**回调一次 `CheckAndFireJobs`:遍历所有启用且未暂停的任务,`ShouldFire` 判定是否到点(含截止时间 `EndTime`、重复次数上限 `RepeatCount` 门控),到点则以 `Task.Run` 异步执行。 - 每次触发后 `UpdateNextFireTime` 重算下次时间: - `Cron` → `CronScheduler.GetNextFireTime(expr)`(自研 `CronHelper` 解析) - `Interval` → `IntervalScheduler.GetNextFireTime(interval)` - `Delay` → **仅在从未触发过时排期一次**(触发后不再续排,避免按 Delay 周期无限重复) - Cron/Interval 若算不出下次时间(表达式无解、超过截止时间等),会**显式打 Warning 日志**而非静默吞掉(任务「注册了但永不执行」时有迹可循)。 ### 执行与租户切换(JobExecutor) - `JobExecutor.ExecuteAsync` 为每次执行创建一个 DI 作用域并切入任务所在租户,再用 `ActivatorUtilities.CreateInstance` 构造 `IJobWorker` 实例、套上中间件管道执行。 - **多租户感知**:`CompositeJobScheduler.ResolveTenantId` 按优先级解析租户——① 参数里的 `tenantId` → ② `JobInfo.TenantId` → ③ 当前异步上下文租户(`AsyncLocalCurrentTenantAccessor`);解析到租户时,`JobExecutor` 在执行开头用 `ICurrentTenant.Change(tenantId, ...)` 切入,实例落库、任务构造、管道执行、状态回写与历史落档(含失败路径)都在该租户作用域内。`TenantId` 为空视为平台任务,在平台(0 号租户)作用域执行。 - 执行结果与异常均落 `JobInstance` 状态并写 `JobHistory`(即便状态回写失败也保证历史留痕,便于排障)。 ### 后台作业队列(BackgroundJobs) - **入队**:`BackgroundJobManager.EnqueueAsync` 用作业参数类型 `TArgs` 解析出稳定作业名(优先已注册配置里的 `JobName`,否则回退 `[BackgroundJobName]` 特性,再回退参数类型全名),连同序列化后的参数、当前租户 `ICurrentTenant.Id`、优先级、`NextTryTime`(按 `delay` 计算或立即)打包成 `BackgroundJobInfo` 写入 `IBackgroundJobStore` 并立即返回作业 Id,不等待执行。 - **轮询执行**:`BackgroundJobWorker`(`BackgroundService`)启动后先等待 `FirstWaitDurationMilliseconds`,随后按 `JobPollPeriodMilliseconds` 周期用 `PeriodicTimer` 轮询;每轮先通过 `IDistributedLock.TryAcquireAsync`(`DistributedLockName` + `DistributedLockExpirySeconds`)抢占单活锁,抢不到则本轮直接跳过(多实例下天然保证同一时刻只有一个实例在处理)。 - **领取与执行**:抢到锁后按 `ApplicationName` 过滤调用 `IBackgroundJobStore.GetWaitingJobsAsync` 批量领取(契约:`!IsAbandoned && NextTryTime <= 现在`,按 `Priority` 降序、`TryCount` 升序、`NextTryTime` 升序排序,限 `MaxJobFetchCount` 条),逐个反序列化参数、以 `ICurrentTenant.Change(job.TenantId)` 切换租户上下文后交给 `IBackgroundJobExecuter.ExecuteAsync`(反射解析 `IAsyncBackgroundJob` 处理器并调用其 `ExecuteAsync`)。 - **成功/失败**:成功即 `IBackgroundJobStore.DeleteAsync` 删除;抛出 `BackgroundJobExecutionException`(业务失败信号)则按指数退避计算下次重试时间(`nextWait = DefaultFirstWaitDurationSeconds × DefaultWaitFactor^(TryCount-1)` 秒),累计耗时(自 `CreationTime` 起)超过 `DefaultTimeoutSeconds`(默认 2 天)则标记 `IsAbandoned` 放弃;找不到作业配置或反序列化失败等致命错误同样直接放弃。默认内存存储会立即移除已放弃作业,Redis 实现则按 `AbandonedRetentionDays` 保留后自动过期。单个作业异常不会杀死 Worker,下一轮继续处理其余作业。 - **作业处理器发现**:实现 `IAsyncBackgroundJob`(或继承 `AsyncBackgroundJob` 基类)的非抽象类型,会在服务注册期被自动收集进 `BackgroundJobOptions`,形成「参数类型 ↔ 处理器类型 ↔ 作业名」三向映射,入队与执行两端各自据此解析。 ### 后台服务(XiHanBackgroundServiceBase) - 主循环:`IsTaskProcessingEnabled` 门控 → 当前运行数 < `MaxConcurrentTasks` 时调 `FetchWorkItemsAsync` 批量取任务 → 每个任务包进带重试/超时的 `Task.Run` 并发执行;无任务或已满并发时按 `IdleDelayMilliseconds` 空转等待。 - 单任务处理包装器统一记录统计(开始/完成/重试次数/耗时),失败走 `OnTaskFailed`(可重写)。 - 停机时等待在跑任务收尾,带 `ShutdownTimeoutMilliseconds` 超时上限。 - 支持 `IDynamicServiceConfig` 运行时热调并发数 / 空闲延迟 / 启停开关(`ConfigChanged` 事件)。 ## 核心能力 - **多触发方式调度**:Cron / Interval / Delay / Manual 四类触发;调度器每秒巡检。 - **执行管道 + 中间件**:横切逻辑(日志、超时、锁、重试、指标)以 `IJobMiddleware` 串联,可 `XiHanJobBuilder.AddMiddleware()` 追加自定义。 - **声明式任务定义**:`[JobName]`、`[JobSchedule]`、`[JobRetry]`、`[JobTimeout]`、`[JobConcurrent]`、`[JobPriority]`、`[JobDescription]` 特性 + `RegisterJobsFromAssembly` 反射自动注册。 - **一次性作业队列**:`IBackgroundJobManager.EnqueueAsync` fire-and-forget 入队,`BackgroundJobWorker` 轮询驱动执行,自带失败指数退避重试与累计超时放弃,默认内存存储、可一行切换 Redis 持久化跨实例。 - **任务锁**:`CachingJobLockProvider` 复用 Caching 统一分布式锁(有 Redis 则跨实例、否则进程内回退),配合 `AllowConcurrent=false` 防止同名任务并发重入。 - **多租户感知**:调度解析 + 执行期租户上下文切换,天然适配多租户定时作业。 - **后台常驻服务基类**:并发控制、批量拉取、重试(指数退避)、优雅停机、运行统计、动态配置一站封装。 ## 主要 API / 类型 ### 定时任务(ScheduledJobs) | 类型 | 说明 | | --- | --- | | `IJobScheduler` / `CompositeJobScheduler` | 调度器契约与复合实现。核心方法:`void RegisterJob(JobInfo)`、`void UnregisterJob(string)`、`void PauseJob(string)`、`void ResumeJob(string)`、`Task TriggerJobAsync(string, IDictionary?)`、`DateTimeOffset? GetNextFireTime(string)`、`IReadOnlyList GetAllJobs()`、`Task StartAsync(...)`、`Task StopAsync(...)` | | `IJobWorker` | 任务业务实现契约:`Task ExecuteAsync(IJobContext, CancellationToken)` | | `IJobContext` | 执行上下文:`JobInstance`、`long? TenantId`、`Parameters`、`ServiceProvider`、`TraceId`、`StartedAt`、`AttemptCount`、`CancellationToken` | | `IJobExecutor` / `JobExecutor` | 执行器,在租户上下文中跑任务实例、写历史 | | `IJobMiddleware` | 执行管道中间件抽象(Logging/Timeout/Lock/Retry/Metrics 内置) | | `IJobStore` / `DefaultJobStore` | 有界默认存储:实例最多 20000 条、终态实例最近 10000 条、历史最多 100000 条 | | `IJobLockProvider` / `CachingJobLockProvider` | 任务锁抽象与基于 Caching 分布式锁的实现 | | `IJobEventPublisher` / `JobMetricsProvider` | 任务事件发布与指标采集 | | `XiHanJobBuilder` | 链式配置:`UseStore()`、`UseLockProvider()`、`AddMiddleware()`、`AddJob()`、`Configure(...)` | | `XiHanJobOptions` | 调度选项(见下表) | ### 任务模型与枚举 | 类型 | 说明 | | --- | --- | | `JobInfo` | 任务静态定义:`JobName`、`JobType`、`TriggerType`、`CronExpression`、`Interval`、`Delay`、`EndTime`、`RepeatCount`、`Priority`、`AllowConcurrent`、`TimeoutMilliseconds`、`RetryPolicy`、`IsEnabled`、`TenantId`、`DefaultParameters` | | `JobResult` | 执行结果,工厂 `Success(...)` / `Failure(...)` / `Canceled(...)` | | `JobRetryPolicy` | 重试策略:`MaxRetryCount`、`RetryIntervalMilliseconds`、`UseExponentialBackoff`、`BackoffMultiplier`、`MaxRetryIntervalMilliseconds`;`CalculateDelay(attempt)` | | `JobTriggerType` | `Cron` / `Interval` / `Delay` / `Manual` | | `JobStatus` | `Pending` / `Running` / `Succeeded` / `Failed` / `Canceled` / `Paused` | | `JobPriority` | `Low` / `Normal` / `High` / `Critical` | ### 声明式特性(Attributes) | 特性 | 说明 | | --- | --- | | `[JobName(name)]` | 任务唯一名称(自动注册的**必需**特性,缺失则跳过该类型) | | `[JobSchedule(cron)]` / `[JobSchedule(intervalSeconds)]` / `[JobSchedule()]` | 触发方式:Cron / Interval / Manual;另有 `DelaySeconds` 属性 | | `[JobRetry]` | `MaxRetryCount`、`RetryIntervalMilliseconds`、`UseExponentialBackoff` | | `[JobTimeout(ms)]` | 超时毫秒 | | `[JobConcurrent(bool)]` | 是否允许并发 | | `[JobPriority(JobPriority)]` | 优先级 | | `[JobDescription(text)]` | 任务描述 | ### 后台作业队列(BackgroundJobs) | 类型 | 说明 | | --- | --- | | `IBackgroundJobManager` / `BackgroundJobManager` | 入队门面:`Task EnqueueAsync(TArgs args, BackgroundJobPriority priority = Normal, TimeSpan? delay = null)` | | `IAsyncBackgroundJob` | 作业处理器契约:`Task ExecuteAsync(TArgs args)`(继承标记接口 `IBackgroundJob`) | | `AsyncBackgroundJob` | 作业处理器抽象基类,实现 `ITransientDependency`(约定自动瞬时注册)+ `Logger` 属性 | | `IBackgroundJobExecuter` / `BackgroundJobExecuter` | 执行器:从 DI 解析处理器,反射调用 `IAsyncBackgroundJob.ExecuteAsync` | | `IBackgroundJobStore` | 存储端口:`FindAsync(jobId)`、`InsertAsync(jobInfo)`、`GetWaitingJobsAsync(applicationName, maxResultCount)`、`DeleteAsync(jobId)`、`UpdateAsync(jobInfo)` | | `DefaultBackgroundJobStore` | 默认内存实现(最多 100000 条;进程重启丢失;成功或放弃后移除) | | `RedisBackgroundJobStore` | 可选 Redis 实现:有序集合索引(score=下次执行时间)+ 字符串键存作业体 JSON,放弃的作业移出索引并设 TTL 便于事后排查 | | `IBackgroundJobSerializer` / `BackgroundJobSerializer` | 参数序列化端口,默认基于 `System.Text.Json` | | `BackgroundJobWorker` | 轮询 `BackgroundService`:抢分布式锁 → 领取 → 执行 → 成功删除 / 失败退避 / 超时放弃 | | `BackgroundJobInfo` | 持久化模型:`Id`、`ApplicationName`、`TenantId`、`JobName`、`JobArgs`(JSON)、`TryCount`、`CreationTime`、`NextTryTime`、`LastTryTime`、`IsAbandoned`、`Priority` | | `BackgroundJobExecutionContext` | 执行上下文:`ServiceProvider`、`JobType`、`JobArgs`、`CancellationToken` | | `BackgroundJobExecutionException` | 业务失败信号(区别于致命错误),可携带 `JobName` / `JobArgs`,触发退避重试 | | `BackgroundJobPriority` | `Low`(5) / `BelowNormal`(10) / `Normal`(15,默认) / `AboveNormal`(20) / `High`(25),领取时按值降序排序 | | `[BackgroundJobName(name)]` | 标注在**作业参数类型**上,给作业一个稳定持久化名(不标注则回退参数类型全名;重命名处理器类型不影响已入库作业) | | `BackgroundJobConfiguration` | 「作业名 ↔ 处理器类型 ↔ 参数类型」三向映射(单条配置) | | `BackgroundJobOptions` | 作业注册表(自动发现填充):`AddJob()`、`GetJobOrNull(name)`、`GetJobByArgsOrNull(argsType)`、`GetJobs()` | | `BackgroundJobArgsHelper` | 反射工具:`GetJobArgsType(jobType)`、`IsBackgroundJob(type)` | ### 后台服务(BackgroundServices) | 类型 | 说明 | | --- | --- | | `XiHanBackgroundServiceBase` | 后台常驻服务抽象基类。抽象方法:`Task> FetchWorkItemsAsync(int, CancellationToken)`、`Task ProcessItemAsync(IBackgroundTaskItem, CancellationToken)`;可重写 `OnTaskFailed(...)`、`CreateDefaultRetryPolicy()`、`OnConfigChanged(...)` | | `IBackgroundTaskItem` | 后台任务项:`TaskId`、`Data`、`CreatedAt`、`RetryCount` | | `IDynamicServiceConfig` / `DynamicServiceConfig` | 运行时动态配置:`UpdateMaxConcurrentTasks(int)`、`UpdateIdleDelay(int)`、`SetTaskProcessingEnabled(bool)` + `ConfigChanged` 事件 | | `XiHanBackgroundServiceOptions` | 后台服务选项(见下表) | | `BackgroundServiceStatistics` / `StatisticsSummary` | 运行统计 | ### DI 扩展方法 | 方法 | 说明 | | --- | --- | | `IServiceCollection.AddXiHanTasks(IConfiguration)` | 从配置绑定并注册全套调度服务,返回 `XiHanJobBuilder` | | `IServiceCollection.AddXiHanTasks(Action?)` | 代码方式配置 + 注册 | | `XiHanJobBuilder.UseDefaultStore()` / `UseDefaultLock()` / `UseDistributedLock()` | 默认存储 / 锁快捷方法(锁后端由 Caching 的应用侧配置决定) | | `IJobScheduler.RegisterJobsFromAssembly(Assembly)` | 反射扫描程序集内带 `[JobName]` 的 `IJobWorker` 自动注册 | | `IJobScheduler.RegisterCronJob(name, cron, ...)` / `RegisterIntervalJob(name, interval, ...)` | 代码方式注册单个任务 | | `IServiceCollection.AddXiHanBackgroundJobs(IConfiguration)` | 注册后台作业队列全套(管理器 + 轮询 Worker + 内存存储默认 + 处理器自动发现);模块已在 `PreConfigureServices` 自动调用,通常无需手动调用 | | `IServiceCollection.UseRedisBackgroundJobStore(Action?)` | 将 `IBackgroundJobStore` 替换为 `RedisBackgroundJobStore`(`services.Replace`),复用 Caching 注册的 `IConnectionMultiplexer` | ## 配置 配置节:`XiHan:Tasks:ScheduledJobs`(`XiHanJobOptions.SectionName`)。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `Enabled` | `bool` | `true` | 是否启用任务调度 | | `AutoDiscoverJobs` | `bool` | `true` | 是否自动发现并注册任务 | | `JobAssemblyPatterns` | `string[]` | `["*.Jobs", "*.Tasks"]` | 扫描的程序集名称模式 | | `DefaultTimeoutMilliseconds` | `int` | `300000` | 默认任务超时(5 分钟) | | `HistoryRetentionDays` | `int` | `30` | 历史记录保留天数 | | `EnableMetrics` | `bool` | `true` | 是否启用性能监控 | | `NodeName` | `string?` | `null` | 任务执行节点名称 | `XiHanBackgroundServiceOptions`(后台服务,需自行绑定,无固定配置节名): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `MaxConcurrentTasks` | `int` | `5` | 最大并发任务数 | | `IdleDelayMilliseconds` | `int` | `1000` | 队列为空时的等待时间 | | `EnableRetry` | `bool` | `true` | 是否启用重试 | | `EnableTaskTimeout` | `bool` | `false` | 是否启用任务超时控制 | | `MaxRetryCount` | `int` | `3` | 失败重试次数 | | `RetryDelayMilliseconds` | `int` | `5000` | 重试延迟 | | `TaskTimeoutMilliseconds` | `int` | `0` | 单任务超时(0=不超时) | | `ShutdownTimeoutMilliseconds` | `int` | `30000` | 停机等待任务收尾超时 | `BackgroundJobWorkerOptions`(后台作业队列,配置节 `XiHan:BackgroundJobs`,`BackgroundJobWorkerOptions.SectionName`,模块自动绑定): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `IsJobExecutionEnabled` | `bool` | `true` | 是否启用作业执行(关闭后入队仍可用,只是 Worker 不执行,适合数据迁移等场景) | | `ApplicationName` | `string?` | `null` | 应用名称,用于多实例隔离;为空表示不区分 | | `FirstWaitDurationMilliseconds` | `int` | `5000` | 首次轮询前的等待 | | `JobPollPeriodMilliseconds` | `int` | `5000` | 轮询间隔 | | `MaxJobFetchCount` | `int` | `1000` | 每轮最多领取的作业数量 | | `DefaultFirstWaitDurationSeconds` | `int` | `60` | 首次失败后的等待秒数(退避基数) | | `DefaultWaitFactor` | `double` | `2.0` | 退避倍率 | | `DefaultTimeoutSeconds` | `int` | `172800`(2 天) | 放弃阈值:自创建起累计重试时间超过此值即放弃,唯一的失败上限(无固定次数) | | `DistributedLockName` | `string` | `"XiHanBackgroundJobWorker"` | 分布式锁名称,保证多实例下单活 Worker | | `DistributedLockExpirySeconds` | `int` | `300` | 分布式锁 TTL(崩溃安全网,应大于单轮处理耗时) | `RedisBackgroundJobStoreOptions`(切换 `services.UseRedisBackgroundJobStore(...)` 时可选配置,无固定配置节名,代码方式传入): | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `KeyPrefix` | `string` | `"Default:BackgroundJobs"` | 键前缀:作业体键 `{Prefix}:job:{id}`,活跃索引有序集合 `{Prefix}:index` | | `AbandonedRetentionDays` | `int` | `7` | 已放弃作业的保留天数(移出活跃索引,作业体设 TTL 便于事后排查) | | `FetchMultiplier` | `int` | `4` | 候选加载倍数:每轮从索引取 `maxResultCount × 本值` 条到期候选,内存二次排序后再取 `maxResultCount` | 示例 `appsettings.json`: ```json { "XiHan": { "Tasks": { "ScheduledJobs": { "Enabled": true, "AutoDiscoverJobs": true, "JobAssemblyPatterns": ["*.Jobs", "*.Tasks"], "DefaultTimeoutMilliseconds": 300000, "HistoryRetentionDays": 30, "EnableMetrics": true } }, "BackgroundJobs": { "IsJobExecutionEnabled": true, "JobPollPeriodMilliseconds": 5000, "MaxJobFetchCount": 1000 } } } ``` ## 使用示例 ### 示例 1:定义一个每日凌晨执行的 Cron 任务 ```csharp using XiHan.Framework.Tasks.ScheduledJobs.Abstractions; using XiHan.Framework.Tasks.ScheduledJobs.Attributes; using XiHan.Framework.Tasks.ScheduledJobs.Models; [JobName("DailyReport")] [JobDescription("每日报表生成")] [JobSchedule("0 0 * * *")] // Cron:每天 00:00(支持 5 位/6 位与 @daily 等预定义) [JobRetry(MaxRetryCount = 2)] [JobConcurrent(false)] // 不允许并发重入 public class DailyReportJob : IJobWorker { private readonly IReportService _reports; public DailyReportJob(IReportService reports) => _reports = reports; public async Task ExecuteAsync(IJobContext context, CancellationToken cancellationToken = default) { // context.TenantId 已由调度器解析;执行时已切到对应租户上下文 await _reports.GenerateDailyAsync(cancellationToken); return JobResult.Success(); } } ``` 注册(反射扫描或显式): ```csharp // 方式一:程序集扫描(类型需带 [JobName]) scheduler.RegisterJobsFromAssembly(typeof(DailyReportJob).Assembly); // 方式二:代码显式注册 scheduler.RegisterCronJob("DailyReport", "0 0 * * *", "每日报表"); scheduler.RegisterIntervalJob("Heartbeat", TimeSpan.FromMinutes(1)); // 手动触发一次(返回任务实例 Id) string instanceId = await scheduler.TriggerJobAsync("DailyReport", new Dictionary { ["tenantId"] = 1001 }); ``` ### 示例 2:一次性 fire-and-forget 后台作业 ```csharp using XiHan.Framework.Tasks.BackgroundJobs.Abstractions; using XiHan.Framework.Tasks.BackgroundJobs.Attributes; using XiHan.Framework.Tasks.BackgroundJobs.Models; // 作业参数类型即作业的稳定标识来源,建议显式标注 [BackgroundJobName] [BackgroundJobName("SendWelcomeEmail")] public class SendWelcomeEmailArgs { public long UserId { get; set; } public string Email { get; set; } = default!; } // 继承 AsyncBackgroundJob:自动瞬时注册 + 自动纳入作业发现,无需手动登记 public class SendWelcomeEmailJob : AsyncBackgroundJob { private readonly IEmailSender _emailSender; public SendWelcomeEmailJob(IEmailSender emailSender) => _emailSender = emailSender; public override async Task ExecuteAsync(SendWelcomeEmailArgs args) { await _emailSender.SendAsync(args.Email, "欢迎注册", "..."); } } ``` ```csharp public class RegistrationAppService { private readonly IBackgroundJobManager _jobManager; public RegistrationAppService(IBackgroundJobManager jobManager) => _jobManager = jobManager; public async Task RegisterAsync(long userId, string email) { // ... 业务逻辑(写库等) ... // 入队即返回作业 Id;实际发送由 BackgroundJobWorker 异步轮询执行,失败自动指数退避重试 await _jobManager.EnqueueAsync(new SendWelcomeEmailArgs { UserId = userId, Email = email }); // 也可指定优先级 / 延迟执行 await _jobManager.EnqueueAsync( new SendWelcomeEmailArgs { UserId = userId, Email = email }, priority: BackgroundJobPriority.High, delay: TimeSpan.FromMinutes(5)); } } ``` 无需额外注册——`XiHanTasksModule` 已在 `PreConfigureServices` 自动接管 `IBackgroundJobManager` / `BackgroundJobWorker` 与作业处理器发现。 ### 示例 3:后台常驻服务持续消费队列 ```csharp using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using XiHan.Framework.Tasks.BackgroundServices; public class OutboxConsumer : XiHanBackgroundServiceBase { private readonly IOutboxQueue _queue; public OutboxConsumer( ILogger logger, IOptions options, IOutboxQueue queue) : base(logger, options) { _queue = queue; } // 从队列批量取任务;返回空集合表示当前无任务(基类会空转等待) protected override async Task> FetchWorkItemsAsync( int maxCount, CancellationToken cancellationToken) => await _queue.DequeueAsync(maxCount, cancellationToken); // 处理单个任务 protected override async Task ProcessItemAsync( IBackgroundTaskItem item, CancellationToken cancellationToken) => await _queue.HandleAsync(item, cancellationToken); } ``` ```csharp // 注册(后台服务不由 AddXiHanTasks 自动接管,需自行注册) services.Configure(o => o.MaxConcurrentTasks = 8); services.AddHostedService(); ``` ## 扩展点 / 自定义 - **替换任务存储 / 锁**:`services.AddSingleton()`(覆盖默认 `TryAddSingleton`),或 `builder.UseStore()` / `builder.UseLockProvider()`。 - **追加中间件**:实现 `IJobMiddleware`,`builder.AddMiddleware()`;注册顺序即执行顺序。 - **持久化后台作业队列**:默认内存存储进程重启丢失;`services.UseRedisBackgroundJobStore()` 一行切换为 Redis 持久化 + 跨实例(复用 Caching 的 `IConnectionMultiplexer`),或自行实现 `IBackgroundJobStore` 接自建存储(如数据库)。 - **自定义后台服务**:继承 `XiHanBackgroundServiceBase`,实现 `FetchWorkItemsAsync` / `ProcessItemAsync`,可重写 `OnTaskFailed` / `CreateDefaultRetryPolicy`。 - **运行时热调**:通过 `IDynamicServiceConfig` 动态改并发数、空闲延迟、启停,无需重启。 ## 注意事项与最佳实践 - 调度器**每秒巡检一次**,触发精度为秒级;`Delay` 是「一次性」延迟,触发后不再续排。 - Cron/Interval 任务若下次触发时间算不出(表达式无解 / 已过截止时间),任务会「注册但永不执行」——留意日志中的 Warning。 - `AllowConcurrent=false` 依赖 `IJobStore.GetRunningInstancesAsync` + 任务锁,跨实例防并发需 Redis 分布式锁(Caching 启用 Redis)。 - 多租户任务:优先用参数 `tenantId` 或 `JobInfo.TenantId` 指定租户;未指定时回退到当前异步上下文租户。平台级任务令 `TenantId` 为空,它在平台作用域执行、只看得到平台数据。 - 后台服务的 `XiHanBackgroundServiceOptions` **默认不启用单任务超时**(`EnableTaskTimeout=false`、`TaskTimeoutMilliseconds=0`),如需超时须显式打开。 - 默认 `DefaultJobStore` 是有界进程内存储,进程重启丢失历史;满载时新增抛 `InvalidOperationException`(历史写入失败只记错误日志,不影响任务执行),框架不自动清理历史,长期运行需定期调 `CleanupHistoryAsync`;需持久化请自行实现 `IJobStore`。 - 后台作业队列没有固定重试次数上限,只有**累计耗时**上限(`DefaultTimeoutSeconds`,默认 2 天)——退避间隔按指数增长,高频失败的作业会更快被判定放弃,而非跑满固定次数。 - `BackgroundJobWorker` 靠分布式锁保证多实例单活;默认 `DefaultBackgroundJobStore` 进程重启丢失全部待执行作业,需要持久化与跨实例可靠投递请切换 `UseRedisBackgroundJobStore()` 或自实现 `IBackgroundJobStore`。 - `[BackgroundJobName]` 标注在**作业参数类型**而非处理器类型上;不标注时回退参数类型全名——修改参数类型的命名空间/类名会导致名称变化,已入库未执行的旧作业将找不到配置而被放弃,关键作业建议显式标注固定名称。 ## 依赖模块 - [XiHan.Framework.Caching](./caching) — 任务锁与 `BackgroundJobWorker` 单活锁复用其统一的分布式锁(Redis 跨实例 / 进程内回退自动选择);`RedisBackgroundJobStore` 复用其注册的 `IConnectionMultiplexer`。 - [XiHan.Framework.MultiTenancy](./multitenancy) 与 [XiHan.Framework.MultiTenancy.Abstractions](./multitenancy-abstractions) — 任务/作业的租户解析与执行期 `ICurrentTenant.Change` 上下文切换。 - [XiHan.Framework.Timing](./timing) — 时间基础设施(`IClock`)。 (Cron 解析、重试与调度均为框架自研,未引入第三方调度库。) ## 相关模块 - [XiHan.Framework.Caching](./caching) — 分布式锁与缓存底座。 - [XiHan.Framework.MultiTenancy](./multitenancy) — 多租户上下文来源。 - [XiHan.Framework.Observability](./observability) — 可观测性,配合任务指标与追踪。 --- 来源:https://framework.docs.xihanfun.com/packages/templating # XiHan.Framework.Templating > 模板渲染:基于第三方 Scriban 的完整引擎 + 一个轻量字符串替换引擎,经引擎注册表 `ITemplateEngineRegistry` 按名称统一管理。 - **NuGet**:`XiHan.Framework.Templating` - **模块类**:`XiHanTemplatingModule` - **所在层**:基础设施层 - **关键依赖**:`Scriban`(7.2.6,模板引擎)+ 框架内部依赖(Core / Serialization / Utils)。 ## 概述 XiHan.Framework.Templating 提供「模板 + 数据 = 文本」的渲染能力,并把多种引擎统一到注册表 `ITemplateEngineRegistry` 下按名称管理。框架内置两种引擎: - **Scriban 引擎** `ScribanTemplateEngine`(注册名 `"Scriban"`,模板类型 `ITemplateEngine