用 Rust 写了个微信公众号 SDK,聊聊过程中踩的坑

用 Rust 写了个微信公众号 SDK,聊聊过程中踩的坑

2026年2月2日
rust
开发日记
技术

用 Rust 写了个微信公众号 SDK,聊聊过程中踩的坑

最近用 Rust 撸了一个微信公众号的 API SDK,目的是方便以后在其他项目里直接调用公众号的各种接口。本来以为就是封装一堆 HTTP 请求的体力活,没想到过程中还是踩了不少坑。记录一下,希望对后来人有帮助。

为什么用 Rust?

其实微信公众号的 SDK,Python 和 Java 社区早就有很成熟的了。但我其他项目用的是 Rust,每次要调公众号接口都得手动拼请求,太烦了。Rust 社区这块确实比较空白,干脆自己造一个轮子。

技术栈选的是 reqwest + tokio 做异步 HTTP,serde 做序列化。看起来很标准是吧?坑就出在序列化上面。

坑一:quick-xml 不支持 serde 的 flatten

微信的消息回调用的是 XML 格式(对,2026 年了还在用 XML),所以需要一个 XML 解析库。我选了 quick-xml,它有 serde 支持,看起来很美好。

一开始我的消息模型是这样设计的:

#[derive(Deserialize)]
struct MessageBase {
    to_user_name: String,
    from_user_name: String,
    create_time: i64,
    msg_id: Option<i64>,
}

#[derive(Deserialize)]
struct TextMessage {
    #[serde(flatten)]
    base: MessageBase,  // 复用公共字段
    content: String,
}

#[serde(flatten)] 把公共字段抽出来,代码多优雅啊。

结果一跑测试直接炸了:

Custom("invalid type: map, expected a string")

查了半天才搞明白,quick-xml 的 serde 实现不支持 flatten。这是因为 XML 和 JSON 的数据模型不同,flatten 需要把结构体当成 map 来处理,XML 解析器做不到。

解决办法:老老实实把公共字段复制到每个消息结构里。代码是丑了点,但能跑。

#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct TextMessage {
    to_user_name: String,
    from_user_name: String,
    create_time: i64,
    msg_id: Option<i64>,
    content: String,
}

有点蠢但是管用。这算是用 Rust 做 XML 处理的一个经典坑了。

坑二:微信的 XML 字段命名鬼才

微信的消息格式大部分字段是 PascalCase 的,比如 ToUserNameFromUserName,用 #[serde(rename_all = "PascalCase")] 就能搞定。

但是!地理位置消息的字段叫 Location_XLocation_Y——带下划线的!这既不是 PascalCase 也不是 snake_case,是个混合体。

rename_all = "PascalCase" 会把 location_x 映射成 LocationX,但实际 XML 里是 Location_X,直接就解析失败了。

解决办法:对这俩字段单独加 rename:

#[serde(rename = "Location_X")]
pub location_x: f64,
#[serde(rename = "Location_Y")]
pub location_y: f64,

小坑,但排查起来挺浪费时间的,因为报错信息只会说 "missing field",不会告诉你名字没对上。

坑三:Access Token 的并发刷新问题

微信的 Access Token 有效期 2 小时,过期了要重新获取。一开始想得很简单:过期了就重新请求呗。

但异步场景下有个问题:如果同时有 10 个请求发现 token 过期了,会不会同时发 10 个刷新请求?这不光浪费,微信还有频率限制,搞不好就被封了。

最后的方案是用 tokio::sync::RwLock,读的时候用读锁(不阻塞),需要刷新时升级到写锁。拿到写锁之后还要再检查一次(double-check),因为可能别的协程已经刷新过了:

pub async fn get_token(&self) -> Result<String> {
    // 快速路径:读锁检查
    {
        let state = self.state.read().await;
        if let Some(ref s) = *state {
            if Instant::now() < s.expires_at {
                return Ok(s.access_token.clone());
            }
        }
    }
    // 慢速路径:写锁刷新
    let mut state = self.state.write().await;
    // Double-check!
    if let Some(ref s) = *state {
        if Instant::now() < s.expires_at {
            return Ok(s.access_token.clone());
        }
    }
    // 真正去刷新
    let new_state = self.fetch_token().await?;
    // ...
}

