From c94f22e26c696a7c52b51d853118843e24820dc0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 08:43:21 +0800 Subject: [PATCH] feat: gate recharge payment by login device --- .hermes/shared-memory/decision-log.md | 8 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 21 +- .../OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md | 16 +- .../crates/api-server/src/auth_session.rs | 25 ++- .../crates/api-server/src/refresh_session.rs | 1 + .../crates/api-server/src/runtime_profile.rs | 189 +++++++++++++++++- server-rs/crates/platform-auth/README.md | 19 +- server-rs/crates/platform-auth/src/lib.rs | 102 +++++++++- .../RpgEntryHomeView.recharge.test.tsx | 86 ++++++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 48 +++-- src/services/payment/paymentPlatform.test.ts | 55 +++++ src/services/payment/paymentPlatform.ts | 29 ++- 12 files changed, 527 insertions(+), 72 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7b276d70..43cc51a3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式 - 背景:抓大鹅结果页 `素材配置 > 物品` 需要在不改变玩法物品映射的前提下,批量重新生成已存在物品的 2D 五视角图片。 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 83ed3fab..8d6eab64 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -9,7 +9,7 @@ 1. `泥点充值` 2. `会员卡充值` -前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,移动网页使用 `wechat_h5`,桌面网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。 +前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,手机微信内网页使用 `wechat_h5`,桌面微信内网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。 ## 2. 产品规则 @@ -64,13 +64,18 @@ 1. 校验 `productId` 2. 缺少 `paymentChannel` 或传入未知渠道时直接返回 `400`,不得再默认解释为 `mock` -3. 生产/真实支付配置拒绝 `paymentChannel = "mock"`;mock 只服务自动测试或显式 mock 测试配置 +3. 后端根据 Bearer JWT 中的 `device` 快照拦截真实微信渠道,不能只信任前端隐藏入口或请求体传入的 `paymentChannel` + - `wechat_mp` 只允许 `device.client_type = "mini_program"` + - `wechat_h5` 只允许 `device.client_type = "wechat_h5"` 且 `device.client_platform` 为 `ios` 或 `android` + - `wechat_native` 只允许 `device.client_type = "wechat_h5"` 且 `device.client_platform` 为 `windows`、`macos` 或 `linux` + - 缺少设备快照、普通浏览器设备、手机微信手动传 Native、桌面微信手动传 H5 都返回 `403` +4. 生产/真实支付配置拒绝 `paymentChannel = "mock"`;mock 只服务自动测试或显式 mock 测试配置 - `wechat_mp` / `wechat_h5` / `wechat_native` 必须在 `WECHAT_PAY_ENABLED=true` 且 `WECHAT_PAY_PROVIDER=real` 时才允许下单;`WECHAT_PAY_PROVIDER=mock` 不能为真实微信渠道返回 mock 支付载荷。 -4. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 +5. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 - JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 -5. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址 -6. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码 -7. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷 +6. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址 +7. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码;该渠道只面向桌面微信内网页 +8. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷 兼容路径:`POST /api/runtime/profile/recharge/orders` @@ -164,8 +169,8 @@ H5 与 Native 响应: - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 -5. 移动网页含微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序。 -6. 桌面网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额。 +5. 手机微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序。 +6. 桌面微信内网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额。 7. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 8. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 9. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md b/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md index 6a81f721..49642eab 100644 --- a/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md +++ b/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md @@ -116,6 +116,7 @@ | `phone_verified` | 是 | 是否已完成手机号验证。 | | `binding_status` | 是 | 账号绑定状态,固定为 `active`、`pending_bind_phone`。 | | `display_name` | 否 | 当前展示名快照,用于少量上游日志/观测;不是授权依据。 | +| `device` | 否 | 登录时采集到的最小设备快照,仅保留 `client_type`、`client_runtime`、`client_platform`,用于后端按设备拦截真实微信支付路径。 | ## 6. 关键字段定义 @@ -264,7 +265,7 @@ 4. refresh token hash 5. 风控状态、captcha 状态、封禁剩余时间 6. 完整用户资料对象 -7. 审计日志、设备列表、IP、UA +7. 审计日志、完整设备对象或列表、IP、UA 原因: @@ -284,6 +285,11 @@ "phone_verified": false, "binding_status": "pending_bind_phone", "display_name": "微信旅人", + "device": { + "client_type": "wechat_h5", + "client_runtime": "wechat_embedded_browser", + "client_platform": "ios" + }, "iat": 1713657600, "exp": 1713664800 } @@ -302,7 +308,8 @@ 1. 签发 access token 2. 校验 `iss/exp/sub/sid/provider/roles/ver` -3. 解析 claims 为统一 Rust 结构 +3. 规范化并透传 `device` 最小设备快照 +4. 解析 claims 为统一 Rust 结构 ### 9.2 `module-auth` @@ -344,6 +351,7 @@ | 无 | `phone_verified` | 新增,表达手机号验证状态。 | | 无 | `binding_status` | 新增,表达待绑手机/已激活状态。 | | 无 | `display_name` | 可选新增。 | +| 无 | `device` | 可选新增,承载最小设备快照,不包含完整 session 或敏感原始环境。 | ## 11. Express / Axum 请求上下文映射 @@ -361,6 +369,7 @@ Rust 侧建议升级为统一 claims 结构,例如: 5. `token_version` 6. `phone_verified` 7. `binding_status` +8. `device` 说明: @@ -384,7 +393,8 @@ Rust 侧建议升级为统一 claims 结构,例如: 1. `iss/sub/sid/provider/roles` 已明确冻结 2. access token 与 refresh session 的职责边界已切开 3. Axum、`platform-auth`、`module-auth`、`SpacetimeDB` 的使用边界已明确 -4. 后续可以直接按这份文档实现签发、校验与身份透传 +4. 登录设备快照的最小字段已明确,且可用于支付路径拦截 +5. 后续可以直接按这份文档实现签发、校验与身份透传 ## 14. 依据文件 diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index e06c27f3..90b1325e 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -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 { - 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(), + } +} diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 28d0432c..9276ce1b 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -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, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a0ed8af8..8374c9ce 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -197,6 +197,8 @@ pub async fn create_profile_recharge_order( let user_id = authenticated.claims().user_id().to_string(); let payment_channel = normalize_recharge_payment_channel(payload.payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; + validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_payment_channel(&state, &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let created_at_micros = current_unix_micros(); @@ -956,6 +958,34 @@ fn validate_recharge_payment_channel( Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效")) } +fn validate_recharge_device_for_payment_channel( + claims: &platform_auth::AccessTokenClaims, + payment_channel: &str, +) -> Result<(), AppError> { + if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { + return Ok(()); + } + + if !is_wechat_recharge_payment_channel(payment_channel) { + return Ok(()); + } + + let is_supported_device = match payment_channel { + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => { + claims.is_wechat_mini_program_device() + } + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(), + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(), + _ => false, + }; + if is_supported_device { + return Ok(()); + } + + Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("当前登录设备不支持充值,请在微信环境内登录后重试")) +} + fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> { if !state.config.wechat_pay_enabled { return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) @@ -1738,7 +1768,7 @@ mod tests { } #[tokio::test] - async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { + async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), @@ -1749,6 +1779,133 @@ mod tests { let token = issue_access_token(&state); let app = build_router(state); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "当前登录设备不支持充值,请在微信环境内登录后重试" + ); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "当前登录设备不支持充值,请在微信环境内登录后重试" + ); + } + + #[tokio::test] + async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = + issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "真实微信支付渠道不能使用 mock 支付配置" + ); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5"); + let app = build_router(state); + let response = app .oneshot( Request::builder() @@ -2043,4 +2200,34 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + + fn issue_wechat_h5_access_token( + state: &AppState, + client_platform: &str, + session_id: &str, + ) -> String { + let claims = AccessTokenClaims::from_input_with_device( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", session_id), + provider: AuthProvider::Wechat, + roles: vec!["user".to_string()], + token_version: 2, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("微信资料页用户".to_string()), + }, + Some(platform_auth::AccessTokenDeviceInfo { + client_type: "wechat_h5".to_string(), + client_runtime: "wechat_embedded_browser".to_string(), + client_platform: client_platform.to_string(), + }), + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } } diff --git a/server-rs/crates/platform-auth/README.md b/server-rs/crates/platform-auth/README.md index eff1d349..5853fb63 100644 --- a/server-rs/crates/platform-auth/README.md +++ b/server-rs/crates/platform-auth/README.md @@ -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 等外部细节重新散落到多个业务模块中各自实现。 diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index ffa21519..c0efd58a 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device: Option, pub iat: u64, pub exp: u64, } @@ -1481,11 +1492,21 @@ impl AccessTokenClaims { input: AccessTokenClaimsInput, config: &JwtConfig, issued_at: OffsetDateTime, + ) -> Result { + Self::from_input_with_device(input, None, config, issued_at) + } + + pub fn from_input_with_device( + input: AccessTokenClaimsInput, + device: Option, + config: &JwtConfig, + issued_at: OffsetDateTime, ) -> Result { 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 { + 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, @@ -2129,10 +2226,7 @@ mod tests { let phone_info = payload.phone_info.expect("phone info should exist"); assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000")); - assert_eq!( - phone_info.pure_phone_number.as_deref(), - Some("13800138000") - ); + assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000")); assert_eq!(phone_info.country_code.as_deref(), Some("86")); } diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a361e871..875055f2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -401,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; @@ -629,6 +631,37 @@ function mockDesktopLayout() { }); } +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: '(max-width: 767px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + function renderProfileView( onRechargeSuccess = vi.fn(), profileDashboardOverrides: Partial< @@ -1013,6 +1046,14 @@ afterEach(() => { 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, @@ -1046,7 +1087,7 @@ test('opens wallet ledger modal from narrative coin card', async () => { test('profile recharge modal shows native qr code on desktop web by default', async () => { const user = userEvent.setup(); - mockDesktopLayout(); + mockWechatDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-1', @@ -1111,24 +1152,7 @@ test('profile recharge modal shows native qr code on desktop web by default', as test('profile recharge modal jumps to h5 payment on mobile web by default', async () => { const user = userEvent.setup(); - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', - }); - Object.defineProperty(window, 'matchMedia', { - configurable: true, - writable: true, - value: vi.fn().mockImplementation(() => ({ - matches: true, - media: '(max-width: 767px)', - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); + mockWechatMobileLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-h5-1', @@ -1603,7 +1627,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa test('profile native qr confirmation refreshes only after server reports paid', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); - mockDesktopLayout(); + mockWechatDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-paid', @@ -1687,6 +1711,23 @@ test('profile native qr confirmation refreshes only after server reports paid', 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(); @@ -1925,7 +1966,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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 7ae68662..5247051e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -77,6 +77,7 @@ import { import { copyTextToClipboard } from '../../services/clipboard'; import { resolveProfileRechargePaymentChannel, + shouldShowRechargeEntry, WECHAT_H5_PAYMENT_CHANNEL, WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, WECHAT_NATIVE_PAYMENT_CHANNEL, @@ -3468,6 +3469,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(''); @@ -4096,6 +4098,14 @@ export function RpgEntryHomeView({ setIsRechargeOpen(true); loadRechargeCenter(); }; + const openRechargeOrRewardCodeModal = () => { + if (showRechargeEntry) { + openRechargeModal(); + return; + } + + openRewardCodeModal(); + }; const buyRechargeProduct = (product: ProfileRechargeProduct) => { if (submittingRechargeProductId) { return; @@ -5463,13 +5473,21 @@ export function RpgEntryHomeView({ @@ -5558,17 +5576,19 @@ export function RpgEntryHomeView({ onClick={openTaskCenterPanel} /> - + {showRechargeEntry ? ( + + ) : null} { ).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({ @@ -61,3 +75,44 @@ describe('resolveProfileRechargePaymentChannel', () => { ).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); + }); +}); diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts index 3c4e21f3..93b26b8a 100644 --- a/src/services/payment/paymentPlatform.ts +++ b/src/services/payment/paymentPlatform.ts @@ -16,6 +16,20 @@ export type PaymentPlatformContext = { 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 { @@ -55,16 +69,21 @@ function isWechatMiniProgramRuntime( ); } +function isWechatBrowserRuntime( + navigatorLike: Partial | null | undefined, +) { + return ( + navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ?? + false + ); +} + function isMobileWebRuntime( navigatorLike: Partial | null | undefined, matchMedia: Window['matchMedia'] | null | undefined, ) { const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? ''; - if ( - /android|iphone|ipad|ipod|mobile|micromessenger|windows phone/u.test( - userAgent, - ) - ) { + if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) { return true; }