feat: switch mini program recharge to virtual payment
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct WechatIdentityProfile {
|
||||
pub provider_union_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 已绑定微信身份快照。
|
||||
@@ -124,6 +125,7 @@ pub struct WechatIdentityRecord {
|
||||
pub user_id: String,
|
||||
pub provider_uid: String,
|
||||
pub provider_union_id: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 微信授权 state 快照。
|
||||
|
||||
@@ -97,6 +97,7 @@ struct StoredWechatIdentity {
|
||||
provider_union_id: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1292,6 +1293,7 @@ impl InMemoryAuthStore {
|
||||
provider_union_id: normalize_optional_string(profile.provider_union_id),
|
||||
display_name: normalize_optional_string(profile.display_name),
|
||||
avatar_url,
|
||||
session_key: normalize_optional_string(profile.session_key),
|
||||
};
|
||||
if let Some(provider_union_id) = identity.provider_union_id.clone() {
|
||||
state
|
||||
@@ -1361,6 +1363,7 @@ impl InMemoryAuthStore {
|
||||
user_id: identity.user_id.clone(),
|
||||
provider_uid: identity.provider_uid.clone(),
|
||||
provider_union_id: identity.provider_union_id.clone(),
|
||||
session_key: identity.session_key.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1377,6 +1380,7 @@ impl InMemoryAuthStore {
|
||||
let next_display_name = normalize_optional_string(profile.display_name);
|
||||
let next_avatar_url = normalize_optional_string(profile.avatar_url);
|
||||
let next_provider_union_id = normalize_optional_string(profile.provider_union_id);
|
||||
let next_session_key = normalize_optional_string(profile.session_key);
|
||||
let next_provider_uid =
|
||||
normalize_required_string(&profile.provider_uid).unwrap_or_default();
|
||||
{
|
||||
@@ -1398,6 +1402,9 @@ impl InMemoryAuthStore {
|
||||
identity.display_name = next_display_name.clone();
|
||||
identity.avatar_url = next_avatar_url;
|
||||
identity.provider_union_id = next_provider_union_id.clone();
|
||||
if next_session_key.is_some() {
|
||||
identity.session_key = next_session_key.clone();
|
||||
}
|
||||
state
|
||||
.wechat_identity_by_provider_uid
|
||||
.insert(next_provider_uid.clone(), identity);
|
||||
@@ -3193,6 +3200,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-shared".to_string()),
|
||||
display_name: Some("微信旅人甲".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3211,6 +3219,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-shared".to_string()),
|
||||
display_name: Some("微信旅人乙".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3258,6 +3267,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-bind".to_string()),
|
||||
display_name: Some("待绑定微信用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
@@ -3303,6 +3313,7 @@ mod tests {
|
||||
provider_union_id: Some("wx-union-bind".to_string()),
|
||||
display_name: Some("已归并微信用户".to_string()),
|
||||
avatar_url: None,
|
||||
session_key: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL: &str = "wechat_mp_virtual";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct WechatIdentityProfile {
|
||||
pub provider_union_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub session_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -359,6 +360,7 @@ struct WechatUserInfoResponse {
|
||||
struct WechatJsCodeSessionResponse {
|
||||
openid: Option<String>,
|
||||
unionid: Option<String>,
|
||||
session_key: Option<String>,
|
||||
errcode: Option<i64>,
|
||||
errmsg: Option<String>,
|
||||
}
|
||||
@@ -834,6 +836,7 @@ impl MockWechatProvider {
|
||||
provider_union_id: self.mock_union_id.clone(),
|
||||
display_name: Some(self.mock_display_name.clone()),
|
||||
avatar_url: self.mock_avatar_url.clone(),
|
||||
session_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -975,6 +978,7 @@ impl RealWechatProvider {
|
||||
provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid),
|
||||
display_name: user_info_payload.nickname,
|
||||
avatar_url: user_info_payload.headimgurl,
|
||||
session_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1053,6 +1057,7 @@ impl RealWechatProvider {
|
||||
provider_union_id: payload.unionid,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
session_key: payload.session_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,22 @@ pub struct WechatMiniProgramPayParamsResponse {
|
||||
pub pay_sign: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramVirtualPayParamsResponse {
|
||||
pub mode: String,
|
||||
pub sign_data: String,
|
||||
pub pay_sig: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum WechatMiniProgramPaymentParamsResponse {
|
||||
Ordinary(WechatMiniProgramPayParamsResponse),
|
||||
Virtual(WechatMiniProgramVirtualPayParamsResponse),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatH5PaymentResponse {
|
||||
@@ -283,7 +299,7 @@ pub struct CreateProfileRechargeOrderResponse {
|
||||
pub order: ProfileRechargeOrderResponse,
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
#[serde(default)]
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPaymentParamsResponse>,
|
||||
#[serde(default)]
|
||||
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
||||
#[serde(default)]
|
||||
@@ -1451,6 +1467,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_recharge_order_response_serializes_virtual_wechat_payloads() {
|
||||
let order = ProfileRechargeOrderResponse {
|
||||
order_id: "rcgtest002".to_string(),
|
||||
product_id: "member_month".to_string(),
|
||||
product_title: "月卡".to_string(),
|
||||
kind: "membership".to_string(),
|
||||
amount_cents: 2800,
|
||||
status: "pending".to_string(),
|
||||
payment_channel: "wechat_mp_virtual".to_string(),
|
||||
paid_at: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-15T10:00:00Z".to_string(),
|
||||
points_delta: 0,
|
||||
membership_expires_at: Some("2026-06-15T10:00:00Z".to_string()),
|
||||
};
|
||||
let center = ProfileRechargeCenterResponse {
|
||||
wallet_balance: 0,
|
||||
membership: ProfileMembershipResponse {
|
||||
status: "normal".to_string(),
|
||||
tier: "normal".to_string(),
|
||||
started_at: None,
|
||||
expires_at: None,
|
||||
updated_at: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
|
||||
order,
|
||||
center,
|
||||
wechat_mini_program_pay_params: Some(WechatMiniProgramPaymentParamsResponse::Virtual(
|
||||
WechatMiniProgramVirtualPayParamsResponse {
|
||||
mode: "short_series_goods".to_string(),
|
||||
sign_data:
|
||||
"{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}"
|
||||
.to_string(),
|
||||
pay_sig: "pay-sig".to_string(),
|
||||
signature: "user-sig".to_string(),
|
||||
},
|
||||
)),
|
||||
wechat_h5_payment: None,
|
||||
wechat_native_payment: None,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["mode"],
|
||||
json!("short_series_goods")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["signData"],
|
||||
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
||||
);
|
||||
assert_eq!(payload["wechatMiniProgramPayParams"]["paySig"], json!("pay-sig"));
|
||||
assert_eq!(payload["wechatMiniProgramPayParams"]["signature"], json!("user-sig"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
||||
|
||||
Reference in New Issue
Block a user