收口微信领域能力

将 api-server 微信 HTTP/BFF 适配统一迁移到 wechat 目录。

将微信支付和虚拟支付消息协议细节下沉到 platform-wechat。

拆分 platform-wechat 的订阅消息与支付模块并补齐依赖。

修正微信相关测试的用户 ID 夹具并同步后端架构文档。
This commit is contained in:
kdletters
2026-06-08 21:05:37 +08:00
parent 11c5e3edf4
commit 088470a315
34 changed files with 925 additions and 837 deletions

View File

@@ -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 }

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View 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)
}
}