fix: 修复微信支付回跳刷新与查单确认
This commit is contained in:
@@ -85,17 +85,42 @@
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `POST /api/profile/recharge/wechat/notify`
|
||||
### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm`
|
||||
|
||||
需要 Bearer JWT。该接口用于小程序支付页返回 web-view 后的主动查单确认,不替代微信支付通知:
|
||||
|
||||
1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态。
|
||||
2. 若订单已是 `paid`,直接返回订单与账户中心快照。
|
||||
3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。
|
||||
4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。
|
||||
5. 如果微信查单仍不是 `SUCCESS`,接口返回当前 pending 订单与账户中心快照;前端只显示“支付已提交”,不提前发放泥点或会员。
|
||||
|
||||
响应结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"order": {
|
||||
"orderId": "rcg...",
|
||||
"status": "paid"
|
||||
},
|
||||
"center": {
|
||||
"walletBalance": 120
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 `POST /api/profile/recharge/wechat/notify`
|
||||
|
||||
微信支付通知地址,无需 Bearer JWT。行为:
|
||||
|
||||
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。
|
||||
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签;验签必须使用原始 HTTP body bytes 构造 `timestamp\nnonce\nbody\n`,不能先把 body 转成字符串再重建。
|
||||
2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。
|
||||
3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。
|
||||
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。
|
||||
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
|
||||
6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。
|
||||
7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。
|
||||
8. 微信支付公钥模式下,真实请求会携带 `Wechatpay-Serial: PUB_KEY_ID_...`,通知验签必须要求回调头 `Wechatpay-Serial` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 对应;若不匹配应返回 `401` 并在日志里记录 reason。
|
||||
|
||||
关键环境变量:
|
||||
|
||||
@@ -118,6 +143,9 @@
|
||||
3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。
|
||||
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。
|
||||
- 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。
|
||||
- native 支付页通过 `wx_pay_result=<requestId>:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。
|
||||
- `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。
|
||||
- `cancel` 和 `fail` 只复位按钮、刷新账户中心并展示状态,不调用入账逻辑。
|
||||
5. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和状态反馈。
|
||||
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
||||
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||
|
||||
function isConfiguredEntryUrl(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
@@ -273,6 +274,20 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
if (!result || !this.data.webViewUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
wx.removeStorageSync(PAY_RESULT_STORAGE_KEY);
|
||||
this.setData({
|
||||
webViewUrl: appendHashParams(this.data.webViewUrl, {
|
||||
wx_pay_result: result,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async handleGetPhoneNumber(event) {
|
||||
if (!this.data.authResult || !this.data.authResult.token) {
|
||||
this.handleRetryLogin();
|
||||
|
||||
@@ -30,18 +30,25 @@ function requestPayment(payParams) {
|
||||
});
|
||||
}
|
||||
|
||||
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
|
||||
|
||||
function appendPayResult(url, requestId, status) {
|
||||
const value = `${requestId}:${status}`;
|
||||
const hashIndex = String(url || '').indexOf('#');
|
||||
const baseUrl =
|
||||
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||
const params = new URLSearchParams(rawHash);
|
||||
params.set('wx_pay_result', value);
|
||||
return `${baseUrl}#${params.toString()}`;
|
||||
const nextHash = rawHash
|
||||
.split('&')
|
||||
.filter((part) => part && !part.startsWith('wx_pay_result='))
|
||||
.concat(`wx_pay_result=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
return `${baseUrl}#${nextHash}`;
|
||||
}
|
||||
|
||||
function notifyPreviousWebView(requestId, status) {
|
||||
const result = `${requestId}:${status}`;
|
||||
wx.setStorageSync(PAY_RESULT_STORAGE_KEY, result);
|
||||
const pages = getCurrentPages();
|
||||
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
||||
if (previousPage && typeof previousPage.setData === 'function') {
|
||||
|
||||
@@ -158,6 +158,11 @@ export type CreateProfileRechargeOrderResponse = {
|
||||
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
|
||||
};
|
||||
|
||||
export type ConfirmWechatProfileRechargeOrderResponse = {
|
||||
order: ProfileRechargeOrder;
|
||||
center: ProfileRechargeCenterResponse;
|
||||
};
|
||||
|
||||
export type ProfileFeedbackStatus = 'open';
|
||||
|
||||
export type ProfileFeedbackEvidenceItemInput = {
|
||||
|
||||
@@ -129,10 +129,11 @@ use crate::{
|
||||
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
|
||||
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
|
||||
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
|
||||
claim_profile_task_reward, 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_task_center, get_profile_wallet_ledger,
|
||||
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
|
||||
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||
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_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||
redeem_profile_reward_code, submit_profile_feedback,
|
||||
},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
@@ -1409,6 +1410,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/orders/{order_id}/wechat/confirm",
|
||||
post(confirm_wechat_profile_recharge_order).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/wechat/notify",
|
||||
post(handle_wechat_pay_notify),
|
||||
|
||||
@@ -10,12 +10,12 @@ use module_runtime::{
|
||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
||||
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord,
|
||||
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
|
||||
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
|
||||
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -25,10 +25,10 @@ use shared_contracts::runtime::{
|
||||
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
|
||||
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
|
||||
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
|
||||
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY,
|
||||
PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED,
|
||||
PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||
@@ -63,7 +63,10 @@ use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
|
||||
wechat_pay::{
|
||||
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros,
|
||||
map_wechat_pay_error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
@@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn confirm_wechat_profile_recharge_order(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(order_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.get_profile_recharge_order(order_id.clone())
|
||||
.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 order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("该充值订单不是微信小程序支付订单"),
|
||||
));
|
||||
}
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let wechat_order = state
|
||||
.wechat_pay_client()
|
||||
.query_order_by_out_trade_no(&order.order_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
||||
})?;
|
||||
if wechat_order.out_trade_no != order.order_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
));
|
||||
}
|
||||
if wechat_order.trade_state != "SUCCESS" {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
wechat_order.out_trade_no,
|
||||
paid_at_micros,
|
||||
wechat_order.transaction_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment(
|
||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||
}
|
||||
|
||||
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
||||
order
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros)
|
||||
}
|
||||
|
||||
fn build_profile_recharge_center_response(
|
||||
record: RuntimeProfileRechargeCenterRecord,
|
||||
) -> ProfileRechargeCenterResponse {
|
||||
@@ -1260,6 +1372,7 @@ mod tests {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
@@ -1271,6 +1384,20 @@ mod tests {
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let confirm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
@@ -28,6 +29,8 @@ const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||
const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0";
|
||||
const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial";
|
||||
const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/";
|
||||
const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127;
|
||||
@@ -54,6 +57,7 @@ pub struct RealWechatPayClient {
|
||||
api_v3_key: String,
|
||||
notify_url: String,
|
||||
jsapi_endpoint: String,
|
||||
query_order_endpoint_base: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -81,7 +85,7 @@ pub enum WechatPayError {
|
||||
Upstream(String),
|
||||
Deserialize(String),
|
||||
Crypto(String),
|
||||
InvalidSignature,
|
||||
InvalidSignature(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -137,6 +141,16 @@ struct WechatPayTransactionResource {
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WechatPayQueryOrderResponse {
|
||||
out_trade_no: String,
|
||||
#[serde(default)]
|
||||
transaction_id: Option<String>,
|
||||
trade_state: String,
|
||||
#[serde(default)]
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
@@ -208,6 +222,7 @@ impl WechatPayClient {
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
)?;
|
||||
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
|
||||
|
||||
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||
client: reqwest::Client::new(),
|
||||
@@ -220,6 +235,7 @@ impl WechatPayClient {
|
||||
api_v3_key,
|
||||
notify_url,
|
||||
jsapi_endpoint,
|
||||
query_order_endpoint_base,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -245,6 +261,22 @@ impl WechatPayClient {
|
||||
Self::Real(client) => client.parse_notify(headers, body),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
match self {
|
||||
Self::Disabled => Err(WechatPayError::Disabled),
|
||||
Self::Mock => Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: normalize_out_trade_no(order_id)?,
|
||||
transaction_id: Some(format!("mock-{order_id}")),
|
||||
trade_state: "SUCCESS".to_string(),
|
||||
success_time: Some(OffsetDateTime::now_utc().to_string()),
|
||||
}),
|
||||
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealWechatPayClient {
|
||||
@@ -283,6 +315,7 @@ impl RealWechatPayClient {
|
||||
self.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
@@ -389,6 +422,58 @@ impl RealWechatPayClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
let order_id = normalize_out_trade_no(order_id)?;
|
||||
let path = format!(
|
||||
"/v3/pay/transactions/out-trade-no/{}?mchid={}",
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let request_url = format!(
|
||||
"{}/{}?mchid={}",
|
||||
self.query_order_endpoint_base.trim_end_matches('/'),
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce = create_nonce()?;
|
||||
let authorization = self.build_authorization("GET", &path, ×tamp, &nonce, "")?;
|
||||
let response = with_wechat_pay_json_headers(
|
||||
self.client
|
||||
.get(request_url)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(WechatPayError::Upstream(format!(
|
||||
"微信支付查单失败:HTTP {status},{response_text}"
|
||||
)));
|
||||
}
|
||||
let payload = serde_json::from_str::<WechatPayQueryOrderResponse>(&response_text).map_err(
|
||||
|error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")),
|
||||
)?;
|
||||
|
||||
Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: payload.out_trade_no,
|
||||
transaction_id: payload
|
||||
.transaction_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
trade_state: payload.trade_state,
|
||||
success_time: payload.success_time,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_notify_signature(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
@@ -399,25 +484,33 @@ impl RealWechatPayClient {
|
||||
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||
if serial != self.platform_serial_no {
|
||||
return Err(WechatPayError::InvalidSignature);
|
||||
warn!(
|
||||
received_serial = serial,
|
||||
configured_serial = self.platform_serial_no.as_str(),
|
||||
"微信支付通知平台公钥序列号不匹配"
|
||||
);
|
||||
return Err(WechatPayError::InvalidSignature(format!(
|
||||
"微信支付通知平台公钥序列号不匹配:received={serial}"
|
||||
)));
|
||||
}
|
||||
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
|
||||
warn!("收到微信支付签名探测通知");
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信支付签名探测通知".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
timestamp,
|
||||
nonce,
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
let signature_bytes = BASE64_STANDARD
|
||||
.decode(signature)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)?;
|
||||
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
|
||||
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
|
||||
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
|
||||
})?;
|
||||
let public_key = signature::UnparsedPublicKey::new(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
&self.platform_public_key_der,
|
||||
);
|
||||
public_key
|
||||
.verify(message.as_bytes(), &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)
|
||||
.verify(&message, &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||||
}
|
||||
|
||||
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||
@@ -499,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +628,10 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder
|
||||
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
|
||||
.header(
|
||||
@@ -541,6 +639,14 @@ fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::R
|
||||
WECHAT_PAY_CONTENT_TYPE_HEADER,
|
||||
)
|
||||
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
|
||||
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
with_wechat_pay_json_headers(builder, platform_serial_no)
|
||||
}
|
||||
|
||||
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||
@@ -627,6 +733,23 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
|
||||
let url = Url::parse(jsapi_endpoint)
|
||||
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
|
||||
let origin = url
|
||||
.origin()
|
||||
.ascii_serialization()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
|
||||
}
|
||||
|
||||
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
|
||||
let value = value.trim();
|
||||
validate_out_trade_no(value)?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_jsapi_order_request(
|
||||
client: &RealWechatPayClient,
|
||||
request: &WechatMiniProgramOrderRequest,
|
||||
@@ -841,7 +964,18 @@ fn read_required_header<'a>(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatPayError::InvalidSignature)
|
||||
.ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头")))
|
||||
}
|
||||
|
||||
fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3);
|
||||
message.extend_from_slice(timestamp);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(nonce);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(body);
|
||||
message.push(b'\n');
|
||||
message
|
||||
}
|
||||
|
||||
fn hex_sha256(content: &[u8]) -> String {
|
||||
@@ -864,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
|
||||
| Self::Upstream(message)
|
||||
| Self::Deserialize(message)
|
||||
| Self::Crypto(message) => formatter.write_str(message),
|
||||
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
||||
Self::InvalidSignature(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +1085,7 @@ mod tests {
|
||||
"Authorization",
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
|
||||
),
|
||||
"PUB_KEY_ID_0119000000012026051400000000000001",
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
@@ -974,6 +1109,23 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_USER_AGENT)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(WECHAT_PAY_SERIAL_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("PUB_KEY_ID_0119000000012026051400000000000001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_signature_message_preserves_raw_body_bytes() {
|
||||
let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n";
|
||||
let message = build_notify_signature_message(b"1778759600", b"nonce-1", body);
|
||||
|
||||
assert_eq!(
|
||||
message,
|
||||
b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -242,6 +242,14 @@ pub fn build_runtime_profile_recharge_center_get_input(
|
||||
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_get_input(
|
||||
order_id: String,
|
||||
) -> Result<RuntimeProfileRechargeOrderGetInput, RuntimeProfileFieldError> {
|
||||
let order_id =
|
||||
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
||||
Ok(RuntimeProfileRechargeOrderGetInput { order_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_create_input(
|
||||
user_id: String,
|
||||
product_id: String,
|
||||
|
||||
@@ -1060,6 +1060,12 @@ pub struct RuntimeProfileRechargeCenterGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeOrderGetInput {
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeOrderCreateInput {
|
||||
|
||||
@@ -268,6 +268,13 @@ pub struct CreateProfileRechargeOrderResponse {
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmWechatProfileRechargeOrderResponse {
|
||||
pub order: ProfileRechargeOrderResponse,
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileFeedbackEvidenceItemRequest {
|
||||
|
||||
@@ -177,6 +177,7 @@ use module_runtime::{
|
||||
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record,
|
||||
build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record,
|
||||
build_runtime_profile_recharge_order_create_input,
|
||||
build_runtime_profile_recharge_order_get_input,
|
||||
build_runtime_profile_redeem_code_admin_disable_input,
|
||||
build_runtime_profile_redeem_code_admin_list_input,
|
||||
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
|
||||
|
||||
@@ -163,6 +163,16 @@ impl From<module_runtime::RuntimeProfileRechargeCenterGetInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRechargeOrderGetInput>
|
||||
for RuntimeProfileRechargeOrderGetInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self {
|
||||
Self {
|
||||
order_id: input.order_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||
for RuntimeProfileRechargeOrderCreateInput
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||
use super::runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct GetProfileRechargeOrderAndReturnArgs {
|
||||
pub input: RuntimeProfileRechargeOrderGetInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GetProfileRechargeOrderAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `get_profile_recharge_order_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait get_profile_recharge_order_and_return {
|
||||
fn get_profile_recharge_order_and_return(&self, input: RuntimeProfileRechargeOrderGetInput) {
|
||||
self.get_profile_recharge_order_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn get_profile_recharge_order_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeOrderGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl get_profile_recharge_order_and_return for super::RemoteProcedures {
|
||||
fn get_profile_recharge_order_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeOrderGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
|
||||
"get_profile_recharge_order_and_return",
|
||||
GetProfileRechargeOrderAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ pub mod get_player_progression_or_default_procedure;
|
||||
pub mod get_profile_dashboard_procedure;
|
||||
pub mod get_profile_play_stats_procedure;
|
||||
pub mod get_profile_recharge_center_procedure;
|
||||
pub mod get_profile_recharge_order_and_return_procedure;
|
||||
pub mod get_profile_referral_invite_center_procedure;
|
||||
pub mod get_profile_task_center_procedure;
|
||||
pub mod get_puzzle_agent_session_procedure;
|
||||
@@ -620,6 +621,7 @@ pub mod runtime_profile_recharge_center_get_input_type;
|
||||
pub mod runtime_profile_recharge_center_procedure_result_type;
|
||||
pub mod runtime_profile_recharge_center_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_create_input_type;
|
||||
pub mod runtime_profile_recharge_order_get_input_type;
|
||||
pub mod runtime_profile_recharge_order_paid_input_type;
|
||||
pub mod runtime_profile_recharge_order_snapshot_type;
|
||||
pub mod runtime_profile_recharge_order_status_type;
|
||||
@@ -1135,6 +1137,7 @@ pub use get_player_progression_or_default_procedure::get_player_progression_or_d
|
||||
pub use get_profile_dashboard_procedure::get_profile_dashboard;
|
||||
pub use get_profile_play_stats_procedure::get_profile_play_stats;
|
||||
pub use get_profile_recharge_center_procedure::get_profile_recharge_center;
|
||||
pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return;
|
||||
pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center;
|
||||
pub use get_profile_task_center_procedure::get_profile_task_center;
|
||||
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
|
||||
@@ -1435,6 +1438,7 @@ pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCe
|
||||
pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
|
||||
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
|
||||
pub use runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput;
|
||||
pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct RuntimeProfileRechargeOrderGetInput {
|
||||
pub order_id: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRechargeOrderGetInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -268,6 +268,33 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_profile_recharge_order(
|
||||
&self,
|
||||
order_id: String,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterRecord,
|
||||
RuntimeProfileRechargeOrderRecord,
|
||||
),
|
||||
SpacetimeClientError,
|
||||
> {
|
||||
let procedure_input = build_runtime_profile_recharge_order_get_input(order_id)
|
||||
.map_err(SpacetimeClientError::validation_failed)?
|
||||
.into();
|
||||
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_profile_recharge_order_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_runtime_profile_recharge_order_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_profile_recharge_order_paid(
|
||||
&self,
|
||||
order_id: String,
|
||||
|
||||
@@ -771,6 +771,27 @@ pub fn get_profile_recharge_center(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_profile_recharge_order_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: RuntimeProfileRechargeOrderGetInput,
|
||||
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) {
|
||||
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
order: Some(order),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
order: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_profile_recharge_order_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -2122,6 +2143,31 @@ fn create_profile_recharge_order_record(
|
||||
))
|
||||
}
|
||||
|
||||
fn get_profile_recharge_order_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeOrderGetInput,
|
||||
) -> Result<
|
||||
(
|
||||
RuntimeProfileRechargeCenterSnapshot,
|
||||
RuntimeProfileRechargeOrderSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let order = ctx
|
||||
.db
|
||||
.profile_recharge_order()
|
||||
.order_id()
|
||||
.find(&validated_input.order_id)
|
||||
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
|
||||
|
||||
Ok((
|
||||
build_profile_recharge_center_snapshot(ctx, &order.user_id),
|
||||
build_profile_recharge_order_snapshot_from_row(&order),
|
||||
))
|
||||
}
|
||||
|
||||
fn mark_profile_recharge_order_paid_record(
|
||||
ctx: &ReducerContext,
|
||||
input: RuntimeProfileRechargeOrderPaidInput,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PublicUserSummary,
|
||||
} from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileTaskCenterResponse,
|
||||
@@ -39,6 +40,7 @@ const {
|
||||
mockBuildReferralCenter,
|
||||
mockBuildTaskCenter,
|
||||
mockClaimRpgProfileTaskReward,
|
||||
mockConfirmWechatRpgProfileRechargeOrder,
|
||||
mockCreateRpgProfileRechargeOrder,
|
||||
mockGetRpgProfileReferralInviteCenter,
|
||||
mockGetRpgProfileRechargeCenter,
|
||||
@@ -219,6 +221,65 @@ const {
|
||||
},
|
||||
}),
|
||||
),
|
||||
mockConfirmWechatRpgProfileRechargeOrder: vi.fn(
|
||||
async (): Promise<ConfirmWechatProfileRechargeOrderResponse> => ({
|
||||
order: {
|
||||
orderId: 'order-wechat-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'wechat_mp',
|
||||
paidAt: '2026-04-25T10:01:00Z',
|
||||
providerTransactionId: 'wx-transaction-1',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 0,
|
||||
durationDays: 0,
|
||||
badgeLabel: '',
|
||||
description: '60泥点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: {
|
||||
orderId: 'order-wechat-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'wechat_mp',
|
||||
providerTransactionId: 'wx-transaction-1',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
paidAt: '2026-04-25T10:01:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||
center: buildReferralCenter({
|
||||
invitedUsers: [],
|
||||
@@ -303,6 +364,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
||||
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
||||
confirmWechatRpgProfileRechargeOrder:
|
||||
mockConfirmWechatRpgProfileRechargeOrder,
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -975,11 +1038,8 @@ test('profile recharge modal buys points through mock channel outside mini progr
|
||||
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
const navigateTo = vi.fn((options: { url: string }) => {
|
||||
const url = new URL(`https://mini.test${options.url}`);
|
||||
const requestId = url.searchParams.get('requestId');
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
});
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
@@ -1040,23 +1100,32 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
|
||||
});
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
|
||||
'requestId',
|
||||
);
|
||||
expect(requestId).toBeTruthy();
|
||||
act(() => {
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
expect(navigateUrl).toContain('order-wechat-1');
|
||||
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
|
||||
expect(await screen.findByText('支付已提交')).toBeTruthy();
|
||||
expect(await screen.findByText('已到账')).toBeTruthy();
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'order-wechat-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
window.wx = undefined;
|
||||
const navigateTo = vi.fn((options: { url: string }) => {
|
||||
const url = new URL(`https://mini.test${options.url}`);
|
||||
const requestId = url.searchParams.get('requestId');
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
});
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
@@ -1120,10 +1189,110 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
|
||||
await waitFor(() => {
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('支付已提交')).toBeTruthy();
|
||||
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
|
||||
'requestId',
|
||||
);
|
||||
expect(requestId).toBeTruthy();
|
||||
act(() => {
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
expect(await screen.findByText('已到账')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile recharge modal releases submitting state after cancelled wechat pay result', 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-cancel-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_mp',
|
||||
paidAt: null as string | null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
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: {
|
||||
timeStamp: '1777110165',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx-prepay-cancel',
|
||||
signType: 'RSA',
|
||||
paySign: 'signature',
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
const buyButton = await screen.findByRole('button', { name: /60泥点/u });
|
||||
await user.click(buyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_mp',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
within(buyButton).getByText('处理中', { selector: 'span' }),
|
||||
).toBeTruthy();
|
||||
|
||||
const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||
const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get(
|
||||
'requestId',
|
||||
);
|
||||
expect(requestId).toBeTruthy();
|
||||
act(() => {
|
||||
window.location.hash = `wx_pay_result=${requestId}:cancel`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
|
||||
expect(await screen.findByText('支付已取消')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(screen.getByRole('button', { name: /60泥点/u })).getByText(
|
||||
'购买',
|
||||
{ selector: 'span' },
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
claimRpgProfileTaskReward,
|
||||
confirmWechatRpgProfileRechargeOrder,
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileRechargeCenter,
|
||||
@@ -216,6 +217,11 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||
type WechatPayResult = {
|
||||
requestId: string;
|
||||
orderId: string | null;
|
||||
status: WechatMiniProgramPaymentStatus;
|
||||
};
|
||||
type DiscoverChannel =
|
||||
| 'recommend'
|
||||
| 'today'
|
||||
@@ -2342,6 +2348,37 @@ function clearWechatPayResultHash() {
|
||||
window.history.replaceState(null, '', nextUrl);
|
||||
}
|
||||
|
||||
function readWechatPayResultFromHash(): WechatPayResult | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = new URLSearchParams(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
).get('wx_pay_result');
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [requestId = '', rawStatus = ''] = result.split(':');
|
||||
const orderId = requestId
|
||||
.replace(/^wechat_pay_/, '')
|
||||
.replace(/_\d+$/, '')
|
||||
.trim();
|
||||
const status =
|
||||
rawStatus === 'success'
|
||||
? 'success'
|
||||
: rawStatus === 'cancel'
|
||||
? 'cancel'
|
||||
: 'fail';
|
||||
|
||||
return {
|
||||
requestId,
|
||||
orderId: orderId || null,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function loadWechatJsSdk() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||
@@ -2385,7 +2422,7 @@ function loadWechatJsSdk() {
|
||||
async function requestWechatMiniProgramPayment(
|
||||
payload: WechatMiniProgramPayParams | null | undefined,
|
||||
orderId: string,
|
||||
) {
|
||||
): Promise<void> {
|
||||
if (!payload) {
|
||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||
}
|
||||
@@ -2396,35 +2433,20 @@ async function requestWechatMiniProgramPayment(
|
||||
}
|
||||
const navigateTo = miniProgram.navigateTo;
|
||||
|
||||
return new Promise<WechatMiniProgramPaymentStatus>((resolve) => {
|
||||
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||
const handleHashChange = () => {
|
||||
const params = new URLSearchParams(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
);
|
||||
const result = params.get('wx_pay_result') ?? '';
|
||||
const [resultRequestId, status] = result.split(':');
|
||||
if (resultRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
resolve(
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'cancel'
|
||||
? 'cancel'
|
||||
: 'fail',
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
navigateTo({
|
||||
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(error) {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
console.error('[wechat-pay] navigateTo failed', error);
|
||||
resolve('fail');
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('请在微信小程序内完成支付'),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -3368,6 +3390,7 @@ export function RpgEntryHomeView({
|
||||
useState<LegalDocumentId | null>(null);
|
||||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
|
||||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||||
const [nicknameInput, setNicknameInput] = useState('');
|
||||
const [nicknameError, setNicknameError] = useState<string | null>(null);
|
||||
@@ -3823,6 +3846,55 @@ export function RpgEntryHomeView({
|
||||
})
|
||||
.finally(() => setIsLoadingRechargeCenter(false));
|
||||
};
|
||||
const refreshRechargeState = useCallback(
|
||||
() => {
|
||||
loadRechargeCenter();
|
||||
setSubmittingRechargeProductId(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
},
|
||||
[loadRechargeCenter],
|
||||
);
|
||||
const handleWechatPayResult = useCallback(() => {
|
||||
const payResult = readWechatPayResultFromHash();
|
||||
if (!payResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pendingWechatRechargeOrderIdRef.current &&
|
||||
payResult.orderId &&
|
||||
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payResult.status === 'success') {
|
||||
setRechargeSuccess('支付已提交');
|
||||
if (payResult.orderId) {
|
||||
void confirmWechatRpgProfileRechargeOrder(payResult.orderId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
setRechargeSuccess(
|
||||
response.order.status === 'paid' ? '已到账' : '支付已提交',
|
||||
);
|
||||
setSubmittingRechargeProductId(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
})
|
||||
.catch(() => refreshRechargeState());
|
||||
} else {
|
||||
refreshRechargeState();
|
||||
}
|
||||
void onRechargeSuccess?.();
|
||||
} else if (payResult.status === 'cancel') {
|
||||
setRechargeSuccess('支付已取消');
|
||||
refreshRechargeState();
|
||||
} else {
|
||||
setRechargeError('微信支付未完成');
|
||||
refreshRechargeState();
|
||||
}
|
||||
|
||||
clearWechatPayResultHash();
|
||||
}, [onRechargeSuccess, refreshRechargeState]);
|
||||
const openRechargeModal = () => {
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
@@ -3847,63 +3919,44 @@ export function RpgEntryHomeView({
|
||||
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||
.then(async (response) => {
|
||||
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
||||
const status = await requestWechatMiniProgramPayment(
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
await requestWechatMiniProgramPayment(
|
||||
response.wechatMiniProgramPayParams,
|
||||
response.order.orderId,
|
||||
);
|
||||
if (status === 'cancel') {
|
||||
setRechargeCenter(response.center);
|
||||
setRechargeSuccess('支付已取消');
|
||||
return;
|
||||
}
|
||||
if (status !== 'success') {
|
||||
throw new Error('微信支付未完成');
|
||||
}
|
||||
setRechargeSuccess('支付已提交');
|
||||
loadRechargeCenter();
|
||||
setRechargeCenter(response.center);
|
||||
return;
|
||||
} else {
|
||||
setRechargeCenter(response.center);
|
||||
setRechargeSuccess('已到账');
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setSubmittingRechargeProductId(null);
|
||||
}
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
setSubmittingRechargeProductId(null);
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isRechargeOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWechatPayResult = () => {
|
||||
const result = new URLSearchParams(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
).get('wx_pay_result');
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [, status] = result.split(':');
|
||||
if (status === 'success') {
|
||||
setRechargeSuccess('支付已提交');
|
||||
loadRechargeCenter();
|
||||
void onRechargeSuccess?.();
|
||||
clearWechatPayResultHash();
|
||||
} else if (status === 'cancel') {
|
||||
setRechargeSuccess('支付已取消');
|
||||
clearWechatPayResultHash();
|
||||
} else {
|
||||
setRechargeError('微信支付未完成');
|
||||
clearWechatPayResultHash();
|
||||
}
|
||||
const handleResume = () => {
|
||||
handleWechatPayResult();
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleWechatPayResult);
|
||||
handleWechatPayResult();
|
||||
return () =>
|
||||
window.removeEventListener('hashchange', handleWechatPayResult);
|
||||
}, [isRechargeOpen, onRechargeSuccess]);
|
||||
window.addEventListener('hashchange', handleResume);
|
||||
window.addEventListener('focus', handleResume);
|
||||
window.addEventListener('pageshow', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
handleResume();
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleResume);
|
||||
window.removeEventListener('focus', handleResume);
|
||||
window.removeEventListener('pageshow', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [handleWechatPayResult]);
|
||||
const loadTaskCenter = () => {
|
||||
setTaskCenterError(null);
|
||||
setIsLoadingTaskCenter(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ClaimProfileTaskRewardResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
@@ -105,6 +106,18 @@ export function createRpgProfileRechargeOrder(
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmWechatRpgProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ConfirmWechatProfileRechargeOrderResponse>(
|
||||
`/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
|
||||
{ method: 'POST' },
|
||||
'确认微信支付订单失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function submitRpgProfileFeedback(
|
||||
payload: SubmitProfileFeedbackRequest,
|
||||
options: RuntimeRequestOptions = {},
|
||||
@@ -305,6 +318,7 @@ export const rpgProfileClient = {
|
||||
getWalletLedger: getRpgProfileWalletLedger,
|
||||
getRechargeCenter: getRpgProfileRechargeCenter,
|
||||
createRechargeOrder: createRpgProfileRechargeOrder,
|
||||
confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder,
|
||||
submitFeedback: submitRpgProfileFeedback,
|
||||
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
|
||||
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -9,6 +9,7 @@ interface Window {
|
||||
miniProgram?: {
|
||||
navigateTo?: (options: {
|
||||
url: string;
|
||||
success?: (result?: unknown) => void;
|
||||
fail?: (error: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
postMessage?: (message: unknown) => void;
|
||||
|
||||
Reference in New Issue
Block a user