fix: lock recharge flow until virtual payment settles

This commit is contained in:
kdletters
2026-06-02 01:47:39 +08:00
parent 1cb11bc1dd
commit 2fdeb34567
13 changed files with 1167 additions and 246 deletions

View File

@@ -8,7 +8,7 @@
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_goods` 模式,并在 `signData` 内带 `productId``goodsPrice` - 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_goods` 模式,并在 `signData` 内带 `productId``goodsPrice`
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。 - H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。 - `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。 - 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
- 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。 - 小程序 WebView 默认进入时会静默调用 `wx.login` 刷新后端微信登录态,避免历史登录用户只有前端 JWT、后端缺少 `session_key` 时无法生成虚拟支付签名。
## 关键文件 ## 关键文件
@@ -18,6 +18,7 @@
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js` - 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
- API 契约:`packages/shared/src/contracts/runtime.ts``server-rs/crates/shared-contracts/src/runtime.rs` - API 契约:`packages/shared/src/contracts/runtime.ts``server-rs/crates/shared-contracts/src/runtime.rs`
- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs` - 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs`
- WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events``POST /api/profile/recharge/orders/{orderId}/wechat/confirm`
- 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs``server-rs/crates/module-auth/src/lib.rs` - 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs``server-rs/crates/module-auth/src/lib.rs`
## 后端配置 ## 后端配置
@@ -66,4 +67,6 @@ npm run check:encoding
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storageWebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。 - 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storageWebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify``xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`JSON 入站回 JSONXML 入站回 XML错误时带具体 `ErrMsg` 便于微信侧重试与排障。 - 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify``xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`JSON 入站回 JSONXML 入站回 XML错误时带具体 `ErrMsg` 便于微信侧重试与排障。
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
- Web 侧在“正在支付”状态下会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底后端收到虚拟支付消息推送并入账后会发布订单更新SSE 先推当前订单快照,再在订单结束时推 `done`
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。

View File

@@ -183,6 +183,15 @@ export type ConfirmWechatProfileRechargeOrderResponse = {
center: ProfileRechargeCenterResponse; center: ProfileRechargeCenterResponse;
}; };
export type WechatProfileRechargeOrderDoneEvent = {
orderId: string;
status: ProfileRechargeOrderStatus;
};
export type WechatProfileRechargeOrderErrorEvent = {
message: string;
};
export type ProfileFeedbackStatus = 'open'; export type ProfileFeedbackStatus = 'open';
export type ProfileFeedbackEvidenceItemInput = { export type ProfileFeedbackEvidenceItemInput = {

View File

@@ -321,9 +321,8 @@ fn validate_admin_creation_entry_config(
let unified_creation_spec_json = unified_creation_spec let unified_creation_spec_json = unified_creation_spec
.as_ref() .as_ref()
.map(|spec| { .map(|spec| {
encode_unified_creation_spec_response(spec).map_err(|error| { encode_unified_creation_spec_response(spec)
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error) .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
})
}) })
.transpose()?; .transpose()?;
Ok(module_runtime::CreationEntryTypeAdminUpsertInput { Ok(module_runtime::CreationEntryTypeAdminUpsertInput {

View File

@@ -14,7 +14,8 @@ use crate::{
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code, submit_profile_feedback, redeem_profile_reward_code, stream_wechat_profile_recharge_order_events,
submit_profile_feedback,
}, },
runtime_save::{list_profile_save_archives, resume_profile_save_archive}, runtime_save::{list_profile_save_archives, resume_profile_save_archive},
state::AppState, state::AppState,
@@ -73,6 +74,12 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_bearer_auth), middleware::from_fn_with_state(state.clone(), require_bearer_auth),
), ),
) )
.route(
"/api/profile/recharge/orders/{order_id}/wechat/events",
get(stream_wechat_profile_recharge_order_events).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route( .route(
"/api/profile/feedback", "/api/profile/feedback",
post(submit_profile_feedback) post(submit_profile_feedback)

View File

@@ -2,7 +2,10 @@ use axum::{
Json, Json,
extract::{Extension, Path, Query, State}, extract::{Extension, Path, Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::{
IntoResponse, Response,
sse::{Event, Sse},
},
}; };
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use module_runtime::{ use module_runtime::{
@@ -23,7 +26,7 @@ use module_runtime::{
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind, RuntimeTrackingScopeKind,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::Sha256; use sha2::Sha256;
use shared_contracts::runtime::{ use shared_contracts::runtime::{
@@ -63,10 +66,12 @@ use shared_contracts::runtime::{
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE, SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
WechatMiniProgramVirtualPayParamsResponse, WechatMiniProgramVirtualPayParamsResponse, WechatProfileRechargeOrderDoneEvent,
WechatProfileRechargeOrderErrorEvent,
}; };
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use std::{convert::Infallible, time::Duration};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{ use crate::{
@@ -347,19 +352,19 @@ pub async fn confirm_wechat_profile_recharge_order(
if order.status == RuntimeProfileRechargeOrderStatus::Paid { if order.status == RuntimeProfileRechargeOrderStatus::Paid {
return Ok(json_success_body( return Ok(json_success_body(
Some(&request_context), Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse { build_wechat_profile_recharge_order_confirmation(center, order),
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
)); ));
} }
if order.status != RuntimeProfileRechargeOrderStatus::Pending { if order.status != RuntimeProfileRechargeOrderStatus::Pending {
return Ok(json_success_body( return Ok(json_success_body(
Some(&request_context), Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse { build_wechat_profile_recharge_order_confirmation(center, order),
order: build_profile_recharge_order_response(order), ));
center: build_profile_recharge_center_response(center), }
}, if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
)); ));
} }
@@ -381,10 +386,7 @@ pub async fn confirm_wechat_profile_recharge_order(
if wechat_order.trade_state != "SUCCESS" { if wechat_order.trade_state != "SUCCESS" {
return Ok(json_success_body( return Ok(json_success_body(
Some(&request_context), Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse { build_wechat_profile_recharge_order_confirmation(center, order),
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
)); ));
} }
@@ -406,13 +408,94 @@ pub async fn confirm_wechat_profile_recharge_order(
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse { build_wechat_profile_recharge_order_confirmation(center, order),
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
)) ))
} }
pub async fn stream_wechat_profile_recharge_order_events(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(order_id): Path<String>,
) -> Result<Response, Response> {
let user_id = authenticated.claims().user_id().to_string();
let (center, order) = load_user_wechat_profile_recharge_order(
&state,
&request_context,
user_id.clone(),
order_id.clone(),
)
.await?;
let stream_state = state.clone();
let stream_context = request_context.clone();
let stream = async_stream::stream! {
let initial_response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"order",
&initial_response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
order_id: order.order_id.clone(),
status: build_profile_recharge_order_status(order.status),
},
));
return;
}
let mut updates = stream_state.subscribe_profile_recharge_order_updates();
let mut poll_interval = tokio::time::interval(Duration::from_millis(1200));
for _ in 0..25 {
tokio::select! {
maybe_order_id = updates.recv() => {
if !matches!(maybe_order_id, Ok(ref value) if value == &order_id) {
continue;
}
}
_ = poll_interval.tick() => {}
}
match load_user_wechat_profile_recharge_order(
&stream_state,
&stream_context,
user_id.clone(),
order_id.clone(),
).await {
Ok((center, order)) => {
let response = build_wechat_profile_recharge_order_confirmation(center, order.clone());
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"order",
&response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
order_id: order.order_id.clone(),
status: build_profile_recharge_order_status(order.status),
},
));
return;
}
}
Err(_) => {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"error",
&WechatProfileRechargeOrderErrorEvent {
message: "读取充值订单状态失败".to_string(),
},
));
return;
}
}
}
};
Ok(Sse::new(stream).into_response())
}
pub async fn submit_profile_feedback( pub async fn submit_profile_feedback(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -1029,6 +1112,81 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context)) error.into_response_with_context(Some(request_context))
} }
async fn load_user_wechat_profile_recharge_order(
state: &AppState,
request_context: &RequestContext,
user_id: String,
order_id: String,
) -> Result<
(
RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord,
),
Response,
> {
let (center, order) = state
.spacetime_client()
.get_profile_recharge_order(order_id)
.await
.map_err(|error| {
runtime_profile_error_response(request_context, map_runtime_profile_client_error(error))
})?;
if order.user_id != user_id {
return Err(runtime_profile_error_response(
request_context,
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
));
}
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
return Err(runtime_profile_error_response(
request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信支付订单"),
));
}
Ok((center, order))
}
fn build_wechat_profile_recharge_order_confirmation(
center: RuntimeProfileRechargeCenterRecord,
order: RuntimeProfileRechargeOrderRecord,
) -> ConfirmWechatProfileRechargeOrderResponse {
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
}
}
fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus) -> String {
match status {
RuntimeProfileRechargeOrderStatus::Pending => "pending",
RuntimeProfileRechargeOrderStatus::Paid => "paid",
RuntimeProfileRechargeOrderStatus::Failed => "failed",
RuntimeProfileRechargeOrderStatus::Closed => "closed",
RuntimeProfileRechargeOrderStatus::Refunded => "refunded",
}
.to_string()
}
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
where
T: Serialize,
{
Event::default()
.event(event_name)
.json_data(payload)
.unwrap_or_else(|_| {
Event::default()
.event("error")
.json_data(&WechatProfileRechargeOrderErrorEvent {
message: "充值订单状态事件序列化失败".to_string(),
})
.unwrap_or_else(|_| Event::default().event("error").data("{}"))
})
}
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> { fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
raw.map(|value| value.trim().to_string()) raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())

View File

@@ -27,7 +27,7 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::sync::Semaphore; use tokio::sync::{Semaphore, broadcast};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -257,6 +257,7 @@ pub struct AppStateInner {
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。 // Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>, creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
profile_recharge_order_updates: broadcast::Sender<String>,
#[cfg(test)] #[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>, test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
@@ -394,6 +395,7 @@ impl AppState {
let llm_client = build_llm_client(&config)?; let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
Ok(Self(Arc::new(AppStateInner { Ok(Self(Arc::new(AppStateInner {
config, config,
@@ -423,6 +425,7 @@ impl AppState {
creative_agent_gpt5_client, creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
#[cfg(test)] #[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
}))) })))
@@ -710,6 +713,16 @@ impl AppState {
self.creative_agent_executor.clone() self.creative_agent_executor.clone()
} }
pub fn subscribe_profile_recharge_order_updates(
&self,
) -> tokio::sync::broadcast::Receiver<String> {
self.profile_recharge_order_updates.subscribe()
}
pub fn publish_profile_recharge_order_update(&self, order_id: impl Into<String>) {
let _ = self.profile_recharge_order_updates.send(order_id.into());
}
pub fn get_creative_agent_session( pub fn get_creative_agent_session(
&self, &self,
session_id: &str, session_id: &str,

View File

@@ -9,9 +9,7 @@ use axum::{
}; };
use base64::{ use base64::{
Engine as _, alphabet, Engine as _, alphabet,
engine::general_purpose::{ engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD,
},
}; };
use bytes::Bytes; use bytes::Bytes;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
@@ -1017,6 +1015,8 @@ pub async fn handle_wechat_virtual_payment_notify(
); );
} }
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
info!( info!(
event = notify.event.as_str(), event = notify.event.as_str(),
order_id = notify.out_trade_no.as_str(), order_id = notify.out_trade_no.as_str(),
@@ -1152,9 +1152,7 @@ fn resolve_wechat_message_push_verify_response(
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| { .ok_or_else(|| WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()))?;
WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string())
})?;
if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) { if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) {
return Err(WechatPayError::InvalidSignature( return Err(WechatPayError::InvalidSignature(
"微信消息推送校验签名无效".to_string(), "微信消息推送校验签名无效".to_string(),

View File

@@ -262,8 +262,18 @@ mod tests {
); );
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert!(jump_hop.fields.iter().any(|field| field.id == "stylePreset")); assert!(
assert!(jump_hop.fields.iter().any(|field| field.id == "endMoodPrompt")); jump_hop
.fields
.iter()
.any(|field| field.id == "stylePreset")
);
assert!(
jump_hop
.fields
.iter()
.any(|field| field.id == "endMoodPrompt")
);
let wooden_fish = let wooden_fish =
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");

View File

@@ -313,6 +313,19 @@ pub struct ConfirmWechatProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse, pub center: ProfileRechargeCenterResponse,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WechatProfileRechargeOrderDoneEvent {
pub order_id: String,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WechatProfileRechargeOrderErrorEvent {
pub message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfileFeedbackEvidenceItemRequest { pub struct ProfileFeedbackEvidenceItemRequest {

View File

@@ -49,6 +49,7 @@ const {
mockGetRpgProfileTasks, mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger, mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode, mockRedeemRpgProfileReferralInviteCode,
mockWatchWechatRpgProfileRechargeOrder,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR'); const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn(); const redirectToPaymentUrl = vi.fn();
@@ -313,6 +314,7 @@ const {
}, },
], ],
})), })),
mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null),
}; };
}); });
@@ -379,6 +381,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
confirmWechatRpgProfileRechargeOrder: confirmWechatRpgProfileRechargeOrder:
mockConfirmWechatRpgProfileRechargeOrder, mockConfirmWechatRpgProfileRechargeOrder,
watchWechatRpgProfileRechargeOrder: mockWatchWechatRpgProfileRechargeOrder,
})); }));
vi.mock('../ResolvedAssetImage', () => ({ vi.mock('../ResolvedAssetImage', () => ({
@@ -1407,7 +1410,7 @@ test('profile recharge modal posts virtual payment params in mini program web-vi
'requestId', 'requestId',
); );
expect(requestId).toBeTruthy(); expect(requestId).toBeTruthy();
expect(screen.getByRole('dialog', { name: '正在支付' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '正在支付' })).toBeNull();
act(() => { act(() => {
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-1`; window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-1`;
window.dispatchEvent(new HashChangeEvent('hashchange')); window.dispatchEvent(new HashChangeEvent('hashchange'));
@@ -1928,6 +1931,234 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}); });
test('profile recharge modal confirms virtual payment after returning without hash result', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-no-hash-paid","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
})
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-no-hash',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
renderProfileView(onRechargeSuccess);
await openRechargeModal(user);
await user.click(await screen.findByRole('button', { name: /60/u }));
act(() => {
window.dispatchEvent(new PageTransitionEvent('pageshow'));
});
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-no-hash-paid',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal blocks tab navigation while virtual payment confirmation is pending', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-confirm-mask',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-confirm-mask","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-confirm-mask',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
});
mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce(new Promise(() => undefined));
renderProfileView();
await openRechargeModal(user);
await user.click(await screen.findByRole('button', { name: /60/u }));
act(() => {
window.dispatchEvent(new PageTransitionEvent('pageshow'));
});
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
expect(
(screen.getByRole('button', { name: '创作' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');

View File

@@ -16,6 +16,7 @@ import {
Heart, Heart,
LogIn, LogIn,
MessageCircle, MessageCircle,
Loader2,
Palette, Palette,
Pencil, Pencil,
Plus, Plus,
@@ -104,6 +105,7 @@ import {
getRpgProfileWalletLedger, getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode, redeemRpgProfileRewardCode,
watchWechatRpgProfileRechargeOrder,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
@@ -337,6 +339,9 @@ type RechargePaymentResult = {
title: string; title: string;
message: string; message: string;
}; };
type WechatRechargeOrderConfirmationState = {
orderId: string;
};
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
@@ -1204,6 +1209,7 @@ function PlatformTabButton({
onClick, onClick,
emphasized = false, emphasized = false,
showDot = false, showDot = false,
disabled = false,
}: { }: {
active: boolean; active: boolean;
label: string; label: string;
@@ -1211,6 +1217,7 @@ function PlatformTabButton({
onClick: () => void; onClick: () => void;
emphasized?: boolean; emphasized?: boolean;
showDot?: boolean; showDot?: boolean;
disabled?: boolean;
}) { }) {
const ariaLabel = showDot ? `${label},有新草稿` : label; const ariaLabel = showDot ? `${label},有新草稿` : label;
@@ -1218,8 +1225,9 @@ function PlatformTabButton({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`} className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
> >
<span className="platform-bottom-nav__button-content"> <span className="platform-bottom-nav__button-content">
<span <span
@@ -1249,6 +1257,7 @@ function DesktopTabButton({
onClick, onClick,
emphasized = false, emphasized = false,
showDot = false, showDot = false,
disabled = false,
}: { }: {
active: boolean; active: boolean;
label: string; label: string;
@@ -1256,6 +1265,7 @@ function DesktopTabButton({
onClick: () => void; onClick: () => void;
emphasized?: boolean; emphasized?: boolean;
showDot?: boolean; showDot?: boolean;
disabled?: boolean;
}) { }) {
const ariaLabel = showDot ? `${label},有新草稿` : label; const ariaLabel = showDot ? `${label},有新草稿` : label;
@@ -1263,8 +1273,9 @@ function DesktopTabButton({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`} className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
> >
<span className="platform-desktop-rail__icon-shell"> <span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" /> <Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
@@ -2797,7 +2808,7 @@ async function confirmWechatRechargeOrderUntilSettled(
orderId: string, orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> { ): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') { if (latestResponse.order.status !== 'pending') {
return latestResponse; return latestResponse;
} }
@@ -2805,12 +2816,17 @@ async function confirmWechatRechargeOrderUntilSettled(
await waitWechatPayConfirmDelay(delayMs); await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') { if (latestResponse.order.status !== 'pending') {
return latestResponse; return latestResponse;
} }
} }
try {
const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId);
return streamedResponse;
} catch {
return latestResponse; return latestResponse;
}
} }
function useWechatNativeQrCode(codeUrl: string | null) { function useWechatNativeQrCode(codeUrl: string | null) {
@@ -3095,6 +3111,35 @@ function RechargePaymentResultModal({
); );
} }
function RechargePaymentConfirmationMask({
orderId,
}: {
orderId: string;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[95] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-label="正在确认支付"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="px-5 pb-5 pt-6 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-[var(--platform-accent)]">
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
</div>
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{orderId}
</div>
</div>
</div>
</div>
);
}
function WalletLedgerModal({ function WalletLedgerModal({
ledger, ledger,
fallbackBalance, fallbackBalance,
@@ -4021,6 +4066,8 @@ export function RpgEntryHomeView({
const [rechargeError, setRechargeError] = useState<string | null>(null); const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] = const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null); useState<RechargePaymentResult | null>(null);
const [wechatRechargeOrderConfirmationState, setWechatRechargeOrderConfirmationState] =
useState<WechatRechargeOrderConfirmationState | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] = const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null); useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] = const [activeRechargeTab, setActiveRechargeTab] =
@@ -4101,6 +4148,7 @@ export function RpgEntryHomeView({
const profileCopyResetTimerRef = useRef<number | null>(null); const profileCopyResetTimerRef = useRef<number | null>(null);
const avatarFileInputRef = useRef<HTMLInputElement | null>(null); const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null); const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
const [nicknameInput, setNicknameInput] = useState(''); const [nicknameInput, setNicknameInput] = useState('');
const [nicknameError, setNicknameError] = useState<string | null>(null); const [nicknameError, setNicknameError] = useState<string | null>(null);
@@ -4591,6 +4639,8 @@ export function RpgEntryHomeView({
loadRechargeCenter(); loadRechargeCenter();
setSubmittingRechargeProductId(null); setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null; pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null); setNativeWechatPayment(null);
}, [loadRechargeCenter]); }, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => { const handleWechatPayResult = useCallback(() => {
@@ -4607,18 +4657,27 @@ export function RpgEntryHomeView({
return false; return false;
} }
setSubmittingRechargeProductId(null);
if (payResult.status === 'success') { if (payResult.status === 'success') {
setRechargePaymentResult({ const orderId = payResult.orderId || pendingWechatRechargeOrderIdRef.current;
kind: 'pending', if (!orderId) {
title: '支付已提交', clearWechatPayResultHash();
message: '正在确认到账状态,请稍后查看余额或会员状态。', return true;
}); }
if (payResult.orderId) { if (confirmingWechatRechargeOrderIdRef.current === orderId) {
void confirmWechatRechargeOrderUntilSettled(payResult.orderId) clearWechatPayResultHash();
return true;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setSubmittingRechargeProductId(null);
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => { .then((response) => {
const isPaid = response.order.status === 'paid'; const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center); setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult( setRechargePaymentResult(
isPaid isPaid
? { ? {
@@ -4628,33 +4687,32 @@ export function RpgEntryHomeView({
} }
: { : {
kind: 'pending', kind: 'pending',
title: '支付已提交', title: '支付处理中',
message: '正在等待微信支付确认,请稍后查看账户状态。', message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
}, },
); );
if (isPaid) { if (isPaid) {
void onRechargeSuccess?.(); void onRechargeSuccess?.();
} }
setSubmittingRechargeProductId(null); clearWechatPayResultHash();
pendingWechatRechargeOrderIdRef.current = null;
}) })
.catch(() => { .catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({ setRechargePaymentResult({
kind: 'pending', kind: 'pending',
title: '支付已提交', title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
}); });
refreshRechargeState(); clearWechatPayResultHash();
}); });
} else {
refreshRechargeState();
}
} else if (payResult.status === 'cancel') { } else if (payResult.status === 'cancel') {
setRechargePaymentResult({ setRechargePaymentResult({
kind: 'cancel', kind: 'cancel',
title: '支付已取消', title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。', message: '本次没有扣款,账户状态未发生变化。',
}); });
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState(); refreshRechargeState();
} else { } else {
const detail = payResult.errorMessage const detail = payResult.errorMessage
@@ -4665,12 +4723,62 @@ export function RpgEntryHomeView({
title: '支付未完成', title: '支付未完成',
message: detail, message: detail,
}); });
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState(); refreshRechargeState();
} }
clearWechatPayResultHash(); clearWechatPayResultHash();
return true; return true;
}, [onRechargeSuccess, refreshRechargeState]); }, [onRechargeSuccess, refreshRechargeState]);
const pollWechatPayResultFromHash = useCallback(
() => handleWechatPayResult(),
[handleWechatPayResult],
);
const confirmPendingWechatRechargeOrder = useCallback(() => {
const orderId = pendingWechatRechargeOrderIdRef.current;
if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) {
return false;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setSubmittingRechargeProductId(null);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付处理中',
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
})
.catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({
kind: 'pending',
title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
});
return true;
}, [onRechargeSuccess]);
const openRechargeModal = () => { const openRechargeModal = () => {
if (!authUi?.user) { if (!authUi?.user) {
authUi?.openLoginModal(); authUi?.openLoginModal();
@@ -4700,17 +4808,13 @@ export function RpgEntryHomeView({
setSubmittingRechargeProductId(product.productId); setSubmittingRechargeProductId(product.productId);
setRechargeError(null); setRechargeError(null);
setRechargePaymentResult(null); setRechargePaymentResult(null);
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null); setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel) void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => { .then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId; pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center); setRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'pending',
title: '正在支付',
message: '请在微信小程序支付页完成支付,返回后会自动刷新状态。',
});
await requestWechatMiniProgramPayment( await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams, response.wechatMiniProgramPayParams,
response.order.orderId, response.order.orderId,
@@ -4816,34 +4920,42 @@ export function RpgEntryHomeView({
.finally(() => setSubmittingRechargeProductId(null)); .finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]); }, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => { useEffect(() => {
const handleResume = () => { const handleHashChange = () => {
handleWechatPayResult(); handleWechatPayResult();
}; };
const handleResume = () => {
if (
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
) {
return;
}
if (!handleWechatPayResult()) {
confirmPendingWechatRechargeOrder();
}
};
window.addEventListener('hashchange', handleResume); window.addEventListener('hashchange', handleHashChange);
window.addEventListener('focus', handleResume); window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume); window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', handleResume); document.addEventListener('visibilitychange', handleResume);
handleResume(); handleWechatPayResult();
return () => { return () => {
window.removeEventListener('hashchange', handleResume); window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('focus', handleResume); window.removeEventListener('focus', handleResume);
window.removeEventListener('pageshow', handleResume); window.removeEventListener('pageshow', handleResume);
document.removeEventListener('visibilitychange', handleResume); document.removeEventListener('visibilitychange', handleResume);
}; };
}, [handleWechatPayResult]); }, [confirmPendingWechatRechargeOrder, handleWechatPayResult]);
useEffect(() => { useEffect(() => {
if ( if (!submittingRechargeProductId || wechatRechargeOrderConfirmationState) {
rechargePaymentResult?.kind !== 'pending' ||
rechargePaymentResult.title !== '正在支付'
) {
return undefined; return undefined;
} }
const startedAt = Date.now(); const startedAt = Date.now();
let timer: number | null = null; let timer: number | null = null;
const pollPayResult = () => { const pollPayResult = () => {
if (handleWechatPayResult()) { if (pollWechatPayResultFromHash()) {
return; return;
} }
if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) { if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) {
@@ -4864,7 +4976,11 @@ export function RpgEntryHomeView({
window.clearTimeout(timer); window.clearTimeout(timer);
} }
}; };
}, [handleWechatPayResult, rechargePaymentResult?.kind, rechargePaymentResult?.title]); }, [
pollWechatPayResultFromHash,
submittingRechargeProductId,
wechatRechargeOrderConfirmationState,
]);
const loadTaskCenter = useCallback(() => { const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current; const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null); setTaskCenterError(null);
@@ -6872,6 +6988,15 @@ export function RpgEntryHomeView({
onClose={() => setRechargePaymentResult(null)} onClose={() => setRechargePaymentResult(null)}
/> />
) : null; ) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<RechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null;
const isRechargePaymentConfirmationPending = Boolean(
wechatRechargeOrderConfirmationState,
);
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? ( const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
<PlatformCategoryFilterDialog <PlatformCategoryFilterDialog
kindFilter={categoryKindFilter} kindFilter={categoryKindFilter}
@@ -6907,7 +7032,9 @@ export function RpgEntryHomeView({
const isMobileRecommendTab = activeTab === 'home'; const isMobileRecommendTab = activeTab === 'home';
return ( return (
<>
<div <div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`} className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
> >
{!isMobileRecommendTab ? ( {!isMobileRecommendTab ? (
@@ -6981,7 +7108,11 @@ export function RpgEntryHomeView({
} }
emphasized={tab === 'create'} emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate} showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => { onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
if (activeTab === 'home' && tab === 'home') { if (activeTab === 'home' && tab === 'home') {
selectNextRecommendEntry(); selectNextRecommendEntry();
return; return;
@@ -7065,11 +7196,17 @@ export function RpgEntryHomeView({
/> />
{profileEditModals} {profileEditModals}
</div> </div>
{rechargePaymentConfirmationMask}
</>
); );
} }
return ( return (
<div className="flex h-full min-h-0 flex-col"> <>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className="flex h-full min-h-0 flex-col"
>
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6"> <div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4"> <div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
@@ -7144,7 +7281,11 @@ export function RpgEntryHomeView({
icon={tabIcons[tab]} icon={tabIcons[tab]}
emphasized={tab === 'create'} emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate} showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => { onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
onTabChange(tab); onTabChange(tab);
}} }}
/> />
@@ -7157,6 +7298,7 @@ export function RpgEntryHomeView({
</div> </div>
</div> </div>
</div> </div>
</div>
{rewardCodeModal} {rewardCodeModal}
{rechargeModal} {rechargeModal}
{rechargePaymentResultModal} {rechargePaymentResultModal}
@@ -7227,7 +7369,8 @@ export function RpgEntryHomeView({
onClose={() => setActiveLegalDocumentId(null)} onClose={() => setActiveLegalDocumentId(null)}
/> />
{profileEditModals} {profileEditModals}
</div> {rechargePaymentConfirmationMask}
</>
); );
} }

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({ const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
fetchWithApiAuthMock: vi.fn(),
requestJsonMock: vi.fn(), requestJsonMock: vi.fn(),
})); }));
@@ -12,6 +13,7 @@ import {
submitRpgProfileFeedback, submitRpgProfileFeedback,
syncRpgProfileBrowseHistory, syncRpgProfileBrowseHistory,
upsertRpgProfileBrowseHistory, upsertRpgProfileBrowseHistory,
watchWechatRpgProfileRechargeOrder,
} from './rpgProfileClient'; } from './rpgProfileClient';
vi.mock('../apiClient', () => ({ vi.mock('../apiClient', () => ({
@@ -21,9 +23,30 @@ vi.mock('../apiClient', () => ({
notifyAuthStateChange: false, notifyAuthStateChange: false,
clearAuthOnUnauthorized: false, clearAuthOnUnauthorized: false,
}, },
fetchWithApiAuth: fetchWithApiAuthMock,
requestJson: requestJsonMock, requestJson: requestJsonMock,
})); }));
function createSseResponse(bodyText: string) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(bodyText));
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
},
);
}
beforeEach(() => {
fetchWithApiAuthMock.mockReset();
});
describe('rpgProfileClient browse history routes', () => { describe('rpgProfileClient browse history routes', () => {
beforeEach(() => { beforeEach(() => {
requestJsonMock.mockReset(); requestJsonMock.mockReset();
@@ -231,3 +254,86 @@ describe('rpgProfileClient feedback routes', () => {
}); });
}); });
}); });
describe('rpgProfileClient recharge order events', () => {
beforeEach(() => {
fetchWithApiAuthMock.mockReset();
});
it('waits for a non-pending order event before completing the SSE watch', async () => {
const pendingOrder = {
orderId: 'order-wechat-sse-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending',
paymentChannel: 'wechat_mp_virtual',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
};
const center = {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
};
const paidOrder = {
...pendingOrder,
status: 'paid',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-sse-1',
pointsDelta: 120,
};
fetchWithApiAuthMock.mockResolvedValueOnce(
createSseResponse(
[
'event: order',
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
'',
'event: order',
`data: ${JSON.stringify({
order: paidOrder,
center: {
...center,
walletBalance: 120,
hasPointsRecharged: true,
},
})}`,
'',
'event: done',
'data: {"orderId":"order-wechat-sse-1","status":"paid"}',
'',
'',
].join('\n'),
),
);
const result = await watchWechatRpgProfileRechargeOrder(
'order-wechat-sse-1',
);
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
'/api/profile/recharge/orders/order-wechat-sse-1/wechat/events',
expect.objectContaining({
method: 'GET',
headers: { Accept: 'text/event-stream' },
}),
expect.any(Object),
);
expect(result.order.status).toBe('paid');
expect(result.center.walletBalance).toBe(120);
});
});

