Skip to main content

sz_rust_auth_facade/
wechat.rs

1//! Wechat 模块 — 微信 SDK 抽象层(对齐 PHP `EasyWeChat`)
2//!
3//! 提供统一的微信 SDK 抽象,支持公众号 / 小程序 / 开放平台 / 企业微信,
4//! 对齐 PHP `overtrue/wechat` 与 `EasyWeChat` 的核心 API。
5//!
6//! ## PHP 对齐
7//!
8//! ### 核心 API 映射
9//!
10//! | PHP 方法 | Rust 方法 | 说明 |
11//! |---------|-----------|------|
12//! | `Factory::officialAccount()` | [`WechatSdk::new`] | 创建 SDK 实例 |
13//! | `EasyWeChat::officialAccount()->oauth->redirect()` | [`WechatSdk::get_authorize_url`] | 构造 OAuth2 授权 URL |
14//! | `EasyWeChat::officialAccount()->oauth->userFromCode()` | [`WechatSdk::get_user_by_code`] | 通过 code 换取用户 |
15//! | `EasyWeChat::officialAccount()->user->get()` | [`WechatSdk::get_user_info`] | 获取用户信息 |
16//! | `EasyWeChat::officialAccount()->server->verifySignature()` | [`WechatSdk::verify_signature`] | 验证签名 |
17//! | `EasyWeChat::officialAccount()->template_message->send()` | [`WechatSdk::send_template_message`] | 发送模板消息 |
18//! | `EasyWeChat::officialAccount()->jssdk->getSignature()` | [`WechatSdk::generate_jsapi_signature`] | 生成 JS-SDK 签名 |
19//! | `EasyWeChat::officialAccount()->qrcode->forever()` | [`WechatSdk::get_qrcode_url`] | 获取带参数二维码 URL |
20//!
21//! ### PHP 行为对齐
22//!
23//! - **统一应用类型**:PHP `EasyWeChat\Factory::officialAccount()` / `miniProgram()` /
24//!   `openPlatform()` / `work()` 创建不同应用。Rust 通过 [`WechatAppType`] 表达。
25//! - **统一配置**:PHP 通过 config 数组传递 `app_id` / `secret` / `token` / `aes_key`。
26//!   Rust 通过 [`WechatConfig`] builder 表达。
27//! - **JS-SDK 签名**:PHP `EasyWeChat\Kernel\Support\JsSign` 生成签名。
28//!   Rust 通过 [`WechatSdk::generate_jsapi_signature`] 实现,使用 SHA1 算法。
29//!
30//! ## 架构说明
31//!
32//! - **WechatHttpTransport trait**:HTTP 传输抽象,解耦 SDK 与具体 HTTP 库
33//! - **MemoryWechatHttpTransport**:内存 HTTP 传输实现,支持预置响应队列,用于测试
34//! - **WechatSdk**:统一 SDK 客户端,通过注入的 [`WechatHttpTransport`] 调用微信 API
35//! - **签名算法**:使用 `sha1` crate 实现 `verify_signature` 与 `generate_jsapi_signature`
36
37use parking_lot::Mutex;
38use sha1::Digest;
39use std::collections::VecDeque;
40use std::sync::Arc;
41use thiserror::Error;
42
43// ============================================================================
44// 错误类型
45// ============================================================================
46
47/// 微信 SDK 错误 — 对齐 PHP `EasyWeChat` 异常体系
48#[derive(Debug, Error)]
49pub enum WechatError {
50    /// 配置错误(app_id / app_secret 为空等)
51    #[error("微信配置错误: {0}")]
52    Config(String),
53    /// 缺少必填字段(code / openid / touser 等)
54    #[error("微信字段缺失: {0}")]
55    MissingField(String),
56    /// API 调用失败(errcode != 0)
57    #[error("微信 API 调用失败: {0}")]
58    ApiFailed(String),
59    /// HTTP 传输失败(网络错误、连接超时等)
60    #[error("微信 HTTP 传输失败: {0}")]
61    HttpTransport(String),
62    /// 序列化/反序列化失败
63    #[error("微信序列化失败: {0}")]
64    Serialize(String),
65    /// access_token 获取失败
66    #[error("微信 access_token 获取失败: {0}")]
67    TokenFailed(String),
68    /// 解密失败(消息加解密)
69    #[error("微信解密失败: {0}")]
70    DecryptFailed(String),
71}
72
73// ============================================================================
74// WechatAppType
75// ============================================================================
76
77/// 微信应用类型 — 对齐 PHP `EasyWeChat` 的应用类型
78///
79/// 对齐 `EasyWeChat\Factory::officialAccount()` / `miniProgram()` /
80/// `openPlatform()` / `work()` 四种应用工厂方法。
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum WechatAppType {
83    /// 公众号(对齐 `Factory::officialAccount()`)
84    OfficialAccount,
85    /// 小程序(对齐 `Factory::miniProgram()`)
86    MiniProgram,
87    /// 开放平台(对齐 `Factory::openPlatform()`)
88    OpenPlatform,
89    /// 企业微信(对齐 `Factory::work()`)
90    Work,
91}
92
93// ============================================================================
94// WechatConfig
95// ============================================================================
96
97/// 微信配置 — 对齐 PHP `EasyWeChat\Factory::officialAccount()` 配置
98///
99/// 使用 Builder 模式构建,必填字段在 [`WechatConfig::new`] 中提供,
100/// 可选字段通过 `with_*` 链式方法追加。
101///
102/// # PHP 对齐
103///
104/// ```php
105/// // PHP EasyWeChat
106/// $config = [
107///     'app_id'  => 'wx-app-id',
108///     'secret'  => 'wx-app-secret',
109///     'token'   => 'wx-token',
110///     'aes_key' => 'wx-aes-key',
111///     'oauth'   => ['redirect_uri' => 'https://example.com/oauth/callback'],
112/// ];
113/// $app = Factory::officialAccount($config);
114/// ```
115///
116/// # Rust 用法
117///
118/// ```ignore
119/// use sz_rust_auth_facade::wechat::{WechatAppType, WechatConfig};
120///
121/// let config = WechatConfig::new(
122///     WechatAppType::OfficialAccount,
123///     "wx-app-id",
124///     "wx-app-secret",
125/// )
126/// .with_token("wx-token")
127/// .with_encoding_aes_key("wx-aes-key")
128/// .with_oauth_redirect_uri("https://example.com/oauth/callback");
129/// ```
130#[derive(Debug, Clone)]
131pub struct WechatConfig {
132    /// 应用类型
133    pub app_type: WechatAppType,
134    /// AppID
135    pub app_id: String,
136    /// AppSecret
137    pub app_secret: String,
138    /// Token(用于消息验证)
139    pub token: Option<String>,
140    /// EncodingAESKey(用于消息加解密)
141    pub encoding_aes_key: Option<String>,
142    /// OAuth2 回调 URL
143    pub oauth_redirect_uri: Option<String>,
144}
145
146impl WechatConfig {
147    /// 创建微信配置
148    ///
149    /// # 参数
150    ///
151    /// - `app_type`: 应用类型
152    /// - `app_id`: AppID
153    /// - `app_secret`: AppSecret
154    pub fn new(
155        app_type: WechatAppType,
156        app_id: impl Into<String>,
157        app_secret: impl Into<String>,
158    ) -> Self {
159        Self {
160            app_type,
161            app_id: app_id.into(),
162            app_secret: app_secret.into(),
163            token: None,
164            encoding_aes_key: None,
165            oauth_redirect_uri: None,
166        }
167    }
168
169    /// 设置消息验证 Token
170    pub fn with_token(mut self, token: impl Into<String>) -> Self {
171        self.token = Some(token.into());
172        self
173    }
174
175    /// 设置 EncodingAESKey(消息加解密)
176    pub fn with_encoding_aes_key(mut self, key: impl Into<String>) -> Self {
177        self.encoding_aes_key = Some(key.into());
178        self
179    }
180
181    /// 设置 OAuth2 回调 URL
182    pub fn with_oauth_redirect_uri(mut self, uri: impl Into<String>) -> Self {
183        self.oauth_redirect_uri = Some(uri.into());
184        self
185    }
186
187    /// 校验配置必填字段
188    ///
189    /// 必填字段:`app_id` / `app_secret`。任一为空字符串则返回 [`WechatError::Config`]。
190    pub fn validate(&self) -> Result<(), WechatError> {
191        if self.app_id.is_empty() {
192            return Err(WechatError::Config("app_id".into()));
193        }
194        if self.app_secret.is_empty() {
195            return Err(WechatError::Config("app_secret".into()));
196        }
197        Ok(())
198    }
199}
200
201// ============================================================================
202// JsApiTicket
203// ============================================================================
204
205/// JS-SDK 签名类型 — 对齐 PHP `EasyWeChat\Kernel\Support\JsSign`
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum JsApiTicket {
208    /// jsapi(用于 JS-SDK 签名)
209    JsApi,
210    /// wx_card(用于微信卡券签名)
211    WxCard,
212}
213
214// ============================================================================
215// WechatUser
216// ============================================================================
217
218/// 微信用户信息 — 对齐 PHP `EasyWeChat\User\User::get()`
219///
220/// 表示从微信 API 获取的用户信息,包含标准字段和原始响应数据。
221///
222/// # PHP 对齐
223///
224/// ```php
225/// $user = $app->user->get($openid);
226/// $user->openid;       // 用户 openid
227/// $user->nickname;     // 昵称
228/// $user->sex;          // 性别(0未知/1男/2女)
229/// $user->province;     // 省份
230/// $user->city;         // 城市
231/// $user->country;      // 国家
232/// $user->headimgurl;   // 头像 URL
233/// $user->privilege;    // 特权信息
234/// $user->unionid;      // unionid(需绑定开放平台)
235/// ```
236#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
237pub struct WechatUser {
238    /// 用户 openid
239    pub openid: String,
240    /// 用户昵称
241    pub nickname: Option<String>,
242    /// 性别(0未知/1男/2女)
243    pub sex: Option<i32>,
244    /// 省份
245    pub province: Option<String>,
246    /// 城市
247    pub city: Option<String>,
248    /// 国家
249    pub country: Option<String>,
250    /// 头像 URL
251    pub headimgurl: Option<String>,
252    /// 特权信息
253    pub privilege: Option<Vec<String>>,
254    /// unionid(需绑定开放平台)
255    pub unionid: Option<String>,
256    /// 原始数据
257    pub raw: serde_json::Value,
258}
259
260// ============================================================================
261// WechatHttpTransport trait
262// ============================================================================
263
264/// 微信 HTTP 传输 trait — 用于解耦 SDK 与具体 HTTP 库
265///
266/// 微信 SDK 需要 GET 和 POST(JSON / Form)调用并返回响应体字符串,
267/// 以便 SDK 解析 JSON 响应。业务方实现此 trait 注入 reqwest / hyper / etc.。
268///
269/// # 线程安全
270///
271/// 实现者必须保证 `Send + Sync`,因为 [`WechatSdk`] 通常作为单例在多线程下使用。
272pub trait WechatHttpTransport: Send + Sync {
273    /// GET 请求
274    ///
275    /// # 参数
276    ///
277    /// - `url`: 目标 URL
278    ///
279    /// # 返回
280    ///
281    /// 成功返回响应体字符串,失败返回 [`WechatError`]。
282    fn get(&self, url: &str) -> Result<String, WechatError>;
283
284    /// POST JSON 请求
285    ///
286    /// # 参数
287    ///
288    /// - `url`: 目标 URL
289    /// - `body`: 请求体(JSON 字符串)
290    ///
291    /// # 返回
292    ///
293    /// 成功返回响应体字符串,失败返回 [`WechatError`]。
294    fn post_json(&self, url: &str, body: &str) -> Result<String, WechatError>;
295
296    /// POST Form 请求
297    ///
298    /// # 参数
299    ///
300    /// - `url`: 目标 URL
301    /// - `body`: 请求体(Form 字符串)
302    ///
303    /// # 返回
304    ///
305    /// 成功返回响应体字符串,失败返回 [`WechatError`]。
306    fn post_form(&self, url: &str, body: &str) -> Result<String, WechatError>;
307}
308
309// ============================================================================
310// MemoryWechatHttpTransport(测试/开发用 HTTP 传输实现)
311// ============================================================================
312
313/// 内存微信 HTTP 传输 — 用于测试和开发环境
314///
315/// 不实际发送 HTTP 请求,而是:
316/// - 将请求暂存到内部 Vec,供测试断言使用
317/// - 从预置响应队列中依次返回 mock 响应(FIFO)
318///
319/// # 用法
320///
321/// ```ignore
322/// use std::sync::Arc;
323/// use sz_rust_auth_facade::wechat::{MemoryWechatHttpTransport, WechatHttpTransport};
324///
325/// let transport = MemoryWechatHttpTransport::new();
326/// transport.push_response(r#"{"access_token":"token123"}"#);
327/// transport.push_response(r#"{"errcode":0}"#);
328///
329/// let resp = transport.get("https://api.weixin.qq.com/token").unwrap();
330/// assert_eq!(resp, r#"{"access_token":"token123"}"#);
331/// ```
332#[derive(Debug, Default)]
333pub struct MemoryWechatHttpTransport {
334    /// 预置的 mock 响应队列(FIFO)
335    responses: Mutex<VecDeque<String>>,
336    /// 已"发送"的 HTTP 请求记录(method, url, body)
337    requests: Mutex<Vec<(String, String, String)>>,
338}
339
340impl MemoryWechatHttpTransport {
341    /// 创建新的内存 HTTP 传输
342    pub fn new() -> Self {
343        Self::default()
344    }
345
346    /// 预置 mock 响应(追加到队列末尾,按调用顺序消费)
347    pub fn push_response(&self, response: impl Into<String>) {
348        self.responses.lock().push_back(response.into());
349    }
350
351    /// 获取已发送请求数量
352    pub fn request_count(&self) -> usize {
353        self.requests.lock().len()
354    }
355
356    /// 获取所有已发送请求(快照)
357    ///
358    /// 每条记录为 `(method, url, body)` 三元组。
359    pub fn requests(&self) -> Vec<(String, String, String)> {
360        self.requests.lock().clone()
361    }
362
363    /// 清空预置响应和请求记录
364    pub fn clear(&self) {
365        self.responses.lock().clear();
366        self.requests.lock().clear();
367    }
368
369    /// 从队列头部取出下一条响应;队列空时返回错误
370    fn next_response(&self) -> Result<String, WechatError> {
371        match self.responses.lock().pop_front() {
372            Some(resp) => Ok(resp),
373            None => Err(WechatError::HttpTransport("无可用预置响应".into())),
374        }
375    }
376}
377
378impl WechatHttpTransport for MemoryWechatHttpTransport {
379    fn get(&self, url: &str) -> Result<String, WechatError> {
380        let response = self.next_response()?;
381        self.requests
382            .lock()
383            .push(("GET".to_string(), url.to_string(), String::new()));
384        Ok(response)
385    }
386
387    fn post_json(&self, url: &str, body: &str) -> Result<String, WechatError> {
388        let response = self.next_response()?;
389        self.requests
390            .lock()
391            .push(("POST_JSON".to_string(), url.to_string(), body.to_string()));
392        Ok(response)
393    }
394
395    fn post_form(&self, url: &str, body: &str) -> Result<String, WechatError> {
396        let response = self.next_response()?;
397        self.requests
398            .lock()
399            .push(("POST_FORM".to_string(), url.to_string(), body.to_string()));
400        Ok(response)
401    }
402}
403
404// ============================================================================
405// WechatSdk
406// ============================================================================
407
408/// 微信 SDK 客户端 — 对齐 PHP `EasyWeChat\Factory::officialAccount()`
409///
410/// 接收 [`WechatConfig`] 和 [`WechatHttpTransport`],提供 OAuth2 / 用户 / 模板消息 /
411/// JS-SDK 签名 / 二维码等核心 API。
412///
413/// # 用法
414///
415/// ```ignore
416/// use std::sync::Arc;
417/// use sz_rust_auth_facade::wechat::{
418///     MemoryWechatHttpTransport, WechatAppType, WechatConfig, WechatSdk,
419/// };
420///
421/// let config = WechatConfig::new(
422///     WechatAppType::OfficialAccount,
423///     "wx-app-id",
424///     "wx-app-secret",
425/// )
426/// .with_oauth_redirect_uri("https://example.com/oauth/callback");
427///
428/// let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
429///
430/// let url = sdk.get_authorize_url("snsapi_userinfo", "state123");
431/// ```
432pub struct WechatSdk {
433    /// 微信配置
434    config: WechatConfig,
435    /// HTTP 传输实现
436    transport: Arc<dyn WechatHttpTransport>,
437}
438
439impl WechatSdk {
440    /// 创建微信 SDK 客户端
441    ///
442    /// # 参数
443    ///
444    /// - `config`: 微信配置
445    /// - `transport`: HTTP 传输实现(业务方注入 reqwest / hyper / etc.)
446    pub fn new(config: WechatConfig, transport: Arc<dyn WechatHttpTransport>) -> Self {
447        Self { config, transport }
448    }
449
450    /// 获取配置引用
451    pub fn config(&self) -> &WechatConfig {
452        &self.config
453    }
454
455    /// 构造 OAuth2 授权 URL — 对齐 `EasyWeChat::officialAccount()->oauth->redirect()`
456    ///
457    /// 拼接 `https://open.weixin.qq.com/connect/oauth2/authorize` 与查询参数:
458    /// - `appid`
459    /// - `redirect_uri`(来自 `oauth_redirect_uri`,未配置时为空字符串)
460    /// - `response_type=code`
461    /// - `scope`
462    /// - `state`
463    ///
464    /// URL 末尾追加 `#wechat_redirect` 锚点。
465    pub fn get_authorize_url(&self, scope: &str, state: &str) -> String {
466        let redirect_uri = self.config.oauth_redirect_uri.as_deref().unwrap_or("");
467        format!(
468            "https://open.weixin.qq.com/connect/oauth2/authorize?appid={}&redirect_uri={}&response_type=code&scope={}&state={}#wechat_redirect",
469            percent_encode(&self.config.app_id),
470            percent_encode(redirect_uri),
471            percent_encode(scope),
472            percent_encode(state),
473        )
474    }
475
476    /// 通过 code 换取用户 — 对齐 `EasyWeChat::officialAccount()->oauth->userFromCode()`
477    ///
478    /// 工作流程:
479    /// 1. GET `https://api.weixin.qq.com/sns/oauth2/access_token` 用 code 换取 access_token + openid
480    /// 2. 调用 [`WechatSdk::get_user_info`] 获取用户信息
481    ///
482    /// # 参数
483    ///
484    /// - `code`: OAuth2 回调携带的授权码
485    ///
486    /// # 返回
487    ///
488    /// 成功返回 [`WechatUser`],失败返回 [`WechatError`]。
489    pub fn get_user_by_code(&self, code: &str) -> Result<WechatUser, WechatError> {
490        self.config.validate()?;
491        if code.is_empty() {
492            return Err(WechatError::MissingField("code".into()));
493        }
494
495        // 1. 用 code 换取 access_token + openid
496        let url = format!(
497            "https://api.weixin.qq.com/sns/oauth2/access_token?appid={}&secret={}&code={}&grant_type=authorization_code",
498            percent_encode(&self.config.app_id),
499            percent_encode(&self.config.app_secret),
500            percent_encode(code),
501        );
502        let response = self.transport.get(&url)?;
503        let token_json: serde_json::Value = serde_json::from_str(&response)
504            .map_err(|err| WechatError::Serialize(format!("解析 token 响应失败: {err}")))?;
505
506        if let Some(errcode) = token_json.get("errcode").and_then(|v| v.as_i64()) {
507            if errcode != 0 {
508                return Err(WechatError::ApiFailed(format!(
509                    "code 换取 token 失败: errcode={errcode}"
510                )));
511            }
512        }
513
514        let access_token = token_json
515            .get("access_token")
516            .and_then(|v| v.as_str())
517            .ok_or_else(|| WechatError::TokenFailed("响应缺少 access_token".into()))?
518            .to_string();
519        let openid = token_json
520            .get("openid")
521            .and_then(|v| v.as_str())
522            .ok_or_else(|| WechatError::MissingField("openid".into()))?
523            .to_string();
524
525        // 2. 用 access_token + openid 获取用户信息
526        self.get_user_info(&openid, &access_token)
527    }
528
529    /// 获取用户信息 — 对齐 `EasyWeChat::officialAccount()->user->get()`
530    ///
531    /// GET `https://api.weixin.qq.com/sns/userinfo?access_token=xxx&openid=xxx`,
532    /// 解析 JSON 响应为 [`WechatUser`]。
533    ///
534    /// # 参数
535    ///
536    /// - `openid`: 用户 openid
537    /// - `access_token`: OAuth2 access_token(来自 `sns/oauth2/access_token`)
538    ///
539    /// # 返回
540    ///
541    /// 成功返回 [`WechatUser`],失败返回 [`WechatError`]。
542    pub fn get_user_info(
543        &self,
544        openid: &str,
545        access_token: &str,
546    ) -> Result<WechatUser, WechatError> {
547        self.config.validate()?;
548        if openid.is_empty() {
549            return Err(WechatError::MissingField("openid".into()));
550        }
551        if access_token.is_empty() {
552            return Err(WechatError::MissingField("access_token".into()));
553        }
554
555        let url = format!(
556            "https://api.weixin.qq.com/sns/userinfo?access_token={}&openid={}",
557            percent_encode(access_token),
558            percent_encode(openid),
559        );
560        let response = self.transport.get(&url)?;
561        let json: serde_json::Value = serde_json::from_str(&response)
562            .map_err(|err| WechatError::Serialize(format!("解析用户信息响应失败: {err}")))?;
563
564        if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
565            if errcode != 0 {
566                return Err(WechatError::ApiFailed(format!(
567                    "获取用户信息失败: errcode={errcode}"
568                )));
569            }
570        }
571
572        Ok(WechatUser {
573            openid: json
574                .get("openid")
575                .and_then(|v| v.as_str())
576                .unwrap_or_default()
577                .to_string(),
578            nickname: json
579                .get("nickname")
580                .and_then(|v| v.as_str())
581                .map(String::from),
582            sex: json.get("sex").and_then(|v| v.as_i64()).map(|v| v as i32),
583            province: json
584                .get("province")
585                .and_then(|v| v.as_str())
586                .map(String::from),
587            city: json.get("city").and_then(|v| v.as_str()).map(String::from),
588            country: json
589                .get("country")
590                .and_then(|v| v.as_str())
591                .map(String::from),
592            headimgurl: json
593                .get("headimgurl")
594                .and_then(|v| v.as_str())
595                .map(String::from),
596            privilege: json.get("privilege").and_then(|v| v.as_array()).map(|arr| {
597                arr.iter()
598                    .filter_map(|v| v.as_str().map(String::from))
599                    .collect()
600            }),
601            unionid: json
602                .get("unionid")
603                .and_then(|v| v.as_str())
604                .map(String::from),
605            raw: json,
606        })
607    }
608
609    /// 验证签名 — 对齐 `EasyWeChat::officialAccount()->server->verifySignature()`
610    ///
611    /// 计算 `SHA1(token + timestamp + nonce)` 并与 `signature` 比较。
612    ///
613    /// # 参数
614    ///
615    /// - `signature`: 待验证的签名(微信回调携带)
616    /// - `timestamp`: 时间戳
617    /// - `nonce`: 随机数
618    /// - `token`: 公众号后台配置的 Token
619    ///
620    /// # 返回
621    ///
622    /// 签名匹配返回 `true`,否则返回 `false`。
623    pub fn verify_signature(
624        &self,
625        signature: &str,
626        timestamp: &str,
627        nonce: &str,
628        token: &str,
629    ) -> bool {
630        let mut hasher = sha1::Sha1::new();
631        hasher.update(token.as_bytes());
632        hasher.update(timestamp.as_bytes());
633        hasher.update(nonce.as_bytes());
634        let computed = hex::encode(hasher.finalize());
635        computed == signature
636    }
637
638    /// 发送模板消息 — 对齐 `EasyWeChat::officialAccount()->template_message->send()`
639    ///
640    /// 工作流程:
641    /// 1. GET `https://api.weixin.qq.com/cgi-bin/token` 获取 access_token
642    /// 2. POST `https://api.weixin.qq.com/cgi-bin/message/template/send` 发送模板消息
643    ///
644    /// # 参数
645    ///
646    /// - `touser`: 接收者 openid
647    /// - `template_id`: 模板 ID
648    /// - `data`: 模板数据(JSON 值)
649    ///
650    /// # 返回
651    ///
652    /// 成功返回 `Ok(())`,失败返回 [`WechatError`]。
653    pub fn send_template_message(
654        &self,
655        touser: &str,
656        template_id: &str,
657        data: &serde_json::Value,
658    ) -> Result<(), WechatError> {
659        self.config.validate()?;
660        if touser.is_empty() {
661            return Err(WechatError::MissingField("touser".into()));
662        }
663        if template_id.is_empty() {
664            return Err(WechatError::MissingField("template_id".into()));
665        }
666
667        // 1. 获取 access_token
668        let access_token = self.fetch_access_token()?;
669
670        // 2. 发送模板消息
671        let url = format!(
672            "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={}",
673            percent_encode(&access_token),
674        );
675        let body = serde_json::json!({
676            "touser": touser,
677            "template_id": template_id,
678            "data": data,
679        });
680        let body_str =
681            serde_json::to_string(&body).map_err(|err| WechatError::Serialize(err.to_string()))?;
682        let response = self.transport.post_json(&url, &body_str)?;
683        let json: serde_json::Value = serde_json::from_str(&response)
684            .map_err(|err| WechatError::Serialize(format!("解析响应失败: {err}")))?;
685
686        let errcode = json.get("errcode").and_then(|v| v.as_i64()).unwrap_or(0);
687        if errcode != 0 {
688            return Err(WechatError::ApiFailed(format!(
689                "发送模板消息失败: errcode={errcode}"
690            )));
691        }
692        Ok(())
693    }
694
695    /// 生成 JS-SDK 签名 — 对齐 `EasyWeChat::officialAccount()->jssdk->getSignature()`
696    ///
697    /// 计算 `SHA1(jsapi_ticket=xxx&noncestr=xxx&timestamp=xxx&url=xxx)` 并返回十六进制字符串。
698    ///
699    /// # 参数
700    ///
701    /// - `url`: 当前页面 URL(不含 `#` 锚点)
702    /// - `noncestr`: 随机字符串
703    /// - `timestamp`: 时间戳(秒)
704    /// - `jsapi_ticket`: jsapi_ticket(通过 `cgi-bin/ticket/get` 获取)
705    ///
706    /// # 返回
707    ///
708    /// 40 字符的 SHA1 十六进制签名。
709    pub fn generate_jsapi_signature(
710        &self,
711        url: &str,
712        noncestr: &str,
713        timestamp: i64,
714        jsapi_ticket: &str,
715    ) -> String {
716        let input = format!(
717            "jsapi_ticket={}&noncestr={}&timestamp={}&url={}",
718            jsapi_ticket, noncestr, timestamp, url,
719        );
720        let mut hasher = sha1::Sha1::new();
721        hasher.update(input.as_bytes());
722        hex::encode(hasher.finalize())
723    }
724
725    /// 获取带参数二维码 URL — 对齐 `EasyWeChat::officialAccount()->qrcode->forever()`
726    ///
727    /// 工作流程:
728    /// 1. GET `https://api.weixin.qq.com/cgi-bin/token` 获取 access_token
729    /// 2. POST `https://api.weixin.qq.com/cgi-bin/qrcode/create` 创建永久二维码 ticket
730    /// 3. 拼接 `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=xxx` 返回
731    ///
732    /// # 参数
733    ///
734    /// - `scene_str`: 场景值(字符串,用于永久二维码)
735    ///
736    /// # 返回
737    ///
738    /// 成功返回二维码图片 URL,失败返回 [`WechatError`]。
739    pub fn get_qrcode_url(&self, scene_str: &str) -> Result<String, WechatError> {
740        self.config.validate()?;
741        if scene_str.is_empty() {
742            return Err(WechatError::MissingField("scene_str".into()));
743        }
744
745        // 1. 获取 access_token
746        let access_token = self.fetch_access_token()?;
747
748        // 2. 创建二维码 ticket
749        let url = format!(
750            "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={}",
751            percent_encode(&access_token),
752        );
753        let body = serde_json::json!({
754            "action_name": "QR_LIMIT_STR_SCENE",
755            "action_info": {
756                "scene": {
757                    "scene_str": scene_str,
758                }
759            }
760        });
761        let body_str =
762            serde_json::to_string(&body).map_err(|err| WechatError::Serialize(err.to_string()))?;
763        let response = self.transport.post_json(&url, &body_str)?;
764        let json: serde_json::Value = serde_json::from_str(&response)
765            .map_err(|err| WechatError::Serialize(format!("解析响应失败: {err}")))?;
766
767        if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
768            if errcode != 0 {
769                return Err(WechatError::ApiFailed(format!(
770                    "获取二维码 ticket 失败: errcode={errcode}"
771                )));
772            }
773        }
774
775        let ticket = json
776            .get("ticket")
777            .and_then(|v| v.as_str())
778            .ok_or_else(|| WechatError::MissingField("ticket".into()))?;
779        Ok(format!(
780            "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={}",
781            percent_encode(ticket)
782        ))
783    }
784
785    /// 获取 access_token(内部方法)
786    ///
787    /// GET `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=xxx&secret=xxx`,
788    /// 解析 JSON 响应中的 `access_token`。
789    fn fetch_access_token(&self) -> Result<String, WechatError> {
790        let url = format!(
791            "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={}&secret={}",
792            percent_encode(&self.config.app_id),
793            percent_encode(&self.config.app_secret),
794        );
795        let response = self.transport.get(&url)?;
796        let json: serde_json::Value = serde_json::from_str(&response)
797            .map_err(|err| WechatError::Serialize(format!("解析 token 响应失败: {err}")))?;
798
799        if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
800            if errcode != 0 {
801                return Err(WechatError::TokenFailed(format!(
802                    "获取 access_token 失败: errcode={errcode}"
803                )));
804            }
805        }
806
807        json.get("access_token")
808            .and_then(|v| v.as_str())
809            .map(String::from)
810            .ok_or_else(|| WechatError::TokenFailed("响应缺少 access_token".into()))
811    }
812}
813
814// ============================================================================
815// 辅助函数
816// ============================================================================
817
818/// 简易百分号编码 — 用于 URL 查询参数
819///
820/// 对齐 RFC 3986 的 unreserved 字符集(`A-Za-z0-9-._~`)保持原样,
821/// 其余字符编码为 `%XX` 形式(UTF-8 字节)。
822fn percent_encode(input: &str) -> String {
823    let mut output = String::with_capacity(input.len());
824    for byte in input.as_bytes() {
825        if matches!(
826            byte,
827            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
828        ) {
829            output.push(*byte as char);
830        } else {
831            output.push_str(&format!("%{byte:02X}"));
832        }
833    }
834    output
835}
836
837// ============================================================================
838// 单元测试
839// ============================================================================
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    // ------------------------------------------------------------------------
846    // WechatAppType 测试
847    // ------------------------------------------------------------------------
848
849    /// 测试 WechatAppType 枚举的变体与 trait 派生
850    #[test]
851    fn test_wechat_app_type() {
852        // 变体存在且相等
853        assert_eq!(
854            WechatAppType::OfficialAccount,
855            WechatAppType::OfficialAccount
856        );
857        assert_eq!(WechatAppType::MiniProgram, WechatAppType::MiniProgram);
858        assert_eq!(WechatAppType::OpenPlatform, WechatAppType::OpenPlatform);
859        assert_eq!(WechatAppType::Work, WechatAppType::Work);
860
861        // 不同变体不等
862        assert_ne!(WechatAppType::OfficialAccount, WechatAppType::MiniProgram);
863        assert_ne!(WechatAppType::OpenPlatform, WechatAppType::Work);
864
865        // Copy + Clone 可用
866        let app_type = WechatAppType::OfficialAccount;
867        let cloned = app_type;
868        assert_eq!(app_type, cloned);
869
870        // Hash 可用(可放入 HashSet)
871        let set: std::collections::HashSet<WechatAppType> = [
872            WechatAppType::OfficialAccount,
873            WechatAppType::MiniProgram,
874            WechatAppType::OpenPlatform,
875            WechatAppType::Work,
876        ]
877        .into_iter()
878        .collect();
879        assert_eq!(set.len(), 4);
880        assert!(set.contains(&WechatAppType::OfficialAccount));
881        assert!(set.contains(&WechatAppType::MiniProgram));
882        assert!(set.contains(&WechatAppType::OpenPlatform));
883        assert!(set.contains(&WechatAppType::Work));
884
885        // Debug 可用
886        assert_eq!(
887            format!("{:?}", WechatAppType::OfficialAccount),
888            "OfficialAccount"
889        );
890    }
891
892    // ------------------------------------------------------------------------
893    // WechatConfig 测试
894    // ------------------------------------------------------------------------
895
896    /// 测试 WechatConfig builder 模式
897    #[test]
898    fn test_wechat_config_builder() {
899        let config =
900            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret")
901                .with_token("wx_token")
902                .with_encoding_aes_key("wx_aes_key")
903                .with_oauth_redirect_uri("https://example.com/oauth/callback");
904
905        assert_eq!(config.app_type, WechatAppType::OfficialAccount);
906        assert_eq!(config.app_id, "wx_app_id");
907        assert_eq!(config.app_secret, "wx_app_secret");
908        assert_eq!(config.token.as_deref(), Some("wx_token"));
909        assert_eq!(config.encoding_aes_key.as_deref(), Some("wx_aes_key"));
910        assert_eq!(
911            config.oauth_redirect_uri.as_deref(),
912            Some("https://example.com/oauth/callback")
913        );
914
915        // validate 通过
916        assert!(config.validate().is_ok());
917
918        // 默认值(仅必填项)
919        let minimal = WechatConfig::new(WechatAppType::MiniProgram, "wx_mini", "secret");
920        assert_eq!(minimal.app_type, WechatAppType::MiniProgram);
921        assert_eq!(minimal.app_id, "wx_mini");
922        assert_eq!(minimal.app_secret, "secret");
923        assert!(minimal.token.is_none());
924        assert!(minimal.encoding_aes_key.is_none());
925        assert!(minimal.oauth_redirect_uri.is_none());
926        assert!(minimal.validate().is_ok());
927    }
928
929    /// 测试 WechatConfig::validate 校验必填字段
930    #[test]
931    fn test_wechat_config_validate() {
932        // 全部合法
933        let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "secret");
934        assert!(config.validate().is_ok());
935
936        // app_id 为空
937        let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "secret");
938        let err = config.validate().unwrap_err();
939        match err {
940            WechatError::Config(field) => assert_eq!(field, "app_id"),
941            other => panic!("期望 Config, 实际 {other:?}"),
942        }
943
944        // app_secret 为空
945        let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "");
946        let err = config.validate().unwrap_err();
947        match err {
948            WechatError::Config(field) => assert_eq!(field, "app_secret"),
949            other => panic!("期望 Config, 实际 {other:?}"),
950        }
951
952        // 两者为空(优先报 app_id)
953        let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "");
954        let err = config.validate().unwrap_err();
955        match err {
956            WechatError::Config(field) => assert_eq!(field, "app_id"),
957            other => panic!("期望 Config, 实际 {other:?}"),
958        }
959    }
960
961    // ------------------------------------------------------------------------
962    // WechatUser 测试
963    // ------------------------------------------------------------------------
964
965    /// 测试 WechatUser 默认值
966    #[test]
967    fn test_wechat_user_default() {
968        let user = WechatUser::default();
969        assert!(user.openid.is_empty());
970        assert!(user.nickname.is_none());
971        assert!(user.sex.is_none());
972        assert!(user.province.is_none());
973        assert!(user.city.is_none());
974        assert!(user.country.is_none());
975        assert!(user.headimgurl.is_none());
976        assert!(user.privilege.is_none());
977        assert!(user.unionid.is_none());
978        assert!(user.raw.is_null());
979    }
980
981    /// 测试 WechatUser 序列化/反序列化往返
982    #[test]
983    fn test_wechat_user_serialize() {
984        let user = WechatUser {
985            openid: "openid_abc".to_string(),
986            nickname: Some("test_user".into()),
987            sex: Some(1),
988            province: Some("广东".into()),
989            city: Some("深圳".into()),
990            country: Some("中国".into()),
991            headimgurl: Some("https://example.com/avatar.png".into()),
992            privilege: Some(vec!["priv1".into(), "priv2".into()]),
993            unionid: Some("unionid_xyz".into()),
994            raw: serde_json::json!({"custom": "field"}),
995        };
996
997        let json = serde_json::to_string(&user).expect("序列化失败");
998        let parsed: WechatUser = serde_json::from_str(&json).expect("反序列化失败");
999
1000        assert_eq!(parsed.openid, "openid_abc");
1001        assert_eq!(parsed.nickname.as_deref(), Some("test_user"));
1002        assert_eq!(parsed.sex, Some(1));
1003        assert_eq!(parsed.province.as_deref(), Some("广东"));
1004        assert_eq!(parsed.city.as_deref(), Some("深圳"));
1005        assert_eq!(parsed.country.as_deref(), Some("中国"));
1006        assert_eq!(
1007            parsed.headimgurl.as_deref(),
1008            Some("https://example.com/avatar.png")
1009        );
1010        assert_eq!(
1011            parsed.privilege.as_deref(),
1012            Some(&vec!["priv1".to_string(), "priv2".to_string()][..])
1013        );
1014        assert_eq!(parsed.unionid.as_deref(), Some("unionid_xyz"));
1015        assert_eq!(parsed.raw["custom"], "field");
1016    }
1017
1018    // ------------------------------------------------------------------------
1019    // WechatSdk::get_authorize_url 测试
1020    // ------------------------------------------------------------------------
1021
1022    /// 测试 WechatSdk::get_authorize_url 构造 OAuth2 授权 URL
1023    #[test]
1024    fn test_wechat_sdk_get_authorize_url() {
1025        // 配置了 redirect_uri
1026        let config =
1027            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret")
1028                .with_oauth_redirect_uri("https://example.com/oauth/callback");
1029        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1030
1031        let url = sdk.get_authorize_url("snsapi_userinfo", "state_abc");
1032        assert!(
1033            url.starts_with("https://open.weixin.qq.com/connect/oauth2/authorize?"),
1034            "URL 前缀错误: {url}"
1035        );
1036        assert!(url.contains("appid=wx_app_id"));
1037        // redirect_uri 百分号编码
1038        assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback"));
1039        assert!(url.contains("response_type=code"));
1040        assert!(url.contains("scope=snsapi_userinfo"));
1041        assert!(url.contains("state=state_abc"));
1042        assert!(url.ends_with("#wechat_redirect"));
1043
1044        // 未配置 redirect_uri 时为空字符串
1045        let config =
1046            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1047        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1048        let url = sdk.get_authorize_url("snsapi_base", "state_123");
1049        assert!(url.contains("redirect_uri="));
1050        assert!(url.contains("scope=snsapi_base"));
1051        assert!(url.contains("state=state_123"));
1052    }
1053
1054    // ------------------------------------------------------------------------
1055    // WechatSdk::verify_signature 测试
1056    // ------------------------------------------------------------------------
1057
1058    /// 测试 WechatSdk::verify_signature 签名验证
1059    #[test]
1060    fn test_wechat_sdk_verify_signature() {
1061        let config =
1062            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1063        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1064
1065        let token = "test_token";
1066        let timestamp = "1234567890";
1067        let nonce = "nonce_abc";
1068
1069        // 计算预期签名(SHA1(token + timestamp + nonce))
1070        let mut hasher = sha1::Sha1::new();
1071        hasher.update(token.as_bytes());
1072        hasher.update(timestamp.as_bytes());
1073        hasher.update(nonce.as_bytes());
1074        let expected = hex::encode(hasher.finalize());
1075
1076        // 正确签名应通过验证
1077        assert!(sdk.verify_signature(&expected, timestamp, nonce, token));
1078
1079        // 错误签名不应通过验证
1080        assert!(!sdk.verify_signature("wrong_signature", timestamp, nonce, token));
1081
1082        // 边界:空字符串(SHA1("") = da39a3ee5e6b4b0d3255bfef95601890afd80709)
1083        assert!(sdk.verify_signature("da39a3ee5e6b4b0d3255bfef95601890afd80709", "", "", ""));
1084
1085        // 顺序敏感:调换 token/timestamp/nonce 顺序应不匹配
1086        let mut hasher = sha1::Sha1::new();
1087        hasher.update(timestamp.as_bytes());
1088        hasher.update(token.as_bytes());
1089        hasher.update(nonce.as_bytes());
1090        let wrong_order = hex::encode(hasher.finalize());
1091        assert!(!sdk.verify_signature(&wrong_order, timestamp, nonce, token));
1092    }
1093
1094    // ------------------------------------------------------------------------
1095    // WechatSdk::generate_jsapi_signature 测试
1096    // ------------------------------------------------------------------------
1097
1098    /// 测试 WechatSdk::generate_jsapi_signature 生成 JS-SDK 签名
1099    #[test]
1100    fn test_wechat_sdk_generate_jsapi_signature() {
1101        let config =
1102            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1103        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1104
1105        let url = "https://example.com/page";
1106        let noncestr = "abc123";
1107        let timestamp = 1609459200_i64;
1108        let jsapi_ticket = "ticket_value";
1109
1110        // 计算预期签名
1111        let input = format!(
1112            "jsapi_ticket={}&noncestr={}&timestamp={}&url={}",
1113            jsapi_ticket, noncestr, timestamp, url,
1114        );
1115        let mut hasher = sha1::Sha1::new();
1116        hasher.update(input.as_bytes());
1117        let expected = hex::encode(hasher.finalize());
1118
1119        let signature = sdk.generate_jsapi_signature(url, noncestr, timestamp, jsapi_ticket);
1120        assert_eq!(signature, expected);
1121        // SHA1 十六进制长度为 40
1122        assert_eq!(signature.len(), 40);
1123
1124        // 不同参数应产生不同签名
1125        let other =
1126            sdk.generate_jsapi_signature("https://other.com", noncestr, timestamp, jsapi_ticket);
1127        assert_ne!(signature, other);
1128
1129        // 边界:空字符串参数
1130        let empty_sig = sdk.generate_jsapi_signature("", "", 0, "");
1131        assert_eq!(empty_sig.len(), 40);
1132    }
1133
1134    // ------------------------------------------------------------------------
1135    // WechatSdk::get_user_by_code 测试
1136    // ------------------------------------------------------------------------
1137
1138    /// 测试 WechatSdk::get_user_by_code 完整流程(使用 MemoryWechatHttpTransport)
1139    #[test]
1140    fn test_wechat_sdk_get_user_by_code() {
1141        let transport = Arc::new(MemoryWechatHttpTransport::new());
1142        // 预置 mock 响应:token 响应 + 用户信息响应
1143        transport.push_response(
1144            r#"{"access_token":"token123","expires_in":7200,"openid":"openid_abc"}"#,
1145        );
1146        transport.push_response(
1147            r#"{"openid":"openid_abc","nickname":"test_user","sex":1,"province":"广东","city":"深圳","country":"中国","headimgurl":"https://example.com/avatar.png","unionid":"unionid_xyz"}"#,
1148        );
1149
1150        let config =
1151            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1152        let sdk = WechatSdk::new(config, transport.clone());
1153
1154        let user = sdk
1155            .get_user_by_code("auth_code_abc")
1156            .expect("get_user_by_code 失败");
1157
1158        // 验证用户字段
1159        assert_eq!(user.openid, "openid_abc");
1160        assert_eq!(user.nickname.as_deref(), Some("test_user"));
1161        assert_eq!(user.sex, Some(1));
1162        assert_eq!(user.province.as_deref(), Some("广东"));
1163        assert_eq!(user.city.as_deref(), Some("深圳"));
1164        assert_eq!(user.country.as_deref(), Some("中国"));
1165        assert_eq!(
1166            user.headimgurl.as_deref(),
1167            Some("https://example.com/avatar.png")
1168        );
1169        assert_eq!(user.unionid.as_deref(), Some("unionid_xyz"));
1170        // raw 保留原始响应
1171        assert_eq!(user.raw["nickname"], "test_user");
1172
1173        // 验证 HTTP 请求次数(token + user info)
1174        assert_eq!(transport.request_count(), 2);
1175
1176        // 验证第一次请求是 GET sns/oauth2/access_token
1177        let requests = transport.requests();
1178        assert_eq!(requests[0].0, "GET");
1179        assert!(requests[0].1.contains("sns/oauth2/access_token"));
1180        assert!(requests[0].1.contains("code=auth_code_abc"));
1181
1182        // 验证第二次请求是 GET sns/userinfo
1183        assert_eq!(requests[1].0, "GET");
1184        assert!(requests[1].1.contains("sns/userinfo"));
1185    }
1186
1187    /// 测试 WechatSdk::get_user_by_code code 为空时返回错误
1188    #[test]
1189    fn test_wechat_sdk_get_user_by_code_empty_code() {
1190        let config =
1191            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1192        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1193
1194        let err = sdk.get_user_by_code("").unwrap_err();
1195        match err {
1196            WechatError::MissingField(field) => assert_eq!(field, "code"),
1197            other => panic!("期望 MissingField, 实际 {other:?}"),
1198        }
1199    }
1200
1201    /// 测试 WechatSdk::get_user_by_code token 响应缺少 access_token 时失败
1202    #[test]
1203    fn test_wechat_sdk_get_user_by_code_missing_access_token() {
1204        let transport = MemoryWechatHttpTransport::new();
1205        transport.push_response(r#"{"errcode":40029,"errmsg":"invalid code"}"#);
1206
1207        let config =
1208            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1209        let sdk = WechatSdk::new(config, Arc::new(transport));
1210
1211        let err = sdk.get_user_by_code("invalid_code").unwrap_err();
1212        assert!(matches!(err, WechatError::ApiFailed(_)));
1213    }
1214
1215    // ------------------------------------------------------------------------
1216    // WechatSdk::get_user_info 测试
1217    // ------------------------------------------------------------------------
1218
1219    /// 测试 WechatSdk::get_user_info 获取用户信息
1220    #[test]
1221    fn test_wechat_sdk_get_user_info() {
1222        let transport = Arc::new(MemoryWechatHttpTransport::new());
1223        transport.push_response(
1224            r#"{"openid":"openid_xyz","nickname":"user_info","sex":2,"province":"北京","city":"北京","country":"中国","headimgurl":"https://example.com/avatar2.png","privilege":["priv_a","priv_b"]}"#,
1225        );
1226
1227        let config =
1228            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1229        let sdk = WechatSdk::new(config, transport.clone());
1230
1231        let user = sdk
1232            .get_user_info("openid_xyz", "access_token_123")
1233            .expect("get_user_info 失败");
1234
1235        assert_eq!(user.openid, "openid_xyz");
1236        assert_eq!(user.nickname.as_deref(), Some("user_info"));
1237        assert_eq!(user.sex, Some(2));
1238        assert_eq!(user.province.as_deref(), Some("北京"));
1239        assert_eq!(user.city.as_deref(), Some("北京"));
1240        assert_eq!(user.country.as_deref(), Some("中国"));
1241        assert_eq!(
1242            user.headimgurl.as_deref(),
1243            Some("https://example.com/avatar2.png")
1244        );
1245        assert_eq!(
1246            user.privilege.as_deref(),
1247            Some(&vec!["priv_a".to_string(), "priv_b".to_string()][..])
1248        );
1249        // raw 保留原始响应
1250        assert_eq!(user.raw["openid"], "openid_xyz");
1251
1252        // 验证仅一次 HTTP 请求
1253        assert_eq!(transport.request_count(), 1);
1254        let requests = transport.requests();
1255        assert_eq!(requests[0].0, "GET");
1256        assert!(requests[0].1.contains("sns/userinfo"));
1257        assert!(requests[0].1.contains("access_token=access_token_123"));
1258        assert!(requests[0].1.contains("openid=openid_xyz"));
1259    }
1260
1261    /// 测试 WechatSdk::get_user_info openid/access_token 为空时返回错误
1262    #[test]
1263    fn test_wechat_sdk_get_user_info_empty_fields() {
1264        let config =
1265            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1266        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1267
1268        // openid 为空
1269        let err = sdk.get_user_info("", "token").unwrap_err();
1270        match err {
1271            WechatError::MissingField(field) => assert_eq!(field, "openid"),
1272            other => panic!("期望 MissingField, 实际 {other:?}"),
1273        }
1274
1275        // access_token 为空
1276        let err = sdk.get_user_info("openid", "").unwrap_err();
1277        match err {
1278            WechatError::MissingField(field) => assert_eq!(field, "access_token"),
1279            other => panic!("期望 MissingField, 实际 {other:?}"),
1280        }
1281    }
1282
1283    // ------------------------------------------------------------------------
1284    // WechatSdk::send_template_message 测试
1285    // ------------------------------------------------------------------------
1286
1287    /// 测试 WechatSdk::send_template_message 发送模板消息
1288    #[test]
1289    fn test_wechat_sdk_send_template_message() {
1290        let transport = Arc::new(MemoryWechatHttpTransport::new());
1291        // 预置 mock 响应:access_token 响应 + 模板消息响应
1292        transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
1293        transport.push_response(r#"{"errcode":0,"errmsg":"ok","msgid":123456789}"#);
1294
1295        let config =
1296            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1297        let sdk = WechatSdk::new(config, transport.clone());
1298
1299        let data = serde_json::json!({
1300            "first": {"value": "您好,订单已支付"},
1301            "keyword1": {"value": "鲜视达订单 #20240101"},
1302            "remark": {"value": "感谢您的惠顾"}
1303        });
1304        sdk.send_template_message("openid_user", "tpl_id_abc", &data)
1305            .expect("send_template_message 失败");
1306
1307        // 验证两次 HTTP 请求
1308        assert_eq!(transport.request_count(), 2);
1309        let requests = transport.requests();
1310
1311        // 第一次:GET cgi-bin/token
1312        assert_eq!(requests[0].0, "GET");
1313        assert!(requests[0].1.contains("cgi-bin/token"));
1314        assert!(requests[0].1.contains("grant_type=client_credential"));
1315
1316        // 第二次:POST cgi-bin/message/template/send
1317        assert_eq!(requests[1].0, "POST_JSON");
1318        assert!(requests[1].1.contains("cgi-bin/message/template/send"));
1319        assert!(requests[1].1.contains("access_token=token123"));
1320        // body 包含 touser / template_id / data
1321        let body: serde_json::Value = serde_json::from_str(&requests[1].2).expect("body 应为 JSON");
1322        assert_eq!(body["touser"], "openid_user");
1323        assert_eq!(body["template_id"], "tpl_id_abc");
1324        assert_eq!(body["data"]["first"]["value"], "您好,订单已支付");
1325    }
1326
1327    /// 测试 WechatSdk::send_template_message touser/template_id 为空时返回错误
1328    #[test]
1329    fn test_wechat_sdk_send_template_message_empty_fields() {
1330        let config =
1331            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1332        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1333
1334        // touser 为空
1335        let err = sdk
1336            .send_template_message("", "tpl_id", &serde_json::json!({}))
1337            .unwrap_err();
1338        match err {
1339            WechatError::MissingField(field) => assert_eq!(field, "touser"),
1340            other => panic!("期望 MissingField, 实际 {other:?}"),
1341        }
1342
1343        // template_id 为空
1344        let err = sdk
1345            .send_template_message("user", "", &serde_json::json!({}))
1346            .unwrap_err();
1347        match err {
1348            WechatError::MissingField(field) => assert_eq!(field, "template_id"),
1349            other => panic!("期望 MissingField, 实际 {other:?}"),
1350        }
1351    }
1352
1353    /// 测试 WechatSdk::send_template_message access_token 获取失败
1354    #[test]
1355    fn test_wechat_sdk_send_template_message_token_failed() {
1356        let transport = MemoryWechatHttpTransport::new();
1357        // access_token 接口返回 errcode
1358        transport.push_response(r#"{"errcode":40013,"errmsg":"invalid appid"}"#);
1359
1360        let config =
1361            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1362        let sdk = WechatSdk::new(config, Arc::new(transport));
1363
1364        let err = sdk
1365            .send_template_message("user", "tpl", &serde_json::json!({}))
1366            .unwrap_err();
1367        assert!(matches!(err, WechatError::TokenFailed(_)));
1368    }
1369
1370    /// 测试 WechatSdk::send_template_message 微信返回 errcode 时失败
1371    #[test]
1372    fn test_wechat_sdk_send_template_message_api_failed() {
1373        let transport = MemoryWechatHttpTransport::new();
1374        transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
1375        transport.push_response(r#"{"errcode":43004,"errmsg":"require subscribe"}"#);
1376
1377        let config =
1378            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1379        let sdk = WechatSdk::new(config, Arc::new(transport));
1380
1381        let err = sdk
1382            .send_template_message("user", "tpl", &serde_json::json!({}))
1383            .unwrap_err();
1384        assert!(matches!(err, WechatError::ApiFailed(_)));
1385    }
1386
1387    // ------------------------------------------------------------------------
1388    // WechatSdk::get_qrcode_url 测试
1389    // ------------------------------------------------------------------------
1390
1391    /// 测试 WechatSdk::get_qrcode_url 获取带参数二维码 URL
1392    #[test]
1393    fn test_wechat_sdk_get_qrcode_url() {
1394        let transport = Arc::new(MemoryWechatHttpTransport::new());
1395        // 预置 mock 响应:access_token 响应 + 二维码 ticket 响应
1396        transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
1397        transport.push_response(
1398            r#"{"ticket":"ticket_abc_xyz","url":"http://weixin.qq.com/q/abc","expire_seconds":0}"#,
1399        );
1400
1401        let config =
1402            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1403        let sdk = WechatSdk::new(config, transport.clone());
1404
1405        let url = sdk
1406            .get_qrcode_url("scene_123")
1407            .expect("get_qrcode_url 失败");
1408
1409        // 应为 showqrcode URL
1410        assert!(url.starts_with("https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket="));
1411        // ticket 百分号编码(ticket_abc_xyz 仅含 unreserved 字符,不编码)
1412        assert!(url.contains("ticket=ticket_abc_xyz"));
1413
1414        // 验证两次 HTTP 请求
1415        assert_eq!(transport.request_count(), 2);
1416        let requests = transport.requests();
1417
1418        // 第一次:GET cgi-bin/token
1419        assert_eq!(requests[0].0, "GET");
1420        assert!(requests[0].1.contains("cgi-bin/token"));
1421
1422        // 第二次:POST cgi-bin/qrcode/create
1423        assert_eq!(requests[1].0, "POST_JSON");
1424        assert!(requests[1].1.contains("cgi-bin/qrcode/create"));
1425        assert!(requests[1].1.contains("access_token=token123"));
1426        // body 包含 action_name 和 scene_str
1427        let body: serde_json::Value = serde_json::from_str(&requests[1].2).expect("body 应为 JSON");
1428        assert_eq!(body["action_name"], "QR_LIMIT_STR_SCENE");
1429        assert_eq!(body["action_info"]["scene"]["scene_str"], "scene_123");
1430    }
1431
1432    /// 测试 WechatSdk::get_qrcode_url scene_str 为空时返回错误
1433    #[test]
1434    fn test_wechat_sdk_get_qrcode_url_empty_scene() {
1435        let config =
1436            WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
1437        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1438
1439        let err = sdk.get_qrcode_url("").unwrap_err();
1440        match err {
1441            WechatError::MissingField(field) => assert_eq!(field, "scene_str"),
1442            other => panic!("期望 MissingField, 实际 {other:?}"),
1443        }
1444    }
1445
1446    // ------------------------------------------------------------------------
1447    // WechatSdk 配置缺失测试
1448    // ------------------------------------------------------------------------
1449
1450    /// 测试 WechatSdk 配置缺失时各 API 返回 Config 错误
1451    #[test]
1452    fn test_wechat_sdk_missing_config() {
1453        let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "");
1454        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1455
1456        // get_user_by_code 应失败(Config)
1457        let err = sdk.get_user_by_code("code").unwrap_err();
1458        assert!(matches!(err, WechatError::Config(_)));
1459
1460        // get_user_info 应失败(Config)
1461        let err = sdk.get_user_info("openid", "token").unwrap_err();
1462        assert!(matches!(err, WechatError::Config(_)));
1463
1464        // send_template_message 应失败(Config)
1465        let err = sdk
1466            .send_template_message("user", "tpl", &serde_json::json!({}))
1467            .unwrap_err();
1468        assert!(matches!(err, WechatError::Config(_)));
1469
1470        // get_qrcode_url 应失败(Config)
1471        let err = sdk.get_qrcode_url("scene").unwrap_err();
1472        assert!(matches!(err, WechatError::Config(_)));
1473
1474        // 仅 app_id 为空
1475        let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "secret");
1476        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1477        let err = sdk.get_user_by_code("code").unwrap_err();
1478        match err {
1479            WechatError::Config(field) => assert_eq!(field, "app_id"),
1480            other => panic!("期望 Config, 实际 {other:?}"),
1481        }
1482
1483        // 仅 app_secret 为空
1484        let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "");
1485        let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
1486        let err = sdk.get_user_info("openid", "token").unwrap_err();
1487        match err {
1488            WechatError::Config(field) => assert_eq!(field, "app_secret"),
1489            other => panic!("期望 Config, 实际 {other:?}"),
1490        }
1491
1492        // 注意:get_authorize_url / verify_signature / generate_jsapi_signature
1493        // 为纯计算方法,不校验配置,可正常调用
1494        let _url = sdk.get_authorize_url("snsapi_base", "state");
1495        assert!(sdk.verify_signature("sig", "ts", "nonce", "token") || true);
1496        let _sig = sdk.generate_jsapi_signature("url", "ns", 0, "ticket");
1497    }
1498
1499    // ------------------------------------------------------------------------
1500    // MemoryWechatHttpTransport 测试
1501    // ------------------------------------------------------------------------
1502
1503    /// 测试 MemoryWechatHttpTransport 的 GET / POST_JSON / POST_FORM 与响应队列
1504    #[test]
1505    fn test_memory_wechat_http_transport() {
1506        let transport = MemoryWechatHttpTransport::new();
1507
1508        // 队列空时返回错误
1509        let err = transport.get("https://api.example.com/get").unwrap_err();
1510        match err {
1511            WechatError::HttpTransport(msg) => assert!(msg.contains("无可用预置响应")),
1512            other => panic!("期望 HttpTransport, 实际 {other:?}"),
1513        }
1514        assert_eq!(transport.request_count(), 0);
1515
1516        // 预置响应后 GET 返回响应并记录请求
1517        transport.push_response(r#"{"code":"ok"}"#);
1518        let resp = transport
1519            .get("https://api.example.com/get?foo=bar")
1520            .expect("GET 应返回预置响应");
1521        assert_eq!(resp, r#"{"code":"ok"}"#);
1522        assert_eq!(transport.request_count(), 1);
1523        let requests = transport.requests();
1524        assert_eq!(requests[0].0, "GET");
1525        assert_eq!(requests[0].1, "https://api.example.com/get?foo=bar");
1526        assert_eq!(requests[0].2, ""); // GET 的 body 为空
1527
1528        // POST_JSON 返回响应并记录请求
1529        transport.push_response(r#"{"errcode":0}"#);
1530        let resp = transport
1531            .post_json("https://api.example.com/post", r#"{"key":"value"}"#)
1532            .expect("POST_JSON 应返回预置响应");
1533        assert_eq!(resp, r#"{"errcode":0}"#);
1534        assert_eq!(transport.request_count(), 2);
1535        let requests = transport.requests();
1536        assert_eq!(requests[1].0, "POST_JSON");
1537        assert_eq!(requests[1].1, "https://api.example.com/post");
1538        assert_eq!(requests[1].2, r#"{"key":"value"}"#);
1539
1540        // POST_FORM 返回响应并记录请求
1541        transport.push_response("form_response");
1542        let resp = transport
1543            .post_form("https://api.example.com/form", "a=1&b=2")
1544            .expect("POST_FORM 应返回预置响应");
1545        assert_eq!(resp, "form_response");
1546        assert_eq!(transport.request_count(), 3);
1547        let requests = transport.requests();
1548        assert_eq!(requests[2].0, "POST_FORM");
1549        assert_eq!(requests[2].1, "https://api.example.com/form");
1550        assert_eq!(requests[2].2, "a=1&b=2");
1551
1552        // FIFO 顺序:交替调用
1553        transport.clear();
1554        transport.push_response("resp1");
1555        transport.push_response("resp2");
1556        transport.push_response("resp3");
1557
1558        let r1 = transport.post_json("url1", "body1").expect("应返回 resp1");
1559        let r2 = transport.get("url2").expect("应返回 resp2");
1560        let r3 = transport.post_form("url3", "body3").expect("应返回 resp3");
1561        assert_eq!(r1, "resp1");
1562        assert_eq!(r2, "resp2");
1563        assert_eq!(r3, "resp3");
1564
1565        // 队列已空
1566        assert!(transport.get("url4").is_err());
1567        assert!(transport.post_json("url4", "body4").is_err());
1568        assert!(transport.post_form("url4", "body4").is_err());
1569
1570        // 验证请求记录顺序
1571        assert_eq!(transport.request_count(), 3);
1572        let requests = transport.requests();
1573        assert_eq!(requests[0].0, "POST_JSON");
1574        assert_eq!(requests[0].1, "url1");
1575        assert_eq!(requests[0].2, "body1");
1576        assert_eq!(requests[1].0, "GET");
1577        assert_eq!(requests[1].1, "url2");
1578        assert_eq!(requests[1].2, "");
1579        assert_eq!(requests[2].0, "POST_FORM");
1580        assert_eq!(requests[2].1, "url3");
1581        assert_eq!(requests[2].2, "body3");
1582
1583        // clear 后队列和记录均清空
1584        transport.clear();
1585        assert_eq!(transport.request_count(), 0);
1586        assert!(transport.get("url").is_err());
1587    }
1588}