diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index abc57c4f..b19f64ff 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -64,7 +64,7 @@ 1. 校验 `productId` 2. `paymentChannel = "mock"` 时后端创建已支付订单 -3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数 +3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index cf8e38ae..aca3825e 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -26,6 +26,12 @@ const WECHAT_PAY_PROVIDER_REAL: &str = "real"; const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048"; const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA"; const WECHAT_PAY_NOTIFY_SUCCESS: &str = ""; +const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; +const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; +const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; +const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32; +const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; +const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; #[derive(Clone, Debug)] pub enum WechatPayClient { @@ -77,7 +83,6 @@ pub enum WechatPayError { } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] struct WechatJsapiOrderRequest<'a> { appid: &'a str, mchid: &'a str, @@ -196,6 +201,7 @@ impl WechatPayClient { config.wechat_pay_notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL", )?; + validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?; let jsapi_endpoint = normalize_required_url( &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", @@ -244,6 +250,7 @@ impl RealWechatPayClient { &self, request: WechatMiniProgramOrderRequest, ) -> Result { + validate_jsapi_order_request(self, &request)?; let amount_total = i64::try_from(request.amount_cents) .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; let body = serde_json::to_string(&WechatJsapiOrderRequest { @@ -595,6 +602,105 @@ fn normalize_required_url(value: &str, key: &str) -> Result Result<(), WechatPayError> { + if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS { + return Err(WechatPayError::InvalidConfig(format!( + "{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符" + ))); + } + if value.contains('?') || value.contains('#') { + return Err(WechatPayError::InvalidConfig(format!( + "{key} 不能包含 query 或 fragment" + ))); + } + Ok(()) +} + +fn validate_jsapi_order_request( + client: &RealWechatPayClient, + request: &WechatMiniProgramOrderRequest, +) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + &client.app_id, + WECHAT_PAY_APP_ID_MAX_CHARS, + "微信支付 appid", + )?; + if !client.app_id.starts_with("wx") { + return Err(WechatPayError::InvalidConfig( + "微信支付 appid 必须使用小程序 AppID".to_string(), + )); + } + validate_non_empty_max_chars( + &client.mch_id, + WECHAT_PAY_MCH_ID_MAX_CHARS, + "微信支付 mchid", + )?; + if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) { + return Err(WechatPayError::InvalidConfig( + "微信支付 mchid 必须是数字字符串".to_string(), + )); + } + + validate_non_empty_max_chars( + &request.description, + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "微信支付商品描述", + )?; + validate_out_trade_no(&request.order_id)?; + if request.amount_cents == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付金额必须大于 0 分".to_string(), + )); + } + validate_non_empty_max_chars( + &request.payer_openid, + WECHAT_PAY_OPENID_MAX_CHARS, + "微信支付 payer.openid", + )?; + Ok(()) +} + +fn validate_non_empty_max_chars( + value: &str, + max_chars: usize, + field_name: &str, +) -> Result<(), WechatPayError> { + let value = value.trim(); + if value.is_empty() { + return Err(WechatPayError::InvalidRequest(format!( + "{field_name} 不能为空" + ))); + } + if value.chars().count() > max_chars { + return Err(WechatPayError::InvalidRequest(format!( + "{field_name} 不能超过 {max_chars} 字符" + ))); + } + Ok(()) +} + +fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + value, + WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS, + "微信支付 out_trade_no", + )?; + if value.chars().count() < 6 { + return Err(WechatPayError::InvalidRequest( + "微信支付 out_trade_no 不能少于 6 字符".to_string(), + )); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*')) + { + return Err(WechatPayError::InvalidRequest( + "微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(), + )); + } + Ok(()) +} + fn read_private_key_pem( inline_pem: Option<&str>, path: Option<&Path>, @@ -768,6 +874,63 @@ mod tests { assert!(!params.pay_sign.is_empty()); } + #[test] + fn jsapi_order_request_uses_wechat_v3_snake_case_fields() { + let body = serde_json::to_value(WechatJsapiOrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + payer: WechatJsapiPayer { + openid: "openid-test", + }, + }) + .expect("JSAPI order request should serialize"); + + assert_eq!(body["out_trade_no"], "rcgtest001"); + assert_eq!( + body["notify_url"], + "https://api.example.com/api/profile/recharge/wechat/notify" + ); + assert!(body.get("outTradeNo").is_none()); + assert!(body.get("notifyUrl").is_none()); + } + + #[test] + fn jsapi_order_request_rejects_provider_field_limit_violations() { + assert!(validate_out_trade_no("abc12").is_err()); + assert!(validate_out_trade_no("abc123").is_ok()); + assert!(validate_out_trade_no("abc123_-|*").is_ok()); + assert!(validate_out_trade_no("abc123中文").is_err()); + assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err()); + + assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok()); + assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err()); + assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err()); + + validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述") + .expect("short description should pass"); + assert!( + validate_non_empty_max_chars( + &"泥".repeat(128), + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "描述" + ) + .is_err() + ); + validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid") + .expect("short openid should pass"); + assert!( + validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid") + .is_err() + ); + } + #[test] fn parse_mock_notify_defaults_success_state() { let notify = diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 53336179..829719be 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -927,10 +927,47 @@ pub fn build_runtime_profile_recharge_order_id( created_at_micros: i64, product_id: &str, ) -> String { - format!( - "recharge:{}", - build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id) - ) + // 微信支付 v3 的 out_trade_no 只接受较短的字母、数字和部分符号。 + // 订单号同时作为本地 profile_recharge_order 主键,因此统一使用可支付渠道兼容的紧凑格式。 + let timestamp = encode_runtime_profile_recharge_order_base36(created_at_micros.unsigned_abs()); + let hash = hash_runtime_profile_recharge_order_key(user_id, product_id, created_at_micros); + format!("rcg{timestamp}{:010x}", hash & 0x0000_0003_ffff_ffff) +} + +fn encode_runtime_profile_recharge_order_base36(mut value: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if value == 0 { + return "0".to_string(); + } + + let mut buffer = Vec::new(); + while value > 0 { + buffer.push(DIGITS[(value % 36) as usize] as char); + value /= 36; + } + buffer.iter().rev().collect() +} + +fn hash_runtime_profile_recharge_order_key( + user_id: &str, + product_id: &str, + created_at_micros: i64, +) -> u64 { + let mut hash = 14_695_981_039_346_656_037u64; + for byte in user_id + .trim() + .as_bytes() + .iter() + .copied() + .chain([b':']) + .chain(product_id.trim().as_bytes().iter().copied()) + .chain([b':']) + .chain(created_at_micros.to_le_bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(1_099_511_628_211); + } + hash } pub fn resolve_runtime_profile_points_recharge_delta( diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 9a385879..3f8eea42 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -683,9 +683,13 @@ mod tests { build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"), "user-1:200:points_60" ); - assert_eq!( - build_runtime_profile_recharge_order_id("user-1", 200, "points_60"), - "recharge:user-1:200:points_60" + let order_id = build_runtime_profile_recharge_order_id("user-1", 200, "points_60"); + assert!(order_id.starts_with("rcg")); + assert!(order_id.len() <= 32); + assert!( + order_id + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) ); assert_eq!( build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),