收口微信领域能力
将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。 将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。 拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。 修正微信相关测试的用户 ID 夹具并同步后端架构文档。
This commit is contained in:
@@ -5,8 +5,18 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aes = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
cbc = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||
ring = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
shared-contracts = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
@@ -1,234 +1,11 @@
|
||||
use std::{collections::BTreeMap, error::Error, fmt};
|
||||
pub mod pay;
|
||||
pub mod subscribe_message;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
pub use pay::{
|
||||
WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayClient,
|
||||
WechatPayConfig, WechatPayError, WechatPayNotifyOrder, WechatWebOrderRequest,
|
||||
};
|
||||
pub use subscribe_message::{
|
||||
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT,
|
||||
WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest,
|
||||
};
|
||||
|
||||
1966
server-rs/crates/platform-wechat/src/pay.rs
Normal file
1966
server-rs/crates/platform-wechat/src/pay.rs
Normal file
File diff suppressed because it is too large
Load Diff
234
server-rs/crates/platform-wechat/src/subscribe_message.rs
Normal file
234
server-rs/crates/platform-wechat/src/subscribe_message.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user