源生成与 AOT 就绪的微信生态 .NET SDK:结构、能力边界与 API 定位方法

常见的 .NET 微信 SDK 采用静态门面 + 进程级凭据字典 + Newtonsoft.Json 反射序列化。这套结构在单体应用里够用,但有三处硬约束:静态方法没有 DI 注入点,无法替换实现,也无法在测试中打桩;全局凭据字典没有租户维度,一个进程服务多个企业时,令牌刷新与失效会互相影响;反射序列化在 Native AOT 或 trim 下不可用,单文件发布会在启动或首次序列化时失败。

MudWechat 按这三点反向设计:每个端点是一个带特性的接口方法,实现类由源生成器产出并按模块注册进 DI;令牌按 {tokenType}:{appKey}:{scopeKey} 分键,企业级令牌一企一份;DTO 序列化走编译期生成的 JsonContext,net8.0 / net10.0 下 AOT 诊断保持净零。

本文讲这套结构的组成、它能覆盖与不能覆盖的边界,以及在一个 470 契约接口的产品线里定位具体方法的过程。


一、项目定位与适用边界

Mud.Wechat 是微信生态的 .NET 服务端 SDK,覆盖七条产品线:

产品线 包前缀 业务域 契约规模(企微按契约接口计,其余按 HTTP 端点计)
企业微信 Mud.Wechat.Work* 42 470 个契约接口
微信公众号 Mud.Wechat.OfficialAccount* 27 194
微信小程序 Mud.Wechat.MiniProgram* 21 141
微信支付 APIv3 / APIv2 Mud.Wechat.Pay* 10 57
微信开放平台 Mud.Wechat.OpenPlatform* — 4
微信小店 / 视频号 Mud.Wechat.Channels* 27 309
腾讯广告(在建) Mud.Wechat.Ads* 8 33

七条线同仓、共用底座,但打包与依赖独立:只装公众号的两个包,不会把企业微信的 42 个域带进来。

1.1 三个明确的不做

  • 不做前端。 不含 JS-SDK 的浏览器侧封装、页面组件、本地缓存。SDK 只处理服务端 HTTP 调用、凭据、加解密、推送接收。
  • 不做字符串驱动的通用客户端。 没有 CallAsync("/cgi-bin/user/get", dict) 这类入口。每个端点是一个强类型方法,路由、参数、令牌归属在编译期固定。
  • 不做运行期反射。 DTO 序列化走源生成 JsonContext,回调载荷走编译期字段映射,MCP 工具 Schema 由生成器产出。

1.2 不适合使用的情况

以下场景直接手写 HttpClient 成本更低:

  • 只需要 1~2 个接口,且不涉及令牌刷新与回调。
  • 必须使用 Newtonsoft.Json 的既有序列化管线,且不愿意为单个 SDK 引入第二套序列化配置。
  • 需要在 .NET Framework 4.0 / 4.5 上运行(netstandard2.0 的最低面是 4.6.1 及以上)。
  • 需要 SDK 替你编排业务流程(审批流转、订单状态机)。这部分不在职责内。

1.3 与手写 HttpClient 的工作量差异

事项 手写时的实际工作量 SDK 的处置
令牌获取与缓存 自己实现并发去重(single-flight)、过期时间、时钟偏差 内置,按 appKey + scope 分键
令牌失效重取 识别官方错误码并清缓存、重放请求 判定器 + 自动恢复
多企业隔离 自建字典,需保证键不串 scopeKey = authCorpId,错配 fail-fast
回调 AES 解密 官方 32 字节块 PKCS7,不能用 BCL 默认实现 手工补位/剥离
回调抗重放 时间窗 + 一次性指纹,且要处理分布式场景 两道闸 + Redis 端口
序列化 AOT 下不能用反射版本 全部 JsonTypeInfo
契约变更追踪 靠人读文档 守卫与分析器

二、框架结构

2.1 分层

Src/Core/                          跨线共享,零业务依赖
├ Mud.Wechat.Abstractions          响应契约、令牌存储端口、回调密码学内核、
│                                  配置基座、WechatApiHosts(SSRF 白名单)
├ Mud.Wechat.Redis                 五个存储端口的 Redis 实现
├ Mud.Wechat.OpenTelemetry          Meter / ActivitySource 装配
├ Mud.Wechat.Callback.Generator    回调契约登记生成器(构建期)
└ Mud.Wechat.Callback.Analyzers    回调处理器分析器(构建期)

Src/Work/                          企业微信线
├ Mud.Wechat.Work                  主包:DTO + 接口 + 服务 + DI + 模块注册
└ Mud.Wechat.Work.Callback         回调接收 + 智能机器人通道

