将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。 将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。 拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。 修正微信相关测试的用户 ID 夹具并同步后端架构文档。
235 lines
7.0 KiB
Rust
235 lines
7.0 KiB
Rust
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<String>,
|
|
pub app_secret: Option<String>,
|
|
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<String>,
|
|
pub miniprogram_state: Option<String>,
|
|
pub lang: Option<String>,
|
|
pub data: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
errcode: Option<i64>,
|
|
errmsg: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WechatSubscribeMessageResponse {
|
|
errcode: i64,
|
|
errmsg: Option<String>,
|
|
}
|
|
|
|
#[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::<BTreeMap<_, _>>();
|
|
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::<WechatSubscribeMessageResponse>()
|
|
.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<String, WechatError> {
|
|
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::<WechatStableAccessTokenResponse>()
|
|
.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<String> {
|
|
if value.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
}
|