feat: 接入微信H5与Native充值支付

This commit is contained in:
2026-05-15 06:40:40 +08:00
parent 73424f958a
commit 5b70ec6af7
18 changed files with 1890 additions and 122 deletions

View File

@@ -64,6 +64,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` - 验证方式:执行 `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/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md` - 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.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/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`
## 2026-05-13 修改密码后全设备强制下线 ## 2026-05-13 修改密码后全设备强制下线
- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token不符合“改密后全设备强制下线”的账号安全预期。 - 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token不符合“改密后全设备强制下线”的账号安全预期。

View File

@@ -9,7 +9,7 @@
1. `泥点充值` 1. `泥点充值`
2. `会员卡充值` 2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。普通 H5 / 本地联调继续使用 `mock` 渠道:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。微信小程序 web-view 使用 `wechat_mp` 渠道:创建订单时只写入 `pending` 订单并返回小程序 `wx.requestPayment` 参数,真实到账以后端微信支付通知为准。 前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,移动网页使用 `wechat_h5`,桌面网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。
## 2. 产品规则 ## 2. 产品规则
@@ -56,22 +56,25 @@
```json ```json
{ {
"productId": "points_300", "productId": "points_300",
"paymentChannel": "mock" "paymentChannel": "wechat_h5"
} }
``` ```
行为: 行为:
1. 校验 `productId` 1. 校验 `productId`
2. `paymentChannel = "mock"` 时后端创建已支付订单 2. 缺少 `paymentChannel` 或传入未知渠道时直接返回 `400`,不得再默认解释为 `mock`
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 3. 生产/真实支付配置拒绝 `paymentChannel = "mock"`mock 只服务自动测试或显式 mock 测试配置
- `wechat_mp` / `wechat_h5` / `wechat_native` 必须在 `WECHAT_PAY_ENABLED=true``WECHAT_PAY_PROVIDER=real` 时才允许下单;`WECHAT_PAY_PROVIDER=mock` 不能为真实微信渠道返回 mock 支付载荷。
4. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。
- JSAPI 下单请求必须显式携带 `Accept: application/json``Content-Type: application/json``User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 - JSAPI 下单请求必须显式携带 `Accept: application/json``Content-Type: application/json``User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。
4. mock 泥点套餐立即写入钱包余额与流水mock 会员套餐立即写入会员状态 5. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址
5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` 6. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码
7. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷
兼容路径:`POST /api/runtime/profile/recharge/orders` 兼容路径:`POST /api/runtime/profile/recharge/orders`
响应里的 `wechatMiniProgramPayParams` 只在微信小程序支付渠道返回,字段直接对应 `wx.requestPayment` 响应里的支付字段只在对应微信支付渠道返回。`wechatMiniProgramPayParams` 字段直接对应 `wx.requestPayment`
```json ```json
{ {
@@ -85,11 +88,24 @@
} }
``` ```
H5 与 Native 响应:
```json
{
"wechatH5Payment": {
"h5Url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=..."
},
"wechatNativePayment": {
"codeUrl": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=..."
}
}
```
### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` ### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm`
需要 Bearer JWT。该接口用于小程序支付返回 web-view 后的主动查单确认,不替代微信支付通知: 需要 Bearer JWT。该接口用于微信支付返回页面或 Native 扫码后的主动查单确认,不替代微信支付通知:
1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态。 1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态`wechat_mp` / `wechat_h5` / `wechat_native` 都可确认,`mock` 不能走该接口
2. 若订单已是 `paid`,直接返回订单与账户中心快照。 2. 若订单已是 `paid`,直接返回订单与账户中心快照。
3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。
4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。
@@ -127,7 +143,7 @@
| 变量 | 说明 | | 变量 | 说明 |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 | | `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 |
| `WECHAT_PAY_PROVIDER` | `mock``real` | | `WECHAT_PAY_PROVIDER` | `mock``real`;真实微信渠道只能使用 `real``mock` 只允许显式测试渠道 |
| `WECHAT_PAY_MCH_ID` | 微信支付商户号 | | `WECHAT_PAY_MCH_ID` | 微信支付商户号 |
| `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 | | `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 |
| `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 | | `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 |
@@ -142,19 +158,23 @@
2. 弹窗顶部标题为 `账户充值`,右上角关闭。 2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `泥点充值`,可切换到 `会员卡充值` 3. 默认打开 `泥点充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard` 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`
- 支付渠道由 `src/services/payment/paymentPlatform.ts` 统一判断,业务 UI 不内联区分小程序、手机网页和桌面网页。
- 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。
- native 支付页通过 `wx_pay_result=<requestId>:success|cancel|fail` 回填 web-viewH5 在 `hashchange``focus``pageshow``visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - native 支付页通过 `wx_pay_result=<requestId>:success|cancel|fail` 回填 web-viewH5 在 `hashchange``focus``pageshow``visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。
- `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。
- 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。
- `cancel``fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 - `cancel``fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。
5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败 5. 移动网页含微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序
6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态 6. 桌面网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额
7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压 7. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败
8. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。
9. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
## 5. 验收 ## 5. 验收
1. 普通用户打开弹窗能看到泥点与会员套餐。 1. 普通用户打开弹窗能看到泥点与会员套餐。
2. 泥点购买后余额增加,流水来源为 `points_recharge` 2. 默认平台通道只会解析到 `wechat_mp` / `wechat_h5` / `wechat_native`,不会解析到 `mock`
3. 首充赠送只在首次泥点充值时生效 3. 泥点购买后余额增加,流水来源为 `points_recharge`
4. 会员购买后会员状态与到期时间立即更新 4. 首充赠送只在首次泥点充值时生效
5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 5. 会员购买后会员状态与到期时间立即更新。
6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

450
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"three": "^0.184.0", "three": "^0.184.0",
@@ -23,6 +24,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
@@ -1679,6 +1681,16 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -2174,7 +2186,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -2183,7 +2194,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@@ -2373,6 +2383,15 @@
"node": ">=6" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001780", "version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
@@ -2444,11 +2463,21 @@
"node": "*" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
}, },
@@ -2459,8 +2488,7 @@
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "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": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "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": "^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": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" "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": { "node_modules/enhanced-resolve": {
"version": "5.20.1", "version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -3262,6 +3311,15 @@
"node": ">=6.9.0" "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": { "node_modules/get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@@ -3558,6 +3616,15 @@
"node": ">=0.10.0" "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": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -4276,6 +4343,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4304,7 +4380,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4384,6 +4459,15 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true "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": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -4488,6 +4572,23 @@
"node": ">=6" "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": { "node_modules/querystringify": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -4547,6 +4648,21 @@
"node": ">=0.10.0" "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": { "node_modules/requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -4700,6 +4816,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4756,11 +4878,24 @@
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true "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": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@@ -6674,6 +6809,12 @@
"node": ">= 8" "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "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": ">=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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -6741,11 +6896,104 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -7716,6 +7964,15 @@
"undici-types": "~6.21.0" "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": { "@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -8048,14 +8305,12 @@
"ansi-regex": { "ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
"dev": true
}, },
"ansi-styles": { "ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": { "requires": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
} }
@@ -8171,6 +8426,11 @@
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true "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": { "caniuse-lite": {
"version": "1.0.30001780", "version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
@@ -8215,11 +8475,20 @@
"get-func-name": "^2.0.2" "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": { "color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": { "requires": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
} }
@@ -8227,8 +8496,7 @@
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"combined-stream": { "combined-stream": {
"version": "1.0.8", "version": "1.0.8",
@@ -8301,6 +8569,11 @@
"ms": "^2.1.3" "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": { "decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -8346,6 +8619,11 @@
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true "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": { "dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" "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": { "enhanced-resolve": {
"version": "5.20.1", "version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "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", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" "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": { "get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@@ -9010,6 +9298,11 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true "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": { "is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -9454,6 +9747,11 @@
"p-limit": "^3.0.2" "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": { "parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9475,8 +9773,7 @@
"path-exists": { "path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
"dev": true
}, },
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "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": { "postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -9599,6 +9901,16 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true "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": { "querystringify": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" "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": { "requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" "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": { "shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9786,11 +10113,20 @@
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true "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": { "strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": { "requires": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
} }
@@ -10724,6 +11060,11 @@
"isexe": "^2.0.0" "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": { "why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -10740,6 +11081,16 @@
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true "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": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -10765,11 +11116,78 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true "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": { "yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" "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": { "yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -54,6 +54,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"three": "^0.184.0", "three": "^0.184.0",
@@ -63,6 +64,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",

View File

@@ -147,6 +147,14 @@ export type WechatMiniProgramPayParams = {
paySign: string; paySign: string;
}; };
export type WechatH5Payment = {
h5Url: string;
};
export type WechatNativePayment = {
codeUrl: string;
};
export type CreateProfileRechargeOrderRequest = { export type CreateProfileRechargeOrderRequest = {
productId: string; productId: string;
paymentChannel?: string; paymentChannel?: string;
@@ -156,6 +164,8 @@ export type CreateProfileRechargeOrderResponse = {
order: ProfileRechargeOrder; order: ProfileRechargeOrder;
center: ProfileRechargeCenterResponse; center: ProfileRechargeCenterResponse;
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
wechatH5Payment?: WechatH5Payment | null;
wechatNativePayment?: WechatNativePayment | null;
}; };
export type ConfirmWechatProfileRechargeOrderResponse = { export type ConfirmWechatProfileRechargeOrderResponse = {
@@ -242,7 +252,12 @@ export type RedeemProfileRewardCodeResponse = {
export type ProfileTaskCycle = 'daily'; export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; 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 = export type ProfileTaskStatus =
| 'incomplete' | 'incomplete'
| 'claimable' | 'claimable'

View File

@@ -1,12 +1,14 @@
use axum::{ use axum::{
Json, Json,
extract::{Extension, Path, Query, State}, extract::{Extension, Path, Query, State},
http::StatusCode, http::{HeaderMap, StatusCode},
response::Response, response::Response,
}; };
use module_runtime::{ use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, 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, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
@@ -64,8 +66,8 @@ use crate::{
request_context::RequestContext, request_context::RequestContext,
state::AppState, state::AppState,
wechat_pay::{ wechat_pay::{
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros, WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
map_wechat_pay_error, current_unix_micros, map_wechat_pay_error,
}, },
}; };
@@ -189,13 +191,14 @@ pub async fn create_profile_recharge_order(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>, Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<CreateProfileRechargeOrderRequest>, Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string(); let user_id = authenticated.claims().user_id().to_string();
let payment_channel = payload let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
.payment_channel .map_err(|error| runtime_profile_error_response(&request_context, error))?;
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); validate_recharge_payment_channel(&state, &payment_channel)
let payment_channel = payment_channel.trim().to_string(); .map_err(|error| runtime_profile_error_response(&request_context, error))?;
let created_at_micros = current_unix_micros(); let created_at_micros = current_unix_micros();
let (center, order) = state let (center, order) = state
.spacetime_client() .spacetime_client()
@@ -236,6 +239,43 @@ pub async fn create_profile_recharge_order(
} else { } else {
None 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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
@@ -243,6 +283,8 @@ pub async fn create_profile_recharge_order(
order: build_profile_recharge_order_response(order), order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center), center: build_profile_recharge_center_response(center),
wechat_mini_program_pay_params, wechat_mini_program_pay_params,
wechat_h5_payment,
wechat_native_payment,
}, },
)) ))
} }
@@ -271,11 +313,11 @@ pub async fn confirm_wechat_profile_recharge_order(
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), 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( return Err(runtime_profile_error_response(
&request_context, &request_context,
AppError::from_status(StatusCode::BAD_REQUEST) AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信小程序支付订单"), .with_message("该充值订单不是微信支付订单"),
)); ));
} }
if order.status == RuntimeProfileRechargeOrderStatus::Paid { if order.status == RuntimeProfileRechargeOrderStatus::Paid {
@@ -885,6 +927,93 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context)) error.into_response_with_context(Some(request_context))
} }
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
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_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( async fn resolve_wechat_identity_for_payment(
state: &AppState, state: &AppState,
user_id: &str, user_id: &str,
@@ -1503,6 +1632,153 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 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_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_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::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] #[tokio::test]
async fn profile_feedback_requires_authentication() { async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1720,7 +1996,11 @@ mod tests {
} }
async fn seed_authenticated_state() -> AppState { 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 state
.seed_test_phone_user_with_password("13800138104", "secret123") .seed_test_phone_user_with_password("13800138104", "secret123")
.await .await

View File

@@ -14,7 +14,9 @@ use ring::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256}; 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 shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::{info, warn}; 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_OUT_TRADE_NO_MAX_CHARS: usize = 32;
const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255;
const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; 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)] #[derive(Clone, Debug)]
pub enum WechatPayClient { pub enum WechatPayClient {
@@ -57,6 +63,8 @@ pub struct RealWechatPayClient {
api_v3_key: String, api_v3_key: String,
notify_url: String, notify_url: String,
jsapi_endpoint: String, jsapi_endpoint: String,
h5_endpoint: String,
native_endpoint: String,
query_order_endpoint_base: String, query_order_endpoint_base: String,
} }
@@ -68,6 +76,14 @@ pub struct WechatMiniProgramOrderRequest {
pub payer_openid: String, 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)] #[derive(Clone, Debug)]
pub struct WechatPayNotifyOrder { pub struct WechatPayNotifyOrder {
pub out_trade_no: String, pub out_trade_no: String,
@@ -110,6 +126,45 @@ struct WechatJsapiPayer<'a> {
openid: &'a str, 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)] #[derive(Deserialize)]
struct WechatJsapiOrderResponse { struct WechatJsapiOrderResponse {
prepay_id: Option<String>, prepay_id: Option<String>,
@@ -117,6 +172,20 @@ struct WechatJsapiOrderResponse {
message: Option<String>, message: Option<String>,
} }
#[derive(Deserialize)]
struct WechatH5OrderResponse {
h5_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatNativeOrderResponse {
code_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct WechatPayNotifyBody { struct WechatPayNotifyBody {
#[serde(default)] #[serde(default)]
@@ -222,6 +291,10 @@ impl WechatPayClient {
&config.wechat_pay_jsapi_endpoint, &config.wechat_pay_jsapi_endpoint,
"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)?; let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
Ok(Self::Real(Arc::new(RealWechatPayClient { Ok(Self::Real(Arc::new(RealWechatPayClient {
@@ -235,6 +308,8 @@ impl WechatPayClient {
api_v3_key, api_v3_key,
notify_url, notify_url,
jsapi_endpoint, jsapi_endpoint,
h5_endpoint,
native_endpoint,
query_order_endpoint_base, query_order_endpoint_base,
}))) })))
} }
@@ -250,6 +325,28 @@ impl WechatPayClient {
} }
} }
pub async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
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<WechatNativePaymentResponse, WechatPayError> {
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( pub fn parse_notify(
&self, &self,
headers: &HeaderMap, headers: &HeaderMap,
@@ -304,13 +401,8 @@ impl RealWechatPayClient {
.map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?; .map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?;
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?; let nonce = create_nonce()?;
let authorization = self.build_authorization( let authorization =
"POST", self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, &timestamp, &nonce, &body)?;
"/v3/pay/transactions/jsapi",
&timestamp,
&nonce,
&body,
)?;
let response = with_wechat_pay_jsapi_headers( let response = with_wechat_pay_jsapi_headers(
self.client self.client
.post(&self.jsapi_endpoint) .post(&self.jsapi_endpoint)
@@ -350,6 +442,147 @@ impl RealWechatPayClient {
self.build_pay_params(&prepay_id) self.build_pay_params(&prepay_id)
} }
async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
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::<WechatH5OrderResponse>(&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<WechatNativePaymentResponse, WechatPayError> {
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::<WechatNativeOrderResponse>(&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<String, WechatPayError> {
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization("POST", canonical_path, &timestamp, &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( fn build_authorization(
&self, &self,
method: &str, 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 { pub fn current_unix_micros() -> i64 {
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
i64::try_from(value).unwrap_or(i64::MAX) 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<WechatPayNotifyOrder, WechatPayError> { fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| { let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
@@ -744,6 +1009,20 @@ fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, Wec
Ok(format!("{origin}/v3/pay/transactions/out-trade-no")) Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
} }
fn resolve_wechat_pay_transaction_endpoint(
jsapi_endpoint: &str,
transaction_path: &str,
) -> Result<String, WechatPayError> {
let url = Url::parse(jsapi_endpoint)
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
let origin = url
.origin()
.ascii_serialization()
.trim_end_matches('/')
.to_string();
Ok(format!("{origin}{transaction_path}"))
}
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> { fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
let value = value.trim(); let value = value.trim();
validate_out_trade_no(value)?; validate_out_trade_no(value)?;
@@ -794,6 +1073,49 @@ fn validate_jsapi_order_request(
Ok(()) 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( fn validate_non_empty_max_chars(
value: &str, value: &str,
max_chars: usize, max_chars: usize,
@@ -1046,6 +1368,84 @@ mod tests {
assert!(body.get("notifyUrl").is_none()); 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::<WechatNativeOrderResponse>(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] #[test]
fn jsapi_order_request_rejects_provider_field_limit_violations() { fn jsapi_order_request_rejects_provider_field_limit_violations() {
assert!(validate_out_trade_no("abc12").is_err()); 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") validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
.is_err() .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] #[test]

View File

@@ -263,7 +263,7 @@ pub fn build_runtime_profile_recharge_order_create_input(
return Err(RuntimeProfileFieldError::UnknownRechargeProduct); return Err(RuntimeProfileFieldError::UnknownRechargeProduct);
} }
let payment_channel = normalize_required_string(payment_channel) let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); .ok_or(RuntimeProfileFieldError::MissingPaymentChannel)?;
Ok(RuntimeProfileRechargeOrderCreateInput { Ok(RuntimeProfileRechargeOrderCreateInput {
user_id, user_id,

View File

@@ -34,6 +34,8 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; 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_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_MIN_CHARS: usize = 10;
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200; pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40; pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;

View File

@@ -74,6 +74,7 @@ pub enum RuntimeProfileFieldError {
TaskAlreadyClaimed, TaskAlreadyClaimed,
MissingOrderId, MissingOrderId,
MissingProductId, MissingProductId,
MissingPaymentChannel,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
MissingCheckpointSessionId, MissingCheckpointSessionId,
@@ -136,6 +137,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"), Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),

View File

@@ -702,6 +702,19 @@ mod tests {
assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct); assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct);
} }
#[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] #[test]
fn runtime_profile_identity_helpers_keep_existing_key_shape() { fn runtime_profile_identity_helpers_keep_existing_key_shape() {
assert_eq!( assert_eq!(

View File

@@ -239,6 +239,18 @@ pub struct WechatMiniProgramPayParamsResponse {
pub pay_sign: String, 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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfileRechargeCenterResponse { pub struct ProfileRechargeCenterResponse {
@@ -266,6 +278,10 @@ pub struct CreateProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse, pub center: ProfileRechargeCenterResponse,
#[serde(default)] #[serde(default)]
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>, pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
#[serde(default)]
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
#[serde(default)]
pub wechat_native_payment: Option<WechatNativePaymentResponse>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -1327,6 +1343,60 @@ mod tests {
assert_eq!(payload.payment_channel, None); 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] #[test]
fn profile_feedback_response_uses_camel_case_fields() { fn profile_feedback_response_uses_camel_case_fields() {
let payload = serde_json::to_value(SubmitProfileFeedbackResponse { let payload = serde_json::to_value(SubmitProfileFeedbackResponse {

View File

@@ -37,6 +37,8 @@ import {
} from './rpgEntryWorldPresentation'; } from './rpgEntryWorldPresentation';
const { const {
mockQrCodeToDataUrl,
mockRedirectToPaymentUrl,
mockBuildReferralCenter, mockBuildReferralCenter,
mockBuildTaskCenter, mockBuildTaskCenter,
mockClaimRpgProfileTaskReward, mockClaimRpgProfileTaskReward,
@@ -48,6 +50,8 @@ const {
mockGetRpgProfileWalletLedger, mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode, mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn();
const buildReferralCenter = ( const buildReferralCenter = (
overrides: Partial<ProfileReferralInviteCenterResponse> = {}, overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse => ({ ): ProfileReferralInviteCenterResponse => ({
@@ -119,6 +123,8 @@ const {
}); });
return { return {
mockQrCodeToDataUrl: qrCodeToDataUrl,
mockRedirectToPaymentUrl: redirectToPaymentUrl,
mockBuildReferralCenter: buildReferralCenter, mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter, mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () => mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
@@ -343,6 +349,16 @@ vi.mock('../../services/authService', () => ({
updateAuthProfile: mockUpdateAuthProfile, updateAuthProfile: mockUpdateAuthProfile,
})); }));
vi.mock('qrcode', () => ({
default: {
toDataURL: mockQrCodeToDataUrl,
},
}));
vi.mock('../../services/payment/paymentRedirect', () => ({
redirectToPaymentUrl: mockRedirectToPaymentUrl,
}));
mockUpdateAuthProfile.mockResolvedValue({ mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1', id: 'user-1',
publicUserCode: '100001', publicUserCode: '100001',
@@ -584,19 +600,32 @@ function buildBabyObjectMatchEntry(
} }
function mockDesktopLayout() { 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', { Object.defineProperty(window, 'matchMedia', {
configurable: true, configurable: true,
writable: true, writable: true,
value: vi.fn().mockImplementation(() => ({ value: vi.fn().mockImplementation((query: string) => {
matches: true, const normalizedQuery = query.replace(/\s/g, '');
media: '(min-width: 1024px)', return {
onchange: null, matches:
addEventListener: vi.fn(), normalizedQuery.includes('min-width:1024px') ||
removeEventListener: vi.fn(), normalizedQuery.includes('min-width:1024'),
addListener: vi.fn(), media: query,
removeListener: vi.fn(), onchange: null,
dispatchEvent: vi.fn(), addEventListener: vi.fn(),
})), removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
}); });
} }
@@ -924,7 +953,9 @@ afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
window.wx = undefined; window.wx = undefined;
document document
.querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]') .querySelectorAll(
'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]',
)
.forEach((script) => script.remove()); .forEach((script) => script.remove());
mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(), mockBuildReferralCenter(),
@@ -975,6 +1006,8 @@ afterEach(() => {
wechatBound: false, wechatBound: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
configurable: true, configurable: true,
writable: true, writable: true,
@@ -1011,11 +1044,45 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy(); 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 user = userEvent.setup();
const onRechargeSuccess = vi.fn(); mockDesktopLayout();
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); renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click( await user.click(
within(shortcutRegion).getByRole('button', { name: //u }), within(shortcutRegion).getByRole('button', { name: //u }),
@@ -1028,14 +1095,96 @@ test('profile recharge modal buys points through mock channel outside mini progr
await waitFor(() => { await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60', 'points_60',
'mock', 'wechat_native',
); );
}); });
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();
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(max-width: 767px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
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( expect(
await screen.findByRole('dialog', { name: '支付成功' }), await screen.findByRole('dialog', { name: '正在打开微信支付' }),
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}); });
test('profile recharge modal posts requestPayment params in mini program web-view', async () => { test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
@@ -1118,9 +1267,12 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
}); });
expect(navigateUrl).toContain('order-wechat-1'); expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect( expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
await screen.findByRole('dialog', { name: '支付成功' }), expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
).toBeTruthy(); 'points_60',
'mock',
);
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1', 'order-wechat-1',
@@ -1266,9 +1418,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
}); });
expect( expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}); });
@@ -1354,9 +1504,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
window.location.hash = `wx_pay_result=${requestId}:success`; window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange')); window.dispatchEvent(new HashChangeEvent('hashchange'));
}); });
expect( expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
}); });
test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => {
@@ -1452,6 +1600,93 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
}); });
test('profile native qr confirmation refreshes only after server reports paid', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockDesktopLayout();
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('profile daily task shortcut opens task center and claims reward', async () => { test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onRechargeSuccess = vi.fn(); const onRechargeSuccess = vi.fn();

View File

@@ -42,6 +42,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -57,6 +58,7 @@ import type {
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileRechargeCenterResponse, ProfileRechargeCenterResponse,
ProfileRechargeProduct, ProfileRechargeProduct,
WechatNativePayment,
WechatMiniProgramPayParams, WechatMiniProgramPayParams,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
ProfileTaskCenterResponse, ProfileTaskCenterResponse,
@@ -73,6 +75,13 @@ import {
updateAuthProfile, updateAuthProfile,
} from '../../services/authService'; } from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard'; import { copyTextToClipboard } from '../../services/clipboard';
import {
resolveProfileRechargePaymentChannel,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
import { import {
claimRpgProfileTaskReward, claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder, confirmWechatRpgProfileRechargeOrder,
@@ -217,9 +226,9 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; 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_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_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type RechargeTab = 'points' | 'membership'; type RechargeTab = 'points' | 'membership';
@@ -235,6 +244,10 @@ type RechargePaymentResult = {
title: string; title: string;
message: string; message: string;
}; };
type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
isConfirming: boolean;
};
type DiscoverChannel = type DiscoverChannel =
| 'recommend' | 'recommend'
| 'today' | 'today'
@@ -2338,18 +2351,6 @@ function formatRechargePrice(priceCents: number) {
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`; 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() { function clearWechatPayResultHash() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -2496,6 +2497,36 @@ async function confirmWechatRechargeOrderUntilSettled(
return latestResponse; return latestResponse;
} }
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function RechargeProductCard({ function RechargeProductCard({
product, product,
submittingProductId, submittingProductId,
@@ -2546,22 +2577,29 @@ function ProfileRechargeModal({
isLoading, isLoading,
error, error,
submittingProductId, submittingProductId,
nativePayment,
activeTab, activeTab,
onTabChange, onTabChange,
onClose, onClose,
onRetry, onRetry,
onBuy, onBuy,
onConfirmNativePayment,
}: { }: {
center: ProfileRechargeCenterResponse | null; center: ProfileRechargeCenterResponse | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
submittingProductId: string | null; submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab; activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void; onTabChange: (tab: RechargeTab) => void;
onClose: () => void; onClose: () => void;
onRetry: () => void; onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void; onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
}) { }) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products = const products =
activeTab === 'points' activeTab === 'points'
? (center?.pointProducts ?? []) ? (center?.pointProducts ?? [])
@@ -2650,6 +2688,33 @@ function ProfileRechargeModal({
</div> </div>
)} )}
{nativePayment ? (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-4 text-center">
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<button
type="button"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
className="platform-primary-button mt-4 rounded-2xl px-4 py-2 text-xs font-black disabled:cursor-wait disabled:opacity-60"
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</button>
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -3420,6 +3485,8 @@ export function RpgEntryHomeView({
const [rechargeError, setRechargeError] = useState<string | null>(null); const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] = const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null); useState<RechargePaymentResult | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] = const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points'); useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] = const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -3941,14 +4008,12 @@ export function RpgEntryHomeView({
}) })
.finally(() => setIsLoadingRechargeCenter(false)); .finally(() => setIsLoadingRechargeCenter(false));
}; };
const refreshRechargeState = useCallback( const refreshRechargeState = useCallback(() => {
() => { loadRechargeCenter();
loadRechargeCenter(); setSubmittingRechargeProductId(null);
setSubmittingRechargeProductId(null); pendingWechatRechargeOrderIdRef.current = null;
pendingWechatRechargeOrderIdRef.current = null; setNativeWechatPayment(null);
}, }, [loadRechargeCenter]);
[loadRechargeCenter],
);
const handleWechatPayResult = useCallback(() => { const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash(); const payResult = readWechatPayResultFromHash();
if (!payResult) { if (!payResult) {
@@ -4036,11 +4101,11 @@ export function RpgEntryHomeView({
return; return;
} }
const paymentChannel = isWechatMiniProgramWebView() const paymentChannel = resolveProfileRechargePaymentChannel();
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
: 'mock';
setSubmittingRechargeProductId(product.productId); setSubmittingRechargeProductId(product.productId);
setRechargeError(null); setRechargeError(null);
setRechargePaymentResult(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel) void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => { .then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
@@ -4051,24 +4116,105 @@ export function RpgEntryHomeView({
); );
setRechargeCenter(response.center); setRechargeCenter(response.center);
return; 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); setRechargeCenter(response.center);
setRechargePaymentResult({ setRechargePaymentResult({
kind: 'success', kind: 'pending',
title: '支付成功', title: '正在打开微信支付',
message: '已到账,账户状态已刷新。', message: '完成支付后返回页面确认到账状态。',
}); });
pendingWechatRechargeOrderIdRef.current = null; redirectToPaymentUrl(h5Url);
setSubmittingRechargeProductId(null); 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) => { .catch((error: unknown) => {
pendingWechatRechargeOrderIdRef.current = null; pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
setRechargeError(error instanceof Error ? error.message : '充值失败'); setRechargeError(error instanceof Error ? error.message : '充值失败');
setSubmittingRechargeProductId(null); 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(() => { useEffect(() => {
const handleResume = () => { const handleResume = () => {
handleWechatPayResult(); handleWechatPayResult();
@@ -5878,11 +6024,13 @@ export function RpgEntryHomeView({
isLoading={isLoadingRechargeCenter} isLoading={isLoadingRechargeCenter}
error={rechargeError} error={rechargeError}
submittingProductId={submittingRechargeProductId} submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab} activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab} onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)} onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter} onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct} onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/> />
) : null; ) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from 'vitest';
import {
resolveProfileRechargePaymentChannel,
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('默认路径永远不会解析成 mock', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: { userAgent: '' },
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
}),
).not.toBe('mock');
});
});

View File

@@ -0,0 +1,76 @@
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
export const MOCK_PAYMENT_CHANNEL = 'mock';
export type ProfileRechargeWechatPaymentChannel =
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
| typeof WECHAT_H5_PAYMENT_CHANNEL
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
export type PaymentPlatformContext = {
location?: Pick<Location, 'search'> | null;
navigator?: Partial<PaymentPlatformNavigator> | null;
matchMedia?: Window['matchMedia'] | null;
};
export function resolveProfileRechargePaymentChannel(
context: PaymentPlatformContext = {},
): ProfileRechargeWechatPaymentChannel {
const location =
context.location ??
(typeof window !== 'undefined' ? window.location : null);
const navigatorLike =
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
const matchMedia =
context.matchMedia ??
(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia.bind(window)
: null);
if (isWechatMiniProgramRuntime(location)) {
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
}
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
return WECHAT_H5_PAYMENT_CHANNEL;
}
return WECHAT_NATIVE_PAYMENT_CHANNEL;
}
export function isManualMockPaymentChannel(paymentChannel: string) {
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
}
function isWechatMiniProgramRuntime(
location: Pick<Location, 'search'> | null | undefined,
) {
const params = new URLSearchParams(location?.search ?? '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function isMobileWebRuntime(
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
matchMedia: Window['matchMedia'] | null | undefined,
) {
const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? '';
if (
/android|iphone|ipad|ipod|mobile|micromessenger|windows phone/u.test(
userAgent,
)
) {
return true;
}
if ((navigatorLike?.maxTouchPoints ?? 0) > 1) {
return true;
}
return Boolean(matchMedia?.('(max-width: 767px)').matches);
}

View File

@@ -0,0 +1,3 @@
export function redirectToPaymentUrl(url: string) {
window.location.assign(url);
}

View File

@@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
); );
} }
export function getRpgProfileWalletLedger( export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) {
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>( return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
'/profile/wallet-ledger', '/profile/wallet-ledger',
{ method: 'GET' }, { method: 'GET' },
@@ -91,7 +89,7 @@ export function getRpgProfileRechargeCenter(
export function createRpgProfileRechargeOrder( export function createRpgProfileRechargeOrder(
productId: string, productId: string,
paymentChannel = 'mock', paymentChannel: string,
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>( return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
@@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive(
worldKey: string, worldKey: string,
options: RuntimeRequestOptions = {}, options: RuntimeRequestOptions = {},
) { ) {
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>( const response =
`/profile/save-archives/${encodeURIComponent(worldKey)}`, await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
{ method: 'POST' }, `/profile/save-archives/${encodeURIComponent(worldKey)}`,
'恢复存档失败', { method: 'POST' },
options, '恢复存档失败',
); options,
);
return { return {
entry: response.entry, entry: response.entry,