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),