Merge branch 'hermes/wechat'

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md
#	docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md
#	server-rs/crates/module-runtime/src/errors.rs
#	src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
2026-05-15 11:32:51 +08:00
23 changed files with 2325 additions and 107 deletions
+16
View File
@@ -16,6 +16,14 @@
---
## 2026-05-15 微信充值支付路径以后端 JWT 设备快照为准
- 背景:前端隐藏非微信环境充值入口只能改善体验,不能阻止用户手动调用 `/api/profile/recharge/orders` 并伪造 `paymentChannel`
- 决策:access JWT 只新增最小设备快照 `device.client_type/client_runtime/client_platform`,来源为登录或 refresh session 中的 `client_info`;不把完整 session、IP、UA 或设备列表塞进 JWT。真实微信充值下单必须由后端按 JWT 设备快照拦截:小程序 `wechat_mp` 只允许 `mini_program`,手机微信内网页 `wechat_h5` 只允许 `wechat_h5 + ios/android`,桌面微信内网页 `wechat_native` 只允许 `wechat_h5 + windows/macos/linux`。非微信环境前端不显示充值入口,改显示兑换码入口。
- 影响范围:`platform-auth` JWT claims、`api-server` auth session/refresh session 签发、`runtime_profile` 充值订单接口、前端支付平台隔离层和“我的”页常用功能区。
- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``cargo test -p api-server profile_recharge_order --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-15 抓大鹅结果页 UI 预览复用运行态布局
- 背景:抓大鹅结果页 `素材配置 > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实运行态顶部关卡卡片、右上设置入口、容器图定位及槽位样式出现漂移。
@@ -170,6 +178,14 @@
- 验证方式:执行 `npm run typecheck``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml``cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`,后端联调仍用 `npm run api-server``/healthz`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-15 微信充值默认真实渠道与 mock 禁用
- 背景:账户充值扩展到普通商户直连 H5 与 Native 支付后,旧的“非小程序默认 mock”会把真实用户充值静默导向测试通道。
- 决策:默认支付渠道只由设备平台隔离层解析为 `wechat_mp` / `wechat_h5` / `wechat_native`:小程序走 `wechat_mp`,移动网页含微信内 H5 走 `wechat_h5`,桌面网页走 `wechat_native` 二维码。`paymentChannel` 缺失或未知直接 `400`;生产/真实支付配置拒绝 `mock`,只有自动测试或显式 mock 测试配置可手动传 `paymentChannel = "mock"`。真实微信渠道必须在 `WECHAT_PAY_ENABLED=true``WECHAT_PAY_PROVIDER=real` 下单,禁止由 mock provider 返回 H5/Native/小程序 mock 支付载荷。所有微信渠道仍以后端通知与服务端查单入账。
- 影响范围:`src/services/payment/paymentPlatform.ts``RpgEntryHomeView` 充值弹窗、`api-server` 充值订单接口、`WechatPayClient` H5/Native 下单、共享 recharge contracts。
- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml``cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-05-13 修改密码后全设备强制下线
- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。
@@ -105,6 +105,9 @@ npm run check:server-rs-ddd
3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
6. `paymentChannel` 缺失、未知或和设备不匹配时必须拒绝;真实微信渠道只允许 `wechat_mp``wechat_h5``wechat_native`,生产配置不得把真实支付静默降级为 `mock`
7. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
## 外部服务与资产
@@ -45,8 +45,10 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
2. 泥点默认档位为 `60 / 180 / 300 / 680 / 1280 / 3280`,会员默认档位为月卡、季卡、年卡;实际展示、下单校验和支付确认都以后端返回的充值商品配置为准。
3. 首充双倍按泥点商品档位独立计算。用户买过 `points_60` 后,只影响 `points_60` 的首充展示和结算,其它未购买档位仍保留各自首充权益。
4. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
5. 小程序 WebView 充值使用 `wechat_mp` 渠道时,H5 只跳转 native 支付页并在返回后请求服务端查单确认;只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态
6. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照
5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp``wechat_h5``wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝
6. 小程序 WebView 充值使用 `wechat_mp` 渠道时,H5 只跳转 native 支付页并在返回后请求服务端查单确认;手机微信内网页使用 `wechat_h5` 跳转微信 H5 支付;桌面微信内网页使用 `wechat_native` 二维码。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态
7. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`
8. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。
## 唯一后端路线
+434 -16
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -55,6 +55,7 @@
"dotenv": "^17.2.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"three": "^0.184.0",
@@ -64,6 +65,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0",
+16 -1
View File
@@ -147,6 +147,14 @@ export type WechatMiniProgramPayParams = {
paySign: string;
};
export type WechatH5Payment = {
h5Url: string;
};
export type WechatNativePayment = {
codeUrl: string;
};
export type CreateProfileRechargeOrderRequest = {
productId: string;
paymentChannel?: string;
@@ -156,6 +164,8 @@ export type CreateProfileRechargeOrderResponse = {
order: ProfileRechargeOrder;
center: ProfileRechargeCenterResponse;
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
wechatH5Payment?: WechatH5Payment | null;
wechatNativePayment?: WechatNativePayment | null;
};
export type ConfirmWechatProfileRechargeOrderResponse = {
@@ -242,7 +252,12 @@ export type RedeemProfileRewardCodeResponse = {
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type AnalyticsGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type AnalyticsGranularity =
| 'day'
| 'week'
| 'month'
| 'quarter'
| 'year';
export type ProfileTaskStatus =
| 'incomplete'
| 'claimable'
@@ -1,19 +1,19 @@
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
use module_auth::{
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionClientInfo,
RefreshSessionError,
};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
AccessTokenClaims, AccessTokenClaimsInput, AccessTokenDeviceInfo, AuthProvider, BindingStatus,
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
};
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{
http_error::AppError, request_context::RequestContext, state::AppState,
tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path,
};
#[cfg(not(test))]
use crate::tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path;
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
@@ -81,6 +81,7 @@ pub fn create_auth_session(
user,
&session.session.session_id,
Some(&session_provider),
Some(&session.session.client_info),
)?;
Ok(SignedAuthSession {
@@ -94,8 +95,9 @@ pub fn sign_access_token_for_user(
user: &AuthUser,
session_id: &str,
session_provider_override: Option<&AuthLoginMethod>,
client_info: Option<&RefreshSessionClientInfo>,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input(
let access_claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
@@ -106,6 +108,7 @@ pub fn sign_access_token_for_user(
binding_status: map_binding_status(&user.binding_status),
display_name: Some(user.display_name.clone()),
},
client_info.map(map_access_token_device_info),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
@@ -182,3 +185,11 @@ fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> Bindin
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
}
}
fn map_access_token_device_info(client_info: &RefreshSessionClientInfo) -> AccessTokenDeviceInfo {
AccessTokenDeviceInfo {
client_type: client_info.client_type.clone(),
client_runtime: client_info.client_runtime.clone(),
client_platform: client_info.client_platform.clone(),
}
}
@@ -54,6 +54,7 @@ pub async fn refresh_session(
&rotated.user,
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
Some(&rotated.session.client_info),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -260,7 +260,7 @@ pub fn build_runtime_profile_recharge_order_create_input(
let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
.ok_or(RuntimeProfileFieldError::MissingPaymentChannel)?;
Ok(RuntimeProfileRechargeOrderCreateInput {
user_id,
@@ -34,6 +34,8 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;
@@ -80,6 +80,7 @@ pub enum RuntimeProfileFieldError {
InvalidRechargeProductDuration,
InvalidRechargeProductKind,
InvalidRechargeProductTier,
MissingPaymentChannel,
MissingWorldKey,
MissingBottomTab,
MissingCheckpointSessionId,
@@ -150,6 +151,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
}
Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"),
Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"),
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),
@@ -745,6 +745,19 @@ mod tests {
assert_eq!(input.payment_channel, "mock");
}
#[test]
fn build_recharge_order_input_rejects_missing_payment_channel() {
let error = build_runtime_profile_recharge_order_create_input(
"user-1".to_string(),
"points_60".to_string(),
" ".to_string(),
1,
)
.expect_err("missing payment channel should fail");
assert_eq!(error, RuntimeProfileFieldError::MissingPaymentChannel);
}
#[test]
fn runtime_profile_identity_helpers_keep_existing_key_shape() {
assert_eq!(
+10 -9
View File
@@ -17,7 +17,7 @@
本阶段已经完成 JWT 基础能力首版落地:
1. 新增 `JwtConfig`,统一管理 `issuer``secret` 与 access token TTL。
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name/device` 映射到 Rust 结构。
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
5. 新增 `RefreshCookieConfig``RefreshCookieSameSite``read_refresh_session_token(...)`,统一 refresh cookie 读取口径。
@@ -36,13 +36,14 @@
1. `JwtConfig::new(...)`
2. `AccessTokenClaims::from_input(...)`
3. `sign_access_token(...)`
4. `verify_access_token(...)`
5. `RefreshCookieConfig::new(...)`
6. `read_refresh_session_token(...)`
7. `AuthProvider`
8. `BindingStatus`
9. `RefreshCookieSameSite`
3. `AccessTokenClaims::from_input_with_device(...)`
4. `sign_access_token(...)`
5. `verify_access_token(...)`
6. `RefreshCookieConfig::new(...)`
7. `read_refresh_session_token(...)`
8. `AuthProvider`
9. `BindingStatus`
10. `RefreshCookieSameSite`
## 4. 配置口径
@@ -67,7 +68,7 @@
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
2. `sub` 必须是稳定 `user_id``sid` 必须是会话 ID,不能退化为一次 token 的随机 ID。
3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。
3. 不允许把手机号、openid、refresh token hash、风控状态、完整设备对象、IP、UA 等敏感或高频变化字段塞进 JWT`device` 只允许保存支付拦截需要的最小设备快照
4. 鉴权状态最终由 `module-auth``crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。
5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。
+97
View File
@@ -66,6 +66,15 @@ pub enum BindingStatus {
PendingBindPhone,
}
// JWT 里只保留一份规范化后的设备快照,用于后端按登录设备拦截充值路径。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AccessTokenDeviceInfo {
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
}
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccessTokenClaimsInput {
@@ -92,6 +101,8 @@ pub struct AccessTokenClaims {
pub binding_status: BindingStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<AccessTokenDeviceInfo>,
pub iat: u64,
pub exp: u64,
}
@@ -1481,11 +1492,21 @@ impl AccessTokenClaims {
input: AccessTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
Self::from_input_with_device(input, None, config, issued_at)
}
pub fn from_input_with_device(
input: AccessTokenClaimsInput,
device: Option<AccessTokenDeviceInfo>,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
let roles = normalize_roles(input.roles)?;
let display_name = normalize_optional_field(input.display_name);
let device = device.map(|device| device.normalize()).transpose()?;
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
@@ -1515,6 +1536,7 @@ impl AccessTokenClaims {
phone_verified: input.phone_verified,
binding_status: input.binding_status,
display_name,
device,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
@@ -1535,6 +1557,46 @@ impl AccessTokenClaims {
self.ver
}
pub fn client_type(&self) -> Option<&str> {
self.device
.as_ref()
.map(|device| device.client_type.as_str())
}
pub fn client_platform(&self) -> Option<&str> {
self.device
.as_ref()
.map(|device| device.client_platform.as_str())
}
pub fn is_wechat_mini_program_device(&self) -> bool {
matches!(self.client_type(), Some("mini_program"))
}
pub fn is_wechat_h5_device(&self) -> bool {
matches!(self.client_type(), Some("wechat_h5"))
}
pub fn is_wechat_payment_device(&self) -> bool {
self.is_wechat_mini_program_device() || self.is_wechat_h5_device()
}
pub fn is_mobile_device(&self) -> bool {
matches!(self.client_platform(), Some("ios" | "android"))
}
pub fn is_desktop_device(&self) -> bool {
matches!(self.client_platform(), Some("windows" | "macos" | "linux"))
}
pub fn is_mobile_wechat_browser_device(&self) -> bool {
self.is_wechat_h5_device() && self.is_mobile_device()
}
pub fn is_desktop_wechat_browser_device(&self) -> bool {
self.is_wechat_h5_device() && self.is_desktop_device()
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
@@ -1543,6 +1605,9 @@ impl AccessTokenClaims {
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
normalize_roles(self.roles.clone())?;
if let Some(device) = &self.device {
device.validate()?;
}
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
@@ -1552,6 +1617,38 @@ impl AccessTokenClaims {
}
}
impl AccessTokenDeviceInfo {
pub fn normalize(self) -> Result<Self, JwtError> {
Ok(Self {
client_type: normalize_required_field(
self.client_type,
"JWT device.client_type 不能为空",
)?,
client_runtime: normalize_required_field(
self.client_runtime,
"JWT device.client_runtime 不能为空",
)?,
client_platform: normalize_required_field(
self.client_platform,
"JWT device.client_platform 不能为空",
)?,
})
}
pub fn validate(&self) -> Result<(), JwtError> {
normalize_required_field(self.client_type.clone(), "JWT device.client_type 不能为空")?;
normalize_required_field(
self.client_runtime.clone(),
"JWT device.client_runtime 不能为空",
)?;
normalize_required_field(
self.client_platform.clone(),
"JWT device.client_platform 不能为空",
)?;
Ok(())
}
}
pub fn sign_access_token(
claims: &AccessTokenClaims,
config: &JwtConfig,
@@ -245,6 +245,18 @@ pub struct WechatMiniProgramPayParamsResponse {
pub pay_sign: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WechatH5PaymentResponse {
pub h5_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WechatNativePaymentResponse {
pub code_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeCenterResponse {
@@ -272,6 +284,10 @@ pub struct CreateProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse,
#[serde(default)]
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
#[serde(default)]
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
#[serde(default)]
pub wechat_native_payment: Option<WechatNativePaymentResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -1381,6 +1397,60 @@ mod tests {
assert_eq!(payload.payment_channel, None);
}
#[test]
fn create_profile_recharge_order_response_serializes_web_wechat_payloads() {
let order = ProfileRechargeOrderResponse {
order_id: "rcgtest001".to_string(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: "points".to_string(),
amount_cents: 600,
status: "pending".to_string(),
payment_channel: "wechat_native".to_string(),
paid_at: None,
provider_transaction_id: None,
created_at: "2026-05-15T10:00:00Z".to_string(),
points_delta: 0,
membership_expires_at: None,
};
let center = ProfileRechargeCenterResponse {
wallet_balance: 0,
membership: ProfileMembershipResponse {
status: "normal".to_string(),
tier: "normal".to_string(),
started_at: None,
expires_at: None,
updated_at: None,
},
point_products: vec![],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
order,
center,
wechat_mini_program_pay_params: None,
wechat_h5_payment: Some(WechatH5PaymentResponse {
h5_url: "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb".to_string(),
}),
wechat_native_payment: Some(WechatNativePaymentResponse {
code_url: "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test".to_string(),
}),
})
.expect("payload should serialize");
assert_eq!(
payload["wechatH5Payment"]["h5Url"],
json!("https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb")
);
assert_eq!(
payload["wechatNativePayment"]["codeUrl"],
json!("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
);
}
#[test]
fn profile_feedback_response_uses_camel_case_fields() {
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
@@ -37,6 +37,8 @@ import {
} from './rpgEntryWorldPresentation';
const {
mockQrCodeToDataUrl,
mockRedirectToPaymentUrl,
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
@@ -48,6 +50,8 @@ const {
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn();
const buildReferralCenter = (
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse => ({
@@ -119,6 +123,8 @@ const {
});
return {
mockQrCodeToDataUrl: qrCodeToDataUrl,
mockRedirectToPaymentUrl: redirectToPaymentUrl,
mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
@@ -343,6 +349,16 @@ vi.mock('../../services/authService', () => ({
updateAuthProfile: mockUpdateAuthProfile,
}));
vi.mock('qrcode', () => ({
default: {
toDataURL: mockQrCodeToDataUrl,
},
}));
vi.mock('../../services/payment/paymentRedirect', () => ({
redirectToPaymentUrl: mockRedirectToPaymentUrl,
}));
mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1',
publicUserCode: '100001',
@@ -385,6 +401,8 @@ vi.mock('../ResolvedAssetImage', () => ({
}));
const originalMatchMedia = window.matchMedia;
const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -584,12 +602,56 @@ function buildBabyObjectMatchEntry(
}
function mockDesktopLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: 0,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const normalizedQuery = query.replace(/\s/g, '');
return {
matches:
normalizedQuery.includes('min-width:1024px') ||
normalizedQuery.includes('min-width:1024'),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function mockWechatDesktopLayout() {
mockDesktopLayout();
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/8.0',
});
}
function mockWechatMobileLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit MicroMessenger/8.0 Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
media: '(max-width: 767px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
@@ -676,7 +738,10 @@ function renderProfileView(
}
async function openRechargeModal(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /\s*\//u }));
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
}
function renderLoggedOutHomeView(
@@ -981,11 +1046,21 @@ afterEach(() => {
wechatBound: false,
createdAt: new Date().toISOString(),
});
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: originalUserAgent,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: originalMaxTouchPoints,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
@@ -1017,12 +1092,49 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile recharge modal buys points through mock channel outside mini program', async () => {
test('profile recharge modal shows native qr code on desktop web by default', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockWechatDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
paidAt: 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,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
},
});
renderProfileView(onRechargeSuccess);
await openRechargeModal(user);
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
@@ -1031,16 +1143,84 @@ test('profile recharge modal buys points through mock channel outside mini progr
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'mock',
'wechat_native',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('微信扫码支付')).toBeTruthy();
await waitFor(() => {
expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy();
});
expect(mockQrCodeToDataUrl).toHaveBeenCalledWith(
'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
expect.objectContaining({ width: 180 }),
);
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal jumps to h5 payment on mobile web by default', async () => {
const user = userEvent.setup();
mockWechatMobileLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-h5-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_h5',
paidAt: 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,
},
wechatH5Payment: {
h5Url:
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_h5',
);
});
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
);
expect(
await screen.findByRole('dialog', { name: '正在打开微信支付' }),
).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal trusts per-product first bonus display after points recharge', async () => {
const user = userEvent.setup();
mockWechatDesktopLayout();
mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({
walletBalance: 60,
membership: {
@@ -1158,6 +1338,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
'points_60',
'mock',
);
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1',
@@ -1476,6 +1661,110 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
});
test('profile native qr confirmation refreshes only after server reports paid', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockWechatDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
paidAt: 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,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-paid',
},
});
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_native',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-native-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: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await user.click(await screen.findByRole('button', { name: '我已支付' }));
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-native-paid',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('non-wechat profile shows reward code instead of recharge entry', async () => {
const user = userEvent.setup();
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
await user.click(within(shortcutRegion).getByRole('button', { name: //u }));
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -1731,7 +2020,10 @@ test('opens reward code modal from profile action on mobile', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
const modal = await screen.findByPlaceholderText('输入兑换码');
expect(modal).toBeTruthy();
+208 -31
View File
@@ -42,6 +42,7 @@ import {
useRef,
useState,
} from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -57,6 +58,7 @@ import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
WechatNativePayment,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
@@ -73,6 +75,14 @@ import {
updateAuthProfile,
} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
resolveProfileRechargePaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
import {
claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder,
@@ -217,9 +227,9 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type RechargeTab = 'points' | 'membership';
@@ -235,6 +245,10 @@ type RechargePaymentResult = {
title: string;
message: string;
};
type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
isConfirming: boolean;
};
type DiscoverChannel =
| 'recommend'
| 'today'
@@ -2527,18 +2541,6 @@ function formatRechargePrice(priceCents: number) {
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function isWechatMiniProgramWebView() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function clearWechatPayResultHash() {
if (typeof window === 'undefined') {
return;
@@ -2685,6 +2687,36 @@ async function confirmWechatRechargeOrderUntilSettled(
return latestResponse;
}
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function RechargeProductCard({
product,
submittingProductId,
@@ -2737,22 +2769,29 @@ function ProfileRechargeModal({
isLoading,
error,
submittingProductId,
nativePayment,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
}: {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
}) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
@@ -2841,6 +2880,33 @@ function ProfileRechargeModal({
</div>
)}
{nativePayment ? (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-4 text-center">
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<button
type="button"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
className="platform-primary-button mt-4 rounded-2xl px-4 py-2 text-xs font-black disabled:cursor-wait disabled:opacity-60"
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</button>
</div>
) : null}
</div>
</div>
</div>
@@ -3594,6 +3660,7 @@ export function RpgEntryHomeView({
hasUnreadDraftUpdate = false,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const showRechargeEntry = shouldShowRechargeEntry();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
@@ -3611,6 +3678,8 @@ export function RpgEntryHomeView({
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -4149,6 +4218,7 @@ export function RpgEntryHomeView({
loadRechargeCenter();
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
}, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
@@ -4232,16 +4302,24 @@ export function RpgEntryHomeView({
setIsRechargeOpen(true);
loadRechargeCenter();
};
const openRechargeOrRewardCodeModal = () => {
if (showRechargeEntry) {
openRechargeModal();
return;
}
openRewardCodeModal();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
const paymentChannel = isWechatMiniProgramWebView()
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
: 'mock';
const paymentChannel = resolveProfileRechargePaymentChannel();
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargePaymentResult(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
@@ -4252,24 +4330,105 @@ export function RpgEntryHomeView({
);
setRechargeCenter(response.center);
return;
} else {
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
const h5Url = response.wechatH5Payment?.h5Url?.trim();
if (!h5Url) {
throw new Error('微信 H5 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
kind: 'pending',
title: '正在打开微信支付',
message: '完成支付后返回页面确认到账状态。',
});
pendingWechatRechargeOrderIdRef.current = null;
setSubmittingRechargeProductId(null);
redirectToPaymentUrl(h5Url);
return;
}
void onRechargeSuccess?.();
if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) {
const codeUrl = response.wechatNativePayment?.codeUrl?.trim();
if (!codeUrl) {
throw new Error('微信 Native 支付二维码生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setNativeWechatPayment({
orderId: response.order.orderId,
codeUrl,
isConfirming: false,
});
setSubmittingRechargeProductId(null);
return;
}
throw new Error('充值支付渠道无效');
})
.catch((error: unknown) => {
pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
setRechargeError(error instanceof Error ? error.message : '充值失败');
setSubmittingRechargeProductId(null);
});
};
const confirmNativeWechatPayment = useCallback(() => {
if (!nativeWechatPayment || nativeWechatPayment.isConfirming) {
return;
}
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: true }
: current,
);
setRechargePaymentResult({
kind: 'pending',
title: '正在确认支付',
message: '正在查询微信支付到账状态。',
});
void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
},
);
if (isPaid) {
setNativeWechatPayment(null);
pendingWechatRechargeOrderIdRef.current = null;
void onRechargeSuccess?.();
} else {
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
}
})
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
});
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
})
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const handleResume = () => {
handleWechatPayResult();
@@ -5569,13 +5728,21 @@ export function RpgEntryHomeView({
<button
type="button"
onClick={openRechargeModal}
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
<Coins className="h-4 w-4" />
{showRechargeEntry ? (
<Coins className="h-4 w-4" />
) : (
<Ticket className="h-4 w-4" />
)}
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">/</div>
<div className="text-xs font-bold">
{showRechargeEntry ? '充值' : '兑换码'}
</div>
<div className="text-[10px] opacity-80">
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -5659,11 +5826,19 @@ export function RpgEntryHomeView({
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
label={showRechargeEntry ? '充值' : '兑换码'}
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
icon={showRechargeEntry ? Coins : Ticket}
onClick={openRechargeOrRewardCodeModal}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
/>
) : null}
<ProfileShortcutButton
label="邀请好友"
subLabel={
@@ -6149,11 +6324,13 @@ export function RpgEntryHomeView({
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
@@ -0,0 +1,118 @@
import { describe, expect, test } from 'vitest';
import {
resolveProfileRechargePaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from './paymentPlatform';
describe('resolveProfileRechargePaymentChannel', () => {
test('小程序运行态选择 wechat_mp', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
}),
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
});
test('移动网页选择 wechat_h5', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
},
}),
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
});
test('微信内 H5 首版仍选择 wechat_h5', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
},
}),
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
});
test('桌面网页选择 wechat_native', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
});
test('桌面微信内网页选择 wechat_native', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit MicroMessenger/8.0',
},
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
});
test('默认路径永远不会解析成 mock', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: { userAgent: '' },
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).not.toBe('mock');
});
});
describe('shouldShowRechargeEntry', () => {
test('小程序运行态显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '?clientRuntime=wechat_mini_program' },
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
}),
).toBe(true);
});
test('微信内网页显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
},
}),
).toBe(true);
});
test('普通浏览器不显示充值入口', () => {
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
},
}),
).toBe(false);
expect(
shouldShowRechargeEntry({
location: { search: '' },
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
}),
).toBe(false);
});
});
+95
View File
@@ -0,0 +1,95 @@
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
export const MOCK_PAYMENT_CHANNEL = 'mock';
export type ProfileRechargeWechatPaymentChannel =
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
| typeof WECHAT_H5_PAYMENT_CHANNEL
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
export type PaymentPlatformContext = {
location?: Pick<Location, 'search'> | null;
navigator?: Partial<PaymentPlatformNavigator> | null;
matchMedia?: Window['matchMedia'] | null;
};
export function shouldShowRechargeEntry(
context: PaymentPlatformContext = {},
) {
const location =
context.location ?? (typeof window !== 'undefined' ? window.location : null);
const navigatorLike =
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
return (
isWechatMiniProgramRuntime(location) ||
isWechatBrowserRuntime(navigatorLike)
);
}
export function resolveProfileRechargePaymentChannel(
context: PaymentPlatformContext = {},
): ProfileRechargeWechatPaymentChannel {
const location =
context.location ??
(typeof window !== 'undefined' ? window.location : null);
const navigatorLike =
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
const matchMedia =
context.matchMedia ??
(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia.bind(window)
: null);
if (isWechatMiniProgramRuntime(location)) {
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
}
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
return WECHAT_H5_PAYMENT_CHANNEL;
}
return WECHAT_NATIVE_PAYMENT_CHANNEL;
}
export function isManualMockPaymentChannel(paymentChannel: string) {
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
}
function isWechatMiniProgramRuntime(
location: Pick<Location, 'search'> | null | undefined,
) {
const params = new URLSearchParams(location?.search ?? '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function isWechatBrowserRuntime(
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
) {
return (
navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ??
false
);
}
function isMobileWebRuntime(
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
matchMedia: Window['matchMedia'] | null | undefined,
) {
const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? '';
if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) {
return true;
}
if ((navigatorLike?.maxTouchPoints ?? 0) > 1) {
return true;
}
return Boolean(matchMedia?.('(max-width: 767px)').matches);
}
+3
View File
@@ -0,0 +1,3 @@
export function redirectToPaymentUrl(url: string) {
window.location.assign(url);
}
+9 -10
View File
@@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
);
}
export function getRpgProfileWalletLedger(
options: RuntimeRequestOptions = {},
) {
export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
'/profile/wallet-ledger',
{ method: 'GET' },
@@ -91,7 +89,7 @@ export function getRpgProfileRechargeCenter(
export function createRpgProfileRechargeOrder(
productId: string,
paymentChannel = 'mock',
paymentChannel: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
@@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive(
worldKey: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复存档失败',
options,
);
const response =
await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复存档失败',
options,
);
return {
entry: response.entry,