fix: lock recharge flow until virtual payment settles
This commit is contained in:
@@ -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 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
- 小程序支付承接页回传 `wx_pay_result` 时必须携带 `requestId:status:orderId[:error]`,并同时写入上一页 hash 与本地 storage;WebView `onShow` 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
|
||||||
- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify` 和 `xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`(JSON 入站回 JSON,XML 入站回 XML),错误时带具体 `ErrMsg` 便于微信侧重试与排障。
|
- 微信虚拟支付消息推送使用独立后端入口 `/api/profile/recharge/wechat/virtual-notify`,按 `xpay_goods_deliver_notify` 和 `xpay_coin_pay_notify` 推进充值订单入账;回包需按入站格式返回 `ErrCode=0` / `ErrMsg=success`(JSON 入站回 JSON,XML 入站回 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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user