feat: switch mini program recharge to virtual payment

This commit is contained in:
kdletters
2026-05-26 22:32:16 +08:00
parent b388b124da
commit f36b90ebdb
22 changed files with 959 additions and 137 deletions

View File

@@ -38,6 +38,7 @@ platform-image = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
hmac = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -64,7 +65,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
[dev-dependencies]
base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
tower = { workspace = true, features = ["util"] }

View File

@@ -96,6 +96,10 @@ pub struct AppConfig {
pub wechat_pay_api_v3_key: Option<String>,
pub wechat_pay_notify_url: Option<String>,
pub wechat_pay_jsapi_endpoint: String,
pub wechat_mini_program_virtual_payment_offer_id: Option<String>,
pub wechat_mini_program_virtual_payment_app_key: Option<String>,
pub wechat_mini_program_virtual_payment_sandbox_app_key: Option<String>,
pub wechat_mini_program_virtual_payment_env: u8,
pub oss_bucket: Option<String>,
pub oss_endpoint: Option<String>,
pub oss_access_key_id: Option<String>,
@@ -240,6 +244,10 @@ impl Default for AppConfig {
wechat_pay_notify_url: None,
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
.to_string(),
wechat_mini_program_virtual_payment_offer_id: None,
wechat_mini_program_virtual_payment_app_key: None,
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_virtual_payment_env: 0,
oss_bucket: None,
oss_endpoint: None,
oss_access_key_id: None,
@@ -590,6 +598,18 @@ impl AppConfig {
{
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
}
config.wechat_mini_program_virtual_payment_offer_id =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"]);
config.wechat_mini_program_virtual_payment_app_key =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"]);
config.wechat_mini_program_virtual_payment_sandbox_app_key =
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"]);
if let Some(env) =
read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"])
&& env <= 1
{
config.wechat_mini_program_virtual_payment_env = env;
}
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
@@ -1379,6 +1399,10 @@ mod tests {
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
std::env::set_var("WECHAT_PAY_ENABLED", "true");
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
@@ -1394,6 +1418,19 @@ mod tests {
"WECHAT_PAY_NOTIFY_URL",
"https://api.example.com/api/profile/recharge/wechat/notify",
);
std::env::set_var(
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID",
"offer-001",
);
std::env::set_var(
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY",
"app-key-001",
);
std::env::set_var(
"WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY",
"sandbox-app-key-001",
);
std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1");
}
let config = AppConfig::from_env();
@@ -1416,6 +1453,21 @@ mod tests {
config.wechat_pay_platform_serial_no.as_deref(),
Some("platform-serial-001")
);
assert_eq!(
config.wechat_mini_program_virtual_payment_offer_id.as_deref(),
Some("offer-001")
);
assert_eq!(
config.wechat_mini_program_virtual_payment_app_key.as_deref(),
Some("app-key-001")
);
assert_eq!(
config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref(),
Some("sandbox-app-key-001")
);
assert_eq!(config.wechat_mini_program_virtual_payment_env, 1);
unsafe {
std::env::remove_var("WECHAT_PAY_ENABLED");
@@ -1427,6 +1479,10 @@ mod tests {
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY");
std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV");
}
}

View File

@@ -8,6 +8,7 @@ use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
@@ -23,6 +24,8 @@ use module_runtime::{
};
use serde::Deserialize;
use serde_json::{Value, json};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use shared_contracts::runtime::{
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
@@ -59,7 +62,8 @@ use shared_contracts::runtime::{
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
WechatMiniProgramVirtualPayParamsResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
@@ -78,6 +82,8 @@ use crate::{
},
};
type HmacSha256 = Hmac<Sha256>;
pub async fn get_profile_dashboard(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -231,7 +237,7 @@ pub async fn create_profile_recharge_order(
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
state
.wechat_pay_client()
.create_mini_program_order(build_wechat_payment_request(
@@ -244,6 +250,15 @@ pub async fn create_profile_recharge_order(
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
))
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
build_wechat_virtual_pay_params(&state, &order, &openid)
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
)
} else {
None
@@ -1059,6 +1074,9 @@ fn validate_recharge_device_for_payment_channel(
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
_ => false,
@@ -1106,6 +1124,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
matches!(
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
@@ -1148,6 +1167,127 @@ async fn resolve_wechat_identity_for_payment(
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn build_wechat_virtual_pay_params(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
openid: &str,
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
})?;
let session_key = identity.session_key.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前微信登录态缺少 session_key请重新登录后再试")
})?;
let product = module_runtime::runtime_profile_recharge_product_by_id(&order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值商品不存在")
})?;
let offer_id = required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_offer_id
.as_deref(),
"微信虚拟支付 OfferId 未配置",
)?;
let mode = match product.kind {
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
};
let mut sign_data = serde_json::json!({
"offerId": offer_id,
"buyQuantity": 1,
"env": state.config.wechat_mini_program_virtual_payment_env,
"currencyType": "CNY",
"outTradeNo": order.order_id,
"attach": serde_json::json!({
"userId": order.user_id,
"productId": order.product_id,
"paymentChannel": order.payment_channel,
"openId": openid,
}).to_string(),
});
if product.kind == RuntimeProfileRechargeProductKind::Membership {
sign_data["productId"] = json!(product.product_id);
sign_data["goodsPrice"] = json!(product.price_cents);
}
let sign_data = sign_data.to_string();
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
let signature = calc_wechat_virtual_payment_signature_with_key(&session_key, &sign_data)?;
Ok(WechatMiniProgramVirtualPayParamsResponse {
mode: mode.to_string(),
sign_data,
pay_sig,
signature,
})
}
fn calc_wechat_virtual_payment_signature(
state: &AppState,
sign_data: &str,
use_sandbox_key: bool,
) -> Result<String, AppError> {
let env = state.config.wechat_mini_program_virtual_payment_env;
let app_key = if use_sandbox_key || env == 1 {
state
.config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref()
.or(state.config.wechat_mini_program_virtual_payment_app_key.as_deref())
} else {
state
.config
.wechat_mini_program_virtual_payment_app_key
.as_deref()
}
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信虚拟支付 AppKey 未配置")
})?;
calc_wechat_virtual_payment_signature_with_key(app_key, sign_data)
}
fn required_wechat_virtual_payment_config<'a>(
value: Option<&'a str>,
message: &str,
) -> Result<&'a str, AppError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
}
fn calc_wechat_virtual_payment_signature_with_key(
key: &str,
sign_data: &str,
) -> Result<String, AppError> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("微信虚拟支付签名密钥初始化失败")
})?;
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
}
fn to_lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(char::from(HEX[(byte >> 4) as usize]));
output.push(char::from(HEX[(byte & 0x0f) as usize]));
}
output
}
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
order
.success_time
@@ -1619,9 +1759,17 @@ fn build_profile_redeem_code_admin_response(
#[cfg(test)]
mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType;
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRechargeProductKind, RuntimeProfileWalletLedgerSourceType,
};
use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
use super::{
build_wechat_virtual_pay_params, format_profile_wallet_ledger_source_type,
normalize_admin_invite_code_metadata,
};
use axum::{
body::Body,
@@ -2082,6 +2230,70 @@ mod tests {
);
}
#[tokio::test]
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-00000001".to_string(),
provider_union_id: Some("union-user-00000001".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-1".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id;
let order = RuntimeProfileRechargeOrderRecord {
order_id: "memberorder01".to_string(),
user_id: user_id.clone(),
product_id: "member_month".to_string(),
product_title: "月卡".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 2800,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-26T10:00:00Z".to_string(),
created_at_micros: 1_779_756_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let params = build_wechat_virtual_pay_params(&state, &order, "openid-user-00000001")
.expect("membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
let attach: Value = serde_json::from_str(
sign_data["attach"]
.as_str()
.expect("attach should be string json"),
)
.expect("attach should decode");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["offerId"], "offer-1");
assert_eq!(sign_data["productId"], "member_month");
assert_eq!(sign_data["goodsPrice"], 2800);
assert_eq!(sign_data["outTradeNo"], "memberorder01");
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
assert!(!params.pay_sig.is_empty());
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -385,6 +385,7 @@ fn map_wechat_profile_to_domain(
provider_union_id: profile.provider_union_id,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
session_key: profile.session_key,
}
}