Src/OfficialAccount/               公众号:主包 + Callback
Src/MiniProgram/                   小程序:单包
Src/Pay/                           支付:主包(v3 + v2)+ Callback
Src/OpenPlatform/                  开放平台:单包(推送由主包承载)
Src/Channels/                      小店 / 视频号:主包 + Callback
Src/Ads/                           腾讯广告:单包(在建)
Src/AI/                            AI 层 1 + 层 2,共四包

叶层 Mud.Wechat.Abstractions 不引用任何产品线工程。它承载三类内容:跨线共用的响应与配置基座、存储端口(IWechatTokenStore / IWechatCorpAuthStore / IWechatSuiteTicketStore / IWechatCallbackReplayGuard / IWechatBotConnectionLease)、回调密码学与 WechatApiHosts。后者的作用是:所有线允许访问的域名只在一处维护,自定义 BaseUrl 必须登记后才被令牌失效判定器放行。

2.2 包清单(18 个可打包工程)

分类 包 说明
叶层 Mud.Wechat.Abstractions 跨线契约与端口
基建 Mud.Wechat.Redis 多实例存储实现
基建 Mud.Wechat.OpenTelemetry 可观测性装配
企业微信 Mud.Wechat.Work 主包
企业微信 Mud.Wechat.Work.Callback 回调
公众号 Mud.Wechat.OfficialAccount 主包
公众号 Mud.Wechat.OfficialAccount.Callback 回调
小程序 Mud.Wechat.MiniProgram 单包,依赖公众号主包
支付 Mud.Wechat.Pay v3 与 v2 同包
支付 Mud.Wechat.Pay.Callback 回调
开放平台 Mud.Wechat.OpenPlatform 单包
小店 Mud.Wechat.Channels 主包
小店 Mud.Wechat.Channels.Callback 回调
广告 Mud.Wechat.Ads 单包,在建
AI Mud.Wechat.AI 层 1:端口
AI Mud.Wechat.Work.AI 层 2:Agent 集成
AI Mud.Wechat.Work.AI.Tools 层 2:工具面
AI Mud.Wechat.Work.AI.Mcp 层 2:MCP 协议层

Mud.Wechat.Callback.Generator 与 Mud.Wechat.Callback.Analyzers 是构建期工具,IsPackable=false,不进 NuGet 清单。

2.3 依赖方向

{线}.Callback  →  {线}
MiniProgram    →  OfficialAccount
Redis          →  Abstractions + Work
Ads            →  (与其余六线零引用)
AI 层 2        →  Mud.Wechat.AI + Work

跨线依赖白名单只有两条:小程序 → 公众号、AI 层 2 → 层 1 / Work。前者不是复用代码的问题,而是令牌归属问题:小程序与公众号共用同一个 /cgi-bin/token 与同一套令牌类型,复制一份会让 errcode 自愈在两条线之间失效。

01-分层结构:七线同仓,共用叶层

2.4 单包目录

{线}/
├ Interfaces/{域}/        声明式接口
├ DataModels/{域}/        请求 / 响应 DTO
├ DataModels/Generated/   JsonContext(生成物,入库,勿手改)
├ Extensions/            Add{域}Api()、模块枚举、配置绑定
├ Services/              有状态的编排服务(企微授权链路等)
└ Internal/              生成实现类(生成物,勿手改)

用户实际会打开的是前四个位置,后两个只在排查生成行为时用。

2.5 四条生成链

输入 输出 落点
[HttpClientApi] + [Get]/[Post] + [Token] + [Query]/[Body] 实现类 + Add{域}WebApiHttpClient() Internal/(生成,不入库)
[HttpJsonSerializable] {域}JsonContext.g.cs DataModels/Generated/
[WechatCallbackContract] / [PayloadContract] 契约登记表 RegisterAll + 字段映射 生成器输出
[AgentTool] / [ToolParameter] MCP Schema + 参数解包器 + 域注册器 工具面生成物

四条链的共同约束是:运行期不读反射元数据。代价写在编码规则里——DTO 必须标注、配置 DTO 不能写 required、业务代码不能调用反射版 JsonSerializer.Serialize<T>。

02-四条生成链:声明进,代码出


三、用户视角的能力与约束

3.1 注册与调用

// Program.cs
builder.Services.AddWechatApp(builder.Configuration, "WechatApps");
builder.Services.AddWechatWorkServices(b => b
    .AddContactApi()
    .AddExternalContactApi());
public sealed class MemberService(IWechatWorkInternalUsersService users)
{
    public Task<UserInfo> GetAsync(string userid, CancellationToken ct)
        => users.GetUserAsync(userid, ct);
}

