# 曦寒开发框架 · 开发指南 共 38 页。索引见 https://framework.docs.xihanfun.com/llms.txt --- 来源:https://framework.docs.xihanfun.com/guide/ai # AI 与 MCP 框架把大模型接入收敛成三件事:按名解析 provider 拿到一个 `IChatClient`、把应用能力登记成「技能」、再决定这些技能是内部工具调用还是对外 MCP tool。本章讲这三条链路怎么串、配置放哪、以及哪些开关不打开就等于没有。 完整 API 与全部配置项见 [AI 包](../packages/ai)、[AI.Abstractions 包](../packages/ai-abstractions)、[Web.Mcp 包](../packages/web-mcp)。 ## 三个包的分工 | 包 | 模块类 | 职责 | | --- | --- | --- | | `XiHan.Framework.AI.Abstractions` | 无(纯契约) | 接口与 Options:`IXiHanAiService`、`IAiProviderConfigStore`、`IAiSkill`、RAG 契约、护栏契约 | | `XiHan.Framework.AI` | `XiHanAIModule` | 默认实现:OpenAI 兼容工厂、解析器、护栏、技能注册表、Agent 工厂、RAG | | `XiHan.Framework.Web.Mcp` | `XiHanWebMcpModule` | 把技能经 HTTP 暴露成 MCP Server 端点,并做 key 鉴权 | 底座是 `Microsoft.Extensions.AI`(会话与嵌入)、`Microsoft.Agents.AI`(Agent)、`ModelContextProtocol`(MCP)、`Microsoft.Extensions.VectorData.Abstractions`(向量数据契约)。框架只在其上加「多 provider 选择 + 可插拔配置源 + 技能」这层语义,会话本身不做二次包装。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.AI ``` ```csharp [DependsOn(typeof(XiHanAIModule))] public class MyModule : XiHanModule { } ``` `XiHanAIModule.ConfigureServices` 只做两件事:`AddXiHanAI()` 与 `AddXiHanRAG()`。两者注册的实现**全部走 `TryAdd*`**,所以应用层在任何位置显式注册同一接口都能顶掉默认实现。唯一例外是护栏 `IAiGuardrail`——它走 `TryAddEnumerable`,应用层注册的是追加而非顶替。 `XiHanAIModule` 依赖 `XiHanHttpModule`。要对外暴露 MCP 端点则改依赖 `XiHanWebMcpModule`(它已 `DependsOn` AI 模块与 Web.Core 模块)。 ## 第一步:配 provider,发起对话 `XiHan:AI` 节里,`Providers` 是一张「provider 名 → 配置」的字典(键大小写不敏感),`DefaultProvider` 指定不显式传名时用哪一个。 ```json { "XiHan": { "AI": { "DefaultProvider": "DeepSeek", "Providers": { "DeepSeek": { "BaseUrl": "https://api.deepseek.com/v1", "ApiKey": "sk-xxx", "Model": "deepseek-chat", "EmbeddingModel": "text-embedding-3-small" }, "Local": { "BaseUrl": "http://localhost:11434/v1", "Model": "qwen2.5" } } } } } ``` ```csharp public sealed class AskService(IXiHanAiService ai) { public async Task AskAsync(string question, CancellationToken ct) { ChatMessage[] messages = [ new(ChatRole.System, "你是简洁的助理。"), new(ChatRole.User, question) ]; // Provider 为 null 时用 DefaultProvider var response = await ai.ChatAsync(messages, new XiHanChatOptions { Provider = "Local" }, ct); return response.Text; } } ``` 流式用 `ChatStreamAsync`,逐条拿 `ChatResponseUpdate`: ```csharp await foreach (var update in ai.ChatStreamAsync(messages, options, ct)) { Console.Write(update.Text); } ``` ::: tip 一个适配器覆盖多数厂商 `OpenAiCompatibleChatClientFactory` 只用 OpenAI 兼容协议建客户端。云端服务、本地推理服务、自训模型只要提供 OpenAI 兼容端点,都是「`BaseUrl` + `Model` + `ApiKey`」三个字段的事,不需要为每家写适配器。`ApiKey` 留空时会用占位串 `no-key`,方便本地端点不校验密钥的场景。 ::: ::: warning 采样参数不在 provider 配置里生效 `AiProviderOptions` 上的 `MaxOutputTokens`、`Temperature`、`TimeoutSeconds`、`ExtraJson` **当前没有任何实现读取**——工厂建客户端时只用 `Model` / `ApiKey` / `BaseUrl`。要控制温度、最大输出、工具集,请走 `XiHanChatOptions.ChatOptions`(原生 `ChatOptions`),它会被门面透传下去。 ::: ## 配置源怎么选 `IAiProviderConfigStore` 是 provider 配置的唯一入口,框架给了两种用法: | 方案 | 实现 | 适用 | | --- | --- | --- | | Options 兜底(默认) | `OptionsAiProviderConfigStore` 读 `IOptionsMonitor` | 单部署、密钥由运维管、不需要租户各配各的 | | 应用 store 化 | 应用自实现,`AddSingleton()` 覆盖 | 密钥落库并加密、后台页面维护、按租户给不同 provider | 覆盖是透明的:解析器、Agent 工厂、RAG 都只认这个接口,换源不改任何调用方。默认实现里键即 provider 名——`AiProviderOptions.Provider` 没显式填时会被回填成字典键。 ## 热切换:改了配置必须 Invalidate `AiChatClientResolver` 和 `AiEmbeddingGeneratorResolver` 都按 provider 名把构建好的客户端缓存在 `ConcurrentDictionary` 里,首次 `Resolve` 才读配置源。**缓存不会感知外部变更**,所以 store 化之后,写完配置必须主动使缓存失效: ```csharp public sealed class ProviderAdmin( IAiChatClientResolver chatResolver, IAiEmbeddingGeneratorResolver embeddingResolver) { public void OnProviderUpdated(string providerName) { chatResolver.Invalidate(providerName); embeddingResolver.Invalidate(providerName); } } ``` `Invalidate` 的语义: - 传 provider 名 → 移除该名下的客户端,**同时移除默认槽**(默认 provider 可能正指向它); - 传 `null` 或空 → 清空全部; - 被移除的客户端会被 `Dispose`,下次 `Resolve` 用最新配置重建。 ::: warning 两个解析器要分别失效 会话客户端与嵌入生成器是两份独立缓存。只调会话侧的 `Invalidate`,RAG 仍会用旧密钥/旧端点。 ::: ## 会话管道:三个开关默认全关 `OpenAiCompatibleChatClientFactory` 建客户端时按 `XiHan:AI:Pipeline` 逐项叠加中间件。注册顺序即由外到内: | 层 | 开关 | 行为 | | --- | --- | --- | | 内容护栏 | `EnableGuardrail` | 最外层,先见原始输入;拦截即不下发内层 | | 遥测 | `EnableTelemetry` | `UseOpenTelemetry`,源名取 `TelemetrySourceName`(默认 `XiHan.AI`) | | 响应缓存 | `EnableResponseCache` | `UseDistributedCache`,需 DI 里有 `IDistributedCache` | | 工具调用 | 无开关,**恒定挂载** | `UseFunctionInvocation`,最内层 | `Pipeline` 是全局配置,不是按 provider 配的;三个开关默认 `false`。 ::: danger 工具会自动执行,没有人工批准环节 `UseFunctionInvocation` 恒挂在最内层,Agent 侧的 `ChatClientAgent` 内部也自带函数调用中间件。模型决定调工具就直接调,没有确认步骤。只读型技能(检索、查询)无所谓;一旦技能有副作用(写库、发消息、调外部接口),批准与审计必须由技能自己实现。 ::: ::: warning 缓存开在工具调用之外 `EnableResponseCache` 位于 `UseFunctionInvocation` 外层,命中缓存会连工具调用一起跳过,相同输入恒返回同一答案。创造性/高温场景不要开。 ::: 遥测开关只负责挂中间件,未接 `TracerProvider` / `MeterProvider` 与导出器时它是静默空操作,看不到任何数据。`EnableSensitiveTelemetry` 控制是否把 prompt 与响应原文记进遥测,默认关。 ## 内容护栏 护栏是 `IAiGuardrail` 列表,`GuardrailChatClient`(一个 `DelegatingChatClient`)逐个跑 `InspectInputAsync`: - 任一护栏返回拦截 → 直接回 `RefusalMessage`,不下发内层; - 护栏自身抛异常 → 同样按拦截处理(fail-closed,避免护栏故障时误放行); - 流式与非流式行为一致,拦截时流里只吐一条拒绝话术。 默认实现 `KeywordBlocklistGuardrail` 是薄自包含实现:`BlockedKeywords` 做大小写不敏感子串匹配,`InjectionPatterns` 加上内置的中英注入启发式正则做匹配,**只检查 `ChatRole.User` 消息**。它定位「第一道防线」,混淆、多语言改写、编码绕过都能穿过去。 追加护栏用 `AddSingleton()`——默认护栏是 `TryAddEnumerable` 注册的,不会被顶掉,多个护栏全部放行才算通过。要接真正的内容安全检测服务,实现这个接口挂进去即可,管道不用动。 ::: warning 只做输入侧 当前护栏不检查模型输出,没有输出脱敏。输出侧的合规处理需要自己在调用方做。 ::: ## 技能注册表 「技能」是应用供给的一个命名能力单元,接口只有三个成员: ```csharp public sealed class SearchDocSkill : IAiSkill { public string Name => "search_doc"; public string Description => "在项目文档中检索关键词,返回最相关的片段。"; public AIFunction AsFunction() => AIFunctionFactory.Create( (string keyword) => Search(keyword), name: Name, description: Description); } ``` ```csharp services.AddSingleton(); ``` `DefaultAiSkillRegistry` 构造时把 DI 里所有 `IAiSkill` 一次性收纳进来(线程安全、按名索引、同名覆盖),也支持运行期 `Register` 追加。注册表的价值在于**一次登记、两条交付通道**: | 通道 | 怎么用 | | --- | --- | | 对话工具 | `skill.AsFunction()` 放进 `ChatOptions.Tools` 或 Agent 的 `tools` | | MCP tool | 由 `SkillMcpToolsConfigurator` 自动投影,外部 AI 客户端可见 | ::: tip 框架不内置任何技能 框架只提供接口与注册表,一个内置技能都没有。不注册技能时注册表是空的——MCP 端点即使开了也是零工具。 ::: ## Agent 门面 `IXiHanAgentFactory` 是对 Agent 框架的薄封装,只加「按 provider 名选模型」这一层,返回的是原生 `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: "你是项目助理,回答前先用工具查证。", name: "assistant", tools: tools, providerName: null); var response = await agent.RunAsync(task, cancellationToken: ct); return response.Text; } } ``` 多轮对话与记忆走原生会话对象,框架不再包一层: ```csharp var session = await agent.CreateSessionAsync(ct); var first = await agent.RunAsync("帮我看下这个模块", session, cancellationToken: ct); var second = await agent.RunAsync("那第二点怎么改?", session, cancellationToken: ct); ``` 流式用 `RunStreamingAsync`。工厂本身不缓存 Agent——每次 `Create` 都新建一个,底下的 `IChatClient` 才是被解析器缓存的那个。 ## MCP Server:暴露与守门 `XiHanWebMcpModule` 让外部 AI 客户端通过 MCP 协议调用你的技能。它由 `XiHan:AI:Mcp` 节门控,**默认不暴露**: ```json { "XiHan": { "AI": { "Mcp": { "Enabled": true, "ApiKey": "替换为足够长的随机密钥", "Path": "/mcp", "HeaderName": "X-Api-Key", "Stateless": true } } } } ``` 判定集中在 `XiHanMcpOptions.IsExposable`(`Enabled` 且 `ApiKey` 非空),并且在两处都生效: | 阶段 | 未就绪时的行为 | | --- | --- | | `AddXiHanWebMcp(configuration)` | 只绑定选项,**不注册** MCP Server 与 HTTP 传输 | | `MapXiHanMcp(options)` | 直接返回,**不映射任何端点** | 端点映射后做了两步:`AllowAnonymous()` 绕开框架的全局鉴权兜底策略,再挂 `McpApiKeyEndpointFilter` 用应用管理的 key 守门。过滤器先读 `HeaderName` 指定的请求头,为空则回退读 `Authorization: Bearer`,比较用定长比较防时序侧信道,不匹配直接 401。 ::: danger key 是平台级凭据,不是用户身份 它只表示「这个 MCP 客户端可以访问本应用」。经 MCP 进来的调用**没有用户上下文,也没有租户上下文**——技能内部不要假设 `ICurrentUser` / `ICurrentTenant` 有值;涉及租户数据的技能必须自己决定取哪个租户的数据,或干脆拒绝在无上下文时执行。 ::: ::: warning 部署侧两件事 `/mcp` 是 SSE 长连接:走反向代理要关闭响应缓冲并放宽超时。若启用了开放接口签名校验之类的前置中间件,把 `/mcp` 加进它的忽略路径。 ::: ## RAG `AddXiHanRAG()` 注册四件默认实现,链路是固定的: | 环节 | 接口 | 默认实现 | | --- | --- | --- | | 切片 | `IChunkingStrategy` | `FixedWindowChunkingStrategy`(固定字符窗口 + 重叠,唯一策略) | | 摄取 | `IKnowledgeIngestor` | 切片 → 批量嵌入 → upsert 向量库 | | 检索 | `IKnowledgeRetriever` | query 嵌入 → 向量检索 → 映射 `RetrievedChunk` | | 提示增强 | `IRagPromptAugmenter` | 约束语 + 编号片段 + 问题的纯字符串拼接 | ```csharp public sealed class KnowledgeSample( IKnowledgeIngestor ingestor, IKnowledgeRetriever retriever, IRagPromptAugmenter augmenter, IXiHanAiService ai) { public Task ImportAsync(string docId, string text, CancellationToken ct) => ingestor.IngestAsync(new KnowledgeIngestRequest { DocumentId = docId, Text = text, Title = "产品手册", Source = "manual.md", TenantId = 0, Chunking = new ChunkingOptions { MaxChunkSize = 800, Overlap = 80 } }, ct); public async Task AskAsync(string question, CancellationToken ct) { var chunks = await retriever.RetrieveAsync( question, topK: 5, filter: new RetrievalFilter { TenantId = 0 }, cancellationToken: ct); var prompt = augmenter.Augment(question, chunks); var response = await ai.ChatAsync([new ChatMessage(ChatRole.User, prompt)], null, ct); return response.Text; } } ``` ::: danger 向量库必须由应用自己注册 `AddXiHanRAG()` **不注册任何 `VectorStore`**——选哪个向量库属于部署决策,框架只依赖 `Microsoft.Extensions.VectorData` 的抽象。应用侧不登记具体连接器,解析 `IKnowledgeIngestor` / `IKnowledgeRetriever` 时就会因缺少 `VectorStore` 依赖而失败。 ::: 几个必须知道的约定: - **维度必须与嵌入模型一致**。`KnowledgeVectorOptions` 默认集合名 `default_knowledge`、维度 `1536`。维度对不上时摄取与检索都会在 upsert / 向量检索**之前**抛出明确异常(`EnsureDimensions`),而不是丢一个驱动层报错。换模型要同时改维度并更换集合名或删除原集合重建。 - **这份 Options 不绑配置节**。`AddXiHanRAG(Action)` 只接受代码配置;要从 appsettings 读,应用侧自行 `services.Configure(configuration.GetSection("..."))`。 - **切片主键是确定性的**。由 `documentId:index` 派生,重复摄取同一文档即覆盖,天然幂等。 - **删除要传原切片数**。`RemoveDocumentAsync(documentId, chunkCount)` 靠枚举 `0..chunkCount-1` 生成键来删,`chunkCount` 传小了会留孤儿向量——摄取返回的切片数要存下来。 - **过滤只作用于两个索引字段**。`RetrievalFilter` 只有 `TenantId` 与 `DocumentId`,它们在集合定义里标了索引,作为 pre-filter 下推到向量库。租户谓词恒存在:检索只在一个租户内进行(平台为 0,`filter` 为空即平台),不存在不限租户的检索。 - **嵌入模型单独配**。取 `AiProviderOptions.EmbeddingModel`,与会话共用同一端点和密钥;该字段为空时解析嵌入生成器会抛异常。 ::: tip 故障可读 向量库连不上会被翻译成 `ServiceUnavailableException`(503),嵌入端点的 401/404/429/5xx 也被翻译成带 provider 名与模型名的可操作消息。请求本身的问题(如 400 内容超长、维度不符)原样抛出——那类失败重试没用,报成 503 反而掩盖真实缺陷。 ::: ## 配置速查 | 配置节 | Options 类 | 管什么 | | --- | --- | --- | | `XiHan:AI` | `XiHanAiOptions` | `DefaultProvider`、`Providers`、`Pipeline`、`Prompts` | | `XiHan:AI:Pipeline` | `AiPipelineOptions` | 护栏/遥测/缓存三开关,默认全 `false` | | `XiHan:AI:Guardrail` | `AiGuardrailOptions` | 敏感词、注入正则、内置启发式开关、拒绝话术 | | `XiHan:AI:Mcp` | `XiHanMcpOptions` | MCP 端点开关、密钥、请求头名、路径、无状态传输 | 提示词模板走 `IAiPromptStore`:默认实现 `OptionsAiPromptStore` 读 `XiHan:AI:Prompts`,按 `Name` + 可选 `Version` 查;应用层可覆盖成落库版本,对上层同样透明。 全部字段的类型与默认值见 [AI 包](../packages/ai) 与 [Web.Mcp 包](../packages/web-mcp)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 抛「未找到 AI Provider 配置」 | `DefaultProvider` 没配,或传的 provider 名不在 `Providers` 字典里 | | 改了数据库里的密钥仍用旧的 | 没调 `Invalidate`,解析器缓存不感知外部变更 | | 换了密钥后会话正常但 RAG 报鉴权失败 | 只失效了会话解析器,嵌入解析器还是旧客户端 | | 配了 `Temperature` / `MaxOutputTokens` 不起作用 | 这些字段当前无实现读取,改用 `XiHanChatOptions.ChatOptions` | | 护栏没拦住任何东西 | `Pipeline.EnableGuardrail` 默认关;或内容不在 `ChatRole.User` 消息里 | | 开了遥测但没有任何数据 | 未接 `TracerProvider` / `MeterProvider` 与导出器,中间件是空操作 | | 相同问题总是同一个答案 | 开了 `EnableResponseCache`,命中缓存连工具调用一起跳过 | | `/mcp` 返回 404 | `Enabled` 为 false 或 `ApiKey` 为空,端点根本没被映射 | | `/mcp` 返回 401 | 请求头名与 `HeaderName` 不一致,或没走 `Authorization: Bearer` | | MCP 连上了但看不到工具 | 没有任何 `IAiSkill` 注册,框架不内置技能 | | 解析 `IKnowledgeIngestor` 失败 | 应用没注册 `VectorStore`,框架不提供具体连接器 | | 摄取时报维度不匹配 | 嵌入模型输出维度与 `KnowledgeVectorOptions.Dimensions` 不一致 | | 删了文档但还能检索到 | `RemoveDocumentAsync` 的 `chunkCount` 小于实际切片数 | | 向量库故障返回 503 | 连接类故障被统一翻译,原始异常在内部异常里 | ## 下一步 - [配置与选项](./configuration):Options 绑定与覆盖顺序 - [依赖注入](./dependency-injection):`TryAdd` 覆盖默认实现的时机 - [Web 应用开发](./web):端点鉴权与全局兜底策略 - [缓存与分布式锁](./caching):响应缓存依赖的 `IDistributedCache` - [AI 包](../packages/ai):完整 API 与全部配置项 - [AI.Abstractions 包](../packages/ai-abstractions):接口与契约 - [Web.Mcp 包](../packages/web-mcp):MCP 端点与鉴权细节 --- 来源:https://framework.docs.xihanfun.com/guide/aop # AOP 与拦截器 `[UnitOfWork]`、`[Cacheable]`、审计留痕这些横切能力,底层都是同一套机制:**Castle 动态代理 + `IXiHanInterceptor`**。理解它能解释一大类「特性标了没生效」的问题,也是写自己横切逻辑的入口。 ## 一条必须先记住的限制 ::: danger 拦截只对「以接口为服务类型」的注册生效 `AddCastleDynamicProxy` 只处理 `ServiceType.IsInterface` 为真的服务描述器(用的是 `CreateInterfaceProxyWithTarget`)。 ```csharp services.AddScoped(); // ❌ 注册为自身类型 → 不会被代理 services.AddScoped(); // ✅ 接口 → 会被代理 ``` 把类注册为它自己,自定义拦截器**全部静默失效**——不报错、不记日志。要用 AOP 就走「接口 + 实现」。 ::: 其次检查:类型有没有被列进 `DynamicProxyIgnoreTypes`。 ::: tip HTTP 入口不受这条限制 控制器由 MVC 自行激活、动态 API 控制器又直接注入应用服务的具体类,两者都不走代理。框架内置的两个横切能力另有 MVC 过滤器覆盖这条路径:`XiHanUnitOfWorkFilter` 处理 `[UnitOfWork]`、`XiHanCacheFilter` 处理 `[Cacheable]` / `[CacheEvict]`,都经动作上的 `OriginalMethodAttribute` 回查应用服务的原始方法读取特性。 所以这两个特性在 HTTP 请求上照常生效;**自定义拦截器没有这层兜底**,只能靠接口代理。 ::: ## 写一个自己的拦截器 ### 1. 拦截器 ```csharp using XiHan.Framework.Core.DynamicProxy; public class BillingAuditInterceptor(ILogger logger) : IXiHanInterceptor { public async Task InterceptAsync(IXiHanMethodInvocation invocation) { var sw = Stopwatch.StartNew(); await invocation.ProceedAsync(); // ← 必须调用,否则目标方法不会执行 logger.LogInformation("{Method} 耗时 {Ms}ms", invocation.Method.Name, sw.ElapsedMilliseconds); } } ``` `IXiHanMethodInvocation` 能拿到什么: | 成员 | 说明 | | --- | --- | | `Method` | `MethodInfo`,可读特性 | | `Arguments` / `ArgumentsDictionary` | 入参 | | `GenericArguments` | 泛型实参 | | `TargetObject` | 真实目标实例 | | `ReturnValue` | 返回值,**可读可写** | | `ProceedAsync()` | 调用下一环 | ### 2. 注册器:决定拦谁 ```csharp public static class BillingAuditInterceptorRegistrar { public static void RegisterIfNeeded(IOnServiceRegistredContext context) { if (!DynamicProxyIgnoreTypes.Contains(context.ImplementationType) && context.ImplementationType.IsDefined(typeof(BillingAuditAttribute), inherit: true)) { context.Interceptors.TryAdd(); } } } ``` ### 3. 挂上 ```csharp public override void PreConfigureServices(ServiceConfigurationContext context) { context.Services.OnRegistered(BillingAuditInterceptorRegistrar.RegisterIfNeeded); } ``` `OnRegistered` 登记的回调会在每个服务描述器上跑一遍;只要往 `context.Interceptors` 塞了拦截器,该服务就会被包成代理。框架自己的 `XiHanUowModule`、`XiHanCachingModule` 用的就是这个 API。 ::: tip 挂在 `PreConfigureServices` 注册器要在其他模块注册服务**之前**登记好,否则先注册的服务不会被扫到。 ::: ## 拿到未被代理的真实实例 某些场景下代理会坏事——最典型的是**匿名端点**: ::: danger 匿名端点调被代理的服务会永久挂起 匿名端点(Minimal API、OAuth 回调等)不经过工作单元中间件,而服务被代理包着,UoW 拦截器会急切开事务从而死锁。 解法是绕开代理: - 注入真正的依赖直连,或 - 用 `ProxyHelper.UnProxy(service)` 取真实目标实例再调。 框架的 OAuth 回调端点就是这么处理的。 ::: ## 执行顺序 多个拦截器按登记顺序串成管道: ```text 拦截器 A 前段 → 拦截器 B 前段 → 目标方法 → B 后段 → A 后段 ``` 先登记的在外层。要控制顺序就控制 `OnRegistered` 的调用顺序(即模块的拓扑序)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 特性标了没生效 | 服务注册类型不是接口(最常见);或类型在 `DynamicProxyIgnoreTypes` 里。`[UnitOfWork]` / `[Cacheable]` 走 HTTP 时不受此限,见上方提示 | | 目标方法没执行 | 拦截器忘了调 `ProceedAsync()` | | 匿名端点调用挂起 | 走了代理,用 `ProxyHelper.UnProxy` | | 自定义拦截器不生效 | `OnRegistered` 挂在了 `ConfigureServices`(太晚),改到 `PreConfigureServices` | ## 下一步 - [依赖注入](./dependency-injection):服务注册与暴露类型 - [工作单元与事务](./uow):最重要的一个拦截器 - [Castle 包](../packages/castle):动态代理集成细节 --- 来源:https://framework.docs.xihanfun.com/guide/auditing # 审计 审计日志回答的是「谁、在什么时候、对什么做了什么」——请求访问、业务操作、异常、开放接口调用、登录、实体字段级变更。框架负责**采集、异步化、脱敏**,但不决定存到哪张表:落库由你实现的写入器完成。 本章讲这条链路怎么接通、哪些环节是自动的、哪些必须你自己写。完整 API 清单与配置全表见 [Auditing 包文档](../packages/auditing)。 ## 审计日志不是运行日志 两者经常被混为一谈,但在框架里是完全独立的两套东西: | | 运行日志(`ILogger`) | 审计日志 | | --- | --- | --- | | 目的 | 排障、观察 | 追责、合规、业务回溯 | | 去处 | 控制台 / 文件 / 日志系统 | 业务库表(由写入器决定) | | 触发 | 代码显式写 | 中间件 / 过滤器 / ORM AOP 自动采集 | | 内容 | 自由文本 + 结构化字段 | 固定记录模型(6 类) | | 脱敏 | 无 | 采集点强制脱敏 | 「请求开始 / 请求结束」这类 `ILogger` 输出属于运行日志,和审计日志表是两条互不相干的路径。 ## 六类记录与它们的采集点 | 记录模型 | 谁采集 | 触发条件 | | --- | --- | --- | | `AccessLogRecord` | `XiHanRequestLoggingMiddleware`(Web.Api) | 每个未命中 `IgnoredPathPrefixes` 的请求 | | `OperationLogRecord` | `XiHanActionLoggingFilter`(全局 MVC 过滤器) | Action 执行完成,且请求方法不是 GET / HEAD / OPTIONS | | `ExceptionLogRecord` | `ExceptionLogReporter`(由 `XiHanApiResponseResultFilter` 与 `XiHanExceptionLoggingMiddleware` 调用) | 未处理异常 | | `ApiLogRecord` | `XiHanApiLoggingMiddleware` | **仅当**请求携带开放接口安全头(AccessKey / Signature) | | `LoginLogRecord` | 无 | 由应用自己调 `ILoginLogPipeline` | | `EntityDiffLogRecord` | `SqlSugarDiffLogAop`(Data 包) | 仓储写操作,且 `EnableDiffLog = true` | ::: warning 登录日志没有自动采集方 框架只提供 `LoginLogRecord` 与 `ILoginLogPipeline`,没有任何中间件会去填它。登录日志需要应用在登录成功 / 失败的分支里自己写: ```csharp await loginLogPipeline.WriteAsync(new LoginLogRecord { UserId = user.Id, UserName = user.UserName, LoginResult = 0, // 语义由应用定义:0 = 成功 Message = "登录成功", LoginIp = ip, LoginTime = DateTimeOffset.UtcNow }); ``` ::: ## 采集链路 ```text 中间件 / 过滤器 / ORM AOP └→ IXxxLogPipeline.WriteAsync(record) (Scoped) ├ EnableXxxLogQueue = false(默认) │ └→ 直接 await IXxxLogWriter.WriteAsync(record) ← 同步落库,计入请求耗时 └ EnableXxxLogQueue = true └→ ILogQueue(Singleton,有界 Channel) └→ XxxLogQueueWorker(HostedService)攒批 └→ 新建 DI 作用域 → 逐条 IXxxLogWriter.WriteAsync ``` 三段职责很清楚: - **管道**决定「同步写还是入队」; - **队列**只提供两种语义明确的入队方式(`TryEnqueue` 满时返回 `false`,`EnqueueAsync` 满时等待),丢不丢由管道按 `DropOnFull` 选; - **消费者**攒批(`BatchSize` 条或距上次 flush 超过 `BatchDelayMilliseconds` 即 flush),在新作用域里**逐条**调用写入器,写入异常只记 `LogWarning`、不重试。 ::: warning 队列默认全部关闭 5 个 `EnableXxxLogQueue` 默认都是 `false`。此时管道**同步 await 写入器**——写库耗时直接计入接口响应时间,且传的是 `CancellationToken.None`(客户端断连也会把这条日志写完)。生产环境建议逐类打开队列。 ::: ## 安装与启用 用 [Web.Api](../packages/web-api) 或 [Data](../packages/data) 时,`XiHanAuditingModule` 已作为传递依赖引入,无需单独安装。只在非 Web、非数据场景单独用时才需要显式依赖: ```csharp [DependsOn(typeof(XiHanAuditingModule))] public class MyModule : XiHanModule; ``` 模块的 `ConfigureServices` 调 `services.AddXiHanAuditing(config)`,一次性装好:配置绑定(`XiHan:Auditing:LogQueue`)、开放泛型队列 `ILogQueue<>` → `ChannelLogQueue<>`(单例)、5 个后台消费者、5 个采集管道,以及**默认为空实现**的写入器契约。 ## 让日志真正落库 ::: danger 不实现写入器 = 日志静默丢弃 5 个 `IXxxLogWriter` 与 `IEntityDiffLogWriter` 的默认实现全是空实现(`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.AddAsync(new SysOperationLog { TraceId = record.TraceId, UserId = record.UserId, UserName = record.UserName, ControllerName = record.ControllerName, ActionName = record.ActionName, Method = record.Method, Path = record.Path, RequestParams = record.RequestParams, StatusCode = record.StatusCode, ElapsedMilliseconds = record.ElapsedMilliseconds }, cancellationToken); } } ``` 两种注册方式都行: | 方式 | 写法 | 说明 | | --- | --- | --- | | 约定注册 | 实现类同时标记 `IScopedDependency` | 框架的空实现先注册,你的实现后注册,解析时后者胜出 | | 显式替换 | `context.Services.Replace(ServiceDescriptor.Scoped())` | 容器里只留一条,意图最明确 | 也可以在实现类上加 `[Dependency(ReplaceServices = true)]`,由约定注册器走 `Replace`。 ## 实体变更审计 这是唯一不经 HTTP 层的一类,走的是 SqlSugar 原生 `Aop.OnDiffLogEvent`。要产生记录,**三个条件缺一不可**: 1. 配置 `XiHan:Data:SqlSugarCore:EnableDiffLog = true`(默认 `false`,不开则 AOP 根本不挂); 2. 应用实现并注册 `IEntityDiffLogWriter`; 3. 写操作走框架仓储——仓储的 Insert / Update / Delete 已经内建 `.EnableDiffLogEvent(typeof(TEntity))`,业务代码不用改。 裸 SQL、直接用 `ISqlSugarClient` 绕过仓储的写入**不会**产生变更记录。 ### AOP 产出什么 | 方面 | 行为 | | --- | --- | | 记录粒度 | **每个受影响的行一条** `EntityDiffLogRecord`,按主键值配对前后镜像(不按下标) | | `OperationType` | `Create` / `Delete` / `Update`;更新时若 `Is_Deleted` 由 false 翻 true 记为 `Delete`、由 true 翻 false 记为 `Restore` | | `ChangedFields` | `{Field, Before, After}` 数组;前后镜像都在且无字段变更的行被跳过 | | 快照裁剪 | 单列值超 1000 字符先截断;整条快照 JSON 超 8000 字符时产出**合法的**截断标记对象,不会从中间切断出非法 JSON | | 失败处理 | AOP 内部整体 try/catch,审计失败只记错误日志,绝不影响主业务 | ### 写入器的事务契约 ```csharp public class EntityDiffLogWriter : IEntityDiffLogWriter, IScopedDependency { private readonly ISqlSugarClientResolver _clientResolver; public EntityDiffLogWriter(ISqlSugarClientResolver clientResolver) { _clientResolver = clientResolver; } public async Task WriteAsync(EntityDiffLogRecord record, CancellationToken cancellationToken = default) { // 必须用当前工作单元的连接,与业务写在同一事务里 await _clientResolver.GetCurrentClient() .Insertable(new SysDiffLog { OperationType = record.OperationType, EntityType = record.EntityType, EntityId = record.EntityId, BeforeData = record.BeforeData, AfterData = record.AfterData, ChangedFields = record.ChangedFields, UserId = record.UserId, TenantId = record.TenantId }) .ExecuteCommandAsync(cancellationToken); } } ``` ::: danger 不要在差异日志写入器里另开连接 必须经 `ISqlSugarClientResolver.GetCurrentClient()` 使用当前工作单元连接:业务回滚时审计行随之回滚。改用 `CopyNew()` 或独立连接,会在业务回滚后留下「从未生效的变更」的幽灵记录。 ::: 同时,写入器要走**裸 `Insertable`、不挂 `EnableDiffLogEvent`**,否则写审计的 SQL 会再次触发 Diff 事件。`IEntityAuditContextProvider.ShouldAudit` 的默认排除名单(`XiHan.Framework.Auditing` 命名空间、类型全名含 `AuditLog` / `DiffLog`)是第二道保险。 ### 自定义审计上下文 `IEntityAuditContextProvider` 决定「审计哪些实体」和「记录哪些上下文字段」。默认实现 `DefaultEntityAuditContextProvider` 只从 `ICurrentUser` / `ICurrentTenant` 填 `UserId` / `UserName` / `TenantId`。要补 HTTP 上下文就自己实现: ```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; } ``` 覆盖 `ShouldAudit` 时**务必把审计表自己排除掉**,否则写审计的写入器一旦走了仓储,就会自我触发。 ## 审计与 AOP 的关系 「审计」在框架里用了三种完全不同的机制,别混着理解: | 机制 | 用在哪 | 是不是 Castle 拦截器 | | --- | --- | --- | | 中间件 + MVC 过滤器 | 访问 / 操作 / 异常 / 接口日志 | 否,是 ASP.NET Core 管线 | | SqlSugar `Aop.OnDiffLogEvent` | 实体变更日志 | 否,是 ORM 原生事件 | | SqlSugar `DataExecuting` | 审计**字段**(`CreatedTime` / `ModifiedTime` / `TenantId`)自动赋值 | 否,同样是 ORM 事件 | 也就是说,审计链路**不依赖动态代理**,不需要接口虚方法、不受代理失效影响。第三项虽然也叫「审计字段」,但它只是在写库时补列值,和审计日志表毫无关系,详见 [数据访问](./data)。 ::: warning 写路径别叠 `.EnableQueryFilter()` 框架仓储的 `Updateable` / `Deleteable` 已经自动挂了一次全局过滤器。再显式挂一次会把同一份过滤烘进 WHERE 两遍、生成同名参数,叠上 Diff 的重查即崩。 ::: ## 脱敏 `LogSanitizer` 是静态类,也是全框架敏感判定的唯一入口。判定分两步:**名字归一化后命中敏感词**,且**不以元数据后缀结尾**。 - 归一化 = 只保留字母数字并转小写,于是 `X-Api-Key`、`Connection_String`、`apiKey` 都能命中同一份规则; - 命中即**整体掩码**为 `***`(`LogSanitizer.Mask`),不保留片段——`Authorization: Bearer …` 这类值留一半也等同泄露; - 元数据后缀豁免:`Token_Type`、`Last_Password_Change_Time`、`Max_Output_Tokens` 这种「关于秘密的元数据」**不掩**,掩掉它们等于把审计价值一起掩掉; - 值层面另外识别 15 / 18 位身份证号,按「保留首尾各 3 位、掩中段」处理。 | 方法 | 用途 | | --- | --- | | `MaskSensitiveData(string?)` | JSON / 表单文本,正则掩敏感键的字面量值(字符串 / 数字 / 布尔 / null),嵌套对象与数组掩不掉 | | `MaskQueryString(string?)` | URL 查询串 | | `MaskJsonFields(string?)` | 解析 JSON 对象逐键掩码,敏感键的值是嵌套对象 / 数组时也能整体掩掉;根不是对象或解析失败时回落到正则 | | `MaskHeaders(...)` | 请求头,头名敏感则整体掩,其余头的值再走一遍通用脱敏 | | `MaskFieldValue(name, value)` | 字段级:名字敏感返回掩码,否则原值 | | `IsSensitiveName(string?)` | 只做判定,供自定义脱敏逻辑复用 | 框架已经在这些位置内建了脱敏,你不必重复处理: | 位置 | 处理 | | --- | --- | | 请求体 / 查询串 | 在 `XiHanRequestLoggingMiddleware` 捕获点即脱敏并存入 `HttpContext.Items`,访问 / 接口 / 异常日志复用同一份副本 | | 响应体 | `XiHanApiLoggingMiddleware` 落库前脱敏(仅 JSON / XML / text 类型转文本,有界捕获 4096 字节) | | 操作日志参数与结果 | `XiHanActionLoggingFilter` 序列化后脱敏 | | 异常日志请求头 | `ExceptionLogReporter` 走 `MaskHeaders` | | 实体快照列值 | `SqlSugarDiffLogAop` 按列名走 `MaskFieldValue` | 自己组装记录(比如登录日志)时,需要自己调用相应方法。 ::: tip 变更判定用原值,掩码在之后 差异日志比较字段是否变更时用的是**原值**,掩码发生在写进 `ChangedFields` 之后。否则改密码前后都成 `***`,会被判定为「未变更」而完全不留痕迹。 ::: ## 与多租户的配合 6 类记录都自带 `TenantId`(`null` 即平台)。`EntityDiffLogRecord` 由 `DefaultEntityAuditContextProvider` 取 `ICurrentUser.TenantId ?? ICurrentTenant?.Id` 填充;访问、操作、异常、API 四类日志由 Web.Api 的采集点取请求所属租户——租户解析完成后定型写回 `RequestContext` 的那个值,已认证请求即令牌里的租户;登录日志由产生它的应用在采集时填。写入器按 record 上的 `TenantId` 落戳,不要再读环境上下文,原因见下面这条。 ::: danger 队列模式下写入器读不到当前用户 / 租户 后台消费者在自己的线程上用 `IServiceScopeFactory.CreateScope()` 新建作用域:没有 `HttpContext`,AsyncLocal 的租户上下文也不流到那里。写入器里读 `ICurrentUser` / `ICurrentTenant` 会得到空值。 凡是写入器需要的身份信息,必须在**采集点**放进 record。 ::: 两种模式行为不同,别依赖「碰巧能读到」: | 模式 | 写入器执行位置 | 能否读到用户 / 租户上下文 | | --- | --- | --- | | 队列关闭(默认) | 请求作用域内同步执行 | 能 | | 队列开启 | 后台线程新作用域 | 不能 | | 实体差异日志 AOP | 请求线程内同步执行 | 能 | 审计表本身按你自己的实体定义走既有多租户规则:平台只看平台自己的审计,跨租户查审计要显式清租户过滤(`ClearTenantFilter()`),见 [多租户](./multi-tenancy)。 ## 配置 审计的行为由两个配置节共同决定: | 配置键 | 默认 | 说明 | | --- | --- | --- | | `XiHan:Auditing:LogQueue:IgnoredPathPrefixes` | `["/hubs"]` | 完全不记请求日志的路径前缀(不区分大小写) | | `XiHan:Auditing:LogQueue:EnableAccessLogQueue` 等 5 项 | `false` | 各类日志是否走异步队列;关闭 = 同步写 | | `XiHan:Auditing:LogQueue:QueueCapacity` | `10000` | 有界 Channel 容量 | | `XiHan:Auditing:LogQueue:DropOnFull` | `false` | `false` = 队列满时等待(反压请求线程);`true` = 丢弃并记一条 `LogWarning` | | `XiHan:Auditing:LogQueue:BatchSize` | `100` | 攒批条数阈值 | | `XiHan:Auditing:LogQueue:BatchDelayMilliseconds` | `200` | 攒批时间阈值 | | `XiHan:Data:SqlSugarCore:EnableDiffLog` | `false` | 实体变更日志总开关(属于 Data 包配置节) | ```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 } }, "Data": { "SqlSugarCore": { "EnableDiffLog": true } } } } ``` 完整字段表见 [Auditing 包文档](../packages/auditing)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 日志页面恒空 | 写入器没注册,默认空实现把记录丢掉了 | | 数据变更日志恒空 | `EnableDiffLog` 没开(默认 `false`),或 `IEntityDiffLogWriter` 没实现 | | 登录日志一条没有 | 框架不采集登录日志,要自己调 `ILoginLogPipeline` | | 接口日志一条没有 | 只在请求携带开放接口安全头时才采集 | | GET 请求没有操作日志 | 过滤器有意跳过 GET / HEAD / OPTIONS | | SignalR 请求没有访问日志 | `/hubs` 在 `IgnoredPathPrefixes` 默认值里 | | 接口响应变慢 | 队列默认关闭,写入器是同步执行的 | | 高峰期请求线程被拖住 | `DropOnFull = false` 时队列满会等待空位 | | 写入器里 `ICurrentUser` 为空 | 队列模式在后台新作用域执行,身份要在采集点写进 record | | 批量更新产生 N 条变更记录 | 每个受影响行一条,是预期行为 | | 更新了但没有变更记录 | 前后镜像都在且没有字段真正变化的行会被跳过 | | 不敏感的字段被掩成 `***` | 名字归一化后命中了敏感词子串 | | `Token_Type` 之类没被掩 | 元数据后缀豁免,故意保留审计价值 | | 写库失败但没人告诉我 | 消费者的写入异常只记 `LogWarning`,不重试 | | 绕过仓储的写没有变更记录 | 差异日志只覆盖走仓储的写操作 | ## 下一步 - [数据访问](./data):仓储写路径、审计字段自动赋值 - [工作单元与事务](./uow):差异日志与业务同事务的前提 - [多租户](./multi-tenancy):租户上下文与平台(0 号租户) - [Web 应用开发](./web):中间件与过滤器在管线里的位置 - [Auditing 包文档](../packages/auditing):完整 API 与配置表 - [Logging 包文档](../packages/logging):结构化运行日志(与审计日志是两回事) --- 来源:https://framework.docs.xihanfun.com/guide/authentication # 认证 认证解决「你是谁」。这一章讲令牌怎么签发与校验、密码怎么存、第三方登录与二次验证怎么接、会话失效怎么表达。 「你能做什么」在 [授权](./authorization)。 ## 在管道里的位置 ```text … → UseAuthentication → 租户解析 → 会话闸门 → UseAuthorization → 端点 ↑ 你是谁 ↑ 哪个租户 ↑ 会话还有效吗 ↑ 你能做什么 ``` 顺序有讲究:租户解析要读令牌里的租户 claim,所以排在认证之后;授权判定要在租户上下文里进行,所以排在租户解析之后。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Authentication ``` ```csharp [DependsOn(typeof(XiHanAuthenticationModule))] public class MyAppModule : XiHanModule { } ``` 包内分六块:`Jwt`(令牌)、`Users`(认证流程与用户存储)、`OAuth`(第三方登录)、`Oidc`(OIDC 签发)、`Otp`(TOTP)、`OneTimeCode`(一次性验证码)。 ## JWT 令牌 ### 配置 配置节 `XiHan:Authentication:Jwt`: | 键 | 默认 | 说明 | | --- | --- | --- | | `SecretKey` | — | 签名密钥。**生产务必改为高强度随机值**,走环境变量或密钥库 | | `Issuer` / `Audience` | — | 签发者 / 受众,校验时比对 | | `AccessTokenExpirationMinutes` | `60` | 访问令牌有效期 | | `RefreshTokenExpirationDays` | `7` | 刷新令牌有效期 | | `ClockSkewMinutes` | `5` | 允许的时钟偏差,多节点部署必需 | ### `IJwtTokenService` | 成员 | 说明 | | --- | --- | | `GenerateAccessToken(List)` | 签发访问令牌,返回 `JwtTokenResult` | | `GenerateRefreshToken()` | 生成刷新令牌(不透明随机串) | | `ValidateToken(string)` | 校验并返回 `ClaimsPrincipal`,失败返回 `null` | | `GetClaimsFromToken(string)` | 取出 Claim 列表 | | `IsTokenExpired(string)` | 是否已过期 | | `RefreshAccessToken(accessToken, refreshToken)` | 用旧访问令牌 + 刷新令牌换新的一套 | 刷新令牌的持久化走 `IRefreshTokenStore`。默认 `DefaultRefreshTokenStore` 是可直接使用的单实例实现:最多保留 100000 条,并在写入过程中批量清理过期项;达到上限后明确拒绝新增,不会继续挤占进程内存。框架不提供该端口的 Redis 实现,多实例共享或持久化由应用层实现 `IRefreshTokenStore` 后 `Replace` 默认注册。 ::: tip 别把权限码冻结进令牌 把权限清单写进 JWT 有两个后果:授予或回收后要等令牌过期才生效;权限清单随令牌一起泄露。 推荐只放**身份信息**(用户、会话、租户、角色),权限交给服务端实时判定——判定可以走缓存快照,性能不是问题。见 [授权](./authorization)。 ::: ::: warning 刷新端点必须匿名 访问令牌过期后再带着它去刷新,如果刷新端点本身要求认证就永远进不去。刷新端点应当 `[AllowAnonymous]`,并**同时校验旧访问令牌与刷新令牌**——两者成对才算合法。 ::: ## 密码 ### 哈希算法 配置节 `XiHan:Authentication:PasswordHasher`,算法是 **PBKDF2**: | 键 | 默认 | 说明 | | --- | --- | --- | | `Version` | `1` | 方案版本号,参数升级后老密码仍按旧版本校验 | | `Iterations` | `600000` | 迭代次数,OWASP 对 PBKDF2-SHA256 的推荐量级 | | `SaltSize` | `32` | 盐长度(字节) | | `HashSize` | `32` | 输出长度(字节) | | `HashAlgorithm` | `SHA256` | 哈希算法 | 存储格式是自描述串: ```text version:iterations:algorithm:base64(salt):base64(hash) ``` 自描述的好处是**参数可以随时调大而不影响存量密码**——每条哈希都记着自己是用什么参数算的。校验用定长比较抵抗时序攻击;参数变更后 `NeedsRehash` 会提示该密码需要在下次登录成功时透明重算。 ### 认证流程 `IAuthenticationService` 是认证主流程的门面: | 方法 | 用途 | | --- | --- | | `AuthenticateAsync(username, password)` | 主认证,返回 `AuthenticationResult` | | `ValidatePasswordStrengthAsync(password, customBlacklist?)` | 密码强度校验 | | `ChangePasswordAsync` / `ResetPasswordAsync` | 改密 / 重置 | | `EnableTwoFactorAuthenticationAsync` / `DisableTwoFactorAuthenticationAsync` | 开关二次验证 | | `VerifyTwoFactorCodeAsync` | 校验二次验证码 | | `GenerateRecoveryCodesAsync` / `VerifyRecoveryCodeAsync` | 恢复码 | | `RecordFailedLoginAttemptAsync` | 记录失败尝试(锁定与风控的输入) | 用户数据的读写走 `IUserStore`——框架只定义契约,**业务侧必须 `Replace` 成自己的实现**(通常是数据库)。 ## 一次性验证码 `IOneTimeCodeService` 用于「签发 → 一次性消费」的场景:邮箱/短信登录验证码、改绑联系方式验证码等。 四条设计特征: | 特征 | 说明 | | --- | --- | | 加密安全随机 | 用 `RandomNumberGenerator` 生成,不是 `Random` | | 存分布式缓存 | 状态放 `IDistributedCache`,宿主接了 Redis 就天然支持多实例 | | **消费即销毁** | **无论校验成功与否,读取后即删除**——杜绝重放与暴力穷举 | | 可携带负载 | 如改绑场景暂存待生效的新邮箱/新手机号,消费成功后取回 | ::: warning 同「用途 + 目标」重复签发会覆盖旧码 用途标识(如 `auth:email-login`)参与存储键隔离。对同一个邮箱重复请求验证码,后一次会把前一次覆盖掉——用户拿旧码来验会失败。前端要做发送频率限制。 ::: ## 第三方登录 配置节 `XiHan:Authentication:OAuth`: | 键 | 说明 | | --- | --- | | `Enabled` | 总开关 | | `FrontendCallbackUrl` | 第三方授权完成后跳回的前端页面 | | `Providers[]` | 各提供商配置 | 每个 provider 的字段: | 字段 | 说明 | | --- | --- | | `Name` | **AuthenticationScheme 名,参与回调路由(默认 `/signin-{Name}`),不要随意改** | | `Provider` | 提供商类型;留空时取 `Name`。同一家要同时开两种登录方式时,两条配置用不同的 `Name`、相同的 `Provider` | | `Mode` | `QrCode`(默认)/ `Account`,只对微信、企业微信、飞书、钉钉生效 | | `DisplayName` | 前端展示名 | | `Enabled` / `ClientId` / `ClientSecret` / `CallbackPath` | 同标准 OAuth2 | | `Scopes[]` | 申请的权限范围,在提供商默认值之外**追加**;微信与企业微信例外,两种登录方式的范围互斥,配置非空时整体替换 | | `AgentId` | 企业微信自建应用 AgentId | | `LoadMemberProfile` | 企业微信是否额外读通讯录补姓名 | | `CorpId` | 钉钉企业 CorpId,随授权请求带出;**要拿到用户选定的组织,还须在 `Scopes` 里加 `corpid`**,钉钉只在权限范围含它时才回传,框架据此写出 `urn:dingtalk:corpid` 声明 | | `AuthorizationEndpoint` | 逃生舱:直接指定授权页地址,覆盖按 `Provider` + `Mode` 的推导 | | `AuthorizationParameters` | 逃生舱:追加到授权地址上的任意参数,如 Google 的 `access_type` | 包内建八个:`google` / `github` / `gitee` / `qq` / `weixin` / `workweixin` / `feishu` / `dingtalk`(微信、企业微信、飞书分别可写成 `wechat` / `wecom` / `lark`)。绑定关系的读写走 `IExternalLoginStore`。 ### 账号授权与扫码登录 两种方式**只差授权页地址与申请的权限范围**,换令牌与拉用户信息的接口是同一套,所以不需要另建一条登录路径——同一家注册两个 scheme 就行: ```json "Providers": [ { "Name": "wechat-qr", "Provider": "wechat", "Mode": "QrCode", "ClientId": "开放平台网站应用 AppId", "ClientSecret": "..." }, { "Name": "wechat-mp", "Provider": "wechat", "Mode": "Account", "ClientId": "公众号 AppId", "ClientSecret": "..." } ] ``` 各家两种方式落到的授权页: | 提供商 | 扫码登录 | 账号授权 | | --- | --- | --- | | 微信 | 开放平台网站应用 `connect/qrconnect`,`scope=snsapi_login` | 公众号网页授权 `connect/oauth2/authorize`,`scope=snsapi_userinfo` | | 企业微信 | `login.work.weixin.qq.com/wwlogin/sso/login` | 应用内网页授权 `connect/oauth2/authorize`,`scope=snsapi_privateinfo` | | 钉钉 | `login.dingtalk.com/oauth2/challenge.htm` | `login.dingtalk.com/oauth2/auth` | | 飞书 | `passport.feishu.cn/suite/passport/oauth/authorize` | `accounts.feishu.cn/open-apis/authen/v1/authorize` | ::: warning 微信两种方式用的是两个应用 扫码登录属于**开放平台网站应用**,账号授权属于**公众号**,AppId / AppSecret 不通用,所以要写成两条配置。 ::: ::: warning 飞书两套端点不可交叉 扫码走 `passport.*`、账号授权走 `accounts/open-apis`,各自的授权、令牌、用户信息三个接口成套;一套拿到的授权码不能拿去另一套换令牌。框架按 `Mode` 整套切换,不必手工拼。 ::: ::: tip 账号授权的地址里 `state=_oauthstate` 不是 bug 微信公众号与企业微信应用内的网页授权页限制 `state` 长度,容不下受保护的认证属性。框架把真实状态挪进回调地址的 `_oauthstate` 参数、`state` 位上只留一个哨兵,回调时再还原。只在账号授权时出现,扫码链路不受影响。 ::: ### 实现来源 八家**全部由框架自研**,不依赖任何第三方 OAuth 提供商包——认证包现在只剩 `Microsoft.AspNetCore.Authentication.JwtBearer` 一个 NuGet 依赖。 | 分类 | 提供商 | 说明 | | --- | --- | --- | | 走通用形态 | Google | 直接用基类 `XiHanOAuthHandler`,无需单独的处理器 | | 只多一步 | GitHub、Gitee | 覆写 `AfterClaimActionsAsync`,在用户信息没给出邮箱时补取一次 | | 协议有偏离 | QQ、微信、企业微信、飞书、钉钉 | 各自覆写 `BuildChallengeUrl` / `ExchangeCodeAsync` / `CreateTicketAsync` | 新增一家的成本按偏离程度递增:只有端点不同 → 只写一个 `Options`;多一跳或字段名不同 → 再写一个 `Handler`;最后在 `RegisterProvider` 里加一个 `case`。 ### 各家偏离 OAuth2 通用约定的地方 | 提供商 | 偏离点 | | --- | --- | | 微信 | 令牌接口用 `appid`/`secret` 而非 `client_id`/`client_secret`;用 `errcode` 而非 HTTP 状态码表达失败;授权地址必须以 `#wechat_redirect` 结尾 | | 企业微信 | 换到的是**企业凭证**而不是用户令牌,成员身份要再用授权码换一次,敏感资料还要凭 `user_ticket` 取第三次 | | 飞书 | 用响应体里的 `code`/`msg`(开放平台)或 `error`/`error_description`(passport)表达失败;开放平台把用户信息包在 `data` 节点里 | | 钉钉 | 令牌接口收 JSON 体、返回 `accessToken`/`expireIn` 小驼峰字段;用户信息接口用 `x-acs-dingtalk-access-token` 私有头而非 `Authorization` | | QQ | 令牌与用户标识接口默认返回表单文本与 JSONP,靠 `fmt=json` 换成纯 JSON;要先换 `openid` 再取资料 | ### 已知边界 | 边界 | 说明 | | --- | --- | | 企业微信要填 `AgentId` | 扫码页与应用内授权页都要带 | | 企业微信默认拿不到姓名 | 授权链路只给 `userid`。要姓名就开 `LoadMemberProfile` 走通讯录读取;读不到时姓名为空,**不影响登录本身** | | 登录标识优先取 union 类字段 | 微信 `unionid`、钉钉 `unionId`、飞书 `union_id`,缺失时退回 `openid`;企业微信取 `userid`,非企业成员退回 `openid` | | 远端失败会抛异常 | 授权码无效、用户信息拉取失败等由 `RemoteAuthenticationHandler` 抛出;要变成跳转到错误页,需在 provider 的 `Events.OnRemoteFailure` 里处理 | | `Scopes` 默认是追加语义 | 在提供商默认值之外追加,配置里只写增量即可;微信与企业微信例外,两种方式的范围互斥所以整体替换 | ::: danger 首次第三方登录不要按邮箱并号 拿第三方返回的邮箱去匹配既有账号并直接登录,等于把「谁控制这个邮箱」的判断外包给了第三方。第三方邮箱未必经过验证,这会成为账号接管的入口。 安全做法是按 `(Provider, ProviderKey)` 精确定位;找不到就新建账号,让用户在登录态下主动完成绑定。 ::: ## TOTP 二次验证 `IOtpService` 遵循 RFC 6238: | 成员 | 说明 | | --- | --- | | `GenerateTotpSecret()` | 生成 Base32 密钥 | | 生成 provisioning URI | 供认证器扫码,形如 `otpauth://totp/{issuer}:{account}?secret=...` | | 校验验证码 | 带窗口容差,容忍客户端与服务端的时钟差 | TOTP 由认证器本地生成,**服务端不需要下发**——这是它与邮箱/短信验证码在流程上的关键区别:判定需要二次验证时,TOTP 直接等用户输入,邮箱/短信则要先发码。 ## 会话闸门:401 与 423 `XiHanSessionStateMiddleware` 位于认证之后、授权之前,判定委托给 `ISessionStateGate`(框架默认实现一律放行)。业务侧 `Replace` 后可产出两种结果: | 结果 | 语义 | 客户端该怎么做 | | --- | --- | --- | | `401` | 会话已失效(登出 / 被踢 / 撤销 / 过期) | 尝试刷新令牌,失败则跳登录页 | | **`423`** | **会话被锁定,身份仍然有效** | **引导解锁,不是跳登录页** | ::: tip 为什么框架不定义锁定原因 锁屏、风控挂起、强制改密、二次验证未完成都可能导致「身份有效但当前不能操作」。框架只提供 `423` 这个信号位,原因与解锁方式由应用侧定义。 ::: 位置也是有讲究的:**在认证之后**(要读 `session_id` claim)、**在租户解析之后**(会话表通常是多租户实体,租户上下文没解析会被全局过滤器挡掉)、**在授权之前**(`423` / `401` 要先于权限评估短路,不能和 `403` 混淆)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 多节点部署偶发令牌校验失败 | 时钟偏差;调大 `ClockSkewMinutes` 或给节点校时 | | 刷新令牌一直失败 | 刷新端点没设匿名;或没有同时提交旧访问令牌与刷新令牌 | | 验证码验不过 | 已被消费(读取即销毁);或同目标重复签发把旧码覆盖了 | | 匿名端点里拿不到用户 | 匿名端点不经过认证中间件,`ICurrentUser` 是空的 | | 收到 `423` 却跳了登录页 | 客户端把 `423` 当成 `401` 处理了 | | 改了 `Iterations` 后老用户登录失败 | 不应该失败——哈希串自描述,老密码按串里记录的旧参数校验。若真失败,检查是不是连存储格式一起改了 | ## 下一步 - [授权](./authorization):权限码与判定链 - [数据加解密](./security):哈希、签名、对称与非对称加密 - [Web 应用开发](./web):中间件管道全貌 - [多租户](./multi-tenancy):租户解析 - [Authentication 包](../packages/authentication):完整 API 与配置项 --- 来源:https://framework.docs.xihanfun.com/guide/authorization # 授权 认证解决「你是谁」,授权解决「你能做什么」。这一章讲怎么用一个权限码把接口保护起来、权限判定实际走了哪条链路、以及为什么真实项目**几乎总要**换掉框架的默认判定器。 完整 API 清单见 [Authorization 包](../packages/authorization)。 ## 和认证的分工 两件事分属两个包、在管道里也是两段,中间还夹着租户解析与会话闸门: ```text UseAuthentication → 租户解析 → 会话闸门 → UseAuthorization → 端点 你是谁 哪个租户 会话还有效吗 你能做什么 ``` | 关注点 | 认证 | 授权 | | --- | --- | --- | | 回答的问题 | 你是谁 | 你能做什么 | | 输入 | 令牌 / Cookie | 已解析的 `ClaimsPrincipal` + 权限数据 | | 产物 | `ClaimsPrincipal` 上的身份声明 | 通过 / 不通过 | | 失败状态码 | `401`(未认证) | `403`(已认证但权限不足) | | 主要扩展点 | `IJwtTokenService` 等 | `IPermissionChecker` | ::: tip 身份放令牌,权限别放令牌 令牌里只放身份信息(用户、会话、租户、角色),权限一律服务端实时判定。框架的授权处理器就是这么做的——它不读令牌里的权限声明,每次都问 `IPermissionChecker`,所以授权/回收、账号禁用、会话注销都能立刻生效,不用等令牌过期。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Authorization ``` 框架里没有任何模块自动依赖它,必须在你的模块上显式声明: ```csharp [DependsOn( typeof(XiHanAuthorizationModule), typeof(XiHanWebApiModule) )] public class MyAppModule : XiHanModule { } ``` `XiHanAuthorizationModule` 依赖 `XiHanAuthenticationModule`,`ConfigureServices` 里调 `AddXiHanAuthorization(config)`,用 `TryAdd` 把整套判定件注册进容器:存储(`IPermissionStore` / `IRoleStore` / `IPolicyStore`)、判定(`IPermissionChecker` / `IPolicyEvaluator` / `IAbacAttributeCollector` / `IAbacEvaluator`)、门面(`IAuthorizationService`),以及 ASP.NET Core 集成件 `HybridPermissionPolicyProvider` 与 `HybridPermissionAuthorizationHandler`。 `UseAuthorization()` 由 `XiHanWebApiModule` 编排在管道里,不需要你自己调。 ::: warning 漏掉 DependsOn 的表现很不直观 没依赖这个模块时,`HybridPermissionPolicyProvider` 不会注册,`[PermissionAuthorize]` 生成的策略名在默认提供器里找不到,请求期直接抛 `InvalidOperationException`(提示策略 `xihan.hybrid:p=...` 未找到),不是 403。 ::: ## 用特性保护接口 `PermissionAuthorizeAttribute` 继承自 ASP.NET Core 的 `AuthorizeAttribute`,可以标在类或方法上,`AllowMultiple = true`、`Inherited = true`。动态 API 把应用服务本身当作 Controller,所以直接标在应用服务方法上即可: ```csharp public class InvoiceAppService : ApplicationServiceBase, IInvoiceAppService { [PermissionAuthorize(InvoicePermissionCodes.Read)] public Task GetAsync(long id) { … } [PermissionAuthorize(InvoicePermissionCodes.Create)] public Task CreateAsync(InvoiceCreateDto input) { … } } ``` 多个授权特性叠加是 **AND**:类上标一个、方法上再标一个,两个都得通过。 ```csharp // 类上打底:进这个服务先要有读权限 [PermissionAuthorize(InvoicePermissionCodes.Read)] public class InvoiceAppService : ApplicationServiceBase { // 这个方法额外要求导出权限:Read + Export 都要有 [PermissionAuthorize(InvoicePermissionCodes.Export)] public Task ExportAsync(InvoiceQueryDto input) { … } } ``` ## 权限码约定 框架**不解析权限码的结构**——`DefaultPermissionChecker` 是按名称做序数比较(区分大小写),层级、通配、继承都不是框架行为。所以格式由你定,只要全项目一致即可。推荐 `资源:操作`,跨模块可能重名时加模块前缀: | 形态 | 例子 | 适用 | | --- | --- | --- | | `资源:操作` | `invoice:create`、`code_gen:read` | 资源名在全局唯一时 | | `模块:资源:操作` | `saas:tenant:create` | 同名资源分属多个模块时 | ::: tip 权限码必须有单一事实源 把权限码定义成常量集中在一个类里,代码里一律引用常量: ```csharp public static class InvoicePermissionCodes { public const string Resource = "invoice"; public const string Read = "invoice:read"; public const string Create = "invoice:create"; public const string Update = "invoice:update"; public const string Delete = "invoice:delete"; public const string Export = "invoice:export"; } ``` 内联字符串拼错不会编译报错,只会在运行时静默鉴权失败——排查成本极高。 ::: 超级管理员的通配 `*` **不是框架能力**:`DefaultPermissionChecker` 里没有任何通配处理。要支持它,就在你自己的 `IPermissionChecker` 实现里判定(例如权限集合里含 `*` 即放行)。 ## 在代码里手动判定 特性覆盖不到的场景(判定结果要影响返回内容、要按数据行判定、要在领域服务里判定)直接注入 `IPermissionChecker`: ```csharp public class InvoiceExporter { private readonly IPermissionChecker _permissionChecker; public InvoiceExporter(IPermissionChecker permissionChecker) { _permissionChecker = permissionChecker; } public async Task ExportAsync(string userId, CancellationToken ct) { // 单个 var canExport = await _permissionChecker.IsGrantedAsync(userId, InvoicePermissionCodes.Export, ct); // 任一:审计员或财务都能看金额列 var canSeeAmount = await _permissionChecker.IsAnyGrantedAsync( userId, [InvoicePermissionCodes.Read, "audit:read"], ct); // 全部 var canArchive = await _permissionChecker.IsAllGrantedAsync( userId, [InvoicePermissionCodes.Read, InvoicePermissionCodes.Delete], ct); // 该用户全部权限码(做前端菜单/按钮级控制时用) var all = await _permissionChecker.GetGrantedPermissionsAsync(userId, ct); … } } ``` 契约共五个方法,判定用户的四个都按 `userId` 字符串传入,不依赖当前请求上下文;`PermissionExistsAsync` 不接收 `userId`: | 方法 | 语义 | | --- | --- | | `IsGrantedAsync(userId, permissionName, ct)` | 单个权限码 | | `IsAnyGrantedAsync(userId, names, ct)` | 任一命中即通过 | | `IsAllGrantedAsync(userId, names, ct)` | 全部命中才通过 | | `GetGrantedPermissionsAsync(userId, ct)` | 该用户的全部权限码 | | `PermissionExistsAsync(permissionName, ct)` | 权限定义是否存在(判定定义,不判定授予) | ::: warning 空列表一律返回 false `IsAnyGrantedAsync` 和 `IsAllGrantedAsync` 传入空列表都返回 `false`(fail-closed)。别把「没配权限要求」写成传空列表,那是拒绝,不是放行。 ::: 想要带失败原因的结果对象(`Succeeded` / `FailureReason` / `FailedRequirements`)而不是裸 `bool`,用门面 `IAuthorizationService`,它内部就是转调 `IPermissionChecker`: ```csharp var result = await _authorizationService.AuthorizeAsync(userId, InvoicePermissionCodes.Export); if (!result.Succeeded) { _logger.LogWarning("拒绝导出:{Reason}", result.FailureReason); } ``` ## 换成自己的判定器 框架默认的 `DefaultPermissionChecker` 读 `IPermissionStore` / `IRoleStore`,而这两个默认实现是**纯内存**的(`ConcurrentDictionary`):不持久化、不预置任何数据。而且它们注册为 **Scoped**,字典是实例字段,随请求作用域新建——这一个请求里写进去的授权,下一个请求就读不到了。也就是说,**不换实现的话,对真实用户的所有判定都返回 false**。它的定位是参考实现与开发期占位。 生产做法是自己实现 `IPermissionChecker`,读一份按用户缓存的授权快照: ```csharp public sealed class SnapshotPermissionChecker : IPermissionChecker { private const string Wildcard = "*"; private readonly IAuthorizationSnapshotService _snapshots; public SnapshotPermissionChecker(IAuthorizationSnapshotService snapshots) { _snapshots = snapshots; } public async Task IsGrantedAsync(string userId, string permissionName, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(permissionName)) { return false; } var permissions = await _snapshots.GetPermissionsAsync(userId, cancellationToken); return permissions.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || permissions.Contains(permissionName.Trim(), StringComparer.OrdinalIgnoreCase); } // IsAnyGrantedAsync / IsAllGrantedAsync / GetGrantedPermissionsAsync / PermissionExistsAsync 同理 … } ``` 注册**必须用 `Replace`**: ```csharp using Microsoft.Extensions.DependencyInjection.Extensions; public class MyAppModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Replace( ServiceDescriptor.Scoped()); } } ``` ::: danger 用 TryAdd 会被静默忽略 `AddXiHanAuthorization` 已经用 `TryAddScoped` 占了位。你再 `TryAdd` 一次不会报错、不会告警,鉴权照旧走默认实现——线上表现就是「权限配了但一直 403」或「换了实现毫无变化」。同理适用于 `IPermissionStore` / `IRoleStore` / `IPolicyStore` / `IAbacEvaluator`。 ::: ::: tip 判定器是请求期热路径 默认注册是 Scoped。一次请求里可能触发多次判定,实现里做请求级记忆化(字段缓存本次请求已构建的快照),避免同一请求反复查库。 ::: 如果只想换数据来源、保留框架的「用户直授 + 角色继承」合并逻辑,那就不动 `IPermissionChecker`,改为 `Replace` 掉 `IPermissionStore` 和 `IRoleStore`:`DefaultPermissionChecker.IsGrantedAsync` 会先查用户直接权限,再遍历用户的启用角色查角色权限,任一命中且 `IsEnabled` 即通过。 ## 关键机制 ### 混合策略名 `[PermissionAuthorize]` 在构造时就把「权限码 + ABAC 策略码」编码进一个 ASP.NET Core 策略名: ```text xihan.hybrid:p={URL编码的权限码};a={URL编码的ABAC策略码} ``` `HybridPermissionPolicyProvider` 拦截 `xihan.hybrid:` 前缀的策略名,解析出两个编码后**动态构建**一条含 `HybridPermissionRequirement` 的策略;其余策略名回落到 `DefaultAuthorizationPolicyProvider`。 这带来一个实用后果:**任何权限码都不需要预先 `AddPolicy` 注册**,写上特性就能用。代价是策略名里出现了 `;`、`=` 等字符(含这些字符的权限码会被 `Uri.EscapeDataString` 转义),所以别手写策略名,用特性。 ### 判定顺序 `HybridPermissionAuthorizationHandler` 的执行顺序是固定的: 1. 从主体声明取 userId:取声明集合里**第一条**类型命中 `NameIdentifier` / `sub` / `userid` / `user_id`(大小写不敏感)的声明。取不到就直接不通过。 2. 若有权限码,调 `IPermissionChecker.IsGrantedAsync` 实时判定;不通过则终止(不再评估 ABAC)。 3. 若没有 ABAC 策略码,到此通过。 4. 若有 ABAC 策略码,`IAbacAttributeCollector` 收集主体 / 资源 / 环境属性,交 `IAbacEvaluator` 评估,允许才通过。 全程 fail-closed:任何一步拿不到结论都是拒绝。传给判定器的 `userId` 是声明里的**原始字符串**,实现里自己转成业务的 ID 类型。 ### 叠加属性约束(ABAC) 权限码只能回答「能不能做这类事」,回答不了「能不能对**这一条**做」。`PermissionAuthorizeAttribute` 的第二个参数接一个 ABAC 策略码,在权限码之上再加一层属性约束: ```csharp // 有读权限,且资源与当前用户同租户 [PermissionAuthorize(InvoicePermissionCodes.Read, "same_tenant")] public Task GetAsync(long id) { … } // 只判属性、不判权限码,用 AbacAuthorize // 参数名就是属性键,必须写成 user_id(见下方属性键说明) [AbacAuthorize("self_only")] public Task GetMyProfileAsync(long user_id) { … } ``` `DefaultAbacEvaluator` 内置的常用策略码(大小写不敏感): | 策略码 | 判定 | | --- | --- | | `allow` | 无条件允许 | | `same_tenant` / `tenant_match` | 主体 `tenant_id` 与资源的 `tenant_id` 等任一来源相等 | | `self_only` / `owner_match` | 主体 `user_id` 与资源的 `user_id` / `owner_user_id` 等任一来源相等 | | 比较表达式 | 如 `subject.tenant_id == resource.tenant_id`,支持 `==` / `!=` / `=` | | 其它 | 拒绝(不支持的策略码) | 属性从哪来:主体属性来自声明;资源属性来自授权 `resource` 对象的公共属性,若能从中解析出 `HttpContext` 还会带上 `route.*` / `query.*`;环境属性含权限码、策略码、UTC 时间、请求路径等。完整属性键与策略词汇见[包文档](../packages/authorization#默认-abac-策略词汇)。 ::: warning ABAC 拿不到资源属性就等于拒绝 `same_tenant` / `self_only` 依赖资源侧的属性。如果既没有可解析的 `HttpContext`(路由/查询参数里没有 `tenant_id`、`user_id`),又没有把资源对象传进授权流程,属性字典里就没有对应键,判定结果是拒绝。要么把标识放进路由(`/invoices/{tenant_id}/...`),要么实现 `IAbacAttributeCollector` 补齐领域属性。 属性键只做 `ToLowerInvariant()` 归一化,不做驼峰转下划线:路由参数写成 `{tenantId}` 得到的键是 `route.tenantid`,与默认评估器要找的 `route.tenant_id` 对不上,判定照样拒绝。走默认策略词汇时,路由/查询参数就得直接命名为 `tenant_id`、`user_id`。 ::: 需要更复杂的规则(时间窗、IP 段、组织树、外部策略引擎)时,实现 `IAbacEvaluator` 并 `Replace` 掉默认实现,特性侧的写法完全不变。 ### Policy 子系统 除了「权限码 + ABAC」这条主线,包里还有一套独立的 Policy 判定:`PolicyDefinition` 可以组合所需角色(任一命中)、所需权限(全部命中)、所需声明(键值匹配)和自定义要求,由 `IPolicyEvaluator` 评估,入口是 `IAuthorizationService.AuthorizePolicyAsync(userId, policyName, resource)`。策略定义放在 `IPolicyStore` 里,适合把「一组判定条件」做成可配置的命名策略。 ::: warning 两个同名的 IAuthorizationRequirement `XiHan.Framework.Authorization.Policies.IAuthorizationRequirement` 是 Policy 子系统的自定义要求接口(有 `Name` 和 `EvaluateAsync`),和 ASP.NET Core 的标记接口 `Microsoft.AspNetCore.Authorization.IAuthorizationRequirement` 同名不同物。写自定义要求时看清 using。 ::: 自定义要求里读声明要注意:`DefaultPolicyEvaluator` 的声明来自 `ICurrentUser`,也就是**当前请求的主体**,不是参数里那个 `userId` 的声明。给别人的 userId 做离线判定时,声明类要求会判失败。 ## 配置 这个包没有配置节。`AddXiHanAuthorization` 虽然接收 `IConfiguration` 参数,但注册过程不读任何配置键——所有行为差异都通过替换实现来表达,不通过配置开关。 需要留意的只有两处环境前提: | 前提 | 说明 | | --- | --- | | 模块依赖 | 你的模块必须 `[DependsOn(typeof(XiHanAuthorizationModule))]` | | 管道顺序 | `UseAuthorization()` 由 `XiHanWebApiModule` 编排在认证、租户解析、会话闸门之后,自己插中间件时别插到它后面还指望能读到授权结果 | ## 常见问题 | 现象 | 原因 | | --- | --- | | 换了 `IPermissionChecker` 毫无变化 | 用了 `TryAdd`,框架已占位;必须用 `Replace` | | 所有权限判定恒为 `false` | 没换实现,默认内存存储不预置任何数据 | | 权限码明明配了还是 403 | 默认判定器按序数比较、区分大小写;权限码常量与种子数据里的字符串必须逐字一致 | | 超管也被拦 | 通配 `*` 不是框架行为,要在自己的 `IPermissionChecker` 实现里判 | | 抛 `InvalidOperationException` 说策略未找到 | 模块没 `DependsOn(typeof(XiHanAuthorizationModule))`,策略提供器未注册 | | 该 403 却收到 401 | 请求根本没通过认证;授权只在认证成功后才有 403 可言 | | 授权改了要重新登录才生效 | 判定器读的是登录时冻结的数据;改成读可失效的授权快照 | | 传空列表却被拒绝 | `IsAnyGrantedAsync` / `IsAllGrantedAsync` 对空列表返回 `false` | | `same_tenant` 恒拒绝 | 资源属性里没有 `tenant_id`:没传资源对象,路由/查询里也没有该参数 | | 类和方法上各标一个特性后接口进不去 | 多个授权特性是 AND 关系,两个权限码都要有 | ## 下一步 - [认证](./authentication):身份、令牌与会话闸门 - [多租户](./multi-tenancy):租户上下文,ABAC 的 `same_tenant` 依赖它 - [Web 应用开发](./web):中间件管道全貌 - [扩展与二次开发](./extending):替换框架默认实现的通用套路 - [Authorization 包](../packages/authorization):完整 API 与配置清单 - [BasicApp 权限模型](https://basicapp.docs.xihanfun.com/backend/permission):权限码、数据范围与字段脱敏的应用层落地 --- 来源:https://framework.docs.xihanfun.com/guide/bot # 机器人 把「往哪些渠道发通知」和「用哪个厂商发」拆开:业务只面对一个 `IBotClient`,邮件 / 短信 / Telegram / 钉钉 / 飞书 / 企业微信各是一个可插拔的通道子包,凭证由数据库供给而不是写死在 `appsettings`。 完整 API 与配置全表见 [Bot 包](../packages/bot) 及各通道包文档。 ## 一套抽象,六个通道 核心包 `XiHan.Framework.Bot` 只做编排——它不含任何平台 SDK。每个通道是独立子包,各自实现 `IBotProvider` 并引入自己的第三方 SDK。 | 通道 | 包 | 模块类 | 提供者名 | 第三方 SDK | | --- | --- | --- | --- | --- | | 邮件 | `XiHan.Framework.Bot.Email` | `XiHanBotEmailModule` | `Email` | MailKit | | 短信 | `XiHan.Framework.Bot.Sms` | `XiHanBotSmsModule` | `Sms` | 阿里云 / 腾讯云 SDK | | Telegram | `XiHan.Framework.Bot.Telegram` | `XiHanBotTelegramModule` | `Telegram` | Telegram.Bot | | 钉钉 | `XiHan.Framework.Bot.DingTalk` | `XiHanBotDingTalkModule` | `DingTalk` | 无(HTTP Webhook) | | 飞书 | `XiHan.Framework.Bot.Lark` | `XiHanBotLarkModule` | `Lark` | 无(HTTP Webhook) | | 企业微信 | `XiHan.Framework.Bot.WeCom` | `XiHanBotWeComModule` | `WeCom` | 无(HTTP Webhook) | 提供者名常量集中在 `BotProviderNames`,别手写字符串。 **渠道(Channel)不是提供者(Provider)**:渠道是逻辑名(如 `"ops-alert"`),映射到一组提供者。业务按渠道名发送,底层用哪几个厂商由配置决定。`BotProviderManager.ResolveProviders` 先查渠道表,没命中才按提供者名直接匹配——所以两者可以混着传。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Bot dotnet add package XiHan.Framework.Bot.DingTalk # 想要哪个通道就装哪个 dotnet add package XiHan.Framework.Bot.Email ``` 在模块上依赖内核模块与需要的通道子模块,再用 `AddXiHanBot` 组装: ```csharp [DependsOn( typeof(XiHanBotModule), typeof(XiHanBotDingTalkModule), typeof(XiHanBotEmailModule) )] public class MyNotificationModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddXiHanBot(bot => { bot.Configure(options => { options.DefaultStrategy = BotStrategyNames.Failover; options.RateLimitPerSecond = 5; }) .AddChannel("ops-alert", BotProviderNames.DingTalk, BotProviderNames.Email); }); } } ``` `AddXiHanBot` 注册的都是单例:`BotProviderManager`、`BotDispatcher`、`IBotClient`、`IBotTemplateEngine`,三个策略(`Broadcast` / `Failover` / `Priority`),四个管道(`LoggingPipeline` → `EnvironmentFilterPipeline` → `RetryPipeline` → `RateLimitPipeline`,注册顺序即洋葱顺序)。 ::: tip 子模块已经把提供者注册好了 `XiHanBotEmailModule` 之类的通道模块在 `ConfigureServices` 里各自调用了 `AddXiHanBotEmail()`,提供者与默认配置存储已经进 DI。`BotBuilder` 上的 `UseEmail(...)` / `UseDingTalk(...)` / `UseLark(...)` / `UseWeCom(...)` / `UseTelegram(...)` 是**顺带写选项**的另一条路——它们的 `configure` 参数是必填的,不能像 `UseSms()` 那样空调用。 ::: ## 发一条消息 ```csharp public class AlarmService(IBotClient bot) { public async Task NotifyAsync(CancellationToken ct) { var message = new BotMessage { Title = "磁盘告警", Content = "node-01 磁盘使用率 92%", Type = BotMessageType.Markdown, Mentions = { "13800000000" } }; // 只发 "ops-alert" 渠道;channels 传 null 或空 = 广播给全部已注册提供者 var result = await bot.SendAsync(message, ["ops-alert"], ct); if (!result.IsSuccess) { foreach (var item in result.Results.Where(x => !x.IsSuccess)) { // item.Provider / item.Message 定位是哪个通道挂了 } } } } ``` 链式写法: ```csharp await bot.Alert() .Title("部署完成") .Content("**release-2026.8** 已上线") .Type(BotMessageType.Markdown) .Mention("@all") .SendTo("ops-alert") .SendAsync(ct); ``` `IBotClient` 另有 `SendTemplateAsync`(按 `BotTemplate` 名渲染后发)、`SendBatchAsync`(逐条发、逐条返回)、`SendDelayedAsync`(`Task.Delay` 后发,**不落盘**,进程重启即丢)。 ### 富消息走 `Data` 扩展键 `BotMessage.Content` 是所有通道的公共部分。平台特有的结构(钉钉 ActionCard、飞书富文本、企业微信图文……)放进 `Data` 字典,键名由各子包的 `{X}MessageDataKeys` 提供,大小写不敏感: ```csharp var message = new BotMessage { Type = BotMessageType.Card, Content = "订单待审批" }; message.Data[DingTalkMessageDataKeys.DingTalkActionCard] = new DingTalkActionCard { /* … */ }; message.Data[EmailMessageDataKeys.EmailTo] = new[] { "ops@example.com" }; message.Data[SmsMessageDataKeys.PhoneNumbers] = "13800000000,13900000000"; ``` 提供者按 `Type` 选分支,取不到对应的 `Data` 就**回落成纯文本**发出去——不会报错,所以键名拼错的表现是「消息发出去了但样式不对」。 `BotMessageType` 取值:`Text` / `Markdown` / `Card` / `Image` / `File` / `Link`。 ## 关键机制 ### 一次调度的完整链路 ``` IBotClient.SendAsync └─ BotDispatcher.DispatchAsync 1. 规范化 channels 2. BotProviderManager.ResolveProviders → 选中的提供者列表 3. 无提供者 → ThrowWhenNoProvider ? 抛异常 : BotDispatchResult.NoProvider 4. 解析策略:消息 Data["Strategy"] ?? DefaultStrategy,找不到回退 Broadcast 5. 管道洋葱包裹策略:Logging → EnvironmentFilter → Retry → RateLimit → 策略 6. 策略逐个调 IBotProvider.SendAsync,结果写入 BotContext.Results └─ BotDispatchResult.From(results, isSkipped) ``` 三个策略的区别: | 策略 | 行为 | | --- | --- | | `Broadcast`(默认) | 发给全部选中提供者;某个失败后是否继续由 `ContinueOnError` 决定 | | `Failover` | 按顺序发,**第一个成功就停** | | `Priority` | 只发列表里的第一个提供者 | 单条消息想临时换策略,写 `message.Data[BotMessageDataKeys.Strategy] = BotStrategyNames.Failover;`。 ### 成败判定:只看 `BotDispatchResult.IsSuccess` ::: danger 提供者异常不会冒泡 策略内部 `SafeSendAsync` 捕获了所有异常,转成 `BotResult.Failed(ex.Message, provider.Name)` 记进结果列表。所以 `SendAsync` 不抛异常**不代表发成功**。 只有两种情况会抛:`ThrowWhenNoProvider = true` 且无提供者;重试耗尽且 `context.LastException` 非空。 ::: `IsSuccess` 为 `true` 的条件是「至少有一个结果,且全部成功」。以下都是 `false`: - 没有任何提供者被选中(`ErrorMessage = "No bot provider configured."`); - 被环境过滤跳过(`IsSkipped = true`); - 任意一个提供者失败(`ErrorMessage` 把各失败项拼成 `提供者:原因`)。 单项结果 `BotResult.Code` 是 `BotResultCodes`:`Success = 200` / `BadRequest = 400`(配置缺失、收件人为空这类调用方问题)/ `Failed = 500`。 ### 重试只重发失败的提供者 `RetryPipeline` 每轮先 `ClearResults()`,然后把提供者列表收缩为上一轮失败的那几个再跑一遍。`RetryCount <= 1` 或开关关闭时整个管道透传。这意味着广播场景下**已成功的通道不会被重复打扰**。 ### 限流是进程内的 `RateLimitPipeline` 用一个进程内滑动窗口队列限制每秒条数。多实例部署时每个实例各限各的,不是全局配额。 ### 环境过滤 `EnableEnvironmentFilter = true` 且 `AllowedEnvironments` 非空时,`IHostEnvironment.EnvironmentName` 不在列表里就把整次调度标记 `IsSkipped` 并直接返回。默认关闭。开发环境防误发告警很有用,但别忘了它会让 `IsSuccess` 变成 `false`。 ## 配置存储:把凭证搬进数据库 每个通道都有一个 `*ConfigStore` 接口,提供者**每次发送前**都会调它拿当前生效配置。默认实现从 `IOptionsMonitor` 读(也就是 `appsettings` 那套),生产环境应该换成数据库实现——这样运维在后台改完 Webhook 地址、换完密钥立刻生效,不用重启也不用把密钥写进配置文件。 | 通道 | 接口 | 默认实现读什么 | | --- | --- | --- | | 邮件 | `IEmailConfigStore` | `EmailOptions` | | 短信 | `ISmsConfigStore` | **恒返回 `null`**(凭证不入配置文件) | | Telegram 单发 | `ITelegramConfigStore` | `TelegramOptions` | | 钉钉 | `IDingTalkConfigStore` | `DingTalkOptions` | | 飞书 | `ILarkConfigStore` | `LarkOptions` | | 企业微信 | `IWeComConfigStore` | `WeComOptions` | | Telegram 机器人列表 | `ITelegramBotConfigStore` | `TelegramBotPlatformOptions.Bots` | | Telegram 平台设置 | `ITelegramBotSettingsStore` | `TelegramBotPlatformOptions.Settings` | 前六个单配置 store 都只有一个 `GetAsync(CancellationToken)`,返回 `null` 表示未配置,提供者会返回 `BadRequest` 而不是静默成功。末两个 Telegram 平台 store 方法名不同也不返回 `null`:`ITelegramBotConfigStore.GetBotConfigsAsync` 返回机器人列表(无配置即空列表),`ITelegramBotSettingsStore.GetSettingsAsync` 返回全局设置对象。 ### 用 `Replace` 覆盖,不要用 `TryAdd` ::: danger 顺序决定了必须用 Replace 框架的默认实现是以 `TryAddSingleton` 注册的,而被 `[DependsOn]` 依赖的模块**先于**你的模块执行 `ConfigureServices`——等你的代码跑到时,坑位已经被默认实现占了。此时再调 `TryAddSingleton` 是**静默 no-op**,表现为「我明明写了数据库 store,读到的还是 appsettings」。 ::: ```csharp [DependsOn(typeof(XiHanBotDingTalkModule))] public class MyBotConfigModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { // 必须 Replace context.Services.Replace( ServiceDescriptor.Singleton()); } } public class DbDingTalkConfigStore(IMyBotConfigRepository repository) : IDingTalkConfigStore { public async Task GetAsync(CancellationToken cancellationToken = default) { var entity = await repository.GetEnabledAsync("DingTalk", cancellationToken); if (entity is null) { return null; // 未配置 → 提供者 fail-closed } return new DingTalkOptions { Enabled = entity.IsEnabled, WebHookUrl = entity.WebHookUrl, AccessToken = entity.AccessToken, Secret = DecryptSecret(entity.Secret), // 解密责任在 store 实现内 KeyWord = entity.KeyWord }; } } ``` ::: warning 解密在 store 里做完 框架侧完全不感知加密方案。`SmsChannelConfig.AccessKeySecret` 的注释写得很直白:拿到手必须是明文。store 实现负责解密、负责租户过滤、负责缓存——出了 store 的边界,框架按明文用。 ::: ### 短信通道多一层模板映射 云厂商短信必须按模板发,所以 `SmsChannelConfig` 里有一份 `TemplateMap`(JSON),把业务内部模板码翻译成服务商模板码: ```json { "auth-sms-login-code": { "templateCode": "SMS_123456", "paramOrder": ["code", "minutes"] } } ``` 发送时在 `Data` 里给三个键: ```csharp var message = new BotMessage(); message.Data[SmsMessageDataKeys.PhoneNumbers] = "13800000000,13900000000"; message.Data[SmsMessageDataKeys.TemplateCode] = "auth-sms-login-code"; message.Data[SmsMessageDataKeys.TemplateParams] = """{"code":"482913","minutes":"5"}"""; await bot.SendAsync(message, [BotProviderNames.Sms], ct); ``` 阿里云按命名 JSON 参数发送(键名必须和控制台模板变量名一致),腾讯云用位置参数数组,顺序取自 `paramOrder`。`SmsProviderType` 只有 `Aliyun` 和 `TencentCloud` 两个取值;腾讯云还必须填 `SdkAppId` 与 `Region`。 `ISmsGatewayResolver` 每次解析都重读 store,按配置指纹(含密钥、签名、模板映射等字段)缓存已构建的网关客户端——**改配置即热生效**,不需要额外的缓存失效通知。无可用配置时返回 `null`,`SmsBotProvider` 直接判 `BadRequest`。 ## Telegram:不止是一个发送通道 Telegram 子包里其实有两套东西,`XiHanBotTelegramModule` 会把两套都注册上: | | 单发提供者 | 多机器人平台 | | --- | --- | --- | | 入口 | `AddXiHanBotTelegram()` | `AddXiHanBotTelegramPlatform()` | | 用途 | 作为 `IBotClient` 的一个通道往固定 ChatId 推消息 | 托管多个机器人,**接收**并处理用户消息 | | 配置 | `TelegramOptions`(Token / ChatId / ParseMode) | `TelegramBotPlatformOptions`,配置节 `XiHan:Bot:Telegram:Platform` | | 默认状态 | `Enabled = true` | `Settings.Enabled = false`,宿主服务空转 | 只想发通知的话,看到这里就够了。要做交互机器人(命令、按钮、多步会话)才需要往下读。 ### 平台怎么跑起来 `TelegramBotHostedService` 拉起 `TelegramBotManager`,管理器按 `ManagerRefreshSeconds`(默认 5 秒)轮询 `ITelegramBotConfigStore`,对比出新增 / 变更 / 删除,分别启动、重启、停止对应机器人。改数据库里的机器人配置**不用重启应用**。 传输模式由 `Settings.WebhookBaseUrl` 决定: - **非空 → Webhook**:管理器调 `SetWebhook`,地址是 `{WebhookBaseUrl}{WebhookRoutePrefix}/{botName}`,同时带上 `secret_token`。 - **为空 → 长轮询**:管理器先 `DeleteWebhook`,再 `StartReceiving`。 `Settings.Network` 变更(代理、自建 Bot API Server、超时)会导致全部机器人客户端重建。 ### Webhook 接入 除了填 `WebhookBaseUrl`,还要在管道里挂中间件: ```csharp public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.UseTelegramBotWebhook(); // 匹配 POST {WebhookRoutePrefix}/{botName} app.UseAuthentication(); app.UseAuthorization(); // … } ``` ::: tip 挂在鉴权之前 中间件自己完成鉴权(下面的 secret_token 校验),路径不匹配就直接 `next()` 放行。把它放在会拒绝匿名请求的鉴权中间件之前,Telegram 的回调才进得来。 ::: 路由前缀默认 `/api/telegram-bot/webhook`,可用 `Settings.WebhookRoutePrefix` 改(会被归一化:补前导 `/`、去尾部 `/`)。中间件只认 `POST` 且路径正好是「前缀 + 一段机器人名」,多一层路径段不匹配。 ### secret_token 校验是强制的 ::: danger Webhook 模式下 `WebhookSecretToken` 必填 请求体里的字段可以伪造,不能作为鉴权依据,所以框架 fail-closed: - `WebhookSecretToken` 为空时,管理器**拒绝注册 Webhook**(记 Error 日志),中间件对所有 Webhook 请求一律返回 **401**; - 配置了以后,中间件强制校验 `X-Telegram-Bot-Api-Secret-Token` 请求头,用固定时间比较(`CryptographicOperations.FixedTimeEquals`),不匹配返回 **401**。 「留空 = 不校验」在这里不成立。 ::: 校验通过后中间件把 `Update` 反序列化、按机器人名从注册表取实例、`QueueDispatch` 排入后台分发,然后**立即返回 200**。处理生命周期与 HTTP 请求解耦:慢处理器不会被客户端断连半途取消,也不会因为 Telegram 超时重发而被幂等标记误丢。反序列化失败、机器人没找到,同样返回 200——避免 Telegram 对失败响应发起重发风暴。 请求头名与默认前缀都在 `TelegramBotPlatformConsts` 里:`SecretTokenHeaderName`、`DefaultWebhookRoutePrefix`。 ### 处理器要显式注册 平台**不做程序集扫描**,没登记的处理器不会被路由: ```csharp context.Services .AddTelegramBotBuiltinHandlers() // /start /help /myid .AddTelegramBotHandler() .AddTelegramBotHandler(); ``` ```csharp [BotCommand("/order", Description = "查询订单", Aliases = ["/o"], AdminOnly = false)] public sealed class OrderCommandHandler(ITelegramNotifier notifier) : IBotCommandHandler { public async Task HandleAsync(TelegramBotContext context, string[] args, CancellationToken cancellationToken = default) { await notifier.SendTextAsync(context.Bot.Name, context.ChatId, $"订单号:{args.FirstOrDefault()}", context.TriggerMessageId, replyMarkup: null, cancellationToken); } } ``` 七种处理器接口: | 接口 | 触发时机 | 配套特性 | | --- | --- | --- | | `IBotCommandHandler` | `/命令`,或 `Pattern` 正则命中的普通文本 | `[BotCommand]` | | `IBotCallbackHandler` | 内联键盘回调,按 `action:id` 的 `action` 路由 | `[BotCallback]` | | `IBotInlineQueryHandler` | 内联查询 `@bot query`,返回 `InlineQueryResult` 列表 | `CanHandle` | | `IBotStartPayloadHandler` | `/start payload` 深链,返回 `true` 即消费 | 按 `Order` 排序 | | `IBotStateHandler` | 会话存在活跃 `ConversationState` 时的非命令消息 | `CanHandle` + `Order` | | `IBotReplyHandler` | 用户回复了某条消息 | `CanHandle` + `Order` | | `IBotMessageHandler` | 兜底的普通消息 | `CanHandle` + `Order` | 分发次序是固定的:群组/频道白名单守卫 → `update_id` 幂等 → 内联查询 → 会话状态机 → 回调路由 → `/start` 深链 → 命令路由 → 回复路由 → 消息路由 → 兜底回复。 ::: warning 群组白名单为空 = 全部拒收 `TelegramBotConfig.AllowedGroupChatIds` 是 fail-closed 的:**空数组表示拒收所有群组与频道消息**,跟「空 = 不限制」的直觉正好相反。私聊不受影响;`/start`、`/myid`、`/id`、`/help`、`/h` 这几个命令永久放行(只豁免这一层守卫,命令白名单和 `AdminOnly` 仍然生效)。 而 `AllowedCommands` 是另一套语义:空数组才表示不限制。 ::: 主动发消息用 `ITelegramNotifier`(按机器人名定位实例),内建 429 按 `RetryAfter` 精确等待、5xx / 超时指数退避的重试环,最终失败可按 `Retry.NotifyAdminOnFinalFailure` 通知管理员,每次发送都会写 `ITelegramMessageAuditStore`。 ### 默认存储都可以换 平台把这些也做成了 `TryAdd` 的可替换实现,多实例部署时需要换成分布式版本: | 接口 | 默认实现 | 多实例下的问题 | | --- | --- | --- | | `ITelegramUpdateDeduplicator` | `DefaultTelegramUpdateDeduplicator` | 进程内 TTL 字典(30 分钟,最多 100000 条,满载抛异常),**多实例失效** | | `IConversationStateStore` | `DefaultConversationStateStore` | 进程内(最多 50000 条,满载抛异常),会话状态不跨实例 | | `ITelegramMessageAuditStore` | `NoOpTelegramMessageAuditStore` | 不落任何审计 | 替换方式同样是 `Replace`。 ## 配置 内核的 `XiHanBotOptions` 是代码配置(渠道表与模板表是运行时字典),常用几项: | 字段 | 默认值 | 说明 | | --- | --- | --- | | `DefaultStrategy` | `"Broadcast"` | 默认策略,可被消息 `Data["Strategy"]` 覆盖 | | `ContinueOnError` | `true` | 广播时某提供者失败后是否继续 | | `ThrowWhenNoProvider` | `false` | 无提供者时抛异常还是返回失败结果 | | `RetryCount` / `RetryDelay` | `3` / `1s` | `<= 1` 时重试管道不生效 | | `RateLimitPerSecond` | `5` | `<= 0` 关闭限流 | | `EnableEnvironmentFilter` | `false` | 配合 `AllowedEnvironments` 使用 | Telegram 平台走配置节 `XiHan:Bot:Telegram:Platform`(仅作兜底,生产由 store 覆盖): ```json { "XiHan": { "Bot": { "Telegram": { "Platform": { "Settings": { "Enabled": true, "ManagerRefreshSeconds": 5, "WebhookBaseUrl": "https://example.com", "WebhookRoutePrefix": "/api/telegram-bot/webhook", "WebhookSecretToken": "", "EnableFallbackReply": false, "Network": { "ProxyUrl": "", "BaseUrl": "", "TimeoutSeconds": 100 } }, "Bots": [ { "Name": "ops", "Token": "", "AdminUsers": [ 10001 ], "AllowedGroupChatIds": [ -1001234567890 ], "AllowedCommands": [] } ], "Retry": { "MaxRetries": 3, "BaseDelayMs": 500, "MaxDelayMs": 10000, "NotifyAdminOnFinalFailure": true } } } } } } ``` ::: warning 别把 Token 和 SecretToken 留在配置文件里 上面这份只适合本地调试。生产环境把 `ITelegramBotConfigStore` 和 `ITelegramBotSettingsStore` 换成数据库实现,Token、`WebhookSecretToken` 一并落库加密。 ::: `TelegramBotTexts` 里的全部回复文案(`InternalErrorReply`、`CommandDisabledReply`、`AdminOnlyCommandReply`、`UnhandledMessageReply`、`StartReply`、`HelpHeader`、`MyIdReply` 等)都带中文默认值,可整体覆盖。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 广播时莫名多出失败项 | `channels` 传空 = 发给**全部**已注册提供者,包括没配凭证的那些 | 显式传渠道名,或把没用的通道子模块摘掉 | | 数据库 store 不生效,读到的还是 `appsettings` | 用了 `TryAdd`,坑位已被框架默认实现占掉 | 改用 `services.Replace(...)` | | `SendAsync` 没抛异常但对方没收到 | 提供者异常被策略吞成 `Failed` 结果 | 判 `BotDispatchResult.IsSuccess`,遍历 `Results` 看明细 | | 消息发出去了但样式不对 | `Data` 扩展键拼错或类型不匹配,提供者回落成纯文本 | 用 `{X}MessageDataKeys` 常量,别手写字符串 | | Telegram Webhook 全部 401 | Webhook 模式下 `WebhookSecretToken` 为空,或请求头对不上 | 配置密钥并确保 `SetWebhook` 用的是同一个值 | | Telegram Webhook 请求根本没进来 | 中间件没挂,或挂在鉴权之后被拦 | `app.UseTelegramBotWebhook()` 前置 | | 群里发命令机器人不理 | `AllowedGroupChatIds` 为空 = 拒收全部群组消息 | 把群 ChatId 加进白名单(`/myid` 可查) | | 命令回「该命令未开启」 | 命中了 `AllowedCommands` 白名单过滤 | 补进白名单,或清空该数组表示不限制 | | 短信报「缺少模板码」 | 云厂商必须按模板发,`Sms.TemplateCode` 没给或 `TemplateMap` 没配 | 补 `TemplateMap` 映射与 `Data` 键 | | 多实例部署 Telegram 消息被处理两次 | 幂等去重器是进程内字典 | 换成分布式 `ITelegramUpdateDeduplicator` 实现 | | 限流没起到全局作用 | `RateLimitPipeline` 是进程内滑动窗口 | 需要全局配额得自己做分布式限流 | ## 下一步 - [配置与选项](./configuration) —— 选项绑定与配置节约定 - [依赖注入](./dependency-injection) —— `TryAdd` / `Replace` 的覆盖语义 - [Web 应用开发](./web) —— 中间件注册次序 - [Bot 包](../packages/bot) —— 内核完整 API 与配置全表 - [Bot.Email](../packages/bot-email) · [Bot.Sms](../packages/bot-sms) · [Bot.Telegram](../packages/bot-telegram) - [Bot.DingTalk](../packages/bot-dingtalk) · [Bot.Lark](../packages/bot-lark) · [Bot.WeCom](../packages/bot-wecom) --- 来源:https://framework.docs.xihanfun.com/guide/caching # 缓存与分布式锁 框架的缓存抽象、分布式锁、以及**失效时序**这个最容易出错的点。 完整 API 见 [Caching 包](../packages/caching)。 ## 三层能力 | 能力 | 接口 | 说明 | | --- | --- | --- | | 分布式缓存 | `IDistributedCache` / `IDistributedCache` | 有 Redis 走 Redis,否则退化为进程内内存 | | 分布式锁 | `IDistributedLock` | 同上,**未接 Redis 时退化为进程内锁**:最多 10000 个锁条目,满载时获取返回 `null`,与「锁被占用」表现相同 | | 延迟队列 | `IRedisDelayQueue` | 有 Redis 走 Sorted Set;否则退化为进程内 `DefaultDelayQueue`:不跨实例、进程退出即丢,最多 100000 条,满载入队抛 `InvalidOperationException` | | 流式队列 | `IRedisStreamQueue` | 仅在启用 Redis 并配置连接串时注册,没有进程内回退 | 配置节 `XiHan:Caching:Redis`: | 键 | 说明 | | --- | --- | | `IsEnabled` | **关闭则退化为进程内内存缓存** | | `Configuration` | 连接串 `host:port,user=,password=,defaultDatabase=` | | `InstanceName` | Key 统一前缀,隔离不同应用/环境 | | `AbortOnConnectFail` | `false` = 后台持续重连,更适合生产 | | `AllowAdmin` | 允许 FLUSHDB / CONFIG 等管理命令,**生产慎开** | ::: danger 关掉 Redis 的连锁反应 `IsEnabled=false` 时**分布式锁退化为进程内锁**——多实例部署会各跑各的:定时任务重复执行、后台 Worker 不再单活、工作流定时器多实例并发。 单机开发无所谓,**生产必须开**。 ::: ## 声明式缓存 给方法打特性: ```csharp [Cacheable(Key = "user:{userId}", ExpirationSeconds = 300)] public Task GetUserAsync(long userId) { … } [CacheEvict(Key = "user:{userId}")] public Task UpdateUserAsync(long userId, UserUpdateDto input) { … } ``` ::: warning 非 HTTP 入口的前提是服务注册为接口 声明式缓存有两条生效路径:HTTP 请求由 `XiHanCacheFilter` 在 MVC 动作外层处理(动态 API 控制器注入具体类也生效),进程内互调靠 AOP 代理,**此时服务的注册类型必须是接口**,否则特性静默失效。见 [AOP 与拦截器](./aop)。 ::: ## 缓存条目模式 复杂场景推荐「一个热点读定义一个条目类」,把「键怎么拼、值是什么、多久过期」封装在一起,而不是到处 `cache.GetAsync("字符串")`。 好处: - 键名集中,不会拼错; - 失效时能按模式批量清理; - 值的结构有类型约束。 ::: tip 键名一律用常量 把缓存键前缀定义成 `const` 集中管理,禁止内联字符串——否则失效逻辑找不到你的键,表现是「改完不生效」。 ::: ## 失效时序:最容易出错的地方 ::: danger 写路径失效必须排队到事务提交之后 ```csharp await cache.RemoveByPatternAsync(pattern, hideErrors: true, considerUow: true, token: ct); ``` `considerUow: true` 让失效动作**排队到工作单元提交之后**执行。 如果在事务提交**前**就清了缓存,并发请求会立刻回源查库,读到的是**尚未提交的旧值**,然后把旧值重新写回缓存——事务提交后缓存反而是错的,**且不会自愈**。 这类问题极难复现(要恰好有并发读落在提交前的窗口里),一旦发生只能靠手工清缓存恢复。**务必保持 `considerUow: true`。** ::: 完整的提交时序见 [工作单元与事务](./uow#提交时序)。 ## 分布式锁 ```csharp await using var handle = await distributedLock.TryAcquireAsync("resource-key", TimeSpan.FromMinutes(5)); if (handle is null) return; // 没抢到,本轮跳过 // 临界区 ``` 用途:多实例下的单活 Worker、防止定时任务重入、跨实例的资源互斥。 ::: warning 锁 TTL 要大于临界区耗时 TTL 是崩溃安全网——进程挂了锁能自动释放。但如果临界区跑得比 TTL 久,锁会在执行中途过期,另一个实例就进来了。 ::: ## Lua 脚本 Lua 执行**不在 `IDistributedCache` 这个通用契约上**,而是可选能力接口 `ICacheSupportsLuaScript`——通用缓存抽象不应该焊上某个客户端的类型,否则换实现就要改所有调用方。 用法是先做类型判断: ```csharp if (cache is ICacheSupportsLuaScript lua) { CacheScriptResult result = await lua.ScriptEvaluateAsync(script, keys, args); } ``` 签名是中立的:入参 `object?[]`,返回 `CacheScriptResult`(承载标量、整数、布尔与嵌套数组)。具体实现负责把自己的原生返回值映射成这个中立结构。 `IRedisStreamQueue` 只在启用 Redis 后才能注入;`IRedisDelayQueue` 未接 Redis 时是进程内的有界回退,多实例部署或需要持久化时必须接 Redis。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 改了数据但读到旧值 | 写侧漏调失效;或键名内联字符串拼错 | | 偶发读到旧值且不自愈 | 失效没走 `considerUow: true` | | `[Cacheable]` 没生效 | 非 HTTP 入口(后台作业、事件处理器、Minimal API)且服务注册类型不是接口 | | 多实例定时任务重复执行 | Redis 没开,分布式锁退化成进程内锁 | | 想执行 Lua 脚本 | 用能力接口 `ICacheSupportsLuaScript`,见上节 | ## 下一步 - [工作单元与事务](./uow):失效为什么要排队 - [AOP 与拦截器](./aop):声明式缓存的实现机制 - [Caching 包](../packages/caching):完整 API 与配置 --- 来源:https://framework.docs.xihanfun.com/guide/configuration # 配置与选项 框架怎么读配置、选项模式怎么用、配置节怎么命名、什么该放配置什么该写代码。 ## 配置来源 标准 .NET 配置栈,优先级从低到高: ```text appsettings.json → appsettings.{Environment}.json → 环境变量 → 命令行参数 ``` 层级用**双下划线**表示:`XiHan:Data:SqlSugarCore:EnableDiffLog` → `XiHan__Data__SqlSugarCore__EnableDiffLog`。 ::: warning 密钥走环境变量 JWT 签名密钥、数据库密码、第三方 AppSecret 一律用环境变量或密钥库注入,不要提交明文。 ::: ## 配置节命名约定 框架自身的配置节统一在 **`XiHan:`** 命名空间下,按「领域 → 子领域」分层: | 配置节 | 归属包 | | --- | --- | | `XiHan:Data:SqlSugarCore` | [Data](../packages/data) | | `XiHan:Caching:Redis` | [Caching](../packages/caching) | | `XiHan:Authentication:Jwt` / `:PasswordHasher` / `:OAuth` | [Authentication](../packages/authentication) | | `XiHan:Web:Api:Auth` / `:Cors` / `:OpenApiSecurity` | [Web.Api](../packages/web-api) | | `XiHan:Web:RealTime:SignalR` | [Web.RealTime](../packages/web-realtime) | | `XiHan:Tasks:ScheduledJobs` / `XiHan:BackgroundJobs` | [Tasks](../packages/tasks) | | `XiHan:Workflow` / `XiHan:Workflow:Worker` | [Workflow](../packages/workflow) | | `XiHan:Localization` | [Localization](../packages/localization) | | `XiHan:ObjectStorage` | [ObjectStorage](../packages/object-storage) | | `XiHan:Observability` | [Observability](../packages/observability) | | `XiHan:DistributedIds:SnowflakeId` | [DistributedIds](../packages/distributed-ids) | | `XiHan:Upgrade` | [Upgrade](../packages/upgrade) | | `XiHan:SearchEngines:Elasticsearch` | [SearchEngines.Elasticsearch](../packages/search-engines-elasticsearch) | 你自己的业务包**用自己的顶层命名空间**(如 `MyCompany:Billing`),避免和框架撞节。 ## 选项模式 ### 定义 把配置节名钉成常量放在 Options 类里,代码里一律引用常量: ```csharp public class BillingOptions { public const string SectionName = "MyCompany:Billing"; public int InvoiceRetentionDays { get; set; } = 365; public bool EnableAutoArchive { get; set; } } ``` ::: tip 为什么要 `SectionName` 常量 内联 `"MyCompany:Billing"` 字符串会散落在绑定处、测试里、文档里,改名时必漏。常量还能让「这个 Options 对应哪个节」一眼可见。 ::: ### 绑定 在模块的 `ConfigureServices` 里: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { var configuration = context.Services.GetConfiguration(); // 从配置节绑定 Configure(configuration.GetSection(BillingOptions.SectionName)); // 或直接赋值 Configure(o => o.InvoiceRetentionDays = 180); } ``` `Configure` 是 `XiHanModule` 上的便捷方法,等价于 `services.Configure(...)`。 ### 消费 ```csharp public class InvoiceService(IOptions options) : ITransientDependency { private readonly BillingOptions _options = options.Value; } ``` | 接口 | 何时用 | | --- | --- | | `IOptions` | **默认选择**。单例,应用生命周期内不变 | | `IOptionsSnapshot` | 每个作用域重新计算(Scoped 服务里用) | | `IOptionsMonitor` | 需要**热更新**与变更通知(单例服务里用) | ## 三个配置钩子的时序 `XiHanModule` 提供三个层次,对应模块生命周期的三个阶段: | 方法 | 时机 | 用途 | | --- | --- | --- | | `PreConfigure` | `PreConfigureServices` | **先于其他模块预设**,供后续模块读取 | | `Configure` | `ConfigureServices` | 常规配置 | | `PostConfigure` | `PostConfigureServices` | **所有模块配置完之后**做最终覆盖 | 典型用法: ```csharp // 库作者:给使用方一个预埋钩子 public override void PreConfigureServices(ServiceConfigurationContext context) { PreConfigure(o => o.EnableAutoArchive = true); } // 应用作者:不管中间谁改过,我最后说了算 public override void PostConfigureServices(ServiceConfigurationContext context) { PostConfigure(o => o.InvoiceRetentionDays = 90); } ``` ## 不走配置文件的选项 有些选项**刻意不从配置绑定**,只能代码方式设置——因为它们要在装配期就确定,或者值是类型/委托而非标量。 最典型的是[动态 API](./dynamic-api) 的 `DynamicApiOptions`:框架 `TryAddSingleton` 一个实例并交给约定实现持有,你的模块在 `ConfigureServices` 里就地改它: ```csharp context.Services.ConfigureDynamicApiConventions(conventions => { conventions.HttpMethodConventions["Import"] = "POST"; }); ``` 遇到「这个选项怎么配置文件里绑不上」时,先看包文档里它是不是这一类。 ## 什么该放配置 | 判断 | 结论 | | --- | --- | | 启动期就要用、改了必须重启(连接串、密钥、端口) | **配置文件 / 环境变量** | | 运行期由管理员调整、要按租户隔离(业务开关、阈值) | **数据库**(自行实现配置源) | | 装配期确定的类型/委托/约定 | **代码** | 框架的可替换点大多留了「配置源」接口(如 `IAiProviderConfigStore`),业务侧可以 `Replace` 成数据库实现——这就是「配置落库」的标准做法,见 [扩展与二次开发](./extending#配方-b-替换框架的默认实现)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 配置改了不生效 | 用了 `IOptions`(单例快照);要热更新改 `IOptionsMonitor` | | 环境变量没覆盖掉 json | 层级分隔符要用双下划线 `__` | | 某个选项在 appsettings 里绑不上 | 它可能是代码方式配置的(如 `DynamicApiOptions`),或该字段是第三方库的字段而非属性 | | `PostConfigure` 没生效 | 检查是否有更晚的模块又改了一次——最后执行的赢 | ## 下一步 - [模块生命周期](./lifecycle):三个配置钩子分别在什么时候跑 - [依赖注入](./dependency-injection):选项模式与约定注册 - [扩展与二次开发](./extending):把配置源换成数据库实现 --- 来源:https://framework.docs.xihanfun.com/guide/data # 数据访问 框架的数据访问基于 **SqlSugar**。本章讲实体怎么定义、仓储怎么用、查询过滤器做了什么、分页协议长什么样。完整 API 见 [Data 包](../packages/data)。 ## 安装 ```bash dotnet add package XiHan.Framework.Data ``` ```csharp [DependsOn(typeof(XiHanDataModule))] public class MyAppModule : XiHanModule { } ``` 配置节 `XiHan:Data:SqlSugarCore`,支持 PostgreSQL / MySQL / SQL Server / Oracle / 达梦 / 人大金仓等。 ## 实体基类 按「需要哪些审计能力」选: | 基类 | 主键 | 审计 | 软删 | | --- | --- | --- | --- | | `SugarEntity` | 应用生成 | 无 | 否 | | `SugarEntityWithIdentity` | 数据库自增 | 无 | 否 | | `SugarCreationEntity` | 应用生成 | 创建 | 否 | | `SugarModificationEntity` | 应用生成 | 创建 + 修改 | 否 | | `SugarDeletionEntity` | 应用生成 | 创建 + 删除 | 是 | | **`SugarFullAuditedEntity`** | 应用生成 | 全套 | **是** | | `SugarAggregateRoot` | 应用生成 | 全套 | 是 | 每个都有 `SugarMultiTenant*` 变体,**多带一个 `Tenant_Id` 列**——多租户应用一律用这一系列。 自动注入的列: | 列 | 说明 | | --- | --- | | `Basic_Id` | 主键(注意不是 `Id`),默认非自增 | | `Row_Version` | 乐观并发标识 | | `Created_Time` / `Created_Id` / `Created_By` | 创建审计,`IsOnlyIgnoreUpdate` | | `Modified_*` / `Deleted_*` / `Is_Deleted` | 修改与删除审计 | | `Tenant_Id` | 多租户列 | ::: tip 审计列由 AOP 自动填 走 SqlSugar 的 `DataExecuting` AOP,**业务代码不要手动赋值**。 ::: ## 仓储 ```csharp public class OrderService(IRepositoryBase orders) : ITransientDependency { public async Task GetAsync(long id) => await orders.GetByIdAsync(id); public async Task CreateAsync(Order order) => await orders.InsertReturnEntityAsync(order); } ``` 仓储基类实现 `IScopedDependency`,**约定自动注册**,不用手写 DI。 自定义仓储: ```csharp public interface IOrderRepository : IRepositoryBase { Task ExistsCodeAsync(string code); } public class OrderRepository(ISqlSugarClientResolver resolver) : SqlSugarRepositoryBase(resolver), IOrderRepository { public Task ExistsCodeAsync(string code) => DbClient.Queryable().AnyAsync(x => x.Code == code); } ``` ## 全局查询过滤器 租户过滤与软删过滤由 `ISqlSugarClientResolver` + 全局 QueryFilter AOP 统一挂上,**业务代码不用写 `WHERE TenantId = ?` 和 `WHERE IsDeleted = false`**。 ### 写操作的过滤是自动的 配置 `EnableAutoUpdateQueryFilter` / `EnableAutoDeleteQueryFilter` 默认 `true`,SqlSugar 的 `Updateable()` / `Deleteable()` 工厂内部**已经自动挂了一次**。 ::: danger 仓储里禁止再显式调用 `.EnableQueryFilter()` 重复挂会把同一份过滤烘进 WHERE 两遍、生成同名参数 `@constant1001`;一旦叠加 Diff 的 `GetDiffTable` 重查旧值就崩(MySQL 驱动直接抛,PG 容忍但仍是冗余死条件)。 `.EnableDiffLogEvent()` 保留,它单独用是安全的。 ::: ### 写路径的租户边界 **读共享 ≠ 写共享**:全局过滤器为「读共享」放行 `TenantId=0` 的平台全局行,但写路径**不复用这个口径**——每个作用域只能改写/删除本作用域的行:租户态不能动全局行,平台(0 号租户)也不能动租户行。预读守卫会校验取回行的 `TenantId`,条件写自动追加当前作用域的 `Where`。 平台就是 0 号租户:没有租户上下文与 `Change(0)` 同义。维护全局数据在平台作用域进行;写某个租户的数据要 `Change(tenantId)` 切入该租户。跨租户读取只能显式清过滤(`CreateNoTenantQueryable()` / `.ClearTenantFilter()`),见 [多租户](./multi-tenancy)。 **例外:用户自有行**。按 `UserId` 归属、`TenantId` 只是「注册地」元数据的行(账号、安全记录、个人设置、站内信收件行等),平台归属用户或跨租户成员在别的租户里写自己的这些行是合法的。这类写入用 `TenantWriteGuard.Suppress()` 显式包裹:作用域内对象式写(Update / Delete / 软删 / 恢复)的预读同时忽略租户过滤并跳过写边界校验,条件写不再追加当前租户 `Where`;契约是被包裹的写只作用于当前用户自己的行。 ```csharp using (TenantWriteGuard.Suppress()) { await _userRepository.UpdateAsync(user, cancellationToken); } ``` ## 分页与查询协议 分页请求是两段结构(`PageRequestDtoBase`): ```json { "conditions": { "keyword": { "value": "张", "fields": ["userName", "nickName"] }, "filters": [ { "field": "status", "operator": 1000, "value": "Enabled" }, { "field": "createdTime", "operator": 4000, "values": ["2026-01-01", "2026-08-04"] } ], "sorts": [{ "field": "createdTime", "direction": 1001, "priority": 0 }] }, "page": { "pageIndex": 1, "pageSize": 20 } } ``` `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)。 `page`:`pageIndex` 从 1 开始(小于 1 自动纠正),`pageSize` 默认 `20`、**上限 `500`**(超出自动截断)。 响应是 `PageResultDtoBase`:`Items` + `Page`(`totalCount` / `totalPages` / `hasNext` / `startRecord` 等)+ 可选 `ExtendDatas`。 ::: warning 分页方法要显式标 `[HttpPost]` 动态 API 会把 `GetXxxPageAsync` 推导成 GET,请求体绑不上。见 [动态 API](./dynamic-api)。 ::: ## 建库建表与种子 | 配置 | 说明 | | --- | --- | | `EnableDbInitialization` | 启动时自动建库 | | `EnableTableInitialization` | 启动时 CodeFirst 建表 | | `EnableDataSeeding` | 启动时播种 | | `TableInitialization` | 建哪些表(特性 `[TableInitialization]` + 分组/名单/委托筛选) | | `DataSeeding` | 跑哪些种子(特性 `[DataSeeding]` + 分组/名单/委托筛选) | 开关只管开不开,范围由后两项决定:默认全量,标 `[TableInitialization(false)]` 的实体不建、标 `Target = DbInitializationTarget.Platform` 的实体不进租户独立库;要整体自己实现就 `Replace` 掉 `IDbEntityTypeProvider` / `IDataSeederSelector`。细节见 [XiHan.Framework.Data](../packages/data#选择初始化范围)。 初始化分三段:**全部连接建库建表 → 表结构升级 → 全部连接播种**。表结构升级由注册的 `IDbSchemaUpgrader` 执行(只在开启建表时调用,失败即中断启动);种子按最新实体读写,所以排在升级之后。 ::: danger `DbInitializer` 表存在就跳过,从不补列 给既有实体加字段后,存量库只能靠升级补列:注册一个 `IDbSchemaUpgrader`(通常接到升级模块的 `IUpgradeEngine` 上跑版本化脚本),或者重建数据库。不补列就部署,种子一查就报「列不存在」。**框架不是迁移工具。** ::: ## 读写分离 `ConnectionConfigs[].SlaveConnectionConfigs` 填了从库后,SELECT 自动走从库、写与事务走主库。 ::: warning `HitRate` 绑不上 appsettings 它是 SqlSugar 的**字段**不是属性,配了也恒为 0。框架会把权重 0 的从库归一化为 `DefaultSlaveHitRate`(默认 10),所以不写也能等权分担读。需要差异化权重用代码钩子 `ConfigureConnectionConfigs`。 ::: ## 数据变更日志 `EnableDiffLog` **默认 `false`**——不开则 Diff AOP 不挂载,收集到的差异被直接丢弃。开启后每个写操作会多一次 SELECT 用于算差异,且只覆盖走仓储的写。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 部署后报「列不存在」 | 加了字段,存量库没有升级器补列(见上文 `IDbSchemaUpgrader`) | | 写操作报参数重名 | 仓储里显式调了 `.EnableQueryFilter()` | | 查到了别的租户数据 | 实体没继承 `SugarMultiTenant*` 系列 | | 变更日志空 | `EnableDiffLog` 没开 | | 时间列投影崩溃 | `DateTimeOffset` 别做单列标量投影,整行取实体 | ## 下一步 - [工作单元与事务](./uow):事务边界与提交时序 - [多租户](./multi-tenancy):租户解析与隔离 - [Data 包](../packages/data):完整配置项与 API --- 来源:https://framework.docs.xihanfun.com/guide/dependency-injection # 依赖注入 XiHan.Framework 直接使用 **.NET 内置的依赖注入容器**(`IServiceCollection` / `IServiceProvider`),没有另造一套。在此之上,它加了两样让日常开发更省心的东西:**约定式自动注册**和**选项模式封装**。 ## 两种注册方式 ### 方式一:约定式自动注册(推荐) 给你的服务实现一个**标记接口**,框架会自动扫描并按对应生命周期注册进容器——你**不用**写 `services.AddXxx`: ```csharp using XiHan.Framework.Core.DependencyInjection.ServiceLifetimes; // 瞬时:每次解析都新建 public class SmsSender : ISmsSender, ITransientDependency { } // 作用域:每个请求一个实例 public class OrderService : IOrderService, IScopedDependency { } // 单例:全应用一个实例 public class ConfigCache : IConfigCache, ISingletonDependency { } ``` | 标记接口 | 生命周期 | 等价于 | | --- | --- | --- | | `ITransientDependency` | Transient | `AddTransient` | | `IScopedDependency` | Scoped | `AddScoped` | | `ISingletonDependency` | Singleton | `AddSingleton` | 框架会自动把实现类注册为它实现的接口。**应用服务基类 `ApplicationServiceBase` 已经实现了 `ITransientDependency`**,所以你写的应用服务天然会被注册(见 [动态 API](./dynamic-api))。 ### 精细控制:`DependencyAttribute` 标记接口能覆盖大多数场景,但遇到下面这些情况,可以在类上叠加 `[Dependency(...)]` 特性做更精细的控制: ```csharp using Microsoft.Extensions.DependencyInjection; using XiHan.Framework.Core.DependencyInjection; // 显式指定生命周期,优先级高于标记接口推断出的结果 [Dependency(ServiceLifetime.Singleton)] public class ConfigCache : IConfigCache { } // 替换掉之前已注册的同接口实现(内部走 services.Replace) [Dependency(ServiceLifetime.Scoped, ReplaceServices = true)] public class OrderService : IOrderService, IScopedDependency { } // 已存在同接口注册时跳过,不重复添加(内部走 services.TryAdd) [Dependency(TryRegister = true)] public class DefaultCache : ICache, ISingletonDependency { } ``` - `Lifetime` 的优先级高于标记接口(`ITransientDependency` 等)推断出的生命周期; - `ReplaceServices = true` 时用 `services.Replace(...)` 覆盖同接口的既有注册; - `TryRegister = true` 时用 `services.TryAdd(...)`,同接口已注册就不再重复添加; - 想让某个类彻底跳过约定式扫描(比如打算完全手写注册),在类上加 `[DisableConventionalRegistration]` 即可。 ### 控制暴露的服务类型:`ExposeServicesAttribute` 默认情况下,约定式注册只把实现类暴露为"类名去掉前导 `I` 后同名"的接口(例如 `OrderService : IOrderService` 会被注册为 `IOrderService`)。想额外暴露成别的接口、暴露成自身类型,或者暴露成 .NET 的键控服务(Keyed Service),用: ```csharp using Microsoft.Extensions.DependencyInjection; using XiHan.Framework.Core.DependencyInjection; // 额外暴露为 INotifiable,同时保留默认推断出的接口 [ExposeServices(typeof(INotifiable), IncludeDefaults = true)] public class OrderService : IOrderService, INotifiable, IScopedDependency { } // 键控服务:同一接口按 key 注册多个实现 [ExposeKeyedService("alipay")] public class AlipayGateway : IPaymentGateway, ISingletonDependency { } ``` 消费键控服务时用 `[FromKeyedServices("alipay")]` 构造函数参数,或 `serviceProvider.GetKeyedService("alipay")`。 ### 方式二:手动注册 需要更精细的控制时,在模块的 `ConfigureServices` 里照常手写: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddSingleton(); context.Services.AddHttpClient(); } ``` `context.Services` 就是标准的 `IServiceCollection`,所有你熟悉的 .NET DI 扩展方法都能用。 > 有些领域服务(尤其是需要特定构造参数或工厂的)**必须手写注册**——约定式注册只覆盖能无参解析接口的常规场景。 ## 注入依赖 和标准 .NET 一样,构造函数注入: ```csharp public class OrderService : IOrderService, IScopedDependency { private readonly IRepositoryBase _orders; private readonly ISmsSender _sms; public OrderService(IRepositoryBase orders, ISmsSender sms) { _orders = orders; _sms = sms; } } ``` > 极少数无法走构造函数注入的场景(比如遗留类型的属性/字段),可以用 `[AutowiredService]` 标记,再配合 `AutowiredServiceHandler.Autowired(this)` 手动触发装配。这只是兜底手段,**日常开发请优先用构造函数注入**。 ## 选项模式 框架在 `XiHanModule` 上封装了 `Configure` / `PreConfigure` / `PostConfigure`,简化配置对象的绑定: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { // 直接配置 Configure(o => o.Retries = 3); // 从配置节绑定 Configure(context.Services .GetConfiguration() .GetSection("XiHan:MyModule")); } ``` 消费时注入 `IOptions` / `IOptionsMonitor`: ```csharp public class MyService(IOptions options) : ITransientDependency { private readonly MyOptions _options = options.Value; } ``` - `PreConfigure` —— 在正式配置**之前**预设值,供后续模块读取 - `PostConfigure` —— 在所有模块配置**之后**做最终覆盖 它们对应生命周期里的 `PreConfigureServices` / `PostConfigureServices`(见 [生命周期](./lifecycle))。 ## 小结 - 优先用**标记接口**自动注册,减少样板 - 生命周期/替换/跳过等精细控制交给 `[Dependency(...)]`,暴露类型/键控服务交给 `[ExposeServices]` / `[ExposeKeyedService]` - 需要更彻底的控制时在 `ConfigureServices` 里手写,或用 `[DisableConventionalRegistration]` 退出扫描 - 用 `Configure` 绑定选项,用 `IOptions` 消费 - 一切都建立在标准 .NET DI 之上,没有魔法黑箱 ## 下一步 - [动态 API](./dynamic-api):应用服务如何自动变成接口 - [模块系统](./modularity):模块如何被装配 --- 来源:https://framework.docs.xihanfun.com/guide/distributed-ids # 分布式 ID 主键、消息标识、对外短码各需要不同形状的唯一标识。这一章讲四种方案怎么选,以及雪花算法里那几个**上线后改不动**的配置。 完整 API 与全部配置项见 [DistributedIds 包](../packages/distributed-ids)。 ## 四种方案 | 方案 | 类型 | 产物 | 能否反解 | 需要节点配置 | | --- | --- | --- | --- | --- | | Snowflake | `SnowflakeIdGenerator` | `long`,时间有序 | 可提取时间 / 机器码 / 序列号 | **必须**,`WorkerId` 全局唯一 | | SequentialGuid | `SequentialGuidGenerator` | `Guid`,毫秒级有序 | 可提取时间 | 不需要 | | NanoId | `NanoIdGenerator` | 随机字符串 | 不可 | 不需要 | | Sqids | `SqidsEncoder` | 数字的短码 | 可解回原数字(同一实例内) | 不需要 | 前三个都实现 `IDistributedIdGenerator`(`TKey` 是 `long` 或 `Guid`),可以注入。Sqids 是**编码器不是生成器**——它不产生新 ID,只把你已有的数字换个样子。 选型走这条链: | 问题 | 选择 | | --- | --- | | 主键是 `long`,想按时间排序、想从 ID 反查生成时间 | Snowflake | | 主键是 `Guid`,只想让索引写入友好 | SequentialGuid | | 要一段猜不出来的短串(邀请码、分享链接、外部令牌串),不需要还原 | NanoId | | 已有自增/雪花数字 ID,只想对外藏住真实数值和数据量 | Sqids | ::: warning 只有两个生成器在 DI 里 `AddXiHanDistributedIds` 只注册 `IDistributedIdGenerator`(SequentialGuid)和 `IDistributedIdGenerator`(Snowflake)。 NanoId 和 Sqids 的 Options 虽然也绑定了配置节,但**没有任何服务消费它们**——要用得自己经 `IdGeneratorFactory` / `new SqidsEncoder(...)` 构造。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.DistributedIds ``` ```csharp [DependsOn(typeof(XiHanDistributedIdsModule))] public class MyModule : XiHanModule { } ``` 多数应用不用手写这行——`XiHanApplicationModule`、`XiHanDataModule`、`XiHanEventBusModule`、`XiHanWorkflowModule` 都已经 `DependsOn` 了它。 启用后拿到的两个默认生成器: | 注入类型 | 实现 | 选项来源 | | --- | --- | --- | | `IDistributedIdGenerator` | `SequentialGuidGenerator` | `XiHan:DistributedIds:SequentialGuid`,未配置时用 `SequentialAtEnd` | | `IDistributedIdGenerator` | `SnowflakeIdGenerator` | 基线(`WorkerIdBitLength=6`、`SeqBitLength=12`、`WorkerId=1`)再由 `XiHan:DistributedIds:SnowflakeId` 覆盖 | 基线只是「配置没写这个键时的取值」,写了的键真正生效。 ## 核心用法 ### 注入生成器 ```csharp public class OrderService( IDistributedIdGenerator idGenerator, IDistributedIdGenerator guidGenerator) { public long NewOrderNo() => idGenerator.NextId(); public Guid NewRowId() => guidGenerator.NextId(); public Task PreallocateAsync(int count) => idGenerator.NextIdsAsync(count); // 从雪花 ID 反查生成时间 public DateTime WhenCreated(long orderNo) => idGenerator.ExtractTime(orderNo); } ``` 同步方法之外还有 `NextIdAsync` / `NextIdStringAsync` / `NextIdsAsync` / `NextIdStringsAsync`,实现都是 `Task.FromResult` 包装同步逻辑——它们是为了签名统一,不会带来并发收益。 ### 实体主键自动填充 数据层已经接好了雪花生成器:SqlSugar 的 `DataExecuting` 事件在插入时调用 `TrySetSnowflakeId`,**四个条件同时满足**才会写入。 | 条件 | 不满足时 | | --- | --- | | 该列是主键 | 跳过 | | 该列不是数据库自增(`IsIdentity`) | 跳过,交给数据库 | | 属性类型是 `long` 或 `long?` | 跳过,`Guid` 主键不由这里填 | | 当前值是默认值(0 / null) | 跳过,尊重你手动赋的值 | 所以 `long` 主键实体直接 `InsertAsync` 就有 ID,不用手动 `NextId()`;`Guid` 主键需要自己注入 `IDistributedIdGenerator` 赋值。 ### 按场景构造 NanoId ```csharp // URL 安全字符集,长度 21 var nano = IdGeneratorFactory.CreateNanoIdGenerator_UrlSafe(21); string inviteCode = nano.NextIdString(); // 去掉易混字符(1/l/I、0/O/o 等),适合人工抄写 var readable = IdGeneratorFactory.CreateNanoIdGenerator_Safe(12); string pickupCode = readable.NextIdString(); ``` 工厂里还有 `_Numeric` / `_Lowercase` / `_Uppercase` / `_Hex` / `_Custom(alphabet, size)`。 ::: danger NanoId 的 `NextId()` 不是分布式唯一 `NextIdString()` 才是 NanoId——加密安全随机串。 `NextId()` 返回的 `long` 是 `(时间戳 << 22) | 进程内序列`,**不含任何节点标识**,多实例部署下不同进程会算出相同的值。别拿它当分布式主键。 ::: ### Sqids 短码 ```csharp var encoder = new SqidsEncoder(new SqidsOptions { MinLength = 8 }); string code = encoder.Encode(orderId); // 对外展示 long[] back = encoder.Decode(code); // 解回原值,失败返回空数组 // 一次编多个数字(例如 租户 + 主键) string packed = encoder.Encode(tenantId, orderId); ``` 也有便捷扩展方法 `1234L.ToSqid()` / `code.FromSqidToInt64()`,但它们走的是内部静态编码器(默认选项),**不读 `XiHan:DistributedIds:Sqids` 配置节**。 ## 关键机制 ### 雪花的位布局 64 位从高到低:时间戳 → 数据中心码 → 机器码 → 序列号。 | 段 | 位长来源 | 说明 | | --- | --- | --- | | 时间戳 | `TimestampType` 决定:`Milliseconds` 41 位(约 69 年)、`Seconds` 32 位(约 136 年) | 存的是「当前时间 − `BaseTime`」的偏移量 | | 数据中心码 | `DataCenterIdBitLength`,默认 5 | 传统雪花模式才参与反解 | | 机器码 | `WorkerIdBitLength`,默认 6(即 `WorkerId` 取值 0–63) | 必须全局唯一 | | 序列号 | `SeqBitLength`,默认基线 12 | 同一时间片内的自增计数 | 硬约束:`WorkerIdBitLength + SeqBitLength ≤ 22`,越界抛 `ArgumentException`。`WorkerIdBitLength` 范围 1–15,`SeqBitLength` 范围 3–21。 ### 单时间片的实际上限由 MaxSeqNumber 决定 这是最容易误判吞吐的地方。`SeqBitLength=12` 看上去每毫秒能发 4096 个,但生成器真正用的上限是 `MaxSeqNumber`(**默认 63**),只有把它显式配成 `0` 才会回落成 `2^SeqBitLength - 1`(负数会被选项校验拒绝)。 序列从 `MinSeqNumber`(默认 5)起步,到 `MaxSeqNumber` 为止——默认配置下单节点每毫秒约 59 个 ID。要放开就显式配 `MaxSeqNumber`,取值上限是 `2^SeqBitLength - 1`。 序列耗尽后的行为看 `LoopedSequence`: | 取值 | 行为 | | --- | --- | | `false`(默认) | 自旋等待下一个时间片,吞吐被限住但 ID 不重复 | | `true` | 序列直接绕回 `MinSeqNumber`,**时间片不变** | ::: danger LoopedSequence 会发出重复 ID 回绕时时间戳、机器码都没变,序列号又回到起点——同一毫秒内前后两个 ID 完全相同。除非你清楚自己在做什么,否则保持默认 `false`。 ::: ### 时钟回拨 | 算法 | `SnowflakeIdType` | 回拨时的行为 | | --- | --- | --- | | 雪花漂移(默认) | `SnowFlakeMethod` | `Thread.Sleep(5)` 后重试;回拨幅度超过 `MaxBackwardToleranceMs`(默认 10000)抛异常;重试次数超过 `TopOverCostCount`(默认 2000)抛异常 | | 传统雪花 | `ClassicSnowFlakeMethod` | 直接抛异常,不等待 | 两种模式都不会静默发出可能重复的 ID,代价是回拨期间调用方会阻塞或收到异常。生产环境把 NTP 校时配成缓步调整(slew),别让系统时间跳变。 ### 反解的适用范围 `ExtractTime` / `ExtractWorkerId` / `ExtractSequence` / `ExtractDataCenterId` 是接口的统一签名,但不是每个实现都有意义: | 生成器 | ExtractTime | ExtractWorkerId | ExtractSequence | ExtractDataCenterId | | --- | --- | --- | --- | --- | | Snowflake(漂移) | 有效 | 有效 | 有效 | **恒返回 0** | | Snowflake(传统) | 有效 | 有效 | 有效 | 有效 | | SequentialGuid | 有效 | 恒 0 | 恒 0 | 恒 0 | | NanoId | 对 `NextId()` 的 `long` 有效 | 恒 0 | 有效 | 恒 0 | 反解用的是**当前生成器实例的配置**去拆位。配置和生成时不一致,解出来就是错的时间,而且不会报错。 ### SequentialGuid 的三种排序模式 生成的 Guid = 6 字节毫秒时间戳 + 10 字节加密安全随机数,区别只在时间戳放哪儿、字节序怎么排。 | `SequentialGuidType` | 时间戳位置 | 适用 | | --- | --- | --- | | `SequentialAsString` | 前 6 字节,小端系统上前 4 字节与随后 2 字节各自反转 | 按字符串比较排序的场景 | | `SequentialAsBinary` | 前 6 字节,不反转 | 按二进制排序的数据库 | | `SequentialAtEnd` | 后 6 字节 | SQL Server 聚集索引,**框架默认** | ::: warning 有序性只到毫秒 同一毫秒内生成的多个 Guid 之间没有单调计数器,先后顺序由随机部分决定。它保证的是「批量插入落在相邻页」,不是「严格递增」。 另外 `ExtractTime` 按当前 `DefaultSequentialGuidType` 拆字节——排序模式改过之后,历史 Guid 的时间解不出来。 ::: ### Sqids 的短码不跨进程稳定 `SqidsEncoder` 构造时会用 `_options.GetHashCode()` 当种子洗牌字母表。`SqidsOptions` 没有重写 `GetHashCode`,拿到的是**对象引用哈希**——每个 encoder 实例、每次进程启动都不一样。 | 场景 | 是否可靠 | | --- | --- | | 同一个 encoder 实例内 `Encode` → `Decode` 往返 | 可靠 | | 进程内经扩展方法 `ToSqid()` / `FromSqidToInt64()` 往返(走同一个静态实例) | 可靠 | | 应用重启后解码重启前发出的短码 | **不可靠** | | 两个不同的 encoder 实例互相解码 | **不可靠** | ::: danger 不要把 Sqids 短码落库或写进长期链接 短码只适合「本次会话内生成、立刻使用」的一次性展示。需要长期稳定的对外标识,用 NanoId 生成一个真实存在的列,别指望从数字算出来。 ::: 另外两点:`Encode` 不接受负数(抛 `ArgumentException`);`Decode` 遇到字母表以外的字符**静默返回空数组**,扩展方法则返回 `0`——解码结果要自己判空。 ## 配置 四个配置节,各自独立: | Options | 配置节 | | --- | --- | | `SnowflakeIdOptions` | `XiHan:DistributedIds:SnowflakeId` | | `SequentialGuidOptions` | `XiHan:DistributedIds:SequentialGuid` | | `NanoIdOptions` | `XiHan:DistributedIds:NanoId` | | `SqidsOptions` | `XiHan:DistributedIds:Sqids` | 上线前必须想清楚的雪花键(其余键见[包文档](../packages/distributed-ids)): | 键 | 默认 | 上线后可改吗 | | --- | --- | --- | | `WorkerId` | 基线 1 | 可改,但每个实例必须互不相同 | | `WorkerIdBitLength` | 6 | **不可改**,改了位布局就变了 | | `SeqBitLength` | 基线 12 | **不可改**,同上 | | `BaseTime` | `2026-01-01 UTC` | **不可改** | | `TimestampType` | `Milliseconds` | **不可改**,41 位与 32 位布局不同 | | `SnowflakeIdType` | `SnowFlakeMethod` | **不可改**,两种算法位段划分不同 | | `MaxSeqNumber` | 63 | 可改,决定单时间片吞吐 | | `MaxBackwardToleranceMs` | 10000 | 可改 | ```json { "XiHan": { "DistributedIds": { "SnowflakeId": { "WorkerId": 1, "WorkerIdBitLength": 6, "SeqBitLength": 12, "MaxSeqNumber": 4095, "TimestampType": "Milliseconds" }, "SequentialGuid": { "DefaultSequentialGuidType": "SequentialAtEnd" } } } } ``` ### WorkerId 必须逐实例分配 ::: danger 这是最常见的重复 ID 来源 不配置 `WorkerId` 时,所有实例都拿基线值 `1`。两个副本在同一毫秒生成,序列号各自从 5 开始——**直接撞主键**。 容器/多副本部署必须给每个实例注入不同的值,例如环境变量: ```bash XiHan__DistributedIds__SnowflakeId__WorkerId=3 ``` 有状态副本(StatefulSet 序号)、注册中心分配、或按主机名映射都可以,关键是同一时刻集群内不重号,且实例重建后不要立刻把号让给别人。 ::: ::: warning 超过 64 个节点时别只靠配置绑定 `WorkerId` 的合法上限由**赋值那一刻**已生效的 `WorkerIdBitLength` 决定,而选项是「先落基线位长 6,再套配置」。所以在同一配置节里同时写大 `WorkerIdBitLength` 和大于 63 的 `WorkerId`,可能在绑定阶段就抛「工作机器唯一标识必须在 0-63 之间」。 需要更多节点时,用 `IdGeneratorFactory.CreateSnowflakeIdGenerator(options)` 自己构造选项对象(顺序自己控制)并 `Replace` 掉默认注册。 ::: ### BaseTime 与位长为什么不能改 雪花 ID 里存的不是绝对时间,是相对 `BaseTime` 的偏移量;每一段占多少位由 `WorkerIdBitLength` / `SeqBitLength` / `TimestampType` 决定。 | 改了什么 | 后果 | | --- | --- | | `BaseTime` 往后调 | 偏移量整体变小,新 ID 落回历史 ID 已用过的数值区间 | | `BaseTime` 往前调 | 新 ID 变大,暂时不撞,但所有历史 ID 的 `ExtractTime` 全部解错 | | 任一位长 / `TimestampType` / `SnowflakeIdType` | 位段边界移动,新旧 ID 之间既不保证有序也不保证不撞,反解全错 | 这几个键属于「建库时定好,之后当成常量」。生成器构造时只会校验 `BaseTime` 不晚于当前系统时间,不会替你发现「和历史数据对不上」。 ::: tip UseCustomEpoch 目前不起作用 `SnowflakeIdOptions.UseCustomEpoch` 这个属性存在,但生成器没有读取它——纪元起点始终取 `BaseTime`。配它没有效果。 ::: ### 替换默认生成器 模块用 `AddSingleton` 的工厂委托注册(不是 `TryAdd`),要换实现在自己模块里 `Replace`: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Replace(ServiceDescriptor.Singleton( IdGeneratorFactory.CreateSnowflakeIdGenerator_Classic(workerId: 3, dataCenterId: 1))); } ``` 自定义算法实现 `IDistributedIdGenerator` 后同样注册即可,接口与内置实现之间没有耦合。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 多实例部署出现重复主键 | `WorkerId` 没有逐实例分配,全都是基线值 1 | | 单节点吞吐上不去、插入偶发变慢 | `MaxSeqNumber` 默认 63,序列耗尽后自旋等下一毫秒;调大 `SeqBitLength` 不会自动放开这个上限 | | 开了 `LoopedSequence` 之后出现重复 ID | 序列回绕但不换时间片,见上文 | | 抛「时钟回拨太多」 | 回拨幅度超过 `MaxBackwardToleranceMs`;检查 NTP 是否在跳变式校时 | | 传统雪花模式下回拨直接抛异常 | `ClassicSnowFlakeMethod` 不做等待重试,这是预期行为 | | `ExtractDataCenterId` 恒为 0 | 漂移模式不反解数据中心,只有 `ClassicSnowFlakeMethod` 有意义 | | `ExtractTime` 解出的时间不对 | 当前配置的 `BaseTime` / 位长 / `TimestampType` 与生成时不一致 | | SequentialGuid 的 `ExtractTime` 解不出来 | `DefaultSequentialGuidType` 改过,字节位置对不上 | | 同一毫秒的 Guid 顺序不严格递增 | 毫秒内由随机部分决定,设计如此 | | NanoId 的 `NextId()` 跨实例撞了 | 那个 `long` 不含节点标识,只有 `NextIdString()` 是 NanoId | | 应用重启后旧的 Sqids 短码解不回来 | 字母表种子随 encoder 实例变化 | | 改了 `XiHan:DistributedIds:Sqids` 但 `ToSqid()` 没变化 | 扩展方法用内部静态编码器,走默认选项 | | `Decode` 返回空数组 / `FromSqidTo*` 返回 0 | 短码含字母表以外的字符,静默失败不抛异常 | | 注入 `IDistributedIdGenerator` 报未注册 | 模块没有 `DependsOn(typeof(XiHanDistributedIdsModule))` | | 注入 NanoId / Sqids 失败 | 它们不在 DI 里,只能自己构造 | ## 下一步 - [数据访问](./data):`long` 主键的自动填充发生在哪一层 - [配置与选项](./configuration):配置节绑定与环境变量覆盖规则 - [事件总线](../packages/eventbus):消息标识用的就是 `IDistributedIdGenerator` - [DistributedIds 包](../packages/distributed-ids):完整 API 清单、全部配置项、`GuidHelper` 工具箱 --- 来源:https://framework.docs.xihanfun.com/guide/dynamic-api # 动态 API 动态 API 是 XiHan.Framework 暴露接口的方式:**你写应用服务,框架把它自动变成 REST 接口**,不需要为每个服务再写一个 Controller。 ## 传统做法 vs 动态 API ```text 传统 ASP.NET Core XiHan 动态 API ┌───────────────────┐ ┌───────────────────┐ │ OrderController │ 样板代码 │ OrderAppService │ 没有 Controller │ ├ [HttpGet] │ ───────► │ ├ GetAsync() │ 方法即接口 │ ├ [HttpPost] │ │ ├ CreateAsync() │ │ └ 手动调用 Service│ │ └ ... │ └───────────────────┘ └───────────────────┘ │ │ Service(真正逻辑) [DynamicApi] 自动生成路由 ``` ## 写一个动态 API 两步:**继承应用服务基类** + **打上 `[DynamicApi]`**。 ```csharp using XiHan.Framework.Application.Attributes; using XiHan.Framework.Application.Services; [DynamicApi] public class OrderAppService : ApplicationServiceBase { public Task GetAsync(long id) { /* ... */ } public Task CreateAsync(CreateOrderDto input) { /* ... */ } } ``` 框架启动时,动态 API 约定会扫描到这个类,为它的每个公共方法生成对应的 HTTP 端点,并纳入 [Web.Docs](../packages/web-docs) 的 Scalar / Swagger 文档。 > `ApplicationServiceBase` 实现了 `IApplicationService` 与 `ITransientDependency`,所以应用服务**同时**被自动注册进 DI 并被动态 API 识别。 ## CRUD 基类:一套增删改查开箱即用 对标准的实体增删改查,框架提供了 `CrudApplicationServiceBase`,泛型指定实体、DTO、主键类型即可获得完整 CRUD: ```csharp [DynamicApi] public class ProductAppService : CrudApplicationServiceBase { public ProductAppService(IRepositoryBase repository) : base(repository) { } } ``` 它内置了 `GetByIdAsync`、分页查询、创建、更新、删除等方法,全部自动暴露为接口,还负责实体与 DTO 之间的映射。需要自定义时重写对应方法即可。 ## 路由与文档的定制 `[DynamicApi]` 提供了丰富的定制项(类级与方法级都可标注,方法级优先): | 属性 | 作用 | | --- | --- | | `IsEnabled` | 是否启用(任一层级为 `false` 即禁用) | | `RouteTemplate` | 自定义路由模板(类级替换控制器路由,方法级替换动作路由段) | | `Name` | API 名称(类级定制控制器名,方法级定制动作名;同简名服务冲突时用类级 `Name` 改名) | | `Version` | API 版本(可叠加多个,写纯数字不带 `v` 前缀——路由拼接时框架会自动加上) | | `Group` / `GroupName` | 文档分组键 / 分组显示名 | | `Tag` | API 标签(可叠加) | | `Description` | API 描述 | | `UsePascalCaseRoutes` / `UseLowercaseRoute` | 路由大小写风格 | | `PreserveRoutePredicate` | 是否保留 Get/Create/Delete 等动词前缀 | | `VisibleInApiExplorer` | 是否在 API 浏览器/文档中显示(任一层级为 `false` 即隐藏) | | `Order` | 同层级多个特性的合并顺序(数值越大越晚应用、优先级越高) | | `CustomProperties` | 自定义键值属性,`key=value` 形式,可叠加 | ```csharp [DynamicApi(Group = "order", GroupName = "订单管理", Version = "1")] public class OrderAppService : ApplicationServiceBase { } ``` **合并优先级**:全局配置 < 类级特性 < 方法级特性;同一层级按 `Order` 升序合并(后写入覆盖先写入)。 ## 它跑在标准 MVC 管道上 动态 API 生成的是**真正的 MVC 控制器**,因此完全兼容 ASP.NET Core 的一切:过滤器、模型绑定、`[Authorize]`、认证/授权中间件等。请求会经过 [Web.Api 的完整中间件管道](./lifecycle#真实示例-web-api-的中间件管道)(TraceId → 请求文化 → 认证 → 租户解析 → 授权 → 端点)。 > 如果某个场景确实需要手写传统 Controller,也完全可以——`Web.Api` 的 `MapControllers()` 会一并识别特性路由的控制器。两种方式可以共存。 ## 小结 - 写**应用服务**,不写 Controller - `ApplicationServiceBase` + `[DynamicApi]` = 自动注册 + 自动暴露 + 自动进文档 - 标准 CRUD 用 `CrudApplicationServiceBase` 一步到位 - 路由、分组、版本通过 `[DynamicApi]` 的属性灵活定制 ## 下一步 - [Web.Api 模块](../packages/web-api):动态 API 的实现细节与中间件管道 - [Web.Docs 模块](../packages/web-docs):Scalar / Swagger 文档 - [Application 模块](../packages/application):应用服务与 CRUD 基类 --- 来源:https://framework.docs.xihanfun.com/guide/event-bus # 事件总线 用事件把「一件事发生了」和「谁来响应」拆开:发布方不认识订阅方,订阅方随时可增可减。这一章讲清楚两件最容易出错的事——处理器怎样才算真的订阅上了,以及事件到底在哪个时刻发出去。 完整 API 清单与全部配置项见 [EventBus 包](../packages/eventbus)。 ## 本地事件还是分布式事件 框架提供两条独立的总线,接口不同、发布时机不同,选错会直接影响事务正确性。 | | 本地事件 | 分布式事件 | | --- | --- | --- | | 发布接口 | `ILocalEventBus` | `IDistributedEventBus` | | 处理器接口 | `ILocalEventHandler` | `IDistributedEventHandler` | | 传播范围 | 当前进程 | 取决于 Broker,默认仍在当前进程 | | 在工作单元中的发布时机 | **事务提交之前** | **事务提交之后** | | 默认投递路径 | 直接触发处理器 | 先落发件箱,后台异步投递 | | 处理器抛异常的后果 | 事务回滚 | 处理器在收件箱后台跑,异常回不到调用方:重试到 `MaxInboxRetryCount` 后丢弃 | | 典型用途 | 同进程解耦,处理器还要继续写库 | 通知另一个服务,可接受最终一致 | 选择很简单:**消费方在同一个进程里就用本地事件**,只有跨进程/跨服务才用分布式事件。分布式事件是异步的,调用返回时处理器多半还没跑。 ::: tip 不装消息中间件也能用分布式事件 不引入任何 Broker 包时,`IDistributedEventBus` 的实现是 `LocalDistributedEventBus`:它照样走发件箱、收件箱和事件名映射,但最终把事件交回本地事件总线,事件不出进程。这让你可以先按分布式语义写代码,以后再接中间件。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.EventBus ``` ```csharp [DependsOn(typeof(XiHanEventBusModule))] public class MyModule : XiHanModule; ``` `XiHanEventBusModule` 在 `PreConfigureServices` 里调用 `services.AddXiHanEventBus(configuration)`,启用后容器里有: | 服务 | 默认实现 | 说明 | | --- | --- | --- | | `ILocalEventBus` | `LocalEventBus` | 单例 | | `IDistributedEventBus` | `LocalDistributedEventBus` | 单例,`TryRegister`,可被 Broker 实现替换 | | `IEventOutbox` / `IEventInbox` | `DefaultEventOutbox` / `DefaultEventInbox` | 单例,`TryAddSingleton` | | `IUnitOfWorkEventPublisher` | `UnitOfWorkEventPublisher` | `ReplaceServices`,负责在工作单元完成后发事件 | | `IHostedService` | `EventBoxOutboxSenderHostedService`、`EventBoxInboxProcessorHostedService` | 发件箱发送与收件箱处理的轮询循环 | 同时会创建一个名为 `Default` 的发件箱配置和一个名为 `Default` 的收件箱配置,`DatabaseName` 均为 `"Default"`,默认都处于启用状态。 ## 定义事件与处理器 事件就是一个普通类,只放数据: ```csharp public class OrderCreatedEvent { public long OrderId { get; set; } public long TenantId { get; set; } } ``` 处理器实现对应接口,方法名固定为 `HandleEventAsync`: ```csharp public class OrderCreatedNotifier : ILocalEventHandler, ITransientDependency { private readonly ILogger _logger; public OrderCreatedNotifier(ILogger logger) { _logger = logger; } public Task HandleEventAsync(OrderCreatedEvent eventData) { _logger.LogInformation("订单 {OrderId} 已创建", eventData.OrderId); return Task.CompletedTask; } } ``` 发布方注入 `ILocalEventBus`: ```csharp public class OrderAppService : ApplicationServiceBase { private readonly ILocalEventBus _localEventBus; private readonly IRepositoryBase _orders; public OrderAppService(ILocalEventBus localEventBus, IRepositoryBase orders) { _localEventBus = localEventBus; _orders = orders; } [UnitOfWork(true)] public async Task CreateAsync(OrderCreateDto input) { var order = await _orders.AddAsync(input.ToEntity()); // onUnitOfWorkComplete 默认 true:先记进工作单元,提交时才真正发布 await _localEventBus.PublishAsync(new OrderCreatedEvent { OrderId = order.Id }); } } ``` ## 处理器必须登记进 Handlers ::: danger 这是本章最重要的一条 运行期的订阅**只有一个来源**:`LocalEventBus` 构造时对 `XiHanLocalEventBusOptions.Handlers` 逐个订阅,`LocalDistributedEventBus`(以及各 Broker 实现的基类 `BrokerDistributedEventBusBase`)构造时对 `XiHanDistributedEventBusOptions.Handlers` 逐个订阅。 **没进 `Handlers` 的处理器不会被订阅,事件发出去没人接,并且不会报任何错。** ::: `AddXiHanEventBus` 里确实注册了一个自动收集钩子(`services.OnRegistered`),但它的覆盖面很窄: - 这个钩子只在应用引入了 `XiHanCastleModule` 时才会被执行(动态代理扫描阶段); - 扫描时**跳过服务类型不是接口的注册**,而约定注册默认只把「名字是类名后缀的接口」暴露成服务类型——`ILocalEventHandler` 只有在处理器类名以 `LocalEventHandler` 结尾时才会被暴露。 所以别指望自动发现,**显式登记**: ```csharp public static IServiceCollection AddLocalEventHandler(this IServiceCollection services) where THandler : class, IEventHandler { services.AddTransient(); services.Configure(options => options.Handlers.TryAdd()); return services; } // 分布式处理器同理,换成 XiHanDistributedEventBusOptions public static IServiceCollection AddDistributedEventHandler(this IServiceCollection services) where THandler : class, IEventHandler { services.AddTransient(); services.Configure(options => options.Handlers.TryAdd()); return services; } ``` 模块里成批登记: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddLocalEventHandler(); context.Services.AddLocalEventHandler(); context.Services.AddDistributedEventHandler(); } ``` ::: warning 处理器类本身必须能从容器解析 订阅时用的是 `IocEventHandlerFactory`:每次触发都新建一个服务作用域,按**具体处理器类型**从容器解析实例。类型没注册就抛 `InvalidOperationException`,提示无法从 IoC 容器解析事件处理器。用 `Transient` 生命周期,避免并发复用同一实例。 ::: ## 发布时机与事件顺序 `PublishAsync` 并不总是立刻触发处理器: | 场景 | 行为 | | --- | --- | | 有当前工作单元 + `onUnitOfWorkComplete: true`(默认) | 事件连同一个 `EventOrder` 序号记进工作单元,等工作单元完成时发布;工作单元回滚则不发 | | 有当前工作单元 + `onUnitOfWorkComplete: false` | 立即走发布路径 | | 没有当前工作单元 | 立即走发布路径,`onUnitOfWorkComplete` 不起作用 | 工作单元完成时的动作是有先后的: ```text 1. SaveChanges 2. 发布本地事件 ← 提交之前(处理器的写入落在同一事务里) 3. 提交事务 4. 发布分布式事件 ← 提交之后(避免事务回滚了事件照发) ``` **EventOrder** 是记进工作单元时由 `EventOrderGenerator.GetNext()` 取的进程内自增序号。工作单元完成时,本地事件与分布式事件**各自**按 `EventOrder` 升序发布: - 同一批里的相对顺序 = 你调用 `PublishAsync` 的顺序; - 本地事件和分布式事件是两批,分别排序,不会互相交错; - 本地事件的处理器如果又发布了新事件,会在同一个循环里继续发布并再次 `SaveChanges`,直到没有新事件为止。 ::: warning 两类事件的失败后果完全不同 本地事件在提交前发布,处理器抛异常会让工作单元回滚并把异常上抛。分布式事件在提交后发布,此时事务已经落库,投递失败只会把异常抛给调用方——数据不会跟着回滚。 ::: ## 处理器执行顺序 同一个事件的多个本地处理器,按 `LocalEventHandlerOrderAttribute` 的 `Order` 升序执行: ```csharp [LocalEventHandlerOrder(1)] public class OrderCreatedStockReserver : ILocalEventHandler { /* … */ } [LocalEventHandlerOrder(2)] public class OrderCreatedNotifier : ILocalEventHandler { /* … */ } ``` - 未标注视为 `0`; - `Order` 相同的处理器之间不保证稳定顺序; - 每个处理器的异常都会被单独捕获收集,**所有处理器都会被执行完**,然后统一抛出:一个异常按原样重抛,多个则包成 `AggregateException`。 别把「顺序」当成事务:需要严格串行且失败即止的步骤,写在一个方法里,不要拆成多个处理器。 ## 事件继承与租户上下文 **继承会被触发**:发布子类型时,订阅了其父类型或接口的处理器同样会被调用(判定用 `IsAssignableFrom`)。可以借此订阅一个事件基类统一处理,也要小心别意外命中。 **租户上下文按事件数据决定**:触发处理器前会切换到事件数据携带的租户——事件实现 `IMultiTenant`、实现 `IEventDataMayHaveTenantId`,或者干脆有一个 `long` / `string` 类型的 `TenantId` 属性,都能被识别;三者都没有时沿用当前租户。 ::: warning 异步路径上没有请求上下文 发件箱、收件箱和 Broker 消费都在后台线程里跑,没有 HTTP 请求带来的租户信息。跨租户场景**必须把 TenantId 放进事件数据本身**,否则处理器拿到的是后台线程的默认租户。 ::: ## 分布式事件的投递链路 `IDistributedEventBus.PublishAsync` 比本地多一个 `useOutbox` 参数,默认 `true`。默认实现(`LocalDistributedEventBus`)的完整链路: ```text PublishAsync(useOutbox: true) → 有工作单元? 记进工作单元,提交后再继续 → 写入发件箱(IEventOutbox.EnqueueAsync) → EventBoxOutboxSenderHostedService 轮询取出,投递 → 写入收件箱(按 messageId 去重) → EventBoxInboxProcessorHostedService 轮询取出,触发处理器 ``` ::: warning 发件箱只在有工作单元时生效 写发件箱要从当前工作单元的服务提供器里取实现,所以**没有当前工作单元时,`useOutbox: true` 会静默退化为直接投递**。想让事件走可靠投递,发布点必须处在工作单元内。 ::: 收件箱负责幂等与重试: | 环节 | 行为 | | --- | --- | | 去重 | 入队前按 `messageId` 查 `ExistsByMessageIdAsync`,重复消息直接视为已处理 | | 成功 | `MarkAsProcessedAsync` | | 失败 | `RetryLaterAsync`,延迟 `InboxRetryDelaySeconds` 秒后再来 | | 连续失败达到 `MaxInboxRetryCount` | `MarkAsDiscardAsync` 丢弃并记警告日志 | | 清理 | 每轮末尾 `DeleteOldEventsAsync`;内存实现只清理非等待中、且最后修改时间超过 7 天的记录 | ::: danger 默认事件盒在内存里 `DefaultEventOutbox` / `DefaultEventInbox` 用 `ConcurrentDictionary` 存储,各最多 100000 条,进程一停全部丢失,重试计数也只在当前进程内累计。发件箱满载时发布抛 `InvalidOperationException`;收件箱满载时先清理 7 天前的已处理 / 已丢弃记录,仍满则抛出。开发和单机够用,要真正的「不丢」必须换成持久化实现:把自己的 `IEventOutbox` / `IEventInbox` 注册进容器,并把 `ImplementationType` 指过去。 ::: ```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); }); }); ``` 无参的 `Configure` 配的是名为 `Default` 的那一个;用 `Configure("名字", ...)` 可以配多个命名事件盒,再用 `Selector` / `EventSelector` 把不同事件路由到不同事件盒。 ## 事件命名 分布式事件靠**事件名**跨进程传递,接收端按事件名反查类型再反序列化。 ```csharp [EventName("MyApp.Orders.OrderShipped")] public class OrderShippedEvent { public long OrderId { get; set; } } ``` - 不标注时事件名回退为 `Type.FullName`——重命名类或挪动命名空间就会破坏跨服务契约,所以**跨服务的事件一律显式标注**; - 泛型事件用 `GenericEventNameAttribute`,按其唯一泛型参数生成名字,可配 `Prefix` / `Postfix`;泛型参数不止一个时抛 `XiHanException`; - 事件名到类型的映射来自订阅登记和发件箱写入,**本实例没有订阅者的事件名,收到消息也会被直接忽略**。 ## 三个 Broker 怎么选 装上对应包并 `[DependsOn]` 对应模块,`IDistributedEventBus` 的实现就被替换掉,业务代码一行不改。 | | RabbitMQ | Kafka | Redis Streams | | --- | --- | --- | --- | | 模块类 | `XiHanRabbitMQEventBusModule` | `XiHanKafkaEventBusModule` | `XiHanRedisEventBusModule` | | 配置节 | `XiHan:EventBus:RabbitMQ` | `XiHan:EventBus:Kafka` | `XiHan:EventBus:Redis` | | 消息模型 | direct 交换机 + 事件名做路由键,持久化消息 | 单主题 + 事件名做消息 Key | 单 Stream + 消费者组 | | 竞争消费单位 | 队列 `QueueName` | 消费者组 `GroupId` | 消费者组 `ConsumerGroup` | | 位点/确认 | 手动 Ack | `EnableAutoCommit = false`,手动提交 | 处理成功才 `XACK` | | 失败处理 | Nack 且不重回队列,重试交给收件箱 | 不提交位点 | 滞留消息按 `ClaimMinIdleMilliseconds` 被其他消费者接管,投递次数超过 `MaxDeliveryCount` 转入死信 Stream | | 额外依赖 | RabbitMQ | Kafka 集群 | 已有 Redis 即可 | 选型建议: - **单进程 / 开发环境**:什么都不装,`LocalDistributedEventBus` 就够; - **已经有 Redis、事件量不大**:选 Redis Streams,不引入新中间件,且自带死信和滞留接管; - **需要成熟的路由、死信队列和运维台**:选 RabbitMQ; - **高吞吐、要保留事件历史并支持重放**:选 Kafka。 ::: warning 处理器要在初始化前登记 Broker 模块在 `OnApplicationInitializationAsync` 阶段调用 `InitializeAsync` 建连接、起消费者。RabbitMQ 在这一步按**当时已登记的事件名**绑定队列路由键——处理器没在服务配置阶段进 `Handlers`,这个事件的消息连收都收不到。这是「必须显式登记」的第二个后果。 ::: ## 观测分布式事件 每次真正投递或收到分布式事件时,总线会额外发一条**本地事件**:`DistributedEventSent` / `DistributedEventReceived`,带 `Source`(`Direct` / `Outbox` / `Inbox`)、`EventName`、`EventData`。做统一日志、审计或指标时订阅它们即可,不必改每个业务处理器: ```csharp public class DistributedEventLogger : ILocalEventHandler, ITransientDependency { public Task HandleEventAsync(DistributedEventSent eventData) { // eventData.Source / eventData.EventName return Task.CompletedTask; } } ``` 这两条事件以 `onUnitOfWorkComplete: false` 发布,且触发过程中的异常会被吞掉,不影响主链路——反过来说,这里的处理器出错不会有任何提示。 ## 配置 事件盒后台处理走配置节 `XiHan:EventBus:EventBoxes`: | 配置项 | 默认值 | 含义 | | --- | --- | --- | | `PollingIntervalMilliseconds` | `2000` | 两个后台循环的轮询间隔,实际下限 200ms | | `OutboxBatchSize` | `100` | 发件箱单批取数 | | `InboxBatchSize` | `100` | 收件箱单批取数 | | `MaxInboxRetryCount` | `5` | 收件箱最大重试次数,达到即丢弃 | | `InboxRetryDelaySeconds` | `10` | 收件箱重试延迟秒数 | ```json { "XiHan": { "EventBus": { "EventBoxes": { "PollingIntervalMilliseconds": 2000, "OutboxBatchSize": 100, "InboxBatchSize": 100, "MaxInboxRetryCount": 5, "InboxRetryDelaySeconds": 10 } } } } ``` 事件盒实现、事件路由选择器、处理器选择器不走配置文件,用代码 `Configure` 设置。各 Broker 的配置项见对应包文档。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 事件发出去没人处理,也不报错 | 处理器没进 `Handlers`;显式 `Configure` 登记 | | 解析处理器时抛「无法从 IoC 容器解析事件处理器」 | 处理器进了 `Handlers` 但具体类型没注册进容器 | | 工作单元回滚了事件却发了 | 发布时传了 `onUnitOfWorkComplete: false`,或者当时根本没有工作单元 | | 分布式事件迟迟不到 | 走发件箱本就是异步,间隔为 `PollingIntervalMilliseconds`;默认配置还要再过一次收件箱轮询 | | `useOutbox: true` 但发件箱里查不到记录 | 发布点没有当前工作单元,静默退化成了直接投递 | | 进程重启后未投递的事件消失 | 默认事件盒是内存实现,换持久化实现 | | 换了 Broker 后某个事件收不到 | 处理器没在初始化前登记(RabbitMQ 按已登记事件名绑队列),或两端事件名不一致 | | 处理器里租户不对 | 事件数据没带 `TenantId`,后台线程拿不到请求上下文 | | 收件箱事件被丢弃 | 连续失败达到 `MaxInboxRetryCount`,已 `MarkAsDiscardAsync`,看警告日志 | | 一个处理器出错,整个发布调用抛异常 | 设计如此:所有处理器都会执行完,异常统一抛出(多个时为 `AggregateException`) | ## 下一步 - [工作单元与事务](./uow):本地/分布式事件的发布时序就定在这里 - [多租户](./multi-tenancy):处理器里的租户上下文从哪来 - [定时任务与后台作业](../packages/tasks):不需要「发生了什么」语义时的另一种异步 - [EventBus 包](../packages/eventbus):完整 API 与全部类型清单 - [EventBus.RabbitMQ](../packages/eventbus-rabbitmq)、[EventBus.Kafka](../packages/eventbus-kafka)、[EventBus.Redis](../packages/eventbus-redis):各 Broker 的完整配置项 --- 来源:https://framework.docs.xihanfun.com/guide/extending # 扩展与二次开发 本页讲**怎么在 XiHan.Framework 上写自己的东西**:新建一个模块、替换框架的默认实现、加自定义 AOP 拦截器、定制动态 API 约定。所有约定都对照框架源码核实,照抄即可。 > 先读 [模块系统](./modularity)(`[DependsOn]` 与拓扑排序)、[生命周期](./lifecycle)(七个钩子)、[依赖注入](./dependency-injection)(约定注册与选项模式)三页打底,本页只讲**怎么落地**。 ## 先决定扩展粒度 | 你想做的事 | 用哪个配方 | | --- | --- | | 给应用加一块自成体系的能力(自己的服务 + 配置 + 中间件) | [配方 A:写一个模块](#配方-a-写一个自己的模块) | | 框架某个默认实现不满足需求(存储、配置源、权限判定器…) | [配方 B:替换默认实现](#配方-b-替换框架的默认实现) | | 想给一批方法加统一的横切逻辑(事务、缓存、审计之外的) | [配方 C:自定义 AOP 拦截器](#配方-c-自定义-aop-拦截器) | | 想改接口路由的推导规则(前缀、谓词、版本段) | [配方 D:定制动态 API 约定](#配方-d-定制动态-api-约定) | | 想做一个能发 NuGet 的通用框架包 | [配方 E:发一个框架级包](#配方-e-发一个框架级包) | --- ## 配方 A:写一个自己的模块 **模块 = 一个继承 `XiHanModule` 的类**。它声明自己依赖谁、注册自己的服务、在生命周期节点接中间件。这是扩展框架的默认姿势。 ### 1. 建工程、装依赖 ```bash dotnet new classlib -n MyCompany.Billing cd MyCompany.Billing dotnet add package XiHan.Framework.Application # 应用服务基类 + 动态 API 特性 dotnet add package XiHan.Framework.Data # 用到数据访问时 ``` 只装你真的要用的包——框架是按模块安装的,传递依赖会自动带上底层库。 ### 2. 写模块类 ```csharp using Microsoft.Extensions.DependencyInjection; using XiHan.Framework.Core.Extensions.DependencyInjection; using XiHan.Framework.Core.Modularity; using XiHan.Framework.Data; namespace MyCompany.Billing; [DependsOn( typeof(XiHanDataModule) // 声明依赖:框架会保证它先于本模块装配 )] public class MyCompanyBillingModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { var services = context.Services; var configuration = services.GetConfiguration(); // 绑定自己的配置节 Configure(configuration.GetSection(BillingOptions.SectionName)); // 注册自己的服务(把接线集中到扩展方法里,见下) services.AddBillingDomainServices(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.UseMiddleware(); } } ``` 要点: - **`[DependsOn]` 只写直接依赖**。间接依赖由被依赖模块自己声明,框架按依赖图拓扑排序,不需要你重复列举。 - 钩子选择:注册服务用 `ConfigureServices`;接中间件、映射端点用 `OnApplicationInitialization`;需要在管道最前面插东西(如某些 Webhook 校验)用 `OnPreApplicationInitialization`;要等一切就绪后再做的事(如把数据库里的任务同步进调度器)用 `OnPostApplicationInitialization`。 - 拿 `IApplicationBuilder` / `IConfiguration` / `IWebHostEnvironment` 的扩展方法(`GetApplicationBuilder()` 等)来自 `XiHan.Framework.Web.Core`,纯类库模块里没有。 ### 3. 服务接线集中到扩展方法 框架内与 BasicApp 都遵循同一个约定:**模块类只调用扩展方法,具体注册写在 `Extensions/ServiceCollectionExtensions.cs`**。这样模块类始终一目了然,接线细节可独立测试与复用。 ```csharp public static class ServiceCollectionExtensions { public static IServiceCollection AddBillingDomainServices(this IServiceCollection services) { // 领域服务接口不带 DI 标记接口,必须显式登记 services.AddScoped(); return services; } } ``` ::: warning 什么该手写、什么会自动注册 - **自动注册**:实现 `ITransientDependency` / `IScopedDependency` / `ISingletonDependency` 标记接口的类;应用服务(`ApplicationServiceBase` 已实现 `ITransientDependency`);仓储基类。 - **必须手写**:**领域服务**——它们的接口通常不带任何 DI 标记接口,约定注册扫不到,漏了就是运行期 DI 解析异常。这是新增纵切片最常见的漏接线点。 ::: ### 4. 选项与配置节命名 约定是把配置节名钉成常量放在 Options 类里,代码里一律引用常量而非内联字符串: ```csharp public class BillingOptions { public const string SectionName = "MyCompany:Billing"; public int InvoiceRetentionDays { get; set; } = 365; } ``` 框架自身的配置节统一在 `XiHan:` 命名空间下(`XiHan:Data:SqlSugarCore`、`XiHan:Tasks:ScheduledJobs`、`XiHan:Web:Api:OpenApiSecurity` …),你的业务包建议用自己的顶层命名空间,避免和框架撞节。 消费时注入 `IOptions`;需要热更新用 `IOptionsMonitor`。`XiHanModule` 上的 `PreConfigure` / `Configure` / `PostConfigure` 分别对应「先于其他模块预设」「常规配置」「所有模块配置完后最终覆盖」。 ### 5. 挂到宿主 在应用的启动模块上加一行 `[DependsOn]` 即可: ```csharp [DependsOn( typeof(XiHanWebApiModule), typeof(MyCompanyBillingModule) // ← 新模块 )] public class MyAppModule : XiHanModule { } ``` **不要**在宿主里重复调用 `services.AddBilling(...)`——模块自己的 `ConfigureServices` 会被框架在依赖图里调用一次,重复调用只会重复注册。 ### 6. 暴露 REST 接口 不用写 Controller。应用服务打 `[DynamicApi]` 就是接口: ```csharp using XiHan.Framework.Application.Attributes; using XiHan.Framework.Application.Services; [DynamicApi(Group = "MyCompany.Billing", GroupName = "计费服务", Tag = "发票")] public class InvoiceAppService(IInvoiceDomainService invoices) : ApplicationServiceBase { public Task GetInvoiceDetailAsync(long id) => invoices.GetAsync(id); // → GET /api/Invoice/InvoiceDetail?id=... public Task CreateInvoiceAsync(InvoiceCreateDto input) => invoices.CreateAsync(input); // → POST /api/Invoice/Invoice } ``` 路由推导规则(动词前缀剥离、默认 POST、路由段只由 `[FromRoute]` 产生)见 [动态 API](./dynamic-api)。 --- ## 配方 B:替换框架的默认实现 框架的可替换点几乎都遵循同一个模式:**接口 + `TryAdd` 注册的默认实现**。常见的有 `IJobStore`、`IBackgroundJobStore`、`IPermissionChecker`、`IUserStore`、`IAiProviderConfigStore`、各 Bot 的 `*ConfigStore` 等。 ::: danger 必须用 `Replace`,不能用 `TryAdd` 框架模块**先于**你的模块注册了默认实现(`TryAdd` 语义)。你再 `TryAdd` / `AddSingleton` 一个同接口实现,`TryAdd` 会被**静默忽略**,你的实现永不生效,且没有任何异常和日志。 ::: ```csharp using Microsoft.Extensions.DependencyInjection.Extensions; public static IServiceCollection AddBillingStores(this IServiceCollection services) { // ✅ 覆盖框架默认:Replace services.Replace(ServiceDescriptor.Singleton()); // ❌ 会被静默忽略 // services.TryAddSingleton(); return services; } ``` 也可以用特性走约定注册: ```csharp [Dependency(ServiceLifetime.Scoped, ReplaceServices = true)] public class DbPermissionChecker : IPermissionChecker { } ``` 替换时**生命周期要和被替换者一致**——把 Singleton 的默认实现换成 Scoped 会在解析时抛作用域校验异常。 --- ## 配方 C:自定义 AOP 拦截器 框架的 `[UnitOfWork]`、`[Cacheable]`、审计等横切能力都建立在同一套机制上:**Castle 动态代理 + `IXiHanInterceptor`**。你的拦截器接同一套。 ### 1. 写拦截器 ```csharp using XiHan.Framework.Core.DynamicProxy; public class BillingAuditInterceptor(ILogger logger) : IXiHanInterceptor { public async Task InterceptAsync(IXiHanMethodInvocation invocation) { var sw = Stopwatch.StartNew(); await invocation.ProceedAsync(); // ← 必须调用,否则目标方法不会执行 logger.LogInformation("{Method} 耗时 {Ms}ms", invocation.Method.Name, sw.ElapsedMilliseconds); } } ``` ### 2. 写注册器:决定哪些服务要被拦截 ```csharp using XiHan.Framework.Core.DependencyInjection; using XiHan.Framework.Core.DynamicProxy; public static class BillingAuditInterceptorRegistrar { public static void RegisterIfNeeded(IOnServiceRegistredContext context) { if (!DynamicProxyIgnoreTypes.Contains(context.ImplementationType) && context.ImplementationType.IsDefined(typeof(BillingAuditAttribute), inherit: true)) { context.Interceptors.TryAdd(); } } } ``` ### 3. 在模块里挂上 ```csharp public override void PreConfigureServices(ServiceConfigurationContext context) { context.Services.OnRegistered(BillingAuditInterceptorRegistrar.RegisterIfNeeded); } ``` `OnRegistered` 登记的回调会在每个服务描述器上跑一遍;只要回调往 `context.Interceptors` 里塞了拦截器,该服务就会被包成代理。框架自己的 `XiHanUowModule`、`XiHanCachingModule` 用的就是这个 API。 ::: warning 拦截只对「以接口为服务类型」的注册生效 `AddCastleDynamicProxy` 只处理 `ServiceType.IsInterface` 为真的描述器(用的是 `CreateInterfaceProxyWithTarget`)。把类注册为它自己(`services.AddScoped()`)**不会**被拦截——`[UnitOfWork]` / `[Cacheable]` 都会静默失效。要用 AOP 就走「接口 + 实现」。 ::: ::: tip 拿到未被代理的真实实例 匿名端点等没有 UoW 中间件的场景里,调用被代理的服务会让拦截器急切开事务而死锁。用 `ProxyHelper.UnProxy(service)` 取出真实目标实例即可绕开。 ::: --- ## 配方 D:定制动态 API 约定 动态 API 的选项**不走配置文件**,是一个代码方式配置的单例:`XiHanWebApiModule` 在装配时 `TryAddSingleton` 了一个 `DynamicApiOptions` 实例并把它交给约定实现持有。你的模块(依赖它,因而在它之后执行)在 `ConfigureServices` 里就地改这个实例即可: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.ConfigureDynamicApiConventions(conventions => { conventions.HttpMethodConventions["Import"] = "POST"; conventions.HttpMethodConventions["Export"] = "POST"; }); context.Services.ConfigureDynamicApiRoutes(routes => { routes.UseModuleNameAsRoute = true; }); } ``` 常用旋钮: | 选项 | 默认值 | 作用 | | --- | --- | --- | | `IsEnabled` | `true` | 动态 API 总开关 | | `DefaultRoutePrefix` | `"api"` | 全局路由前缀 | | `RemoveServiceSuffix` | `true` | 是否剥掉服务类名后缀 | | `ServiceSuffixes` | `["ApplicationService", "AppService", "Service"]` | 要剥掉的后缀列表 | | `EnableApiVersioning` | `true` | 是否在路由里插 `v{version}` 段 | | `DefaultApiVersion` | `null` | 默认版本号(不带 `v` 前缀) | | `ThrowOnGenerationFailure` | `true` | **控制器生成失败时抛异常中断装配**;设为 `false` 会只记日志并跳过该服务的全部端点(接口静默消失,不建议) | | `EnableBatchOperations` / `MaxBatchSize` | `true` / `100` | 批量操作开关与上限 | | `Conventions.HttpMethodConventions` | 见 [动态 API](./dynamic-api) | 动词前缀 → HTTP 谓词映射表,可增删 | | `Conventions.PreserveRoutePredicate` | `false` | 设为 `true` 则路由保留动词前缀(`GetUsers` → `/GetUsers`) | | `Conventions.UseLowercaseRoutes` | `false` | 路由全小写 | | `Routes.UseNamespaceAsRoute` | `false` | 把命名空间片段作为路由段 | | `Routes.UseModuleNameAsRoute` | `false` | 把模块名作为路由段 | 需要**完全自定义推导逻辑**时,实现 `IDynamicApiConvention` 并在框架之前把它注册进容器——`AddDynamicApi` 用的是 `TryAddSingleton`,且会优先复用已注册的实例,所以在 `PreConfigureServices` 里 `services.AddSingleton(new MyConvention(...))` 即可接管,`DefaultDynamicApiConvention` 不会再被注册。 ::: warning 改约定会改路由 动词表、`PreserveRoutePredicate`、`UseLowercaseRoutes` 任一改动都会让**已有接口的 URL 变化**,前端得同步改。上线后再动这些开关等于一次破坏性变更。 ::: --- ## 配方 E:发一个框架级包 想做一个可被别人 `dotnet add package` 的通用能力包时,跟着框架自身的约定走: | 约定 | 规则 | | --- | --- | | **包名** | 通用类库 `XiHan.Framework.[模块名]`;Web 相关 `XiHan.Framework.Web.[模块名]`。第三方作者请用自己的命名空间 | | **SDK** | 通用类库用 `Microsoft.NET.Sdk`;Web 相关用 `Microsoft.NET.Sdk.Web` | | **模块类名** | `XiHan[模块名]Module`,继承 `XiHanModule`。纯工具库(无需注册服务)可以不带模块类,直接引用即可 | | **抽象/实现分包** | 契约放 `*.Abstractions`(零/极少依赖),实现另起一包。框架里 `EventBus`/`MultiTenancy`/`Validation`/`Localization`/`SearchEngines`/`Workflow`/`AI` 都是这个结构 | | **分层** | 只能依赖比自己低的层(公共 → 元数据 → 核心 → 领域 → 应用 → 基础设施 → Web),**绝不反向**,保证依赖图无环、可裁剪 | | **默认实现用 `TryAdd`** | 这样使用方能用 `Replace` 覆盖你的默认实现 | | **配置节** | 常量 `SectionName` 钉在 Options 类上 | 分层图与命名细节见 [框架概述 · 分层架构](../overview#分层架构)。 --- ## 约定速查 | 场景 | 做法 | 漏了会怎样 | | --- | --- | --- | | 常规服务注册 | 实现 `ITransientDependency` / `IScopedDependency` / `ISingletonDependency` | — | | **领域服务注册** | **手写 `services.AddScoped()`** | 运行期 DI 解析异常 | | **覆盖框架默认实现** | **`services.Replace(...)` 或 `[Dependency(ReplaceServices = true)]`** | 被静默忽略,默认实现继续生效 | | 一个实现暴露多个接口 | `[ExposeServices(typeof(IA), IncludeDefaults = true)]` | 只注册默认推断出的那个接口 | | 同接口多实现按 key 区分 | `[ExposeKeyedService("key")]` + `[FromKeyedServices("key")]` | — | | 退出约定扫描 | `[DisableConventionalRegistration]` | 被自动注册一遍 | | 要 AOP 拦截 | 服务类型必须是**接口** | 拦截器静默不生效 | | 自定义拦截器 | `services.OnRegistered(注册器)` | 代理不会创建 | | **本地事件处理器** | 显式加入 `XiHanLocalEventBusOptions.Handlers` | 裸 `AddTransient<具体类>` **不会被订阅**,静默失败 | | 分页方法 | 显式标 `[HttpPost]` | 被推导成 GET,请求体绑不上 | | 想要 `/{id}` 路径段 | 参数标 `[FromRoute]` | 参数落到查询串,路由段不会生成 | ## 下一步 - [常见问题](./faq):模块不生效、拦截器失效、事务没提交等排查 - [模块系统](./modularity):`[DependsOn]` 与拓扑排序机制 - [生命周期](./lifecycle):七个钩子分别在什么时候跑 - [动态 API](./dynamic-api):路由推导的完整规则 - [BasicApp 二次开发](https://basicapp.docs.xihanfun.com/backend/development):在完整业务系统上扩展的实战清单 --- 来源:https://framework.docs.xihanfun.com/guide/faq # 常见问题 框架层面的高频故障速查。这些坑的共同点是**没有异常、没有日志、静默失效**——知道去哪儿看就能秒解,不知道就能耗一天。每条都对照框架源码核实。 --- ## 模块与装配 ### 我的模块写好了,但服务一个都没注册 `ConfigureServices` 只会被框架在**依赖图内**调用。检查两点: 1. 你的模块有没有出现在启动模块(或其某个上游模块)的 `[DependsOn]` 里。不在依赖图里的模块类,框架根本不认识它。 2. 有没有在宿主里额外手写 `services.AddMyModule(...)`。模块自己的 `ConfigureServices` 已经会跑一次,宿主再调一次就是**重复注册**。宿主只用 `[DependsOn]` 挂模块,不重复接线。 ### `[DependsOn]` 要把间接依赖也列上吗 不用。**只写直接依赖**——间接依赖由被依赖模块自己声明,框架按依赖图拓扑排序装配,重复列举没有收益,只会让依赖关系失真。 ### 启动时抛出动态 API 装配异常 这是**故意的**。`DynamicApiOptions.ThrowOnGenerationFailure` 默认 `true`:任一应用服务生成控制器失败就中断装配并抛聚合异常,异常信息里有具体是哪个服务、哪个方法。 设成 `false` 会改为只记日志并跳过——该服务的全部端点就从路由表里静默消失,前端拿到 404 却查不出所以然。**不建议**关掉。 ### 代码该写在哪个生命周期钩子里 | 你要做的事 | 钩子 | | --- | --- | | 注册服务、绑定选项 | `ConfigureServices` | | 挂拦截器注册器、预设其他模块要读的选项 | `PreConfigureServices` | | 所有模块注册完后做最终覆盖 | `PostConfigureServices` | | 接中间件、映射端点 | `OnApplicationInitialization` | | 要插在管道最前面(如 Webhook 校验) | `OnPreApplicationInitialization` | | 一切就绪后再做(如把库里的任务同步进调度器) | `OnPostApplicationInitialization` | | 释放资源 | `OnApplicationShutdown`(**逆序**执行,与其余六个钩子相反) | 拿 `IApplicationBuilder` 的 `context.GetApplicationBuilder()` 等扩展方法来自 `XiHan.Framework.Web.Core`,纯类库模块里没有。 --- ## 依赖注入 ### 运行期抛「无法解析服务」 最常见的是**领域服务**。领域服务接口通常不带 `ITransientDependency` / `IScopedDependency` / `ISingletonDependency` 标记接口,约定注册扫不到,必须手写: ```csharp services.AddScoped(); ``` 对比:应用服务(`ApplicationServiceBase` 已实现 `ITransientDependency`)与仓储(基类带 `IScopedDependency`)都会被自动注册,不用手写。 ### 我实现了同一个接口,但用的还是框架的默认实现 框架用 **`TryAdd`** 注册默认实现,且**先于**你的模块执行。你再 `TryAdd`(包括很多 `AddXxx` 扩展方法内部就是 `TryAdd`)会被**静默忽略**。 覆盖必须用 `Replace`: ```csharp services.Replace(ServiceDescriptor.Singleton()); ``` 或走特性:`[Dependency(ServiceLifetime.Scoped, ReplaceServices = true)]`。 替换时**生命周期要与被替换者一致**,否则解析时抛作用域校验异常(Singleton 依赖 Scoped 是非法的)。 ### 一个实现类实现了多个接口,只有一个能注入 约定注册默认只把实现类暴露为「类名去掉前导 `I` 后同名」的那个接口(`OrderService` → `IOrderService`)。要额外暴露: ```csharp [ExposeServices(typeof(INotifiable), IncludeDefaults = true)] public class OrderService : IOrderService, INotifiable, IScopedDependency { } ``` 同接口多实现按 key 区分用 `[ExposeKeyedService("alipay")]`,消费侧 `[FromKeyedServices("alipay")]`。 ### 想让某个类完全不被自动注册 类上加 `[DisableConventionalRegistration]`,然后自己手写注册。 --- ## AOP 与拦截器 ### `[UnitOfWork]` / `[Cacheable]` 标了没生效 **最可能的原因:服务的注册类型不是接口。** 框架的 AOP 走 Castle 的 `CreateInterfaceProxyWithTarget`,`AddCastleDynamicProxy` 只处理 `ServiceType.IsInterface` 为真的服务描述器。所以: ```csharp services.AddScoped(); // ❌ 注册为自身类型 → 不会被代理,特性静默失效 services.AddScoped(); // ✅ 接口 → 会被代理 ``` 其次检查:类型有没有被列进 `DynamicProxyIgnoreTypes`。 ### 匿名端点调用应用服务时永久挂起 匿名端点(Minimal API、OAuth 回调等)不经过工作单元中间件,而应用服务被 Castle 代理包着,拦截器会急切开事务从而死锁。 解法是**绕开代理**:注入真正的依赖直连,或用 `ProxyHelper.UnProxy(service)` 取真实目标实例再调。 ### 怎么加自己的拦截器 写一个 `IXiHanInterceptor` 实现 + 一个决定「拦谁」的注册器,在 `PreConfigureServices` 里 `services.OnRegistered(注册器)` 挂上。完整示例见 [扩展与二次开发 · 自定义 AOP 拦截器](./extending#配方-c-自定义-aop-拦截器)。 --- ## 工作单元与事务 ### 我的应用服务方法没有事务 **没标 `[UnitOfWork]` 就没有环境工作单元。** 框架判定「这个类型要不要被工作单元拦截」的条件是:类或任一方法上有 `[UnitOfWork]`,**或者**类型实现了 `IUnitOfWorkEnabled`。而 `IUnitOfWorkEnabled` 在框架里**没有任何实现者**,`ApplicationServiceBase` 也没实现它。 所以写操作要事务,就在方法(或类)上显式标注: ```csharp [UnitOfWork(true)] // true = 需要事务 public async Task CreateInvoiceAsync(InvoiceCreateDto input) { … } ``` 标注在 HTTP 与进程内两条路径上都生效:HTTP 请求由 `XiHanUnitOfWorkFilter` 在 MVC 动作外层开启工作单元,进程内互调由 Castle 接口代理的 `UnitOfWorkInterceptor` 开启。两条路径的判定规则一致,详见 [工作单元与事务](./uow#两条生效路径-aop-代理与-mvc-过滤器)。 ### 内层调了 `RollbackAsync`,外层还能提交吗 不能。回滚之后再提交会抛 `XiHanException`。 父子工作单元共用同一物理事务,内层判定失败回滚就意味着整体终止——让外层若无其事地提交会产出「接口返回 200 但数据一行没写」。 同理 `SqlSugarClientResolver` 的钉住连接判定包含「是否已回滚」:检出已回滚的事务型工作单元会抛出并提示改用 `Begin(requiresNew: true)`,避免回滚之后的写入落在一条已无事务的连接上被逐条自动提交。 ### `requiresNew` 到底给不给我一个独立事务 给。`requiresNew` 同时意味着**新的逻辑工作单元**和**新的物理连接**——两者缺一,内层就还是跑在外层的连接和事务上。 用的时候记住语义代价:内层提交后**不再受外层回滚影响**。所以**不要在已经修改过某些行的事务里,再用 `requiresNew` 去改同一批行**——两条独立连接改同一批行,轻则互相覆盖,重则死锁。 ### 本地事件和分布式事件的发布时机有什么区别 时机不同,且这个差别是有意为之: - **本地事件**:**提交前**发布。其处理器可能继续写库,这些写入必须落在同一个事务里。 - **分布式事件**:循环内只累积,**事务提交成功后**再按 `EventOrder` 统一发布。这样事务回滚时下游不会收到一份从未落库的数据。 代价是提交成功后若投递失败事件会丢。发件箱是进程内实现,进程一停即丢——要强投递保证请自行接持久化发件箱。 --- ## 动态 API ### 生成的路由和我预期的不一样 三条最容易忘的规则: 1. **动词前缀会被剥离**:`GetUserPageAsync` → 动作名 `UserPage`(不是 `GetUserPage`),`Async` 后缀也去掉。 2. **没有匹配动词前缀时默认 POST**:`SwitchTenantAsync` → `POST /api/Auth/SwitchTenant`。 3. **路由段只由显式 `[FromRoute]` 参数产生**。普通参数一律落到查询串或请求体,**不会**自动变成 `/{id}` 路径段。 前缀匹配要求**词边界**(前缀之后必须是大写字母或下划线),所以 `AddressBook` 不会被 `Add` 命中而变成 `POST /ressBook`,`EditorTemplate` 也不会被 `Edit` 命中。 完整动词表见 [动态 API](./dynamic-api)。 ### 分页方法收不到请求体 方法名以 `Get` 开头会被推导为 GET,请求体自然绑不上。**分页方法必须显式标 `[HttpPost]`**。方法上显式标注的 `[HttpGet]` / `[HttpPost]` 优先于前缀推导。 ### 一次上传多个文件 参数写成 `List`、`IFormFile[]`、`IFormFileCollection`,或放进 DTO 的 `List` 属性,前端把多个文件追加到同一个 `FormData` 字段即可,不必逐个调用接口。 ```csharp public Task> UploadFilesAsync(List files, FileUploadOptionsDto options) ``` 同一方法里的 DTO 与集合参数随表单一起提交,字段直接用属性名。`string remark` 这类简单参数默认走查询字符串,要放进表单就标 `[FromForm]`。不要给与文件同处的参数标 `[FromBody]`:表单请求没有 JSON 请求体,框架会在启动时报错。 ### 改了动词映射表,前端全 404 了 改 `Conventions.HttpMethodConventions`、`PreserveRoutePredicate`、`UseLowercaseRoutes`、`Routes.*` 中任何一个,都会让**已有接口的 URL 变化**。上线后再动这些开关等于一次破坏性变更,前端必须同步改。 --- ## 数据访问 ### 给实体加了字段,部署后报「列不存在」 `DbInitializer` **表存在就跳过建表**(日志里是「表已存在,跳过创建」),它**从不为已有表补列**。 要么重建数据库,要么手动 `ALTER TABLE` 补列。框架的定位是「首次启动自动建表 + 播种」,不是迁移工具。 ### 实体变更日志(Diff)一条都没有 两个条件必须同时成立: 1. 配置 **`XiHan:Data:SqlSugarCore:EnableDiffLog` 设为 `true`**——**默认是 `false`**,不开则 Diff AOP 根本不挂载,收集到的差异被直接丢弃; 2. 写操作走仓储并调用了 `.EnableDiffLogEvent(businessData)`。 生产的 `appsettings` 常被 gitignore,最容易漏的就是第一条。 ### 写操作报 `Parameter '@constant1001' already been defined` 仓储里**显式调用了 `.EnableQueryFilter()`**。 框架默认 `EnableAutoUpdateQueryFilter` / `EnableAutoDeleteQueryFilter` 均为 `true`,SqlSugar 的 `Updateable()` / `Deleteable()` 工厂内部**已经自动挂了一次**过滤。你再显式挂一次,同一份过滤会被烘进 WHERE 两遍、生成同名参数;一旦叠加 Diff 的 `GetDiffTable` 重查旧值就崩(MySQL 驱动直接抛,PG 驱动容忍重名故不崩,但仍是冗余死条件)。 **解法:把仓储里所有显式 `.EnableQueryFilter()` 删掉。** `.EnableDiffLogEvent()` 保留,它单独用是安全的。 ### 租户上下文里改不了全局数据 这是**有意的写路径边界**:全局租户过滤器为「读共享」放行 `TenantId=0` 的平台全局行,但**写路径不复用这个口径**——每个作用域只能改写/删除本作用域的行,预读守卫会校验取回行的 `TenantId`,条件写会自动追加当前作用域的 Where。 全局数据在平台(0 号租户,无租户上下文与 `Change(0)` 同义)作用域里维护;某个租户的数据切入该租户(`Change(tenantId)`)维护。平台同样只能改写 `TenantId=0` 的行,不存在「平台写全部」。 --- ## 缓存与事件 ### 本地事件处理器不触发 事件总线只自动发现「**以接口为服务类型**」的注册。裸 `services.AddTransient()`(注册为具体类)**不会被订阅,静默失败**。 处理器必须显式加入 `XiHanLocalEventBusOptions.Handlers`: ```csharp services.AddTransient(); services.Configure(o => o.Handlers.AddIfNotContains(typeof(MyHandler))); ``` BasicApp 把这两步封装成了 `AddSaasLocalEventHandler()`。 ### 怎么在缓存上执行 Lua 脚本 Lua 执行不在 `IDistributedCache` 这个通用契约上,而是**可选能力接口** `ICacheSupportsLuaScript`——通用缓存抽象不应该焊上某个客户端的类型。 用法是先做类型判断: ```csharp if (cache is ICacheSupportsLuaScript lua) { CacheScriptResult result = await lua.ScriptEvaluateAsync(script, keys, args); } ``` 入参是中立的 `object?[]`,返回 `CacheScriptResult`(支持标量、整数、布尔与嵌套数组)。`IRedisDelayQueue` / `IRedisStreamQueue` 是名字里就带 Redis 的专用抽象,直接用即可。 --- ## 还没解决? - [扩展与二次开发](./extending):模块、替换默认实现、拦截器、动态 API 定制的完整配方 - [模块系统](./modularity) / [生命周期](./lifecycle) / [依赖注入](./dependency-injection):机制本身 - [模块总览](../packages/):逐包的配置项、API 与注意事项 - [BasicApp 常见问题](https://basicapp.docs.xihanfun.com/faq):业务系统层面的坑(认证、权限、菜单、前端) - 仍未解决可到 [GitHub Issues](https://github.com/XiHanFun/XiHan.Framework/issues) 提问 --- 来源:https://framework.docs.xihanfun.com/guide/gateway # 网关与流量治理 灰度发布要按人群、租户、百分比把一部分流量导到新逻辑上,同时希望每条请求可追踪、出错有统一响应格式。这章讲这套东西怎么装、决策怎么算出来、以及哪些开关目前只是声明位。 ## 两个包,各管一段 | 包 | 职责 | 有没有 HTTP 依赖 | | --- | --- | --- | | `XiHan.Framework.Traffic` | 灰度规则模型、匹配器、规则引擎、规则仓储;限流/熔断的策略接口 | 无,纯决策 | | `XiHan.Framework.Web.Gateway` | 把决策接进 ASP.NET Core 请求管道:构建上下文、写决策、追踪、异常兜底 | 有 | 分工是「Traffic 算,Gateway 接」。灰度规则怎么写、怎么匹配全在 Traffic;Gateway 只负责从 `HttpContext` 里凑出 `GrayContext`、调一次引擎、把结果塞回 `HttpContext.Items`。 ::: warning 它不是反向代理 `GrayRoutingMiddleware` **不转发请求**,也不做服务发现和负载均衡。它只回答「这条请求该走哪个版本」,写进 `HttpContext.Items`,之后走哪套代码由你自己的中间件或控制器决定。需要边缘代理仍然用独立的网关组件。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Web.Gateway ``` ```csharp [DependsOn(typeof(XiHanWebGatewayModule))] public class MyAppModule : XiHanModule { } ``` `XiHanWebGatewayModule.ConfigureServices` 只做一件事:调用 Traffic 提供的 `AddGrayRouting()`,注册引擎、内存仓储和五个内置匹配器。**中间件不会自动挂载**,要自己挂: ```csharp public override void OnPreApplicationInitialization(ApplicationInitializationContext context) { context.GetApplicationBuilder().UseGateway(); } ``` `UseGateway()` 按固定顺序挂三段: ```text ① GatewayExceptionMiddleware try/catch 兜底(必须最外层) ② RequestTracingMiddleware TraceId 注入 + 请求起止日志 ③ GrayRoutingMiddleware 构建上下文 → 引擎决策 → 写 HttpContext.Items ``` 也可以只挂需要的那段:`UseGrayRouting()` 或 `UseRequestTracing()`。 ::: tip 已经用了 Web.Api 就别挂满三段 `XiHanWebApiModule` 的管道里已经有 `XiHanTraceIdMiddleware`(同样从 W3C `Activity` 取 TraceId、同样写 `X-Trace-Id` 响应头)和全局异常处理,这时通常只需要 `app.UseGrayRouting()` 一段。 两个追踪中间件写的是**不同的 `Items` 键**——网关写 `GatewayConstants.TraceIdKey`(`"XiHan.Gateway.TraceId"`),Web.Api 写 `"__XiHanTraceId"`。`HttpContext.GetTraceId()` 读的是前者,没挂 `RequestTracingMiddleware` 就会返回 `null`。 ::: ::: warning 挂载位置决定客户端 IP 准不准 框架的模块生命周期是「所有模块的 `OnPreApplicationInitialization` → 所有模块的 `OnApplicationInitialization`」。Web.Api 的整条管道(含最前面的 `UseForwardedHeaders()` 和最后的 `UseEndpoints()`)建在 `OnApplicationInitialization`,所以: - 挂在 `OnPreApplicationInitialization` → 网关中间件排在 `UseForwardedHeaders()` **之前**,`GrayContext.ClientIpAddress` 取到的是反向代理的 IP,`IpAddress` 类型的规则会失准。 - 挂在你自己模块的 `OnApplicationInitialization` → 排在 `UseEndpoints()` 之后,永远不会执行。 反向代理后面又要按 IP 灰度,就别用 `UseGateway()` 统一挂,改成在 Web.Api 管道内部自己 `app.UseMiddleware()`,或在匹配器里改从转发头取 IP。 ::: `AddGateway(configure)` 只是把你传入的委托 `Configure` 进 `XiHanGatewayOptions`,不注册任何服务——引擎来自模块里的 `AddGrayRouting()`,不调 `AddGateway` 也能跑。 ## 定义灰度规则 一条规则就是一个 `GrayRule`: ```csharp var rule = new GrayRule { RuleId = "gray-order-v2-whitelist", RuleName = "订单 v2 内测白名单", RuleType = GrayRuleType.UserId, IsEnabled = true, Priority = 10, // 数字越小越先匹配 TargetVersion = "v2", TargetServiceId = "order-service", Configuration = """{"userIds":[1001,1002]}""", EffectiveTime = DateTime.UtcNow, ExpiryTime = DateTime.UtcNow.AddDays(7), CreatedTime = DateTime.UtcNow }; ``` `Configuration` 是 JSON 字符串,结构随 `RuleType` 变: | `GrayRuleType` | `Configuration` | 命中条件 | 需要的上下文 | | --- | --- | --- | --- | | `Percentage = 1` | `{"percentage":20}` | `Random.Shared.Next(1, 101)` 落在 `<= percentage` | 无 | | `UserId = 2` | `{"userIds":[1001,1002]}` | 上下文 `UserId` 在列表里 | `GrayContext.UserId` | | `TenantId = 3` | `{"tenantIds":[1,2]}` | 上下文 `TenantId` 在列表里 | `GrayContext.TenantId` | | `Header = 4` | `{"headerName":"X-Gray","headerValue":"true"}` | 请求头存在且值相等(忽略大小写);省略 `headerValue` 则只要头存在就命中 | `GrayContext.Headers` | | `IpAddress = 5` | `{"ipAddresses":["10.0.0.7","192.168.1.0/24"]}` | 精确 IP 相等,或落在 CIDR 网段内 | `GrayContext.ClientIpAddress` | | `Custom = 99` | 自定 | 由你实现的 `IGrayMatcher` 决定 | 自定 | ::: danger userIds / tenantIds 必须写数字 两个匹配器把它们反序列化成 `List`。写成 `["admin","tester"]` 会抛 `JsonException`,被匹配器内部的 `catch` 吞掉后直接返回「不命中」——**不报错、不打日志、永远不生效**。上下文里的 `UserId` 同理是 `long?`,由 `long.TryParse` 解析而来,非数字用户标识拿不到。 ::: 上下文由 `GrayRoutingMiddleware` 从请求里凑: | `GrayContext` 字段 | 来源 | | --- | --- | | `RequestPath` / `RequestMethod` | `HttpContext.Request` | | `ClientIpAddress` | `HttpContext.Connection.RemoteIpAddress` | | `UserId` | Claim `sub` → Claim `userId` → 请求头 `X-User-Id`,逐个回退后 `long.TryParse` | | `TenantId` | `ICurrentTenant.Id` | | `Headers` | 全部请求头,键不区分大小写 | ## 优先级与决策顺序 `DefaultGrayRuleEngine.DecideAsync` 的流程: ```text 取 IGrayRuleRepository.GetEnabledRulesAsync() ← 只取 IsEnabled = true 的 ↓ 一条都没有 返回 NotGray("没有启用的灰度规则") ↓ 有规则 按 Priority 升序排序(数字小的先看) ↓ 逐条 校验有效期:EffectiveTime / ExpiryTime 对比 DateTime.UtcNow,失效跳过 ↓ 按 rule.RuleType 找匹配器,找不到打 Warning 跳过 ↓ IsMatchAsync 命中 → 立即短路返回 Gray(TargetVersion ?? "gray", RuleId, Reason) ↓ 全不命中 返回 NotGray("未命中任何灰度规则") ``` 四条要记住的语义: - **`Priority` 越小越优先,命中即短路**。把「必须稳定进新版」的规则(内部白名单、指定租户)排在百分比规则前面。 - **有效期比的是 UTC**。`EffectiveTime` / `ExpiryTime` 存本地时间会整体偏移。 - **一个 `RuleType` 只有一个匹配器生效**。引擎用 `FirstOrDefault(m => m.RuleType == rule.RuleType)` 找匹配器,同类型注册多个时只有最先注册的那个被调用——想按多个自定义维度分流,需要各自实现后在一个 `Custom` 匹配器里分派,或者复用 `Header` 类型。 - **决策异常一律降级为「未命中」**。引擎最外层的 `catch` 把异常写日志后返回 `NotGray($"决策异常: {ex.Message}")`,规则坏了不会阻断主流程,但也不会向上报错,得看日志。 典型的放量节奏就是几条规则的优先级排布: | 阶段 | 规则类型 | `Priority` | `Configuration` | | --- | --- | --- | --- | | 内测 | `UserId` | 10 | `{"userIds":[1001,1002]}` | | 内网验证 | `IpAddress` | 20 | `{"ipAddresses":["10.0.0.0/8"]}` | | 小流量 | `Percentage` | 100 | `{"percentage":5}` | | 放量 | `Percentage` | 100 | `{"percentage":20}` | 白名单规则的 `Priority` 小于百分比规则,测试账号才能每次都稳定落到新版本。 ## 消费决策 决策写在 `HttpContext.Items[GatewayConstants.GrayDecisionKey]`,用 `GatewayContextHelper` 的扩展方法读: ```csharp [HttpGet] public IActionResult GetOrders() { if (HttpContext.IsGrayRequest()) { var version = HttpContext.GetTargetVersion(); // "v2" return Ok(_v2Service.Query()); } return Ok(_v1Service.Query()); } ``` 要拿完整决策(命中的规则 Id、原因): ```csharp var decision = HttpContext.GetGrayDecision(); // IGrayDecision? if (decision?.IsGray == true) { _logger.LogInformation("灰度命中 {RuleId}: {Reason}", decision.MatchedRuleId, decision.Reason); } ``` `IGrayDecision.TargetServiceId` 恒为 `null`:`GrayDecision.Gray()` 只写 `TargetVersion` / `MatchedRuleId` / `Reason`,规则上配的 `TargetServiceId` 不会被带进决策。要按目标服务分流,拿 `MatchedRuleId` 去 `IGrayRuleRepository.GetRuleByIdAsync()` 回查规则。 想让下游或前端也看见版本,自己挂一段中间件把它写进响应头: ```csharp public class GrayVersionHeaderMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context) { var decision = context.GetGrayDecision(); if (decision?.IsGray == true) { context.Response.Headers[GatewayConstants.Headers.GrayVersion] = decision.TargetVersion ?? "gray"; } await next(context); } } ``` `GatewayConstants.Headers` 里定义了 `X-Trace-Id` / `X-Gray-Version` / `X-User-Id` / `X-Tenant-Id` 四个头名,其中只有 `X-Trace-Id` 由框架主动写入,另外三个是约定给你用的常量。 ## 生产化:换掉内存规则仓储 默认的 `DefaultGrayRuleRepository` 是最多保存 10000 条规则的单例进程内仓储(满载时 `AddRule` 抛 `InvalidOperationException`):规则重启即丢、多实例各存各的、没有任何管理入口(`AddRule` / `RemoveRule` / `Clear` 不在 `IGrayRuleRepository` 接口上)。 生产要实现三个只读方法后替换: ```csharp public class DbGrayRuleRepository : IGrayRuleRepository { public Task> GetEnabledRulesAsync(CancellationToken cancellationToken = default) { … } public Task GetRuleByIdAsync(string ruleId, CancellationToken cancellationToken = default) { … } public Task RefreshAsync(CancellationToken cancellationToken = default) { … } } ``` ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.ReplaceGrayRuleRepository(); } ``` ::: warning GetEnabledRulesAsync 是每请求调用一次的 `GrayRoutingMiddleware` 每条请求都会走一次引擎,引擎每次都问仓储要全部启用规则。数据库实现**必须自己带缓存**,否则每个请求一次查库。`RefreshAsync` 是留给你在规则变更后主动刷缓存的钩子——框架自身不会调它,得由你的规则管理入口(写完规则后)触发。 仓储用 `TryAddSingleton` 注册,替换实现也是单例;里面若要用 Scoped 的仓储/DbContext,从 `IServiceScopeFactory` 开作用域。 ::: 新增匹配维度就实现 `IGrayMatcher`: ```csharp public class DeviceTypeGrayMatcher : IGrayMatcher { public GrayRuleType RuleType => GrayRuleType.Custom; public bool IsMatch(GrayContext context, IGrayRule rule) { return context.Headers is not null && context.Headers.TryGetValue("X-Device-Type", out var deviceType) && deviceType.Equals("mobile", StringComparison.OrdinalIgnoreCase); } public Task IsMatchAsync(GrayContext context, IGrayRule rule, CancellationToken cancellationToken = default) { return Task.FromResult(IsMatch(context, rule)); } } ``` ```csharp context.Services.AddGrayMatcher(); ``` 匹配器是单例,别在里面存请求级状态。 ## 请求追踪 `RequestTracingMiddleware` 做三件事:定 TraceId、写出去、记两条日志。 TraceId 的取值优先级: | 顺序 | 来源 | 说明 | | --- | --- | --- | | 1 | `Activity.Current.TraceId`(W3C,32 位十六进制) | 与 Web.Api、可观测性同源;上游传了 `traceparent` 会自动延续 | | 2 | 请求头 `X-Trace-Id` | 没有 Activity 时才轮到 | | 3 | `HttpContext.TraceIdentifier` | 兜底 | 定好后同时写进 `HttpContext.Items[GatewayConstants.TraceIdKey]` 和响应头 `X-Trace-Id`,业务代码用 `HttpContext.GetTraceId()` 读。 ::: tip 每条请求两条 Information 日志 「请求开始」和「请求结束」(含状态码与耗时毫秒)都是 `Information` 级,且结束那条写在 `finally` 里、异常也会记。QPS 高的服务要么按类别 `XiHan.Framework.Web.Gateway.Middlewares.RequestTracingMiddleware` 调日志级别过滤,要么别挂这一段、直接用 Web.Api 的请求日志。 ::: ## 网关异常响应 `GatewayExceptionMiddleware` 捕获后续管道的未处理异常,按类型映射状态码,以 camelCase JSON 返回 `GatewayErrorResponse`: | 异常类型 | 状态码 | | --- | --- | | `UnauthorizedAccessException` | 401 | | `ArgumentException`(含派生类) | 400 | | 其余 | 500 | ```json { "traceId": "8a3f…", "errorCode": "GATEWAY_ERROR", "errorMessage": "……", "path": "/api/orders", "timestamp": "2026-08-05T02:11:34.000Z" } ``` `errorCode` 恒为 `GATEWAY_ERROR`,`details` 字段模型里有但中间件不填。 ::: danger 异常消息原样返回给调用方 `errorMessage` 直接取 `exception.Message`,可能带连接串、SQL 片段、内部路径。对公网暴露前要么在它外面再包一层自己的异常中间件改写响应,要么干脆不挂这一段,用 Web.Api 的统一异常处理。 另外它不检查 `Response.HasStarted`——响应已经开始写之后再抛异常,这里设置状态码会二次抛出。流式接口(下载、SSE)挂在它后面时要注意。 ::: ## 限流与熔断当前在哪 ::: warning Traffic 的限流/熔断只有接口 `IRateLimitPolicy`(`PolicyName` / `IsAllowedAsync(key, ct)`)和 `ICircuitBreakerPolicy`(`PolicyName` / `IsOpen(key)` / `RecordSuccess(key)` / `RecordFailure(key)`)在 Traffic 包里是**纯接口,没有任何内置实现,模块也不注册它们**。`UseGateway()` 挂的三段中间件里同样没有限流和熔断。 真正能用的入站限流与熔断在 `XiHan.Framework.Web.Api` 的管道里(路由后、鉴权前),配置开关分别是 `XiHan:Web:RateLimiting:IsEnabled` 与 `XiHan:Web:CircuitBreaking:IsEnabled`,默认都关。见 [Web 应用开发](./web)。 ::: ## 配置 ::: danger XiHan:Web:Gateway 配置节当前不生效 `XiHanGatewayOptions.SectionName` 定义了 `"XiHan:Web:Gateway"`,但**没有任何代码把这个配置节绑定到选项**——`AddGateway(configure)` 只应用你传进去的委托。更关键的是,三段中间件都不注入 `IOptions`,所以下表所有字段目前**没有消费方**,改了不会有任何效果。 ::: | 字段 | 默认值 | 想达到同样效果,实际该用 | | --- | --- | --- | | `EnableGrayRouting` | `true` | 挂或不挂 `UseGrayRouting()` | | `EnableRequestTracing` | `true` | 挂或不挂 `UseRequestTracing()` | | `EnableRateLimiting` | `false` | `XiHan:Web:RateLimiting:IsEnabled`(Web.Api) | | `EnableCircuitBreaker` | `false` | `XiHan:Web:CircuitBreaking:IsEnabled`(Web.Api) | | `RequestTimeoutSeconds` | `30` | 宿主/代理层的超时设置 | | `AllowedOrigins` | `[]` | `XiHan:Web:Api:Cors`(Web.Api 的 `XiHanCorsOptions`) | | `GlobalHeaders` | `{}` | 自己写一段中间件往 `Response.Headers` 里塞 | 换句话说,当前这一层的「开关」就是**挂不挂对应的中间件**,粒度靠 `UseGrayRouting()` / `UseRequestTracing()` 分别控制。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 决策永远是「没有启用的灰度规则」 | 用的还是默认内存仓储,里面一条规则都没有 | | 规则配好了但从不命中,也不报错 | `Configuration` 的 JSON 结构或类型不对(`userIds` / `tenantIds` 必须是数字),匹配器把异常吞了 | | 日志里出现「找不到规则类型 X 的匹配器」 | 用了 `Custom` 但没 `AddGrayMatcher()` 注册对应匹配器 | | 同一个用户一会儿新版一会儿旧版 | 百分比匹配器每次请求独立取随机数,本来就不粘;要稳定分流自己写按用户哈希的匹配器 | | 多实例之间灰度比例/名单对不上 | 内存仓储各进程独立,换成共享的数据库仓储 | | 规则到点没失效 | `EffectiveTime` / `ExpiryTime` 与 `DateTime.UtcNow` 比较,存了本地时间会偏 | | `HttpContext.GetTraceId()` 返回 `null` | 没挂 `RequestTracingMiddleware`;Web.Api 的 TraceId 在另一个 `Items` 键上 | | 改了 `appsettings` 里的 `XiHan:Web:Gateway` 完全没反应 | 该配置节没有绑定代码,见上一节 | | 按 IP 的规则在生产上全不命中 | 网关中间件排在 `UseForwardedHeaders()` 之前,取到的是代理 IP | | 决策出来了但请求没被分流 | 网关不转发,只写决策,分流逻辑要你自己写 | | 自定义匹配器没被调用 | 同一 `RuleType` 已有匹配器先注册,引擎只取第一个 | ## 下一步 - [Web 应用开发](./web):Web.Api 的完整管道、限流与熔断的真实位置 - [多租户](./multi-tenancy):`ICurrentTenant` 从哪来,按租户灰度的前提 - [Traffic 包](../packages/traffic):规则模型、匹配器与引擎的完整 API - [Web.Gateway 包](../packages/web-gateway):中间件、常量与选项的完整清单 - [可观测性](../packages/observability):把灰度命中与 TraceId 接进指标和链路 --- 来源:https://framework.docs.xihanfun.com/guide/http # HTTP 远程请求 调用外部 API 时,你不想每次都手写 `HttpClient` 注册、超时、重试、日志、异常兜底这一堆样板。`XiHan.Framework.Http` 在 `IHttpClientFactory` 之上把这些固化下来:一个注入即用的服务、一套统一的返回值 `HttpResult`、一组按名字挑选的客户端,以及基于 Polly 的重试 / 超时 / 熔断。 ## 先分清:这一章和动态 API 是两件事 两者经常被放在一起提,但方向相反。 | | 动态 API | HTTP 远程请求 | | --- | --- | --- | | 方向 | **入站**:把你的应用服务暴露成 HTTP 端点 | **出站**:你的进程去调别人的 HTTP 接口 | | 所在包 | `XiHan.Framework.Web.Api` | `XiHan.Framework.Http` | | 关键类型 | `DynamicApiControllerFactory`;`[DynamicApi]` 特性在 `XiHan.Framework.Application` | `IAdvancedHttpService`、`HttpRequestBuilder` | | 你写的东西 | 应用服务方法 | 请求 URL + 选项 | ::: warning 框架不提供客户端代理生成 `Web.Api` 的 `DynamicApi` 目录里只有控制器工厂、约定、参数分析这些**服务端** MVC 侧的东西,没有「按服务接口自动生成 HTTP 调用代理」的能力。要调用另一个服务的动态 API 端点,仍然是本章的做法:拿到 URL,用 `IAdvancedHttpService` 发请求,自己声明返回 DTO。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Http ``` ```csharp [DependsOn(typeof(XiHanHttpModule))] public class MyModule : XiHanModule { } ``` `XiHanHttpModule` 依赖 `XiHanSerializationModule`,会一并启用。模块启动时做两件事: | 阶段 | 行为 | | --- | --- | | `ConfigureServices` | 绑定 `XiHanHttpClientOptions`(`XiHan:Http`)与 `XiHanProxyPoolOptions`(`XiHan:Http:ProxyPool`);注册 `IAdvancedHttpService`(Scoped)、`IProxyValidator` / `IProxyPoolManager`(Singleton);注册 `Remote`、`Local` 及配置里声明的自定义命名客户端 | | `OnApplicationInitialization` | 调用 `StringHttpExtensions.Initialize(context.ServiceProvider)`,激活字符串链式扩展 | ## 两个入口 ### 入口一:注入 `IAdvancedHttpService` 推荐的默认写法,构造函数注入,测试时可替换。 ```csharp using XiHan.Framework.Http.Extensions; using XiHan.Framework.Http.Options; using XiHan.Framework.Http.Services; public class WeatherClient { private readonly IAdvancedHttpService _http; public WeatherClient(IAdvancedHttpService http) { _http = http; } public async Task GetAsync(string city, CancellationToken ct = default) { var options = new XiHanHttpRequestOptions() .WithAuthorization("your-token") // Authorization: Bearer your-token .AddQueryParameter("unit", "metric") .SetTimeout(TimeSpan.FromSeconds(10)); var result = await _http.GetAsync( $"https://api.example.com/weather/{city}", options, ct); return result.GetDataOrDefault(); // 失败返回 default } } ``` `XiHanHttpRequestOptions` 本身带链式方法(`AddHeader` / `AddQueryParameter` / `AddTag` / `SetTimeout` / `SetRequestId` / `SetProxy` / `EnableProxyPool`),`HttpServiceExtensions` 再补一层语义化的(`WithAuthorization` / `WithBasicAuth` / `AsJson` / `AsForm` / `WithUserAgent` / `WithCorrelationId` / `WithLanguage` / `WithoutRetry` / `WithoutCircuitBreaker` / `WithoutCache` / `UseClient` / `WithVerboseLogging` / `WithoutLogging`)。 ### 入口二:字符串链式 `AsHttp()` 适合没有构造函数可注入的静态工具代码。 ```csharp using XiHan.Framework.Http.Extensions; var result = await "/repos/owner/name/issues" .AsHttp() .UseClient("github") // 用配置里声明的命名客户端 .SetJsonBody(new { title = "bug", body = "..." }) .PostAsync(); if (result.IsSuccess) { var issue = result.Data; } ``` `string` 上还有一批直达扩展,跳过 `AsHttp()`:`"url".GetAsync()`、`"url".GetStringAsync()`、`"url".PostAsync(body)`、`"url".PutAsync(body)`、`"url".DeleteAsync()`、`"url".DownloadAsync(path)`。 ::: warning 链式扩展依赖应用初始化 `AsHttp()` 从静态字段拿 `IAdvancedHttpService`,该字段由 `OnApplicationInitialization` 填充。在应用初始化完成之前(含单元测试直接调用)会抛 `InvalidOperationException`,提示「HTTP 服务扩展未初始化」。这类场景请注入 `IAdvancedHttpService`。 ::: ## 统一返回值 `HttpResult` 所有方法都返回 `HttpResult`,**不靠抛异常表达失败**——网络异常、超时、非 2xx 状态码都被收进同一个对象里。 ```csharp var result = await _http.GetAsync(url); result.IsSuccess; // 是否 2xx result.StatusCode; // HttpStatusCode result.Data; // 反序列化后的 T result.RawDataString; // 原始响应字符串 result.ErrorMessage; // 失败时的消息,含被截断的响应体 result.Exception; // 失败时的异常 result.ElapsedMilliseconds; // 耗时 ``` 取值有三种姿势,按调用点的容错要求选: ```csharp var a = result.GetDataOrThrow(); // 失败抛 HttpRequestException var b = result.GetDataOrDefault(); // 失败返回 default var c = result.GetDataOrDefault(new OrderDto()); // 失败返回指定兜底值 ``` 无响应体的动词(`DeleteAsync(url)` / `HeadAsync` / `OptionsAsync`)返回非泛型的 `HttpResult`,它继承自 `HttpResult`。 ## 命名客户端 框架注册三类命名客户端,请求时按名字挑: | 名称 | 来源 | BaseAddress | 用途 | | --- | --- | --- | --- | | `Remote` | `HttpGroupEnum.Remote`,**默认** | 无 | 调用外部服务,URL 写全 | | `Local` | `HttpGroupEnum.Local` | `http://127.0.0.1` | 本机服务间调用,URL 只写路径 | | 自定义 | `XiHan:Http:Clients` 的每个键 | 由配置给出 | 每个上游一套独立的地址 / 超时 / 请求头 | 选择靠 `UseClient(name)`,它把名字写进 `XiHanHttpRequestOptions.Tags["ClientName"]`;`AdvancedHttpService` 读这个标签调 `IHttpClientFactory.CreateClient(name)`,没有标签就用 `Remote`。 新增一个上游只改配置、不改代码: ```json { "XiHan": { "Http": { "Clients": { "github": { "BaseAddress": "https://api.github.com", "TimeoutSeconds": 30, "Headers": { "Accept": "application/vnd.github+json" }, "EnableRetry": true, "EnableCircuitBreaker": true } } } } } ``` ::: warning 自定义客户端的两个边界 - `EnableRetry` 与 `EnableCircuitBreaker` **同时为 `false`** 时,整段 Polly 配置会被跳过——连超时策略也不会挂上,这个客户端只剩 `HttpClient.Timeout` 一道保护。 - `Local` 客户端没有配置主消息处理器,所以全局的 `IgnoreSslErrors` 对它不生效;`Remote` 与自定义客户端生效。 ::: ## 韧性策略:管道长什么样 每个命名客户端的处理器链,从外到内是固定顺序: ```text IAdvancedHttpService │ CancellationTokenSource(请求级超时或 DefaultTimeoutSeconds) ▼ ┌──────────────────────────┐ │ HttpLoggingMiddleware │ 记录请求/响应,敏感头脱敏 ├──────────────────────────┤ │ 重试策略 │ HandleTransientHttpError + TimeoutRejectedException ├──────────────────────────┤ │ 超时策略 │ 2 × DefaultTimeoutSeconds ├──────────────────────────┤ │ 熔断策略 │ HandleTransientHttpError ├──────────────────────────┤ │ HttpClientHandler │ └──────────────────────────┘ ``` 有两个由顺序直接决定的结论: - **日志在重试之外**。一次逻辑请求只打一对请求/响应日志,`Elapsed` 包含全部重试耗时,看不到中间的失败尝试。 - **超时策略在重试之内**。每次重试各自享有一份 Polly 超时预算,但整体仍受最外层 `CancellationTokenSource` 约束。 ### 重试 重试条件是 Polly 的 `HandleTransientHttpError()`(网络层 `HttpRequestException`、5xx、408)再加上 `TimeoutRejectedException`。重试节奏来自 `RetryDelaySeconds` 数组: ```json { "XiHan": { "Http": { "RetryDelaySeconds": [1, 5, 10] } } } ``` 上面这份配置 = 重试 3 次,间隔 1 秒、5 秒、10 秒。 ::: tip 重试次数只看数组长度 `RetryCount` 这个配置项存在且会被绑定,但策略构建过程完全不读它。要改重试次数,改 `RetryDelaySeconds` 的**元素个数**。 ::: ### 超时:三层,取最紧的那层 | 层 | 值 | 作用范围 | | --- | --- | --- | | `CancellationTokenSource` | `XiHanHttpRequestOptions.Timeout` ?? `DefaultTimeoutSeconds` | 整次调用,**含全部重试** | | `HttpClient.Timeout` | 客户端的 `TimeoutSeconds` ?? `DefaultTimeoutSeconds`;请求级 `Timeout` 会覆写 | 单次底层发送 | | Polly 超时策略 | `2 × DefaultTimeoutSeconds` | 单次尝试 | ::: danger 请求级超时是总预算 `SetTimeout(TimeSpan.FromSeconds(5))` 之后,5 秒是「首次尝试 + 所有重试 + 所有退避间隔」的总和。配了 `[1, 5, 10]` 的重试却只给 5 秒超时,等于重试根本没机会跑完。排查超时时三层都要看。 另外,Polly 超时策略固定按全局 `DefaultTimeoutSeconds` 算,自定义客户端的 `TimeoutSeconds` 不参与。 ::: ### 熔断 熔断用 `HandleTransientHttpError().CircuitBreakerAsync(...)`,阈值取 `CircuitBreakerFailureThreshold`,断开时长取 `CircuitBreakerDurationOfBreakSeconds`。断路器打开时 `AdvancedHttpService` 捕获 `BrokenCircuitException`,返回状态码 `503` 的失败结果,消息里带上还要等多久。 ::: danger 当前实现下熔断器不会真正打开 熔断和重试都注册成 Polly 的**动态策略选择器**(`AddPolicyHandler(request => ...)`),选择器在每次请求时被调用。重试策略是无状态的,每次新建没有影响;但熔断器是有状态的,每次请求新建一份就意味着失败计数从零开始,无法跨请求累积,因此不会进入 Open 状态。 需要真正的跨请求出站熔断,请自行为该命名客户端追加一个**静态**的熔断策略实例,或把熔断放到网关层处理。 ::: 配置项里的 `CircuitBreakerSamplingDurationSeconds` 与 `CircuitBreakerMinimumThroughput` 同样只被绑定、不被策略构建使用——用的是连续失败计数式熔断,不是采样窗口式。 ### 按单次请求关闭策略 策略开关通过 `HttpRequestMessage.Options` 传给动态选择器,选择器读到 `false` 就退化成 `Policy.NoOpAsync`。所以不用改全局配置也能对某一次请求让路: ```csharp // 非幂等的写操作,重试会造成重复提交 var options = new XiHanHttpRequestOptions() .WithoutRetry() .WithoutCircuitBreaker(); await _http.PostAsync(url, input, options); ``` 链式写法同理:`"url".AsHttp().WithoutRetry().PostAsync()`。 ::: tip 默认给 POST/PUT/PATCH 关掉重试 `HandleTransientHttpError()` 会重试 5xx,而 5xx 不代表上游没收到、没处理。除非接口明确幂等,写操作建议显式 `WithoutRetry()`。 ::: ## 日志与脱敏 `HttpLoggingMiddleware` 挂在每个命名客户端上。行为要点: | 项 | 行为 | | --- | --- | | 开关 | 全局 `EnableRequestLogging` / `EnableResponseLogging`;请求级 `WithVerboseLogging()` / `WithoutLogging()` 覆盖全局 | | 敏感请求头 | `Authorization`、`Cookie`、`Set-Cookie`、`X-API-Key`、`X-Auth-Token`、`Proxy-Authorization` 记为 `***` | | 请求体 | **仅当 `LogSensitiveData = true` 时**才读取并记录 | | 响应体 | 开了响应日志就完整记录,**不做截断**(`MaxResponseContentLength` 不作用于日志) | | 日志级别 | 响应 2xx 用 `Information`,否则 `Warning`;异常用 `Error` | | 关联 | 每次请求自动带 `X-Request-Id` 头,日志用同一个值串起请求与响应 | ::: warning `LogSensitiveData` 是生产禁区 打开它会把 `Authorization` 头和完整请求体明文写进日志。它默认为 `false`,只在本地排障时临时开。 ::: 跨服务追链路用 `WithCorrelationId()`,它写的是 `X-Correlation-Id` 头(不传参时自动生成 GUID)。 ## 上传、下载与批量 ```csharp // 单文件上传,附带表单字段 await using var fs = File.OpenRead(@"D:\report.xlsx"); var upload = await _http.UploadFileAsync( "https://api.example.com/files", fs, "report.xlsx", fieldName: "file", additionalData: new Dictionary { ["category"] = "monthly" }); // 下载到磁盘,带进度 var progress = new Progress(bytes => Console.WriteLine($"已下载 {bytes} 字节")); await _http.DownloadFileAsync( "https://example.com/large.zip", @"D:\downloads\large.zip", progress); // 批量并发,默认并发 10 var results = await _http.BatchRequestAsync(new[] { new BatchRequestInfo { Method = HttpMethod.Get, Url = "https://api.example.com/a" }, new BatchRequestInfo { Method = HttpMethod.Get, Url = "https://api.example.com/b" }, }, maxConcurrency: 4); ``` 多文件用 `UploadFilesAsync(url, IEnumerable files, ...)`。批量请求的返回类型固定是 `HttpResult`,其中 `Data` 是反序列化出的动态对象,不是强类型 DTO;需要强类型就自己 `Task.WhenAll` 拼多个 `GetAsync`。 ::: warning 大响应体走 `DownloadFileAsync`,不要用 `GetStreamAsync` `SendRequestAsync` 会先把响应完整读成字符串填进 `RawDataString`,再反序列化成 `T`,方法返回前响应对象已经释放。这意味着: - 任何请求的响应体都会整份进内存,几十 MB 的接口要留意; - `GetStreamAsync` 拿到的流依附于已释放的响应,不适合当作持续读取的下载流。 需要流式落盘时用 `DownloadFileAsync`——它走 `HttpCompletionOption.ResponseHeadersRead` + 8 KB 缓冲逐段写文件,并逐段回调 `IProgress`。 ::: ## 代理(可选) 两种用法:请求级直接指定,或从代理池取。 ```csharp // 指定代理 var direct = new XiHanHttpRequestOptions() .SetProxy(new ProxyConfiguration { Host = "127.0.0.1", Port = 7890, Type = ProxyType.Http }); // 从代理池取下一个(需 XiHan:Http:ProxyPool:Enabled = true 且池中有代理) var pooled = new XiHanHttpRequestOptions().EnableProxyPool(); ``` 代理池由 `IProxyPoolManager` 管理,选择策略 `ProxySelectionStrategy` 支持 `RoundRobin` / `Random` / `LeastUsed` / `FastestResponse` / `Priority`;`IProxyValidator` 负责可用性校验;`Enabled` 与 `EnableHealthCheck` 同时为真时才注册后台服务 `ProxyPoolHealthCheckService`。每次请求结束会把成败与耗时回写给代理池(`RecordProxyResult`)。 ::: danger 走代理 = 绕过命名客户端 一旦请求指定了 `Proxy`、启用了 `UseProxyPool` 并真的取到代理,或者 `ValidateSslCertificate` 的结果与全局 `IgnoreSslErrors` 不一致,`AdvancedHttpService` 会临时 `new HttpClient(handler)` 发送这一次请求。这条路径**不经过 `IHttpClientFactory`**,因此: - 重试、超时、熔断策略全部不生效; - 日志中间件不生效; - `DefaultHeaders` 与命名客户端的 `BaseAddress`、`Headers` 全部不生效(URL 必须写全)。 此外 `DownloadFileAsync` 始终走命名客户端,不处理 `Proxy` / `UseProxyPool`。 ::: ## 配置速览 主配置节 `XiHan:Http`,常调的几项: | 键 | 默认值 | 说明 | | --- | --- | --- | | `DefaultTimeoutSeconds` | `60` | 客户端超时;Polly 超时策略取其 2 倍 | | `RetryDelaySeconds` | `[1, 5, 10]` | 重试间隔序列,长度即重试次数 | | `CircuitBreakerFailureThreshold` | `5` | 熔断失败阈值 | | `CircuitBreakerDurationOfBreakSeconds` | `10` | 熔断断开时长 | | `EnableRequestLogging` / `EnableResponseLogging` | `true` | 请求 / 响应日志开关 | | `LogSensitiveData` | `false` | 明文记录敏感头与请求体 | | `MaxResponseContentLength` | `4096` | 失败响应体拼进 `ErrorMessage` 时的截断长度 | | `ClientLifetimeMinutes` | `5` | 命名客户端处理器生存期 | | `IgnoreSslErrors` | `false` | 忽略证书错误(不作用于 `Local`) | | `DefaultHeaders` | 空 | 所有命名客户端的默认请求头 | | `Clients` | 空 | 自定义命名客户端 | ```json { "XiHan": { "Http": { "DefaultTimeoutSeconds": 60, "RetryDelaySeconds": [1, 5, 10], "CircuitBreakerFailureThreshold": 5, "CircuitBreakerDurationOfBreakSeconds": 10, "EnableRequestLogging": true, "LogSensitiveData": false, "DefaultHeaders": { "User-Agent": "XiHan/1.0" }, "Clients": {}, "ProxyPool": { "Enabled": false } } } } ``` 完整配置项(含代理池全部字段)见 [Http 包文档](../packages/http)。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 调 `AsHttp()` 抛 `InvalidOperationException` | 应用尚未走完 `OnApplicationInitialization` | 改注入 `IAdvancedHttpService` | | 改了 `RetryCount` 没有任何变化 | 该项不被策略构建读取 | 改 `RetryDelaySeconds` 的元素个数 | | 上游持续挂掉,熔断器却从不打开 | 熔断策略每次请求新建,计数不跨请求累积 | 为该客户端追加静态熔断策略,或在网关层熔断 | | 配了重试但只跑了一次就返回超时 | 请求级 `Timeout` 是含重试的总预算 | 放大请求级超时,或缩短 `RetryDelaySeconds` | | 自定义客户端完全没有超时保护 | `EnableRetry` 与 `EnableCircuitBreaker` 同为 `false`,整段 Polly 被跳过 | 至少保留一项为 `true`,或依赖 `TimeoutSeconds` | | 走代理后重试 / 日志全没了 | 代理路径使用临时 `HttpClient`,不经工厂 | 已知边界;代理场景请自行判断失败并重发 | | `UseClient("x")` 之后 `BaseAddress` 没拼上 | 该次请求命中了代理 / SSL 临时客户端分支 | URL 写全,或去掉代理与 `ValidateSslCertificate` | | 下载大文件内存飙升 | 用了 `GetStreamAsync` / `GetBytesAsync`,响应会整份进内存 | 换 `DownloadFileAsync` | | `Local` 客户端仍校验证书 | `Local` 未配置主消息处理器 | 用 `Remote` 或自定义客户端,并配 `IgnoreSslErrors` | | 日志里看不到请求体 | `LogSensitiveData` 为 `false` | 仅本地排障时临时打开 | ## 下一步 - [Http 包文档](../packages/http):完整 API 清单与全部配置项 - [动态 API](./dynamic-api):把自己的应用服务暴露成 HTTP 端点 - [Web 应用开发](./web):入站请求管道与中间件顺序 - [配置与选项](./configuration):Options 绑定与配置节约定 - [Serialization 包文档](../packages/serialization):请求 / 响应的 JSON 与动态对象处理 - [Web.Gateway 包文档](../packages/web-gateway):网关侧的流量治理与转发 --- 来源:https://framework.docs.xihanfun.com/guide/lifecycle # 模块生命周期 每个模块在框架启动到关闭的过程中,会依次经过若干**生命周期钩子**。理解它们,你就知道"该把代码写在哪个方法里"。 ## 两个阶段 框架启动分为两大阶段,钩子分布在其中: ```text ① 服务注册阶段(构建 DI 容器) ② 应用初始化阶段(装配中间件管道) ┌──────────────────────────┐ ┌──────────────────────────────────┐ │ PreConfigureServices │ │ OnPreApplicationInitialization │ │ ConfigureServices │ ──► │ OnApplicationInitialization │ │ PostConfigureServices │ │ OnPostApplicationInitialization │ └──────────────────────────┘ └──────────────────────────────────┘ │ 应用运行…… ▼ ┌──────────────────────────────────┐ │ OnApplicationShutdown(关闭时) │ └──────────────────────────────────┘ ``` - **阶段 ①** 在 `AddApplicationAsync()` 时执行 —— 这时还没有 `ServiceProvider`,只能往 `IServiceCollection` 里**注册**服务。 - **阶段 ②** 在 `InitializeApplicationAsync()` 时执行 —— 这时容器已建好,可以**解析服务、接入中间件**。 > 每个钩子都有**同步**和**异步**两个版本(如 `ConfigureServices` / `ConfigureServicesAsync`)。重写任一个都可以,默认同步版本会被异步版本调用。 ## 钩子说明 | 钩子 | 阶段 | 典型用途 | | --- | --- | --- | | `PreConfigureServices` | ① | 在正式注册前预配置,供后续模块读取(`PreConfigure`) | | `ConfigureServices` | ① | **最常用**。注册自己的服务、配置选项 | | `PostConfigureServices` | ① | 所有模块注册完后做收尾/覆盖(`PostConfigure`) | | `OnPreApplicationInitialization` | ② | 中间件管道装配前的准备 | | `OnApplicationInitialization` | ② | **最常用**。接入中间件、映射端点 | | `OnPostApplicationInitialization` | ② | 初始化完成后的收尾工作 | | `OnApplicationShutdown` | ② | 应用关闭时释放资源、刷写缓冲 | **执行顺序**:同一个钩子会按[模块拓扑排序](./modularity)的顺序,在所有模块上依次调用——底层模块先执行,你的应用模块最后执行。`OnApplicationShutdown` 是唯一的例外:`ModuleManager` 关闭时按**逆序**遍历模块列表,你的应用模块先关闭、底层模块最后关闭,与其余六个钩子的正向顺序相反,使资源释放顺序与初始化顺序对称。 ## 注册服务:`ConfigureServices` 在阶段 ① 里,通过 `context.Services`(标准 `IServiceCollection`)注册服务: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddSingleton(); // 配置选项:XiHanModule 提供了便捷的 Configure Configure(options => { options.Timeout = TimeSpan.FromSeconds(30); }); } ``` ## 接入中间件:`OnApplicationInitialization` 在阶段 ② 里,通过 `context.GetApplicationBuilder()` 拿到 `IApplicationBuilder`,接入中间件: ```csharp public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.UseMyMiddleware(); // 也可以读配置、解析服务: // var opt = context.ServiceProvider.GetRequiredService>().Value; } ``` `ApplicationInitializationContext`(`XiHan.Framework.Core`)自身只暴露一个 `ServiceProvider` 属性;`GetApplicationBuilder()`、`GetConfiguration()`、`GetEnvironment()`、`GetLoggerFactory()` 都是 `XiHan.Framework.Web.Core` 追加的扩展方法(`ApplicationInitializationContextExtensions`),随 Web 相关模块(如 `XiHan.Framework.Web.Api`)一并引入,方便你按环境/配置决定行为。 ## 真实示例:Web.Api 的中间件管道 `XiHanWebApiModule` 就是在 `OnApplicationInitialization` 里装配了整条 Web 中间件管道,顺序经过精心编排: ```text UseForwardedHeaders 反向代理转发头还原(必须最前) → TraceId 分配请求追踪 ID → RequestCulture 请求文化(i18n) → RequestContext 请求上下文 → ExceptionLogging 异常日志 → RequestLogging 请求日志 → UseRouting 路由 → RateLimiter / CircuitBreak 限流 / 熔断(按配置开关) → UseCors 跨域 → 本地对象存储静态文件 公开资源匿名直链 → ApiLogging API 日志 → OpenApiSecurity OpenAPI 安全 → UseAuthentication 认证 → TenantResolve 租户解析 → UseAuthorization 授权 → UseEndpoints 映射控制器 + OpenAPI ``` 这条管道你**不需要自己写**——引用 `XiHan.Framework.Web.Api` 后它就自动生效了。这正是模块化的价值:一整套编排好的基础设施,一行 `[DependsOn]` 即可获得。 ## 下一步 - [依赖注入](./dependency-injection):约定式注册与选项模式 - [动态 API](./dynamic-api):应用服务如何变成 REST 接口 - [模块系统](./modularity):回顾 `[DependsOn]` 与拓扑排序 --- 来源:https://framework.docs.xihanfun.com/guide/localization # 国际化 框架的多语言方案:文案放 JSON 文件、按请求头切文化、枚举与异常消息也一并本地化。这一章讲清资源怎么组织、文化怎么解析、以及哪些地方最容易踩空。 完整 API 与全部配置项见 [Localization 包](../packages/localization) 与 [Localization.Abstractions 包](../packages/localization-abstractions)。 ## 整体流程 ```text 请求进来 └─ XiHanRequestCultureMiddleware 解析文化 → 设置 CurrentCulture / CurrentUICulture └─ 业务代码取 IStringLocalizer["Key"] └─ JSON 资源存储(虚拟文件系统加载的 *.json) └─ miss → ResourceManager(.resx)兜底 └─ 仍 miss → 原样返回键名 ``` 三件事分别由三个东西负责,理解这个分工就不会找错地方: | 职责 | 承担者 | 关键点 | | --- | --- | --- | | 决定「当前是哪种语言」 | `XiHanRequestCultureMiddleware` | 只在请求范围内生效,请求结束还原线程文化 | | 决定「文案从哪来」 | `JsonLocalizationResourceStore` | 经虚拟文件系统读 JSON,支持热重载 | | 决定「怎么取」 | `XiHanStringLocalizerFactory` / `XiHanJsonStringLocalizer` | 以 `Replace` 覆盖了微软默认工厂 | ## 安装与启用 ```bash dotnet add package XiHan.Framework.Localization ``` ```csharp [DependsOn(typeof(XiHanLocalizationModule))] public class MyModule : XiHanModule { } ``` 模块的 `ConfigureServices` 调用 `AddXiHanLocalization(config)`,从配置节 `XiHan:Localization` 绑定 `XiHanLocalizationOptions`,并把 `IStringLocalizerFactory` 替换为 `XiHanStringLocalizerFactory`(JSON 优先)、注册 `JsonLocalizationResourceStore` 与 `IEnumLocalizationService`。 ::: tip 走 Web.Api 时中间件已经接好 `XiHanWebApiModule` 在 `OnApplicationInitialization` 里已经调用了 `app.UseXiHanRequestCulture()`(紧跟 TraceId、先于路由与 MVC)。 只有自己搭非 Web.Api 的宿主管线时,才需要手工调 `app.UseXiHanRequestCulture()`,且必须放在需要文化的中间件之前。 ::: ## 资源文件怎么组织 资源根目录由 `ResourcesPath` 决定,默认虚拟路径 `/Localization`;存储会**递归**枚举其下所有 `*.json`。虚拟文件系统默认挂载应用基目录与当前工作目录,因此实际落点就是运行目录下的 `Localization/`。 工程里放物理文件时,记得让它随输出走: ```xml PreserveNewest ``` 也可以把资源做成嵌入式资源,由虚拟文件系统的 `AddEmbedded()` 提供,取值方式完全一致。 ### 文件格式 推荐 `{Resource}.{culture}.json` 命名,并在文件内同时声明 `resource` 与 `culture`: ```json { "resource": "Errors", "culture": "zh-CN", "texts": { "Auth.UserNotFound": "用户不存在。", "Tenant.CodeAlreadyExists": "租户编码已存在。", "CodeGeneration.TableAlreadyConfigured": "数据库表“{0}”已配置,请勿重复导入。" } } ``` - 文本节点从 `texts` 读取,没有 `texts` 时读 `resources`,再没有就读根对象。 - 嵌套对象会被**扁平化**为 `a.b.c`;数组按原始文本存,数字与布尔转成字符串。 - 键的比较大小写不敏感。 - 同一「资源 + 文化」可以由多个文件贡献,按路径顺序合并,后者覆盖前者。 ### 资源名与文化的解析优先级 | 情形 | 资源名取自 | 文化取自 | | --- | --- | --- | | JSON 同时有 `resource` 和 `culture` | JSON 的 `resource` | JSON 的 `culture` | | JSON 无 `culture`,文件名形如 `Errors.zh-CN.json` | 文件名的 `Errors` | 文件名的 `zh-CN` | | 文件名就是纯文化码,如 `zh-CN.json` | 所在目录名 | 文件名 | | 都识别不出 | 所在目录名 | `DefaultCulture` | ::: warning 别只声明 resource 不声明 culture JSON 里写了 `resource` 但没写 `culture` 时,会继续按文件名解析文化;一旦文件名带文化后缀,**文件名前缀会覆盖 JSON 里的 `resource`**。两处保持一致最省心。 另外,形如 `Errors.json`(无文化后缀,`culture` 与 `resource` 两个字段也都没写)会被归到默认文化,且资源名取的是**目录名**而不是 `Errors`——取值时必然 miss。 ::: ## 取文案 注入 `IStringLocalizer` 或 `IStringLocalizer`: ```csharp using Microsoft.Extensions.Localization; public class GreetingService(IStringLocalizer localizer) { // 资源名 = 类型短名 GreetingService public string Hello() => localizer["HelloWorld"]; // 带格式化参数,按当前文化 string.Format public string Welcome(string name) => localizer["Welcome", name]; } ``` 也可以按资源名直接创建,适合与类型无关的公共资源(如 `Errors`、`ApiResponse`): ```csharp public class ErrorTextProvider(IStringLocalizerFactory factory) { public string NotFound() => factory.Create("Errors", "Errors")["Auth.UserNotFound"]; } ``` | 创建方式 | 资源名取值规则 | | --- | --- | | `Create(Type)` / `IStringLocalizer` | 类型短名(`Type.Name`) | | `Create(baseName, location)` | `baseName` 按 `.` `/` `\` 拆分后的**最后一段** | ::: tip 纯 JSON 资源不需要 .resx `Create(baseName, location)` 的 ResourceManager 兜底会尝试把 `location` 当程序集加载。像 `Errors` 这种没有 backing 程序集的纯 JSON 资源,工厂会把兜底降级为「无兜底」,取值仍走 JSON,不会因为加载程序集失败而崩。 ::: ### 取值链与回退 一次取值按下面顺序找,命中即返回: 1. 按**文化回退链**逐个文化查;每个文化下先查指定资源,miss 再查 `DefaultResourceName`(默认 `Default`)。 2. JSON 全 miss → 回退 `IStringLocalizer`(ResourceManager 或空实现)。 3. 仍 miss → 返回 `LocalizedString(name, name, resourceNotFound: true)`,即页面上看到的是**键名本身**。 文化回退链的构成: | 顺序 | 内容 | 受控开关 | | --- | --- | --- | | 1 | 当前文化(`CurrentUICulture` 或 `WithCulture` 指定的固定文化) | — | | 2 | 逐级父文化(`zh-CN` → `zh`) | `FallbackToParentCultures` | | 3 | `DefaultCulture` | `FallbackToDefaultCulture` | ## 请求文化与 X-Language ```text X-Language 头(头名可配) ──在受支持文化内?──▶ 用它 └─否─▶ Accept-Language(按 q 权重降序逐个试) ──命中?──▶ 用它 └─否─▶ DefaultCulture ``` - 头名由 `CultureHeaderName` 配置,默认 `X-Language`。 - `SupportedCultures` 非空时,只接受**列表内**的文化(大小写不敏感);列表为空表示不限制,只要求是合法文化码。 - 解析结果写入 `HttpContext.Items`,键为常量 `XiHanRequestCultureMiddleware.CultureItemKey`(`"__XiHanCulture"`),方便日志与请求上下文取用。 - 中间件在 `finally` 中还原线程的 `CurrentCulture` / `CurrentUICulture`,避免线程池复用时残留请求级文化。 ::: warning 白名单是精确匹配,不做父文化归并 `SupportedCultures` 配 `["zh-CN", "en-US"]` 时,客户端发 `X-Language: en` **不会**命中 `en-US`——它会继续往下走 `Accept-Language`,最终多半落到默认文化。 要么让前端发完整文化码,要么把 `en` 也加进白名单。 ::: ::: tip 后台任务没有请求文化 中间件只覆盖请求管线。定时任务、队列消费者等后台线程用的是进程默认文化,需要指定语言时显式创建带固定文化的本地化器(`XiHanJsonStringLocalizer.WithCulture(culture)`),或在枚举查询里传 `CultureName`。 ::: ## 枚举本地化 枚举展示文案的单一事实源是本地化资源,默认资源名由 `EnumResourceName` 决定(默认 `Enums`): ```json { "resource": "Enums", "culture": "en-US", "texts": { "EnableStatus.Disabled": "Disabled", "EnableStatus.Enabled": "Enabled", "UserGender.Unknown": "Unknown" } } ``` 通过 `IEnumLocalizationService` 取: ```csharp public class EnumMetadataService(IEnumLocalizationService enumLocalization) { // 按类型取,未指定文化时用当前 UI 文化 public LocalizedEnumDefinition ByType() => enumLocalization.Get(typeof(EnableStatus)); // 按类型名取(短名或完整名皆可) public LocalizedEnumDefinition ByName() => enumLocalization.Get("EnableStatus", new EnumLocalizationQuery { CultureName = "en-US", IncludeHidden = false, Ordered = true }); // 批量与安全版本 public IReadOnlyDictionary Many() => enumLocalization.GetMany(["EnableStatus", "UserGender"]); } ``` 返回的 `LocalizedEnumDefinition.Items` 每项都带 `Name`、`Value`、`Label`、`Description`、`Theme`、`Icon`、`Order`、`Hidden`、`Disabled`、`Extra`,前端可以直接渲染下拉与状态标签。 ### 键的候选顺序 对每个枚举项,按下表**依次**尝试,第一个命中的即为 `Label`;全部 miss 则回退到该项的 `Description`(来自 `[EnumDisplay]` / `[Description]` 等特性)。 | 顺序 | 候选键 | 来源 | | --- | --- | --- | | 1 | 字段显式指定的键 | `[EnumLocalizationKey("...")]` 或 `[EnumDisplay(LocalizationKey = "...")]` | | 2 | `{前缀}.{枚举名}.{字段名}` | 前缀取 `[EnumLocalizationResource(KeyPrefix = "...")]`,为空时取 `EnumLocalizationKeyPrefix`;两者都为空则跳过本行 | | 3 | `{枚举名}.{字段名}` | 约定(**推荐用这一种**) | | 4 | `{枚举名}_{字段名}` | 约定 | | 5 | `{字段名}` | 约定 | 资源名的选取顺序:字段上的 `[EnumLocalizationResource]` → 枚举类型上的 `[EnumLocalizationResource]` → `EnumResourceName`;解析结果为空时兜底 `DefaultResourceName`。 ::: warning 短名冲突会解析失败 首次按名称取值时才扫描已加载程序集建立枚举索引(`Get(Type)` 不走该索引)。若两个不同命名空间存在**同名枚举**,该短名会被判为二义并从短名索引中移除——此时 `Get("XxxStatus")` 抛 `KeyNotFoundException`,必须改用完整名或 `Get(Type)`。 用 `TryGet` 可以拿到 `false` 而不是异常。 ::: ## 可本地化异常 面向用户的错误消息不要硬编码中文。抛异常时传一个 `ResourceLocalizableString`,同时给一句回退文案: ```csharp using XiHan.Framework.Core.Exceptions; using XiHan.Framework.Localization.Abstractions; // 无参 throw new UserFriendlyException( new ResourceLocalizableString("Errors", "Auth.UserNotFound"), "用户不存在。"); // 带格式化参数(对应 JSON 里的 {0}) throw new UserFriendlyException( new ResourceLocalizableString("Errors", "CodeGeneration.TableAlreadyConfigured", tableName), $"数据库表“{tableName}”已配置,请勿重复导入。"); ``` 机制: - `BusinessException.LocalizableMessage` 以 `object` 弱类型承载可本地化消息,`UserFriendlyException` 的第二个构造函数负责写入。 - 真正解析发生在 `XiHanApiResponseResultFilter`:识别出 `ILocalizableString` 后按**当前请求文化**解析,缺键时回退到异常的 `Message`(也就是你传的那句回退文案)。 - 未携带可本地化消息的 500 响应,会按资源 `ApiResponse`、键 `ServerError` 本地化通用提示;正常响应的默认消息按业务码名(如 `Success`、`BadRequest`)在同一资源里查。 ::: tip 回退文案不是可选项 第二个参数同时充当「本地化键缺失时的展示文案」和「日志里的异常消息」。省掉它,键一旦写错,用户看到的就是空消息。 ::: 其它可本地化构件: | 类型 | 用途 | | --- | --- | | `FixedLocalizableString` | 固定文本,直接返回原值;`"文本".ToFixedLocalizableString()` 可快速构造 | | `ResourceLocalizableString(Type, name, args)` | 按资源类型解析,等价于 `IStringLocalizer` | | `IHasNameWithLocalizableDisplayName` | 「有标识名 + 可本地化显示名」的对象契约,配合 `GetLocalizedDisplayName(factory)` 使用 | | `LocalizeOrFallback(factory, fallback)` | 解析并在缺键时回退,避免到处判 `ResourceNotFound` | ## 动态热重载 `EnableDynamicJsonReload` 默认 `true`。资源存储同时监听两个来源: | 触发源 | 条件 | | --- | --- | | 虚拟文件系统 `OnFileChanged` | 变更文件在 `ResourcesPath` 之下且扩展名为 `.json` | | `IOptionsMonitor` 变更 | 重算资源路径、重挂监听 | 命中后重置整份缓存并递增 `JsonLocalizationResourceStore.Version`,下次取值时按需重新加载。`Version` 可以作为上层缓存(例如前端字典缓存)的失效依据。 ::: tip 改文案不用重启,改配置也不用 开发期直接改运行目录下的 JSON 即可即时生效。生产若不希望文件监听开销,把 `EnableDynamicJsonReload` 置 `false`。 ::: ## 配置 配置节 `XiHan:Localization`,常用几项: ```json { "XiHan": { "Localization": { "ResourcesPath": "/Localization", "DefaultCulture": "zh-CN", "SupportedCultures": ["zh-CN", "en-US"], "CultureHeaderName": "X-Language", "DefaultResourceName": "Default", "EnumResourceName": "Enums", "EnableDynamicJsonReload": true } } } ``` | 键 | 默认值 | 什么时候需要改 | | --- | --- | --- | | `ResourcesPath` | `/Localization` | 资源不放默认目录时 | | `DefaultCulture` | `zh-CN` | 主语言不是中文时 | | `SupportedCultures` | `["zh-CN", "en-US"]` | 新增语言时**必须**同步加,否则请求头切不过去 | | `CultureHeaderName` | `X-Language` | 前端约定了别的头名时 | | `DefaultResourceName` | `Default` | 想换通用文案的兜底资源名时 | | `EnumResourceName` | `Enums` | 枚举文案不放 `Enums` 资源时 | | `EnableDynamicJsonReload` | `true` | 生产想关掉文件监听时 | 完整配置表见 [Localization 包](../packages/localization)。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 页面显示的是键名本身 | JSON 与 ResourceManager 都没命中该键 | 核对资源名、文化码、扁平化后的完整键 | | 文件放了但完全不生效 | 未随输出拷贝,或不在 `ResourcesPath` 之下 | 加 `CopyToOutputDirectory`,确认运行目录下确有该文件 | | 资源名对不上 | 文件名无文化后缀,资源名回落成了**目录名** | 用 `{Resource}.{culture}.json`,或在 JSON 内声明 `resource` + `culture` | | 发 `X-Language: en` 不切换 | 白名单精确匹配,`en` ≠ `en-US` | 发完整文化码,或把 `en` 加入 `SupportedCultures` | | 切了语言但枚举标签还是中文 | 枚举键写法与候选顺序不符 | 统一用 `{枚举名}.{字段名}`,并确认资源名是 `EnumResourceName` | | `Get("XxxStatus")` 抛 `KeyNotFoundException` | 不同命名空间存在同名枚举,短名二义 | 用完整名或 `Get(Type)`;不确定时用 `TryGet` | | 异常消息没被翻译 | 抛的是普通异常,或没传 `ILocalizableString` | 用 `UserFriendlyException(new ResourceLocalizableString(...), 回退文案)` | | 后台任务的文案永远是默认语言 | 后台线程不经过请求文化中间件 | 显式指定文化(`WithCulture` 或 `EnumLocalizationQuery.CultureName`) | | 自定义的 `IStringLocalizerFactory` 不生效 | 本模块用 `Replace` 覆盖了该服务 | 在本模块之后再次 `Replace` | ## 下一步 - [配置与选项](./configuration) — `XiHan:Localization` 所在的配置体系与选项绑定方式 - [Web 应用开发](./web) — 请求管线顺序,中间件在哪一段接入 - [常见问题](./faq) — 框架层通用排查清单 - [Localization 包](../packages/localization) — 完整 API 清单与全部配置项 - [Localization.Abstractions 包](../packages/localization-abstractions) — `ILocalizableString` 等契约 - [VirtualFileSystem 包](../packages/virtual-file-system) — JSON 资源的加载与变更监听 --- 来源:https://framework.docs.xihanfun.com/guide/logging # 日志 框架把 Serilog 接成唯一的日志出口:控制台 + 异步滚动文件,每行自带 W3C TraceId。这一章讲怎么写日志才可聚合、TraceId 是怎么串起来的、以及哪些配置项写了也不会生效。 完整 API 与全部配置项见 [Logging 包](../packages/logging)。 ## 先分清五件事 | 你要做的 | 用什么 | 落到哪 | | --- | --- | --- | | 普通业务日志 | `ILogger`(原生 `Microsoft.Extensions.Logging`) | Serilog 管道 → 控制台 + 文件 | | 带一坨对象的结构化日志 | `IStructuredLogger` | 同上(属性进事件,见下方模板陷阱) | | 耗时计量 | `IPerformanceLogger` | 同上 | | 登录/操作/接口/异常留痕 | Auditing 包的各 Pipeline | 数据库,见 [Auditing 包](../packages/auditing) | | 请求级字段暂存 | `ILogContext` | 内存数据袋,**不自动进日志** | ::: tip 日常写日志就用 `ILogger` 本包不要求你换接口。`AddSerilog` 把 Serilog 设为日志提供程序后,注入原生 `ILogger` 写出的日志就已经走本模块配置好的管道了。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Logging ``` ```csharp [DependsOn(typeof(XiHanLoggingModule))] public class MyModule : XiHanModule { } ``` `XiHanApplicationModule` 已经 `[DependsOn(typeof(XiHanLoggingModule))]`,应用层模块通常不必再声明一次。 模块做两件事: - `PreConfigureServices` 先写入默认值 `IsEnabled = true`、`MinimumLevel = Information`; - `ConfigureServices` 调 `AddXiHanLogging(config)`,绑定配置节 `XiHan:Logging`(后绑定的配置值覆盖上面的默认值),注册日志服务,并装配 Serilog。 注册结果: | 服务 | 实现 | 生命周期 | | --- | --- | --- | | `IXiHanLoggerFactory` | `XiHanLoggerFactory` | Singleton | | `IXiHanLogger` / `IXiHanLogger<>` | `XiHanLogger` / `XiHanLogger<>` | Transient | | `IStructuredLogger` | `StructuredLogger` | Singleton | | `IPerformanceLogger` | `PerformanceLogger` | Singleton | | `ILogContext` | `LogContext` | Scoped | 全部走 `TryAdd*`,应用侧提前注册自己的实现即可覆盖。 ## 结构化日志约定 Serilog 存的是「消息模板 + 具名属性」,不是拼好的字符串。写法决定了这条日志能不能被检索和聚合。 ```csharp public class OrderAppService(ILogger logger) { public async Task PayAsync(long orderId, decimal amount) { // 好:模板固定,orderId/amount 作为独立属性存下来 logger.LogInformation("订单支付完成 {OrderId},金额 {Amount}", orderId, amount); // 好:整个对象解构成结构化属性 logger.LogInformation("支付回调 {@Callback}", callback); // 差:字符串插值,每条日志都是不同模板,属性全丢 logger.LogInformation($"订单支付完成 {orderId},金额 {amount}"); } } ``` 约定: | 约定 | 说明 | | --- | --- | | 用具名占位符,不用字符串插值 | 插值后模板不再固定,无法按模板聚合,属性也不复存在 | | 占位符用 PascalCase | 与框架内置日志(`{TraceId}`、`{StatusCode}`、`{Elapsed}`)保持一致 | | 对象前缀 `@` 解构 | `{@Order}` 展开成结构化属性;不加 `@` 只调 `ToString()` | | 异常放第一个参数 | `logger.LogError(ex, "…")`,不要把 `ex.Message` 拼进消息 | | 高基数值(订单号、用户 ID)作为属性 | 不要拼进消息文本,否则检索不到 | ::: warning `IXiHanLogger` 不绑定调用方的模板 `XiHanLogger` 内部固定使用模板 `"{Message}{Args}"`:你传进去的 `message` 整体成为一个 `Message` 属性,`args` 整体成为一个 `Args` 属性。 也就是说 `LogInfo("用户 {UserId} 登录", userId)` 里的 `{UserId}` **不会被替换**,输出的是字面量加上参数数组。 需要占位符绑定就用 `ILogger`。`IXiHanLogger` 适合消息本身已经拼好、只求一行简短 API 的场景。 ::: ### `IStructuredLogger`:挂一坨数据 ```csharp public class OrderService(IStructuredLogger logger) { public void CreateOrder(long orderId, decimal amount) { logger.LogBusiness("OrderCreated", new { OrderId = orderId, Amount = amount }); logger.LogEvent("PaymentRequested", new { OrderId = orderId }); logger.LogInformation("库存已锁定", new { OrderId = orderId, Sku = "A-001" }); } } ``` 实现是把数据 `PushProperty` 进 Serilog 的 `LogContext` 后再写一条日志,附加的属性名固定为: | 方法 | 附加属性 | 消息模板 | | --- | --- | --- | | `LogInformation/LogWarning/LogError/Log` | `StructuredData` | 你传入的 message 原样作为模板 | | `LogEvent` | `EventName`、`EventData` | `Event: {EventName}` | | `LogBusiness` | `BusinessAction`、`BusinessData` | `Business Action: {BusinessAction}` | ::: warning 默认输出模板不渲染这些属性 默认的控制台/文件模板里只有 `{Timestamp}`、`{Level}`、`{TraceId}`(文件模板还有 `{SpanId}`)、`{SourceContext}`、`{Message}`、`{Exception}`,**没有 `{Properties}`**。`StructuredData`、`BusinessData` 这些属性确实写进了日志事件,但在默认文本输出里看不见。 要看到它们:把 `{Properties}` 加进 `ConsoleOutputTemplate` / `FileOutputTemplate`,或者在应用侧自行接一个 JSON 格式的 Sink。 ::: ### `IPerformanceLogger`:耗时计量 ```csharp public class ReportService(IPerformanceLogger perf) { public void Generate() { using var timer = perf.StartTimer("GenerateReport"); timer.AdditionalData = new { Rows = 12000 }; // 释放时自动写出 Operation {OperationName} completed in {Duration}ms } } ``` 还有 `LogOperation`、`LogApiCall`、`LogDatabaseQuery`、`LogMemoryUsage`、`LogCpuUsage`,全部以 `Information` 级别写出。其中 `LogOperation` / `LogApiCall` / `LogDatabaseQuery` 带可选 `additionalData` 参数,模板末尾渲染 `{@AdditionalData}`;`LogMemoryUsage` / `LogCpuUsage` 没有这个参数,模板里也没有 `{@AdditionalData}`。 ::: tip 两条性能日志路径的门控不一样 `IPerformanceLogger` 无条件写出;而 `IXiHanLogger.LogPerformance(...)` 受 `EnablePerformanceCounters` 门控,该项默认 `false`,也就是默认什么都不写。 ::: ### `ILogContext` 是数据袋,不是 enricher `ILogContext`(Scoped)带 `UserId`、`UserName`、`TenantId`、`RequestId`、`TraceId`、`SessionId`、`IpAddress`、`UserAgent` 和自定义 `Properties`,`CreateScope` 进出作用域会自动还原原值。 ::: warning 它不会让日志自动带上这些字段 `LogContext` 的实现只是一个进程内字典,框架没有把它接进 Serilog 的属性增强链。设了 `UserId` 不等于日志行里就有 `UserId`。 要让一段代码内的所有日志都带上某属性,用其中之一: ```csharp // 方式一:Serilog 的上下文(AddXiHanSerilog 已启用 Enrich.FromLogContext) using (Serilog.Context.LogContext.PushProperty("TenantId", tenantId)) { // 这里写的日志都带 TenantId 属性 } // 方式二:M.E.L 的作用域 using (logger.BeginScope(new Dictionary { ["TenantId"] = tenantId })) { // 同上 } ``` 属性写进事件之后,同样要模板里出现 `{TenantId}` 或用 JSON Sink 才看得见。 ::: ## TraceId 如何贯穿 日志行首那对方括号里的链路 ID 不是框架填的,是 Serilog 从当前 `Activity` 上取的。 | 环节 | 机制 | 源 | | --- | --- | --- | | 日志行 | Serilog 的 `LogEvent` 自带 `TraceId` / `SpanId`,取自事件创建时的环境 `Activity`;默认模板已把 `{TraceId}` 放在级别之后 | Serilog | | 响应头与 `HttpContext` | `XiHanTraceIdMiddleware` 把 W3C TraceId 镜像进 `HttpContext.Items["__XiHanTraceId"]` 和响应头 `X-Trace-Id` | Web.Api | | 业务取值 | `ITraceIdProvider`(`HttpTraceIdProvider`)与 `ICorrelationIdProvider`(`DefaultCorrelationIdProvider`)都优先返回 `Activity.Current.TraceId` 的 32 位十六进制串 | Web.Api / Core | | 审计留痕 | 访问日志/接口日志/异常日志记录都带 `TraceId` 字段 | Auditing | | 跨进程 | 事件总线发布时把当前 trace id 作为 `correlationId` 随消息发出;消费端据它建 Consumer Span,从而落在同一条 trace 上 | EventBus | 中间件的回退链(`XiHanTraceIdMiddleware`): 1. `Activity.Current.TraceId`(W3C,32 位十六进制); 2. 入站请求头 `X-Trace-Id`; 3. `HttpContext.TraceIdentifier`(Kestrel 的连接内序号)。 ::: warning 两个 TraceId 口径可能不一致 日志模板里的 `{TraceId}` **只**来自 `Activity`;而审计表、响应头里的 TraceId 走的是上面那条带回退的链。没有 `Activity` 时前者渲染为空、后者是 `TraceIdentifier`,两边对不上。 `XiHanTraceIdMiddleware` 注册在管线最前(仅次于 `UseForwardedHeaders`),HTTP 请求内两者一致。 ::: ::: warning 跨进程串联依赖可观测性开关 消费端的 Consumer Span 只在对应 `ActivitySource` 有监听者时才创建,也就是要开 `XiHan:Observability`(`Enabled` + `EnableTracing`)。没开的话生产者与消费者是两条独立的 trace,日志的 TraceId 串不起来。 后台作业、定时任务同理:没有 `Activity` 就没有 TraceId,日志里那对方括号是空的。 ::: 配置与导出见 [Observability 包](../packages/observability)。当前 OTLP 只导出 trace 与 metrics,日志本身不经 OTLP 导出。 ## 日志级别 只有一个总开关 `XiHan:Logging:MinimumLevel`,映射到 Serilog 级别: | `LogLevel` | Serilog `LogEventLevel` | | --- | --- | | `Trace` | `Verbose` | | `Debug` | `Debug` | | `Information` | `Information` | | `Warning` | `Warning` | | `Error` | `Error` | | `Critical` | `Fatal` | | `None` | `Fatal` | ::: danger `None` 不是「关掉日志」 `LogLevel.None` 被映射成 `Fatal`。配 `"MinimumLevel": "None"` 的效果是**只保留致命日志**,不是静默。要静默请把输出目标指向别处,或在应用侧自行装配 Serilog。 ::: ::: warning 没有按分类的级别过滤 `AddXiHanSerilog` 没有配置任何 `MinimumLevel.Override`,`XiHanLoggingOptions.Filters` 字段也没有被读取。也就是说框架装配路径下压不掉 `Microsoft.*` / `System.*` 的噪声,只能整体调高 `MinimumLevel`。需要按分类分级请在应用侧自行装配 Serilog。 ::: `IsEnabled` 是另一件事:它只在 `XiHanLogger` 的方法里做前置判断,`IsEnabled: false` 时 `IXiHanLogger` 系列不写日志,但 `ILogger`、`IStructuredLogger`、`IPerformanceLogger` 照写不误。 ## 输出目标 装配是固定的两路,都在 `AddXiHanSerilog` 里写死: | 目标 | 说明 | | --- | --- | | 控制台 | 模板取 `ConsoleOutputTemplate` | | 文件 | 默认经 `WriteTo.Async` 异步写入(`EnableAsyncLogging` 配 `false` 时改挂同步文件 Sink),模板取 `FileOutputTemplate`,滚动/保留/大小按配置 | 统一附加:`Enrich.FromLogContext()` 与固定属性 `Application = "XiHanFramework"`(不可配置)。 文件相关配置项: | 键 | 默认值 | 说明 | | --- | --- | --- | | `FileOutputPath` | `logs/xihan-.log` | 基名,Serilog 按滚动间隔在扩展名前插日期,如 `logs/xihan-20260805.log` | | `RollingInterval` | `Day` | 滚动间隔 | | `RetainedFileCountLimit` | `31` | 保留文件数,`null` 表示永久保留 | | `FileSizeLimitBytes` | `104857600`(100 MB) | 单文件上限 | | `RollOnFileSizeLimit` | `true` | 达到上限时切新文件 | ::: warning 两路 Sink 都关不掉,也没有追加钩子 没有开关能单独禁用控制台或文件;`AddXiHanSerilog` 也没有对外暴露「再挂一个 Sink」的入口。要写 Seq / Elasticsearch / OTLP,就在应用侧自行装配 Serilog 管道,不要指望通过 `XiHan:Logging` 配出来。 ::: ### 声明了但当前不生效的选项 这些字段存在于 `XiHanLoggingOptions`,但当前实现中没有任何代码读取它们。配了不会报错,也不会有效果: | 字段 | 现状 | 替代做法 | | --- | --- | --- | | `ContextProperties` | 未加入 enricher | `Serilog.Context.LogContext.PushProperty` | | `Filters` | 未转成 `MinimumLevel.Override` | 应用侧自行装配 Serilog | | `EnableRequestLogging` / `RequestLoggingExcludePaths` | 无消费方 | 请求日志由 Web.Api 的中间件负责,排除路径见 [Auditing 包](../packages/auditing) 的 `IgnoredPathPrefixes` | 同样地,`XiHanFileLoggerOptions` 的 `BufferSize`、`FlushPeriod`、`EnableAsyncWrite` 也没有被对应的 Provider 读取:该 Provider 每条日志都同步 `File.AppendAllText`,既无缓冲也无异步,配了不会有效果。高吞吐场景请改用本模块的 Serilog 文件 Sink。 `XiHanConsoleLoggerOptions` 的 `UseStdErrorForErrors` 已经生效:为 `true` 时 `Error` 及以上写标准错误流,其余级别仍写标准输出;默认 `false`,即全部写标准输出。 `XiHanLoggingOptions` 的 `EnableAsyncLogging`、`AsyncBufferSize`、`BlockWhenFull` 也已经生效:`EnableAsyncLogging` 为 `true`(默认)时文件 Sink 包一层 `WriteTo.Async`,`AsyncBufferSize` 与 `BlockWhenFull` 分别透传为它的 `bufferSize` 与 `blockWhenFull`;为 `false` 时直接挂同步文件 Sink,写调用返回即落盘。默认值 `true` / `10000` / `false` 与 `WriteTo.Async` 的默认值一致,因此不改这三个键的宿主行为不变。 ## 配置 只列当前真正生效的键: ```json { "XiHan": { "Logging": { "IsEnabled": true, "MinimumLevel": "Information", "ConsoleOutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{TraceId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", "FileOutputPath": "logs/xihan-.log", "FileOutputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{TraceId} {SpanId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", "RollingInterval": "Day", "RetainedFileCountLimit": 31, "FileSizeLimitBytes": 104857600, "RollOnFileSizeLimit": true, "EnableAsyncLogging": true, "AsyncBufferSize": 10000, "BlockWhenFull": false, "EnableStructuredLogging": true, "EnablePerformanceCounters": false } } } ``` 配置节常量为 `XiHanLoggingOptions.SectionName`。全部字段清单见 [Logging 包](../packages/logging)。 ## 自建 ILoggerProvider 的适用场景 本包另外提供两个原生 `ILoggerProvider`: ```csharp builder.Logging.AddXiHanConsoleLogger(o => { o.MinLevel = LogLevel.Debug; o.SingleLine = true; o.LogFormat = "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Category}: {Message}{Exception}"; }); builder.Logging.AddXiHanFileLogger(o => o.FilePath = "logs/app-.log"); ``` ::: danger 和本模块的 Serilog 管道互斥 `AddSerilog` 默认 `writeToProviders: false`——Serilog 不会把事件转发给通过 `Microsoft.Extensions.Logging` 注册的 `ILoggerProvider`。所以在启用了 `XiHanLoggingModule` 的应用里挂这两个 Provider,**它们收不到任何日志**。 它们的用途是在不走本模块 Serilog 管道的宿主(工具、独立进程)里当轻量输出。 ::: ::: warning 两个 Options 必须用 lambda 配 `XiHanFileLoggerOptions.SectionName` / `XiHanConsoleLoggerOptions.SectionName` 只是常量,`AddXiHanFileLogger` / `AddXiHanConsoleLogger` 内部并没有绑定这两个配置节。写进 `appsettings.json` 的 `XiHan:Logging:File` / `XiHan:Logging:Console` 不会被读取。 ::: ## 引导日志 `XiHanLoggerBuilder` 是一套与 DI 装配完全独立的 Fluent 构建器,用于 DI 就绪前的引导日志: ```csharp var bootstrapLogger = new XiHanLoggerBuilder().CreateLoggerDefault(); ``` `CreateLoggerDefault()` 给出的是另一套默认策略:按级别分目录写文件(`Logs/Debug/`、`Logs/Info/`、`Logs/Waring/`、`Logs/Error/`、`Logs/Fatal/`,路径相对 `AppContext.BaseDirectory` 且强制转小写),单文件 10 MB、保留 60 个,并对 `Microsoft`、`System` 两个源做 `Warning` 覆盖。 ::: warning 别把两套默认值混为一谈 `XiHanLoggerConfigurationBuilder` 的模板、目录、保留策略与 `XiHanLoggingOptions` 毫无关系,改 `XiHan:Logging` 不会影响引导日志。 ::: ## 别和 Utils 里的静态日志混用 `XiHan.Framework.Utils.Logging` 下的 `LogHelper` / `LogFileHelper` 是一套独立的静态工具,自带配置与文件写入,**不经过 Serilog 管道**,输出也不带 TraceId。应用代码请统一走 `ILogger`。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 日志行里 `[]` 是空的,没有 TraceId | 当前执行流没有 `Activity`。HTTP 请求内正常有值;后台作业、定时任务里没有 | | 事件总线消费端的 TraceId 和生产端对不上 | 没开 `XiHan:Observability`,消费端不建 Consumer Span | | `IStructuredLogger` 写的数据在日志里看不到 | 默认模板没有 `{Properties}`,属性进了事件但不渲染 | | `IXiHanLogger.LogInfo("… {UserId} …", id)` 没替换占位符 | 该实现固定用 `"{Message}{Args}"` 模板,不绑定调用方模板 | | `MinimumLevel: "None"` 之后只剩致命日志 | `None` 被映射为 `Fatal` | | `IsEnabled: false` 但日志照写 | 该开关只作用于 `IXiHanLogger` 系列 | | `LogPerformance` 什么都不写 | `EnablePerformanceCounters` 默认 `false`(`IPerformanceLogger` 不受此限) | | `AddXiHanConsoleLogger()` / `AddXiHanFileLogger()` 没有输出 | Serilog 作为日志提供程序时默认不向 M.E.L 的 Provider 转发事件 | | `Filters` / `RequestLoggingExcludePaths` 配了没反应 | 当前实现未读取这些字段 | | `ILogContext` 设了 `UserId` 但日志里没有 | 它是内存数据袋,没接进 Serilog 的属性增强链 | | 后台任务里 `ILogContext` 每次都是新实例 | Scoped,无 DI 作用域时需自建 `IServiceScope` | | 日志文件名带日期后缀 | Serilog 按 `RollingInterval` 自动插入,`FileOutputPath` 只给基名 | ## 下一步 - [配置与选项](./configuration):`XiHan:Logging` 的绑定与覆盖顺序 - [Web 应用开发](./web):TraceId 中间件在请求管线中的位置 - [Auditing 包](../packages/auditing):登录/操作/接口/异常日志落库 - [Observability 包](../packages/observability):链路追踪与指标,TraceId 的来源 - [Logging 包](../packages/logging):完整 API 清单与全部配置项 --- 来源:https://framework.docs.xihanfun.com/guide/mapping # 对象映射与序列化 实体和 DTO 之间怎么搬数据,对象和 JSON 之间怎么往返。这两件事分散在三个包里,找错地方是最常见的浪费时间点。 ## 三个包,各管一段 | 你要做的事 | 去哪 | 形态 | | --- | --- | --- | | 实体 ↔ DTO 批量赋值 | `XiHan.Framework.ObjectMapping` | 集成 Mapster,注册 `IMapper` | | 不改类定义给对象挂动态属性 | `XiHan.Framework.ObjectMapping` | `IHasExtraProperties` + `ObjectExtensionManager` | | JSON 转换器、序列化选项、`JsonHelper` | `XiHan.Framework.Utils` 的 `Serialization/Json` | 全静态,无需 DI | | 结构不固定的 JSON(免 DTO) | `XiHan.Framework.Serialization` | `DynamicJsonObject` 等动态类型 | | Web 出入参的 JSON 约定 | `XiHan.Framework.Web.Api` | `XiHanWebCoreMvcOptions` | ::: warning 序列化选项不在 Serialization 包里 `JsonConverterFactory`、`JsonSerializeOptions`、`JsonHelper` 全部位于 **`XiHan.Framework.Utils`** 的 `Serialization/Json` 目录。 `XiHan.Framework.Serialization` 包提供的是**动态 JSON** 那一套;它的模块类 `XiHanSerializationModule.ConfigureServices` 是空实现,不注册任何服务,能力全部通过静态类调用。 ::: ## 安装与启用 对象映射需要引模块: ```bash dotnet add package XiHan.Framework.ObjectMapping ``` ```csharp [DependsOn(typeof(XiHanObjectMappingModule))] public class MyModule : XiHanModule; ``` `XiHanObjectMappingModule.ConfigureServices` 里只做一件事:调 `services.AddXiHanMapster()`,把 `MapsterMapper.IMapper` 注册为 **Transient**(实现类 `MapsterMapper.Mapper`)。`ExtensionPropertyPolicyChecker` 实现 `ITransientDependency`,由约定注册自动接入。`ObjectExtensionManager` 是静态单例,不走 DI。 JSON 那部分不需要额外装包:`XiHan.Framework.Utils` 是全框架的基础依赖,直接 `using XiHan.Framework.Utils.Serialization.Json;` 即可。 ## 对象映射 ### 框架默认走的是 Mapster 静态扩展 `CrudApplicationServiceBase` 的映射方法用的是 Mapster 的静态扩展 `Adapt()`,不是注入的 `IMapper`: ```csharp protected virtual Task MapEntityToDtoAsync(TEntity entity) { var dto = entity.Adapt(); return Task.FromResult(EnsureNotNullMapping(dto)); } ``` 这一组方法都是 `protected virtual`,字段对不上时**重写它们**是最直接的做法: | 方法 | 时机 | | --- | --- | | `MapEntityToDtoAsync(TEntity)` | 单条实体转 DTO | | `MapEntitiesToDtosAsync(IEnumerable)` | 列表转 DTO 列表 | | `MapDtoToEntityAsync(TEntityDto)` | DTO 转新实体 | | `MapDtoToEntityAsync(TEntityDto, TEntity)` | DTO 写入已有实体 | | `MapDtoToEntityAsync(TCreateDto)` | 创建 DTO 转新实体 | | `MapDtoToEntityAsync(TUpdateDto, TEntity)` | 更新 DTO 写入已有实体 | ```csharp protected override Task MapEntityToDtoAsync(SysUser entity) { var dto = entity.Adapt(); dto.DisplayName = $"{entity.NickName}({entity.UserName})"; return Task.FromResult(dto); } ``` ### 注入 IMapper 自己的应用服务里想用 DI 版本,直接注入: ```csharp using MapsterMapper; public class UserAppService(IMapper mapper) { public UserDto ToDto(SysUser entity) => mapper.Map(entity); } ``` ::: tip 两条路走的是同一份配置 框架只注册了 `IMapper`,没有向 DI 注册任何 `TypeAdapterConfig`。因此 `Adapt()` 与注入的 `IMapper` 解析的是同一份 Mapster 全局配置——自定义映射规则用 Mapster 原生 API 在应用启动阶段注册一次,两边都生效。 `IMapper` 是 **Transient**,按需注入即可,不要缓存成长生命周期字段。 ::: ## 动态扩展属性 不改类定义、给对象挂额外属性并参与序列化与持久化。 ### 三步 ```csharp using XiHan.Framework.ObjectMapping; using XiHan.Framework.ObjectMapping.Extensions; using XiHan.Framework.ObjectMapping.Extensions.Data; // 1. 类实现 IHasExtraProperties public class Customer : IHasExtraProperties { public ExtraPropertyDictionary ExtraProperties { get; } = new(); } // 2. 启动阶段注册扩展属性定义(TObject 必须实现 IHasExtraProperties) ObjectExtensionManager.Instance .AddOrUpdateProperty("VipLevel", property => { property.DefaultValue = "Normal"; }); // 3. 运行期读写 var customer = new Customer(); customer.SetProperty("VipLevel", "Gold"); var level = customer.GetProperty("VipLevel"); ``` `SetProperty` / `RemoveProperty` 返回源对象,可以链式调用。 ### 关键机制 | 机制 | 行为 | | --- | --- | | 定义存放 | `ObjectExtensionManager.Instance` 静态单例,`ConcurrentDictionary` 按类型索引 | | 写入验证 | `SetProperty` 的 `validate` 默认 `true`,走 `ExtensibleObjectValidator.CheckValue`,失败抛 `XiHanValidationException` | | 类型自动特性 | `ExtensionPropertyHelper.GetDefaultAttributes` 对非可空基元类型和枚举自动追加 `RequiredAttribute`,枚举再追加 `EnumDataTypeAttribute` | | 默认值优先级 | `DefaultValueFactory` > `DefaultValue` > 类型默认值 | | 代理穿透 | 验证前用 `ProxyHelper.UnProxy` 取真实类型,AOP 代理过的对象也能命中定义 | ::: warning `GetProperty` 只吃扩展原始类型 泛型版按 `TypeHelper.IsPrimitiveExtended(includeEnums: true)` 放行:基元类型、枚举、`string`、`decimal`、`DateTime`、`DateTimeOffset`、`TimeSpan`、`Guid`,以及它们的可空版本。转换分三路:`Guid` 走 `TypeConverter`,枚举走 `Enum.Parse`,其余走 `Convert.ChangeType`。类型不在此列会抛 `XiHanException`——复杂类型请用弱类型 `GetProperty(name)` 自己转。 ::: ::: warning 没注册过的属性名不会报错 `ExtensibleObjectValidator` 在两处直接返回:类型没在 `ObjectExtensionManager` 注册过、或该属性名没定义过。也就是说 `SetProperty("Typo", value)` 即使 `validate: true` 也**静默写进字典**,不会抛异常。属性名建议定为常量集中管理。 ::: ### 访问策略当前是骨架 `ExtensionPropertyPolicyChecker.CheckPolicyAsync` 依次校验全局功能、功能开关、权限三段,每段支持「要求全部」与「要求任一」。但三个具体判定方法都是 `protected virtual` 且**默认实现恒返回 `true`**: ```csharp protected virtual Task CheckGlobalFeaturesAsync(string featureName) => Task.FromResult(true); protected virtual Task CheckFeaturesAsync(string featureName) => Task.FromResult(true); protected virtual Task CheckPermissionsAsync(string permissionName) => Task.FromResult(true); ``` 所以 `GetPropertiesAndCheckPolicyAsync` 开箱即用时会**返回全部属性**。要真正门控,必须派生 `ExtensionPropertyPolicyChecker` 重写这三个方法并替换注册。 ```csharp var visible = await ObjectExtensionManager.Instance .GetPropertiesAndCheckPolicyAsync(serviceProvider); ``` ## JSON 转换器 `JsonConverterFactory`(命名空间 `XiHan.Framework.Utils.Serialization.Json`)按用途分组提供转换器,共 28 个(每个类型都配了可空版本)。 | 方法 | 覆盖类型 | | --- | --- | | `GetNumericConverters()` | `int` `long` `float` `double` `decimal` `byte` `short` `uint` 及其可空版 | | `GetDateTimeConverters(dateFormat, timeFormat, isUtc)` | `DateOnly` `TimeOnly` `DateTime` `DateTimeOffset` 及其可空版 | | `GetCommonConverters()` | `bool` `Guid` 及其可空版 | | `GetAllConverters(...)` | 上面三组合并 | | `ConfigureConverters(this JsonSerializerOptions, ...)` | 把全部转换器加进已有选项,返回该选项 | | `CreateOptions(writeIndented, camelCase, dateFormat, timeFormat, isUtc)` | 新建一份带全部转换器的选项 | 默认格式参数:`dateFormat = "yyyy-MM-dd"`、`timeFormat = "HH:mm:ss"`、`isUtc = false`。 ### 写出端的行为差异 | 类型 | 输出 | | --- | --- | | `long` / `long?` | **JSON 字符串**,避免 JS Number 精度溢出 | | 其余数值类型 | JSON 数字 | | `bool` | JSON 布尔 | | `Guid` | 字符串 | | `DateOnly` | `dateFormat`,默认 `2026-08-05` | | `TimeOnly` | `timeFormat`,默认 `12:30:00` | | `DateTime` | `"{dateFormat} {timeFormat}"`,默认 `2026-08-05 12:30:00` | | `DateTimeOffset` | 固定 ISO 8601 带偏移 `yyyy-MM-ddTHH:mm:sszzz`,**不受 dateFormat / timeFormat 影响** | ### 读入端一律容错 所有转换器的读取端都接受字符串形式的数字与布尔:`"123"` 能读成 `int`,`"true"` 和 `1` 都能读成 `bool`。 ::: danger 解析失败不报错,落默认值 读取端匹配不上时返回的是 `default`:`IntJsonConverter` 给 `0`,`GuidJsonConverter` 给 `Guid.Empty`,`DateTimeJsonConverter` 给 `default(DateTime)`,可空版给 `null`。 脏数据不会抛 `JsonException`,而是变成一个看起来正常的零值悄悄进入业务。对外部来源的 JSON,别指望这层帮你挡格式错误,该校验就在 DTO 上加验证特性。 ::: ## Web 出入参约定 `XiHanWebCoreMvcOptions.ConfigureJsonOptionsDefault()` 是全框架 Web 接口的 JSON 事实标准,由 `XiHanWebApiServiceCollectionExtensions` 在装配 MVC 时调用。 | 设置 | 值 | 对接影响 | | --- | --- | --- | | `PropertyNamingPolicy` | `CamelCase` | `UserName` → `userName`;`OAuthProviders` → `oAuthProviders`(只有首字符变小写) | | `DictionaryKeyPolicy` | `CamelCase` | 字典键同样转驼峰 | | `DefaultIgnoreCondition` | `WhenWritingNull` | null 字段整个不出现,客户端按可选处理 | | `PropertyNameCaseInsensitive` | `false` | **请求体属性名必须精确匹配驼峰**,大小写写错等于没传 | | `ReferenceHandler` | `IgnoreCycles` | 循环引用不抛异常,输出 null | | `NumberHandling` | `Strict` | 见下方说明 | | `AllowTrailingCommas` | `true` | 请求体允许尾随逗号 | | `ReadCommentHandling` | `Skip` | 请求体允许注释 | | `Encoder` | `UnsafeRelaxedJsonEscaping` | 中文不转义成 `\uXXXX` | | `WriteIndented` | `true` | 响应带缩进 | 转换器按这个顺序注册(顺序决定优先级): 1. `DateTimeJsonConverter` / `DateTimeNullableConverter` / `DateTimeOffsetJsonConverter` / `DateTimeOffsetNullableConverter`,均传入时区解析委托; 2. `JsonStringEnumConverter`; 3. `ConfigureConverters()` 补齐其余全部转换器(含 `long` 转字符串)。 ::: warning 时区版必须先注册 `System.Text.Json` 取**首个匹配类型**的转换器。时区感知的四个时间转换器必须排在 `ConfigureConverters()` 之前,否则会被后面的普通时间转换器抢先,换时区静默失效。自己往这份选项里加时间转换器时,注意别插到前面去。 ::: ::: tip `NumberHandling = Strict` 为什么还能收字符串数字 自定义转换器完全接管了读取过程,`NumberHandling` 对被转换器覆盖的类型不起作用。所以 `int` / `long` / `decimal` 等仍然同时接受 `123` 和 `"123"`。 ::: ## 时区感知的时间输出 存储统一 UTC,输出按请求头 `X-Timezone`(IANA 标识,如 `Asia/Shanghai`)换算。解析入口: ```csharp private static string? ResolveUserTimeZone() { return HttpContextAccessor.HttpContext?.Request.Headers["X-Timezone"].ToString(); } ``` | 类型 | 带 `X-Timezone` | 无 `X-Timezone` 或时区非法 | | --- | --- | --- | | `DateTime` | 值按 UTC 标记后 `ConvertTimeFromUtc`,输出 `2026-08-05 20:30:00` | 按原值输出 `yyyy-MM-dd HH:mm:ss` | | `DateTimeOffset` | `ConvertTime` 后输出**无偏移墙钟** `2026-08-05 20:30:00` | 输出 ISO 8601 带偏移 `2026-08-05T12:30:00+00:00` | 两种类型换算后的输出格式一致,都是无偏移墙钟——前端拿到直接按本地显示即可,不会被浏览器时区二次偏移。时区标识解析失败时(`TimeZoneInfo.FindSystemTimeZoneById` 抛异常)静默回退到无换算行为,不会让请求失败。 ::: warning 业务时间多为 `DateTimeOffset` 只覆盖 `DateTime` 是不够的。审计字段、业务时间字段大量使用 `DateTimeOffset`,它默认自带偏移,前端会按浏览器时区渲染,看上去"换时区没生效"。四个转换器要一起挂。 ::: ## 枚举 全局挂了 `JsonStringEnumConverter`,枚举默认按**成员名**输出:`Status.Enabled` → `"Enabled"`。 需要某个枚举输出数字时用 `NumericEnumConverter`,但**标注位置决定它到底生效不生效**。 ::: danger 转换器优先级:类型特性打不过全局集合 System.Text.Json 的优先级从高到低是: 1. 标在**属性**上的 `[JsonConverter]` 2. `JsonSerializerOptions.Converters` **集合**里的转换器 3. 标在**类型**(枚举/类)上的 `[JsonConverter]` 框架把 `JsonStringEnumConverter` 加进了第 2 层的集合,所以**标在枚举类型上的 `NumericEnumConverter` 会被压过、完全不生效**。 ```csharp // ❌ 标在类型上:被全局集合压过,仍输出成员名字符串 [JsonConverter(typeof(NumericEnumConverter))] public enum MyStatus { Ok = 200 } // ✅ 标在属性上:优先级最高,确实输出数字 public class MyDto { [JsonConverter(typeof(NumericEnumConverter))] public MyStatus Status { get; set; } } ``` 要在整个应用范围内让某个枚举恒为数字,另一条路是把 `NumericEnumConverter` 也加进 `Converters` 集合,并确保它**排在 `JsonStringEnumConverter` 之前**(集合内先匹配者胜)——但顺序依赖脆弱,优先用属性级标注。 ::: 框架的 `ApiResponse.Code` 就是属性级标注的实例:枚举 `ApiResponseCodes` 类型上也标了同一个转换器(供不经 Web 管道的场景使用),属性上再标一次保证 Web 管道下同样输出数字。 `NumericEnumConverter` 的读取端兼容数字、数字字符串和成员名三种来源,且解析不了时**会抛 `JsonException`**(与其他转换器的静默默认值不同)。 ## 脱离 Web 管道用 `JsonHelper` 提供序列化、反序列化、节点操作、结构比较等静态方法,配套两个选项类: ```csharp using XiHan.Framework.Utils.Serialization.Json; var json = JsonHelper.Serialize(dto, JsonSerializeOptions.WebApi); var back = JsonHelper.Deserialize(json, JsonDeserializeOptions.WebApi); // 不抛异常的版本 if (JsonHelper.TryDeserialize(json, out var result)) { /* … */ } ``` | 类 | 预设 | | --- | --- | | `JsonSerializeOptions` | `Default` / `Compact` / `Formatted` / `Strict` / `WebApi` | | `JsonDeserializeOptions` | `Default` / `Strict` / `Lenient` / `WebApi` | 两个 `WebApi` 预设都会带上 `JsonConverterFactory.GetAllConverters()`,与 Web 管道的类型行为对齐(但不含时区换算,那部分需要 HTTP 上下文)。 ::: danger 部分选项属性当前不生效 `ToSystemOptions()` 只映射它显式列出的字段。以下属性**声明了但没有被消费**,设了也没有效果: | 类 | 无效属性 | | --- | --- | | `JsonSerializeOptions` | `IgnoreNullValues`、`Encoding`(仅 `SerializeToFile` 用它写文件编码) | | `JsonDeserializeOptions` | `IgnoreUnknownProperties`、`UseDefaultValues`、`MaxStringLength`、`MaxArrayLength` | 要省略 null,请直接设 `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`,不要依赖 `IgnoreNullValues`。未知属性 `System.Text.Json` 本来就默认忽略。 另外 `JsonErrorHandling.Log` 当前与 `Ignore` 行为相同(返回默认值,不写日志)。 ::: ## 动态 JSON 结构不固定、不想为每种形状定义 DTO 时,用 `XiHan.Framework.Serialization` 的动态类型: ```csharp using XiHan.Framework.Serialization.Dynamic; dynamic node = DynamicJsonObject.Parse(payload); var name = node.user.name; // 点分路径读写嵌套属性 var city = DynamicJsonHelper.SelectToken(node, "user.profile.city"); DynamicJsonHelper.SetToken(node, "user.profile.city", "杭州"); // 流式构建 var built = DynamicJsonFactory.Object() .AddString("name", "曦寒") .AddNumber("level", 3) .AddObject("profile", b => b.AddString("city", "杭州")) .Build(); ``` 底层完全基于 `System.Text.Json.Nodes`,四个类型分别对应对象、数组、值、属性:`DynamicJsonObject` / `DynamicJsonArray` / `DynamicJsonValue` / `DynamicJsonProperty`。还提供深度合并 `Merge`、深度克隆 `DeepClone`、深度比较 `DeepEquals`、扁平化 `Flatten` / `Unflatten`,以及全套 `Try*` 无异常版本。 需要在已有选项基础上换掉某个转换器时,用 `JsonSerializerOptionsHelper`: ```csharp var options = JsonSerializerOptionsHelper.Create( baseOptions, converter => converter is DateTimeJsonConverter, // 移除谓词 new DateTimeJsonConverter("yyyy/MM/dd HH:mm", false)); // 追加(已存在则跳过) ``` ## 常见问题 | 现象 | 原因 | | --- | --- | | 前端拿到的 `long` 是字符串 | 有意为之,`LongJsonConverter` 防 JS Number 精度溢出;反序列化数字与字符串都收 | | 响应里某个字段整个不见了 | 值为 null,`DefaultIgnoreCondition = WhenWritingNull` 省略了它 | | 请求体某字段收不到 | `PropertyNameCaseInsensitive = false`,属性名大小写必须与驼峰完全一致 | | `oAuthProviders` 这种怪命名 | camelCase 策略只把首字符变小写,不拆词 | | 换了 `X-Timezone` 时间没变 | 只覆盖了 `DateTime` 没覆盖 `DateTimeOffset`;或自加的时间转换器排到了时区版前面 | | 时间格式忽然带上 `+08:00` | 请求没带 `X-Timezone`(或时区标识非法),`DateTimeOffset` 回退到 ISO 8601 带偏移 | | 脏数据变成 0 / `Guid.Empty` / 默认时间 | 转换器读取失败静默落默认值,不抛异常 | | 枚举返回的是数字不是名字 | 该 DTO **属性**上标了 `[JsonConverter(typeof(NumericEnumConverter))]`——属性级优先级最高。标在枚举类型上则无效 | | `IgnoreNullValues = true` 没效果 | 该属性未被 `ToSystemOptions()` 消费,改用 `DefaultIgnoreCondition` | | `SetProperty` 写错属性名没报错 | 类型或属性名未在 `ObjectExtensionManager` 注册时,验证直接跳过 | | `GetPropertiesAndCheckPolicyAsync` 没过滤掉任何属性 | `ExtensionPropertyPolicyChecker` 三个判定方法默认恒 `true`,需派生重写 | | `GetProperty` 抛 `XiHanException` | 泛型版只放行扩展原始类型(基元类型、枚举、`string`、`decimal`、`DateTime`、`DateTimeOffset`、`TimeSpan`、`Guid`) | | 改了 DTO 字段名映射就空了 | Mapster 按名匹配,重写 `MapEntityToDtoAsync` 或注册 Mapster 映射规则 | ## 下一步 - [Web 应用开发](./web):出入参约定在整条管道里的位置 - [动态 API](./dynamic-api):应用服务怎么变成 REST 接口 - [数据访问](./data):实体与仓储,映射的上游 - [ObjectMapping 包](../packages/objectmapping):扩展属性完整 API 与类型清单 - [Serialization 包](../packages/serialization):动态 JSON 完整 API - [Utils 包](../packages/utils):`JsonHelper` 与全部转换器清单 --- 来源:https://framework.docs.xihanfun.com/guide/messaging # 消息通知 给「发一条通知」定义统一的模型和入口:业务代码只组装一个信封、指定通道,具体走邮件、短信还是站内信由路由决定。框架这一层**只做路由**,不含任何通道实现,也不替你排队和重试。 完整 API 与全部字段见 [Messaging 包](../packages/messaging)。 ## 三个角色 | 角色 | 类型 | 职责 | | --- | --- | --- | | 信封 | `MessageEnvelope` | 一条消息的全部内容:通道、主题、正文、模板编码与参数、接收人集合、元数据 | | 调度器 | `IMessageDispatcher` | 按信封的 `Channel` 挑一个发送器,**逐个接收人**调用它,汇总结果 | | 发送器 | `IMessageSender` | 真正干活的一端:`CanHandle(channel)` 声明自己吃哪个通道,`SendAsync` 投递给单个接收人 | 调度器与发送器的分工是这一章的核心: - 调度器**不知道**任何通道细节。它不认识 SMTP、不认识短信签名,只做「选谁」和「循环调」。 - 发送器**不关心**批量与容错策略。它只处理「这一条信封发给这一个人」,成败通过 `MessageSendResult` 返回。 - 所以框架里**没有内置任何真实发送器**。包里唯一的实现是兜底用的 `NotConfiguredMessageSender`,它对任何通道都返回「未配置发送器」的失败结果。 ::: tip 选型:什么时候值得引入这一层 只有一个通道、且调用点很少时,直接注入你自己的服务更简单。当出现「同一段业务要按用户偏好走不同通道」或「通道实现将来会换」时,再让业务改为面向 `IMessageDispatcher` 编程。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Messaging ``` ```csharp [DependsOn(typeof(XiHanMessagingModule))] public class MyModule : XiHanModule { } ``` 模块的 `ConfigureServices` 只调用一次 `services.AddXiHanMessaging()`,注册两样东西: | 注册 | 方式 | 说明 | | --- | --- | --- | | `IMessageDispatcher` → `DefaultMessageDispatcher` | `TryAddSingleton` | 默认路由实现 | | `IMessageSender` 集合追加 `NotConfiguredMessageSender` | `TryAddEnumerable` 单例 | 兜底项,不会遮蔽你注册的真实发送器 | ::: warning 引入事件总线会顺带启用它 `XiHanEventBusModule` 依赖 `XiHanMessagingModule`,所以用了事件总线就一定装上了消息路由。但事件总线**不通过** `IMessageDispatcher` 投递事件,两条链路互不相干,别把它们混为一谈。 ::: ## 写一个发送器 接通道 = 实现 `IMessageSender` + 注册进容器,没有别的接线点。 ```csharp public class SiteMessageSender : IMessageSender { // 通道名建议用不区分大小写比较,调度器只帮你 Trim,不做大小写归一 public bool CanHandle(string channel) => string.Equals(channel, "site", StringComparison.OrdinalIgnoreCase); public async Task SendAsync( MessageEnvelope envelope, MessageRecipient recipient, CancellationToken cancellationToken = default) { // 站内信落库;envelope.TenantId / CorrelationId 由发送器自行取用 await _repository.InsertAsync(...); return new MessageSendResult { MessageId = envelope.MessageId, Channel = envelope.Channel, RecipientAddress = recipient.Address, IsSuccess = true }; } } ``` 注册时**追加**到集合,而不是覆盖: ```csharp services.AddSingleton(); ``` ## 发一条消息 ```csharp public class NoticeAppService { private readonly IMessageDispatcher _dispatcher; public NoticeAppService(IMessageDispatcher dispatcher) => _dispatcher = dispatcher; public async Task NotifyAsync(IReadOnlyList userIds, CancellationToken ct) { var envelope = new MessageEnvelope { Channel = "site", Subject = "系统通知", Content = "您有一条新消息", Recipients = userIds .Select(id => new MessageRecipient { ReceiverId = id, Address = id }) .ToArray() }; IReadOnlyList results = await _dispatcher.DispatchAsync(envelope, ct); var failed = results.Where(item => !item.IsSuccess).ToArray(); // failed[i].ErrorMessage 给出每个接收人的失败原因 } } ``` `MessageRecipient.Address` 是**通道相关**的地址:邮箱、手机号、用户 Id 都塞这里,怎么解释由发送器决定。`ReceiverId` 是业务侧的接收人主键,可选。 ## 路由是怎么选的 `DefaultMessageDispatcher.DispatchAsync` 的流程: | 步骤 | 动作 | 异常路径 | | --- | --- | --- | | 1 | 校验信封非空、`Channel` 非空白 | 分别抛 `ArgumentNullException` / `InvalidOperationException` | | 2 | 接收人为空则直接返回空集合 | —— | | 3 | `Channel.Trim()` 后先找**非兜底**且 `CanHandle` 的发送器 | —— | | 4 | 找不到再回退到任意 `CanHandle` 的(即兜底) | —— | | 5 | 仍然没有 | 按 `ThrowWhenNoSender` 抛异常,或给每个接收人返回失败结果。默认注册下兜底发送器恒匹配,这一步只有把 `NotConfiguredMessageSender` 从集合里移除后才可达 | | 6 | 逐接收人 `SendAsync`,回填结果里缺省的字段 | 失败或抛异常时按 `ContinueOnError` 决定继续还是中断 | 几个必须知道的细节: | 细节 | 行为 | | --- | --- | | 兜底优先级 | `NotConfiguredMessageSender.CanHandle` 恒为 `true`,调度器做**两段式选择**:先找非兜底的,找不到才回退,所以它永远不会挡住真实发送器 | | 同通道多个发送器 | 只命中**第一个**匹配的(按注册顺序),不会依次尝试、也不会广播 | | 结果回填 | 发送器返回的 `MessageId` / `Channel` / `RecipientAddress` 为空时,调度器用信封和接收人的值补上;你在发送器里可以只填 `IsSuccess` 和 `ErrorMessage` | | 异常收敛 | 发送器抛出的异常会被捕获、记 `LogError`,转成一条 `IsSuccess = false` 的结果,**不向外抛** | | 通道名匹配 | 调度器只对 `Channel` 做 `Trim()`,大小写是否敏感取决于各发送器的 `CanHandle` 实现 | ::: warning 循环中的取消不会抛出 `DispatchAsync` 只在进入循环**前**做一次 `ThrowIfCancellationRequested()`。循环里发送器因取消抛出的 `OperationCanceledException` 会被同一个 `catch` 吞掉、变成失败结果。判定「是否被取消」要看结果里的 `ErrorMessage`,别指望 catch 到取消异常。 ::: ## 框架不做后台异步发送 这是最容易误解的一点:`DispatchAsync` 是**同步语义的即时投递**——在你的调用线程上逐个接收人跑完才返回。框架这一层**不提供**: | 不提供的能力 | 后果 | 该由谁做 | | --- | --- | --- | | 入队 / 后台发送 | 接收人多时会拖长当前请求 | 业务层:提交后入队,后台服务拉取消费 | | 重试 | 一次失败就是失败 | 发送器内部,或上层队列的重试机制 | | 持久化 / 发件箱 | 进程退出未发的消息就没了 | 业务层落库 + 补偿 | | 去重 | 同一条可能被重复投递 | 业务层按 `MessageId` 幂等 | | 定时发送 | `ScheduledTime` / `ExpireTime` 调度器**根本不读** | 上层队列或调度器自行解释 | ::: danger 信封上的字段不等于框架会处理 `ScheduledTime`、`ExpireTime`、`TenantId`、`CorrelationId`、`TemplateCode`、`TemplateParams` 都只是**随信封携带的数据**。`DefaultMessageDispatcher` 一个都不解释——模板不会被渲染、过期时间不会被检查。要生效就得在你的 `IMessageSender` 里自己读。 ::: 正确的异步姿势是:业务侧把待发消息入队(框架提供了 `IRedisDelayQueue` 这类基础设施),后台服务拉取后再调 `IMessageDispatcher`。见 [缓存与分布式锁](./caching)。 ## 与 Bot 各通道的关系 框架里有两套并行的发送栈,**没有默认打通**: | | Messaging | Bot | | --- | --- | --- | | 入口 | `IMessageDispatcher` | `IBotClient` | | 扩展点 | `IMessageSender` | `IBotProvider` | | 目标形态 | 面向「接收人」的一对一投递 | 面向「渠道 / 群机器人」的推送 | | 内置实现 | 无(只有兜底) | 钉钉、飞书、企业微信、Telegram、邮件、短信 | | 附加能力 | 无 | 策略(广播 / 故障转移 / 优先级)、管道(日志 / 重试 / 限流 / 环境过滤)、模板引擎 | Bot 的六个提供者包实现的是 `IBotProvider`,**不是** `IMessageSender`——所以装了 Bot 并不会让 `Channel = "email"` 的信封自动走 Bot 的邮件提供者。要打通就自己写一个桥接发送器: ```csharp public class BotMessageSender : IMessageSender { private readonly IBotClient _botClient; public BotMessageSender(IBotClient botClient) => _botClient = botClient; public bool CanHandle(string channel) => string.Equals(channel, "bot", StringComparison.OrdinalIgnoreCase); public async Task SendAsync( MessageEnvelope envelope, MessageRecipient recipient, CancellationToken cancellationToken = default) { var message = new BotMessage { Title = envelope.Subject, Content = envelope.Content ?? string.Empty }; // 这里把 Address 约定为 Bot 渠道名(对应 XiHanBotOptions.Channels 的键) var dispatch = await _botClient.SendAsync(message, [recipient.Address], cancellationToken); return new MessageSendResult { MessageId = envelope.MessageId, Channel = envelope.Channel, RecipientAddress = recipient.Address, IsSuccess = dispatch.IsSuccess, ErrorMessage = dispatch.ErrorMessage }; } } ``` ::: tip 桥接时注意语义差 `BotDispatchResult.IsSuccess` 的含义是「至少有一个提供者结果且全部成功」,被管道跳过(`IsSkipped`)算作**不成功**。如果你的业务把「环境过滤跳过」视为正常,桥接时要把 `IsSkipped` 单独拆出来处理,别一律当失败。 ::: Bot 自身的渠道映射、策略与管道配置见 [Bot 包](../packages/bot)。 ## 配置 `XiHanMessagingOptions` **没有配置节**,不能从 `appsettings.json` 读,只能用代码配置: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Configure(options => { options.ContinueOnError = false; options.ThrowWhenNoSender = true; }); } ``` | 选项 | 默认值 | 含义 | | --- | --- | --- | | `ContinueOnError` | `true` | 单个接收人失败后是否继续发给后面的人。设为 `false` 时遇到第一个失败就中断,已产生的结果照常返回 | | `ThrowWhenNoSender` | `false` | 一个发送器都匹配不到时是否抛 `InvalidOperationException`。`false` 则给每个接收人返回失败结果 | ::: warning `ThrowWhenNoSender` 在默认注册下不会触发 `AddXiHanMessaging()` 注册的 `NotConfiguredMessageSender` 的 `CanHandle` 恒为 `true`,调度器总能选到它,`sender is null` 这个分支永远走不到。所以打开 `ThrowWhenNoSender` 并不会让「通道没接好」抛异常——它仍然表现为兜底发送器返回的「消息通道 'xxx' 未配置发送器」失败结果。想让它生效,得先把兜底项从 `IMessageSender` 集合里移除。 ::: ::: tip 开关的取舍 批量通知类场景保持默认(`ContinueOnError = true`),别让一个坏邮箱堵住整批。要在联调期让「通道没接好」立刻可见,靠的是检查每条结果的 `IsSuccess` / `ErrorMessage`,而不是这两个开关。 ::: ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 结果里全是「消息通道 'xxx' 未配置发送器」 | 没有任何真实发送器的 `CanHandle` 认这个通道,命中了兜底 | 检查发送器是否注册、`CanHandle` 的比较是否大小写敏感 | | 注册了发送器但没被调用 | 同通道有多个匹配的发送器,只命中注册顺序里的第一个 | 一个通道只留一个发送器,或在 `CanHandle` 里收窄条件 | | `DispatchAsync` 返回空集合 | `Recipients` 为空时直接返回 `[]`,不算失败 | 调用前校验接收人;别用 `results.All(r => r.IsSuccess)` 判成功——空集合恒为 `true` | | 抛 `InvalidOperationException: 消息通道不能为空` | `Channel` 为 `null` / 空白。这个校验与 `ThrowWhenNoSender` 无关,一定抛 | 显式设置 `Channel`(默认值是 `"default"`,通常没有发送器认它) | | 模板没渲染、`ScheduledTime` 没生效 | 调度器不解释这些字段 | 在发送器里读取并自行处理 | | 发送器抛的异常在上层 catch 不到 | 调度器已捕获并转为失败结果 | 看 `MessageSendResult.ErrorMessage`,或看发送器打的 `LogError` | | 换了自己的 `IMessageDispatcher` 不生效 | 被依赖模块先执行,`TryAddSingleton` 已占位 | 用 `services.Replace(ServiceDescriptor.Singleton())` | | 大批量接收人时接口变慢 | 逐接收人串行投递,且在调用线程上完成 | 改为入队 + 后台消费,见上文「框架不做后台异步发送」 | ## 下一步 - [缓存与分布式锁](./caching)——延迟队列,做后台异步发送的基础设施 - [事件总线](../packages/eventbus)——用领域事件解耦「业务动作」与「发通知」 - [多租户](./multi-tenancy)——发送器里读 `TenantId` 前先弄清当前租户上下文 - [扩展与二次开发](./extending)——替换框架默认实现的通用做法 - [Messaging 包](../packages/messaging)——完整 API 清单与 `MessageEnvelope` 全字段表 - [Bot 包](../packages/bot)——机器人推送栈的渠道、策略与管道 --- 来源:https://framework.docs.xihanfun.com/guide/modularity # 模块系统 模块系统是 XiHan.Framework 的地基。理解了它,你就理解了整个框架的组织方式。 ## 什么是"模块" 一个**模块**就是一个继承 `XiHanModule` 的类。它代表一块**可独立安装、可独立启用**的能力(一个 NuGet 包通常对应一个模块类)。模块自己负责三件事: 1. **注册服务** —— 把自己需要的服务加进 DI 容器 2. **声明依赖** —— 用 `[DependsOn]` 说明"我要用哪些别的模块" 3. **接入生命周期** —— 在合适的时机接入中间件、后台任务等 ```csharp using XiHan.Framework.Core.Modularity; public class MyModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { // 注册服务:context.Services 就是标准的 IServiceCollection context.Services.AddSingleton(); } } ``` ## `[DependsOn]`:声明依赖 模块之间**不是靠你在 `Program.cs` 里手动排顺序**,而是靠 `[DependsOn]` 声明依赖关系: ```csharp using XiHan.Framework.Core.Modularity; using XiHan.Framework.Web.Api; using XiHan.Framework.Data; [DependsOn( typeof(XiHanWebApiModule), typeof(XiHanDataModule) )] public class MyAppModule : XiHanModule { } ``` 这行声明的含义是:**加载 `MyAppModule` 之前,必须先加载 `XiHanWebApiModule` 和 `XiHanDataModule`**。而这两个模块又各自 `[DependsOn]` 更底层的模块……于是整棵依赖树被完整地表达出来。 ## 自动拓扑排序加载 启动时你只需要指定**一个根模块**: ```csharp await builder.AddApplicationAsync(); ``` 框架会: 1. 从根模块出发,沿着 `[DependsOn]` **递归收集**所有直接和间接依赖的模块(`XiHanModuleHelper.FindAllModuleTypes`),同一个模块被多处依赖时自动去重,只加载一次 2. 对收集到的模块做**拓扑排序**(`SortByDependencies`,被依赖的排在前面);一旦发现循环依赖,或者某个模块声明依赖了一个不存在于依赖树里的类型,会在**启动阶段**直接抛异常,不会拖到运行时才出问题 3. 按这个顺序依次调用每个模块的生命周期钩子 启动日志会把这棵依赖树原样打印出来(重复出现的分支会标注"已跳过-重复加载")。以上面 `MyAppModule` 的声明为例,实际展开后大致是这样(未继续下钻的分支各自还有依赖,规则相同,此处从略): ```text MyAppModule ├─ Web.Api │ ├─ Web.Core │ ├─ MultiTenancy │ ├─ Serialization │ └─ Auditing └─ Data ├─ Domain │ └─ Domain.Shared ├─ Uow ├─ DistributedIds ├─ MultiTenancy(已加载,跳过) ├─ Security └─ Auditing(已加载,跳过) ``` **好处**: - 加/减一块能力 = 加/减一行 `[DependsOn]`,不用管注册顺序 - 依赖图不允许有环,一旦成环或缺失依赖,应用**启动时**就会失败,不会等到跑到某个功能才发现 - 每个模块可以独立测试、独立发布 ## 自动服务注册 除了模块级别的装配,框架还提供**约定式的服务注册**:实现了标记接口的类会被自动扫描并注册进 DI,无需逐个 `services.AddXxx`。 ```csharp using XiHan.Framework.Core.DependencyInjection.ServiceLifetimes; // 实现 ITransientDependency,自动以 Transient 生命周期注册 public class OrderService : IOrderService, ITransientDependency { } ``` 细节见 [依赖注入](./dependency-injection)。 ## 插件式动态加载模块(进阶) 除了用 `[DependsOn]` 在编译期把依赖关系写死,`AddApplicationAsync` 的 `optionsAction` 里还暴露了 `options.PlugInSources`(`PlugInSourceList`),支持在**启动时从外部程序集动态发现模块** —— 用于插件化场景,即某些模块不随主程序一起编译,而是运行时按需装配: ```csharp await builder.AddApplicationAsync(options => { // 扫描某个文件夹下的所有程序集,收纳其中继承 XiHanModule 的类型 options.PlugInSources.AddFolder(@"C:\Plugins", SearchOption.TopDirectoryOnly); // 直接指定已加载程序集里的具体模块类型 options.PlugInSources.AddTypes(typeof(SomePlugInModule)); // 按文件路径逐个加载程序集 options.PlugInSources.AddFiles(@"C:\Plugins\Foo.dll"); }); ``` 插件模块会和 `[DependsOn]` 声明的模块合并进同一棵依赖树,走同一套拓扑排序与生命周期调用逻辑,也会级联展开自己的 `[DependsOn]`。这是一个偏底层的扩展点,绝大多数业务模块用不到,仅在真正需要"运行时插拔功能"时才会用到。 ## 无模块类的包 少数包是**纯工具库、框架基础设施或分析器**,没有模块类,也不参与 `[DependsOn]`: - `XiHan.Framework.Utils` —— 零依赖工具库,直接引用、直接调用静态方法 - `XiHan.Framework.Metadata` —— 框架元数据常量 - `XiHan.Framework.Core` —— 模块系统本身的定义所在(`XiHanModule`、`[DependsOn]`、`ModuleLoader` 等都在这个包里),几乎所有模块工程都会引用它,但它自己没有模块类,不能出现在别人的 `[DependsOn]` 列表里 - `XiHan.Framework.Analyzers` —— Roslyn 分析器,作为 `Analyzer` 引用,编译期生效 这些包 `dotnet add package` 之后即可使用,不需要写 `[DependsOn]`。 ## 小结 | 概念 | 作用 | | --- | --- | | `XiHanModule` | 模块的基类,一块能力的载体 | | `[DependsOn]` | 声明模块依赖,驱动自动装配 | | `AddApplicationAsync` | 指定根模块,收集 + 拓扑排序 + 加载整棵依赖树 | | `PlugInSourceList` | `options.PlugInSources`,运行时从文件夹/文件/类型动态发现模块,用于插件化场景 | | 标记接口 | `ITransientDependency` 等,驱动约定式服务注册 | ## 下一步 - [生命周期](./lifecycle):模块被加载时,具体会调用哪些钩子 - [依赖注入](./dependency-injection):约定式注册与选项模式 - [动态 API](./dynamic-api):应用服务如何变成 REST 接口 --- 来源:https://framework.docs.xihanfun.com/guide/multi-tenancy # 多租户 框架提供租户上下文、租户解析中间件与数据隔离基础设施。本章讲隔离策略怎么选、上下文怎么切、写路径为什么和读路径不一样。 完整 API 见 [MultiTenancy](../packages/multitenancy) 与 [MultiTenancy.Abstractions](../packages/multitenancy-abstractions)。 ## 两种隔离策略 | 策略 | 做法 | 适用 | | --- | --- | --- | | **字段级隔离** | 所有租户共库共表,靠 `Tenant_Id` 列 + 全局查询过滤器区分 | **默认**。租户多、单租户数据量不大 | | 库级隔离 | 每个租户独立连接串 | 单租户数据量大、合规要求物理隔离 | 两种可以混用——同一套代码里,一部分租户走共享库、一部分走独立库,由租户的连接配置决定。 ## 租户上下文 `ICurrentTenant` 是运行期的租户身份: ```csharp public class OrderService(ICurrentTenant currentTenant) : ITransientDependency { public long? TenantId => currentTenant.Id; // null 与 0 同义:平台(0 号租户) } ``` ### 谁来设置它 请求链路里由 **`XiHanTenantResolveMiddleware`** 设置,位置在**认证之后**(要读令牌里的租户 claim)、**授权之前**(授权判定要在租户上下文里进行)。租户来源分两种信任级别: - **令牌**:已认证请求一律以令牌为准——带租户声明即该租户,不带即平台。请求头、查询串不能替已认证身份另选租户,否则宿主令牌可以逐请求自选任意租户、绕过成员关系。 - **外部输入**:未认证请求才看 `X-Tenant-Id` 请求头、`tenantId` 查询串与 `FallbackTenant`。给出的租户必须在 `ITenantStore` 中存在且激活,否则请求以 400 拒绝;靠匿名请求头识别租户的场景(如租户专属登录页),要把租户登记进 `ITenantStore`。 非请求场景(定时任务、后台作业、控制台)**没有中间件**,要自己切: ```csharp using (currentTenant.Change(tenantId)) { // 这个块里所有仓储查询都带上该租户的过滤 } ``` 框架的任务调度与后台作业已经内建了这一步——执行时按「参数 tenantId → 任务归属租户 → 当前异步上下文」的优先级解析并切换。 ### 平台就是 0 号租户 没有租户上下文(`Id == null`)与 `Change(0)` 同义,都是**平台**,也就是 0 号租户。平台和任何一个租户一样,只看、只写自己的数据: - 读:平台只看 `TenantId = 0` 的行,**看不到任何租户的数据**; - 写:新行落 `TenantId = 0`;预置了别的租户标识的插入直接拒绝;只能改写 / 删除 `TenantId = 0` 的行。 不存在「没有租户上下文就看全部、写全部」的隐式口径。跨租户只有显式通道: | 需求 | 做法 | | --- | --- | | 跨租户读取(按全局唯一邮箱定位账号、按会话标识找会话等) | 仓储里 `CreateNoTenantQueryable()`,或在查询上 `.ClearTenantFilter()`(读共享与严格隔离的过滤一并清除) | | 写某个租户的数据(开通租户、逐租户维护) | `using (currentTenant.Change(tenantId))` 切入该租户后再写 | | 用户自有行(账号、会话、个人设置) | `TenantWriteGuard.Suppress()`,见 [数据访问](./data) | `IsAvailable` 表示「当前处于某个业务租户」,平台(`null` 或 `0`)为 `false`。 ::: warning 不要直接写 `ClearFilter()` 租户过滤按读共享(`IMultiTenantEntity`)与严格隔离(`IStrictMultiTenantEntity`)两个类型登记,只清前者时严格隔离实体仍被收紧在当前作用域,跨租户读取会静默缺数据。统一用 `ClearTenantFilter()` / `ClearTenantAndSoftDeleteFilter()`。 ::: ## 全局记录约定 ::: tip `TenantId = 0` 而不是 NULL 框架与 BasicApp 的约定是用 **`TenantId = 0`** 表示平台级/全局记录,**不用 NULL**。业务租户 Id 从 1 开始。 好处:列非空、索引干净、`WHERE TenantId IN (0, @current)` 这类合并查询写起来自然。 需要 `IsGlobal` 语义时用**派生只读属性** `IsGlobal => TenantId == 0`,**不落库**——避免两个字段漂移。 ::: ## 读写口径不对称 这是多租户里最容易出事的一点: ```text 读:全局过滤器放行 TenantId IN (0, 当前租户) ← 租户能读到平台全局数据;平台只读 0 写:只能改写 / 删除当前作用域的行 ← 租户不能改 0 号全局行,平台也不能改租户行 ``` 平台与租户各自独有、不存在共用的数据(运行数据、日志、会话等),实体再实现 `IStrictMultiTenantEntity` 收紧为严格相等:租户态只看本租户行,不再读共享平台行。 ::: danger 「读共享」不等于「写共享」 如果写路径复用读的口径,租户就能改掉平台的全局数据——这是越权。 框架的做法:预读守卫校验取回行的 `TenantId`,条件写自动追加当前作用域的 `Where`。要维护全局数据,在平台(0 号租户)作用域里改。 ::: ## 实体怎么支持多租户 继承 `SugarMultiTenant*` 系列基类即可——它们带 `Tenant_Id` 列并实现 `IMultiTenantEntity`。 ::: danger 只加列不实现接口 = 隔离完全失效 全局过滤器是按 `AddTableFilter` 挂的。实体只加了 `TenantId` 列却没实现 `IMultiTenantEntity`,过滤器对它**全程 no-op**——所有租户都能看到所有数据,且没有任何报错。 用框架提供的 `SugarMultiTenant*` 基类就不会踩到;手写实体时务必确认接口实现上了。 ::: ## 切换租户 用户从一个租户切到另一个时,正确做法是**复用会话、轮换令牌**: - 不发登录事件、不新增设备记录; - 在目标租户上下文里重建授权快照(不同租户可用功能不同)。 如果实现成「登出再登录」,用户会收到两条登录通知、设备列表里多出一台设备。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 查到了别的租户数据 | 实体没实现 `IMultiTenantEntity`(只加了列) | | 后台任务里查不到租户数据 | 后台没有租户上下文即平台,只看得到平台数据;逐个 `Change(tenantId)` 切入租户处理 | | 改不了全局数据 | 这是**有意的**——在平台(0 号租户)作用域里改 | | 切租户后权限没变 | 授权快照没在目标上下文重建 | | 平台下查不到租户数据 | 这是**有意的**——平台只看自己的数据;确需跨租户读取用 `ClearTenantFilter()` / `CreateNoTenantQueryable()` | | 平台插入报「平台上下文只能写入平台数据」 | 在平台里给行预置了租户标识;写某个租户的数据要先切入该租户 | | 跨租户读取漏了一部分表 | 只清了 `IMultiTenantEntity` 过滤,严格隔离实体仍被收紧;改用 `ClearTenantFilter()` | ## 下一步 - [数据访问](./data):全局查询过滤器 - [认证与授权](./authentication):租户解析在管道里的位置 - [MultiTenancy 包](../packages/multitenancy):完整 API --- 来源:https://framework.docs.xihanfun.com/guide/observability # 可观测性 线上出问题时要能回答三个问题:这次请求走过哪些环节、哪一步慢、进程现在什么状态。框架把这件事拆成链路追踪、指标、进程内性能统计与诊断、健康检查四块,其中链路与指标基于 OpenTelemetry,**默认全部关闭**。 完整 API 与配置全表见 [Observability 包](../packages/observability)。 ## 四块能力,两种数据去向 | 能力 | 入口类型 | 受 `Enabled` 门控 | 数据去向 | | --- | --- | --- | --- | | 链路追踪 | `ActivitySource`(`XiHanActivitySources`) | 是 | OTLP / 控制台导出器 | | 指标 | `IMetricsCollector` | 记录随时可调,**导出**受门控 | OTLP / 控制台导出器,进程内不留存 | | 性能统计 | `IPerformanceMonitor` | 否 | 进程内内存,只能用 API 读 | | 运行时诊断 | `IDiagnosticsService` | 否 | 即时读取,不留存 | | 健康检查 | `Microsoft.Extensions.Diagnostics.HealthChecks` | 否 | 需要自己映射端点 | ::: warning 本包不含任何采集后端 它只负责按 OTLP 标准协议把 trace / metrics 发出去。采集端与可视化面板要自行部署,框架不内置、也不代管。 ::: ## 启用 没有任何框架模块 `DependsOn` 可观测性模块——它必须由宿主模块自己声明: ```csharp [DependsOn(typeof(XiHanObservabilityModule))] public class MyWebHostModule : XiHanModule { } ``` 模块的 `ConfigureServices` 只做一件事:调用 `services.AddXiHanObservability(configuration)`。这个扩展方法按顺序执行: 1. 把配置节 `XiHan:Observability` 绑定到 `XiHanObservabilityOptions`; 2. `services.AddHealthChecks()` 注册健康检查基础设施; 3. 单例注册 `IMetricsCollector` / `IPerformanceMonitor` / `IDiagnosticsService`; 4. **若 `Enabled` 为 `false`(默认值),到此 `return`**; 5. 否则 `services.AddOpenTelemetry()`,配置 Resource,再按 `EnableTracing` / `EnableMetrics` 分别装配。 ::: danger 只挂模块不改配置 = 什么都不会发生 `Enabled` 默认 `false`,OTel SDK 根本不装配。此时 `IMetricsCollector` 的调用是「装配即孤儿」——数据既不落内存也不导出,唯一开销是方法调用本身。链路那侧同理,各处 span 代码会被 `HasListeners()` 短路。 ::: ## 打开 OpenTelemetry 开发环境(全采样 + 控制台看效果): ```json { "XiHan": { "Observability": { "Enabled": true, "ServiceName": "XiHan.BasicApp", "ServiceVersion": "1.0.0", "EnableTracing": true, "EnableMetrics": true, "SamplingRatio": 1.0, "ConsoleExporter": true } } } ``` 生产环境(降采样 + 只发 OTLP): ```json { "XiHan": { "Observability": { "Enabled": true, "ServiceName": "XiHan.BasicApp", "EnableTracing": true, "EnableMetrics": true, "SamplingRatio": 0.1, "ConsoleExporter": false, "OtlpEndpoint": "http://otel-collector:4317", "AdditionalSources": ["MyApp.Custom"] } } } ``` 开关与实际装配的对应关系: | 开关 | 装配了什么 | | --- | --- | | `EnableTracing`(默认 `true`) | `SetSampler(ParentBased(TraceIdRatioBased))` + `AddSource(XiHanActivitySources.All)` + `AddAspNetCoreInstrumentation()` + `AddHttpClientInstrumentation()`,再追加 `AdditionalSources` | | `EnableMetrics`(默认 `false`) | `AddMeter(MetricsCollector.MeterName)`,即 `"XiHan.Metrics"` | | `ConsoleExporter` | 给已启用的 Tracing / Metrics 各加一个控制台导出器 | | `OtlpEndpoint` 非空 | 给已启用的 Tracing / Metrics 各加一个 OTLP 导出器,`Endpoint` 取该值 | | `EnableLogging` | **占位**,扩展方法中没有对应的 `WithLogging(...)` 装配代码 | ::: warning 两个默认值方向相反 `EnableTracing` 默认 `true`、`EnableMetrics` 默认 `false`。只把 `Enabled` 打开,你会拿到链路但拿不到指标。 ::: ::: danger 采了不等于发得出去 `ConsoleExporter` 与 `OtlpEndpoint` 都不配时,SDK 装配完成但没有任何出口,表现是「开关全开却一条数据都看不到」。二者至少配一个。 ::: ## 采样率的实际语义 采样器是 `ParentBasedSampler(new TraceIdRatioBasedSampler(Math.Clamp(SamplingRatio, 0d, 1d)))`。两层含义: - **`ParentBased`**:请求带了 `traceparent` 且上游已标记采样时,本服务跟随上游决定,不重新掷骰; - **`TraceIdRatioBased`**:只有作为链路根(没有远端父级)时,才按比例决定整条链路采不采。 ::: warning 采样是链路级的,不是 span 级的 `SamplingRatio: 0.1` 的意思是「本服务发起的新链路里保留一成」,被选中的链路整条完整保留,没选中的整条丢弃。它不会让你「每种 span 都看到 10%」。同理,把本服务调低而上游仍是 1.0,上游进来的请求依旧全量落盘。 ::: `SamplingRatio` 会被 `Math.Clamp` 到 `0~1`,配成 `5` 或 `-1` 不会报错,等价于 `1` 和 `0`。 ## 框架内置的 Span 源 `ActivitySource` 的名字与实例集中在 `XiHan.Framework.Core.Tracing.XiHanActivitySources`(放在 Core 而非本包,是为了让 Data / EventBus / Cache 这些只依赖 Core 的层也能发 span 而不产生环依赖)。`All` 汇总全部源名,装配时一次性 `AddSource`。 | 源名 | 常量 / 实例 | 当前谁在发 span | | --- | --- | --- | | `XiHan.App` | `App` / `AppSource` | 框架自身不发,留给业务层打点 | | `XiHan.Data` | `Data` / `DataSource` | SqlSugar 执行回调,span 名 `db.query`,`ActivityKind.Client`,带 `db.system` / `db.statement` 标签,异常时置 Error 状态 | | `XiHan.EventBus` | `EventBus` / `EventBusSource` | Broker 分布式事件消费入口,span 名 `eventbus.consume {事件名}`,`ActivityKind.Consumer` | | `XiHan.Cache` | `Cache` / `CacheSource` | Redis 缓存的批量 / 模式匹配 / Lua 异步操作,`ActivityKind.Client`,带 `db.system=redis` | | `XiHan.Grpc` | `Grpc` / `GrpcSource` | 常量与实例已定义并纳入 `All`,但**框架内暂无代码在此源上创建 span** | | `XiHan.AI` | `Ai`(只有名字常量,无静态实例) | 由 AI 会话管道的 `UseOpenTelemetry` 中间件产出,源名取自 `AiPipelineOptions.TelemetrySourceName` | ::: tip 未开启时零开销的实现方式 Data / Cache / EventBus 的 span 代码开头都是 `source.HasListeners()` 判断,没有监听者直接 return。这是「默认关闭不付代价」的落点,也意味着这些 span 的有无完全由 `EnableTracing` 决定。 ::: ::: warning AI 的 span 要开两处开关 `XiHan.AI` 源已在 `All` 里被 `AddSource`,但真正产出 span 的是 AI 管道中间件,需要 `XiHan:AI:Pipeline:EnableTelemetry` 也为 `true`。默认它是关的。 ::: ## 业务代码打点 用内置的应用源: ```csharp using var activity = XiHanActivitySources.AppSource.StartActivity("PlaceOrder"); activity?.SetTag("order.id", orderId); activity?.SetTag("order.channel", "web"); ``` ::: warning 必须用 `activity?.` 没有监听者时 `StartActivity` 返回 `null`。写成 `activity.SetTag(...)` 在 OTel 关闭的环境里会直接空引用崩溃。 ::: 自定义源要额外声明才会被采集: ```csharp public static class MyAppSources { public const string Custom = "MyApp.Custom"; public static readonly ActivitySource CustomSource = new(Custom); } ``` ```json { "XiHan": { "Observability": { "AdditionalSources": ["MyApp.Custom"] } } } ``` 框架内置的六个源由 `XiHanActivitySources.All` 自动纳入,不要在 `AdditionalSources` 里重复写。 ## TraceId 与 W3C Activity 框架的 `TraceId` 不是自己造的编号,而是**优先取 `Activity.Current.TraceId`**。理解这条链,才能解释「为什么开了 OTel 之后日志里的 TraceId 长得不一样了」。 产出侧,`XiHanTraceIdMiddleware` 位于 Web.Api 管线最前(仅次于 `UseForwardedHeaders`),按优先级取值: | 顺序 | 来源 | 形态 | | --- | --- | --- | | 1 | `Activity.Current.TraceId.ToHexString()` | W3C 32 位十六进制 | | 2 | 入站请求头 `X-Trace-Id` | 调用方给什么就是什么 | | 3 | `HttpContext.TraceIdentifier` | Kestrel 的「连接 ID:请求序号」 | 取到后写入 `HttpContext.Items["__XiHanTraceId"]`,并回写响应头 `X-Trace-Id`。 消费侧,同一个值散落在这些地方: | 消费方 | 取值途径 | | --- | --- | | 统一响应体 `ApiResponse.TraceId` | Web.Api 响应过滤器 | | 审计五表(访问 / 接口 / 操作 / 异常 / 登录日志)的 `TraceId` 字段 | 各中间件与过滤器 | | `ITraceIdProvider`(实现 `HttpTraceIdProvider`) | 给不依赖 ASP.NET Core 的层用;同样先看 `Activity.Current`,再回退 Items / `TraceIdentifier` | | 实体的 `ITraceableEntity.TraceId` | 数据层写入拦截时经 `ITraceIdProvider` 自动填充 | | `ICorrelationIdProvider` | 先看 `Activity.Current.TraceId`,无 Activity 时回退 `Change()` 显式设置的值 | | 日志模板中的 `{TraceId}` / `{SpanId}` | Serilog 从 `Activity.Current` 自动填充,无链路时渲染为空 | ::: warning 开启 OTel 会改变 TraceId 的形态 未启用时 TraceId 通常是 `HttpContext.TraceIdentifier`(形如 `0HN7...:00000001`);启用后 ASP.NET Core instrumentation 会为每个请求建 Activity,TraceId 变成 32 位十六进制,与追踪后端里的链路可直接 join。如果你的日志检索规则、告警正则按旧格式写死,要一并调整。 ::: 跨进程的异步链路也串得上:分布式事件发布时把 `ICorrelationIdProvider.Get()`(即当前 TraceId)作为 `correlationId` 随消息带走;消费端拿它重建一个 `isRemote: true` 的 `ActivityContext` 作为父级,消费 span 因此归入同一条 trace。值不是合法 32-hex 时退化为新的根 span,不会报错。 ::: tip 发布时没有 Activity 就串不起来 后台作业、定时任务里发布事件时,若没有环境 Activity,`correlationId` 取到的是 `Change()` 设置的值或 `null`,消费端会开新链路。需要串联时在作业入口自己 `StartActivity`。 ::: ## 指标 `IMetricsCollector` 是单例,底层是一个 `Meter("XiHan.Metrics")`: ```csharp metricsCollector.RecordCounter("orders.created"); metricsCollector.RecordCounter("orders.created", 5, new() { ["channel"] = "web" }); metricsCollector.RecordHistogram("order.amount", 199.9); using (metricsCollector.BeginTimer("order.handle")) { // 业务处理;Dispose 时记录直方图 `order.handle.duration`(毫秒) } ``` ::: danger `GetMetrics()` 恒返回空列表 `MetricsCollector` 直出 `Meter`,进程内不留存任何指标;`GetMetrics()` 返回空集合、`Clear()` 是空操作,两者仅为接口成员保留。想做一个「读 `GetMetrics()` 渲染指标面板」的接口,拿不到任何数据——指标只能通过导出器看。 ::: ::: warning `RecordMeasurement` 实际记的是直方图 没有 pull 型 gauge 的回调上下文,`RecordMeasurement` 内部直接委托给 `RecordHistogram`,分布与百分位交给后端聚合。`MetricType` 枚举里的 `Gauge` / `Summary` 当前没有对应的记录路径。 ::: ## 性能统计与运行时诊断 这两块与 OTel 开关无关,`Enabled=false` 时照样可用。 ```csharp using (var tracker = performanceMonitor.BeginOperation("PlaceOrder")) { tracker.AddTag("channel", "web"); tracker.Checkpoint("validated"); // ... 处理 ... tracker.Checkpoint("persisted"); } var stats = performanceMonitor.GetStatistics(); // 总数/成功/失败、平均/最小/最大、P50/P95/P99,并按操作名分组 var slow = performanceMonitor.GetSlowOperations(500); // 耗时 ≥ 500ms,按耗时倒序 ``` ::: warning 只保留最近 10000 条记录 `PerformanceMonitor` 的记录存在进程内队列,超过 10000 条自动丢弃最旧的,`GetStatistics()` / `GetSlowOperations()` 只反映这部分;进程重启即全丢,`Clear()` 可主动清空。它适合排查期临时观察,不适合当常驻监控——常驻请走 OTel 指标与链路。 ::: `IDiagnosticsService` 读进程当下状态,全部基于 BCL,无副作用(除 GC 那个方法外): | 方法 | 内容 | | --- | --- | | `GetSystemInfo()` | 操作系统描述与版本、机器名、CPU 核数、系统启动时间、用户名 | | `GetRuntimeInfo()` | .NET 版本、进程 ID、应用启动时间与运行秒数、是否 64 位 | | `GetMemoryInfo()` | 已分配字节(最近一次 GC 结束时的堆大小减碎片)、工作集、私有内存,以及各代 GC 次数与暂停占比 | | `GetThreadInfo()` | 线程池线程数、可用/最大工作线程与 IO 线程、待处理工作项数 | | `GetDiagnosticsReport()` | 上述四项一次性汇总 | | `ForceGarbageCollection()` | 两轮 `GC.Collect()` 中间夹一次 `WaitForPendingFinalizers()` | ::: danger 不要把 `ForceGarbageCollection` 挂到匿名端点 它是真的强制回收并等待终结器,会造成停顿。只在受权限保护的运维接口里暴露,且不要给任何自动化调用。 ::: ## 健康检查 `AddXiHanObservability` 只调了 `AddHealthChecks()` 注册基础设施,**一个检查项都没注册**。包内自带的唯一实现是 `MemoryHealthCheck`: ```csharp services.AddHealthChecks() .AddCheck("memory", new MemoryHealthCheck(thresholdMb: 512)); ``` 它读最近一次 GC 结束时的托管堆大小减碎片(`GC.GetGCMemoryInfo()` 的 `HeapSizeBytes - FragmentedBytes`,即回收后对象实际占用的字节数,不含尚未回收的垃圾),超过阈值返回 `Degraded`(不是 `Unhealthy`),两种结果都附带各代 GC 次数与内存负载阈值明细。默认阈值 1024MB。读数是 GC 快照:进程还没发生过 GC 时为 0,空闲期间保持上一次回收的值。 其余检查项(数据库、Redis、向量库等)需要由应用实现 `IHealthCheck` 并注册。下面以应用自己的检查类型为例,再在模块初始化时映射端点: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddHealthChecks() .AddCheck("database") .AddCheck("redis"); } public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); var options = new HealthCheckOptions { ResponseWriter = WriteMinimalHealthResponseAsync }; if (app is IEndpointRouteBuilder endpoints) { endpoints.MapHealthChecks("/health", options).AllowAnonymous(); } } ``` ::: danger 开了 FallbackPolicy 时 `/health` 不加 `AllowAnonymous()` 会 401 `XiHan:Web:Api:Auth:RequireAuthenticatedUser`(默认 `false`)设为 `true` 时,Web.Api 才装配鉴权 FallbackPolicy,此时未标记匿名的端点一律被授权中间件拦下。另外,开放接口签名中间件(`XiHan:Web:Api:OpenApiSecurity:IsEnabled`)的 `ProtectedPathPrefixes` 默认是 `["/api"]`、不覆盖 `/health`;若把它改成覆盖 `/health` 的前缀(或置空表示全路径),还要把 `/health` 加进 `IgnoredPathPrefixes`。 ::: ::: warning 默认响应体会外泄细节 标准健康检查响应包含每项的 `description` 与异常信息,连接串很容易顺着异常消息暴露。自定义 `ResponseWriter`,只回总状态与各项名称/状态。 ::: ## 关键配置 配置节 `XiHan:Observability`,最常调的几项: | 键 | 默认值 | 说明 | | --- | --- | --- | | `Enabled` | `false` | OTel 总开关,关闭则整个 SDK 不装配 | | `EnableTracing` | `true` | 链路追踪,仅在 `Enabled=true` 时生效 | | `EnableMetrics` | `false` | 指标导出,仅在 `Enabled=true` 时生效 | | `SamplingRatio` | `1.0` | 根链路采样比例,自动 Clamp 到 `0~1` | | `OtlpEndpoint` | `null` | OTLP 端点,为空则不装 OTLP 导出器 | | `ConsoleExporter` | `false` | 控制台导出器,生产关闭 | `ServiceName`(默认 `"XiHan.App"`)、`ServiceVersion`、`EnableLogging`、`AdditionalSources` 见 [Observability 包](../packages/observability) 的完整配置表。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 一条 trace 都没有 | `Enabled` 默认 `false`,OTel 未装配 | 在 `XiHan:Observability` 显式打开 | | `Enabled` 开了仍无数据 | 既没配 `OtlpEndpoint` 也没开 `ConsoleExporter`,没有出口 | 二者至少配一个 | | 有链路但没有指标 | `EnableMetrics` 默认 `false` | 显式设为 `true` | | `EnableLogging` 开了没有日志导出 | 扩展方法未接入 `WithLogging`,该项为占位 | 日志走 Logging 包的落地方式 | | 看不到 `db.query` span | `EnableTracing=false` 时 `DataSource` 无监听者,代码被 `HasListeners()` 短路 | 开启 `EnableTracing` | | gRPC 没有 span | `XiHan.Grpc` 源已注册但框架内无代码在其上发 span | 需要则自行用 `GrpcSource` 打点 | | AI 调用没有 span | 还需 `XiHan:AI:Pipeline:EnableTelemetry=true` | 两处开关一起开 | | `GetMetrics()` 返回空 | 设计如此,指标不在进程内留存 | 通过导出器查看 | | 日志里 TraceId 格式变了 | 开启 OTel 后从 `TraceIdentifier` 变为 W3C 32-hex | 同步调整日志检索与告警规则 | | 日志 TraceId 为空 | 当前上下文没有 Activity(如未走 HTTP 管线的后台线程) | 在入口自行 `StartActivity` | | 消费端与发布端不在同一条 trace | 发布时没有环境 Activity,`correlationId` 非法或为空 | 在作业/任务入口建 Activity | | 早期的慢操作查不到、统计总数不再增长 | `PerformanceMonitor` 只保留最近 10000 条 | 需要全量历史改用 OTel 指标 | | `/health` 返回 401 | `RequireAuthenticatedUser=true` 时装配的鉴权 FallbackPolicy 拦截 | `MapHealthChecks(...).AllowAnonymous()` | | `/health` 200 但没有任何检查项 | 模块只注册基础设施,不含检查项 | 应用侧 `AddCheck` | | `StartActivity` 处空引用 | OTel 关闭时返回 `null` | 一律用 `activity?.` | ## 下一步 - [日志](../packages/logging) — `{TraceId}` / `{SpanId}` 输出模板与落地方式 - [审计](../packages/auditing) — 五类审计记录中的 `TraceId` 字段 - [数据访问](./data) — `db.query` span 与 `ITraceableEntity` 的填充时机 - [缓存与分布式锁](./caching) — Redis 操作 span 的覆盖范围 - [Web 应用开发](./web) — 中间件管线顺序与统一响应体 - [Observability 包](../packages/observability) — 完整 API 清单与全部配置项 - [Core 包](../packages/core) — `XiHanActivitySources` 与 `ICorrelationIdProvider` 的定义位置 --- 来源:https://framework.docs.xihanfun.com/guide/realtime # 实时通信 服务端要主动把消息推到浏览器——通知、聊天、任务进度、强制下线——走 `XiHan.Framework.Web.RealTime` 提供的 SignalR 集成。本章讲怎么定义 Hub、怎么从业务代码推送、连接是怎么被找到的,以及一条最容易翻车的载荷约定。 完整 API 清单与全部配置项见 [Web.RealTime 包](../packages/web-realtime)。 ## 什么时候用 | 需求 | 选择 | | --- | --- | | 服务端有事就通知前端,前端不知道什么时候会有 | Hub 推送 | | 前端定期刷新一份数据,能接受几秒延迟 | 普通 HTTP 轮询,不必上 Hub | | 一次长耗时操作要报进度 | Hub 推送 `TaskProgress` | | 同账号多端状态同步(偏好、被踢下线) | Hub 推送,按用户点发 | Hub 是有状态长连接,会占住连接数、跨实例还要额外考虑路由。能用请求-响应解决的事就别开 Hub。 ## 启用 ```bash dotnet add package XiHan.Framework.Web.RealTime ``` ```csharp [DependsOn(typeof(XiHanWebRealTimeModule))] public class MyAppModule : XiHanModule { } ``` `XiHanWebRealTimeModule` 依赖 `XiHanWebCoreModule` 与 `XiHanAuthenticationModule`,在 `ConfigureServices` 里调用 `AddXiHanSignalRWithJson(config)`,一次性完成: | 动作 | 说明 | | --- | --- | | `AddSignalR().AddJsonProtocol(...)` | JSON 协议:`PropertyNamingPolicy = CamelCase`、`WriteIndented = false` | | 绑定 `XiHanSignalROptions` | 读配置节 `XiHan:Web:RealTime:SignalR` | | 桥接到 `HubOptions` | 保活/超时/握手/消息大小等运行时从 DI 取值 | | 注册 `IConnectionManager` → `ConnectionManager` | `Singleton`,进程内连接表 | | 注册 `IUserIdProvider` / `IXiHanUserIdProvider` → `XiHanUserIdProvider` | `Singleton` | | 注册 `IRealtimeNotificationService<>` → `RealtimeNotificationService<>` | `Scoped` | ::: warning 端点不会自动映射 模块只注册服务,**不映射任何 Hub 端点**——包括内置示例 `NotificationHub`。路径由你在应用侧决定。 ::: ## 定义 Hub 继承 `XiHanHub`,构造函数接 `IConnectionManager` 传给基类: ```csharp using XiHan.Framework.Web.RealTime.Attributes; using XiHan.Framework.Web.RealTime.Hubs; using XiHan.Framework.Web.RealTime.Services; [AuthorizeHub] public class ChatHub : XiHanHub { public ChatHub(IConnectionManager connectionManager) : base(connectionManager) { } // 参数一律用 string 接收,原因见「载荷不走 MVC 的 JSON 管道」 public Task JoinConversation(string conversationId) { return Groups.AddToGroupAsync(ConnectionId!, $"conversation:{conversationId}"); } } ``` `XiHanHub` 在基类里提供三个只读属性和一个受保护成员: | 成员 | 来源 | | --- | --- | | `ConnectionId` | `Context.ConnectionId` | | `UserId` | `ClaimTypes.NameIdentifier` claim | | `UserName` | `ClaimTypes.Name` claim | | `ConnectionManager`(`protected`) | 注入的 `IConnectionManager` | `AuthorizeHubAttribute` 继承自 `AuthorizeAttribute`,可打在类或方法上,可重复标注,也可带策略名:`[AuthorizeHub("MyPolicy")]`。 ### 映射端点 在模块的 `OnApplicationInitialization` 里映射: ```csharp public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.UseEndpoints(endpoints => { endpoints.MapXiHanHub("/hubs/chat"); endpoints.MapXiHanHub(SignalRConstants.HubPaths.Notification); }); } ``` `MapXiHanHub` 有两个重载,第二个接 `Action`,用来单独调传输方式、缓冲区等。泛型约束是 `where THub : XiHanHub`——直接继承 `Hub` 的类型用不了这个扩展。 `SignalRConstants.HubPaths` 里预置了三个路径常量:`Notification`(`/hubs/notification`)、`Chat`(`/hubs/chat`)、`Data`(`/hubs/data`)。 ## 从业务代码推送 业务侧不碰 Hub 实例,注入按 Hub 泛型化的 `IRealtimeNotificationService`: ```csharp public class OrderNotifier(IRealtimeNotificationService realtime) { public Task NotifyCreatedAsync(long userId, long orderId) { // long 显式转 string,见下节 return realtime.SendToUserAsync( userId.ToString(), SignalRConstants.ClientMethods.ReceiveNotification, new { orderId = orderId.ToString(), title = "订单已创建" }); } } ``` 泛型参数决定推到哪条 Hub,推错 Hub 是编译期错误。六个方法: | 方法 | 目标 | | --- | --- | | `SendToUserAsync(userId, method, args)` | 单个用户的全部连接 | | `SendToUsersAsync(userIds, method, args)` | 一批用户的全部连接 | | `SendToGroupAsync(groupName, method, args)` | SignalR 组 | | `SendToAllAsync(method, args)` | 全体连接 | | `AddToGroupAsync(userId, groupName)` | 把该用户当前的所有连接加进组 | | `RemoveFromGroupAsync(userId, groupName)` | 反向操作 | ::: warning `args` 是 `params object[]`,没有 `CancellationToken` 重载 把 `CancellationToken` 顺手当最后一个参数传进去,它会被当成一条业务载荷序列化发给客户端。要传就只传真正的载荷。 ::: ::: tip 服务是 `Scoped` 从 `BackgroundService`、`IHostedService` 这类单例里用它,需要先 `CreateScope()`;或者直接注入 `IHubContext` 自己发。 ::: ## 关键机制 ### 连接是怎么被登记的 `XiHanHub.OnConnectedAsync` / `OnDisconnectedAsync` 已经重写好:**当 `ConnectionId` 与 `UserId` 都非空时**,调 `IConnectionManager` 登记 / 注销「用户 ID → 连接 ID 集合」。 ::: danger 匿名连接推不到 `UserId` 取的是 `NameIdentifier` claim。匿名连上来的客户端拿不到这个 claim,**不会进连接表**,后续 `SendToUserAsync` 永远找不到它。要点对点推送,Hub 就得打 `[AuthorizeHub]`,且令牌里必须有 `NameIdentifier`。 ::: 如果你在派生 Hub 里重写了这两个方法,**必须调 `base.OnConnectedAsync()` / `base.OnDisconnectedAsync(exception)`**,否则登记逻辑被整条跳过。 ### 哪些方法依赖连接表 这是多实例部署时唯一需要记住的分界: | 方法 | 是否查 `IConnectionManager` | | --- | --- | | `SendToUserAsync` / `SendToUsersAsync` | 是 | | `AddToGroupAsync` / `RemoveFromGroupAsync` | 是 | | `SendToGroupAsync` / `SendToAllAsync` | 否,直接走 SignalR 的 `Group` / `All` | 默认的 `ConnectionManager` 是**进程内**实现(`ConcurrentDictionary` + 锁,用户连接清空后自动删条目)。多实例下: - 用户连在 A 实例,B 实例上的 `SendToUserAsync` 查不到连接,静默什么都不发; - 接了 SignalR backplane 之后,`SendToGroupAsync` / `SendToAllAsync` 能跨实例,`SendToUserAsync` 仍然不行——它依赖的是本进程的连接表。 两条出路: 1. 直接注入 `IHubContext` 用 `Clients.User(userId)`——它走 `IUserIdProvider`,而 `XiHanUserIdProvider` 取的正是 `NameIdentifier`(缺失时回退 `Name`),与 `SendToUserAsync` 的用户键一致; 2. 用共享存储实现 `IConnectionManager`,在模块注册之后覆盖那条 `Singleton` 注册。 ### 组只对「调用那一刻已存在的连接」生效 `AddToGroupAsync(userId, groupName)` 会遍历该用户当时的连接逐个入组。用户之后新开的标签页不会自动进组。需要「用户级常驻组」就在 `OnConnectedAsync` 里补入组。 ### 载荷不走 MVC 的 JSON 管道 ::: danger 这是最容易踩的一条 SignalR 的 JSON 协议在框架里**只**配了两件事:camelCase 命名、不缩进。MVC 那套 `long` → 字符串、枚举 → 成员名、按 `X-Timezone` 换算时间的转换器,全都挂在 MVC 的 JSON 选项上,**对 Hub 载荷不生效**。 后果:直接推一个含雪花 ID 的对象,前端拿到的是会丢精度的 Number;推枚举拿到的是数字。 ::: 两条约定: 1. **推送前手动投影**——ID `ToString()`,枚举转成约定字符串,时间自己决定格式; 2. **Hub 方法参数用 `string` 接收 ID**,服务端自行解析。内置示例 `NotificationHub` 的方法参数全是 `string`,就是这个原因。 写新推送时先问一句「这个 payload 里有 `long` 或枚举吗」,有就先投影。 框架提供的 `NotificationMessage` 模型(`Id` / `SenderId` / `ReceiverId` / `Type` / `Title` / `Content` / `Data` / `CreatedTime` / `IsRead`)是个现成的载荷形状,但它的 `Data` 是 `object?`——用它也一样要自己投影里面的内容。 ### 方法名用常量 客户端方法名是字符串,`SignalRConstants` 里集中了内置的一批: - `ClientMethods`:`ReceiveMessage`、`ReceiveNotification`、`UserJoined`、`UserLeft`、`Connected`、`Disconnected`、`Error`、`ForceLogout`、`TaskProgress`、`UserSettingChanged` - `ServerMethods`:`SendMessage`、`SendMessageToUser`、`SendMessageToAll`、`JoinGroup`、`LeaveGroup`、`SendMessageToGroup`、`GetOnlineUserCount`、`IsUserOnline` - `Groups`:`Admin`、`Users`、`Notifications` 其中 `TaskProgress` 与 `UserSettingChanged` 在源码里带载荷约定注释:前者是 `taskId` / `label` / `detail` / `state`(`loading` \| `success` \| `error` \| `info`)/ `progress`(0-100)/ `link`;后者是 `scene` / `settingKey` / `settingValue` / `sourceClientId`。业务自己新增的方法名,也建一个常量类,别内联字符串。 ### 在线状态 `IConnectionManager` 顺带就是在线状态的数据源: ```csharp await connectionManager.IsUserOnlineAsync(userId); // 该用户是否有活跃连接 await connectionManager.GetOnlineUserCountAsync(); // 在线用户数(不是连接数) await connectionManager.GetOnlineUsersAsync(); // 在线用户 ID 列表 await connectionManager.GetConnectionsAsync(userId); // 该用户的连接 ID 列表 ``` 注意 `GetOnlineUserCountAsync` 数的是**用户**,一个用户开三个标签页仍然只算一个。同样受进程内实现的限制。 ## Hub 路径前缀 Hub 路径挂在哪里不是随便定的,Web.Api 侧有两处按前缀判断的逻辑,默认都是 `/hubs`: | 配置键 | 默认 | 作用 | | --- | --- | --- | | `XiHan:Web:Api:Auth:SignalRHubPathPrefix` | `/hubs` | 命中该前缀时,才从 query string 的 `access_token` 取 JWT | | `XiHan:Web:SessionState:SignalRHubPathPrefix` | `/hubs` | 命中该前缀时,会话闸门中间件整体跳过 | 前一条是必需的:WebSocket / SSE 握手带不了 `Authorization` 头,令牌只能走 query,连接地址形如 `/hubs/chat?access_token=`。后一条是为了避免长连接被 401 / 423 直接切断——客户端会陷入重连死循环,而且这条连接本身还要用来接收「你被踢下线了」的推送;Hub 的方法级拦截交给 `IHubFilter` 做。 ::: warning Hub 挂到前缀之外要两处一起改 把 Hub 映射到 `/realtime/chat` 这种路径,上面两个配置都得跟着改,否则表现是「连不上(401)」或者「连上了但过一会被闸门切断」。 ::: ## 异常过滤器 `HubExceptionFilter` 实现 `IHubFilter`,统一记录方法调用 / 连接 / 断开三处的异常,并把方法异常替换成脱敏后的 `HubException`(只回 `调用方法 {方法名} 时发生错误`,不外泄堆栈)。 它**不是自动生效的**,需要自己挂: ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { // 全局挂到所有 Hub context.Services.Configure(options => options.AddFilter()); } ``` ## 配置 配置节 `XiHan:Web:RealTime:SignalR`(常量 `XiHanSignalROptions.SectionName`),值会被桥接进 SignalR 的 `HubOptions`: ```json { "XiHan": { "Web": { "RealTime": { "SignalR": { "EnableDetailedErrors": false, "KeepAliveInterval": "00:00:15", "ClientTimeoutInterval": "00:00:30", "HandshakeTimeout": "00:00:15", "MaximumReceiveMessageSize": 32768, "StreamBufferCapacity": 10, "MaximumParallelInvocationsPerClient": 1 } } } } } ``` 几个要拿捏的: | 键 | 默认 | 拿捏点 | | --- | --- | --- | | `EnableDetailedErrors` | `false` | 打开会把服务端异常原文发给客户端,**生产别开** | | `KeepAliveInterval` | `15s` | 服务端心跳间隔 | | `ClientTimeoutInterval` | `30s` | 保持在保活间隔的约 **2 倍**,调小容易误判掉线 | | `MaximumReceiveMessageSize` | `32768`(32KB) | 客户端→服务端单条消息上限,富文本/附件元数据可能顶到 | | `MaximumParallelInvocationsPerClient` | `1` | 默认串行,同一连接上的慢方法会阻塞后续调用;调大前先确认 Hub 方法是并发安全的 | `XiHanSignalROptions` 里还有一个 `EnableConnectionMetrics`(默认 `true`),当前只是一个选项位,没有代码读取它。 完整字段表见 [Web.RealTime 包](../packages/web-realtime)。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 握手直接 401 | Hub 路径不在 `SignalRHubPathPrefix` 下,或客户端没带 `access_token` query | 把 Hub 挂到 `/hubs` 下,或同步改两处前缀配置 | | 连上了,但 `SendToUserAsync` 收不到 | 连接是匿名的,没进连接表 | Hub 打 `[AuthorizeHub]`,确认令牌带 `NameIdentifier` | | 重写了 `OnConnectedAsync` 后点对点推送全失效 | 漏调 `base.OnConnectedAsync()` | 补上基类调用 | | 前端拿到的 ID 与后端对不上 | 载荷里的 `long` 没投影,JS Number 溢出 | 推送前 `ToString()` | | 前端拿到的枚举是数字 | Hub 载荷不走 MVC 的枚举转换器 | 推送前转成约定字符串 | | 多实例部署后点对点推送时灵时不灵 | 连接表是进程内的 | 改用 `Clients.User(userId)`,或替换 `IConnectionManager` | | 同一份消息收到两遍 | `SendToUsersAsync` 的入参里有重复 `userId`,连接会被重复收集 | 调用前对 `userIds` 去重 | | `AddToGroupAsync` 之后新开的标签页收不到组消息 | 只对调用时已存在的连接生效 | 在 `OnConnectedAsync` 里补入组 | | Hub 异常没有统一日志 | `HubExceptionFilter` 没注册 | 用 `HubOptions.AddFilter` 挂上 | | 消息能到但延迟高、CPU 偏高 | 反向代理没放行 `Upgrade` / `Connection`,退回 SSE / LongPolling | 改反代配置,确认协商结果是 WebSockets | | 客户端反复掉线重连 | `ClientTimeoutInterval` 相对 `KeepAliveInterval` 太小 | 恢复到约 2 倍关系 | ## 下一步 - [Web 应用开发](./web):中间件管道,以及 MVC 侧的 JSON 序列化约定(与 Hub 的差异就在这) - [认证与授权](./authentication):JWT 与 claim 来源,Hub 的 `UserId` 从这里来 - [配置](./configuration):配置节总览与绑定方式 - [Web.RealTime 包](../packages/web-realtime):完整 API 与全部配置项 - [Web.Api 包](../packages/web-api):`SignalRHubPathPrefix` 与会话闸门所在 --- 来源:https://framework.docs.xihanfun.com/guide/response # 统一响应与异常 框架让所有接口回同一个 JSON 信封:成功的业务对象被自动包进去,抛出的异常也被翻译成同一个形状。本章讲这个信封长什么样、失败怎么表达、哪些地方会绕开它。 ## 概述 两个组件协作完成这件事,都在 `XiHan.Framework.Web.Api` 包里: | 组件 | 类型 | 职责 | | --- | --- | --- | | `XiHanApiResponseResultFilter` | MVC 过滤器(`IAsyncResultFilter` + `IAsyncExceptionFilter`) | 包装正常返回;把未处理异常转成同一个信封 | | `XiHanExceptionLoggingMiddleware` | 中间件 | 只记日志、回填状态码,**不产出响应体** | 信封本身 `ApiResponse` / `ApiResponse` 与业务码枚举 `ApiResponseCodes` 定义在 `XiHan.Framework.Application.Contracts`,不依赖 ASP.NET Core——微服务、消息队列、RPC 场景可以复用同一套结果表达。 一次成功响应: ```json { "code": 200, "message": "请求成功", "data": { "basicId": "1863928374", "name": "示例商品" }, "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "timestamp": "2026-08-05T10:12:33.482+08:00", "isSuccess": true } ``` 信封字段: | 字段 | 类型 | 说明 | | --- | --- | --- | | `code` | `int` | 业务码,**恒为数字**。见下方说明 | | `message` | `string` | 面向用户的提示,默认取业务码的 `DescriptionAttribute` | | `data` | `any` | 成功时是业务数据;失败时承载错误明细 | | `traceId` | `string?` | 请求追踪 ID,与响应头 `X-Trace-Id` 同源 | | `timestamp` | `DateTimeOffset` | 服务端时间,默认 `DateTimeOffset.UtcNow`,输出时按 `X-Timezone` 换算 | | `isSuccess` | `bool` | 只读计算属性,`(int)Code is >= 200 and < 300` | `ApiResponse` 用 `new T? Data` 遮蔽父类的 `object? Data`,给客户端代码生成和 OpenAPI 一个精确的数据形状。 ::: tip `code` 为什么标在属性上而不是枚举上 `ApiResponse.Code` **属性**上标了 `[JsonConverter(typeof(NumericEnumConverter))]`,而不是只在 `ApiResponseCodes` 枚举类型上标。 原因是 System.Text.Json 的转换器优先级: ```text 属性特性 > JsonSerializerOptions.Converters 集合 > 类型特性 ``` Web 管道会把 `JsonStringEnumConverter` 加进 `Converters` 集合(全局枚举转成员名),**只标在枚举类型上会被集合压过**,`code` 就变成 `"Success"` 这样的字符串了。标在属性上优先级最高,才能保证无论管道怎么配 `code` 恒为数字。 自己定义信封、或在 DTO 里暴露枚举又希望它输出数字时,照这个位置标。 ::: ::: warning 判定成功一律用 `isSuccess` 两条理由: 1. 不要用「有没有 `data` 字段」判断——序列化开了 `WhenWritingNull`,`data` 为 null 时字段整个不出现; 2. 成功不止 `200` 一个码,`201` / `202` / `204` 同样是成功(`isSuccess` 已经按 `[200, 300)` 算好)。 需要区分具体情形时再比 `code` 的数值。 ::: ## 安装与启用 信封与业务码随契约包引入: ```bash dotnet add package XiHan.Framework.Application.Contracts ``` 自动包装与异常翻译随 Web API 模块生效,不需要额外注册: ```csharp [DependsOn(typeof(XiHanWebApiModule))] public class MyAppModule : XiHanModule { } ``` `AddXiHanWebApiLogging` 把 `XiHanApiResponseResultFilter` 注册为 Scoped 服务,`AddXiHanWebApiMvc` 通过 `options.Filters.AddService()` 挂到全局过滤器链上;`XiHanExceptionLoggingMiddleware` 由模块的 `OnApplicationInitialization` 装进管道。 ## 核心用法 ### 直接返回业务对象 最常见的写法——什么都不用做,返回你的 DTO 即可: ```csharp using XiHan.Framework.Application.Services; public class ProductAppService : ApplicationServiceBase, IProductAppService { public async Task GetByIdAsync(long id) { // 直接返回 DTO,过滤器会包成 { code: 200, message: "请求成功", data: {...} } return await _productRepository.GetDtoAsync(id); } } ``` ### 手动构造信封 需要自己决定业务码时,用静态工厂——它保证 `Code` 与 `Message` 语义一致,不会出现「code 是 403、message 写着请求成功」这种搭配: | 工厂 | 业务码 | 明细去向 | | --- | --- | --- | | `ApiResponse.Success(data, traceId)` | 200 | `Data` = 业务数据 | | `ApiResponse.Success(data, traceId = null)` | 200 | 强类型 `Data` | | `ApiResponse.Created(data = null, traceId = null)` | 201 | `Data` | | `ApiResponse.Continue()` | 100 | — | | `ApiResponse.BadRequest(errorMessage = null, traceId = null)` | 400 | `Data` = 错误明细 | | `ApiResponse.Unauthorized(errorMessage = null)` | 401 | `Data` | | `ApiResponse.Forbidden()` | 403 | — | | `ApiResponse.NotFound()` | 404 | — | | `ApiResponse.UnprocessableEntity(errorMessage = null)` | 422 | `Data` | | `ApiResponse.TooManyRequests()` | 429 | — | | `ApiResponse.InternalServerError(errorMessage = null, traceId = null)` | 500 | `Data`(建议留空) | | `ApiResponse.ServiceUnavailable(errorMessage = null)` | 503 | `Data` = 排查线索 | | `ApiResponse.Failure(code, errorMessage = null, traceId = null)` | 任意 | `Data` | `Failure` 是没有专用工厂时的通用入口,业务段的 10000+ 码都走它: ```csharp using XiHan.Framework.Application.Contracts.Dtos; using XiHan.Framework.Application.Contracts.Enums; var response = ApiResponse.Failure( ApiResponseCodes.PermissionDenied, "缺少 product:delete 权限", traceId); ``` ::: tip `Failure` 接受未定义的枚举值 `Message` 取自 `Enum.IsDefined(code) ? code.GetDescription() : "请求失败"`。传一个自定义的整数码不会抛异常,只是拿不到描述文案。 ::: 控制器基类 `XiHanController` 提供了四个短写法:`Success(data)`、`Success()`、`Fail(message)`、`Fail()`。后两个固定产出 `400` + `ApiResponse.BadRequest`。 ### 用异常表达失败 业务代码里更推荐抛异常,而不是层层往上传 `ApiResponse`——异常会被过滤器统一翻译,并且顺带记进异常日志: ```csharp using XiHan.Framework.Core.Exceptions; // 400:调用方输入或业务状态有问题,重试同样的请求仍会失败 throw new UserFriendlyException("商品已下架,无法加入购物车"); // 503:外部依赖不可达,依赖恢复后同样的请求即可成功 throw new ServiceUnavailableException( "向量库连接失败,请检查 Qdrant 服务状态", innerException: ex); ``` `UserFriendlyException` 与 `ServiceUnavailableException` 都派生自 `BusinessException`,构造参数除消息外还有 `code`、`details`、`innerException`、`logLevel`(前者默认 `Warning`,后者默认 `Error`)。原始基础设施异常放进 `innerException`,它会进日志但不会回给调用方。 ### 不要信封 个别端点(文件下载、第三方回调、需要裸 JSON 的开放接口)打 `[IgnoreApiResponse]`: ```csharp using XiHan.Framework.Web.Api.Filters; [HttpGet("callback")] [IgnoreApiResponse] public IActionResult Callback() => Content("success", "text/plain"); ``` 特性可以打在方法或类上(`AllowMultiple = false`、`Inherited = true`),过滤器同时检查过滤器链与端点元数据。 ::: warning `[IgnoreApiResponse]` 只免成功包装 异常路径不看这个特性。打了它的端点抛出未处理异常,仍然返回标准 `ApiResponse` JSON。 ::: ## 关键机制 ### 哪些返回值会被包装 `OnResultExecutionAsync` 按顺序判断,命中即停: | 情形 | 处理 | | --- | --- | | 过滤器链或端点元数据上有 `IgnoreApiResponseAttribute` | 原样放行 | | 结果是 `FileResult` | 原样放行 | | 结果是 `ObjectResult` 且 `Value` 是 `Stream` | 转成 `FileStreamResult`(`application/octet-stream`),不包装 | | `ObjectResult` / `JsonResult` 且 `Value` 已经是 `ApiResponse` | 不重复包装 | | `ObjectResult` / `JsonResult` / `ContentResult` / `StatusCodeResult` / `EmptyResult` | 包装 | ::: tip 裸 `Stream` 必须被特判 `Stream.Handle` 是 `IntPtr`,直接 JSON 序列化会炸。所以返回 `Stream` 的下载接口不需要额外标注,过滤器自动转成文件流响应。 ::: 状态码决定包成功还是失败:`>= 400` 走错误分支,其余走 `ApiResponse.Success`。另外 **`204` 会被归一成 `200`**(`NormalizeStatusCode`)——`204` 按 HTTP 规范不能带响应体,而信封本身就是响应体。 ### 错误明细放哪 这是最容易搞混的一点:**同样是 400,明细的落点取决于响应是谁构造的。** | 来源 | `message` | `data` | | --- | --- | --- | | `ApiResponse.BadRequest("库存不足")` 等工厂 | 业务码的通用描述(「请求错误」) | `"库存不足"` | | 过滤器包装一个 `ObjectResult` + 字符串值 | 该字符串 | 省略 | | 过滤器包装 `ProblemDetails` / `ValidationProblemDetails` | `Detail` → `Title` → 业务码描述 | 整个 `ProblemDetails` 对象 | | 过滤器包装其它对象 | 业务码描述 | 该对象 | 异常路径走的是工厂,所以 `UserFriendlyException("库存不足")` 的文案在 `data` 里,`message` 是「请求错误」。 ::: warning 客户端取错误提示的顺序 先读 `data`(具体错因),再回退 `message`(业务码的通用描述)。反过来会把所有 400 都显示成「请求错误」。 ::: 模型校验失败同理:`InvalidModelStateResponseFactory` 把 `ModelState` 里的错误消息去重后用 `"; "` 拼接(一条都没有时用 `"请求参数校验失败"`),交给 `ApiResponse.BadRequest(message, traceId)`——所以校验明细也在 `data`。 ### 异常怎么变成状态码 `XiHanApiResponseResultFilter.MapException` 是唯一的映射表,公开静态供中间件复用: | 异常类型 | 状态码 | 构造 | | --- | --- | --- | | `ServiceUnavailableException` | 503 | `ServiceUnavailable(ex.Message)` | | `UserFriendlyException` | 400 | `BadRequest(ex.Message)` | | `BusinessException` | 400 | `BadRequest(ex.Message)` | | `UnauthorizedAccessException` | 401 | `Unauthorized("未授权访问")` | | `KeyNotFoundException` | 404 | `NotFound()` | | `ArgumentException` | 400 | `BadRequest(ex.Message)` | | `InvalidOperationException` | 422 | `UnprocessableEntity(ex.Message)` | | 其它 | 500 | `InternalServerError()` | ::: danger 分支顺序有语义 `ServiceUnavailableException` 与 `UserFriendlyException` 都派生自 `BusinessException`,所以它们必须排在 `BusinessException` 前面。自定义异常派生自这三者之一时,同样要意识到 `switch` 是自上而下第一个匹配生效。 ::: `ArgumentNullException` / `ArgumentOutOfRangeException` 派生自 `ArgumentException`,一并落 400。 ### 500 和 503 的区别 两者都是 5xx,但语义、`data` 策略、日志级别都不同: | | 500 `InternalServerError` | 503 `ServiceUnavailable` | | --- | --- | --- | | 触发 | 未匹配任何分支的异常 | 显式抛 `ServiceUnavailableException` | | 含义 | 服务端实现出了预期外的问题 | 外部依赖暂时不可用 | | 重试 | 重试大概率还是失败 | 依赖恢复后同样的请求即可成功 | | `data` | 工厂调用时不传参,**留空** | 允许带排查线索(是哪个依赖不可达) | | 默认日志级别 | 中间件路径记 `LogError` | 异常自带 `LogLevel.Error` | ::: danger 503 的 data 也不是什么都能写 可以写「向量库连接失败」,不要写主机、端口、连接串、凭据等拓扑信息。500 的 `data` 一律留空——堆栈只进异常日志表,不回给调用方。 ::: 区分这两者的实际收益在监控侧:告警规则可以据此把「用户操作错误」和「基础设施故障」分开,所以不要用 `UserFriendlyException` 代替 `ServiceUnavailableException` 去描述依赖故障。 ### 响应与日志是两条独立的路 理解这一点,才能解释「为什么有的异常返回了 JSON,有的只有一个状态码」: | 异常发生位置 | 谁产出响应 | 谁记日志 | | --- | --- | --- | | MVC 管线内(控制器、应用服务、仓储) | `XiHanApiResponseResultFilter.OnExceptionAsync` → 完整 `ApiResponse` JSON | `XiHanActionLoggingFilter` 记 `ILogger` 告警;过滤器调 `ExceptionLogReporter` 落异常日志表 | | MVC 管线外、且位于异常日志中间件之下游(请求日志 / 限流熔断 / 认证 / 租户解析 / 会话闸门等) | 无响应体,`XiHanExceptionLoggingMiddleware` 仅在 `!Response.HasStarted` 时回填状态码 | 中间件按状态码分级:`>= 500` 记 `LogError`,否则 `LogWarning`;同样调 `ExceptionLogReporter` | 中间件里还会把异常挂到 `Activity.Current` 上(`AddException` + `SetStatus(ActivityStatusCode.Error, ...)`),OpenTelemetry 未激活时 `Activity.Current` 为 null,安全跳过。 `ExceptionLogReporter.ReportAsync` 是异常日志表的单一入口,组装 `ExceptionLogRecord` 交给 `IExceptionLogPipeline`: - 拿不到 `IExceptionLogPipeline` 就静默返回;写入失败只记一条 `LogWarning`,绝不影响主流程。 - 请求头经 `LogSanitizer.MaskHeaders` 按头名整体掩码(`Authorization` / `Cookie` 等),路由与查询参数经 `LogSanitizer.MaskSensitiveData` 脱敏,请求体取的是中间件放进 `HttpContext.Items` 的已脱敏副本。 - 序列化结果超过 4000 字符会被截断。 ::: warning 异常日志默认不落库 `IExceptionLogWriter` 的框架默认实现是 `NullExceptionLogWriter`。要真正入库,应用侧必须注册自己的实现覆盖它(`TryAddScoped` 不覆盖已注册项,所以在应用模块里直接注册即可)。 ::: ### 消息本地化 `message` 会按请求文化(`X-Language`)解析,资源名固定为 `ApiResponse`,键是业务码的**枚举成员名**: ```json { "resource": "ApiResponse", "culture": "en-US", "texts": { "Success": "Success", "BadRequest": "Bad request", "ServerError": "Internal server error" } } ``` 三条规则: 1. 正常响应:键 = `Code.ToString()`,缺工厂或缺键时回退 `DescriptionAttribute` 的中文描述。 2. 异常且携带 `BusinessException.LocalizableMessage`:本地化值优先覆盖 `Data`(当 `Data` 是字符串时),否则覆盖 `Message`——因为 4xx 工厂把用户消息放在 `Data`。 3. 异常且未携带本地化消息、状态码 `>= 500`:用固定键 `ServerError` 本地化通用提示。 带本地化消息的异常这样抛(第一个参数声明为 `object`,传 `ILocalizableString` 实例;第二个 `fallbackMessage` 是键缺失时的回退文案): ```csharp throw new UserFriendlyException( localizableMessage, fallbackMessage: "商品已下架"); ``` ::: tip 本地化查找永不让响应崩溃 `LocalizeApiResponseMessage` 对每个响应都会调用,内部 `try/catch` 全兜住,任何本地化异常都回退原文。 ::: ## 业务码 `ApiResponseCodes` 分两段。**协议段 100–599** 与 HTTP 状态码同值同名,非 HTTP 场景(消息队列、RPC)也能复用;**业务段 10000+** 按千位分类留段扩展。 协议段: | 段 | 成员 = 值 | | --- | --- | | 1xx | `Continue` = 100、`SwitchingProtocols` = 101 | | 2xx | `Success` = 200、`Created` = 201、`Accepted` = 202、`NoContent` = 204 | | 3xx | `MultipleChoices` = 300、`MovedPermanently` = 301、`Found` = 302、`NotModified` = 304 | | 4xx | `BadRequest` = 400、`Unauthorized` = 401、`Forbidden` = 403、`NotFound` = 404、`MethodNotAllowed` = 405、`RequestTimeout` = 408、`Conflict` = 409、`Gone` = 410、`UnsupportedMediaType` = 415、`UnprocessableEntity` = 422、`Locked` = 423、`TooManyRequests` = 429 | | 5xx | `InternalServerError` = 500、`NotImplemented` = 501、`BadGateway` = 502、`ServiceUnavailable` = 503、`GatewayTimeout` = 504 | 业务段: | 段 | 成员 = 值 | 用途 | | --- | --- | --- | | 10xxx 认证与授权 | `LoginExpired` = 10001、`TokenInvalid` = 10002、`TokenExpired` = 10003、`PermissionDenied` = 10004 | 比 401/403 更细的语义 | | 11xxx 数据校验 | `ValidationFailed` = 11000 | 明细逐项放 `Data` | | 12xxx 业务处理 | `BusinessFailed` = 12000 | 业务规则/状态机不允许 | | 13xxx 数据访问 | `DatabaseError` = 13000 | 持久化层错误 | | 14xxx 外部依赖 | `ThirdPartyServiceError` = 14000 | 第三方接口不可用/超时 | 选码的判断顺序: - 客户端要**区分动作**(跳登录页 / 刷新令牌 / 提示缺权限)时用业务段。`401` 只说「没认证」,`LoginExpired` 才说「曾登录、现已过期」;`403` 只说「无权限」,`PermissionDenied` 面向按钮级、字段级的权限点。 - 客户端不需要区分、只要展示消息时,协议段够用。 - **`Locked` = 423 单独记**:会话已锁定但**身份仍然有效**,客户端应引导解锁而不是跳登录页。框架不假设锁定原因(锁屏、风控挂起、强制改密、二次验证未完成都可能),原因由应用侧经 `ISessionStateGate` 定义。 `XiHanSessionStateMiddleware` 是少数直接写信封的中间件——它在授权之前拦截失效会话(`401` + 「会话已失效,请重新登录」)与锁定会话(`423` + `data` 带 `reason` / `displayName` / `avatarUrl`),因为解锁页要显示「锁的是谁」,而用户信息接口此时已被挡住。 ## 配置 信封与包装本身没有配置项,行为是固定约定。相关的可配项在异常日志一侧,配置节 `XiHan:Auditing:LogQueue`: | 键 | 默认值 | 说明 | | --- | --- | --- | | `EnableExceptionLogQueue` | `false` | `false` 时同步调 `IExceptionLogWriter`;`true` 时入队后由后台 Worker 批量写 | | `QueueCapacity` | `10000` | 有界队列容量 | | `DropOnFull` | `false` | `true` 满时丢弃并记警告;`false` 满时等待,反压到请求线程 | | `BatchSize` | `100` | 批量落库条数 | | `BatchDelayMilliseconds` | `200` | 批量落库间隔 | | `IgnoredPathPrefixes` | `["/hubs"]` | 不记请求日志的路径前缀 | 本地化资源按 `ApiResponse.{culture}.json` 命名,键用枚举成员名加固定键 `ServerError`,接入方式见[国际化](../packages/localization)。 ## 常见问题 | 现象 | 原因 | 处理 | | --- | --- | --- | | 前端拿到的错误提示全是「请求错误」 | 只读了 `message`,工厂把明细放在 `data` | 取值顺序改为 `data` → `message` | | `data` 字段在响应里整个消失 | JSON 序列化 `WhenWritingNull` | 客户端按可选字段处理,判成功用 `isSuccess` | | `code` 输出成字符串 | 不会发生——`NumericEnumConverter` 强制 int | 若自定义了信封,记得给枚举加同样的转换器 | | 返回 `NoContent()` 却收到 `200` | `204` 被 `NormalizeStatusCode` 归一为 `200` | 这是有意的,信封本身就是响应体 | | 下载接口报 `System.IntPtr ... is not supported` | 返回值不是裸 `Stream` 也不是 `FileResult`,被包装后序列化 | 直接返回 `Stream` 或 `FileResult`,或打 `[IgnoreApiResponse]` | | 打了 `[IgnoreApiResponse]` 仍收到信封 | 该特性只免成功包装,异常路径不看它 | 在端点内部自行捕获异常 | | 异常日志表一条不写 | `IExceptionLogWriter` 停留在 `NullExceptionLogWriter` | 应用侧注册真实现 | | 认证中间件抛异常后拿到空响应体 | 异常在 MVC 管线外,中间件只回填状态码 | 按状态码 + 响应头 `X-Trace-Id` 排查 | | 熔断触发时收到的不是标准信封 | `XiHanCircuitBreakingMiddleware` 在 MVC 之前短路,写的是极简 JSON | 客户端对 `503` 做兜底处理,别假设一定有 `code` 字段 | | 校验失败的 `traceId` 与响应头 `X-Trace-Id` 不一致 | `InvalidModelStateResponseFactory` 与 `XiHanController` 的助手方法取的是 `HttpContext.TraceIdentifier` | 排查以响应头 `X-Trace-Id` 为准;应用代码取 TraceId 用 `ITraceIdProvider.GetCurrentTraceId()` | ## 下一步 - [Web 应用开发](./web):整条中间件管道、JSON 序列化约定与请求头约定 - [动态 API](./dynamic-api):应用服务怎么变成 REST 接口 - [认证与授权](./authentication):`401` / `403` / `423` 分别由谁产出 - [常见问题](./faq):跨能力域的排查清单 - [Application.Contracts 包](../packages/application-contracts):信封与业务码的完整 API 清单 - [Web.Api 包](../packages/web-api):过滤器、中间件与全部配置项 - [Auditing 包](../packages/auditing):异常日志的记录模型与写入器契约 --- 来源:https://framework.docs.xihanfun.com/guide/script # 脚本引擎 把易变的业务规则(折扣公式、审批条件、字段派生逻辑)写成一段 C# 源码存起来,运行期编译执行,改逻辑不用重新发布。这一章讲怎么把它接进项目、`IScriptEngine` 的契约长什么样,以及它的安全边界到哪为止——**边界比名字听上去窄得多,先看完再决定用不用**。 ## 只有 C# `XiHan.Framework.Script` 的唯一第三方依赖是 `Microsoft.CodeAnalysis.CSharp`(Roslyn)。没有 JavaScript、Python、Lua 引擎,也没有留出接别的语言的抽象层。 脚本不是被解释执行的 DSL,而是**真的 C# 源码**:Roslyn 在内存里把它编译成一个程序集,然后反射调用其中的入口方法。所以脚本拥有和宿主代码同等的能力——这既是它好用的原因,也是安全章节要反复强调的原因。 | 适合 | 不适合 | | --- | --- | | 运营/实施人员维护的计算公式、判定条件 | 执行终端用户提交的任意代码 | | 需要频繁调整、又不值得每次发版的分支逻辑 | 需要真正沙箱隔离的多租户脚本市场 | | 工作流节点里的一小段胶水代码 | 高频热路径(每次执行都要反射调用 + 载入程序集) | | 配置化的数据清洗、字段映射 | 依赖大量业务类型的复杂逻辑(引用要一个个手动挂) | ## 安装与启用 ```bash dotnet add package XiHan.Framework.Script ``` ```csharp [DependsOn(typeof(XiHanScriptModule))] public class MyModule : XiHanModule { } ``` ::: warning 模块本身不注册任何服务 `XiHanScriptModule.ConfigureServices` 是空的——依赖它并不会让你能注入 `IScriptEngine`。三条拿到引擎的路子,按场景选: ```csharp // 1. 静态门面:一次性、脚手架、单元测试 var value = await XiHanScript.EvalAsync("1 + 2 * 3"); // 2. 命名引擎:想按业务域隔离编译缓存 var engine = ScriptEngineFactory.GetOrCreate("discount-rules"); // 3. 自己注册进 DI:想在服务里构造注入(推荐) context.Services.TryAddSingleton(_ => new ScriptEngine()); ``` 第三条正是框架内部的做法——`XiHanWorkflowModule` 依赖脚本模块之后,自己补了一句 `TryAddSingleton(_ => new ScriptEngine())` 给脚本活动用。 ::: `ScriptEngine` 实现了 `IDisposable`,`Dispose()` 只是清空它的编译缓存。编译缓存用并发字典存放,可以被并发调用;引擎**当单例长期复用**才有意义——缓存挂在实例上,每次 `new` 一个引擎等于把缓存清零。(并发下 `GetStatistics()` 的缓存命中/未命中计数有轻微漂移,只当观测参考,别拿它做判定。) ## IScriptEngine 契约 | 方法 | 干什么 | 失败时 | | --- | --- | --- | | `ExecuteAsync(code, options)` | 编译并执行,返回 `ScriptResult` | 返回 `IsSuccess=false`,**不抛异常** | | `ExecuteAsync(code, options)` | 同上,结果转 `ScriptResult` | 同上 | | `ExecuteFileAsync(path, options)` | 读文件后执行 | 文件不存在→失败结果;**加载/文件安全校验失败会抛异常** | | `CompileAsync(code, options)` | 只编译不执行,返回 `CompilationResult` | 返回失败结果 + 诊断 | | `CreateInstanceAsync(code, options)` | 执行脚本,把返回值 `as T` | 返回 `null` | | `EvaluateAsync(expr, options)` | 求值表达式,直接返回值 | 返回 `null`,**错误信息全部丢失** | | `EvaluateAsync(expr, options)` | 同上,类型不匹配返回 `default` | 同上 | | `ClearCache()` | 清空该引擎的编译缓存 | — | | `GetStatistics()` | 取 `EngineStatistics` 快照 | — | ::: tip 契约上的三个反直觉点 1. **`ExecuteAsync` 从不抛异常**。超时、安全违规、脚本内部异常,全被包成 `ScriptResult.Failure(...)`,原始异常放在 `Exception` 属性里。要判断是哪类失败就 `result.Exception is ScriptSecurityException` 这样取。 2. **`ExecuteFileAsync` 会抛**。文件层的 `ScriptLoadException` / `ScriptSecurityException` 是直接抛出去的,和 `ExecuteAsync` 不对称。想统一成失败结果,用扩展方法 `ExecuteFileSafelyAsync`。 3. **`CreateInstanceAsync` 不 new 任何东西**。它就是执行一次脚本,再把入口方法的返回值 `as T`。想拿到实例,脚本自己得 `return new Xxx();`。 ::: 同步版本、批量、基准测试、异常兜底等便捷方法都在 `ScriptEngineExtensions` 扩展里(`Execute` / `ExecuteBatchAsync` / `ExecuteSafelyAsync` / `BenchmarkAsync` …),完整清单见[包文档](../packages/script)。 ## 第一件事:选对 ScriptType 这是新手踩坑最集中的地方。引擎会先按 `ScriptType` 把你的源码**包进一个固定壳子**,然后编译,最后去程序集里找 `ScriptClass` 类的 `public static Execute` 方法调用。 | ScriptType | 引擎怎么包 | 你要怎么写 | 返回值从哪来 | | --- | --- | --- | --- | | `Statement`(默认) | 包进 `ScriptClass.Execute()`,开头注入 `object result = null;`,结尾 `return result;` | 直接写语句 | 给 `result` 赋值 | | `Expression` | 包进 `ScriptClass.Execute()`,写成 `return {你的表达式};` | 只写一个表达式,不带分号 | 表达式的值 | | `Method` | 原样嵌进 `public class ScriptClass { … }` | 自己写 `public static object Execute(...)` | 方法的 `return` | | `Class` | 原样输出(前面带 `using` 头) | 自己写完整的类,**类名必须是 `ScriptClass`** | 其 `public static Execute` 的 `return` | | `Program` | 完全不包装,**连 `using` 头都不加** | 自己写全部代码,仍需包含 `ScriptClass.Execute` | 同上 | ::: warning 入口点是硬编码的 `FindEntryPoint` 找的是 `assembly.GetType("ScriptClass")` 上的 `public static Execute`——全局命名空间,类名一个字都不能差。`Class` / `Program` 类型下写了别的类名,会得到"未找到有效的入口点"。 ::: ### 表达式与语句 ```csharp // Expression:EvaluateAsync 会自动把 ScriptType 切成 Expression int sum = await XiHanScript.EvalAsync("1 + 2 * 3"); // 7 // Statement(默认):给内置的 result 变量赋值就是返回值 var r = await XiHanScript.RunAsync("result = 10 * 20;"); if (r.IsSuccess) { Console.WriteLine(r.Value); // 200 Console.WriteLine(r.FromCache); // 第二次执行同一段代码就是 true } ``` ::: danger `EvalAsync` 会吞掉全部错误 `EvaluateAsync` 在失败时直接 `return null`——编译错误、安全违规、脚本抛异常,你一律只看到 `null` 或 `default`。只在"表达式一定是对的"场景用它;需要错误信息就走 `ExecuteAsync` 拿 `ScriptResult`。 ::: ### 方法:唯一能收参数的形态 ```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 ``` ## 传参进脚本:Globals 走的是方法参数 `Globals` **不是脚本里可以直接访问的字典**。引擎反射调用入口方法时,逐个读方法的参数名,去 `Globals` 里找同名键填成实参;找不到就填 `null`。 推论很直接: - `Statement` / `Expression` 被包出来的 `Execute()` **没有参数**,所以这两种形态下 `Globals` 里放什么都到不了脚本里。 - 想传值,必须用 `Method`(或自己写 `ScriptClass` 的 `Class` / `Program`),并让参数名和 `Globals` 的键一致。 - 参数类型要和 `Globals` 里的值类型对得上,否则反射调用会失败(被包成执行失败结果)。 框架自己的工作流脚本活动就是这个套路:把实例变量装成一个 `IDictionary` 传进去,脚本读写这个字典,执行成功后再整体合并回实例变量。 ```csharp var vars = new Dictionary { ["amount"] = 100m }; var options = new ScriptOptions() .WithScriptType(ScriptType.Method) .AddGlobal("variables", vars); await engine.ExecuteAsync( """ public static object Execute(System.Collections.Generic.IDictionary variables) { variables["discounted"] = (decimal)variables["amount"] * 0.9m; return null; } """, options); ``` ::: tip 想让脚本用到你的业务类型 默认只挂了极少几个程序集引用:`object` / `Console` / `Enumerable` 所在的程序集,加上运行时目录下的 `System.Runtime.dll` 与 `System.Collections.dll`。其余(包括 `System.Text.Json`、`System.Net.Http` 和你自己的业务程序集)都要显式加: ```csharp options.AddReference(typeof(OrderDto)) // 按类型挂程序集 .AddReference("/app/plugins/Rules.dll") // 按路径挂 .AddImport("MyApp.Domain.Orders"); // 加 using ``` 注意 `Program` 类型不会自动写 `using` 头,`AddImport` 对它无效,得在脚本里自己写。 ::: ## 编译校验:把错误挡在保存那一刻 脚本是配置,配置就该在保存时校验,而不是等业务跑到那一步才炸。两个入口: | 入口 | 返回 | 用途 | | --- | --- | --- | | `CompileAsync(code, options)` | `CompilationResult`(`IsSuccess` / `Diagnostics` / `ErrorMessage` / `CompilationTimeMs`) | 只编译不执行 | | `ValidateSyntaxAsync(code, options)`(扩展方法) | `SyntaxValidationResult`(`IsValid` / `Errors` / `Warnings` / `FormatErrors()`) | 编译一次并把诊断按严重级别分好 | ```csharp var syntax = await engine.ValidateSyntaxAsync(WrapRule(body), BuildOptions()); if (!syntax.IsValid) { throw new UserFriendlyException($"规则脚本有语法错误:\n{syntax.FormatErrors()}"); } ``` 两点要知道: - **`CompileAsync` 不走编译缓存**,每次都真编译一遍。校验频率高的话自己节流。 - 编译过程抛出异常(而不是编译报错)时,返回的失败结果 `Diagnostics` 为空、`ErrorMessage` 为空串。看到"编译失败但没有任何错误信息",说明走的是这条分支。 ## 编译缓存:省的是编译,不是加载 缓存挂在引擎实例上,`EnableCache` 默认为 `true`。缓存键取 `CacheKey`(显式指定时优先),否则由这五项算出来: ```text 脚本源码 + ScriptType + Imports + References 的 FullName + ReferencePaths ``` ::: warning 缓存键不含编译开关 `AllowUnsafe`、`OptimizationLevel`、`CompilerOptions`、`SecurityOptions`、`Globals` **都不参与缓存键计算**。同一段代码换了这些开关重新执行,会直接复用上一次编译出来的程序集,编译级设置不会重新生效。 好消息是安全校验是在**每次执行**时做的(不是编译期),所以换安全策略仍然有效;受影响的只是 `AllowUnsafe` 和优化级别这类编译期开关。真要区分,用 `WithCacheKey(...)` 手动把它们编进键里。 ::: `ExecuteFileAsync` 会自动用 `file:{路径}:{最后写入时间Ticks}` 作缓存键——改文件自动失效,这点不用操心。 ::: danger 命中缓存也不是零成本 即便命中缓存,每次执行仍然会 `Assembly.Load(编译出的字节数组)` 载入一份程序集,而 .NET 里这样载入的程序集**无法卸载**。高频、长期执行会持续累积程序集与元数据内存。 所以:脚本适合"低频 + 逻辑可变",不适合放在每秒成千上万次的热路径上。真要高频,考虑把脚本结果缓存成委托或直接落成代码。 ::: 统计信息可以拿来观察缓存效果: ```csharp var stats = engine.GetStatistics(); // TotalExecutions / SuccessRate / CacheHitRate / AverageCompilationTimeMs / Uptime ``` ## 超时:它保护调用方,保护不了进程 ::: danger `ScriptOptions.TimeoutMs` 让调用方按时返回,停不下脚本本身 引擎把执行任务与 `Task.Delay(TimeoutMs)`(默认 30000)竞速,到点即判超时:`ExecuteAsync` 返回 `IsSuccess=false`、`Exception` 为 `ScriptTimeoutException` 的结果。但托管代码没有安全的线程中止手段,**被判超时的脚本线程仍在后台跑完**。 ::: 想让超时直接抛异常,用扩展方法——它会覆盖 `options.TimeoutMs`,到点**抛出** `ScriptTimeoutException`,而不是返回失败结果: ```csharp try { var result = await engine.ExecuteWithTimeoutAsync(code, timeoutMs: 2000, options); } catch (ScriptTimeoutException ex) { // 2 秒后这里一定会走到 } ``` 两种方式到点都把控制权还给你,代价也一样:**脚本线程还在后台跑到底**,线程池线程和它占的内存都收不回来。一个 `while(true)` 会永久占住一个线程池线程直到进程重启。 结论:超时只是给调用方的 SLA 兜底,不是安全机制。真正的兜底是"不执行不可信代码"。 ## 安全边界 ::: danger 这不是沙箱 脚本编译出的程序集载入宿主进程、以宿主的完整权限运行。这里的"安全机制"是三层**有限的静态检查**,全部可以被绕过。**不要用它执行不可信代码。** ::: 三层检查各自实际做了什么: ### 1. 文件层(只在 `ExecuteFileAsync` 生效) | 检查 | 依据 | | --- | --- | | 扩展名白名单 | `AllowedFileExtensions`,默认 `.cs` / `.csx` / `.txt` | | 路径里含 `..`、`~`、`$` 直接拒 | 硬编码 | | 文件大小上限 | `MaxFileSize`,默认 10MB | 违规抛 `ScriptSecurityException`,`ViolationType` 分别是 `InvalidFileExtension` / `DangerousPath` / `FileTooLarge`。 ### 2. 程序集层(每次执行都跑) 编译成功后、调用入口方法前,对 `assembly.GetTypes()` 做反射检查: | 检查 | 命中条件 | | --- | --- | | 不安全代码(`AllowUnsafe=false` 时) | 类型名**包含** `Unsafe`,或有指针类型的字段/参数/返回值 | | 禁止命名空间 | 类型的 `Namespace` 以 `ForbiddenNamespaces` 中任一项开头 | | 禁止类型 | 类型的 `FullName` **包含** `ForbiddenTypes` 中任一项 | | 危险方法 | 方法的 `Name` **包含** `DangerousKeywords` 中任一项 | ::: danger 检查的是"脚本定义了什么",不是"脚本调用了什么" `GetTypes()` 只能看到脚本自己声明的类型,`ForbiddenTypes` / `ForbiddenNamespaces` 比对的也是这些类型的名字。 所以脚本里写 `System.Diagnostics.Process.Start(...)` **不会**被这层拦下来——它只声明了 `ScriptClass` 一个类型,`FullName` 是 `"ScriptClass"`,不含任何黑名单项。反过来,比对用的是黑名单里的**完整串**:把自己的类命名成 `MyProcessHelper` 同样不会命中 `ForbiddenTypes`,因为默认项是 `System.Diagnostics.Process` 整串。 同理,`DangerousKeywords` 比对的是脚本自己定义的方法名,不是方法体里的调用——真会误伤的是方法名里恰好含黑名单串的情况,比如方法叫 `MarshalRow` 会命中 `Marshal`(大小写敏感)。 ::: 顺带一个真实的误伤:`AllowUnsafe=false`(默认)时,**类名里带 `Unsafe` 字样就会被拒**,哪怕它完全安全。 ### 3. 源码预检(可选,纯字符串匹配) `ValidateSecurityAsync` 扩展方法是**独立于执行期检查的另一套东西**:先编译一次验语法,再在源码文本里 `Contains` 一批关键字,给出 `SecurityRiskLevel`(`Low` / `Medium` / `High`)和问题清单。 ```csharp var check = await engine.ValidateSecurityAsync(code); if (check.RiskLevel >= SecurityRiskLevel.Medium) { logger.LogWarning("脚本包含敏感操作:{Issues}", check.FormatIssues()); } ``` 匹配的关键字分三档:危险(`unsafe` / `DllImport` / `Process.Start` / `Assembly.Load` / `Environment.Exit` …)判 `High`,网络(`HttpClient` / `Socket` / `TcpClient` …)和文件(`File.` / `Directory.` / `FileStream` …)判 `Medium`。字符串拼接就能绕开,**只适合当提交时的提醒,不能当门禁**。 ### `SecurityOptions` 里哪些开关真的有用 ::: warning 一半开关目前只是数据 | 字段 | 是否被消费 | | --- | --- | | `EnableSecurityChecks` | ✅ 总开关,关掉则整层跳过 | | `AllowedFileExtensions` / `MaxFileSize` | ✅ 仅 `ExecuteFileAsync` 路径 | | `ForbiddenNamespaces` / `ForbiddenTypes` / `DangerousKeywords` | ✅ 程序集层反射检查 | | `EnableStrictMode` | ❌ 引擎从不读取 | | `AllowFileSystemAccess` / `AllowNetworkAccess` / `AllowReflectionAccess` | ❌ 引擎从不读取 | | `AllowProcessOperations` / `AllowRegistryAccess` / `AllowEnvironmentAccess` | ❌ 引擎从不读取 | 因此 `ScriptOptions.WithStrictSecurity()` 实际只做了一件有效的事:把 `AllowUnsafe` 设为 `false`(而它本来就是 `false`)。它设置的另外四个标志目前不影响任何行为。 要真正收紧,请改**有效的那三个列表**: ```csharp var options = new ScriptOptions().WithSecurity(s => { s.ForbiddenNamespaces.Add("MyApp.Infrastructure"); s.DangerousKeywords.Add("Sql"); }); ``` `SecurityOptions.Strict()` 预设相比默认值,真正生效的差异是把 `MaxFileSize` 收到 1MB、扩展名白名单收到 `.cs` / `.csx`——只影响文件执行路径。 ::: ### 实际能守住的底线 - 脚本必须由**你自己或受信的运维/实施人员**编写,并走评审或审批流程。 - 存脚本的表要有权限控制和变更审计,改动可追溯到人。 - 用 `ForbiddenNamespaces` 把内部基础设施命名空间挡掉,减少手滑范围。 - 要跑真正不可信的代码,请上进程或容器级隔离——本包提供不了这个层级的保证。 ## 配置 ::: warning 脚本选项不走 appsettings `ScriptOptions` 没有配置节名,不绑定 `IConfiguration`,也不经 DI Options 系统。它是**每次调用时作为参数传进去的普通对象**,不传就用 `ScriptOptions.Default`(每次返回一个新实例)。 ::: 常用项(完整表格见[包文档](../packages/script)): | 字段 | 默认值 | 说明 | | --- | --- | --- | | `ScriptType` | `Statement` | 脚本形态,决定包装方式 | | `TimeoutMs` | `30000` | 见上文「超时」一节的实际效果 | | `EnableCache` / `CacheKey` | `true` / `null` | 编译缓存开关与自定义键 | | `Imports` | System / Collections.Generic / Linq / Text / Threading.Tasks | 自动写入的 `using` | | `References` / `ReferencePaths` | 空 | 附加程序集引用 | | `Globals` | 空 | 按入口方法参数名匹配注入 | | `AllowUnsafe` | `false` | 允许不安全代码 | | `OptimizationLevel` | `Debug` | 长期跑的脚本建议 `WithOptimization()` 切 `Release` | | `SecurityOptions` | 见上 | 安全约束 | ::: danger `ScriptOptions` 是可变对象,别共享 所有 `WithXxx` / `AddXxx` 都是在**当前实例上原地修改**再 `return this`,不是复制。后果: - 把一个共享的 options 传给 `EvaluateAsync`,它的 `ScriptType` 会被永久改成 `Expression`。 - 传给 `ExecuteFileAsync`,它会被写上一个 `CacheKey`。 - 传给 `ExecuteWithTimeoutAsync`,它的 `TimeoutMs` 会被改掉。 要么每次调用前新建,要么用工厂方法产出: ```csharp private static ScriptOptions BuildOptions() => new ScriptOptions() .WithScriptType(ScriptType.Method) .WithTimeout(2000); ``` ::: ::: danger 构建器和工厂的配置不会传给引擎 `ScriptEngineBuilder` 的 `AddReference` / `AddImport` / `AddGlobal` / `WithTimeout` / `WithOptimization` / `WithUnsafe` / `DisableCache` 只写进构建器内部持有的一份 `ScriptOptions`,而 `Build()` 只是 `new ScriptEngine()` 后执行 `Configure(Action)` 注册的委托——那份 options 根本不会交给引擎。`ScriptEngineFactory.Create(configure)` 同理,`configure` 参数当前被忽略。 所以:**一切执行期配置只能通过 `Execute*` / `Evaluate*` 的 `options` 参数传**。链式调完构建器就以为配好了,是最难查的一类"设置没生效"。 ::: `CompilerOptions` 里同样有一部分只是数据:引擎实际读取 `LanguageVersion`(默认 `Latest`)、`WarningLevel`(4)、`TreatWarningsAsErrors`、`GenerateDebugInfo`、`DebugInformationFormat`;而 `PreprocessorSymbols`、`WarningsAsErrors`、`WarningsNotAsErrors`、`DisabledWarnings` 目前不参与编译。 ## 一个完整的接法:规则存库,运行期执行 ```csharp using Microsoft.Extensions.DependencyInjection.Extensions; using XiHan.Framework.Script; using XiHan.Framework.Script.Core; using XiHan.Framework.Script.Enums; using XiHan.Framework.Script.Extensions; using XiHan.Framework.Script.Options; public class DiscountRuleEvaluator(IScriptEngine engine) { /// 保存规则前校验 public async Task ValidateAsync(string ruleBody) { var syntax = await engine.ValidateSyntaxAsync(Wrap(ruleBody), BuildOptions()); return syntax.IsValid ? null : syntax.FormatErrors(); } /// 执行规则,失败时回退到原价 public async Task EvaluateAsync(string ruleBody, decimal amount, int level) { var options = BuildOptions() .AddGlobal("amount", amount) .AddGlobal("level", level); var result = await engine.ExecuteAsync(Wrap(ruleBody), options); return result.IsSuccess ? result.Value : amount; } // 规则体只写方法体,壳子由这里补,运营就不用关心签名 private static string Wrap(string ruleBody) { return "public static object Execute(decimal amount, int level)\n{\n" + ruleBody + "\n}"; } private static ScriptOptions BuildOptions() { return new ScriptOptions() .WithScriptType(ScriptType.Method) .WithTimeout(2000) .WithSecurity(s => s.ForbiddenNamespaces.Add("MyApp.Infrastructure")); } } ``` 注册(引擎做成单例,编译缓存才能跨请求复用): ```csharp public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.TryAddSingleton(_ => new ScriptEngine()); context.Services.AddTransient(); } ``` 运营写的规则体长这样: ```csharp if (level >= 3) return amount * 0.8m; if (amount > 1000m) return amount * 0.9m; return amount; ``` ## 和工作流的联动 `XiHanWorkflowModule` 依赖 `XiHanScriptModule`,内置了一个 C# 脚本活动:节点上配 `Code` 属性(方法体代码),通过 `variables` 字典读写流程实例变量,配 `ResultVariable` 则把 `return` 的值写进指定变量。脚本对 `variables` 的修改会在执行成功后合并回实例变量。 想在工作流里嵌一小段动态逻辑,用现成的脚本活动比自己接引擎省事。细节见[工作流包文档](../packages/workflow)。 ## 包里还有什么,以及它们的成色 | 组件 | 当前状态 | | --- | --- | | `ScriptTemplateManager` / `ScriptTemplate` | 可用。参数化脚本模板,`Code` 里用 `#{参数名}` 占位,`GenerateCode(...)` 做纯字符串替换(不是模板引擎),`ValidateParameters` 支持必填/范围/正则/枚举校验 | | `ScriptMonitor` / `ScriptMonitorOptions` | 可用但**不自动接线**。引擎不会调用它,需要你在每次执行后自己 `monitor.LogExecution(result, code, path)`;之后才能取统计、订阅事件、导出日志 | | `DebugOptions` / `Breakpoint` / `DebugLevel` / `HitCountCondition` | **未接入**。是公开的数据模型,但 `ScriptOptions` 和 `ScriptEngine` 都不引用它们——设断点、调调试级别当前对执行没有任何影响,不要基于它们设计功能 | ## 常见问题 | 现象 | 原因 | | --- | --- | | 「未找到有效的入口点」 | `Class` / `Program` 脚本的类名不是 `ScriptClass`,或没有 `public static Execute` | | `Globals` 传的值脚本里拿不到 | 用了 `Statement` / `Expression`(包出来的 `Execute()` 无参);改用 `Method` 并让参数名对上键名 | | `Program` 脚本报找不到 `List` 之类 | `Program` 不会自动写 `using` 头,`Imports` / `AddImport` 对它无效,脚本里自己写 | | 编译报找不到业务类型 | 默认只挂了几个基础程序集,要 `AddReference(typeof(X))` + `AddImport("命名空间")` | | `EvalAsync` 一直返回 `null` | 它失败时就返回 `null`,看不到原因;换 `ExecuteAsync` 读 `ErrorMessage` / `Diagnostics` | | `ScriptResult.Value` 是 `default` 但 `IsSuccess` 是 `true` | 脚本返回的类型和 `T` 对不上,取值时静默降级为 `default` | | 死循环脚本超时后仍占着线程 | 到 `TimeoutMs` 调用方拿到超时失败结果,但脚本线程仍在后台跑;超时不是隔离手段,不要执行不可信代码 | | 类名带 `Unsafe` 被判违规 | `AllowUnsafe=false` 时按类型名字符串匹配,改类名 | | 脚本调 `Process.Start` 没被拦住 | 安全检查只看脚本**声明**的类型/方法名,不看调用;这是设计上的边界,不是配置问题 | | 改了 `AllowUnsafe` / 优化级别没生效 | 这两项不参与缓存键,命中缓存直接复用旧程序集;`ClearCache()` 或用 `WithCacheKey` 区分 | | 构建器上配的引用/超时全部不生效 | `Build()` 不传那份 options,配置要通过每次调用的 `options` 参数给 | | 长跑服务内存缓慢上涨 | 每次执行都 `Assembly.Load` 且程序集不可卸载,降低执行频率或改用编译后的委托 | | 「编译失败」但没有任何错误信息 | 编译过程本身抛了异常,此时诊断为空、`ErrorMessage` 为空串 | | 注入不到 `IScriptEngine` | `XiHanScriptModule` 不注册服务,自己 `TryAddSingleton(_ => new ScriptEngine())` | ## 下一步 - [脚本引擎包文档](../packages/script):完整 API 清单与全部配置项 - [模块化](./modularity):模块依赖与 `DependsOn` - [依赖注入](./dependency-injection):把引擎注册成单例的几种写法 - [工作流包文档](../packages/workflow):内置 C# 脚本活动 - [常见问题](./faq):框架级排查清单 --- 来源:https://framework.docs.xihanfun.com/guide/search # 搜索引擎 框架把全文检索收敛成一个很小的接口 `ISearchEngine`:业务代码只写「建索引、写文档、查」,开发期跑进程内实现,生产切到 Elasticsearch,业务代码一行不改。 ## 三个包的分工 | 包 | 内容 | 何时引用 | | --- | --- | --- | | `XiHan.Framework.SearchEngines.Abstractions` | 契约本体:`ISearchEngine`、请求/结果/索引定义类型。**无任何实现、无第三方依赖** | 只想依赖契约的类库项目 | | `XiHan.Framework.SearchEngines` | `XiHanSearchEnginesModule` + `DefaultSearchEngine`(进程内兜底实现) | 应用宿主,必引 | | `XiHan.Framework.SearchEngines.Elasticsearch` | `ElasticsearchSearchEngine` + `ElasticsearchOptions`。**没有模块类** | 生产接 Elasticsearch 时追加 | ## 契约是「交集」,不是「并集」 `ISearchEngine` 按「Elasticsearch 与 PostgreSQL 全文检索都能落地」的交集设计,签名里不出现任一后端的类型。这带来两条要接受的约束: - **字段类型只有六种**:`Text`、`Keyword`、`Integer`、`Double`、`Boolean`、`DateTime`。分片、副本、分析器链这类后端独有的调优项不进抽象,由各实现从自己的配置节读。 - **过滤条件之间恒为「与」**。同字段多值用 `SearchFilterOperator.In` 表达;**跨字段的「或」不在契约范围内**,需要时由调用方拆成多次查询自己合并。 `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)` | 让此前的写入立即对检索可见 | 完整类型清单(`SearchRequest`、`SearchFilter`、`SearchSort`、`SearchHit` 等各成员)见 [Abstractions 包](../packages/search-engines-abstractions)。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.SearchEngines ``` ```csharp [DependsOn(typeof(XiHanSearchEnginesModule))] public class MyModule : XiHanModule { } ``` 依赖上模块后即可无条件注入 `ISearchEngine`,此时拿到的是 `DefaultSearchEngine`。模块自身只做兜底注册: ```csharp context.Services.TryAddSingleton(); context.Services.TryAddSingleton(sp => sp.GetRequiredService()); ``` ::: warning 进程内实现能做什么、不能做什么 `DefaultSearchEngine` 面向**单机开发与自动化测试**:最多 100 个索引、每索引 100000 条文档,超限抛 `InvalidOperationException`(批量写入整批拒绝);索引状态放在进程内,**进程重启即丢**,多实例部署时各进程各有一份、互不可见;关键字匹配是**大小写不敏感的子串包含**,命中几个字段就得几分,**不分词、没有相关度模型**。 用它验证「链路通不通」可以,验证「搜得准不准」不行。生产必须换实现。 ::: ## 核心用法 三步:建索引 → 写文档 → 查。 ```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"; // 建索引:已存在时返回 false,不做任何事 public Task EnsureIndexAsync() { return search.CreateIndexAsync(new SearchIndexDefinition(Index, [ new SearchFieldDefinition("title", SearchFieldType.Text, Searchable: true), new SearchFieldDefinition("content", SearchFieldType.Text, Searchable: true), // 要排序/精确过滤的文本字段用 Keyword,不要用 Text new SearchFieldDefinition("tag", SearchFieldType.Keyword, Sortable: true), new SearchFieldDefinition("views", SearchFieldType.Integer, Sortable: true), new SearchFieldDefinition("publishTime", SearchFieldType.DateTime, Sortable: true), ]) { Language = "zh" }); } // 写入:标识已存在时整体覆盖 public async Task IndexAsync(Article article) { await search.IndexAsync(Index, new SearchDocument
(article.Id.ToString(), article)); } public Task IndexManyAsync(IEnumerable
articles) { return search.IndexManyAsync(Index, articles.Select(a => new SearchDocument
(a.Id.ToString(), a))); } // 检索 public Task> SearchAsync(string keyword, int skip) { return search.SearchAsync
(new SearchRequest(Index) { Keyword = keyword, Fields = ["title", "content"], Filters = [ new SearchFilter("tag", SearchFilterOperator.In, values: ["dotnet", "vue"]), new SearchFilter("views", SearchFilterOperator.GreaterThanOrEqual, 100), ], Sorts = [SearchSort.ByScore], HighlightFields = ["title"], Skip = skip, Take = 20, }); } } ``` 结果里 `Hits` 是当前页,`TotalCount` 是不受分页影响的命中总数;每个 `SearchHit` 带 `Id`、`Document`、`Score` 和 `Highlights`(键为字段名)。 ::: danger 字段名用序列化后的 JSON 名,不是 C# 属性名 两个实现都用 `JsonSerializerDefaults.Web` 序列化文档,属性名默认转成**小驼峰**。索引定义的字段名、`Filters` 的 `Field`、`Sorts` 的 `Field`、`Fields`、`HighlightFields` 全都要写 JSON 里的那个名字(或 `JsonPropertyName` 指定的名字)。 写成 C# 的 `PublishTime` 而不是 `publishTime`,过滤会**默默匹配不到任何文档**,不报错。 ::: 字段名支持点分隔的嵌套路径,如 `author.name`。 ## 关键机制 ### 索引必须先建 两个实现都拒绝往不存在的索引写数据,抛 `InvalidOperationException`。 Elasticsearch 实现里这是**刻意拦截**:集群默认允许写入时自动建索引,但自动建索引走动态映射,本该是 `keyword` 的字段会被推断成 `text`,其上的精确过滤随即失效**且不报错**。实现先 `HEAD` 校验索引存在,校验结果按索引名缓存,每个索引只在首次写入时多一个 `HEAD` 请求。 所以应用启动或首次使用前,请显式调一次 `CreateIndexAsync`——它是幂等的,重复调用只返回 `false`。 ### 写入可见性 契约规定:写入对检索可见的延迟由实现决定,需要立即可见时调 `RefreshAsync(index)`。 - 进程内实现写入即可见,`RefreshAsync` 是空操作。 - Elasticsearch 有刷新间隔,写完立刻查可能查不到;`RefreshAsync` 会打到 `_refresh`。 ::: warning 不要在高频写入路径上每条都刷 刷新代价很高。只在「写完必须马上能查到」的场景(例如导入后立刻跳转结果页、集成测试的断言前)调一次。 ::: ### 排序与相关度 `Sorts` 为空时按相关度降序。要显式按相关度排就用 `SearchSort.ByScore`(字段名常量是 `SearchSort.ScoreField`,即 `_score`)。 `Score` 只在按相关度检索时有意义——没有关键字时,进程内实现给所有文档 0 分。 ### 批量写入的失败判定 Elasticsearch 的批量接口整体返回 200 的同时可能有个别条目失败。实现会解析响应体的 `errors` 标志,有失败条目就抛异常并带上服务端返回内容——不会把部分失败当成全部成功。 ### 索引名前缀 `ElasticsearchOptions.IndexPrefix` 由实现内部统一拼接。业务代码传的始终是**不带前缀的逻辑名**,多环境或多租户共用一个集群时靠它隔离。 ## 切到 Elasticsearch ```bash dotnet add package XiHan.Framework.SearchEngines.Elasticsearch ``` ::: danger 本包没有模块类,也没有 AddXxx 扩展方法 框架的约定注册只扫描**模块所在的程序集**。这个包不含模块类,它的程序集不会被扫描,`ElasticsearchSearchEngine` 上的 `ISingletonDependency` 不会自动生效——必须在你自己的模块里手写注册。 而且必须用 `Replace`:`XiHanSearchEnginesModule` 已经注册过 `ISearchEngine`,此时再 `TryAdd` 会被**静默忽略**,表现为「配置全填了,跑起来还是进程内实现」。 ::: ```csharp using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using XiHan.Framework.Core.Extensions.DependencyInjection; using XiHan.Framework.Core.Modularity; using XiHan.Framework.SearchEngines; using XiHan.Framework.SearchEngines.Abstractions; using XiHan.Framework.SearchEngines.Elasticsearch; using XiHan.Framework.SearchEngines.Elasticsearch.Options; [DependsOn(typeof(XiHanSearchEnginesModule))] public class MySearchModule : XiHanModule { public override void ConfigureServices(ServiceConfigurationContext context) { var services = context.Services; services.Configure( services.GetConfiguration().GetSection(ElasticsearchOptions.SectionName)); services.TryAddSingleton(); services.Replace(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); } } ``` `[DependsOn(typeof(XiHanSearchEnginesModule))]` 是必需的:它保证你的模块在依赖图里排在被覆盖者之后,`Replace` 才能盖住那份兜底注册。 ### 实现取舍 连接、认证与重试交给官方客户端的传输层,**请求体与响应解析由实现以 JSON 直接构造**,不使用客户端的强类型查询 DSL——那套 DSL 在大版本间变动频繁,焊进实现会让本包的可用性跟着客户端版本走。 ## 两个实现的已知差异 同一份业务代码在两个实现上行为并不完全一致。写代码时按下表取交集,才能真正做到「切换实现不改代码」。 | 主题 | 进程内实现 | Elasticsearch 实现 | | --- | --- | --- | | 关键字匹配 | 大小写不敏感的子串包含,命中字段数即得分 | `multi_match`,走引擎的分词与相关度模型 | | `Fields` 为空时 | 取索引定义中 `Searchable: true` 的字段 | 不带 `fields`,匹配全部字段 | | 可排序字段 | 任意字段,字段缺失按空串参与排序 | `Text` 字段默认**不可排序**,需排序请定义为 `Keyword` | | 写入可见性 | 立即可见,`RefreshAsync` 为空操作 | 有刷新间隔,需要立即可见须显式刷新 | | 高亮 | 把整段字段文本里的关键字包上 ``,返回整段 | 返回引擎生成的高亮**片段** | | 读/删不存在的索引 | `GetAsync` / `DeleteAsync` / `SearchAsync` 均抛 `InvalidOperationException` | `GetAsync` 返回 `null`、`DeleteAsync` 返回 `false`、`SearchAsync` 抛异常 | | 索引名大小写 | 不区分 | 由集群规则决定,别依赖大小写差异 | | 持久性 | 进程内,重启即丢,多实例不共享 | 集群持久化 | ::: tip 用进程内实现写测试的正确姿势 断言「查得到 / 过滤对不对 / 分页对不对」是可靠的;断言「排在第几位」不可靠——相关度模型不同。 ::: ## 配置 只有 Elasticsearch 实现有配置节:`XiHan:SearchEngines:Elasticsearch`(常量 `ElasticsearchOptions.SectionName`)。进程内实现无任何配置项。 ```json { "XiHan": { "SearchEngines": { "Elasticsearch": { "Uri": "http://127.0.0.1:9200", "ApiKey": "从环境变量或密钥库注入", "IndexPrefix": "prod-", "RequestTimeoutSeconds": 30, "NumberOfShards": 3, "NumberOfReplicas": 1 } } } } ``` 最常调的几项: | 键 | 默认值 | 说明 | | --- | --- | --- | | `Uri` | `http://localhost:9200` | 节点地址 | | `ApiKey` | 空 | **优先于**用户名密码;生产推荐 | | `UserName` / `Password` | 空 | 基础认证 | | `IndexPrefix` | 空 | 索引名前缀,多环境/多租户隔离用 | | `AllowUntrustedCertificate` | `false` | 跳过服务端证书校验,**仅限本地自签名证书的开发环境** | 全部配置项见 [Elasticsearch 包](../packages/search-engines-elasticsearch)。 ::: warning 凭据不要写进 appsettings.json `ApiKey` / `Password` 从环境变量或密钥库注入。`AllowUntrustedCertificate` 在生产打开等同于放弃传输层身份校验。 ::: ## 常见问题 | 现象 | 原因 | | --- | --- | | 配置全填了,跑起来还是进程内实现 | 用 `TryAdd` 注册 Elasticsearch 实现,被静默忽略;必须 `Replace` | | 引用了 Elasticsearch 包但注入不到它的实现 | 该包无模块类,程序集不参与约定注册扫描,必须自己 `TryAddSingleton` | | 写入报「索引 'xxx' 不存在,请先创建索引」 | 没先调 `CreateIndexAsync`,两个实现都不允许隐式建索引 | | 过滤条件匹配不到任何文档 | 字段名写成了 C# 属性名;应为序列化后的小驼峰名 | | 排序在 Elasticsearch 上报错 | 对 `Text` 字段排序;改成 `SearchFieldType.Keyword` | | 刚写完立刻查不到 | Elasticsearch 刷新间隔;需要立即可见时调 `RefreshAsync` | | 改了字段类型但映射没变 | `CreateIndexAsync` 对已存在的索引直接返回 `false`,不会改映射;需先 `DeleteIndexAsync` 再重建并回灌 | | 重启后索引空了 | 还在用进程内实现,数据本就不持久化 | | 需要「A 命中 或 B 命中」 | 契约不支持跨字段「或」,拆成多次查询自行合并 | | 构造 `SearchFilter` 抛 `ArgumentException` | `In` 必须给非空 `values`;除 `In`/`Exists` 外必须给 `value` | | 构造 `SearchRequest` 抛 `ArgumentOutOfRangeException` | `Take` 必须大于 0(默认 20),`Skip` 不能为负 | ## 下一步 - [依赖注入](./dependency-injection):`Replace` 与 `TryAdd` 的注册语义 - [配置与选项](./configuration):选项绑定与配置节约定 - [模块系统](./modularity):`DependsOn` 如何决定注册顺序 - [SearchEngines.Abstractions 包](../packages/search-engines-abstractions):契约的完整类型与成员 - [SearchEngines 包](../packages/search-engines):进程内实现细节 - [SearchEngines.Elasticsearch 包](../packages/search-engines-elasticsearch):全部配置项表 --- 来源:https://framework.docs.xihanfun.com/guide/security # 数据加解密 密码怎么存、第三方凭证怎么落库还能还原、报文怎么防篡改——这三件事在框架里分属三套机制。选错了要么不安全,要么解不回来。 完整 API 清单见 [Security 包](../packages/security) 与 [Utils 包](../packages/utils)。 ## 先分清三类需求 | 需求 | 特征 | 用什么 | | --- | --- | --- | | 用户密码 | 只需比对,**永远不需要还原** | `IPasswordHasher`(PBKDF2 单向哈希) | | 第三方凭证(AK/SK、连接串、机器人 Token) | 调用时**必须拿到明文** | ASP.NET Core `IDataProtector` 可逆加密 | | 报文完整性 | 不隐藏内容,只防篡改与重放 | HMAC / RSA / SM2 签名 | ::: danger 别把密码做成可逆加密 可逆加密意味着拿到密钥就能批量还原所有用户密码。密码一律走 `IPasswordHasher`;反过来,AK/SK 这类调用时要用的密钥不能哈希,否则永远拿不回来。 ::: ## 能力分布 | 能力 | 类型 | 所在包 | | --- | --- | --- | | 密码哈希 / 密码策略 / 历史复用 | `IPasswordHasher`、`IPasswordPolicyService`、`IPasswordHistoryStore` | `XiHan.Framework.Security` | | 国密 SM2 签名、SM4 对称加密 | `Sm2Helper`、`Sm4Helper` | `XiHan.Framework.Security` | | Blowfish 对称加密 | `BlowfishHelper` | `XiHan.Framework.Security` | | AES / DES 对称加密 | `AesHelper`、`DesHelper` | `XiHan.Framework.Utils` | | RSA / DSA / ECDSA / ECIES | `RsaHelper`、`DsaHelper`、`EcdsaHelper`、`EciesHelper` | `XiHan.Framework.Utils` | | 摘要与消息认证码 | `HashHelper`、`HmacHelper` | `XiHan.Framework.Utils` | | 开放接口验签与报文加解密 | `XiHanOpenApiSecurityMiddleware` | `XiHan.Framework.Web.Api` | ::: tip 加密辅助类都是静态类 `AesHelper` / `Sm4Helper` / `RsaHelper` 这些不走 DI,引用包后直接静态调用,密钥从哪来、放哪里由调用方负责。只有密码相关的三个服务是注入的。 ::: ## 安装与启用 ```bash dotnet add package XiHan.Framework.Security ``` ```csharp [DependsOn(typeof(XiHanSecurityModule))] public class MyModule : XiHanModule { } ``` 模块的 `ConfigureServices` 调用 `AddXiHanSecurityServices(configuration)`,登记内容如下: | 登记项 | 生命周期 | 说明 | | --- | --- | --- | | `PasswordHasherOptions` | — | 绑定配置节 `XiHan:Authentication:PasswordHasher` | | `PasswordPolicyOptions` | — | 绑定配置节 `XiHan:Authentication:PasswordPolicy` | | `IPasswordHasher` → `PasswordHasher` | `TryAddSingleton` | PBKDF2 哈希器 | | `IPasswordPolicyService` → `PasswordPolicyService` | `TryAddScoped` | 强度校验 + 历史复用 | | `IPasswordHistoryStore` → `DefaultPasswordHistoryStore` | `TryAddScoped` | 进程内存实现 | 全部用 `TryAdd*`,所以应用层只要**先于**本模块注册自己的实现就能覆盖。 ## 密码存储 ### 哈希与校验 ```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); } public bool Login(string storedHash, string input) { return hasher.VerifyPassword(storedHash, input); } } ``` ### 哈希串是自描述的 `HashPassword` 的返回值是五段冒号分隔的字符串: ```text version : iterations : hashAlgorithm : salt(Base64) : hash(Base64) 1:600000:SHA256:qw3f…:8Kd2… ``` 校验时从串里**读回当时的参数**重算,而不是用当前配置——所以调整参数不会让存量密码失效。比对用 `CryptographicOperations.FixedTimeEquals`,恒定时间,避免时序侧信道。 默认参数:PBKDF2-SHA256、600000 次迭代、32 字节随机盐、32 字节输出。 ### 参数升级靠 NeedsRehash ```csharp if (hasher.VerifyPassword(user.PasswordHash, input)) { if (hasher.NeedsRehash(user.PasswordHash)) { user.PasswordHash = hasher.HashPassword(input); // 用新参数重算并落库 } } ``` 登录成功那一刻是唯一能拿到明文的时机,重哈希只能在这里做。 ::: warning NeedsRehash 只比较三项 它比对的是 `Version`、`Iterations`、`HashAlgorithm`。只改 `SaltSize` 或 `HashSize` **不会**触发重哈希——要让存量密码跟着走,必须同时把 `Version` 加一。 另外 `HashAlgorithm` 只识别 `MD5` / `SHA1` / `SHA256` / `SHA384` / `SHA512`,配了别的值会静默回落到 SHA256。 ::: ### 密码策略 `IPasswordPolicyService.Validate` 依次检查:长度上下限、大小写/数字/特殊字符、30 条内置弱密码黑名单、`CustomBlacklist` 子串命中、连续 3 个相同字符、连续 3 位字母或数字序列(`abc`、`321` 都算)。 评分构成(`PasswordValidationResult.Score`): | 项 | 分值 | | --- | --- | | 超出最小长度的部分 | 每字符 1 分,最多 10 | | 含大写 / 小写 / 数字 | 各 10 | | 含特殊字符 | 15 | | 命中内置黑名单 | −30 | | 命中自定义黑名单 | −20 | | 重复字符 / 连续序列 | 各 −10 | 分值下限为 0,实际能拿到的上限是 55——它是相对强度参考,不要拿去当百分比展示。 ::: warning 历史复用检查传的是明文 ```csharp await policy.IsPasswordReusedAsync(newPassword, userId, historyCount, ct); ``` 第一个参数是**新密码明文**。历史里存的是加盐哈希,同一明文每次哈希结果都不同,只能用 `VerifyPassword(历史哈希, 明文)` 逐条比对,不能直接比字符串。 ::: ::: danger 默认历史存储是内存的,而且不会自动写入 `DefaultPasswordHistoryStore` 用静态 `ConcurrentDictionary` 存在进程里,重启即失、多实例不共享。 更要注意:`IPasswordHistoryStore` 接口**只有读方法** `GetRecentPasswordHashesAsync`。写入靠 `DefaultPasswordHistoryStore.RecordPassword(userId, passwordHash, maxHistoryCount)` 这个静态方法,框架不会在改密成功后自动调用它。不接管的话历史队列恒空,`IsPasswordReusedAsync` 永远返回 `false`。 生产做法:实现 `IPasswordHistoryStore` 读数据库最近 N 条哈希,并在改密流程里自己写一条历史。 ::: ## 对称加密 | 辅助类 | 密钥要求 | 模式 | 密文形态 | | --- | --- | --- | --- | | `Sm4Helper` | 恰好 16 字节 UTF-8 | CBC + PKCS7 | `IV(16) + 密文`,整体 Base64 | | `BlowfishHelper` | ≤ 56 字节 UTF-8 | CBC + PKCS7 | `IV(8) + 密文`,整体 Base64 | | `AesHelper` | 见下方说明 | CBC | 纯密文 Base64 | | `DesHelper` | 8 字节 | CBC | 纯密文 Base64 | `Sm4Helper` 与 `BlowfishHelper` 每次加密都生成随机 IV 并前置拼在密文头部,解密时自动读回——相同明文加相同密钥不会产生相同密文,直接用即可: ```csharp var cipher = Sm4Helper.Encrypt("需要保护的内容", "0123456789abcdef"); // key 必须 16 字节 var plain = Sm4Helper.Decrypt(cipher, "0123456789abcdef"); ``` ::: warning AesHelper 的单口令重载是确定性的 `AesHelper.Encrypt(plainText, password)` 用**全零盐**从口令 PBKDF2 派生 Key 与 IV,因此相同明文 + 相同口令永远得到相同密文,能被比对和字典攻击。 要随机 IV 就用三参重载 `Encrypt(plainText, key, iv)` 自己管理:`key` 必须是 16/24/32 字节的 UTF-8 字符串,`iv` 必须是 16 字节,每条数据换一个 IV 并随密文一起存。 ::: ::: danger DES 只用于对接遗留系统 `DesHelper.Encrypt(plainText)` / `Decrypt(encryptedText)` 的单参重载用的是**写死在代码里的密钥和 IV**,等同于没有加密。DES 本身 56 位有效密钥也早已不具备强度,新数据一律不要用。 ::: ## 非对称加密与签名 ### RSA ```csharp // 签名:默认 SHA256 + PKCS#1 v1.5 var signature = RsaHelper.SignData(payload, privateKey); var ok = RsaHelper.VerifyData(payload, signature, publicKey); // 加密大数据:RSA 包 AES 密钥的混合模式 var cipher = RsaHelper.EncryptWithAes(bigText, publicKey); var plain = RsaHelper.DecryptWithAes(cipher, privateKey); ``` | 方法族 | 默认值 | 适用 | | --- | --- | --- | | `Encrypt` / `Decrypt` | 填充 `OaepSHA256`,自动分段 | 小数据;密文体积膨胀明显 | | `EncryptWithAes` / `DecryptWithAes` | AES-256-CBC 数据 + RSA-OAEP-SHA256 包密钥 | 大文本、文件、高频加解密 | | `SignData` / `VerifyData` | `SHA256` + `RSASignaturePadding.Pkcs1` | 报文签名 | 密钥长度低于 2048 位或不是 8 的倍数,`GenerateKeys` 直接抛 `ArgumentException`。混合密文的结构是 `[版本 1 字节][RSA(AESKey) 长度 2 字节][RSA(AESKey)][RSA(AESIV) 长度 2 字节][RSA(AESIV)][AES(Data)]`,版本号不为 1 会拒绝解密。 ::: danger 公钥必须是 SubjectPublicKeyInfo 格式 `RsaHelper` 内部用 `ImportSubjectPublicKeyInfo` 导入公钥(即 X.509 公钥、PEM 里的 `-----BEGIN PUBLIC KEY-----`),而 `GenerateKeys()` / `ExportPublicKeyFromPrivateKey()` 导出的是 **PKCS#1** 编码——两者不通用,把生成的公钥直接喂给 `Encrypt` / `VerifyData` 会解析失败。 私钥没有这个问题:导入时先试 PKCS#8、失败再退回 PKCS#1,两种都能吃。 要一对能直接用的密钥,自己导出 SPKI: ```csharp using var rsa = RSA.Create(2048); var publicKey = Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()); var privateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()); ``` ::: ### 国密 SM2 / SM4 `Sm2Helper` 基于 BouncyCastle,曲线 `sm2p256v1`,签名算法 `SM3WITHSM2`(SM3 摘要在签名器内部完成,不单独暴露 SM3 辅助类): ```csharp var ok = Sm2Helper.VerifyData(canonicalString, signatureBase64, publicKeyBase64); ``` ::: danger SM2 的密钥格式同样要对齐 `SignData` 把私钥 Base64 解码后**直接当作标量 d**,`VerifyData` 走 `curve.DecodePoint(...)` 把公钥当作**曲线点编码**(未压缩点以 `0x04` 开头)。而 `GenerateKeys()` 导出的是 PKCS#8 / SubjectPublicKeyInfo 的 DER——格式不同,不能直接互喂。 对接国密时以对方给的原始 d 值与曲线点为准,别用 `GenerateKeys()` 的输出去签、再拿它的公钥去验。 ::: 国密全链路的分工:SM2 负责签名验签,SM4 负责对称加密,SM3 摘要由 `SM3WITHSM2` 内部承担。 ### 其他非对称算法 `DsaHelper` / `EcdsaHelper` 提供 `GenerateKeys` / `SignData` / `VerifyData`,`EciesHelper` 提供基于椭圆曲线的 `Encrypt` / `Decrypt`。签名场景优先 ECDSA 或 SM2,DSA 仅用于兼容既有系统。 ## 哈希与消息认证码 ```csharp HashHelper.Sha256("content"); // 大写十六进制 HashHelper.StreamMd5(stream); // 流/文件校验,SHA256 版本是 StreamHash HmacHelper.HmacSha256(key, message); // Base64 ``` `HashHelper` 的所有方法返回 `Convert.ToHexString` 的**大写**十六进制串,比对时注意大小写;`HmacHelper` 的字符串重载返回 Base64,字节重载 `ComputeHmacBytes(algorithm, key, data)` 返回原始字节,算法名传 `"HMACSHA1"` / `"HMACSHA256"` / `"HMACSHA512"` 这类全大写字符串。 ::: warning 哈希不是加密 `HashHelper` 只保证完整性。用于口令派生请走 `IPasswordHasher`;用于防篡改请加密钥,即用 `HmacHelper` 而不是裸哈希——裸 SHA256 拼一个共享密钥的写法能被长度扩展攻击。 ::: ## 凭证可逆落库:DataProtection 框架不封装这一层,直接用 ASP.NET Core 的 `IDataProtectionProvider`。约定的做法是每类密文一个专用保护器: ```csharp public static class SecretProtectionPurposes { public const string CipherPrefix = "dp:"; public const string StorageSecretAccessKey = "MyApp.StorageConfig.SecretAccessKey.v1"; } public sealed class StorageSecretProtector { private readonly IDataProtector _protector; public StorageSecretProtector(IDataProtectionProvider provider) { _protector = provider.CreateProtector(SecretProtectionPurposes.StorageSecretAccessKey); } public string? Protect(string? plaintext) { if (string.IsNullOrEmpty(plaintext)) { return plaintext; } // 已带前缀说明是密文,不重复加密(幂等) if (plaintext.StartsWith(SecretProtectionPurposes.CipherPrefix, StringComparison.Ordinal)) { return plaintext; } return SecretProtectionPurposes.CipherPrefix + _protector.Protect(plaintext); } public string? Unprotect(string? value) { return string.IsNullOrEmpty(value) ? value : _protector.Unprotect(value[SecretProtectionPurposes.CipherPrefix.Length..]); } } ``` 四条要点: | 要点 | 原因 | | --- | --- | | 一类密文一个 Purpose | Purpose 参与密钥派生,互相之间解不开,泄露一处不牵连其他 | | Purpose 字符串带 `.v1` 后缀 | 需要轮换时递增版本号,且**上线后不得随意改动**,改了历史密文就解不开 | | 密文加固定前缀 | 让 `Protect` 幂等,避免二次加密把值套两层 | | 解密失败直接抛 | fail-closed。吞掉异常返回原值,等于把密文当明文发出去 | ::: danger 多实例必须共享密钥环 默认密钥环写在本机文件系统,多副本部署时 A 实例加密的值 B 实例解不开,表现为随机的解密失败。多实例请把密钥持久化到共享存储(`PersistKeysToFileSystem` 指向共享卷、或数据库/Redis 提供程序),并做好该密钥环自身的备份——丢了等于所有密文作废。 ::: 保护器本身无状态、只依赖单例 `IDataProtectionProvider`,注册为 `Singleton` 即可。 ## 加密的设置项 `SettingManager` 对 `IsEncrypted = true` 的设置项自动加解密,密钥来自配置节 `XiHan:Settings:Aes` 的 `Key`(任意长度字符串,AES 的 Key 与 IV 由它经 PBKDF2 派生)。 ```json { "XiHan": { "Settings": { "Aes": { "Key": "从环境变量或密钥管理服务注入" } } } } ``` ::: warning 没配 Key 会直接抛异常 读写加密设置项时若 `Key` 为空,`SettingManager` 抛 `XiHanException` 而不是退回内置占位密钥。这是有意的 fail-closed:宁可启动失败,也不要用一个人人都知道的默认密钥把数据"加密"了。 ::: 详见 [Settings 包](../packages/settings)。 ## 开放接口验签 `XiHanOpenApiSecurityMiddleware` 提供请求签名、内容签名、防重放与报文加解密,配置节 `XiHan:Web:Api:OpenApiSecurity`,默认关闭。 请求头: | 请求头 | 含义 | | --- | --- | | `X-Access-Key` | 客户端标识 | | `X-Timestamp` / `X-Nonce` | Unix 秒 / 随机串,用于防重放 | | `X-Signature` | 请求签名(十六进制或 Base64 均接受) | | `X-Content-Sign` | 请求体摘要(小写十六进制) | | `X-Sign-Algorithm` / `X-Content-Sign-Algorithm` / `X-Encrypt-Algorithm` | 三个算法选择头,缺省时回落到客户端配置、再回落到全局默认 | | `X-Encrypt-Iv` / `X-Encrypt-Response` | 请求体加密 IV / 要求响应加密 | 待签名串由六行拼成(`\n` 连接),查询串按键、值分别做 `Ordinal` 排序后 `key=value` 用 `&` 连接、键值各自 URL 编码: ```text HTTP方法(大写) 路径 规范化查询串 内容签名 时间戳 随机串 ``` 支持的算法: | 类别 | 支持 | 开关放开后额外支持 | | --- | --- | --- | | 请求签名 | `HMACSHA256`、`HMACSHA512`、`RSASHA256`、`SM2` | `HMACSHA1`(`AllowLegacySignatureAlgorithms`) | | 内容签名 | `SHA256`、`SHA512` | `MD5`(`AllowLegacyContentSignatureAlgorithms`) | | 报文加密 | `NONE`、`AES`、`AES-CBC` | `BLOWFISH`(`AllowLegacyEncryptionAlgorithms`) | ::: warning 不加密报文时必须显式发 X-Encrypt-Algorithm: NONE `DefaultEncryptionAlgorithm` 是 `AES-CBC`。客户端不带这个头时会回落到默认值,中间件会拿明文 JSON 去走解密流程,返回 400「请求体解密失败」。 另外内容签名是对**解密后的明文体**计算的,加密场景下签名基准是明文而不是密文。 ::: 完整配置项见 [Web.Api 包](../packages/web-api)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 改了 `Iterations` 后老用户登不上 | 不会发生。哈希串自带参数,校验用串里的值;只有哈希串被截断/改写才失败 | | 调大 `SaltSize` 后 `NeedsRehash` 一直是 `false` | 它只比较 `Version` / `Iterations` / `HashAlgorithm`,改盐长需同时提升 `Version` | | 密码历史复用检查永远通过 | 默认存储是只读内存实现,改密后没人调 `RecordPassword`;生产要自实现 `IPasswordHistoryStore` | | 相同明文每次加密结果一样 | 用了 `AesHelper.Encrypt(text, password)`(全零盐派生 Key/IV);换三参重载并每条数据用新 IV | | RSA 加密/验签报密钥解析错误 | 公钥不是 SubjectPublicKeyInfo 编码;用 `ExportSubjectPublicKeyInfo()` 重新导出 | | SM2 验签恒为 false | 公钥不是曲线点编码,或私钥不是原始标量;`GenerateKeys()` 的输出格式与签名/验签期望的格式不同 | | 换台机器部署后凭证全解不开 | DataProtection 密钥环没共享,各实例用各自的本机密钥 | | 启动读设置项抛 `XiHanException` | 有 `IsEncrypted` 的设置项但没配 `XiHan:Settings:Aes:Key` | | 开放接口返回 400「请求体解密失败」 | 没发 `X-Encrypt-Algorithm: NONE`,回落到默认的 `AES-CBC` | | 开放接口返回 409「检测到重复请求」 | `X-Nonce` 重复,或客户端重试时复用了同一个 nonce | ## 下一步 - [认证与授权](./authentication):登录、令牌与权限判定 - [配置与选项](./configuration):密钥从环境变量与配置提供程序注入的方式 - [Security 包](../packages/security):完整 API 与全部配置项 - [Utils 包](../packages/utils):AES / RSA / 哈希等辅助类清单 - [Web.Api 包](../packages/web-api):开放接口安全中间件的完整配置 --- 来源:https://framework.docs.xihanfun.com/guide/storage # 对象存储与虚拟文件 业务要存文件,但不该被绑死在某一家云厂商上;程序自己也要读文件(模板、配置、内置资源),但这些文件既可能在磁盘上也可能编译进了程序集。框架把这两件事拆成两个互不相干的包:`ObjectStorage` 管**业务文件的读写与生命周期**,`VirtualFileSystem` 管**程序自身资源的只读访问**。 ## 先分清用哪个 | | XiHan.Framework.ObjectStorage | XiHan.Framework.VirtualFileSystem | | --- | --- | --- | | 面向 | 用户上传的业务文件 | 程序自带的资源 | | 典型对象 | 头像、附件、导出文件 | 邮件模板、代码生成模板、内置 JSON | | 后端 | 本地磁盘 / MinIO / 阿里云 OSS / 腾讯云 COS | 物理目录 + 程序集嵌入资源 | | 读写 | 读写、删除、复制、移动 | **只读** | | 核心入口 | `IFileStorageRouter`、`IFileStorageProviderManager` | `IVirtualFileSystem` | ::: tip 判断标准 文件是不是运行期由用户产生的?是就用 ObjectStorage,不是就用 VirtualFileSystem。 ::: --- ## 对象存储 ### 安装与启用 ```bash dotnet add package XiHan.Framework.ObjectStorage ``` ```csharp [DependsOn(typeof(XiHanObjectStorageModule))] public class MyModule : XiHanModule { } ``` `XiHanObjectStorageModule` 在 `ConfigureServices` 里调 `services.AddXiHanObjectStorage(config)`,完成三件事:绑定 `XiHan:ObjectStorage` 及其四个子节;`TryAddSingleton` 注册 `IFileStorageProviderManager` 与 `IFileStorageRouter`;按 `EnabledProviders` + `DefaultProvider` 自动注册被启用的后端。两者都为空时回退到 `Local`。 ### 上传一个文件 ```csharp public class AvatarAppService { private readonly IFileStorageRouter _router; public AvatarAppService(IFileStorageRouter router) => _router = router; public async Task UploadAsync(Stream stream, string fileName) { // 按业务路由键选后端,未命中映射时回退 DefaultProvider var provider = _router.Route(routeKey: "avatar"); var result = await provider.UploadAsync(new FileUploadRequest { FileStream = stream, FileName = fileName, StoragePath = $"avatars/{Guid.NewGuid():N}/{fileName}", ContentType = "image/png", Overwrite = true }); // 必须检查 Success —— 失败不会抛异常 return result.Success ? result.Url : null; } } ``` ::: danger 上传失败不抛异常 `FileStorageProviderBase.UploadAsync` 把子类 `UploadCoreAsync` 的异常统一收敛成 `FileUploadResult { Success = false, ErrorMessage = ... }`,顺带记下 `DurationMs`。 只写 `try/catch` 而不判 `result.Success`,失败会被当成成功继续往下走。**其余方法(下载、删除、元数据)则是正常抛异常的**,只有上传这一条路径特殊。 ::: ### 后端怎么选 四个后端实现同一个 `IFileStorageProvider`,但能力不齐: | 提供程序 | `ProviderName` | 分片上传 | 预签名 URL | | --- | --- | --- | --- | | `LocalFileStorageProvider` | `Local` | 支持(临时目录合并) | 返回静态直链,`expiresIn` 被忽略 | | `AliyunOssStorageProvider` | `AliyunOSS` | 支持 | 真实签名 | | `TencentCosStorageProvider` | `TencentCOS` | 声明支持,分片方法有坑(见下) | 真实签名 | | `MinioFileStorageProvider` | `MinIO` | **不支持** | 真实签名 | ::: warning MinIO 的分片方法是占位实现 `MinioFileStorageProvider.SupportChunkedUpload` 为 `false`,但它**覆盖了**四个分片方法而不是让基类抛 `NotSupportedException`:`InitiateChunkedUploadAsync` 返回一个随便生成的 GUID,`UploadChunkAsync` 直接返回 `Success = true` 和形如 `chunk-1` 的假 ETag(**分片数据被丢弃**),`CompleteChunkedUploadAsync` 才返回 `Success = false`。 也就是说前面每一步都"成功",最后一步才失败。走 MinIO 上传大文件请直接用 `UploadAsync`,由 SDK 自行处理分片。 **调分片方法前一律先判 `provider.SupportChunkedUpload`。** ::: ::: warning 腾讯云 COS 的 UploadChunkAsync 不写分片数据 `TencentCosStorageProvider.SupportChunkedUpload` 为 `true`,`InitiateChunkedUploadAsync` / `CompleteChunkedUploadAsync` 走的也是真实的 `InitMultipartUpload` / `CompleteMultiUpload`,但 `UploadChunkAsync` 调的是 `PutObject(bucket, key, request.StoragePath)`——既没用 `UploadPart`,也没读 `ChunkData`,而是把 `StoragePath` 当成本地文件路径整份上传。 走 COS 上传大文件同样请用 `UploadAsync`。 ::: ### 分片上传流程 支持分片的后端按四步走,分片序号**从 1 开始**: ```csharp var provider = _router.Route(routeKey: "attachment"); if (!provider.SupportChunkedUpload) { // 退回整体上传 } var uploadId = await provider.InitiateChunkedUploadAsync(new ChunkedUploadInitRequest { FileName = fileName, StoragePath = storagePath, TotalSize = totalSize, ChunkSize = 5 * 1024 * 1024 // 默认即 5MB }); var chunkInfos = new List(); foreach (var (chunkStream, index) in chunks) // index 从 1 开始 { var chunk = await provider.UploadChunkAsync(new ChunkUploadRequest { UploadId = uploadId, StoragePath = storagePath, ChunkNumber = index, ChunkData = chunkStream, ChunkSize = chunkStream.Length, TotalSize = totalSize, TotalChunks = totalChunks }); if (!chunk.Success) { await provider.AbortChunkedUploadAsync(uploadId); throw new UserFriendlyException(chunk.ErrorMessage ?? "分片上传失败"); } chunkInfos.Add(new ChunkInfo { ChunkNumber = chunk.ChunkNumber, ETag = chunk.ETag }); } var result = await provider.CompleteChunkedUploadAsync(new ChunkedUploadCompleteRequest { UploadId = uploadId, StoragePath = storagePath, ChunkInfos = chunkInfos }); ``` 失败路径务必调 `AbortChunkedUploadAsync` 收尾。本地存储的分片落在系统临时目录 `chunked-uploads/{uploadId}` 下,`Complete` 或 `Abort` 都会清掉它;两个都不调时,会话在最后一次活动 24 小时后、于下一次 `InitiateChunkedUploadAsync` 时被回收并删除临时目录(惰性回收,没有新上传就不触发;进程重启前的残留目录需自行清理)。 ::: warning 分片会话在进程内存里 Local / OSS / COS 的上传会话都存在进程内存中,24 小时无活动即被回收。进程重启、会话已被回收,或多实例部署时分片请求被打到另一个实例,都会报会话不存在(Local 为 `Upload session not found`,OSS / COS 为「上传会话不存在」)。多实例下的分片上传需要会话粘滞。 ::: ### 后端是怎么被选中的 `IFileStorageRouter.ResolveProviderName` 是一条三级瀑布: | 优先级 | 来源 | 行为 | | --- | --- | --- | | 1 | 显式传入的 `providerName` | 非空即用,不再往下 | | 2 | `routeKey` 查 `RouteProviderMappings` | 大小写不敏感,命中即用 | | 3 | `DefaultProvider` | 兜底 | 第 2 步未命中时,`StrictRouteMatch = true` 会抛 `InvalidOperationException`,默认的 `false` 则静默回退到 `DefaultProvider`。 取到名字后交给 `IFileStorageProviderManager.GetProvider`:查 `XiHanObjectStorageProviderOptions.ProviderTypes` 拿类型,没登记就抛 `InvalidOperationException`;实例从容器解析出来后缓存进字典,后续复用。要静默判断用 `TryGetProvider`。 这套设计的意义是:**本地开发写磁盘、生产切云端,只改配置不改代码**。 ```json { "XiHan": { "ObjectStorage": { "DefaultProvider": "Local", "EnabledProviders": [ "Local", "AliyunOSS" ], "RouteProviderMappings": { "avatar": "Local", "attachment": "AliyunOSS" } } } } ``` ::: danger 配置节名 ≠ 提供程序名 配置**节**是 `Local` / `Minio` / `AliyunOss` / `TencentCos`(各 Options 的 `SectionName`);提供程序**名**是 `Local` / `MinIO` / `AliyunOSS` / `TencentCOS`(`ObjectStorageProviderNames` 常量,用于 `EnabledProviders`、`DefaultProvider` 和映射值)。 拼写不同,别互抄。`EnabledProviders` 里写了识别不了的名字,启动期直接抛 `InvalidOperationException`。 ::: ### 路径与存储桶 `StoragePath` 一律是**对象键**,不含桶名。桶由 `BucketName` 决定,为空则用该后端配置里的 `DefaultBucket`: - `UploadAsync` 读 `FileUploadRequest.BucketName`; - `DeleteAsync` / `ExistsAsync` / `GetMetadataAsync` 各有一个带 `bucketName` 参数的重载;基类的默认实现忽略该参数、转调单参版本,只有真正实现了它的后端才生效。 腾讯云 COS 的桶名会自动拼成 `{DefaultBucket}-{AppId}`,配置里填不带 AppId 的部分。阿里云 OSS 与腾讯云 COS 配了 `CdnDomain` 时,返回的 URL 走 `https://{CdnDomain}/{objectKey}`,不再走桶域名。 --- ## 本地存储:RootPath、UrlPrefix 与静态文件挂载 本地存储是默认后端,也是最容易配错的一个,因为它牵扯两个各自独立读配置的组件。 ### 两个字段 `LocalStorageOptions`(配置节 `XiHan:ObjectStorage:Local`)只有两个字段: | 字段 | 默认值 | 作用 | | --- | --- | --- | | `RootPath` | `wwwroot/Uploads` | 文件真正落盘的目录 | | `UrlPrefix` | `/uploads` | 拼 `FileUploadResult.Url` 用的路径前缀 | `LocalFileStorageProvider` 构造时把 `RootPath` 过一遍 `Path.GetFullPath`,目录不存在就创建;`UrlPrefix` 归一化成 `"/" + 去掉首尾斜杠`。 于是 `StoragePath = "avatars/a.png"` 落盘在 `{RootPath}/avatars/a.png`,`Url` 是 `/uploads/avatars/a.png`。 ### 静态文件是自动挂上的 `XiHanWebApiModule` 在管线里替你挂好了这个目录,无需自己写 `UseStaticFiles`: ``` UseCors() → 本地对象存储静态文件服务 ← 在这里 → UseAuthentication() → UseAuthorization() ``` 它经 `IConfiguration` 直接读 `XiHan:ObjectStorage:Local:RootPath` 和 `:UrlPrefix`(这样 Web.Api 不必编译期依赖 ObjectStorage 包),然后以 `PhysicalFileProvider` + `RequestPath = UrlPrefix` 调 `UseStaticFiles`。 ::: warning 显式配置 RootPath `LocalStorageOptions` 3.10.1 的默认落盘目录是 `wwwroot/Uploads`,但 Web.Api 在完全没有该配置节时仍回退到 `wwwroot/uploads`。在 Linux 等区分大小写的文件系统上,这会成为两个目录。Web 应用应显式配置 `RootPath`,并确保对象存储与静态文件挂载读取同一个值。 ::: ::: warning 挂在鉴权之前,意味着匿名可读 这个位置是有意的——头像之类的公开资源要能匿名直链。反过来说,**落进 `RootPath` 的所有文件都是公开的**。 `FileUploadRequest.AccessControl` 对本地存储不起作用:本地提供程序压根不读这个字段(阿里云 OSS 会把它翻成 `public-read` / `private` 的 ACL,腾讯云 COS 原样交给 `SetCosACL`)。私密文件不要放本地存储的 `UrlPrefix` 目录下。 ::: ### 三个易错点 ::: danger 相对路径的基准不一致 提供程序用 `Path.GetFullPath(RootPath)` 解析——基准是**进程当前工作目录**;静态文件中间件用 `Path.Combine(ContentRootPath, RootPath)`——基准是**内容根目录**。 平时两者相同,但以 systemd、容器 `WORKDIR` 或 Windows 服务方式启动、工作目录被设成别处时,就会出现"文件明明写进去了,URL 访问却 404"。**生产环境把 `RootPath` 配成绝对路径**,两边就都不会猜。 ::: ::: warning 未知扩展名一律 404 静态文件以 `ServeUnknownFileTypes = false` 挂载,MIME 类型识别不出来的文件不会被返回。上传前统一扩展名,或者让这类文件走后端下载接口而不是静态直链。 ::: ::: warning UrlPrefix 不能配成 "/" 归一化后等于 `/` 时,挂载逻辑直接跳过,静态直链整体失效。 ::: ### 一个顺手的机制 `NormalizeLocalPath` 会把路径开头的 `UrlPrefix` 段剥掉。所以 `FileUploadResult.Url`(`/uploads/avatars/a.png`)可以原样回传给 `DeleteAsync` / `ExistsAsync`,不用先手工去前缀: ```csharp await provider.DeleteAsync(result.Url!); // 与传 "avatars/a.png" 等价 ``` 另外两点:`FileUploadRequest.Overwrite` 默认 `false`,目标已存在会返回 `Success = false` + `"File already exists"`;设置了 `ProgressCallback` 时,回调的第二个参数取自 `FileStream.Length`,所以传进来的流必须能取长度。 --- ## 虚拟文件系统 ### 解决什么问题 模板、内置配置这类程序自带的资源,有两种存在形式:磁盘上的物理文件,和编译进程序集的嵌入资源。虚拟文件系统把它们叠成一个统一的路径空间——同一个 `/Templates/mail.html`,先查磁盘,磁盘没有再取程序集内置的。 这天然形成一个覆盖机制:**框架包内置一份默认模板,宿主应用在磁盘上放同名文件就能覆盖它**,不用改代码也不用重新打包。 ### 安装与启用 ```bash dotnet add package XiHan.Framework.VirtualFileSystem ``` ```csharp [DependsOn(typeof(XiHanVirtualFileSystemModule))] public class MyModule : XiHanModule { } ``` `IVirtualFileSystem` 以单例注册,直接注入即可。 ### 默认挂载与优先级 无需任何配置,构造时就挂好了这些提供程序(**数值越大越优先**): | 优先级 | 来源 | 开关 | | --- | --- | --- | | 100 | 当前工作目录 | `IncludeCurrentDirectory`,默认 `true` | | 90 | `AdditionalPhysicalPaths` 里的每个目录 | 默认空 | | 80 | 应用基目录(`AppContext.BaseDirectory`) | `IncludeAppBaseDirectory`,默认 `true` | | 50 | `AddEmbedded` 注册的程序集 | 需显式添加 | `GetFile` 按优先级从高到低逐个问,第一个命中的就返回——所以物理目录天然压过嵌入资源。物理目录不存在时会被自动创建。 追加自己的来源有两种写法: ```csharp // 启动期:在模块里配置 services.Configure(options => { options.AddPhysical("./templates", priority: 120); options.AddEmbedded(priority: 50); }); // 运行期:动态挂载/卸载 _virtualFileSystem.Mount(new PhysicalFileProvider("/data/skins"), priority: 200); _virtualFileSystem.Unmount(provider); ``` 同一个物理目录或同一个程序集重复添加会覆盖前一次,不会挂两遍。 ### 读取与枚举 ```csharp public class MailTemplateProvider { private readonly IVirtualFileSystem _vfs; public MailTemplateProvider(IVirtualFileSystem vfs) => _vfs = vfs; public async Task ReadAsync(string name) { var file = _vfs.GetFile($"~/Templates/{name}.html"); if (!file.Exists) { throw new FileNotFoundException(name); } using var stream = file.CreateReadStream(); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(); } // 递归列出全部 json,返回虚拟路径集合 public IReadOnlyList ListConfigs() => _vfs.EnumerateFiles("/Configs", "*.json", recursive: true); } ``` `GetFile` 找不到时返回 `NotFoundFileInfo` 而不是抛异常,判 `file.Exists` 即可;`GetDirectoryContents` 同样不抛异常,返回的是 `PrioritizedDirectoryContents` 包装,目录不存在时 `Exists` 为 `false`。 路径写法由 `PathResolver` 统一处理: | 写法 | 解析结果 | | --- | --- | | `/Templates/a.html` | 原样归一化 | | `~/Templates/a.html` | 去掉 `~`,等价于上一行 | | `embedded://MyAssembly/Templates/a.html` | 剥掉协议头与**第一段**,得到 `/Templates/a.html` | | `memory://a.json`、`mem://a.json` | 剥掉协议头,得到 `/a.json` | ::: warning embedded:// 里的程序集名只是标记 `ResolveEmbeddedPath` 剥掉的是协议头到第一个斜杠之间的整段,剩下的路径仍按优先级在**全部**已挂载的提供程序里查找。写 `embedded://A/x.html` 不代表只在程序集 A 里找,物理目录里有同路径文件照样会先命中。 ::: ### 嵌入资源 `AddEmbedded` 建立在 `EmbeddedFileProvider` 之上,未指定基命名空间,因此虚拟路径 `/Templates/mail.html` 对应资源名 `{程序集名}.Templates.mail.html`。文件必须真的被打包进去: ```xml ``` 漏了这行,`AddEmbedded` 注册成功但永远取不到文件。 ### 变更监听 ```csharp _vfs.OnFileChanged += (_, e) => { // e.FilePath、e.ChangeType(Created / Modified / Deleted) _cache.Remove(e.FilePath); }; // 关键:不调 Watch 就不会有任何事件 _vfs.Watch("**/*.json"); ``` ::: danger 只订阅事件不生效 `OnFileChanged` 的触发链是挂在 `Watch(filter)` 注册的回调上的。只订阅事件、从不调 `Watch`,事件永远不会触发。 ::: 通配符规则:`**` 匹配任意层级,`*` 匹配单层内任意字符,`?` 匹配单字符;`*` 和 `**/*` 会短路成"全部匹配"。事件带防抖,间隔取 `ChangeDebounceMilliseconds`(默认 500)与 50 的较大值。不需要监听时把 `EnableChangeTracking` 设为 `false`——它会在挂载物理目录时递归扫描全部文件建立基线快照,目录大时启动开销可观。 ### 文件版本快照 `IFileVersioningService` 提供 `Snapshot(IFileInfo)` 与 `Rollback(path, steps)`,把文件内容整份压进栈里,回滚时弹出并写回磁盘。 ::: warning 进程内、不持久化 版本栈是内存里的 `ConcurrentDictionary`,进程重启即丢,且完整内容常驻内存。`Snapshot` 要求 `file.PhysicalPath` 非空(嵌入资源没有物理路径,会抛 `ArgumentNullException`),`Rollback` 也要求目标文件仍存在。它适合"改配置前留个后悔药"这类短周期场景,不是版本管理方案。 ::: --- ## 配置速查 对象存储 `XiHan:ObjectStorage`: | 键 | 默认值 | 说明 | | --- | --- | --- | | `DefaultProvider` | `Local` | 默认后端名 | | `EnabledProviders` | `["Local"]` | 启用哪些后端,值必须是可识别的名字 | | `RouteProviderMappings` | 空 | 业务路由键 → 后端名,大小写不敏感 | | `StrictRouteMatch` | `false` | 路由键未命中时抛异常还是回退默认 | 子节:`:Local`(`RootPath`、`UrlPrefix`)、`:Minio`(`Endpoint`、`AccessKey`、`SecretKey`、`DefaultBucket`、`UseSSL`、`Region`)、`:AliyunOss`(`AccessKeyId`、`AccessKeySecret`、`Endpoint`、`DefaultBucket`、`CdnDomain`、`UseInternal`)、`:TencentCos`(`SecretId`、`SecretKey`、`AppId`、`Region`、`DefaultBucket`、`CdnDomain`)。 虚拟文件系统 `XiHan:VirtualFileSystem`: | 键 | 默认值 | 说明 | | --- | --- | --- | | `IncludeCurrentDirectory` | `true` | 自动挂载当前工作目录(优先级 100) | | `IncludeAppBaseDirectory` | `true` | 自动挂载应用基目录(优先级 80) | | `AdditionalPhysicalPaths` | 空 | 追加的物理目录(优先级 90) | | `EnableChangeTracking` | `true` | 是否启用变更追踪 | | `ChangeDebounceMilliseconds` | `500` | 变更事件防抖,实际取值不低于 50 | 完整配置项见 [ObjectStorage 包](../packages/object-storage) 与 [VirtualFileSystem 包](../packages/virtual-file-system)。 ## 常见问题 | 现象 | 原因 | | --- | --- | | 上传失败但没有异常 | `UploadAsync` 把异常收敛进 `ErrorMessage`,必须判 `result.Success` | | 文件写进去了,URL 却 404 | `RootPath` 用了相对路径而工作目录 ≠ 内容根;或扩展名 MIME 识别不出被 `ServeUnknownFileTypes = false` 拦掉 | | 静态直链整体失效 | `UrlPrefix` 归一化后等于 `/`,挂载逻辑直接跳过 | | 本地存储设了 `AccessControl` 仍能匿名访问 | 本地提供程序不读该字段,`UrlPrefix` 目录挂在鉴权之前,一律匿名可读 | | MinIO 分片每步都成功,最后失败 | 分片方法是占位实现、数据被丢弃;改用 `UploadAsync` | | 腾讯云 COS 分片上传报本地文件找不到 | `UploadChunkAsync` 把 `StoragePath` 当本地文件路径调 `PutObject`,从不读 `ChunkData`;改用 `UploadAsync` | | 分片上传报 `Upload session not found` / 「上传会话不存在」 | 会话在进程内存里:进程重启、会话 24 小时无活动已被回收,或请求被打到别的实例 | | `GetProvider` 抛 `InvalidOperationException` | 该名字不在 `EnabledProviders` / `DefaultProvider` 中,也没经 `AddFileStorageProvider` 注册 | | 启动期抛"不支持的对象存储提供程序" | `EnabledProviders` 里的名字拼错,与配置节名混用了 | | 本地 `GetMetadataAsync` 的 `ContentType` 回落成 `application/octet-stream` | 本地提供程序只内置 jpg/jpeg、png、gif、webp、pdf、zip、mp4、mp3 的映射,其余扩展名一律回落 | | 预签名 URL 在本地存储上没有时效 | 本地返回的是静态直链,`expiresIn` 被忽略 | | 虚拟文件读到的是另一个目录里的同名文件 | 优先级高的先命中:工作目录 100 > 附加目录 90 > 基目录 80 > 嵌入资源 50 | | `AddEmbedded` 后取不到文件 | 文件没配 `EmbeddedResource`,或资源名与 `{程序集名}.{目录}.{文件名}` 对不上 | | 订阅了 `OnFileChanged` 却从不触发 | 没调 `Watch(filter)`,事件链没挂上 | ## 下一步 - [Web 应用开发](./web):静态文件挂载所在的中间件管线 - [配置与选项](./configuration):配置节绑定与 Options 模式 - [模块系统](./modularity):`DependsOn` 与模块装配 - [ObjectStorage 包](../packages/object-storage):完整 API 清单与全部配置项 - [VirtualFileSystem 包](../packages/virtual-file-system):完整 API 清单与全部配置项 --- 来源:https://framework.docs.xihanfun.com/guide/tasks # 定时任务与后台作业 框架里有三条彼此独立的「后台执行」能力线,选错了会很难受:定时调度、一次性后台作业、后台常驻服务。这章讲怎么选、怎么写、以及租户上下文和分布式锁这两个最容易翻车的地方。 完整 API 与配置全表见 [Tasks 包](../packages/tasks)。 ## 三条能力线怎么选 | 你的需求 | 用哪条线 | 入口类型 | | --- | --- | --- | | 每天凌晨跑一次、每 5 分钟跑一次、启动后延迟一次 | 定时调度 ScheduledJobs | `IJobWorker` + `IJobScheduler` | | 把一次性的耗时活儿甩出请求线程,要持久化、失败自动退避重试 | 后台作业 BackgroundJobs | `IBackgroundJobManager.EnqueueAsync` | | 常驻循环,持续从自己的队列批量拉取并**并发**处理 | 后台常驻服务 BackgroundServices | `XiHanBackgroundServiceBase` | 判断依据只有两条: - **谁决定什么时候跑?** 时间表决定 → 定时调度;业务代码入队时决定 → 后台作业;自己写的循环决定 → 常驻服务。 - **跑一次还是反复跑?** 一次性 fire-and-forget → 后台作业;周期性 → 定时调度。 三者可以在同一个应用里并存,互不干扰。 ## 安装与启用 ```bash dotnet add package XiHan.Framework.Tasks ``` ```csharp [DependsOn(typeof(XiHanTasksModule))] public class MyModule : XiHanModule; ``` 模块启用后自动发生两件事: | 阶段 | 做了什么 | | --- | --- | | `PreConfigureServices` | `AddXiHanBackgroundJobs(config)`:注册 `IBackgroundJobManager`、托管服务 `BackgroundJobWorker`、默认内存存储,并挂上作业处理器的自动发现钩子(必须早于业务模块的约定注册) | | `ConfigureServices` | `AddXiHanTasks(config)`:注册 `IJobScheduler`、`IJobExecutor`、`IJobStore`、`IJobLockProvider`、五个内置中间件,以及托管服务 `JobHostedService` | ::: warning 常驻服务基类不在自动注册范围内 `XiHanBackgroundServiceBase` 只是一个基类,`AddXiHanTasks` / `AddXiHanBackgroundJobs` 都不会替你注册它。你的服务需要自己 `services.AddHostedService()`。 ::: ## 定时调度 ### 写一个任务 实现 `IJobWorker`,用特性描述调度方式: ```csharp using XiHan.Framework.Tasks.ScheduledJobs.Abstractions; using XiHan.Framework.Tasks.ScheduledJobs.Attributes; using XiHan.Framework.Tasks.ScheduledJobs.Models; [JobName("DailyReport")] [JobDescription("每日报表生成")] [JobSchedule("0 2 * * *")] // 每天 02:00 [JobTimeout(600000)] // 10 分钟 [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) { var count = await _reports.GenerateDailyAsync(cancellationToken); return JobResult.Success(count); } } ``` 特性一览: | 特性 | 作用 | 不写时 | | --- | --- | --- | | `[JobName("x")]` | 任务唯一名 | 程序集扫描会**跳过**这个类型 | | `[JobSchedule("0 2 * * *")]` | Cron 触发 | 见下方警告 | | `[JobSchedule(60)]` | 固定间隔(秒) | — | | `[JobSchedule]` | 手动触发(只能靠 `TriggerJobAsync` 拉起) | — | | `[JobRetry]` | `MaxRetryCount`=3、`RetryIntervalMilliseconds`=1000、`UseExponentialBackoff`=true | 用 `JobRetryPolicy.Default`(同上默认值) | | `[JobTimeout(ms)]` | 单次执行超时 | 300000(5 分钟) | | `[JobConcurrent(false)]` | 禁止同名任务并发 | `AllowConcurrent = true` | | `[JobPriority(JobPriority.High)]` | 优先级 | `Normal` | | `[JobDescription("…")]` | 描述文本 | 无 | ::: danger 没有 [JobSchedule] 的任务永远不会跑 `JobInfo.TriggerType` 的枚举默认值是 `Cron`,而 `CronExpression` 为空 → 算不出下次触发时间 → 任务「注册成功但永不执行」。 这种情况调度器会打一条 Warning:`任务 {JobName}(Cron)无法计算下次触发时间,将不会被自动调度`。任务不跑先看这条日志。 ::: ::: warning 一次性延迟触发没有对应特性 `JobScheduleAttribute` 的三个构造函数只产出 `Cron` / `Interval` / `Manual` 三种触发类型,它的 `DelaySeconds` 属性只会填上 `JobInfo.Delay`,不会把触发类型改成 `Delay` —— 结果是这个延迟被忽略。 要用一次性延迟触发,直接构造 `JobInfo` 并显式设置 `TriggerType = JobTriggerType.Delay` 和 `Delay`,交给 `RegisterJob` 注册。 ::: ### 注册任务 **特性只是描述,注册才让它跑起来。** 三种注册方式: ```csharp public override void OnApplicationInitialization(ApplicationInitializationContext context) { var scheduler = context.ServiceProvider.GetRequiredService(); // ① 程序集扫描:只收带 [JobName] 的 IJobWorker 实现类 scheduler.RegisterJobsFromAssembly(typeof(DailyReportJob).Assembly); // ② 代码快捷注册 scheduler.RegisterCronJob("DailyReport", "0 2 * * *", "每日报表"); scheduler.RegisterIntervalJob("Heartbeat", TimeSpan.FromMinutes(1)); // ③ 完整控制:截止时间、重复上限、固定租户、默认参数只能这样传 scheduler.RegisterJob(new JobInfo { JobName = "TenantCleanup", JobType = typeof(TenantCleanupJob), TriggerType = JobTriggerType.Interval, Interval = TimeSpan.FromHours(6), TenantId = 1001, RepeatCount = 20, // -1 表示不限 EndTime = DateTimeOffset.UtcNow.AddDays(30), AllowConcurrent = false, DefaultParameters = new Dictionary { ["scope"] = "logs" } }); } ``` ::: warning 没有「自动发现」 `RegisterJobsFromAssembly` 需要你自己调用,且没有配置开关能替代它。配置节 `XiHan:Tasks:ScheduledJobs` 里的 `AutoDiscoverJobs` / `JobAssemblyPatterns` 会被绑定,但调度器不读取它们(见 [配置](#配置))。 ::: ### 运行期控制 ```csharp scheduler.PauseJob("DailyReport"); scheduler.ResumeJob("DailyReport"); scheduler.UnregisterJob("DailyReport"); DateTimeOffset? next = scheduler.GetNextFireTime("DailyReport"); IReadOnlyList all = scheduler.GetAllJobs(); // 手动触发一次,返回任务实例标识;参数里的 tenantId 会覆盖任务自身的租户 string instanceId = await scheduler.TriggerJobAsync("DailyReport", new Dictionary { ["tenantId"] = 1001 }); ``` ### Cron 表达式 自研 `CronHelper` 解析,不依赖第三方调度库: | 支持项 | 说明 | | --- | --- | | 5 位 | `分 时 日 月 周` | | 6 位 | `秒 分 时 日 月 周` | | 预定义 | `@yearly` `@annually` `@monthly` `@weekly` `@daily` `@midnight` `@hourly` | | 名称 | `JAN`–`DEC`、`SUN`–`SAT` | | 特殊符号 | `*` `-` `,` `/` `?` | ::: warning 不支持 L / W / # 解析器只认上表这几个符号。表达式里写 `L`(月末)、`W`(最近工作日)、`#`(第 N 个星期几)会解析失败,`CronScheduler.GetNextFireTime` 吞掉异常返回 `null` —— 任务注册成功但算不出下次触发时间,表现为「永不执行」,日志里只有那条无法计算下次触发时间的 Warning。 ::: ::: warning Cron 按服务器本地时区求值 `"0 2 * * *"` 表示**服务器本地时间** 02:00,不是 UTC 02:00。容器里没设 `TZ` 会按 UTC 跑,和预期差一个时区偏移。 ::: 调度器用一个 `Timer` **每秒**巡检一次到点任务,触发精度为秒级。`Delay` 触发是一次性的:触发过一次后不再续排。 ## 一次性后台作业 适合「用户点了提交,邮件慢慢发」这类场景:入队立刻返回,执行完全交给后台 Worker。 ### 定义参数与处理器 ```csharp using XiHan.Framework.Tasks.BackgroundJobs.Abstractions; using XiHan.Framework.Tasks.BackgroundJobs.Attributes; // 作业的稳定标识来自参数类型,建议显式标注 [BackgroundJobName("SendWelcomeEmail")] public class SendWelcomeEmailArgs { public long UserId { get; set; } public string Email { get; set; } = default!; } // 继承 AsyncBackgroundJob:自动瞬时注册 + 自动纳入作业发现 public class SendWelcomeEmailJob : AsyncBackgroundJob { private readonly IEmailSender _sender; public SendWelcomeEmailJob(IEmailSender sender) => _sender = sender; public override async Task ExecuteAsync(SendWelcomeEmailArgs args) { await _sender.SendAsync(args.Email, "欢迎注册", "…"); } } ``` ### 入队 ```csharp public class RegistrationAppService { private readonly IBackgroundJobManager _jobs; public RegistrationAppService(IBackgroundJobManager jobs) => _jobs = jobs; public async Task RegisterAsync(long userId, string email) { // …写库等业务逻辑… // 入队即返回作业标识,不等待执行 await _jobs.EnqueueAsync(new SendWelcomeEmailArgs { UserId = userId, Email = email }); // 也可以指定优先级和延迟 await _jobs.EnqueueAsync( new SendWelcomeEmailArgs { UserId = userId, Email = email }, priority: BackgroundJobPriority.High, delay: TimeSpan.FromMinutes(5)); } } ``` 优先级取值:`Low`(5) / `BelowNormal`(10) / `Normal`(15,默认) / `AboveNormal`(20) / `High`(25),领取时按值降序。 ### 执行与失败语义 Worker 启动后先等 `FirstWaitDurationMilliseconds`(默认 5000),之后按 `JobPollPeriodMilliseconds`(默认 5000)轮询:抢单活锁 → 批量领取 → 逐个执行。 失败处理分两类,**结果完全不同**: | 失败类型 | 触发条件 | 处理 | | --- | --- | --- | | 业务失败 | 处理器 `ExecuteAsync` 里抛出的任何异常(执行器统一包成 `BackgroundJobExecutionException`) | 按指数退避排下次重试 | | 致命错误 | 作业名找不到对应配置、参数 JSON 反序列化失败 | **立即标记 `IsAbandoned` 放弃**,不重试 | 退避公式与放弃阈值: ```text 下次等待秒数 = DefaultFirstWaitDurationSeconds × DefaultWaitFactor^(TryCount - 1) = 60 × 2^(TryCount - 1) 若 下次执行时间 - 作业创建时间 > DefaultTimeoutSeconds(默认 172800 秒 = 2 天)→ 放弃 ``` ::: tip 没有「最大重试次数」 只有**累计耗时上限**。退避间隔指数增长,所以一个持续失败的作业大约 12 次尝试后就会撞上 2 天阈值被放弃,而不是跑满某个固定次数。 ::: ::: danger 改参数类型的名字会丢作业 不标 `[BackgroundJobName]` 时,作业名回退成参数类型的完整名称。改类名或换命名空间后,已入库未执行的旧作业会「找不到作业配置」→ 按致命错误直接放弃。关键作业一律显式标注固定名称。 ::: ## 后台常驻服务 继承 `XiHanBackgroundServiceBase`,只实现「取任务」和「处理任务」: ```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; } // 批量取任务;返回空集合表示当前无活儿,基类按 IdleDelayMilliseconds 空转 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); // 可选:失败回调 protected override void OnTaskFailed(IBackgroundTaskItem item, Exception exception) => Logger.LogError(exception, "发件箱条目 {TaskId} 处理失败", item.TaskId); } ``` ```csharp services.Configure(o => o.MaxConcurrentTasks = 8); services.AddHostedService(); ``` 基类替你做的事:并发上限门控、批量拉取、失败重试(默认开,指数退避 ×2、上限 5 分钟)、优雅停机(等在跑的任务收尾,超过 `ShutdownTimeoutMilliseconds` 强停)、运行统计(`GetStatistics()` / `GetServiceStatus()`)、运行时热调(`IDynamicServiceConfig` 改并发数、空闲延迟、启停开关)。 基类**不**替你做的事: | 缺口 | 你要做什么 | | --- | --- | | 单活 / 多实例去重 | 在 `FetchWorkItemsAsync` 里做原子领取,或自己加分布式锁 | | 租户上下文 | 在 `ProcessItemAsync` 里自己 `ICurrentTenant.Change(...)` | | 单任务超时 | `EnableTaskTimeout` 默认 `false`、`TaskTimeoutMilliseconds` 默认 `0`,需要就显式打开 | ::: tip 多个后台服务共享同一份选项 构造函数收的是 `IOptions`,所有派生服务默认拿到同一个实例。要让某个服务用不同的并发数,在它自己的构造函数里传 `Options.Create(new XiHanBackgroundServiceOptions { … })`。 ::: ## 关键机制 ### 执行管道 定时任务不是被直接调用的,而是穿过一条中间件管道: ```text LoggingMiddleware → TimeoutMiddleware → LockMiddleware → RetryMiddleware → MetricsMiddleware → IJobWorker.ExecuteAsync ``` 顺序决定语义,有两条不直观的结论: - **超时在重试外层** —— `[JobTimeout]` 是整个「首次执行 + 全部重试」的总预算,不是单次尝试的预算。 - **锁在重试外层** —— 一次抢锁覆盖所有重试轮次;反过来,**抢锁失败不会被重试**,会直接变成一次失败执行记入历史。 重试判定看两样东西:抛出异常,或返回了 `IsSuccess == false` 的 `JobResult`。总尝试次数是 `MaxRetryCount + 1`。 自定义中间件: ```csharp public class TracingMiddleware : IJobMiddleware { public async Task InvokeAsync(IJobContext context, JobExecutionDelegate next) { using var activity = MyTracer.StartActivity(context.JobInstance.JobName); return await next(context); } } ``` ```csharp services.AddXiHanTasks(config).AddMiddleware(); ``` 注册顺序即执行顺序,追加的中间件排在五个内置中间件之后,也就是**最靠近业务代码**的位置。 ### 多租户上下文切换 两条线的租户来源不一样,这是最容易混淆的地方: | 能力线 | 租户从哪来 | 切换时机 | | --- | --- | --- | | 定时调度 | 触发时按优先级解析:① 参数里的 `tenantId` → ② `JobInfo.TenantId` → ③ 当前异步上下文租户 | `JobExecutor` 一开始就 `ICurrentTenant.Change(...)` 切入:实例落库、任务构造、管道执行、状态回写与历史落档(含失败路径)都在该租户作用域内 | | 后台作业 | **入队那一刻**的 `ICurrentTenant.Id`,写进 `BackgroundJobInfo.TenantId` | Worker 执行前 `ICurrentTenant.Change(job.TenantId)` | `TenantId` 为空表示平台级任务,在平台(0 号租户)作用域执行,只看得到平台数据;要处理各租户的数据,在任务里逐个 `Change(tenantId)` 切入。 ::: warning 任务类上的 AOP 特性不生效 因为走 `ActivatorUtilities.CreateInstance`,任务类型本身不经过容器解析,拿不到拦截器代理。打在 `IJobWorker` 实现类上的 `[UnitOfWork]`、`[Cacheable]` 之类特性会静默失效。 正确做法:把需要事务/缓存的逻辑放进从容器注入的应用服务里,任务类只负责调用它。见 [AOP 与拦截器](./aop) 和 [工作单元与事务](./uow)。 ::: ### 分布式锁与单活 框架里有两处独立的锁,都走 Caching 的统一 `IDistributedLock`: | 位置 | 触发条件 | 键 / TTL | 抢不到时 | | --- | --- | --- | --- | | `LockMiddleware`(定时任务) | 仅当 `AllowConcurrent = false` | `job:lock:{任务名}`,TTL = `TimeoutMilliseconds + 5000ms` | 返回失败结果并记入历史 | | `BackgroundJobWorker`(后台作业) | 每一轮轮询 | `DistributedLockName`(默认 `XiHanBackgroundJobWorker`),TTL = `DistributedLockExpirySeconds`(默认 300 秒) | 本轮跳过,下一轮再试 | 定时任务的防重入还有第二道闸:`CompositeJobScheduler` 在触发前会查 `IJobStore.GetRunningInstancesAsync`,有运行中实例就直接跳过本次触发(这一道只看本进程的存储)。 ::: danger 没接 Redis 时锁退化为进程内锁 `IDistributedLock` 在 Redis 未启用时退化为进程内实现。多实例部署下的后果:定时任务每个实例各跑一遍、后台作业 Worker 不再单活、同一批作业被重复执行。 单机开发无所谓,**多实例生产必须启用 Redis**。另外注意 `AllowConcurrent` 默认是 `true`,也就是默认**根本不加锁** —— 需要单活的任务必须显式写 `[JobConcurrent(false)]`。 ::: ### 存储替换 默认实现都是有界的进程内存储,进程重启即丢:`DefaultJobStore` 实例最多 20000 条(终态实例只留最近 10000 条)、历史最多 100000 条;`DefaultBackgroundJobStore` 最多 100000 条。满载时新增抛 `InvalidOperationException`——后台作业入队直接失败,定时任务的历史写入失败只记错误日志。框架不自动清理历史,长期运行要定期调 `IJobStore.CleanupHistoryAsync`。 | 存的是什么 | 默认实现 | 换成别的 | | --- | --- | --- | | 任务实例与执行历史 | `DefaultJobStore` | `AddXiHanTasks(config).UseStore()` | | 任务锁提供者 | `CachingJobLockProvider` | `.UseLockProvider()` | | 待执行的后台作业 | `DefaultBackgroundJobStore` | `services.UseRedisBackgroundJobStore()`,或自实现 `IBackgroundJobStore` | 框架侧用的是 `TryAdd` 语义,业务模块的 `ConfigureServices` 在框架模块之后执行,后注册者胜出。 一行切到 Redis 持久化(复用 Caching 注册的连接): ```csharp services.UseRedisBackgroundJobStore(o => { o.KeyPrefix = "MyApp:BackgroundJobs"; o.AbandonedRetentionDays = 7; }); ``` 自实现 `IBackgroundJobStore` 时,`GetWaitingJobsAsync` 必须遵守契约,否则多实例会重复执行: - 过滤:`ApplicationName` 匹配 且 `!IsAbandoned` 且 `NextTryTime <= 当前时间` - 排序:`Priority` 降序 → `TryCount` 升序 → `NextTryTime` 升序 - 限量:最多 `maxResultCount` 条 - **原子领取**:领到的作业对其他实例不可见 ## 配置 | 配置节 | 绑定到 | 是否生效 | | --- | --- | --- | | `XiHan:BackgroundJobs` | `BackgroundJobWorkerOptions` | 模块自动绑定,全部生效 | | `XiHan:Tasks:ScheduledJobs` | `XiHanJobOptions` | 绑定了,但当前实现不读取(见下方警告) | | 无配置节 | `XiHanBackgroundServiceOptions` | 需自行 `services.Configure<…>(…)` | 常用的后台作业配置: ```json { "XiHan": { "BackgroundJobs": { "IsJobExecutionEnabled": true, "ApplicationName": "MyApp", "JobPollPeriodMilliseconds": 5000, "MaxJobFetchCount": 1000, "DefaultFirstWaitDurationSeconds": 60, "DefaultWaitFactor": 2.0, "DefaultTimeoutSeconds": 172800, "DistributedLockExpirySeconds": 300 } } } ``` - `IsJobExecutionEnabled = false` 只停执行,入队照常可用 —— 数据迁移窗口期很好用。 - `ApplicationName` 用于多个应用共用同一份存储时互相隔离;**入队端和 Worker 端读的是同一份配置**,所以天然一致,但不同应用之间彼此看不见对方的作业。 ::: warning XiHan:Tasks:ScheduledJobs 目前不影响运行时行为 `XiHanJobOptions` 的字段(`Enabled`、`AutoDiscoverJobs`、`JobAssemblyPatterns`、`DefaultTimeoutMilliseconds`、`HistoryRetentionDays`、`EnableMetrics`、`NodeName`)会被绑定成选项对象,但调度器、执行器、存储都不读取它们。实际生效的是: - 超时 → `JobInfo.TimeoutMilliseconds`(`[JobTimeout]`,默认 300000) - 任务注册 → 必须显式调用 `RegisterJobsFromAssembly` / `RegisterCronJob` / `RegisterIntervalJob` / `RegisterJob` - 历史清理 → 自行调用 `IJobStore.CleanupHistoryAsync(retentionDays)` - 执行节点名 → `JobInstance.ExecutionNode`,由调度器写入 `Environment.MachineName` 另外 `IJobEventPublisher` 的默认实现 `DefaultJobEventPublisher` 是空实现,`JobMetricsProvider` 已注册但内置中间件不向它写入 —— 需要任务指标请自行实现 `IJobMiddleware` 采集。 ::: ## 常见问题 | 现象 | 原因 | | --- | --- | | 任务注册成功却从不执行 | 漏了 `[JobSchedule]`:`TriggerType` 取默认值 `Cron` 而表达式为空,算不出下次时间;日志里有 Warning | | `RegisterJobsFromAssembly` 没扫到我的任务 | 类型缺 `[JobName]`,扫描时被静默跳过 | | `[JobSchedule]` 上设了 `DelaySeconds` 却没延迟触发 | 该属性不会把触发类型改成 `Delay`,需直接构造 `JobInfo` 指定 `TriggerType = JobTriggerType.Delay` | | Cron 触发时间比预期早/晚几小时 | Cron 按服务器本地时区求值,容器里没设 `TZ` 就是 UTC | | 多实例下定时任务重复执行 | `AllowConcurrent` 默认 `true` 不加锁;或 Redis 未启用,锁退化为进程内 | | 历史里出现「无法获取任务锁」的失败记录 | 锁在重试外层,抢锁失败直接记为一次失败执行,不会重试 | | `[JobTimeout]` 设了 5 分钟,任务却跑了 15 分钟才超时 | 超时是「首次 + 全部重试」的总预算,与重试次数无关;实际观感差异来自重试间隔 | | 任务里的 `[UnitOfWork]` / `[Cacheable]` 不生效 | 任务实例由 `ActivatorUtilities.CreateInstance` 构造,不经容器代理;把逻辑挪进注入的服务 | | 平台级任务里查不到租户数据 | 未指定租户的任务在平台作用域执行,平台只看自己的数据;给任务指定 `TenantId`,或在任务里逐个 `Change(tenantId)` | | 重启后待执行的后台作业全没了 | 默认 `DefaultBackgroundJobStore` 是进程内的,换 `UseRedisBackgroundJobStore()` 或自实现 | | 入队的作业迟迟不执行 | `IsJobExecutionEnabled = false`;或首轮等待 5 秒 + 轮询间隔 5 秒的正常延迟;或多实例下锁被别的实例持有 | | 作业只试了一次就被放弃 | 属于致命错误:作业名找不到配置(改过参数类型名且没标 `[BackgroundJobName]`),或参数反序列化失败 | | 作业反复重试很久才放弃 | 没有次数上限,只有累计耗时上限 `DefaultTimeoutSeconds`(默认 2 天) | | 常驻服务在多实例下重复消费 | 基类不做单活,需要在 `FetchWorkItemsAsync` 里原子领取 | | 常驻服务里拿不到当前租户 | 基类不做租户切换,自己在 `ProcessItemAsync` 里 `ICurrentTenant.Change(...)` | ## 下一步 - [多租户](./multi-tenancy):`ICurrentTenant` 与上下文切换的完整规则 - [缓存与分布式锁](./caching):任务锁和 Worker 单活锁的底座 - [工作单元与事务](./uow):任务里怎么正确开事务 - [Tasks 包](../packages/tasks):完整 API 清单与全部配置项 --- 来源:https://framework.docs.xihanfun.com/guide/templating # 模板引擎 把「模板 + 数据」渲染成文本(通知正文、邮件、代码文件)。这一章的重点只有一句话:**`ITemplateService` 的默认引擎是简单占位替换,不是 Scriban**——搞错这一点,模板会被原样输出。 完整 API 与配置项全表见 [Templating 包](../packages/templating)。 ## 包里其实有两套引擎 | 引擎 | 类型 | 注册名 | 语法能力 | | --- | --- | --- | --- | | `DefaultTemplateEngine` | `ITemplateEngine` | `"String"` | 自研的占位替换 + 单层条件/循环 | | `ScribanTemplateEngine` | `ITemplateEngine