use parking_lot::Mutex;
use sha1::Digest;
use std::collections::VecDeque;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum WechatError {
#[error("微信配置错误: {0}")]
Config(String),
#[error("微信字段缺失: {0}")]
MissingField(String),
#[error("微信 API 调用失败: {0}")]
ApiFailed(String),
#[error("微信 HTTP 传输失败: {0}")]
HttpTransport(String),
#[error("微信序列化失败: {0}")]
Serialize(String),
#[error("微信 access_token 获取失败: {0}")]
TokenFailed(String),
#[error("微信解密失败: {0}")]
DecryptFailed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WechatAppType {
OfficialAccount,
MiniProgram,
OpenPlatform,
Work,
}
#[derive(Debug, Clone)]
pub struct WechatConfig {
pub app_type: WechatAppType,
pub app_id: String,
pub app_secret: String,
pub token: Option<String>,
pub encoding_aes_key: Option<String>,
pub oauth_redirect_uri: Option<String>,
}
impl WechatConfig {
pub fn new(
app_type: WechatAppType,
app_id: impl Into<String>,
app_secret: impl Into<String>,
) -> Self {
Self {
app_type,
app_id: app_id.into(),
app_secret: app_secret.into(),
token: None,
encoding_aes_key: None,
oauth_redirect_uri: None,
}
}
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
pub fn with_encoding_aes_key(mut self, key: impl Into<String>) -> Self {
self.encoding_aes_key = Some(key.into());
self
}
pub fn with_oauth_redirect_uri(mut self, uri: impl Into<String>) -> Self {
self.oauth_redirect_uri = Some(uri.into());
self
}
pub fn validate(&self) -> Result<(), WechatError> {
if self.app_id.is_empty() {
return Err(WechatError::Config("app_id".into()));
}
if self.app_secret.is_empty() {
return Err(WechatError::Config("app_secret".into()));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsApiTicket {
JsApi,
WxCard,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct WechatUser {
pub openid: String,
pub nickname: Option<String>,
pub sex: Option<i32>,
pub province: Option<String>,
pub city: Option<String>,
pub country: Option<String>,
pub headimgurl: Option<String>,
pub privilege: Option<Vec<String>>,
pub unionid: Option<String>,
pub raw: serde_json::Value,
}
pub trait WechatHttpTransport: Send + Sync {
fn get(&self, url: &str) -> Result<String, WechatError>;
fn post_json(&self, url: &str, body: &str) -> Result<String, WechatError>;
fn post_form(&self, url: &str, body: &str) -> Result<String, WechatError>;
}
#[derive(Debug, Default)]
pub struct MemoryWechatHttpTransport {
responses: Mutex<VecDeque<String>>,
requests: Mutex<Vec<(String, String, String)>>,
}
impl MemoryWechatHttpTransport {
pub fn new() -> Self {
Self::default()
}
pub fn push_response(&self, response: impl Into<String>) {
self.responses.lock().push_back(response.into());
}
pub fn request_count(&self) -> usize {
self.requests.lock().len()
}
pub fn requests(&self) -> Vec<(String, String, String)> {
self.requests.lock().clone()
}
pub fn clear(&self) {
self.responses.lock().clear();
self.requests.lock().clear();
}
fn next_response(&self) -> Result<String, WechatError> {
match self.responses.lock().pop_front() {
Some(resp) => Ok(resp),
None => Err(WechatError::HttpTransport("无可用预置响应".into())),
}
}
}
impl WechatHttpTransport for MemoryWechatHttpTransport {
fn get(&self, url: &str) -> Result<String, WechatError> {
let response = self.next_response()?;
self.requests
.lock()
.push(("GET".to_string(), url.to_string(), String::new()));
Ok(response)
}
fn post_json(&self, url: &str, body: &str) -> Result<String, WechatError> {
let response = self.next_response()?;
self.requests
.lock()
.push(("POST_JSON".to_string(), url.to_string(), body.to_string()));
Ok(response)
}
fn post_form(&self, url: &str, body: &str) -> Result<String, WechatError> {
let response = self.next_response()?;
self.requests
.lock()
.push(("POST_FORM".to_string(), url.to_string(), body.to_string()));
Ok(response)
}
}
pub struct WechatSdk {
config: WechatConfig,
transport: Arc<dyn WechatHttpTransport>,
}
impl WechatSdk {
pub fn new(config: WechatConfig, transport: Arc<dyn WechatHttpTransport>) -> Self {
Self { config, transport }
}
pub fn config(&self) -> &WechatConfig {
&self.config
}
pub fn get_authorize_url(&self, scope: &str, state: &str) -> String {
let redirect_uri = self.config.oauth_redirect_uri.as_deref().unwrap_or("");
format!(
"https://open.weixin.qq.com/connect/oauth2/authorize?appid={}&redirect_uri={}&response_type=code&scope={}&state={}#wechat_redirect",
percent_encode(&self.config.app_id),
percent_encode(redirect_uri),
percent_encode(scope),
percent_encode(state),
)
}
pub fn get_user_by_code(&self, code: &str) -> Result<WechatUser, WechatError> {
self.config.validate()?;
if code.is_empty() {
return Err(WechatError::MissingField("code".into()));
}
let url = format!(
"https://api.weixin.qq.com/sns/oauth2/access_token?appid={}&secret={}&code={}&grant_type=authorization_code",
percent_encode(&self.config.app_id),
percent_encode(&self.config.app_secret),
percent_encode(code),
);
let response = self.transport.get(&url)?;
let token_json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| WechatError::Serialize(format!("解析 token 响应失败: {err}")))?;
if let Some(errcode) = token_json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(WechatError::ApiFailed(format!(
"code 换取 token 失败: errcode={errcode}"
)));
}
}
let access_token = token_json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| WechatError::TokenFailed("响应缺少 access_token".into()))?
.to_string();
let openid = token_json
.get("openid")
.and_then(|v| v.as_str())
.ok_or_else(|| WechatError::MissingField("openid".into()))?
.to_string();
self.get_user_info(&openid, &access_token)
}
pub fn get_user_info(
&self,
openid: &str,
access_token: &str,
) -> Result<WechatUser, WechatError> {
self.config.validate()?;
if openid.is_empty() {
return Err(WechatError::MissingField("openid".into()));
}
if access_token.is_empty() {
return Err(WechatError::MissingField("access_token".into()));
}
let url = format!(
"https://api.weixin.qq.com/sns/userinfo?access_token={}&openid={}",
percent_encode(access_token),
percent_encode(openid),
);
let response = self.transport.get(&url)?;
let json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| WechatError::Serialize(format!("解析用户信息响应失败: {err}")))?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(WechatError::ApiFailed(format!(
"获取用户信息失败: errcode={errcode}"
)));
}
}
Ok(WechatUser {
openid: json
.get("openid")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
nickname: json
.get("nickname")
.and_then(|v| v.as_str())
.map(String::from),
sex: json.get("sex").and_then(|v| v.as_i64()).map(|v| v as i32),
province: json
.get("province")
.and_then(|v| v.as_str())
.map(String::from),
city: json.get("city").and_then(|v| v.as_str()).map(String::from),
country: json
.get("country")
.and_then(|v| v.as_str())
.map(String::from),
headimgurl: json
.get("headimgurl")
.and_then(|v| v.as_str())
.map(String::from),
privilege: json.get("privilege").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
}),
unionid: json
.get("unionid")
.and_then(|v| v.as_str())
.map(String::from),
raw: json,
})
}
pub fn verify_signature(
&self,
signature: &str,
timestamp: &str,
nonce: &str,
token: &str,
) -> bool {
let mut hasher = sha1::Sha1::new();
hasher.update(token.as_bytes());
hasher.update(timestamp.as_bytes());
hasher.update(nonce.as_bytes());
let computed = hex::encode(hasher.finalize());
computed == signature
}
pub fn send_template_message(
&self,
touser: &str,
template_id: &str,
data: &serde_json::Value,
) -> Result<(), WechatError> {
self.config.validate()?;
if touser.is_empty() {
return Err(WechatError::MissingField("touser".into()));
}
if template_id.is_empty() {
return Err(WechatError::MissingField("template_id".into()));
}
let access_token = self.fetch_access_token()?;
let url = format!(
"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={}",
percent_encode(&access_token),
);
let body = serde_json::json!({
"touser": touser,
"template_id": template_id,
"data": data,
});
let body_str =
serde_json::to_string(&body).map_err(|err| WechatError::Serialize(err.to_string()))?;
let response = self.transport.post_json(&url, &body_str)?;
let json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| WechatError::Serialize(format!("解析响应失败: {err}")))?;
let errcode = json.get("errcode").and_then(|v| v.as_i64()).unwrap_or(0);
if errcode != 0 {
return Err(WechatError::ApiFailed(format!(
"发送模板消息失败: errcode={errcode}"
)));
}
Ok(())
}
pub fn generate_jsapi_signature(
&self,
url: &str,
noncestr: &str,
timestamp: i64,
jsapi_ticket: &str,
) -> String {
let input = format!(
"jsapi_ticket={}&noncestr={}×tamp={}&url={}",
jsapi_ticket, noncestr, timestamp, url,
);
let mut hasher = sha1::Sha1::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
pub fn get_qrcode_url(&self, scene_str: &str) -> Result<String, WechatError> {
self.config.validate()?;
if scene_str.is_empty() {
return Err(WechatError::MissingField("scene_str".into()));
}
let access_token = self.fetch_access_token()?;
let url = format!(
"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={}",
percent_encode(&access_token),
);
let body = serde_json::json!({
"action_name": "QR_LIMIT_STR_SCENE",
"action_info": {
"scene": {
"scene_str": scene_str,
}
}
});
let body_str =
serde_json::to_string(&body).map_err(|err| WechatError::Serialize(err.to_string()))?;
let response = self.transport.post_json(&url, &body_str)?;
let json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| WechatError::Serialize(format!("解析响应失败: {err}")))?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(WechatError::ApiFailed(format!(
"获取二维码 ticket 失败: errcode={errcode}"
)));
}
}
let ticket = json
.get("ticket")
.and_then(|v| v.as_str())
.ok_or_else(|| WechatError::MissingField("ticket".into()))?;
Ok(format!(
"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={}",
percent_encode(ticket)
))
}
fn fetch_access_token(&self) -> Result<String, WechatError> {
let url = format!(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={}&secret={}",
percent_encode(&self.config.app_id),
percent_encode(&self.config.app_secret),
);
let response = self.transport.get(&url)?;
let json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| WechatError::Serialize(format!("解析 token 响应失败: {err}")))?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(WechatError::TokenFailed(format!(
"获取 access_token 失败: errcode={errcode}"
)));
}
}
json.get("access_token")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| WechatError::TokenFailed("响应缺少 access_token".into()))
}
}
fn percent_encode(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for byte in input.as_bytes() {
if matches!(
byte,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
) {
output.push(*byte as char);
} else {
output.push_str(&format!("%{byte:02X}"));
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wechat_app_type() {
assert_eq!(
WechatAppType::OfficialAccount,
WechatAppType::OfficialAccount
);
assert_eq!(WechatAppType::MiniProgram, WechatAppType::MiniProgram);
assert_eq!(WechatAppType::OpenPlatform, WechatAppType::OpenPlatform);
assert_eq!(WechatAppType::Work, WechatAppType::Work);
assert_ne!(WechatAppType::OfficialAccount, WechatAppType::MiniProgram);
assert_ne!(WechatAppType::OpenPlatform, WechatAppType::Work);
let app_type = WechatAppType::OfficialAccount;
let cloned = app_type;
assert_eq!(app_type, cloned);
let set: std::collections::HashSet<WechatAppType> = [
WechatAppType::OfficialAccount,
WechatAppType::MiniProgram,
WechatAppType::OpenPlatform,
WechatAppType::Work,
]
.into_iter()
.collect();
assert_eq!(set.len(), 4);
assert!(set.contains(&WechatAppType::OfficialAccount));
assert!(set.contains(&WechatAppType::MiniProgram));
assert!(set.contains(&WechatAppType::OpenPlatform));
assert!(set.contains(&WechatAppType::Work));
assert_eq!(
format!("{:?}", WechatAppType::OfficialAccount),
"OfficialAccount"
);
}
#[test]
fn test_wechat_config_builder() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret")
.with_token("wx_token")
.with_encoding_aes_key("wx_aes_key")
.with_oauth_redirect_uri("https://example.com/oauth/callback");
assert_eq!(config.app_type, WechatAppType::OfficialAccount);
assert_eq!(config.app_id, "wx_app_id");
assert_eq!(config.app_secret, "wx_app_secret");
assert_eq!(config.token.as_deref(), Some("wx_token"));
assert_eq!(config.encoding_aes_key.as_deref(), Some("wx_aes_key"));
assert_eq!(
config.oauth_redirect_uri.as_deref(),
Some("https://example.com/oauth/callback")
);
assert!(config.validate().is_ok());
let minimal = WechatConfig::new(WechatAppType::MiniProgram, "wx_mini", "secret");
assert_eq!(minimal.app_type, WechatAppType::MiniProgram);
assert_eq!(minimal.app_id, "wx_mini");
assert_eq!(minimal.app_secret, "secret");
assert!(minimal.token.is_none());
assert!(minimal.encoding_aes_key.is_none());
assert!(minimal.oauth_redirect_uri.is_none());
assert!(minimal.validate().is_ok());
}
#[test]
fn test_wechat_config_validate() {
let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "secret");
assert!(config.validate().is_ok());
let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "secret");
let err = config.validate().unwrap_err();
match err {
WechatError::Config(field) => assert_eq!(field, "app_id"),
other => panic!("期望 Config, 实际 {other:?}"),
}
let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "");
let err = config.validate().unwrap_err();
match err {
WechatError::Config(field) => assert_eq!(field, "app_secret"),
other => panic!("期望 Config, 实际 {other:?}"),
}
let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "");
let err = config.validate().unwrap_err();
match err {
WechatError::Config(field) => assert_eq!(field, "app_id"),
other => panic!("期望 Config, 实际 {other:?}"),
}
}
#[test]
fn test_wechat_user_default() {
let user = WechatUser::default();
assert!(user.openid.is_empty());
assert!(user.nickname.is_none());
assert!(user.sex.is_none());
assert!(user.province.is_none());
assert!(user.city.is_none());
assert!(user.country.is_none());
assert!(user.headimgurl.is_none());
assert!(user.privilege.is_none());
assert!(user.unionid.is_none());
assert!(user.raw.is_null());
}
#[test]
fn test_wechat_user_serialize() {
let user = WechatUser {
openid: "openid_abc".to_string(),
nickname: Some("test_user".into()),
sex: Some(1),
province: Some("广东".into()),
city: Some("深圳".into()),
country: Some("中国".into()),
headimgurl: Some("https://example.com/avatar.png".into()),
privilege: Some(vec!["priv1".into(), "priv2".into()]),
unionid: Some("unionid_xyz".into()),
raw: serde_json::json!({"custom": "field"}),
};
let json = serde_json::to_string(&user).expect("序列化失败");
let parsed: WechatUser = serde_json::from_str(&json).expect("反序列化失败");
assert_eq!(parsed.openid, "openid_abc");
assert_eq!(parsed.nickname.as_deref(), Some("test_user"));
assert_eq!(parsed.sex, Some(1));
assert_eq!(parsed.province.as_deref(), Some("广东"));
assert_eq!(parsed.city.as_deref(), Some("深圳"));
assert_eq!(parsed.country.as_deref(), Some("中国"));
assert_eq!(
parsed.headimgurl.as_deref(),
Some("https://example.com/avatar.png")
);
assert_eq!(
parsed.privilege.as_deref(),
Some(&vec!["priv1".to_string(), "priv2".to_string()][..])
);
assert_eq!(parsed.unionid.as_deref(), Some("unionid_xyz"));
assert_eq!(parsed.raw["custom"], "field");
}
#[test]
fn test_wechat_sdk_get_authorize_url() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret")
.with_oauth_redirect_uri("https://example.com/oauth/callback");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let url = sdk.get_authorize_url("snsapi_userinfo", "state_abc");
assert!(
url.starts_with("https://open.weixin.qq.com/connect/oauth2/authorize?"),
"URL 前缀错误: {url}"
);
assert!(url.contains("appid=wx_app_id"));
assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback"));
assert!(url.contains("response_type=code"));
assert!(url.contains("scope=snsapi_userinfo"));
assert!(url.contains("state=state_abc"));
assert!(url.ends_with("#wechat_redirect"));
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let url = sdk.get_authorize_url("snsapi_base", "state_123");
assert!(url.contains("redirect_uri="));
assert!(url.contains("scope=snsapi_base"));
assert!(url.contains("state=state_123"));
}
#[test]
fn test_wechat_sdk_verify_signature() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let token = "test_token";
let timestamp = "1234567890";
let nonce = "nonce_abc";
let mut hasher = sha1::Sha1::new();
hasher.update(token.as_bytes());
hasher.update(timestamp.as_bytes());
hasher.update(nonce.as_bytes());
let expected = hex::encode(hasher.finalize());
assert!(sdk.verify_signature(&expected, timestamp, nonce, token));
assert!(!sdk.verify_signature("wrong_signature", timestamp, nonce, token));
assert!(sdk.verify_signature("da39a3ee5e6b4b0d3255bfef95601890afd80709", "", "", ""));
let mut hasher = sha1::Sha1::new();
hasher.update(timestamp.as_bytes());
hasher.update(token.as_bytes());
hasher.update(nonce.as_bytes());
let wrong_order = hex::encode(hasher.finalize());
assert!(!sdk.verify_signature(&wrong_order, timestamp, nonce, token));
}
#[test]
fn test_wechat_sdk_generate_jsapi_signature() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let url = "https://example.com/page";
let noncestr = "abc123";
let timestamp = 1609459200_i64;
let jsapi_ticket = "ticket_value";
let input = format!(
"jsapi_ticket={}&noncestr={}×tamp={}&url={}",
jsapi_ticket, noncestr, timestamp, url,
);
let mut hasher = sha1::Sha1::new();
hasher.update(input.as_bytes());
let expected = hex::encode(hasher.finalize());
let signature = sdk.generate_jsapi_signature(url, noncestr, timestamp, jsapi_ticket);
assert_eq!(signature, expected);
assert_eq!(signature.len(), 40);
let other =
sdk.generate_jsapi_signature("https://other.com", noncestr, timestamp, jsapi_ticket);
assert_ne!(signature, other);
let empty_sig = sdk.generate_jsapi_signature("", "", 0, "");
assert_eq!(empty_sig.len(), 40);
}
#[test]
fn test_wechat_sdk_get_user_by_code() {
let transport = Arc::new(MemoryWechatHttpTransport::new());
transport.push_response(
r#"{"access_token":"token123","expires_in":7200,"openid":"openid_abc"}"#,
);
transport.push_response(
r#"{"openid":"openid_abc","nickname":"test_user","sex":1,"province":"广东","city":"深圳","country":"中国","headimgurl":"https://example.com/avatar.png","unionid":"unionid_xyz"}"#,
);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, transport.clone());
let user = sdk
.get_user_by_code("auth_code_abc")
.expect("get_user_by_code 失败");
assert_eq!(user.openid, "openid_abc");
assert_eq!(user.nickname.as_deref(), Some("test_user"));
assert_eq!(user.sex, Some(1));
assert_eq!(user.province.as_deref(), Some("广东"));
assert_eq!(user.city.as_deref(), Some("深圳"));
assert_eq!(user.country.as_deref(), Some("中国"));
assert_eq!(
user.headimgurl.as_deref(),
Some("https://example.com/avatar.png")
);
assert_eq!(user.unionid.as_deref(), Some("unionid_xyz"));
assert_eq!(user.raw["nickname"], "test_user");
assert_eq!(transport.request_count(), 2);
let requests = transport.requests();
assert_eq!(requests[0].0, "GET");
assert!(requests[0].1.contains("sns/oauth2/access_token"));
assert!(requests[0].1.contains("code=auth_code_abc"));
assert_eq!(requests[1].0, "GET");
assert!(requests[1].1.contains("sns/userinfo"));
}
#[test]
fn test_wechat_sdk_get_user_by_code_empty_code() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_user_by_code("").unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "code"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_wechat_sdk_get_user_by_code_missing_access_token() {
let transport = MemoryWechatHttpTransport::new();
transport.push_response(r#"{"errcode":40029,"errmsg":"invalid code"}"#);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(transport));
let err = sdk.get_user_by_code("invalid_code").unwrap_err();
assert!(matches!(err, WechatError::ApiFailed(_)));
}
#[test]
fn test_wechat_sdk_get_user_info() {
let transport = Arc::new(MemoryWechatHttpTransport::new());
transport.push_response(
r#"{"openid":"openid_xyz","nickname":"user_info","sex":2,"province":"北京","city":"北京","country":"中国","headimgurl":"https://example.com/avatar2.png","privilege":["priv_a","priv_b"]}"#,
);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, transport.clone());
let user = sdk
.get_user_info("openid_xyz", "access_token_123")
.expect("get_user_info 失败");
assert_eq!(user.openid, "openid_xyz");
assert_eq!(user.nickname.as_deref(), Some("user_info"));
assert_eq!(user.sex, Some(2));
assert_eq!(user.province.as_deref(), Some("北京"));
assert_eq!(user.city.as_deref(), Some("北京"));
assert_eq!(user.country.as_deref(), Some("中国"));
assert_eq!(
user.headimgurl.as_deref(),
Some("https://example.com/avatar2.png")
);
assert_eq!(
user.privilege.as_deref(),
Some(&vec!["priv_a".to_string(), "priv_b".to_string()][..])
);
assert_eq!(user.raw["openid"], "openid_xyz");
assert_eq!(transport.request_count(), 1);
let requests = transport.requests();
assert_eq!(requests[0].0, "GET");
assert!(requests[0].1.contains("sns/userinfo"));
assert!(requests[0].1.contains("access_token=access_token_123"));
assert!(requests[0].1.contains("openid=openid_xyz"));
}
#[test]
fn test_wechat_sdk_get_user_info_empty_fields() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_user_info("", "token").unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "openid"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
let err = sdk.get_user_info("openid", "").unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "access_token"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_wechat_sdk_send_template_message() {
let transport = Arc::new(MemoryWechatHttpTransport::new());
transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
transport.push_response(r#"{"errcode":0,"errmsg":"ok","msgid":123456789}"#);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, transport.clone());
let data = serde_json::json!({
"first": {"value": "您好,订单已支付"},
"keyword1": {"value": "鲜视达订单 #20240101"},
"remark": {"value": "感谢您的惠顾"}
});
sdk.send_template_message("openid_user", "tpl_id_abc", &data)
.expect("send_template_message 失败");
assert_eq!(transport.request_count(), 2);
let requests = transport.requests();
assert_eq!(requests[0].0, "GET");
assert!(requests[0].1.contains("cgi-bin/token"));
assert!(requests[0].1.contains("grant_type=client_credential"));
assert_eq!(requests[1].0, "POST_JSON");
assert!(requests[1].1.contains("cgi-bin/message/template/send"));
assert!(requests[1].1.contains("access_token=token123"));
let body: serde_json::Value = serde_json::from_str(&requests[1].2).expect("body 应为 JSON");
assert_eq!(body["touser"], "openid_user");
assert_eq!(body["template_id"], "tpl_id_abc");
assert_eq!(body["data"]["first"]["value"], "您好,订单已支付");
}
#[test]
fn test_wechat_sdk_send_template_message_empty_fields() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk
.send_template_message("", "tpl_id", &serde_json::json!({}))
.unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "touser"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
let err = sdk
.send_template_message("user", "", &serde_json::json!({}))
.unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "template_id"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_wechat_sdk_send_template_message_token_failed() {
let transport = MemoryWechatHttpTransport::new();
transport.push_response(r#"{"errcode":40013,"errmsg":"invalid appid"}"#);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(transport));
let err = sdk
.send_template_message("user", "tpl", &serde_json::json!({}))
.unwrap_err();
assert!(matches!(err, WechatError::TokenFailed(_)));
}
#[test]
fn test_wechat_sdk_send_template_message_api_failed() {
let transport = MemoryWechatHttpTransport::new();
transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
transport.push_response(r#"{"errcode":43004,"errmsg":"require subscribe"}"#);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(transport));
let err = sdk
.send_template_message("user", "tpl", &serde_json::json!({}))
.unwrap_err();
assert!(matches!(err, WechatError::ApiFailed(_)));
}
#[test]
fn test_wechat_sdk_get_qrcode_url() {
let transport = Arc::new(MemoryWechatHttpTransport::new());
transport.push_response(r#"{"access_token":"token123","expires_in":7200}"#);
transport.push_response(
r#"{"ticket":"ticket_abc_xyz","url":"http://weixin.qq.com/q/abc","expire_seconds":0}"#,
);
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, transport.clone());
let url = sdk
.get_qrcode_url("scene_123")
.expect("get_qrcode_url 失败");
assert!(url.starts_with("https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket="));
assert!(url.contains("ticket=ticket_abc_xyz"));
assert_eq!(transport.request_count(), 2);
let requests = transport.requests();
assert_eq!(requests[0].0, "GET");
assert!(requests[0].1.contains("cgi-bin/token"));
assert_eq!(requests[1].0, "POST_JSON");
assert!(requests[1].1.contains("cgi-bin/qrcode/create"));
assert!(requests[1].1.contains("access_token=token123"));
let body: serde_json::Value = serde_json::from_str(&requests[1].2).expect("body 应为 JSON");
assert_eq!(body["action_name"], "QR_LIMIT_STR_SCENE");
assert_eq!(body["action_info"]["scene"]["scene_str"], "scene_123");
}
#[test]
fn test_wechat_sdk_get_qrcode_url_empty_scene() {
let config =
WechatConfig::new(WechatAppType::OfficialAccount, "wx_app_id", "wx_app_secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_qrcode_url("").unwrap_err();
match err {
WechatError::MissingField(field) => assert_eq!(field, "scene_str"),
other => panic!("期望 MissingField, 实际 {other:?}"),
}
}
#[test]
fn test_wechat_sdk_missing_config() {
let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_user_by_code("code").unwrap_err();
assert!(matches!(err, WechatError::Config(_)));
let err = sdk.get_user_info("openid", "token").unwrap_err();
assert!(matches!(err, WechatError::Config(_)));
let err = sdk
.send_template_message("user", "tpl", &serde_json::json!({}))
.unwrap_err();
assert!(matches!(err, WechatError::Config(_)));
let err = sdk.get_qrcode_url("scene").unwrap_err();
assert!(matches!(err, WechatError::Config(_)));
let config = WechatConfig::new(WechatAppType::OfficialAccount, "", "secret");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_user_by_code("code").unwrap_err();
match err {
WechatError::Config(field) => assert_eq!(field, "app_id"),
other => panic!("期望 Config, 实际 {other:?}"),
}
let config = WechatConfig::new(WechatAppType::OfficialAccount, "app_id", "");
let sdk = WechatSdk::new(config, Arc::new(MemoryWechatHttpTransport::new()));
let err = sdk.get_user_info("openid", "token").unwrap_err();
match err {
WechatError::Config(field) => assert_eq!(field, "app_secret"),
other => panic!("期望 Config, 实际 {other:?}"),
}
let _url = sdk.get_authorize_url("snsapi_base", "state");
assert!(sdk.verify_signature("sig", "ts", "nonce", "token") || true);
let _sig = sdk.generate_jsapi_signature("url", "ns", 0, "ticket");
}
#[test]
fn test_memory_wechat_http_transport() {
let transport = MemoryWechatHttpTransport::new();
let err = transport.get("https://api.example.com/get").unwrap_err();
match err {
WechatError::HttpTransport(msg) => assert!(msg.contains("无可用预置响应")),
other => panic!("期望 HttpTransport, 实际 {other:?}"),
}
assert_eq!(transport.request_count(), 0);
transport.push_response(r#"{"code":"ok"}"#);
let resp = transport
.get("https://api.example.com/get?foo=bar")
.expect("GET 应返回预置响应");
assert_eq!(resp, r#"{"code":"ok"}"#);
assert_eq!(transport.request_count(), 1);
let requests = transport.requests();
assert_eq!(requests[0].0, "GET");
assert_eq!(requests[0].1, "https://api.example.com/get?foo=bar");
assert_eq!(requests[0].2, "");
transport.push_response(r#"{"errcode":0}"#);
let resp = transport
.post_json("https://api.example.com/post", r#"{"key":"value"}"#)
.expect("POST_JSON 应返回预置响应");
assert_eq!(resp, r#"{"errcode":0}"#);
assert_eq!(transport.request_count(), 2);
let requests = transport.requests();
assert_eq!(requests[1].0, "POST_JSON");
assert_eq!(requests[1].1, "https://api.example.com/post");
assert_eq!(requests[1].2, r#"{"key":"value"}"#);
transport.push_response("form_response");
let resp = transport
.post_form("https://api.example.com/form", "a=1&b=2")
.expect("POST_FORM 应返回预置响应");
assert_eq!(resp, "form_response");
assert_eq!(transport.request_count(), 3);
let requests = transport.requests();
assert_eq!(requests[2].0, "POST_FORM");
assert_eq!(requests[2].1, "https://api.example.com/form");
assert_eq!(requests[2].2, "a=1&b=2");
transport.clear();
transport.push_response("resp1");
transport.push_response("resp2");
transport.push_response("resp3");
let r1 = transport.post_json("url1", "body1").expect("应返回 resp1");
let r2 = transport.get("url2").expect("应返回 resp2");
let r3 = transport.post_form("url3", "body3").expect("应返回 resp3");
assert_eq!(r1, "resp1");
assert_eq!(r2, "resp2");
assert_eq!(r3, "resp3");
assert!(transport.get("url4").is_err());
assert!(transport.post_json("url4", "body4").is_err());
assert!(transport.post_form("url4", "body4").is_err());
assert_eq!(transport.request_count(), 3);
let requests = transport.requests();
assert_eq!(requests[0].0, "POST_JSON");
assert_eq!(requests[0].1, "url1");
assert_eq!(requests[0].2, "body1");
assert_eq!(requests[1].0, "GET");
assert_eq!(requests[1].1, "url2");
assert_eq!(requests[1].2, "");
assert_eq!(requests[2].0, "POST_FORM");
assert_eq!(requests[2].1, "url3");
assert_eq!(requests[2].2, "body3");
transport.clear();
assert_eq!(transport.request_count(), 0);
assert!(transport.get("url").is_err());
}
}