AddWechatWorkServices 按模块注册,容器里只出现已声明的域。企微共 42 个域,AddAllApis() 存在,但只建议 Demo 与自测使用:全量注册会显著增加服务描述符数量。

3.2 接口形态与注入规则

企业微信线的接口分两半:

  • 公共父接口:[HttpClientApi(IsAbstract = true)],RegistryGroupName 为空。只放三类应用共有端点,供子接口继承,不进注册组。
  • 可注入子接口:IWechatWork{Internal|ThirdParty|Provider}{域}{子族}Service,RegistryGroupName 为域名。

下面这行在企微线解析失败:

var users = provider.GetRequiredService<IWechatWorkUsersService>();   // 父接口,未注册

按应用类型取子接口:

var internalUsers   = provider.GetRequiredService<IWechatWorkInternalUsersService>();
var thirdPartyUsers = provider.GetRequiredService<IWechatWorkThirdPartyUsersService>();
var providerUsers   = provider.GetRequiredService<IWechatWorkProviderUsersService>();

隔离由 RegistryGroupName 决定,不是命名空间:父接口不设注册组,生成器不把它写进 DI;命名空间分区只影响 IDE 自动完成列表。

一个容易误判的点:父接口有实现类。守卫 N2 要求每个父接口的实现类存在于 Mud.Wechat.Work.Interfaces.Internal。实现类存在但没有服务描述符,GetRequiredService 仍然拿不到。守卫 N3 则断言所有父接口的 RegistryGroupName 必须为空,断言消息写明「否则将可被直接解析调用」。

实际有三种形态:

形态 例子 注入方式
父接口 + 三类子接口 通讯录 Users、客户群 GroupChat 按应用类型注入子接口
父接口 + 部分子接口 Basic 只有 Internal / Provider 只注入官方开放的类型
单一接口 企微 OpenHardware;其余六线全部 直接注入该接口

例外:SDK 不注册父接口,但宿主可以显式桥接。若某组件按父接口解析(典型是 AI 工具面),在宿主侧写:

builder.Services.AddSingleton<IWechatWorkExternalContactGroupChatService>(
    sp => sp.GetRequiredService<IWechatWorkInternalExternalContactGroupChatService>());

这是同一实例的转发(子接口实现类同时实现父接口),不新建客户端,也不复制令牌链。它需要显式声明,SDK 不会自动完成。

3.3 令牌生命周期

令牌键为三段式 {tokenType}:{appKey}:{scopeKey},例如 Wechat.AccessToken:default:ww1234。企业级令牌的 scopeKey 是 authCorpId,一企一份,不写 default 段。

四个处理点:

  • 获取与缓存:并发请求命中同一键时只发一次换取请求。
  • 提前刷新:按过期时间前推刷新;刷新服务停机时取消在途刷新,以 OperationCanceledException 结束,不产生未处理异常日志。
  • 失效恢复:官方失效码(40014、42001 等)触发缓存失效,下次调用重新获取;业务错误码不触发。
  • 归属校验:接口声明令牌归属域(@Internal / @Corp),取错时抛 WechatTokenOwnerMismatchException。

错误统一为 WechatWorkException:

try
{
    var user = await users.GetUserAsync(userid, ct);
}
catch (WechatWorkException ex) when (ex.ErrorCode == 42001)
{
    // 自动恢复未覆盖的场景(例如并发刷新失败),按业务决定是否重试
}

RequestUri 在构造时已剥离 query 与 userinfo,写入日志不会带出 access_token。

03-令牌生命周期:三段式键,四个处理点

3.4 多应用与多租户

应用类型 WechatAppType 令牌链
自建 Internal access_token(corpid + corpsecret)
第三方 ThirdParty provider_access_token + suite_access_token + 每授权企业 access_token
服务商代开发 Provider 同上;企业令牌走 gettoken(corpsecret = permanent_code)

第三方与代开发的调用需要落到具体授权企业:

using (switcher.UseCorpScope(appKey: "default", authCorpId, authorization.PermanentCode))
{
    return await users.GetUserAsync(userid, ct);
}

进入时写入「应用 + 企业」两级上下文,退出时逆序还原且幂等。作用域内令牌键自动带 authCorpId;出作用域回到默认应用。

appKey 传 null 的解析顺序是:显式配置的 DefaultAppKey → 仅注册一个应用时的该应用 → 两者都不满足则抛 InvalidOperationException。多个应用且未配默认值时不允许隐式选择,避免换码、撤销落到错误企业。

3.5 回调接收

处理顺序:路径提取 AppKey → IP 白名单 / 方法 / Content-Type / 体长校验 → 验签 → 时间窗口 → AES 解密 → receiveid 校验 → 一次性指纹 → 事件信封解析 → 分发。

