use std::{collections::BTreeMap, error::Error, fmt}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::warn; use url::Url; pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = "https://api.weixin.qq.com/cgi-bin/stable_token"; pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatConfig { pub app_id: Option, pub app_secret: Option, pub stable_access_token_endpoint: String, pub subscribe_message_endpoint: String, } #[derive(Clone, Debug)] pub struct WechatClient { client: Client, config: WechatConfig, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatSubscribeMessageRequest { pub touser: String, pub template_id: String, pub page: Option, pub miniprogram_state: Option, pub lang: Option, pub data: BTreeMap, } #[derive(Debug, PartialEq, Eq)] pub enum WechatError { InvalidConfig(String), RequestFailed(String), DeserializeFailed(String), Upstream(String), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WechatErrorKind { InvalidConfig, RequestFailed, DeserializeFailed, Upstream, } #[derive(Debug, Deserialize)] struct WechatStableAccessTokenResponse { access_token: Option, errcode: Option, errmsg: Option, } #[derive(Debug, Deserialize)] struct WechatSubscribeMessageResponse { errcode: i64, errmsg: Option, } #[derive(Debug, Serialize)] struct WechatTemplateDataValue { value: String, } impl WechatClient { pub fn new(config: WechatConfig) -> Self { Self { client: Client::new(), config, } } pub async fn send_subscribe_message( &self, request: WechatSubscribeMessageRequest, ) -> Result<(), WechatError> { let app_id = self .config .app_id .as_deref() .and_then(non_empty) .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; let app_secret = self .config .app_secret .as_deref() .and_then(non_empty) .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; let access_token = self.request_access_token(app_id, app_secret).await?; let mut send_url = Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) })?; send_url .query_pairs_mut() .append_pair("access_token", &access_token); let data = request .data .into_iter() .map(|(key, value)| (key, WechatTemplateDataValue { value })) .collect::>(); let payload = json!({ "touser": request.touser, "template_id": request.template_id, "page": request.page, "miniprogram_state": request.miniprogram_state, "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), "data": data, }); let response = self .client .post(send_url.as_str()) .json(&payload) .send() .await .map_err(|error| { warn!(error = %error, "微信订阅消息请求失败"); WechatError::RequestFailed("微信订阅消息请求失败".to_string()) })? .json::() .await .map_err(|error| { warn!(error = %error, "微信订阅消息响应解析失败"); WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) })?; if response.errcode != 0 { return Err(WechatError::Upstream(format!( "微信订阅消息发送失败:{}", response.errmsg.unwrap_or_else(|| format!( "subscribeMessage.send 返回错误 {}", response.errcode )) ))); } Ok(()) } async fn request_access_token( &self, app_id: &str, app_secret: &str, ) -> Result { let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) })?; let payload = self .client .post(url.as_str()) .json(&json!({ "grant_type": "client_credential", "appid": app_id, "secret": app_secret, "force_refresh": false, })) .send() .await .map_err(|error| { warn!(error = %error, "微信 stable_token 请求失败"); WechatError::RequestFailed("微信 stable_token 请求失败".to_string()) })? .json::() .await .map_err(|error| { warn!(error = %error, "微信 stable_token 响应解析失败"); WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string()) })?; if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { return Err(WechatError::Upstream(format!( "微信 stable_token 返回错误:{}", payload .errmsg .unwrap_or_else(|| format!("errcode={errcode}")) ))); } payload .access_token .and_then(|value| non_empty_owned(value)) .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) } } impl WechatError { pub fn kind(&self) -> WechatErrorKind { match self { Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig, Self::RequestFailed(_) => WechatErrorKind::RequestFailed, Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed, Self::Upstream(_) => WechatErrorKind::Upstream, } } } impl fmt::Display for WechatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidConfig(message) | Self::RequestFailed(message) | Self::DeserializeFailed(message) | Self::Upstream(message) => f.write_str(message), } } } impl Error for WechatError {} fn non_empty(value: &str) -> Option<&str> { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } } fn non_empty_owned(value: String) -> Option { if value.trim().is_empty() { None } else { Some(value) } }