diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7ec7b82b..20794bc4 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/【项目基线】当前产品与工程约束-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,不符合“改密后全设备强制下线”的账号安全预期。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8053a061..f65a7b87 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 二维码返回都不能直接发放泥点或会员。 ## 外部服务与资产 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 8a40e07c..d04c7773 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -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. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。 ## 唯一后端路线 diff --git a/package-lock.json b/package-lock.json index aa3187a3..9008c606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,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", @@ -23,6 +24,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", @@ -1679,6 +1681,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2174,7 +2186,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2183,7 +2194,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2373,6 +2383,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -2444,11 +2463,21 @@ "node": "*" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2459,8 +2488,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2553,6 +2581,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2614,6 +2651,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2688,6 +2731,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -3262,6 +3311,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -3558,6 +3616,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4276,6 +4343,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4304,7 +4380,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -4384,6 +4459,15 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -4488,6 +4572,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -4547,6 +4648,21 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -4700,6 +4816,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4756,11 +4878,24 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6674,6 +6809,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6699,6 +6840,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6741,11 +6896,104 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7716,6 +7964,15 @@ "undici-types": "~6.21.0" } }, + "@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8048,14 +8305,12 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -8171,6 +8426,11 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, "caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -8215,11 +8475,20 @@ "get-func-name": "^2.0.2" } }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -8227,8 +8496,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "combined-stream": { "version": "1.0.8", @@ -8301,6 +8569,11 @@ "ms": "^2.1.3" } }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + }, "decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -8346,6 +8619,11 @@ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true }, + "dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -8401,6 +8679,11 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -8800,6 +9083,11 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -9010,6 +9298,11 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9454,6 +9747,11 @@ "p-limit": "^3.0.2" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9475,8 +9773,7 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, "path-is-absolute": { "version": "1.0.1", @@ -9537,6 +9834,11 @@ } } }, + "pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" + }, "postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -9599,6 +9901,16 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "requires": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + } + }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -9635,6 +9947,16 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -9742,6 +10064,11 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9786,11 +10113,20 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -10724,6 +11060,11 @@ "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10740,6 +11081,16 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10765,11 +11116,78 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ea50ba07..0d165e86 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 97a82ada..784728f0 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -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' 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 10f01043..345c2b89 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1,12 +1,14 @@ use axum::{ Json, extract::{Extension, Path, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::Response, }; use module_runtime::{ AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, @@ -71,8 +73,8 @@ use crate::{ request_context::RequestContext, state::AppState, wechat_pay::{ - WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros, - map_wechat_pay_error, + WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request, + current_unix_micros, map_wechat_pay_error, }, }; @@ -196,13 +198,16 @@ pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); - let payment_channel = payload - .payment_channel - .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); - let payment_channel = payment_channel.trim().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(); let (center, order) = state .spacetime_client() @@ -243,6 +248,43 @@ pub async fn create_profile_recharge_order( } else { None }; + let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 { + Some( + state + .wechat_pay_client() + .create_h5_order(build_wechat_web_payment_request( + order.order_id.clone(), + order.product_title.clone(), + order.amount_cents, + resolve_wechat_pay_client_ip(&headers), + )) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?, + ) + } else { + None + }; + let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + { + Some( + state + .wechat_pay_client() + .create_native_order(build_wechat_web_payment_request( + order.order_id.clone(), + order.product_title.clone(), + order.amount_cents, + resolve_wechat_pay_client_ip(&headers), + )) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?, + ) + } else { + None + }; Ok(json_success_body( Some(&request_context), @@ -250,6 +292,8 @@ pub async fn create_profile_recharge_order( order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), wechat_mini_program_pay_params, + wechat_h5_payment, + wechat_native_payment, }, )) } @@ -278,11 +322,11 @@ pub async fn confirm_wechat_profile_recharge_order( AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), )); } - if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM { + if !is_wechat_recharge_payment_channel(&order.payment_channel) { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("该充值订单不是微信小程序支付订单"), + .with_message("该充值订单不是微信支付订单"), )); } if order.status == RuntimeProfileRechargeOrderStatus::Paid { @@ -970,6 +1014,121 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr error.into_response_with_context(Some(request_context)) } +fn normalize_recharge_payment_channel(raw: Option) -> Result { + raw.map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空") + }) +} + +fn validate_recharge_payment_channel( + state: &AppState, + payment_channel: &str, +) -> Result<(), AppError> { + if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { + if is_recharge_mock_channel_allowed(state) { + return Ok(()); + } + + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("生产充值不允许使用 mock 支付渠道")); + } + + if is_wechat_recharge_payment_channel(payment_channel) { + validate_real_wechat_recharge_payment_provider(state)?; + return Ok(()); + } + + 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) + .with_message("微信支付真实渠道暂未启用")); + } + + if state + .config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case("real") + { + return Ok(()); + } + + Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("真实微信支付渠道不能使用 mock 支付配置")) +} + +fn is_recharge_mock_channel_allowed(state: &AppState) -> bool { + if cfg!(test) { + return state + .config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK); + } + + false +} + +fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { + matches!( + payment_channel, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + ) +} + +fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String { + headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or("127.0.0.1") + .to_string() +} + async fn resolve_wechat_identity_for_payment( state: &AppState, user_id: &str, @@ -1649,6 +1808,280 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() { + let state = seed_authenticated_state().await; + 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"}"#)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + 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_unknown_payment_channel_before_spacetime() { + let state = seed_authenticated_state().await; + 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":"card"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + 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_mock_when_pay_provider_is_real() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_provider: "real".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + 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":"mock"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + 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_non_wechat_device_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_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() + .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::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_feedback_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1867,7 +2300,11 @@ mod tests { } async fn seed_authenticated_state() -> AppState { - let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build"); + seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await + } + + async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState { + let state = AppState::new(config).expect("state should build"); state .seed_test_phone_user_with_password("13800138104", "secret123") .await @@ -1910,4 +2347,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/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index 63b15b8d..c3b38abd 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -14,7 +14,9 @@ use ring::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use shared_contracts::runtime::WechatMiniProgramPayParamsResponse; +use shared_contracts::runtime::{ + WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse, +}; use shared_kernel::offset_datetime_to_unix_micros; use time::OffsetDateTime; use tracing::{info, warn}; @@ -37,6 +39,10 @@ const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32; const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; +const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45; +const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi"; +const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5"; +const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native"; #[derive(Clone, Debug)] pub enum WechatPayClient { @@ -57,6 +63,8 @@ pub struct RealWechatPayClient { api_v3_key: String, notify_url: String, jsapi_endpoint: String, + h5_endpoint: String, + native_endpoint: String, query_order_endpoint_base: String, } @@ -68,6 +76,14 @@ pub struct WechatMiniProgramOrderRequest { pub payer_openid: String, } +#[derive(Clone, Debug)] +pub struct WechatWebOrderRequest { + pub order_id: String, + pub description: String, + pub amount_cents: u64, + pub payer_client_ip: String, +} + #[derive(Clone, Debug)] pub struct WechatPayNotifyOrder { pub out_trade_no: String, @@ -110,6 +126,45 @@ struct WechatJsapiPayer<'a> { openid: &'a str, } +#[derive(Serialize)] +struct WechatH5OrderRequest<'a> { + appid: &'a str, + mchid: &'a str, + description: &'a str, + out_trade_no: &'a str, + notify_url: &'a str, + amount: WechatJsapiAmount, + scene_info: WechatH5SceneInfo<'a>, +} + +#[derive(Serialize)] +struct WechatH5SceneInfo<'a> { + payer_client_ip: &'a str, + h5_info: WechatH5Info, +} + +#[derive(Serialize)] +struct WechatH5Info { + #[serde(rename = "type")] + kind: &'static str, +} + +#[derive(Serialize)] +struct WechatNativeOrderRequest<'a> { + appid: &'a str, + mchid: &'a str, + description: &'a str, + out_trade_no: &'a str, + notify_url: &'a str, + amount: WechatJsapiAmount, + scene_info: WechatNativeSceneInfo<'a>, +} + +#[derive(Serialize)] +struct WechatNativeSceneInfo<'a> { + payer_client_ip: &'a str, +} + #[derive(Deserialize)] struct WechatJsapiOrderResponse { prepay_id: Option, @@ -117,6 +172,20 @@ struct WechatJsapiOrderResponse { message: Option, } +#[derive(Deserialize)] +struct WechatH5OrderResponse { + h5_url: Option, + code: Option, + message: Option, +} + +#[derive(Deserialize)] +struct WechatNativeOrderResponse { + code_url: Option, + code: Option, + message: Option, +} + #[derive(Deserialize)] struct WechatPayNotifyBody { #[serde(default)] @@ -222,6 +291,10 @@ impl WechatPayClient { &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", )?; + let h5_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?; + let native_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_NATIVE_PATH)?; let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?; Ok(Self::Real(Arc::new(RealWechatPayClient { @@ -235,6 +308,8 @@ impl WechatPayClient { api_v3_key, notify_url, jsapi_endpoint, + h5_endpoint, + native_endpoint, query_order_endpoint_base, }))) } @@ -250,6 +325,28 @@ impl WechatPayClient { } } + pub async fn create_h5_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_h5_payment(&request.order_id)), + Self::Real(client) => client.create_h5_order(request).await, + } + } + + pub async fn create_native_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_native_payment(&request.order_id)), + Self::Real(client) => client.create_native_order(request).await, + } + } + pub fn parse_notify( &self, headers: &HeaderMap, @@ -304,13 +401,8 @@ impl RealWechatPayClient { .map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?; let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); let nonce = create_nonce()?; - let authorization = self.build_authorization( - "POST", - "/v3/pay/transactions/jsapi", - ×tamp, - &nonce, - &body, - )?; + let authorization = + self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, ×tamp, &nonce, &body)?; let response = with_wechat_pay_jsapi_headers( self.client .post(&self.jsapi_endpoint) @@ -350,6 +442,147 @@ impl RealWechatPayClient { self.build_pay_params(&prepay_id) } + async fn create_h5_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + validate_web_order_request(self, &request)?; + let amount_total = i64::try_from(request.amount_cents) + .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; + let body = serde_json::to_string(&WechatH5OrderRequest { + appid: &self.app_id, + mchid: &self.mch_id, + description: &request.description, + out_trade_no: &request.order_id, + notify_url: &self.notify_url, + amount: WechatJsapiAmount { + total: amount_total, + currency: "CNY", + }, + scene_info: WechatH5SceneInfo { + payer_client_ip: &request.payer_client_ip, + h5_info: WechatH5Info { kind: "Wap" }, + }, + }) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 H5 请求序列化失败:{error}")) + })?; + let response_text = self + .post_wechat_json( + &self.h5_endpoint, + WECHAT_PAY_H5_PATH, + body, + "微信支付 H5 下单请求失败", + ) + .await?; + let payload = + serde_json::from_str::(&response_text).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 H5 下单响应解析失败:{error}")) + })?; + let h5_url = payload + .h5_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::Upstream( + payload + .message + .or(payload.code) + .unwrap_or_else(|| "微信支付未返回 h5_url".to_string()), + ) + })?; + + Ok(WechatH5PaymentResponse { h5_url }) + } + + async fn create_native_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + validate_web_order_request(self, &request)?; + let amount_total = i64::try_from(request.amount_cents) + .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; + let body = serde_json::to_string(&WechatNativeOrderRequest { + appid: &self.app_id, + mchid: &self.mch_id, + description: &request.description, + out_trade_no: &request.order_id, + notify_url: &self.notify_url, + amount: WechatJsapiAmount { + total: amount_total, + currency: "CNY", + }, + scene_info: WechatNativeSceneInfo { + payer_client_ip: &request.payer_client_ip, + }, + }) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 Native 请求序列化失败:{error}")) + })?; + let response_text = self + .post_wechat_json( + &self.native_endpoint, + WECHAT_PAY_NATIVE_PATH, + body, + "微信支付 Native 下单请求失败", + ) + .await?; + let payload = + serde_json::from_str::(&response_text).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 Native 下单响应解析失败:{error}")) + })?; + let code_url = payload + .code_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::Upstream( + payload + .message + .or(payload.code) + .unwrap_or_else(|| "微信支付未返回 code_url".to_string()), + ) + })?; + + Ok(WechatNativePaymentResponse { code_url }) + } + + async fn post_wechat_json( + &self, + endpoint: &str, + canonical_path: &str, + body: String, + request_error_prefix: &str, + ) -> Result { + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = + self.build_authorization("POST", canonical_path, ×tamp, &nonce, &body)?; + let response = with_wechat_pay_json_headers( + self.client + .post(endpoint) + .header("Authorization", authorization), + &self.platform_serial_no, + ) + .body(body) + .send() + .await + .map_err(|error| { + WechatPayError::RequestFailed(format!("{request_error_prefix}:{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}" + ))); + } + + Ok(response_text) + } + fn build_authorization( &self, method: &str, @@ -618,6 +851,20 @@ pub fn build_wechat_payment_request( } } +pub fn build_wechat_web_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_client_ip: String, +) -> WechatWebOrderRequest { + WechatWebOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_client_ip, + } +} + pub fn current_unix_micros() -> i64 { let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; i64::try_from(value).unwrap_or(i64::MAX) @@ -664,6 +911,24 @@ fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { } } +fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse { + WechatH5PaymentResponse { + h5_url: format!( + "https://mock.wechat-pay.local/h5?out_trade_no={}", + urlencoding::encode(order_id) + ), + } +} + +fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse { + WechatNativePaymentResponse { + code_url: format!( + "weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}", + hex_sha256(order_id.as_bytes()) + ), + } +} + fn parse_mock_notify(body: &[u8]) -> Result { let value = serde_json::from_slice::(body).map_err(|error| { WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) @@ -744,6 +1009,20 @@ fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result Result { + 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}{transaction_path}")) +} + fn normalize_out_trade_no(value: &str) -> Result { let value = value.trim(); validate_out_trade_no(value)?; @@ -794,6 +1073,49 @@ fn validate_jsapi_order_request( Ok(()) } +fn validate_web_order_request( + client: &RealWechatPayClient, + request: &WechatWebOrderRequest, +) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + &client.app_id, + WECHAT_PAY_APP_ID_MAX_CHARS, + "微信支付 appid", + )?; + if !client.app_id.starts_with("wx") { + return Err(WechatPayError::InvalidConfig( + "微信支付 appid 必须使用已绑定的微信 AppID".to_string(), + )); + } + validate_non_empty_max_chars( + &client.mch_id, + WECHAT_PAY_MCH_ID_MAX_CHARS, + "微信支付 mchid", + )?; + if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) { + return Err(WechatPayError::InvalidConfig( + "微信支付 mchid 必须是数字字符串".to_string(), + )); + } + validate_non_empty_max_chars( + &request.description, + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "微信支付商品描述", + )?; + validate_out_trade_no(&request.order_id)?; + if request.amount_cents == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付金额必须大于 0 分".to_string(), + )); + } + validate_non_empty_max_chars( + &request.payer_client_ip, + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "微信支付 payer_client_ip", + )?; + Ok(()) +} + fn validate_non_empty_max_chars( value: &str, max_chars: usize, @@ -1046,6 +1368,84 @@ mod tests { assert!(body.get("notifyUrl").is_none()); } + #[test] + fn h5_order_request_uses_wechat_required_scene_info() { + let body = serde_json::to_value(WechatH5OrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + scene_info: WechatH5SceneInfo { + payer_client_ip: "203.0.113.10", + h5_info: WechatH5Info { kind: "Wap" }, + }, + }) + .expect("H5 order request should serialize"); + + assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10"); + assert_eq!(body["scene_info"]["h5_info"]["type"], "Wap"); + assert_eq!(body["amount"]["currency"], "CNY"); + assert!(body.get("sceneInfo").is_none()); + assert!(body["scene_info"].get("payerClientIp").is_none()); + } + + #[test] + fn native_order_request_uses_code_url_response_shape() { + let body = serde_json::to_value(WechatNativeOrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + scene_info: WechatNativeSceneInfo { + payer_client_ip: "203.0.113.10", + }, + }) + .expect("Native order request should serialize"); + let response = serde_json::from_value::(json!({ + "code_url": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test" + })) + .expect("Native order response should deserialize"); + + assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10"); + assert_eq!( + response.code_url.as_deref(), + Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test") + ); + } + + #[test] + fn transaction_endpoints_reuse_configured_wechat_pay_origin() { + let h5_endpoint = resolve_wechat_pay_transaction_endpoint( + "https://pay-gateway.example.com/v3/pay/transactions/jsapi", + WECHAT_PAY_H5_PATH, + ) + .expect("H5 endpoint should resolve"); + let native_endpoint = resolve_wechat_pay_transaction_endpoint( + "https://pay-gateway.example.com/v3/pay/transactions/jsapi", + WECHAT_PAY_NATIVE_PATH, + ) + .expect("Native endpoint should resolve"); + + assert_eq!( + h5_endpoint, + "https://pay-gateway.example.com/v3/pay/transactions/h5" + ); + assert_eq!( + native_endpoint, + "https://pay-gateway.example.com/v3/pay/transactions/native" + ); + } + #[test] fn jsapi_order_request_rejects_provider_field_limit_violations() { assert!(validate_out_trade_no("abc12").is_err()); @@ -1074,6 +1474,20 @@ mod tests { validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid") .is_err() ); + validate_non_empty_max_chars( + "203.0.113.10", + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "payer_client_ip", + ) + .expect("short client ip should pass"); + assert!( + validate_non_empty_max_chars( + &"1".repeat(46), + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "payer_client_ip", + ) + .is_err() + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 8504f38c..c9361cff 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -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, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index a820ffda..a10f0cc2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -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; diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 437c6204..7ef3f3fa 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -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 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 0933857c..3d182426 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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!( diff --git a/server-rs/crates/platform-auth/README.md b/server-rs/crates/platform-auth/README.md index ba5d6049..eee3f336 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 4643931e..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, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 893f70ac..2d2fc722 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -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, + #[serde(default)] + pub wechat_h5_payment: Option, + #[serde(default)] + pub wechat_native_payment: Option, } #[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 { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 8bd0e419..e3acdd25 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -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 => ({ @@ -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) { - 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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b5b7fd9c..9129d608 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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(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({ 暂无可购买套餐 )} + + {nativePayment ? ( +
+
微信扫码支付
+
+ {nativeQrImageUrl ? ( + 微信 Native 支付二维码 + ) : ( + + 生成中 + + )} +
+ +
+ ) : null} @@ -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(null); const [rechargePaymentResult, setRechargePaymentResult] = useState(null); + const [nativeWechatPayment, setNativeWechatPayment] = + useState(null); const [activeRechargeTab, setActiveRechargeTab] = useState('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({ @@ -5659,11 +5826,19 @@ export function RpgEntryHomeView({ onClick={openTaskCenterPanel} /> + {showRechargeEntry ? ( + + ) : null} setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} + onConfirmNativePayment={confirmNativeWechatPayment} /> ) : null; const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( diff --git a/src/services/payment/paymentPlatform.test.ts b/src/services/payment/paymentPlatform.test.ts new file mode 100644 index 00000000..f26cf332 --- /dev/null +++ b/src/services/payment/paymentPlatform.test.ts @@ -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); + }); +}); diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts new file mode 100644 index 00000000..93b26b8a --- /dev/null +++ b/src/services/payment/paymentPlatform.ts @@ -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; + +export type PaymentPlatformContext = { + location?: Pick | null; + navigator?: Partial | 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 | null | undefined, +) { + const params = new URLSearchParams(location?.search ?? ''); + return ( + params.get('clientRuntime') === 'wechat_mini_program' || + params.get('clientType') === 'mini_program' + ); +} + +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|windows phone/u.test(userAgent)) { + return true; + } + + if ((navigatorLike?.maxTouchPoints ?? 0) > 1) { + return true; + } + + return Boolean(matchMedia?.('(max-width: 767px)').matches); +} diff --git a/src/services/payment/paymentRedirect.ts b/src/services/payment/paymentRedirect.ts new file mode 100644 index 00000000..923e1c54 --- /dev/null +++ b/src/services/payment/paymentRedirect.ts @@ -0,0 +1,3 @@ +export function redirectToPaymentUrl(url: string) { + window.location.assign(url); +} diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index a0b9a479..f5942c80 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) { ); } -export function getRpgProfileWalletLedger( - options: RuntimeRequestOptions = {}, -) { +export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/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( @@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive( worldKey: string, options: RuntimeRequestOptions = {}, ) { - const response = await requestRpgRuntimeJson( - `/profile/save-archives/${encodeURIComponent(worldKey)}`, - { method: 'POST' }, - '恢复存档失败', - options, - ); + const response = + await requestRpgRuntimeJson( + `/profile/save-archives/${encodeURIComponent(worldKey)}`, + { method: 'POST' }, + '恢复存档失败', + options, + ); return { entry: response.entry,