builder.Services.AddWechatApp(o =>
{
    o.AppKey = "default";
    o.AppType = WechatAppType.Internal;
    o.CorpId = "ww-your-corp-id";
    o.AgentSecret = "your-agent-secret";
});

builder.Services.AddWechatCallback(options =>
{
    options.GlobalRoutePrefix = "wechat";       // 默认值即 "wechat"
    options.Apps["default"] = new WechatAppCallbackOptions
    {
        PushToken = "<回调 Token>",
        PushEncodingAESKey = "<43 位 EncodingAESKey>",
        ReceiveId = "ww-your-corp-id",
        AppType = WechatAppType.Internal,
    };
})
.AddHandler<UserSyncHandler>()
.AddHandler<MySuiteScopedHandler>("suite-a");

var app = builder.Build();
app.UseWechatWebhook();

失败状态码与触发条件:

状态码 触发条件 对官方重推的影响
200 处理完成;或 GET echo 校验通过 不重推
400 机器人 JSON 通道收到空请求体 不重推
403 IP 不在白名单;验签 / 时效窗 / 解密 / receiveid / 凭据校验失败;指纹重复 不重推(fail-closed)
405 方法不是 GET / POST 不重推
413 请求体超过 MaxRequestBodySize(默认 1 MiB) 不重推
415 报文既非加密 XML 也非加密 JSON;低 TFM 收到机器人通道请求 不重推
500 未预期异常;机器人应答形态不受支持 触发重推
503 重放守卫等基础设施故障;分发软超时;分发中断 触发重推

上表的 403 / 503 语义针对 XML 事件通道。智能机器人 JSON 通道的处理不同:软超时回加密空包 + 200,不返回 503(官方只推一次)。

默认值:EventHandlingTimeoutMs = 4500、MaxConcurrentEvents = 10、MaxRequestBodySize = 1_048_576。软超时必须小于官方 5 秒契约,因此可调范围有限。

三个实现细节会影响你的处理器写法:

  • 填充方式是 32 字节块 PKCS7。 BCL 的 PaddingMode.PKCS7 按 16 字节块处理,遇到 pad ∈ [17..32] 会判为非法。此处为手工补位/剥离。
  • 指纹在解密成功后消费。 解密失败不消耗指纹,官方重试可重入;GET echo 不消费指纹,同一 echostr 可以重复保存配置。
  • 指纹在分发前消费。 处理器抛异常会被隔离并记录,但不会回滚指纹,官方重推会被 403。处理器必须幂等。

04-回调管线:九道闸,两种重推语义

3.6 AOT、Trim 与多 TFM

全部可打包工程统一四档:netstandard2.0;net6.0;net8.0;net10.0。net8.0 / net10.0 下逐工程以 AotStrictMode=true 构建,并把 10 类诊断升为错误:IL2026;IL2046;IL2050;IL2057;IL2067;IL2070;IL2072;IL2075;IL2080;IL3050。

对使用者的影响:

  • 可以 dotnet publish -p:PublishAot=true 产出单文件原生二进制,启动过程不含 JIT 编译。
  • trim 场景不会因 SDK 内部反射被裁掉而运行期失败。
  • netstandard2.0 目标可用,但没有 AOT 收益。

配合规则:

  1. 自定义 DTO 标 [HttpJsonSerializable] 后重跑生成脚本;序列化走 JsonTypeInfo。
  2. 配置 DTO 不写 required(源生成绑定以 new T() 构造,会报 CS9035),校验放 Validate()。
  3. 多态解析显式声明 [JsonDerivedType] 并登记 JsonContext。
  4. 低 TFM 缺口的处置优先级:换成四档等价 API → 按 TFM 分流 → 占位类型抛 PlatformNotSupportedException。例如 ArgumentNullException.ThrowIfNull、StringSplitOptions.TrimEntries、MD5.HashData、Convert.ToHexString 在 netstandard2.0 不可用。

3.7 契约守卫与分析器

机制 位置 拦截什么
契约守卫 Tests/**/ContractGuards/ 路由、端点计数、字段名、令牌归属、事件键与载荷配对
回调分析器 Mud.Wechat.Callback.Analyzers MUDCB002 事件键不在载荷契约内(Error);MUDCB003 键非常量;MUDCB004 字面量代替常量;MUDCB005 未注册
配置审计 audit-config-keys.ps1 公开配置属性无真实消费点

这些机制在发布侧约束的是「包里有什么」。使用者感知到的结果是:方法签名与 DTO 的破坏性变更会反映在版本与 CHANGELOG 上,而不是以运行期 404 或字段缺失的形式出现。

