常见的 .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 自愈在两条线之间失效。
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>。
三、用户视角的能力与约束
3.1 注册与调用
csharp
// Program.cs
builder.Services.AddWechatApp(builder.Configuration, "WechatApps");
builder.Services.AddWechatWorkServices(b => b
.AddContactApi()
.AddExternalContactApi());
csharp
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为域名。
下面这行在企微线解析失败:
csharp
var users = provider.GetRequiredService<IWechatWorkUsersService>(); // 父接口,未注册
按应用类型取子接口:
csharp
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 工具面),在宿主侧写:
csharp
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:
csharp
try
{
var user = await users.GetUserAsync(userid, ct);
}
catch (WechatWorkException ex) when (ex.ErrorCode == 42001)
{
// 自动恢复未覆盖的场景(例如并发刷新失败),按业务决定是否重试
}
RequestUri 在构造时已剥离 query 与 userinfo,写入日志不会带出 access_token。
3.4 多应用与多租户
| 应用类型 | WechatAppType |
令牌链 |
|---|---|---|
| 自建 | Internal |
access_token(corpid + corpsecret) |
| 第三方 | ThirdParty |
provider_access_token + suite_access_token + 每授权企业 access_token |
| 服务商代开发 | Provider |
同上;企业令牌走 gettoken(corpsecret = permanent_code) |
第三方与代开发的调用需要落到具体授权企业:
csharp
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 校验 → 一次性指纹 → 事件信封解析 → 分发。
csharp
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。处理器必须幂等。
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 收益。
配合规则:
- 自定义 DTO 标
[HttpJsonSerializable]后重跑生成脚本;序列化走JsonTypeInfo。 - 配置 DTO 不写
required(源生成绑定以new T()构造,会报 CS9035),校验放Validate()。 - 多态解析显式声明
[JsonDerivedType]并登记 JsonContext。 - 低 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。
csharp
[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 注释。
第五步:注册入口。
csharp
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 常用检索动作
- 按命名空间过滤 :父接口在
Mud.Wechat.Work.Interfaces,可注入接口在Mud.Wechat.Work。企微 470 个接口中 158 个是父接口,过滤后候选减少约三分之一。 - 按官方路由反查 :搜索
/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/ |
Ctrl+F12看生成实现:仅在怀疑参数拼装时使用。- 搜注册入口 :
AddHandler<找回调注册,Add{域}Api找模块入口,WechatModule.看全部模块。
4.6 四种形态判断
- 父接口有端点、子接口为空:三类应用公共面,注入任一子接口。
- 父接口为空、仅某子接口有方法:官方只对某类应用开放(如「汇报」仅自建、「设备数据」仅第三方)。
- 没有父接口、只有一个接口 :独立端点域(企微
OpenHardware,其余六线全部),直接注入。 - 完全搜不到:查域 README 的缺口说明。官方文档树缺少该页面、页面撤回、或仅存在于服务商专区的端点,不会凭第三方转述声明。
4.7 AI 场景的工具发现
工具面的 tools/list 直接投影「执行链白名单 ∩ 生成物契约表」,tools/call 走 WechatToolDispatcher。
csharp
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:
json
{
"WechatApps": [
{
"AppKey": "default",
"AppType": "Internal",
"CorpId": "ww-your-corp-id",
"AgentSecret": "your-agent-secret"
}
]
}
WechatApps 是数组(WechatApps:0..N),不是对象包裹。IsDefault 用于多应用时指定默认键,单应用可省略。
csharp
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 第三方 / 代开发:企业作用域
csharp
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 回调处理器
csharp
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;
}
}
注册:
csharp
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
csharp
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 多实例部署的注册顺序
csharp
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 判断是哪种形态。
欢迎添加我的微信公众号