另外还加了提前 5 分钟刷新的 buffer,避免正好卡在过期边界上。

坑四:两个 "Token" 傻傻分不清

微信公众号开发里有两个东西都叫 Token,新手很容易搞混:

  1. Access Token - 调用 API 的凭证,通过 AppID + AppSecret 从接口获取,有效期 2 小时,SDK 自动管理
  2. 服务器配置 Token - 在公众号后台设置的一个自定义字符串,用于验证消息回调的签名

第一个是 SDK 内部自动获取的,你不用管。第二个是你自己随便定义的,需要在代码和公众号后台保持一致。

一开始我把 Config 的 token 字段改成了 Optional,觉得不接收消息的时候不需要配。后来想想还是改回了必填——既然是 SDK,就应该覆盖完整场景,不然用户想接消息了还得改代码。

坑五:IP 白名单

第一次跑集成测试的时候,返回了这个错误:

invalid ip 39.155.78.146, not in whitelist

微信要求调用 API 的服务器 IP 必须加到白名单里。这个在本地开发的时候容易忘记。去公众号后台 → 设置与开发 → 基本配置 → IP 白名单里把你的公网 IP 加上就行。

如果你在公司内网,记得加的是出口 IP,不是内网 IP。

坑六:公众号类型和权限

集成测试跑起来之后,8 个测试只过了 5 个,另外 3 个返回 48001: api unauthorized

排查了半天以为是代码的问题,结果是公众号类型的问题。微信的权限体系大概是这样的:

  • 未认证订阅号:只能用基础接口(token、素材、草稿)
  • 认证订阅号:可以用用户管理、发布文章等
  • 服务号:权限最全,包括支付、网页授权等

所以如果你测试某些接口报 48001,先看看自己的号是什么类型,别在代码上浪费时间。

坑七:发布文章的两种方式

微信发布文章有两个不同的接口,搞清楚区别很重要:

  1. freepublish(发布):文章发布后生成一个链接,但不会推送给粉丝,也不会出现在历史消息里。适合做菜单跳转、分享链接。
  2. mass send(群发):文章会推送到粉丝的消息列表里,有次数限制。

而且不管用哪种方式,都要先把文章存成草稿,再拿草稿的 media_id 去发布或群发。不能直接发布内容,这个流程一开始没搞清楚走了弯路。

关于 reqwest 的 Response 只能消费一次

这是个 Rust 所有权的经典问题。下载素材的时候我先检查 content-type 是不是 JSON(可能是错误响应),如果是就读 text 检查错误,否则读 bytes 返回文件内容:

let resp = self.http.get(&url).send().await?;
if content_type.contains("json") {
    let text = resp.text().await?;  // resp 被 move 了
    // 检查错误...
}
Ok(resp.bytes().await?.to_vec())  // 编译报错!resp 已经被消费了

resp.text() 会消费掉 response,之后就不能再调 resp.bytes() 了。

解决办法:先统一读成 bytes,再根据需要转成字符串检查:

let bytes = resp.bytes().await?.to_vec();
if content_type.contains("json") {
    if let Ok(text) = std::str::from_utf8(&bytes) {
        // 检查是不是错误响应
    }
}
Ok(bytes)

最后

整个 SDK 大概 26 个文件,覆盖了公众号开发常用的所有接口。写下来最大的感受是:微信的接口设计确实有历史包袱,XML 和 JSON 混用、命名风格不统一、文档和实际行为有出入,这些都增加了封装的复杂度。

但好处是 Rust 的类型系统在这种场景下特别有价值——编译通过之后基本就能跑,不用担心运行时突然冒出来的类型错误。

项目开源在 GitHub:WeChatOA_SDK

如果你也在用 Rust 做微信开发,欢迎试用和反馈。