3.8 可选组件

  • Mud.Wechat.Redis:多实例部署需要。注册顺序为 Redis → App → Callback,颠倒会在注册期 fail-fast。它实现五个端口:令牌、企业授权、套件 ticket、回调重放、机器人连接租约。
  • Mud.Wechat.OpenTelemetry:Mud.Wechat 根源 Meter 与产品线通配源。不装可运行,无指标与追踪。
  • AI 四包:工具面自带白名单、授权、审批、审计;默认只开只读工具。工具面多数执行器按父接口解析 SDK,需要按域桥接(见 5.4)。

四、API 定位方法

4.1 先确定三件事

问题 决定
哪个平台 装哪个包
哪类应用 注入哪个子接口(企微线;其余六线无子接口,直接注入)
哪个业务域 Interfaces/{域}/ 与 WechatModule 枚举值

4.2 五步定位(以「查客户群详情」为例)

第一步:主包 README 的域清单。 企微主包 README 按 42 个域逐行说明。客户群属于客户联系,目录为 Interfaces/ExternalContact/。

第二步:域 README 的端点表。 Interfaces/ExternalContact/README.md 给出 16 个子族与端点计数:客户 11、群发 11、朋友圈 14、联系我 10、标签 9、获客助手 9、商品相册 5、拦截规则 5、离职继承 4、客户群 3、在职继承 3、统计 3、企业服务人员 3、获客组件 3、已服务客户 1、附件 1,合计 95,与守卫逐族 HaveCount 一致。

客户群 3 个端点,全部为 POST:

方法 路由
GetGroupChatListAsync /cgi-bin/externalcontact/groupchat/list
GetGroupChatDetailAsync /cgi-bin/externalcontact/groupchat/get
OpenGidToChatIdAsync /cgi-bin/externalcontact/opengid_to_chatid

第三步:接口文件。 目录下四个文件:父接口、_Internal、_ThirdParty、_Provider。

[HttpClientApi(TokenManage = nameof(IWechatAppManager), IsAbstract = true)]
[Token(TokenType = WechatTokenTypes.AccessToken,
      InjectionMode = TokenInjectionMode.Query, Name = "access_token")]
public interface IWechatWorkExternalContactGroupChatService
{
    [Post("/cgi-bin/externalcontact/groupchat/get")]
    Task<GetGroupChatDetailResponse> GetGroupChatDetailAsync(
        [Body] GetGroupChatDetailRequest request,
        CancellationToken cancellationToken = default);
}

[HttpClientApi(RegistryGroupName = "ExternalContact",
    TokenManage = nameof(IWechatAppManager),
    InheritedFrom = nameof(WechatWorkExternalContactGroupChatService))]
[Token(TokenType = WechatTokenTypes.AccessToken,
      TokenManagerKey = WechatTokenManagerKeys.InternalAccessToken,
      InjectionMode = TokenInjectionMode.Query, Name = "access_token")]
public interface IWechatWorkInternalExternalContactGroupChatService
    : IWechatWorkExternalContactGroupChatService
{
}

子接口为空标记的原因:公共端点只声明一次,注入点需要的是「应用类型 + 域」的类型化句柄。DI 只注册子接口。

第四步:DTO。 GetGroupChatDetailRequest / Response 在 DataModels/ExternalContact/GroupChat/。字段名以 [JsonPropertyName] 为准,必填与可空看 XML 注释。

第五步:注册入口。

builder.Services.AddWechatWorkServices(b => b.AddExternalContactApi());

AddExternalContactApi() 内部为 AddModule(WechatModule.ExternalContact)。16 个子族共用注册组名 "ExternalContact"。

4.3 名称解码

IWechatWorkInternalExternalContactGroupChatService
        │      │          │           │
        │      │          │           └─ 子族:GroupChat
        │      │          └───────────── 域:ExternalContact
        │      └──────────────────────── 应用类型:Internal / ThirdParty / Provider
        └─────────────────────────────── 线:Work
片段 含义
IsAbstract = true 且 RegistryGroupName 为空 公共父接口,不可注入
*Internal* / *ThirdParty* / *Provider* 可注入子接口,选错时取令牌 fail-fast
Get* / List* / Create* / Update* / Delete* 与官方动词对齐;Batch* 多为异步任务
以 Async 结尾且带 CancellationToken = default 全部异步、可取消
*Request / *Response 请求 / 响应 DTO,在 DataModels/{域}/

两处命名不一致,按目录走:

  • Interfaces/Contacts/ 的 DTO 命名空间为 Mud.Wechat.Work.DataModels.Contracts.Users(全仓唯一多一段 Contracts)。
  • Interfaces/KF/(大写 F)对应 DataModels/Kf/(小写 f)。

4.4 事实来源的优先级

