feat: switch mini program recharge to virtual payment
This commit is contained in:
@@ -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(¶ms.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"));
|
||||
|
||||
Reference in New Issue
Block a user