View File

@@ -19,8 +19,10 @@ import type {
SubmitProfileFeedbackRequest, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, SubmitProfileFeedbackResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { fetchWithApiAuth } from '../apiClient';
import { import {
RUNTIME_BACKGROUND_AUTH_OPTIONS, RUNTIME_BACKGROUND_AUTH_OPTIONS,
requestRpgRuntimeJson, requestRpgRuntimeJson,
@@ -116,6 +118,235 @@ export function confirmWechatRpgProfileRechargeOrder(
); );
} }
type RechargeOrderSseEvent =
| {
type: 'order';
payload: ConfirmWechatProfileRechargeOrderResponse;
}
| {
type: 'done';
payload: { orderId: string; status: string };
}
| {
type: 'error';
payload: { message: string };
};
function findSseEventBoundary(buffer: string) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
if (lfBoundary === -1 && crlfBoundary === -1) {
return null;
}
if (lfBoundary === -1) {
return {
index: crlfBoundary,
length: 4,
};
}
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
return {
index: lfBoundary,
length: 2,
};
}
return {
index: crlfBoundary,
length: 4,
};
}
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
return {
eventName,
data: dataLines.join('\n'),
};
}
function parseJsonObject(data: string) {
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function normalizeRechargeOrderSseEvent(
eventName: string,
parsed: Record<string, unknown>,
): RechargeOrderSseEvent | null {
if (eventName === 'order' && parsed.order && parsed.center) {
return {
type: 'order',
payload: parsed as ConfirmWechatProfileRechargeOrderResponse,
};
}
if (eventName === 'done') {
const orderId =
typeof parsed.orderId === 'string' ? parsed.orderId.trim() : '';
const status = typeof parsed.status === 'string' ? parsed.status.trim() : '';
if (orderId && status) {
return {
type: 'done',
payload: { orderId, status },
};
}
}
if (eventName === 'error') {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '';
return {
type: 'error',
payload: { message },
};
}
return null;
}
export async function watchWechatRpgProfileRechargeOrder(
orderId: string,
options: RuntimeRequestOptions = {},
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
const response = await fetchWithApiAuth(
`/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`,
{
method: 'GET',
headers: {
Accept: 'text/event-stream',
},
signal: options.signal,
},
{
skipRefresh: options.skipRefresh,
skipAuth: options.skipAuth,
authImpact: options.authImpact,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(
appendApiErrorRequestId(
parseApiErrorMessage(responseText, '订阅充值订单状态失败'),
response.headers.get('x-request-id'),
),
);
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
let streamDone = false;
const consumeBuffer = () => {
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const parsed = parseJsonObject(data);
if (!parsed) {
continue;
}
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
if (!normalized) {
continue;
}
if (normalized.type === 'order') {
lastResponse = normalized.payload;
if (normalized.payload.order.status !== 'pending') {
finalResponse = normalized.payload;
}
continue;
}
if (normalized.type === 'done') {
streamDone = true;
if (!finalResponse && lastResponse) {
finalResponse = lastResponse;
}
continue;
}
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
if (finalResponse) {
break;
}
if (streamDone) {
break;
}
}
buffer += decoder.decode();
consumeBuffer();
if (!finalResponse) {
if (lastResponse) {
finalResponse = lastResponse;
}
}
if (!finalResponse) {
throw new Error('充值订单状态流返回不完整');
}
return finalResponse;
}
export function submitRpgProfileFeedback( export function submitRpgProfileFeedback(
payload: SubmitProfileFeedbackRequest, payload: SubmitProfileFeedbackRequest,
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},