优先级 源 用途
1 Tests/**/ContractGuards/ 端点数量、路由、字段名的最终裁决
2 接口 XML 注释 官方文档 URL / ID、频率上限、串行、覆盖删除、权限
3 域 README 导航、子族划分、开放面概览

README 负责「去哪」,XML 注释负责「怎么调」,守卫负责「有几个」。举例:GetUserAsync 的注释记录了两个时间点限制——2022-06-20 起新建的自建与代开发应用不再返回头像、性别、手机、邮箱等字段;2022-08-15 起「通讯录同步」新增 IP 不能再调用该接口。这类信息在方法注释里最完整。

XML 注释中的 see href 指向官方页面,document/path/{id} 的数字 ID 可直接对照官方页面版本(例如 90196 对应读取成员)。

4.5 常用检索动作

  1. 按命名空间过滤:父接口在 Mud.Wechat.Work.Interfaces,可注入接口在 Mud.Wechat.Work。企微 470 个接口中 158 个是父接口,过滤后候选减少约三分之一。
  2. 按官方路由反查:搜索 /cgi-bin/externalcontact/groupchat/get,命中即方法声明。常见前缀与域对应如下:
路由前缀 域目录
/cgi-bin/user/* Contacts/
/cgi-bin/externalcontact/* ExternalContact/
/cgi-bin/oa/* Approval/(applyevent、getapprovaldetail、oa/approval/* 模板)
/cgi-bin/kf/* KF/
/cgi-bin/wedoc/* Wedoc/
/cgi-bin/webhook/* Webhook/
  1. Ctrl+F12 看生成实现:仅在怀疑参数拼装时使用。
  2. 搜注册入口:AddHandler< 找回调注册,Add{域}Api 找模块入口,WechatModule. 看全部模块。

4.6 四种形态判断

  • 父接口有端点、子接口为空:三类应用公共面,注入任一子接口。
  • 父接口为空、仅某子接口有方法:官方只对某类应用开放(如「汇报」仅自建、「设备数据」仅第三方)。
  • 没有父接口、只有一个接口:独立端点域(企微 OpenHardware,其余六线全部),直接注入。
  • 完全搜不到:查域 README 的缺口说明。官方文档树缺少该页面、页面撤回、或仅存在于服务商专区的端点,不会凭第三方转述声明。

4.7 AI 场景的工具发现

工具面的 tools/list 直接投影「执行链白名单 ∩ 生成物契约表」,tools/call 走 WechatToolDispatcher。

services.AddWechatWorkTools(
    new WechatToolBinding(appKey, authCorpId),
    policy => policy.Enable("groupmsg.send"));

services.AddWechatWorkMcpServer(o =>
{
    o.ServerName = "my-wechat-mcp";
    o.ServerVersion = "1.0.0";
});

装配前提:工具面多数执行器按公共父接口解析 SDK(少数域直连子接口,如 Dial / Checkin / Journal),而父接口不注册 DI。宿主必须按域桥接子接口,否则该域工具在 tools/list 里有名字、调用时报「在白名单内但未注册」。写法见 5.4。


五、可运行示例

5.1 自建应用:查一个成员

appsettings.json:

{
  "WechatApps": [
    {
      "AppKey": "default",
      "AppType": "Internal",
      "CorpId": "ww-your-corp-id",
      "AgentSecret": "your-agent-secret"
    }
  ]
}

WechatApps 是数组(WechatApps:0..N),不是对象包裹。IsDefault 用于多应用时指定默认键,单应用可省略。

builder.Services.AddWechatApp(builder.Configuration, "WechatApps");
builder.Services.AddWechatWorkServices(b => b.AddContactApi());

var app = builder.Build();

app.MapGet("/api/users/{userid}", async (
    IWechatWorkInternalUsersService users,
    string userid,
    CancellationToken ct) =>
{
    try
    {
        var info = await users.GetUserAsync(userid, ct);
        return Results.Ok(info);
    }
    catch (WechatWorkException ex)
    {
        return Results.Problem(detail: ex.Message, statusCode: 502);
    }
});

实际调用会遇到的两种情况:

  • 成员不在应用可见范围内:返回非零 errcode,抛 WechatWorkException,ErrorCode 为对应业务码。
  • 字段为空:UserInfo 的部分字段取决于应用类型与授权状态。注释里已列明 2022 年之后的字段收缩范围,不要假设手机、邮箱一定存在。

5.2 第三方 / 代开发:企业作用域

public sealed class AuthCorpMemberService(
    IWechatWorkProviderUsersService users,
    IWechatAppContextSwitcher switcher,
    IWechatWorkAuthorizationService auth)
{
    public async Task<UserInfo> GetUserAsync(
        string authCorpId, string userid, CancellationToken ct)
    {
        var authorization = await auth.GetAuthorizationAsync(authCorpId, appKey: null, ct)
            ?? throw new InvalidOperationException($"企业 {authCorpId} 尚未授权。");

        using (switcher.UseCorpScope(
            appKey: "default", authCorpId, authorization.PermanentCode))
        {
            return await users.GetUserAsync(userid, ct);
        }
    }
}

约束:

  • UseCorpScope 返回 IDisposable,必须 using。退出时还原企业 → 应用两级上下文,幂等。
  • PermanentCode 从授权记录读取,不落日志。
  • 作用域内令牌键自动带 authCorpId;出作用域回到默认应用。
  • 多应用且未配 DefaultAppKey 时,appKey: null 抛 InvalidOperationException,不做隐式选择。

5.3 回调处理器

public sealed class UserSyncHandler : WechatCallbackPayloadHandler<ContactUserChangedPayload>
{
    public override string SupportedEventType => WechatCallbackEventTypes.CreateUser;

    public override Task HandleAsync(
        WechatCallbackEvent evt, ContactUserChangedPayload payload, CancellationToken ct)
    {
        // payload.Name 在未授权时为 null
        // 指纹已在分发前消费,重推会被 403;此处必须幂等
        return Task.CompletedTask;
    }
}

注册:

builder.Services.AddWechatCallback(options =>
{
    options.Apps["default"] = new WechatAppCallbackOptions
    {
        PushToken = "<回调 Token>",
        PushEncodingAESKey = "<43 位 EncodingAESKey>",
        ReceiveId = "ww-your-corp-id",
        AppType = WechatAppType.Internal,
    };
})
.AddHandler<UserSyncHandler>();

app.UseWechatWebhook();

处理器只承担业务。三点与状态码相关:

  • SupportedEventType 与载荷契约不一致时编译报 MUDCB002。
  • 客户联系族与邮箱族以族事件值为键,具体类别读 evt.ChangeType。
  • 处理器异常会被隔离记录,但返回 200 的分支不会触发重推;需要重试的场景自行写队列。

5.4 MCP server

services.AddWechatApp(config, "WechatApps");
services.AddWechatWorkServices(b => b.AddExternalContactApi());

// 工具面执行器按父接口解析,SDK 不注册父接口,需显式桥接(同一实例)
services.AddSingleton<IWechatWorkExternalContactGroupChatService>(
    sp => sp.GetRequiredService<IWechatWorkInternalExternalContactGroupChatService>());

services.AddWechatWorkTools(
    new WechatToolBinding(appKey: "default", authCorpId: null),
    policy => policy.Enable("externalcontact.groupchat.get"));

services.AddWechatWorkMcpServer(o =>
{
    o.ServerName = "my-wechat-mcp";
    o.ServerVersion = "1.0.0";
});

var app = builder.Build();
await app.Services.GetRequiredService<WechatMcpServer>()
    .RunStdioAsync(Console.In, Console.Out);

漏掉桥接的表现不是启动失败,而是 tools/list 能看到工具名、调用时报「在白名单内但未注册」。原因是执行器构造函数依赖 IWechatWorkExternalContactGroupChatService(父接口),容器无此服务,该域工具整批缺席。工具面里多数域是这种形态;Dial / Checkin / Journal 等少数域直连子接口,不需要桥接。Demos/Mud.Wechat.AI.AgentDemo 有相同注释与处置。

其余约束:工具名来自契约表而非路由;写工具默认关闭;stdio 下 stdout 仅输出协议行。

5.5 多实例部署的注册顺序

builder.Services.AddWechatRedis(builder.Configuration);   // 必须最先
builder.Services.AddWechatApp(builder.Configuration, "WechatApps");
builder.Services.AddWechatCallback(options => { /* ... */ });

顺序颠倒会在注册期抛异常,不会静默退化。宿主也可以前置注册自定义分布式实现,TryAdd 语义保证宿主实现胜出。


六、常见误区的现象与处置

现象 根因 处置
运行时 InvalidOperationException: No service for type 'Mud.Wechat.Work.Interfaces.IWechatWorkUsersService' has been registered 注入了父接口 改注入 *Internal* / *ThirdParty* / *Provider* 子接口
工具在 tools/list 中可见,调用报未注册 工具面按父接口解析,父接口未桥接 按域 AddSingleton<父接口>(sp => sp.GetRequiredService<子接口>())
启动即抛异常,提示注册顺序 AddWechatRedis 在 AddWechatApp 之后 调整顺序为 Redis → App → Callback
改了 DTO,重新构建后改动消失 改的是 DataModels/Generated/*.g.cs 改源 DTO,重跑生成脚本
配置绑定编译失败 CS9035 配置 DTO 写了 required 移除 required,校验写 Validate()
AOT 构建报 IL2026 / IL3050 业务代码调用了反射版 JsonSerializer.Serialize<T> 改用 JsonContext 或类型化 JsonTypeInfo
把 AddPayApi() 当成微信支付 企微主包的 AddPayApi() 是企业支付(对外收款) 商户支付用 Mud.Wechat.Pay,RSA 签名,无 access_token
小程序令牌与公众号不一致 误以为需要单独配置 两条线共用 /cgi-bin/token 与同一令牌类型
回调包单独 dotnet pack 后内嵌分析器不一致 单包打包绕过全量推导路径 走 pack.bat / publish.bat / CI
回调处理器没执行,日志无异常 事件键不匹配(SupportedEventType 与实际键不同) 对照载荷契约的 [WechatCallbackContract] 事件键
日志里出现明文事件内容 打印了 DecryptedXml / SuiteTicket / AuthCode 移除这些字段的日志与遥测输出

七、选型、上手与升级

7.1 按场景选包

场景 需要安装
企业微信调接口 Mud.Wechat.Work
企业微信收回调 / 智能机器人 Mud.Wechat.Work + Mud.Wechat.Work.Callback
公众号调接口 Mud.Wechat.OfficialAccount
公众号收推送 Mud.Wechat.OfficialAccount + Mud.Wechat.OfficialAccount.Callback
小程序 Mud.Wechat.MiniProgram(传递依赖公众号主包)
支付 APIv3 / v2 Mud.Wechat.Pay
支付回调 Mud.Wechat.Pay + Mud.Wechat.Pay.Callback
开放平台 Mud.Wechat.OpenPlatform
小店 / 视频号 Mud.Wechat.Channels(+ .Callback)
腾讯广告 Mud.Wechat.Ads
多实例部署 Mud.Wechat.Redis
指标与追踪 Mud.Wechat.OpenTelemetry
Agent / MCP Mud.Wechat.Work.AI / .Tools / .Mcp(依赖 Mud.Wechat.AI)

7.2 Demo 索引

验证目标 Demo
调接口的最小闭环 Demos/Mud.Wechat.Work.ContactDemo
回调与处理器注册 Demos/Mud.Wechat.Work.ContactCallbackDemo
七线共存 Demos/Mud.Wechat.AllInOne.Demo
开放平台授权链路 Demos/Mud.Wechat.OpenPlatform.Demo
公众号文章与素材 Demos/Mud.Wechat.OfficialAccount.ArticleDemo
Agent 接工具面 Demos/Mud.Wechat.AI.AgentDemo

Demo 的凭据从 appsettings.json 或用户机密读取,不要提交真实 secret。

7.3 阅读路径

  • 只调接口:2.2 → 3.1 / 3.3 → 第四章 → 5.1。跑通第一个调用的最短路径。
  • 做多租户 / 服务商:3.4 → 5.2 → 4.6。重点在接口形态分布与 UseCorpScope 生命周期。
  • 做 AI / MCP:3.6 / 3.8 → 4.7 → 5.4。重点在 AOT 约束、工具白名单与父接口桥接。

7.4 升级注意事项

  • 版本唯一来源是 Directory.Build.props。 包版本不逐工程维护。
  • 同版本重打包不会失效 NuGet 缓存。 全局包缓存按 id + version 计价,内容变了但版本没变时下游仍用旧位。处置顺序:删 <globalPackages>/{id}/{ver} → dotnet restore --no-cache → dotnet clean → dotnet build。跳过 clean 会出现新旧程序集并置,运行期表现为 TypeLoadException。
  • Mud.HttpUtils 全仓单一版本。 混版会导致 TypeLoadException,守卫 G1 断言版本集合大小为 1。
  • 回调宿主包不要单独打包。 四个 Callback 包以相对路径内嵌分析器,单独打包会绕过全量推导。

八、取舍总结

这套结构的核心取舍是:用固定的目录与命名约定,换取协议层代码的消除与契约漂移的编译期暴露。代价有两项:需要接受接口命名规则(4.3),以及它不承担的部分(前端 JS-SDK、业务流程编排、本地缓存)。

定位一个新接口的固定动作:确定线 → 确定应用类型 → 查域 README → 打开接口看 [Get] / [Post] → 看 DataModels/ 字段 → 找 Add{域}Api()。通常六步内可定位到已声明端点;找不到时按 4.6 判断是哪种形态。

源码:GitHub | Gitee(MIT 协议)

欢迎添加我的微信公众号
qrcode_for_gh_acad905b0171_258

posted @ 2026-11-03 10:17  玩泥巴的|mudtools.cn  阅读(144)  评论(0)    收藏  举报