feat: 接入微信小程序支付
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
|
||||||
|
|
||||||
|
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
|
||||||
|
- 决策:`paymentChannel = "mock"` 继续创建即 paid 订单并立即入账;`paymentChannel = "wechat_mp"` 先在 `profile_recharge_order` 写入 `pending` 订单,再由 `api-server` 调微信支付 JSAPI 下单并返回小程序 `wx.requestPayment` 参数。小程序或 H5 的支付成功回调只触发刷新,不直接发放光点或会员;最终入账只由 `/api/profile/recharge/wechat/notify` 验签、解密并确认 `trade_state = SUCCESS` 后完成。`provider_transaction_id` 保存微信支付平台交易号,用于对账、查单、退款和客服排障。
|
||||||
|
- 影响范围:`profile_recharge_order` 表、SpacetimeDB 充值 procedure、`api-server` 微信支付客户端、小程序 native 支付页、H5 充值弹窗与共享 contract。
|
||||||
|
- 验证方式:执行 `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`。
|
||||||
|
|
||||||
## 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,不符合“改密后全设备强制下线”的账号安全预期。
|
||||||
|
|||||||
@@ -541,6 +541,14 @@
|
|||||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 覆盖抓大鹅和拼图生成后自动试玩 / 返回结果页。
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 覆盖抓大鹅和拼图生成后自动试玩 / 返回结果页。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||||
|
|
||||||
|
## 微信支付回调验签不要用商户私钥
|
||||||
|
|
||||||
|
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
|
||||||
|
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
|
||||||
|
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id` 到 `profile_recharge_order.provider_transaction_id`。
|
||||||
|
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
||||||
|
|
||||||
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
|
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
|
||||||
|
|
||||||
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
|
||||||
|
|||||||
@@ -9,30 +9,30 @@
|
|||||||
1. `光点充值`
|
1. `光点充值`
|
||||||
2. `会员卡充值`
|
2. `会员卡充值`
|
||||||
|
|
||||||
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。
|
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。普通 H5 / 本地联调继续使用 `mock` 渠道:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。微信小程序 web-view 使用 `wechat_mp` 渠道:创建订单时只写入 `pending` 订单并返回小程序 `wx.requestPayment` 参数,真实到账以后端微信支付通知为准。
|
||||||
|
|
||||||
## 2. 产品规则
|
## 2. 产品规则
|
||||||
|
|
||||||
### 2.1 光点充值套餐
|
### 2.1 光点充值套餐
|
||||||
|
|
||||||
| productId | 光点 | 金额分 | 徽标 | 说明 |
|
| productId | 光点 | 金额分 | 徽标 | 说明 |
|
||||||
| --- | ---: | ---: | --- | --- |
|
| ------------- | ---: | -----: | -------- | -------------- |
|
||||||
| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 |
|
| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 |
|
||||||
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 |
|
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 |
|
||||||
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 |
|
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 |
|
||||||
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 |
|
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 |
|
||||||
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 |
|
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 |
|
||||||
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 |
|
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 |
|
||||||
|
|
||||||
光点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账光点为基础光点与等额赠送光点之和;已有充值流水后只到账基础光点。实际到账光点写入交易流水,余额以 SpacetimeDB projection 为准。
|
光点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账光点为基础光点与等额赠送光点之和;已有充值流水后只到账基础光点。实际到账光点写入交易流水,余额以 SpacetimeDB projection 为准。
|
||||||
|
|
||||||
### 2.2 会员卡套餐
|
### 2.2 会员卡套餐
|
||||||
|
|
||||||
| productId | 类型 | 天数 | 金额分 | 权益 |
|
| productId | 类型 | 天数 | 金额分 | 权益 |
|
||||||
| --- | --- | ---: | ---: | --- |
|
| --------------- | ---- | ---: | -----: | --------------------------------- |
|
||||||
| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100,每日签到加成0% |
|
| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100,每日签到加成0% |
|
||||||
| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100,每日签到加成100% |
|
| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100,每日签到加成100% |
|
||||||
| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100,每日签到加成210% |
|
| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100,每日签到加成210% |
|
||||||
|
|
||||||
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
|
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
|
||||||
|
|
||||||
@@ -63,19 +63,58 @@
|
|||||||
行为:
|
行为:
|
||||||
|
|
||||||
1. 校验 `productId`
|
1. 校验 `productId`
|
||||||
2. 后端创建已支付订单
|
2. `paymentChannel = "mock"` 时后端创建已支付订单
|
||||||
3. 光点套餐写入钱包余额与流水
|
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数
|
||||||
4. 会员套餐写入会员状态
|
4. mock 光点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态
|
||||||
5. 返回最新账户中心快照与订单摘要
|
5. wechat_mp 订单不提前发光点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`
|
||||||
|
|
||||||
兼容路径:`POST /api/runtime/profile/recharge/orders`
|
兼容路径:`POST /api/runtime/profile/recharge/orders`
|
||||||
|
|
||||||
|
响应里的 `wechatMiniProgramPayParams` 只在微信小程序支付渠道返回,字段直接对应 `wx.requestPayment`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wechatMiniProgramPayParams": {
|
||||||
|
"timeStamp": "1777110165",
|
||||||
|
"nonceStr": "nonce",
|
||||||
|
"package": "prepay_id=wx201410272009395522657a690389285100",
|
||||||
|
"signType": "RSA",
|
||||||
|
"paySign": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 `POST /api/profile/recharge/wechat/notify`
|
||||||
|
|
||||||
|
微信支付通知地址,无需 Bearer JWT。行为:
|
||||||
|
|
||||||
|
1. 真实渠道使用微信支付平台公钥和 `Wechatpay-*` 请求头验签。
|
||||||
|
2. 使用 `WECHAT_PAY_API_V3_KEY` 解密通知 `resource`。
|
||||||
|
3. 仅当 `trade_state = "SUCCESS"` 时确认订单支付。
|
||||||
|
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。
|
||||||
|
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
|
||||||
|
6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。
|
||||||
|
|
||||||
|
关键环境变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 |
|
||||||
|
| `WECHAT_PAY_PROVIDER` | `mock` 或 `real` |
|
||||||
|
| `WECHAT_PAY_MCH_ID` | 微信支付商户号 |
|
||||||
|
| `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 |
|
||||||
|
| `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 |
|
||||||
|
| `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM` / `WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH` | 微信支付平台公钥或平台证书公钥,用于回调验签 |
|
||||||
|
| `WECHAT_PAY_PLATFORM_SERIAL_NO` | 微信支付通知头里的平台证书/公钥序列号 |
|
||||||
|
| `WECHAT_PAY_API_V3_KEY` | 32 字节 API v3 密钥,用于解密通知资源 |
|
||||||
|
| `WECHAT_PAY_NOTIFY_URL` | 公网 HTTPS 通知地址,通常为 `/api/profile/recharge/wechat/notify` |
|
||||||
|
|
||||||
## 4. 前端交互
|
## 4. 前端交互
|
||||||
|
|
||||||
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
|
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
|
||||||
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
|
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
|
||||||
3. 默认打开 `光点充值`,可切换到 `会员卡充值`。
|
3. 默认打开 `光点充值`,可切换到 `会员卡充值`。
|
||||||
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`。
|
4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。
|
||||||
5. 弹窗内不写大段说明文案,只保留必要金额、光点、会员权益和状态反馈。
|
5. 弹窗内不写大段说明文案,只保留必要金额、光点、会员权益和状态反馈。
|
||||||
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
|
||||||
|
|
||||||
|
|||||||
@@ -328,7 +328,8 @@ SELECT * FROM profile_membership WHERE user_id = '<user_id>';
|
|||||||
### `profile_recharge_order`
|
### `profile_recharge_order`
|
||||||
|
|
||||||
- 作用:充值订单表,记录用户购买光点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
|
- 作用:充值订单表,记录用户购买光点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
|
||||||
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Timestamp`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`。
|
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option<Timestamp>`, `provider_transaction_id: Option<String>`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`。
|
||||||
|
- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid`,`provider_transaction_id` 保存微信支付平台订单号。
|
||||||
- 索引:`user_id`, `(user_id, created_at)`。
|
- 索引:`user_id`, `(user_id, created_at)`。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": ["pages/web-view/index", "pages/wechat-pay/index"],
|
||||||
"pages/web-view/index"
|
|
||||||
],
|
|
||||||
"window": {
|
"window": {
|
||||||
"navigationBarTitleText": "百梦",
|
"navigationBarTitleText": "百梦",
|
||||||
"navigationBarBackgroundColor": "#0b0f14",
|
"navigationBarBackgroundColor": "#0b0f14",
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleWebViewMessage(event) {
|
handleWebViewMessage(event) {
|
||||||
// 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。
|
// 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||||
console.info('[web-view] message', event.detail);
|
console.info('[web-view] message', event.detail);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
83
miniprogram/pages/wechat-pay/index.js
Normal file
83
miniprogram/pages/wechat-pay/index.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function parsePayParams(rawValue) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(decodeURIComponent(String(rawValue || '')));
|
||||||
|
if (!params || typeof params !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[wechat-pay] parse params failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPayment(payParams) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wx.requestPayment({
|
||||||
|
timeStamp: String(payParams.timeStamp || ''),
|
||||||
|
nonceStr: String(payParams.nonceStr || ''),
|
||||||
|
package: String(payParams.package || ''),
|
||||||
|
signType: payParams.signType || 'RSA',
|
||||||
|
paySign: String(payParams.paySign || ''),
|
||||||
|
success() {
|
||||||
|
resolve('success');
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
const errMsg = error && error.errMsg ? error.errMsg : '';
|
||||||
|
resolve(/cancel/i.test(errMsg) ? 'cancel' : 'fail');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPayResult(url, requestId, status) {
|
||||||
|
const value = `${requestId}:${status}`;
|
||||||
|
const hashIndex = String(url || '').indexOf('#');
|
||||||
|
const baseUrl =
|
||||||
|
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||||
|
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||||
|
const params = new URLSearchParams(rawHash);
|
||||||
|
params.set('wx_pay_result', value);
|
||||||
|
return `${baseUrl}#${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPreviousWebView(requestId, status) {
|
||||||
|
const pages = getCurrentPages();
|
||||||
|
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null;
|
||||||
|
if (previousPage && typeof previousPage.setData === 'function') {
|
||||||
|
previousPage.setData({
|
||||||
|
webViewUrl: appendPayResult(
|
||||||
|
previousPage.data.webViewUrl,
|
||||||
|
requestId,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
title: '正在拉起支付',
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
async onLoad(query) {
|
||||||
|
const requestId = String(query.requestId || '');
|
||||||
|
const payParams = parsePayParams(query.payParams);
|
||||||
|
if (!requestId || !payParams) {
|
||||||
|
this.setData({
|
||||||
|
title: '支付失败',
|
||||||
|
errorMessage: '缺少支付参数。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await requestPayment(payParams);
|
||||||
|
notifyPreviousWebView(requestId, status);
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBack() {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
});
|
||||||
3
miniprogram/pages/wechat-pay/index.json
Normal file
3
miniprogram/pages/wechat-pay/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "微信支付"
|
||||||
|
}
|
||||||
11
miniprogram/pages/wechat-pay/index.wxml
Normal file
11
miniprogram/pages/wechat-pay/index.wxml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<view class="pay-screen">
|
||||||
|
<view class="pay-card">
|
||||||
|
<view class="pay-title">{{title}}</view>
|
||||||
|
<view wx:if="{{errorMessage}}" class="pay-text pay-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<button wx:if="{{errorMessage}}" class="ghost-button" bindtap="handleBack">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
48
miniprogram/pages/wechat-pay/index.wxss
Normal file
48
miniprogram/pages/wechat-pay/index.wxss
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.pay-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48rpx;
|
||||||
|
background: #0b0f14;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
padding: 36rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-text {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: rgba(245, 247, 251, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-text--danger {
|
||||||
|
color: #ffb4a9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.24);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(245, 247, 251, 0.86);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
@@ -78,7 +78,12 @@ export type ProfileWalletLedgerResponse = {
|
|||||||
export type ProfileRechargeProductKind = 'points' | 'membership';
|
export type ProfileRechargeProductKind = 'points' | 'membership';
|
||||||
export type ProfileMembershipStatus = 'normal' | 'active';
|
export type ProfileMembershipStatus = 'normal' | 'active';
|
||||||
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
|
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
|
||||||
export type ProfileRechargeOrderStatus = 'paid';
|
export type ProfileRechargeOrderStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'paid'
|
||||||
|
| 'failed'
|
||||||
|
| 'closed'
|
||||||
|
| 'refunded';
|
||||||
|
|
||||||
export type ProfileRechargeProduct = {
|
export type ProfileRechargeProduct = {
|
||||||
productId: string;
|
productId: string;
|
||||||
@@ -117,7 +122,8 @@ export type ProfileRechargeOrder = {
|
|||||||
amountCents: number;
|
amountCents: number;
|
||||||
status: ProfileRechargeOrderStatus;
|
status: ProfileRechargeOrderStatus;
|
||||||
paymentChannel: string;
|
paymentChannel: string;
|
||||||
paidAt: string;
|
paidAt: string | null;
|
||||||
|
providerTransactionId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pointsDelta: number;
|
pointsDelta: number;
|
||||||
membershipExpiresAt: string | null;
|
membershipExpiresAt: string | null;
|
||||||
@@ -133,6 +139,14 @@ export type ProfileRechargeCenterResponse = {
|
|||||||
hasPointsRecharged: boolean;
|
hasPointsRecharged: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WechatMiniProgramPayParams = {
|
||||||
|
timeStamp: string;
|
||||||
|
nonceStr: string;
|
||||||
|
package: string;
|
||||||
|
signType: 'RSA';
|
||||||
|
paySign: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateProfileRechargeOrderRequest = {
|
export type CreateProfileRechargeOrderRequest = {
|
||||||
productId: string;
|
productId: string;
|
||||||
paymentChannel?: string;
|
paymentChannel?: string;
|
||||||
@@ -141,6 +155,7 @@ export type CreateProfileRechargeOrderRequest = {
|
|||||||
export type CreateProfileRechargeOrderResponse = {
|
export type CreateProfileRechargeOrderResponse = {
|
||||||
order: ProfileRechargeOrder;
|
order: ProfileRechargeOrder;
|
||||||
center: ProfileRechargeCenterResponse;
|
center: ProfileRechargeCenterResponse;
|
||||||
|
wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProfileFeedbackStatus = 'open';
|
export type ProfileFeedbackStatus = 'open';
|
||||||
|
|||||||
2
server-rs/Cargo.lock
generated
2
server-rs/Cargo.lock
generated
@@ -81,6 +81,7 @@ dependencies = [
|
|||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -109,6 +110,7 @@ dependencies = [
|
|||||||
"platform-oss",
|
"platform-oss",
|
||||||
"platform-speech",
|
"platform-speech",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ langchainrust = "0.2.18"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
rand_core = "0.6"
|
rand_core = "0.6"
|
||||||
reqwest = { version = "0.12", default-features = false }
|
reqwest = { version = "0.12", default-features = false }
|
||||||
|
ring = "0.17"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ license.workspace = true
|
|||||||
async-stream = { workspace = true }
|
async-stream = { workspace = true }
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||||
@@ -34,6 +35,7 @@ platform-auth = { workspace = true }
|
|||||||
platform-llm = { workspace = true }
|
platform-llm = { workspace = true }
|
||||||
platform-oss = { workspace = true }
|
platform-oss = { workspace = true }
|
||||||
platform-speech = { workspace = true }
|
platform-speech = { workspace = true }
|
||||||
|
ring = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ use crate::{
|
|||||||
wechat_auth::{
|
wechat_auth::{
|
||||||
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
||||||
},
|
},
|
||||||
|
wechat_pay::handle_wechat_pay_notify,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
@@ -1410,6 +1411,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/profile/recharge/wechat/notify",
|
||||||
|
post(handle_wechat_pay_notify),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/profile/feedback",
|
"/api/profile/feedback",
|
||||||
post(submit_profile_feedback)
|
post(submit_profile_feedback)
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ pub struct AppConfig {
|
|||||||
pub wechat_mock_union_id: Option<String>,
|
pub wechat_mock_union_id: Option<String>,
|
||||||
pub wechat_mock_display_name: String,
|
pub wechat_mock_display_name: String,
|
||||||
pub wechat_mock_avatar_url: Option<String>,
|
pub wechat_mock_avatar_url: Option<String>,
|
||||||
|
pub wechat_pay_enabled: bool,
|
||||||
|
pub wechat_pay_provider: String,
|
||||||
|
pub wechat_pay_mch_id: Option<String>,
|
||||||
|
pub wechat_pay_merchant_serial_no: Option<String>,
|
||||||
|
pub wechat_pay_private_key_pem: Option<String>,
|
||||||
|
pub wechat_pay_private_key_path: Option<PathBuf>,
|
||||||
|
pub wechat_pay_platform_public_key_pem: Option<String>,
|
||||||
|
pub wechat_pay_platform_public_key_path: Option<PathBuf>,
|
||||||
|
pub wechat_pay_platform_serial_no: Option<String>,
|
||||||
|
pub wechat_pay_api_v3_key: Option<String>,
|
||||||
|
pub wechat_pay_notify_url: Option<String>,
|
||||||
|
pub wechat_pay_jsapi_endpoint: String,
|
||||||
pub oss_bucket: Option<String>,
|
pub oss_bucket: Option<String>,
|
||||||
pub oss_endpoint: Option<String>,
|
pub oss_endpoint: Option<String>,
|
||||||
pub oss_access_key_id: Option<String>,
|
pub oss_access_key_id: Option<String>,
|
||||||
@@ -189,6 +201,19 @@ impl Default for AppConfig {
|
|||||||
wechat_mock_union_id: Some("wx-mock-union".to_string()),
|
wechat_mock_union_id: Some("wx-mock-union".to_string()),
|
||||||
wechat_mock_display_name: "微信旅人".to_string(),
|
wechat_mock_display_name: "微信旅人".to_string(),
|
||||||
wechat_mock_avatar_url: None,
|
wechat_mock_avatar_url: None,
|
||||||
|
wechat_pay_enabled: false,
|
||||||
|
wechat_pay_provider: "mock".to_string(),
|
||||||
|
wechat_pay_mch_id: None,
|
||||||
|
wechat_pay_merchant_serial_no: None,
|
||||||
|
wechat_pay_private_key_pem: None,
|
||||||
|
wechat_pay_private_key_path: None,
|
||||||
|
wechat_pay_platform_public_key_pem: None,
|
||||||
|
wechat_pay_platform_public_key_path: None,
|
||||||
|
wechat_pay_platform_serial_no: None,
|
||||||
|
wechat_pay_api_v3_key: None,
|
||||||
|
wechat_pay_notify_url: None,
|
||||||
|
wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"
|
||||||
|
.to_string(),
|
||||||
oss_bucket: None,
|
oss_bucket: None,
|
||||||
oss_endpoint: None,
|
oss_endpoint: None,
|
||||||
oss_access_key_id: None,
|
oss_access_key_id: None,
|
||||||
@@ -458,6 +483,33 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
|
config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]);
|
||||||
|
|
||||||
|
if let Some(wechat_pay_enabled) = read_first_bool_env(&["WECHAT_PAY_ENABLED"]) {
|
||||||
|
config.wechat_pay_enabled = wechat_pay_enabled;
|
||||||
|
}
|
||||||
|
if let Some(wechat_pay_provider) = read_first_non_empty_env(&["WECHAT_PAY_PROVIDER"]) {
|
||||||
|
config.wechat_pay_provider = wechat_pay_provider;
|
||||||
|
}
|
||||||
|
config.wechat_pay_mch_id = read_first_non_empty_env(&["WECHAT_PAY_MCH_ID"]);
|
||||||
|
config.wechat_pay_merchant_serial_no =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_MERCHANT_SERIAL_NO"]);
|
||||||
|
config.wechat_pay_private_key_pem =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PEM"]);
|
||||||
|
config.wechat_pay_private_key_path =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_PRIVATE_KEY_PATH"]).map(PathBuf::from);
|
||||||
|
config.wechat_pay_platform_public_key_pem =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM"]);
|
||||||
|
config.wechat_pay_platform_public_key_path =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"]).map(PathBuf::from);
|
||||||
|
config.wechat_pay_platform_serial_no =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]);
|
||||||
|
config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]);
|
||||||
|
config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]);
|
||||||
|
if let Some(wechat_pay_jsapi_endpoint) =
|
||||||
|
read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"])
|
||||||
|
{
|
||||||
|
config.wechat_pay_jsapi_endpoint = wechat_pay_jsapi_endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||||
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
|
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
|
||||||
@@ -1081,6 +1133,74 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_reads_wechat_pay_settings() {
|
||||||
|
let _guard = ENV_LOCK
|
||||||
|
.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("env lock should not poison");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("WECHAT_PAY_ENABLED");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PROVIDER");
|
||||||
|
std::env::remove_var("WECHAT_PAY_MCH_ID");
|
||||||
|
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||||
|
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||||
|
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||||
|
std::env::set_var("WECHAT_PAY_ENABLED", "true");
|
||||||
|
std::env::set_var("WECHAT_PAY_PROVIDER", "real");
|
||||||
|
std::env::set_var("WECHAT_PAY_MCH_ID", "1900000109");
|
||||||
|
std::env::set_var("WECHAT_PAY_MERCHANT_SERIAL_NO", "serial-001");
|
||||||
|
std::env::set_var("WECHAT_PAY_PRIVATE_KEY_PATH", "certs/apiclient_key.pem");
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH",
|
||||||
|
"certs/wechatpay_platform.pem",
|
||||||
|
);
|
||||||
|
std::env::set_var("WECHAT_PAY_PLATFORM_SERIAL_NO", "platform-serial-001");
|
||||||
|
std::env::set_var("WECHAT_PAY_API_V3_KEY", "12345678901234567890123456789012");
|
||||||
|
std::env::set_var(
|
||||||
|
"WECHAT_PAY_NOTIFY_URL",
|
||||||
|
"https://api.example.com/api/profile/recharge/wechat/notify",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert!(config.wechat_pay_enabled);
|
||||||
|
assert_eq!(config.wechat_pay_provider, "real");
|
||||||
|
assert_eq!(config.wechat_pay_mch_id.as_deref(), Some("1900000109"));
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_pay_private_key_path.as_deref(),
|
||||||
|
Some(std::path::Path::new("certs/apiclient_key.pem"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_pay_notify_url.as_deref(),
|
||||||
|
Some("https://api.example.com/api/profile/recharge/wechat/notify")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_pay_platform_public_key_path.as_deref(),
|
||||||
|
Some(std::path::Path::new("certs/wechatpay_platform.pem"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.wechat_pay_platform_serial_no.as_deref(),
|
||||||
|
Some("platform-serial-001")
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("WECHAT_PAY_ENABLED");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PROVIDER");
|
||||||
|
std::env::remove_var("WECHAT_PAY_MCH_ID");
|
||||||
|
std::env::remove_var("WECHAT_PAY_MERCHANT_SERIAL_NO");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PRIVATE_KEY_PATH");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH");
|
||||||
|
std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO");
|
||||||
|
std::env::remove_var("WECHAT_PAY_API_V3_KEY");
|
||||||
|
std::env::remove_var("WECHAT_PAY_NOTIFY_URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_ignores_zero_spacetime_pool_size() {
|
fn from_env_ignores_zero_spacetime_pool_size() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ mod vector_engine_audio_generation;
|
|||||||
mod visual_novel;
|
mod visual_novel;
|
||||||
mod volcengine_speech;
|
mod volcengine_speech;
|
||||||
mod wechat_auth;
|
mod wechat_auth;
|
||||||
|
mod wechat_pay;
|
||||||
mod wechat_provider;
|
mod wechat_provider;
|
||||||
mod work_author;
|
mod work_author;
|
||||||
mod work_play_tracking;
|
mod work_play_tracking;
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use module_runtime::{
|
use module_runtime::{
|
||||||
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
|
||||||
RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot,
|
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord,
|
||||||
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
|
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||||
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
|
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||||
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
|
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||||
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||||
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
|
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||||
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
|
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
||||||
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
||||||
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||||
|
RuntimeTrackingScopeKind,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -56,8 +57,13 @@ use spacetime_client::SpacetimeClientError;
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken,
|
admin::AuthenticatedAdmin,
|
||||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
api_response::json_success_body,
|
||||||
|
auth::AuthenticatedAccessToken,
|
||||||
|
http_error::AppError,
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::AppState,
|
||||||
|
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_profile_dashboard(
|
pub async fn get_profile_dashboard(
|
||||||
@@ -186,14 +192,15 @@ pub async fn create_profile_recharge_order(
|
|||||||
let payment_channel = payload
|
let payment_channel = payload
|
||||||
.payment_channel
|
.payment_channel
|
||||||
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
|
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
|
||||||
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
let payment_channel = payment_channel.trim().to_string();
|
||||||
|
let created_at_micros = current_unix_micros();
|
||||||
let (center, order) = state
|
let (center, order) = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.create_profile_recharge_order(
|
.create_profile_recharge_order(
|
||||||
user_id,
|
user_id,
|
||||||
payload.product_id,
|
payload.product_id,
|
||||||
payment_channel,
|
payment_channel.clone(),
|
||||||
created_at_micros as i64,
|
created_at_micros,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -203,11 +210,36 @@ pub async fn create_profile_recharge_order(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let wechat_mini_program_pay_params = if payment_channel
|
||||||
|
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|
||||||
|
{
|
||||||
|
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
|
||||||
|
Some(
|
||||||
|
state
|
||||||
|
.wechat_pay_client()
|
||||||
|
.create_mini_program_order(build_wechat_payment_request(
|
||||||
|
order.order_id.clone(),
|
||||||
|
order.product_title.clone(),
|
||||||
|
order.amount_cents,
|
||||||
|
identity,
|
||||||
|
))
|
||||||
|
.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),
|
||||||
CreateProfileRechargeOrderResponse {
|
CreateProfileRechargeOrderResponse {
|
||||||
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,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -750,6 +782,25 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_wechat_identity_for_payment(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
if let Some(identity) = state
|
||||||
|
.wechat_auth_service()
|
||||||
|
.get_identity_by_user_id(user_id)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.with_message(format!("读取微信身份失败:{error}"))
|
||||||
|
})?
|
||||||
|
{
|
||||||
|
return Ok(identity.provider_uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_profile_recharge_center_response(
|
fn build_profile_recharge_center_response(
|
||||||
record: RuntimeProfileRechargeCenterRecord,
|
record: RuntimeProfileRechargeCenterRecord,
|
||||||
) -> ProfileRechargeCenterResponse {
|
) -> ProfileRechargeCenterResponse {
|
||||||
@@ -825,6 +876,7 @@ fn build_profile_recharge_order_response(
|
|||||||
status: record.status.as_str().to_string(),
|
status: record.status.as_str().to_string(),
|
||||||
payment_channel: record.payment_channel,
|
payment_channel: record.payment_channel,
|
||||||
paid_at: record.paid_at,
|
paid_at: record.paid_at,
|
||||||
|
provider_transaction_id: record.provider_transaction_id,
|
||||||
created_at: record.created_at,
|
created_at: record.created_at,
|
||||||
points_delta: record.points_delta,
|
points_delta: record.points_delta,
|
||||||
membership_expires_at: record.membership_expires_at,
|
membership_expires_at: record.membership_expires_at,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use time::OffsetDateTime;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||||
use crate::wechat_provider::build_wechat_provider;
|
use crate::wechat_provider::build_wechat_provider;
|
||||||
|
|
||||||
const ADMIN_ROLE: &str = "admin";
|
const ADMIN_ROLE: &str = "admin";
|
||||||
@@ -55,6 +56,7 @@ pub struct AppState {
|
|||||||
wechat_auth_state_service: WechatAuthStateService,
|
wechat_auth_state_service: WechatAuthStateService,
|
||||||
wechat_auth_service: WechatAuthService,
|
wechat_auth_service: WechatAuthService,
|
||||||
wechat_provider: WechatProvider,
|
wechat_provider: WechatProvider,
|
||||||
|
wechat_pay_client: WechatPayClient,
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
ai_task_service: AiTaskService,
|
ai_task_service: AiTaskService,
|
||||||
spacetime_client: SpacetimeClient,
|
spacetime_client: SpacetimeClient,
|
||||||
@@ -110,6 +112,7 @@ pub enum AppStateInitError {
|
|||||||
RefreshCookie(RefreshCookieError),
|
RefreshCookie(RefreshCookieError),
|
||||||
AuthStore(String),
|
AuthStore(String),
|
||||||
SmsProvider(SmsProviderError),
|
SmsProvider(SmsProviderError),
|
||||||
|
WechatPay(String),
|
||||||
Oss(OssError),
|
Oss(OssError),
|
||||||
Llm(LlmError),
|
Llm(LlmError),
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,8 @@ impl AppState {
|
|||||||
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
|
||||||
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
let wechat_auth_service = WechatAuthService::new(auth_store.clone());
|
||||||
let wechat_provider = build_wechat_provider(&config);
|
let wechat_provider = build_wechat_provider(&config);
|
||||||
|
let wechat_pay_client =
|
||||||
|
WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?;
|
||||||
let refresh_session_service =
|
let refresh_session_service =
|
||||||
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
|
RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days);
|
||||||
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
// AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。
|
||||||
@@ -206,6 +211,7 @@ impl AppState {
|
|||||||
wechat_auth_state_service,
|
wechat_auth_state_service,
|
||||||
wechat_auth_service,
|
wechat_auth_service,
|
||||||
wechat_provider,
|
wechat_provider,
|
||||||
|
wechat_pay_client,
|
||||||
ai_task_service,
|
ai_task_service,
|
||||||
spacetime_client,
|
spacetime_client,
|
||||||
llm_client,
|
llm_client,
|
||||||
@@ -454,6 +460,10 @@ impl AppState {
|
|||||||
&self.wechat_provider
|
&self.wechat_provider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn wechat_pay_client(&self) -> &WechatPayClient {
|
||||||
|
&self.wechat_pay_client
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
pub fn ai_task_service(&self) -> &AiTaskService {
|
pub fn ai_task_service(&self) -> &AiTaskService {
|
||||||
&self.ai_task_service
|
&self.ai_task_service
|
||||||
@@ -860,7 +870,7 @@ impl fmt::Display for AppStateInitError {
|
|||||||
match self {
|
match self {
|
||||||
Self::Jwt(error) => write!(f, "{error}"),
|
Self::Jwt(error) => write!(f, "{error}"),
|
||||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||||
Self::AuthStore(error) => write!(f, "{error}"),
|
Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"),
|
||||||
Self::SmsProvider(error) => write!(f, "{error}"),
|
Self::SmsProvider(error) => write!(f, "{error}"),
|
||||||
Self::Oss(error) => write!(f, "{error}"),
|
Self::Oss(error) => write!(f, "{error}"),
|
||||||
Self::Llm(error) => write!(f, "{error}"),
|
Self::Llm(error) => write!(f, "{error}"),
|
||||||
|
|||||||
780
server-rs/crates/api-server/src/wechat_pay.rs
Normal file
780
server-rs/crates/api-server/src/wechat_pay.rs
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
use std::{fs, path::Path, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use ring::{
|
||||||
|
aead,
|
||||||
|
rand::{SecureRandom, SystemRandom},
|
||||||
|
signature,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
||||||
|
use shared_kernel::offset_datetime_to_unix_micros;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{http_error::AppError, state::AppState};
|
||||||
|
|
||||||
|
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
|
||||||
|
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||||
|
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||||
|
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||||
|
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum WechatPayClient {
|
||||||
|
Disabled,
|
||||||
|
Mock,
|
||||||
|
Real(Arc<RealWechatPayClient>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RealWechatPayClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
app_id: String,
|
||||||
|
mch_id: String,
|
||||||
|
merchant_serial_no: String,
|
||||||
|
private_key: Arc<signature::RsaKeyPair>,
|
||||||
|
platform_public_key_der: Vec<u8>,
|
||||||
|
platform_serial_no: String,
|
||||||
|
api_v3_key: String,
|
||||||
|
notify_url: String,
|
||||||
|
jsapi_endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WechatMiniProgramOrderRequest {
|
||||||
|
pub order_id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub amount_cents: u64,
|
||||||
|
pub payer_openid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WechatPayNotifyOrder {
|
||||||
|
pub out_trade_no: String,
|
||||||
|
pub transaction_id: Option<String>,
|
||||||
|
pub trade_state: String,
|
||||||
|
pub success_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WechatPayError {
|
||||||
|
Disabled,
|
||||||
|
InvalidConfig(String),
|
||||||
|
InvalidRequest(String),
|
||||||
|
RequestFailed(String),
|
||||||
|
Upstream(String),
|
||||||
|
Deserialize(String),
|
||||||
|
Crypto(String),
|
||||||
|
InvalidSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WechatJsapiOrderRequest<'a> {
|
||||||
|
appid: &'a str,
|
||||||
|
mchid: &'a str,
|
||||||
|
description: &'a str,
|
||||||
|
out_trade_no: &'a str,
|
||||||
|
notify_url: &'a str,
|
||||||
|
amount: WechatJsapiAmount,
|
||||||
|
payer: WechatJsapiPayer<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WechatJsapiAmount {
|
||||||
|
total: i64,
|
||||||
|
currency: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WechatJsapiPayer<'a> {
|
||||||
|
openid: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatJsapiOrderResponse {
|
||||||
|
prepay_id: Option<String>,
|
||||||
|
code: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatPayNotifyBody {
|
||||||
|
#[serde(default)]
|
||||||
|
resource: Option<WechatPayNotifyResource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatPayNotifyResource {
|
||||||
|
ciphertext: String,
|
||||||
|
nonce: String,
|
||||||
|
#[serde(default)]
|
||||||
|
associated_data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WechatPayTransactionResource {
|
||||||
|
out_trade_no: String,
|
||||||
|
#[serde(default)]
|
||||||
|
transaction_id: Option<String>,
|
||||||
|
trade_state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
success_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WechatPayClient {
|
||||||
|
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||||
|
if !config.wechat_pay_enabled {
|
||||||
|
return Ok(Self::Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config
|
||||||
|
.wechat_pay_provider
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK)
|
||||||
|
{
|
||||||
|
return Ok(Self::Mock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config
|
||||||
|
.wechat_pay_provider
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL)
|
||||||
|
{
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"WECHAT_PAY_PROVIDER 仅支持 mock 或 real".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_id = config
|
||||||
|
.wechat_mini_program_app_id
|
||||||
|
.as_ref()
|
||||||
|
.or(config.wechat_app_id.as_ref())
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?;
|
||||||
|
let merchant_serial_no = required_config(
|
||||||
|
config.wechat_pay_merchant_serial_no.as_deref(),
|
||||||
|
"WECHAT_PAY_MERCHANT_SERIAL_NO",
|
||||||
|
)?;
|
||||||
|
let private_key_pem = read_private_key_pem(
|
||||||
|
config.wechat_pay_private_key_pem.as_deref(),
|
||||||
|
config.wechat_pay_private_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?);
|
||||||
|
let platform_public_key_pem = read_pem(
|
||||||
|
config.wechat_pay_platform_public_key_pem.as_deref(),
|
||||||
|
config.wechat_pay_platform_public_key_path.as_deref(),
|
||||||
|
"WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置",
|
||||||
|
"读取微信支付平台公钥失败",
|
||||||
|
)?;
|
||||||
|
let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?;
|
||||||
|
let platform_serial_no = required_config(
|
||||||
|
config.wechat_pay_platform_serial_no.as_deref(),
|
||||||
|
"WECHAT_PAY_PLATFORM_SERIAL_NO",
|
||||||
|
)?;
|
||||||
|
let api_v3_key = required_config(
|
||||||
|
config.wechat_pay_api_v3_key.as_deref(),
|
||||||
|
"WECHAT_PAY_API_V3_KEY",
|
||||||
|
)?;
|
||||||
|
if api_v3_key.as_bytes().len() != 32 {
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let notify_url = required_config(
|
||||||
|
config.wechat_pay_notify_url.as_deref(),
|
||||||
|
"WECHAT_PAY_NOTIFY_URL",
|
||||||
|
)?;
|
||||||
|
let jsapi_endpoint = normalize_required_url(
|
||||||
|
&config.wechat_pay_jsapi_endpoint,
|
||||||
|
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
app_id,
|
||||||
|
mch_id,
|
||||||
|
merchant_serial_no,
|
||||||
|
private_key,
|
||||||
|
platform_public_key_der,
|
||||||
|
platform_serial_no,
|
||||||
|
api_v3_key,
|
||||||
|
notify_url,
|
||||||
|
jsapi_endpoint,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_mini_program_order(
|
||||||
|
&self,
|
||||||
|
request: WechatMiniProgramOrderRequest,
|
||||||
|
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => Err(WechatPayError::Disabled),
|
||||||
|
Self::Mock => Ok(build_mock_pay_params(&request.order_id)),
|
||||||
|
Self::Real(client) => client.create_mini_program_order(request).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_notify(
|
||||||
|
&self,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => Err(WechatPayError::Disabled),
|
||||||
|
Self::Mock => parse_mock_notify(body),
|
||||||
|
Self::Real(client) => client.parse_notify(headers, body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealWechatPayClient {
|
||||||
|
async fn create_mini_program_order(
|
||||||
|
&self,
|
||||||
|
request: WechatMiniProgramOrderRequest,
|
||||||
|
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||||
|
let amount_total = i64::try_from(request.amount_cents)
|
||||||
|
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
|
||||||
|
let body = serde_json::to_string(&WechatJsapiOrderRequest {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
payer: WechatJsapiPayer {
|
||||||
|
openid: &request.payer_openid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?;
|
||||||
|
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||||
|
let nonce = create_nonce()?;
|
||||||
|
let authorization = self.build_authorization(
|
||||||
|
"POST",
|
||||||
|
"/v3/pay/transactions/jsapi",
|
||||||
|
×tamp,
|
||||||
|
&nonce,
|
||||||
|
&body,
|
||||||
|
)?;
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&self.jsapi_endpoint)
|
||||||
|
.header("Authorization", authorization)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_text = response.text().await.map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let payload =
|
||||||
|
serde_json::from_str::<WechatJsapiOrderResponse>(&response_text).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应解析失败:{error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(WechatPayError::Upstream(format!(
|
||||||
|
"微信支付 JSAPI 下单失败:{}",
|
||||||
|
payload
|
||||||
|
.message
|
||||||
|
.or(payload.code)
|
||||||
|
.unwrap_or_else(|| format!("HTTP {status}"))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepay_id = payload
|
||||||
|
.prepay_id
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WechatPayError::Upstream("微信支付未返回 prepay_id".to_string()))?;
|
||||||
|
self.build_pay_params(&prepay_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_authorization(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
canonical_url: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
nonce: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<String, WechatPayError> {
|
||||||
|
let message = format!("{method}\n{canonical_url}\n{timestamp}\n{nonce}\n{body}\n");
|
||||||
|
let signature = self.sign_message(&message)?;
|
||||||
|
Ok(format!(
|
||||||
|
"{WECHAT_PAY_BODY_SIGNATURE_METHOD} mchid=\"{}\",nonce_str=\"{}\",timestamp=\"{}\",serial_no=\"{}\",signature=\"{}\"",
|
||||||
|
self.mch_id, nonce, timestamp, self.merchant_serial_no, signature
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pay_params(
|
||||||
|
&self,
|
||||||
|
prepay_id: &str,
|
||||||
|
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||||
|
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||||
|
let nonce_str = create_nonce()?;
|
||||||
|
let package = format!("prepay_id={prepay_id}");
|
||||||
|
let message = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n",
|
||||||
|
self.app_id, time_stamp, nonce_str, package
|
||||||
|
);
|
||||||
|
let pay_sign = self.sign_message(&message)?;
|
||||||
|
|
||||||
|
Ok(WechatMiniProgramPayParamsResponse {
|
||||||
|
time_stamp,
|
||||||
|
nonce_str,
|
||||||
|
package,
|
||||||
|
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
|
||||||
|
pay_sign,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_notify(
|
||||||
|
&self,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||||
|
self.verify_notify_signature(headers, body)?;
|
||||||
|
let notify = serde_json::from_slice::<WechatPayNotifyBody>(body).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let resource = notify.resource.ok_or_else(|| {
|
||||||
|
WechatPayError::InvalidRequest("微信支付通知缺少 resource".to_string())
|
||||||
|
})?;
|
||||||
|
let plain_text = decrypt_aes_256_gcm(
|
||||||
|
self.api_v3_key.as_bytes(),
|
||||||
|
resource.nonce.as_bytes(),
|
||||||
|
resource.associated_data.as_deref().unwrap_or("").as_bytes(),
|
||||||
|
resource.ciphertext.as_str(),
|
||||||
|
)?;
|
||||||
|
let transaction = serde_json::from_slice::<WechatPayTransactionResource>(&plain_text)
|
||||||
|
.map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(WechatPayNotifyOrder {
|
||||||
|
out_trade_no: transaction.out_trade_no,
|
||||||
|
transaction_id: transaction
|
||||||
|
.transaction_id
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
trade_state: transaction.trade_state,
|
||||||
|
success_time: transaction.success_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_notify_signature(
|
||||||
|
&self,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<(), WechatPayError> {
|
||||||
|
let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?;
|
||||||
|
let nonce = read_required_header(headers, "Wechatpay-Nonce")?;
|
||||||
|
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||||
|
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||||
|
if serial != self.platform_serial_no {
|
||||||
|
return Err(WechatPayError::InvalidSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = format!(
|
||||||
|
"{}\n{}\n{}\n",
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
String::from_utf8_lossy(body)
|
||||||
|
);
|
||||||
|
let signature_bytes = BASE64_STANDARD
|
||||||
|
.decode(signature)
|
||||||
|
.map_err(|_| WechatPayError::InvalidSignature)?;
|
||||||
|
let public_key = signature::UnparsedPublicKey::new(
|
||||||
|
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||||
|
&self.platform_public_key_der,
|
||||||
|
);
|
||||||
|
public_key
|
||||||
|
.verify(message.as_bytes(), &signature_bytes)
|
||||||
|
.map_err(|_| WechatPayError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||||
|
let rng = SystemRandom::new();
|
||||||
|
let mut signature = vec![0_u8; self.private_key.public().modulus_len()];
|
||||||
|
self.private_key
|
||||||
|
.sign(
|
||||||
|
&signature::RSA_PKCS1_SHA256,
|
||||||
|
&rng,
|
||||||
|
message.as_bytes(),
|
||||||
|
&mut signature,
|
||||||
|
)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("微信支付签名失败".to_string()))?;
|
||||||
|
Ok(BASE64_STANDARD.encode(signature))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_wechat_pay_notify(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<&'static str, AppError> {
|
||||||
|
let notify = state
|
||||||
|
.wechat_pay_client()
|
||||||
|
.parse_notify(&headers, &body)
|
||||||
|
.map_err(map_wechat_pay_notify_error)?;
|
||||||
|
if notify.trade_state != "SUCCESS" {
|
||||||
|
info!(
|
||||||
|
order_id = notify.out_trade_no.as_str(),
|
||||||
|
trade_state = notify.trade_state.as_str(),
|
||||||
|
"收到非成功微信支付通知"
|
||||||
|
);
|
||||||
|
return Ok(WECHAT_PAY_NOTIFY_SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let paid_at_micros = notify
|
||||||
|
.success_time
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
|
||||||
|
.map(offset_datetime_to_unix_micros)
|
||||||
|
.unwrap_or_else(current_unix_micros);
|
||||||
|
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.mark_profile_recharge_order_paid(
|
||||||
|
notify.out_trade_no.clone(),
|
||||||
|
paid_at_micros,
|
||||||
|
notify.transaction_id.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||||
|
.with_message(format!("确认微信支付订单失败:{error}"))
|
||||||
|
})?;
|
||||||
|
info!(
|
||||||
|
order_id = notify.out_trade_no.as_str(),
|
||||||
|
"微信支付通知已确认订单入账"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(WECHAT_PAY_NOTIFY_SUCCESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||||
|
match error {
|
||||||
|
WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message("微信支付暂未启用")
|
||||||
|
.with_details(json!({ "provider": "wechat_pay" })),
|
||||||
|
WechatPayError::InvalidConfig(message) => {
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
.with_message(message)
|
||||||
|
.with_details(json!({ "provider": "wechat_pay" }))
|
||||||
|
}
|
||||||
|
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||||
|
.with_message(message)
|
||||||
|
.with_details(json!({ "provider": "wechat_pay" })),
|
||||||
|
WechatPayError::RequestFailed(message)
|
||||||
|
| WechatPayError::Upstream(message)
|
||||||
|
| WechatPayError::Deserialize(message)
|
||||||
|
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||||
|
.with_message(message)
|
||||||
|
.with_details(json!({ "provider": "wechat_pay" })),
|
||||||
|
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||||
|
.with_message("微信支付通知签名无效")
|
||||||
|
.with_details(json!({ "provider": "wechat_pay" })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError {
|
||||||
|
crate::state::AppStateInitError::WechatPay(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_wechat_payment_request(
|
||||||
|
order_id: String,
|
||||||
|
product_title: String,
|
||||||
|
amount_cents: u64,
|
||||||
|
payer_openid: String,
|
||||||
|
) -> WechatMiniProgramOrderRequest {
|
||||||
|
WechatMiniProgramOrderRequest {
|
||||||
|
order_id,
|
||||||
|
description: format!("百梦 - {product_title}"),
|
||||||
|
amount_cents,
|
||||||
|
payer_openid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_unix_micros() -> i64 {
|
||||||
|
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||||
|
i64::try_from(value).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||||
|
warn!(error = %error, "微信支付通知处理失败");
|
||||||
|
map_wechat_pay_error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||||
|
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||||
|
let nonce_str = "mock-nonce".to_string();
|
||||||
|
let package = format!("prepay_id=mock-{order_id}");
|
||||||
|
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
|
||||||
|
|
||||||
|
WechatMiniProgramPayParamsResponse {
|
||||||
|
time_stamp,
|
||||||
|
nonce_str,
|
||||||
|
package,
|
||||||
|
sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(),
|
||||||
|
pay_sign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||||
|
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
|
||||||
|
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(WechatPayNotifyOrder {
|
||||||
|
out_trade_no: value
|
||||||
|
.get("outTradeNo")
|
||||||
|
.or_else(|| value.get("out_trade_no"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string())
|
||||||
|
})?
|
||||||
|
.to_string(),
|
||||||
|
transaction_id: value
|
||||||
|
.get("transactionId")
|
||||||
|
.or_else(|| value.get("transaction_id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
trade_state: value
|
||||||
|
.get("tradeState")
|
||||||
|
.or_else(|| value.get("trade_state"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("SUCCESS")
|
||||||
|
.to_string(),
|
||||||
|
success_time: value
|
||||||
|
.get("successTime")
|
||||||
|
.or_else(|| value.get("success_time"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_config(value: Option<&str>, key: &str) -> Result<String, WechatPayError> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayError> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.starts_with("https://") {
|
||||||
|
return Ok(value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(WechatPayError::InvalidConfig(format!(
|
||||||
|
"{key} 必须是 https 地址"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_private_key_pem(
|
||||||
|
inline_pem: Option<&str>,
|
||||||
|
path: Option<&Path>,
|
||||||
|
) -> Result<String, WechatPayError> {
|
||||||
|
read_pem(
|
||||||
|
inline_pem,
|
||||||
|
path,
|
||||||
|
"WECHAT_PAY_PRIVATE_KEY_PEM 或 WECHAT_PAY_PRIVATE_KEY_PATH 未配置",
|
||||||
|
"读取微信支付私钥失败",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pem(
|
||||||
|
inline_pem: Option<&str>,
|
||||||
|
path: Option<&Path>,
|
||||||
|
missing_message: &str,
|
||||||
|
read_error_prefix: &str,
|
||||||
|
) -> Result<String, WechatPayError> {
|
||||||
|
if let Some(value) = inline_pem.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
return Ok(value.replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
let Some(path) = path else {
|
||||||
|
return Err(WechatPayError::InvalidConfig(missing_message.to_string()));
|
||||||
|
};
|
||||||
|
fs::read_to_string(path).map_err(|error| {
|
||||||
|
WechatPayError::InvalidConfig(format!("{read_error_prefix}:{}:{error}", path.display()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rsa_private_key(pem: &str) -> Result<signature::RsaKeyPair, WechatPayError> {
|
||||||
|
let (label, der) = parse_single_pem_block(pem)?;
|
||||||
|
match label.as_str() {
|
||||||
|
"PRIVATE KEY" => signature::RsaKeyPair::from_pkcs8(&der),
|
||||||
|
"RSA PRIVATE KEY" => signature::RsaKeyPair::from_der(&der),
|
||||||
|
_ => {
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"微信支付私钥必须是 PRIVATE KEY 或 RSA PRIVATE KEY PEM".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|error| WechatPayError::InvalidConfig(format!("微信支付私钥解析失败:{error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_public_key_pem(pem: &str) -> Result<Vec<u8>, WechatPayError> {
|
||||||
|
let (label, der) = parse_single_pem_block(pem)?;
|
||||||
|
if label != "PUBLIC KEY" {
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"微信支付平台公钥必须是 PUBLIC KEY PEM".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_single_pem_block(pem: &str) -> Result<(String, Vec<u8>), WechatPayError> {
|
||||||
|
let mut label: Option<String> = None;
|
||||||
|
let mut content = String::new();
|
||||||
|
for line in pem.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||||
|
if let Some(raw_label) = line
|
||||||
|
.strip_prefix("-----BEGIN ")
|
||||||
|
.and_then(|value| value.strip_suffix("-----"))
|
||||||
|
{
|
||||||
|
label = Some(raw_label.trim().to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with("-----END ") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if label.is_some() {
|
||||||
|
content.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let label = label
|
||||||
|
.ok_or_else(|| WechatPayError::InvalidConfig("微信支付 PEM 缺少 BEGIN 标记".to_string()))?;
|
||||||
|
let der = BASE64_STANDARD
|
||||||
|
.decode(content)
|
||||||
|
.map_err(|_| WechatPayError::InvalidConfig("微信支付 PEM base64 无效".to_string()))?;
|
||||||
|
if der.is_empty() {
|
||||||
|
return Err(WechatPayError::InvalidConfig(
|
||||||
|
"微信支付 PEM 内容为空".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((label, der))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_nonce() -> Result<String, WechatPayError> {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
SystemRandom::new()
|
||||||
|
.fill(&mut bytes)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("生成微信支付 nonce 失败".to_string()))?;
|
||||||
|
Ok(hex_encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_aes_256_gcm(
|
||||||
|
key: &[u8],
|
||||||
|
nonce: &[u8],
|
||||||
|
associated_data: &[u8],
|
||||||
|
ciphertext_base64: &str,
|
||||||
|
) -> Result<Vec<u8>, WechatPayError> {
|
||||||
|
let mut ciphertext = BASE64_STANDARD
|
||||||
|
.decode(ciphertext_base64)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("微信支付通知密文 base64 无效".to_string()))?;
|
||||||
|
if ciphertext.len() < aead::AES_256_GCM.tag_len() {
|
||||||
|
return Err(WechatPayError::Crypto(
|
||||||
|
"微信支付通知密文长度无效".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let nonce = aead::Nonce::try_assume_unique_for_key(nonce)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("微信支付通知 nonce 长度无效".to_string()))?;
|
||||||
|
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("微信支付通知解密 key 无效".to_string()))?;
|
||||||
|
let plain_text = aead::LessSafeKey::new(key)
|
||||||
|
.open_in_place(
|
||||||
|
nonce,
|
||||||
|
aead::Aad::from(associated_data),
|
||||||
|
ciphertext.as_mut_slice(),
|
||||||
|
)
|
||||||
|
.map_err(|_| WechatPayError::Crypto("微信支付通知认证或解密失败".to_string()))?;
|
||||||
|
Ok(plain_text.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_required_header<'a>(
|
||||||
|
headers: &'a HeaderMap,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<&'a str, WechatPayError> {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or(WechatPayError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_sha256(content: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(content);
|
||||||
|
hex_encode(&hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for WechatPayError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => formatter.write_str("微信支付暂未启用"),
|
||||||
|
Self::InvalidConfig(message)
|
||||||
|
| Self::InvalidRequest(message)
|
||||||
|
| Self::RequestFailed(message)
|
||||||
|
| Self::Upstream(message)
|
||||||
|
| Self::Deserialize(message)
|
||||||
|
| Self::Crypto(message) => formatter.write_str(message),
|
||||||
|
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WechatPayError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_pay_params_use_request_payment_shape() {
|
||||||
|
let params = build_mock_pay_params("recharge:user:1:points_60");
|
||||||
|
|
||||||
|
assert!(!params.time_stamp.is_empty());
|
||||||
|
assert_eq!(params.sign_type, "RSA");
|
||||||
|
assert!(params.package.starts_with("prepay_id=mock-"));
|
||||||
|
assert!(!params.pay_sign.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_mock_notify_defaults_success_state() {
|
||||||
|
let notify =
|
||||||
|
parse_mock_notify(br#"{"outTradeNo":"order-1"}"#).expect("mock notify should parse");
|
||||||
|
|
||||||
|
assert_eq!(notify.out_trade_no, "order-1");
|
||||||
|
assert_eq!(notify.transaction_id, None);
|
||||||
|
assert_eq!(notify.trade_state, "SUCCESS");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,14 @@ pub struct WechatIdentityProfile {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 已绑定微信身份快照。
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct WechatIdentityRecord {
|
||||||
|
pub user_id: String,
|
||||||
|
pub provider_uid: String,
|
||||||
|
pub provider_union_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 微信授权 state 快照。
|
/// 微信授权 state 快照。
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct WechatAuthStateRecord {
|
pub struct WechatAuthStateRecord {
|
||||||
|
|||||||
@@ -797,6 +797,13 @@ impl WechatAuthService {
|
|||||||
created: true,
|
created: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_identity_by_user_id(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
|
||||||
|
self.store.get_wechat_identity_by_user_id(user_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthUserService {
|
impl AuthUserService {
|
||||||
@@ -1342,6 +1349,29 @@ impl InMemoryAuthStore {
|
|||||||
.map(|stored| stored.user.clone()))
|
.map(|stored| stored.user.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_wechat_identity_by_user_id(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<WechatIdentityRecord>, WechatAuthError> {
|
||||||
|
let state = self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
|
||||||
|
let Some(identity) = state
|
||||||
|
.wechat_identity_by_provider_uid
|
||||||
|
.values()
|
||||||
|
.find(|identity| identity.user_id == user_id.trim())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(WechatIdentityRecord {
|
||||||
|
user_id: identity.user_id.clone(),
|
||||||
|
provider_uid: identity.provider_uid.clone(),
|
||||||
|
provider_union_id: identity.provider_union_id.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn refresh_wechat_identity_profile(
|
fn refresh_wechat_identity_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|||||||
@@ -190,8 +190,9 @@ pub fn build_runtime_profile_recharge_order_record(
|
|||||||
amount_cents: snapshot.amount_cents,
|
amount_cents: snapshot.amount_cents,
|
||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
payment_channel: snapshot.payment_channel,
|
payment_channel: snapshot.payment_channel,
|
||||||
paid_at: format_utc_micros(snapshot.paid_at_micros),
|
paid_at: snapshot.paid_at_micros.map(format_utc_micros),
|
||||||
paid_at_micros: snapshot.paid_at_micros,
|
paid_at_micros: snapshot.paid_at_micros,
|
||||||
|
provider_transaction_id: snapshot.provider_transaction_id,
|
||||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||||
created_at_micros: snapshot.created_at_micros,
|
created_at_micros: snapshot.created_at_micros,
|
||||||
points_delta: snapshot.points_delta,
|
points_delta: snapshot.points_delta,
|
||||||
|
|||||||
@@ -265,6 +265,20 @@ pub fn build_runtime_profile_recharge_order_create_input(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_runtime_profile_recharge_order_paid_input(
|
||||||
|
order_id: String,
|
||||||
|
paid_at_micros: i64,
|
||||||
|
provider_transaction_id: Option<String>,
|
||||||
|
) -> Result<RuntimeProfileRechargeOrderPaidInput, RuntimeProfileFieldError> {
|
||||||
|
let order_id =
|
||||||
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
||||||
|
Ok(RuntimeProfileRechargeOrderPaidInput {
|
||||||
|
order_id,
|
||||||
|
paid_at_micros,
|
||||||
|
provider_transaction_id: provider_transaction_id.and_then(normalize_required_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_runtime_profile_feedback_submission_input(
|
pub fn build_runtime_profile_feedback_submission_input(
|
||||||
user_id: String,
|
user_id: String,
|
||||||
description: String,
|
description: String,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1;
|
|||||||
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
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_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;
|
||||||
@@ -951,13 +952,21 @@ impl RuntimeProfileMembershipTier {
|
|||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum RuntimeProfileRechargeOrderStatus {
|
pub enum RuntimeProfileRechargeOrderStatus {
|
||||||
|
Pending,
|
||||||
Paid,
|
Paid,
|
||||||
|
Failed,
|
||||||
|
Closed,
|
||||||
|
Refunded,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeProfileRechargeOrderStatus {
|
impl RuntimeProfileRechargeOrderStatus {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
Self::Paid => "paid",
|
Self::Paid => "paid",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
Self::Closed => "closed",
|
||||||
|
Self::Refunded => "refunded",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1009,7 +1018,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot {
|
|||||||
pub amount_cents: u64,
|
pub amount_cents: u64,
|
||||||
pub status: RuntimeProfileRechargeOrderStatus,
|
pub status: RuntimeProfileRechargeOrderStatus,
|
||||||
pub payment_channel: String,
|
pub payment_channel: String,
|
||||||
pub paid_at_micros: i64,
|
pub paid_at_micros: Option<i64>,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
pub created_at_micros: i64,
|
pub created_at_micros: i64,
|
||||||
pub points_delta: i64,
|
pub points_delta: i64,
|
||||||
pub membership_expires_at_micros: Option<i64>,
|
pub membership_expires_at_micros: Option<i64>,
|
||||||
@@ -1059,6 +1069,14 @@ pub struct RuntimeProfileRechargeOrderCreateInput {
|
|||||||
pub created_at_micros: i64,
|
pub created_at_micros: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeProfileRechargeOrderPaidInput {
|
||||||
|
pub order_id: String,
|
||||||
|
pub paid_at_micros: i64,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
|
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
|
||||||
@@ -1471,8 +1489,9 @@ pub struct RuntimeProfileRechargeOrderRecord {
|
|||||||
pub amount_cents: u64,
|
pub amount_cents: u64,
|
||||||
pub status: RuntimeProfileRechargeOrderStatus,
|
pub status: RuntimeProfileRechargeOrderStatus,
|
||||||
pub payment_channel: String,
|
pub payment_channel: String,
|
||||||
pub paid_at: String,
|
pub paid_at: Option<String>,
|
||||||
pub paid_at_micros: i64,
|
pub paid_at_micros: Option<i64>,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub created_at_micros: i64,
|
pub created_at_micros: i64,
|
||||||
pub points_delta: i64,
|
pub points_delta: i64,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ pub enum RuntimeProfileFieldError {
|
|||||||
TaskDisabled,
|
TaskDisabled,
|
||||||
TaskNotClaimable,
|
TaskNotClaimable,
|
||||||
TaskAlreadyClaimed,
|
TaskAlreadyClaimed,
|
||||||
|
MissingOrderId,
|
||||||
MissingProductId,
|
MissingProductId,
|
||||||
MissingWorldKey,
|
MissingWorldKey,
|
||||||
MissingBottomTab,
|
MissingBottomTab,
|
||||||
@@ -133,6 +134,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
|||||||
Self::TaskDisabled => f.write_str("任务已停用"),
|
Self::TaskDisabled => f.write_str("任务已停用"),
|
||||||
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
|
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
|
||||||
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
|
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
|
||||||
|
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::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 不能为空"),
|
||||||
|
|||||||
@@ -222,12 +222,23 @@ pub struct ProfileRechargeOrderResponse {
|
|||||||
pub amount_cents: u64,
|
pub amount_cents: u64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub payment_channel: String,
|
pub payment_channel: String,
|
||||||
pub paid_at: String,
|
pub paid_at: Option<String>,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub points_delta: i64,
|
pub points_delta: i64,
|
||||||
pub membership_expires_at: Option<String>,
|
pub membership_expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WechatMiniProgramPayParamsResponse {
|
||||||
|
pub time_stamp: String,
|
||||||
|
pub nonce_str: String,
|
||||||
|
pub package: String,
|
||||||
|
pub sign_type: String,
|
||||||
|
pub pay_sign: 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 {
|
||||||
@@ -253,6 +264,8 @@ pub struct CreateProfileRechargeOrderRequest {
|
|||||||
pub struct CreateProfileRechargeOrderResponse {
|
pub struct CreateProfileRechargeOrderResponse {
|
||||||
pub order: ProfileRechargeOrderResponse,
|
pub order: ProfileRechargeOrderResponse,
|
||||||
pub center: ProfileRechargeCenterResponse,
|
pub center: ProfileRechargeCenterResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<module_runtime::RuntimeProfileRechargeOrderPaidInput>
|
||||||
|
for RuntimeProfileRechargeOrderPaidInput
|
||||||
|
{
|
||||||
|
fn from(input: module_runtime::RuntimeProfileRechargeOrderPaidInput) -> Self {
|
||||||
|
Self {
|
||||||
|
order_id: input.order_id,
|
||||||
|
paid_at_micros: input.paid_at_micros,
|
||||||
|
provider_transaction_id: input.provider_transaction_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<module_runtime::RuntimeProfileFeedbackSubmissionInput>
|
impl From<module_runtime::RuntimeProfileFeedbackSubmissionInput>
|
||||||
for RuntimeProfileFeedbackSubmissionInput
|
for RuntimeProfileFeedbackSubmissionInput
|
||||||
{
|
{
|
||||||
@@ -2217,6 +2229,7 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot(
|
|||||||
status: map_runtime_profile_recharge_order_status_back(snapshot.status),
|
status: map_runtime_profile_recharge_order_status_back(snapshot.status),
|
||||||
payment_channel: snapshot.payment_channel,
|
payment_channel: snapshot.payment_channel,
|
||||||
paid_at_micros: snapshot.paid_at_micros,
|
paid_at_micros: snapshot.paid_at_micros,
|
||||||
|
provider_transaction_id: snapshot.provider_transaction_id,
|
||||||
created_at_micros: snapshot.created_at_micros,
|
created_at_micros: snapshot.created_at_micros,
|
||||||
points_delta: snapshot.points_delta,
|
points_delta: snapshot.points_delta,
|
||||||
membership_expires_at_micros: snapshot.membership_expires_at_micros,
|
membership_expires_at_micros: snapshot.membership_expires_at_micros,
|
||||||
@@ -5026,9 +5039,21 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back(
|
|||||||
value: crate::module_bindings::RuntimeProfileRechargeOrderStatus,
|
value: crate::module_bindings::RuntimeProfileRechargeOrderStatus,
|
||||||
) -> module_runtime::RuntimeProfileRechargeOrderStatus {
|
) -> module_runtime::RuntimeProfileRechargeOrderStatus {
|
||||||
match value {
|
match value {
|
||||||
|
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending => {
|
||||||
|
module_runtime::RuntimeProfileRechargeOrderStatus::Pending
|
||||||
|
}
|
||||||
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => {
|
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => {
|
||||||
module_runtime::RuntimeProfileRechargeOrderStatus::Paid
|
module_runtime::RuntimeProfileRechargeOrderStatus::Paid
|
||||||
}
|
}
|
||||||
|
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed => {
|
||||||
|
module_runtime::RuntimeProfileRechargeOrderStatus::Failed
|
||||||
|
}
|
||||||
|
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed => {
|
||||||
|
module_runtime::RuntimeProfileRechargeOrderStatus::Closed
|
||||||
|
}
|
||||||
|
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => {
|
||||||
|
module_runtime::RuntimeProfileRechargeOrderStatus::Refunded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
use super::runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||||
|
use super::runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
struct MarkProfileRechargeOrderPaidAndReturnArgs {
|
||||||
|
pub input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for MarkProfileRechargeOrderPaidAndReturnArgs {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
/// Extension trait for access to the procedure `mark_profile_recharge_order_paid_and_return`.
|
||||||
|
///
|
||||||
|
/// Implemented for [`super::RemoteProcedures`].
|
||||||
|
pub trait mark_profile_recharge_order_paid_and_return {
|
||||||
|
fn mark_profile_recharge_order_paid_and_return(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
) {
|
||||||
|
self.mark_profile_recharge_order_paid_and_return_then(input, |_, _| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_profile_recharge_order_paid_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl mark_profile_recharge_order_paid_and_return for super::RemoteProcedures {
|
||||||
|
fn mark_profile_recharge_order_paid_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeProfileRechargeCenterProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeCenterProcedureResult>(
|
||||||
|
"mark_profile_recharge_order_paid_and_return",
|
||||||
|
MarkProfileRechargeOrderPaidAndReturnArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -367,6 +367,7 @@ pub mod list_puzzle_works_procedure;
|
|||||||
pub mod list_square_hole_works_procedure;
|
pub mod list_square_hole_works_procedure;
|
||||||
pub mod list_visual_novel_runtime_history_procedure;
|
pub mod list_visual_novel_runtime_history_procedure;
|
||||||
pub mod list_visual_novel_works_procedure;
|
pub mod list_visual_novel_works_procedure;
|
||||||
|
pub mod mark_profile_recharge_order_paid_and_return_procedure;
|
||||||
pub mod match_3_d_agent_message_finalize_input_type;
|
pub mod match_3_d_agent_message_finalize_input_type;
|
||||||
pub mod match_3_d_agent_message_row_type;
|
pub mod match_3_d_agent_message_row_type;
|
||||||
pub mod match_3_d_agent_message_submit_input_type;
|
pub mod match_3_d_agent_message_submit_input_type;
|
||||||
@@ -616,6 +617,7 @@ pub mod runtime_profile_recharge_center_get_input_type;
|
|||||||
pub mod runtime_profile_recharge_center_procedure_result_type;
|
pub mod runtime_profile_recharge_center_procedure_result_type;
|
||||||
pub mod runtime_profile_recharge_center_snapshot_type;
|
pub mod runtime_profile_recharge_center_snapshot_type;
|
||||||
pub mod runtime_profile_recharge_order_create_input_type;
|
pub mod runtime_profile_recharge_order_create_input_type;
|
||||||
|
pub mod runtime_profile_recharge_order_paid_input_type;
|
||||||
pub mod runtime_profile_recharge_order_snapshot_type;
|
pub mod runtime_profile_recharge_order_snapshot_type;
|
||||||
pub mod runtime_profile_recharge_order_status_type;
|
pub mod runtime_profile_recharge_order_status_type;
|
||||||
pub mod runtime_profile_recharge_product_kind_type;
|
pub mod runtime_profile_recharge_product_kind_type;
|
||||||
@@ -1177,6 +1179,7 @@ pub use list_puzzle_works_procedure::list_puzzle_works;
|
|||||||
pub use list_square_hole_works_procedure::list_square_hole_works;
|
pub use list_square_hole_works_procedure::list_square_hole_works;
|
||||||
pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history;
|
pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history;
|
||||||
pub use list_visual_novel_works_procedure::list_visual_novel_works;
|
pub use list_visual_novel_works_procedure::list_visual_novel_works;
|
||||||
|
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
|
||||||
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
|
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
|
||||||
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
|
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
|
||||||
pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput;
|
pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput;
|
||||||
@@ -1426,6 +1429,7 @@ pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCe
|
|||||||
pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult;
|
||||||
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
|
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
|
||||||
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
|
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
|
||||||
|
pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
|
||||||
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
|
||||||
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
|
||||||
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
|
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ pub struct ProfileRechargeOrder {
|
|||||||
pub amount_cents: u64,
|
pub amount_cents: u64,
|
||||||
pub status: RuntimeProfileRechargeOrderStatus,
|
pub status: RuntimeProfileRechargeOrderStatus,
|
||||||
pub payment_channel: String,
|
pub payment_channel: String,
|
||||||
pub paid_at: __sdk::Timestamp,
|
pub paid_at: Option<__sdk::Timestamp>,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
pub created_at: __sdk::Timestamp,
|
pub created_at: __sdk::Timestamp,
|
||||||
pub points_delta: i64,
|
pub points_delta: i64,
|
||||||
pub membership_expires_at: Option<__sdk::Timestamp>,
|
pub membership_expires_at: Option<__sdk::Timestamp>,
|
||||||
@@ -41,7 +42,8 @@ pub struct ProfileRechargeOrderCols {
|
|||||||
pub status:
|
pub status:
|
||||||
__sdk::__query_builder::Col<ProfileRechargeOrder, RuntimeProfileRechargeOrderStatus>,
|
__sdk::__query_builder::Col<ProfileRechargeOrder, RuntimeProfileRechargeOrderStatus>,
|
||||||
pub payment_channel: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
|
pub payment_channel: __sdk::__query_builder::Col<ProfileRechargeOrder, String>,
|
||||||
pub paid_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
|
pub paid_at: __sdk::__query_builder::Col<ProfileRechargeOrder, Option<__sdk::Timestamp>>,
|
||||||
|
pub provider_transaction_id: __sdk::__query_builder::Col<ProfileRechargeOrder, Option<String>>,
|
||||||
pub created_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
|
pub created_at: __sdk::__query_builder::Col<ProfileRechargeOrder, __sdk::Timestamp>,
|
||||||
pub points_delta: __sdk::__query_builder::Col<ProfileRechargeOrder, i64>,
|
pub points_delta: __sdk::__query_builder::Col<ProfileRechargeOrder, i64>,
|
||||||
pub membership_expires_at:
|
pub membership_expires_at:
|
||||||
@@ -61,6 +63,10 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeOrder {
|
|||||||
status: __sdk::__query_builder::Col::new(table_name, "status"),
|
status: __sdk::__query_builder::Col::new(table_name, "status"),
|
||||||
payment_channel: __sdk::__query_builder::Col::new(table_name, "payment_channel"),
|
payment_channel: __sdk::__query_builder::Col::new(table_name, "payment_channel"),
|
||||||
paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"),
|
paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"),
|
||||||
|
provider_transaction_id: __sdk::__query_builder::Col::new(
|
||||||
|
table_name,
|
||||||
|
"provider_transaction_id",
|
||||||
|
),
|
||||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||||
points_delta: __sdk::__query_builder::Col::new(table_name, "points_delta"),
|
points_delta: __sdk::__query_builder::Col::new(table_name, "points_delta"),
|
||||||
membership_expires_at: __sdk::__query_builder::Col::new(
|
membership_expires_at: __sdk::__query_builder::Col::new(
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||||
|
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||||
|
|
||||||
|
#![allow(unused, clippy::all)]
|
||||||
|
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
pub struct RuntimeProfileRechargeOrderPaidInput {
|
||||||
|
pub order_id: String,
|
||||||
|
pub paid_at_micros: i64,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeProfileRechargeOrderPaidInput {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ pub struct RuntimeProfileRechargeOrderSnapshot {
|
|||||||
pub amount_cents: u64,
|
pub amount_cents: u64,
|
||||||
pub status: RuntimeProfileRechargeOrderStatus,
|
pub status: RuntimeProfileRechargeOrderStatus,
|
||||||
pub payment_channel: String,
|
pub payment_channel: String,
|
||||||
pub paid_at_micros: i64,
|
pub paid_at_micros: Option<i64>,
|
||||||
|
pub provider_transaction_id: Option<String>,
|
||||||
pub created_at_micros: i64,
|
pub created_at_micros: i64,
|
||||||
pub points_delta: i64,
|
pub points_delta: i64,
|
||||||
pub membership_expires_at_micros: Option<i64>,
|
pub membership_expires_at_micros: Option<i64>,
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
|||||||
#[sats(crate = __lib)]
|
#[sats(crate = __lib)]
|
||||||
#[derive(Copy, Eq, Hash)]
|
#[derive(Copy, Eq, Hash)]
|
||||||
pub enum RuntimeProfileRechargeOrderStatus {
|
pub enum RuntimeProfileRechargeOrderStatus {
|
||||||
|
Pending,
|
||||||
|
|
||||||
Paid,
|
Paid,
|
||||||
|
|
||||||
|
Failed,
|
||||||
|
|
||||||
|
Closed,
|
||||||
|
|
||||||
|
Refunded,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl __sdk::InModule for RuntimeProfileRechargeOrderStatus {
|
impl __sdk::InModule for RuntimeProfileRechargeOrderStatus {
|
||||||
|
|||||||
@@ -268,6 +268,42 @@ impl SpacetimeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn mark_profile_recharge_order_paid(
|
||||||
|
&self,
|
||||||
|
order_id: String,
|
||||||
|
paid_at_micros: i64,
|
||||||
|
provider_transaction_id: Option<String>,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
RuntimeProfileRechargeCenterRecord,
|
||||||
|
RuntimeProfileRechargeOrderRecord,
|
||||||
|
),
|
||||||
|
SpacetimeClientError,
|
||||||
|
> {
|
||||||
|
let procedure_input = module_runtime::build_runtime_profile_recharge_order_paid_input(
|
||||||
|
order_id,
|
||||||
|
paid_at_micros,
|
||||||
|
provider_transaction_id,
|
||||||
|
)
|
||||||
|
.map_err(SpacetimeClientError::validation_failed)?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
self.call_after_connect(move |connection, sender| {
|
||||||
|
connection
|
||||||
|
.procedures()
|
||||||
|
.mark_profile_recharge_order_paid_and_return_then(
|
||||||
|
procedure_input,
|
||||||
|
move |_, result| {
|
||||||
|
let mapped = result
|
||||||
|
.map_err(SpacetimeClientError::from_sdk_error)
|
||||||
|
.and_then(map_runtime_profile_recharge_order_procedure_result);
|
||||||
|
send_once(&sender, mapped);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn submit_profile_feedback(
|
pub async fn submit_profile_feedback(
|
||||||
&self,
|
&self,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -1151,6 +1151,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
|||||||
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
|
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if table_name == "profile_recharge_order" {
|
||||||
|
if let Some(object) = next_value.as_object_mut() {
|
||||||
|
// 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。
|
||||||
|
object
|
||||||
|
.entry("provider_transaction_id".to_string())
|
||||||
|
.or_insert(serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
if table_name == "big_fish_creation_session" {
|
if table_name == "big_fish_creation_session" {
|
||||||
if let Some(object) = next_value.as_object_mut() {
|
if let Some(object) = next_value.as_object_mut() {
|
||||||
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ pub struct ProfileMembership {
|
|||||||
btree(columns = [user_id, created_at])
|
btree(columns = [user_id, created_at])
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ProfileRechargeOrder {
|
pub struct ProfileRechargeOrder {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
pub(crate) order_id: String,
|
pub(crate) order_id: String,
|
||||||
@@ -346,7 +347,10 @@ pub struct ProfileRechargeOrder {
|
|||||||
pub(crate) amount_cents: u64,
|
pub(crate) amount_cents: u64,
|
||||||
pub(crate) status: RuntimeProfileRechargeOrderStatus,
|
pub(crate) status: RuntimeProfileRechargeOrderStatus,
|
||||||
pub(crate) payment_channel: String,
|
pub(crate) payment_channel: String,
|
||||||
pub(crate) paid_at: Timestamp,
|
#[default(None::<Timestamp>)]
|
||||||
|
pub(crate) paid_at: Option<Timestamp>,
|
||||||
|
#[default(None::<String>)]
|
||||||
|
pub(crate) provider_transaction_id: Option<String>,
|
||||||
pub(crate) created_at: Timestamp,
|
pub(crate) created_at: Timestamp,
|
||||||
pub(crate) points_delta: i64,
|
pub(crate) points_delta: i64,
|
||||||
pub(crate) membership_expires_at: Option<Timestamp>,
|
pub(crate) membership_expires_at: Option<Timestamp>,
|
||||||
@@ -767,7 +771,6 @@ pub fn get_profile_recharge_center(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前阶段没有真实支付网关,下单后在服务端模拟支付成功并立即写入权益。
|
|
||||||
#[spacetimedb::procedure]
|
#[spacetimedb::procedure]
|
||||||
pub fn create_profile_recharge_order_and_return(
|
pub fn create_profile_recharge_order_and_return(
|
||||||
ctx: &mut ProcedureContext,
|
ctx: &mut ProcedureContext,
|
||||||
@@ -789,6 +792,27 @@ pub fn create_profile_recharge_order_and_return(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::procedure]
|
||||||
|
pub fn mark_profile_recharge_order_paid_and_return(
|
||||||
|
ctx: &mut ProcedureContext,
|
||||||
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
) -> RuntimeProfileRechargeCenterProcedureResult {
|
||||||
|
match ctx.try_with_tx(|tx| mark_profile_recharge_order_paid_record(tx, input.clone())) {
|
||||||
|
Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult {
|
||||||
|
ok: true,
|
||||||
|
record: Some(record),
|
||||||
|
order: Some(order),
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
|
Err(message) => RuntimeProfileRechargeCenterProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
record: None,
|
||||||
|
order: None,
|
||||||
|
error_message: Some(message),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::procedure]
|
#[spacetimedb::procedure]
|
||||||
pub fn submit_profile_feedback_and_return(
|
pub fn submit_profile_feedback_and_return(
|
||||||
ctx: &mut ProcedureContext,
|
ctx: &mut ProcedureContext,
|
||||||
@@ -2049,36 +2073,24 @@ fn create_profile_recharge_order_record(
|
|||||||
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
|
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id)
|
||||||
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||||
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||||
|
let should_settle_immediately =
|
||||||
let (points_delta, membership_expires_at) = match product.kind {
|
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
|
||||||
RuntimeProfileRechargeProductKind::Points => {
|
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
|
||||||
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
|
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||||
let points_delta =
|
ctx,
|
||||||
resolve_runtime_profile_points_recharge_delta(&product, has_recharged);
|
&validated_input.user_id,
|
||||||
apply_profile_wallet_delta(
|
&product,
|
||||||
ctx,
|
validated_input.created_at_micros,
|
||||||
&validated_input.user_id,
|
created_at,
|
||||||
points_delta,
|
)?;
|
||||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
(
|
||||||
&build_runtime_profile_recharge_wallet_ledger_id(
|
RuntimeProfileRechargeOrderStatus::Paid,
|
||||||
&validated_input.user_id,
|
Some(created_at),
|
||||||
validated_input.created_at_micros,
|
points_delta,
|
||||||
&product.product_id,
|
membership_expires_at,
|
||||||
),
|
)
|
||||||
created_at,
|
} else {
|
||||||
)?;
|
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
|
||||||
(points_delta as i64, None)
|
|
||||||
}
|
|
||||||
RuntimeProfileRechargeProductKind::Membership => {
|
|
||||||
let expires_at = apply_profile_membership_purchase(
|
|
||||||
ctx,
|
|
||||||
&validated_input.user_id,
|
|
||||||
product.tier,
|
|
||||||
product.duration_days,
|
|
||||||
created_at,
|
|
||||||
);
|
|
||||||
(0, Some(expires_at))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let order = ProfileRechargeOrder {
|
let order = ProfileRechargeOrder {
|
||||||
@@ -2092,9 +2104,10 @@ fn create_profile_recharge_order_record(
|
|||||||
product_title: product.title.clone(),
|
product_title: product.title.clone(),
|
||||||
kind: product.kind,
|
kind: product.kind,
|
||||||
amount_cents: product.price_cents,
|
amount_cents: product.price_cents,
|
||||||
status: RuntimeProfileRechargeOrderStatus::Paid,
|
status,
|
||||||
payment_channel: validated_input.payment_channel,
|
payment_channel: validated_input.payment_channel,
|
||||||
paid_at: created_at,
|
paid_at,
|
||||||
|
provider_transaction_id: None,
|
||||||
created_at,
|
created_at,
|
||||||
points_delta,
|
points_delta,
|
||||||
membership_expires_at,
|
membership_expires_at,
|
||||||
@@ -2109,6 +2122,106 @@ fn create_profile_recharge_order_record(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_profile_recharge_order_paid_record(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
input: RuntimeProfileRechargeOrderPaidInput,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
RuntimeProfileRechargeCenterSnapshot,
|
||||||
|
RuntimeProfileRechargeOrderSnapshot,
|
||||||
|
),
|
||||||
|
String,
|
||||||
|
> {
|
||||||
|
let validated_input = build_runtime_profile_recharge_order_paid_input(
|
||||||
|
input.order_id,
|
||||||
|
input.paid_at_micros,
|
||||||
|
input.provider_transaction_id,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut order = ctx
|
||||||
|
.db
|
||||||
|
.profile_recharge_order()
|
||||||
|
.order_id()
|
||||||
|
.find(&validated_input.order_id)
|
||||||
|
.ok_or_else(|| "profile_recharge_order 不存在".to_string())?;
|
||||||
|
|
||||||
|
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||||
|
return Ok((
|
||||||
|
build_profile_recharge_center_snapshot(ctx, &order.user_id),
|
||||||
|
build_profile_recharge_order_snapshot_from_row(&order),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||||
|
return Err("profile_recharge_order 当前状态不能确认支付".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let product = runtime_profile_recharge_product_by_id(&order.product_id)
|
||||||
|
.ok_or_else(|| "recharge.product_id 不存在".to_string())?;
|
||||||
|
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
|
||||||
|
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||||
|
ctx,
|
||||||
|
&order.user_id,
|
||||||
|
&product,
|
||||||
|
order.created_at.to_micros_since_unix_epoch(),
|
||||||
|
paid_at,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
ctx.db
|
||||||
|
.profile_recharge_order()
|
||||||
|
.order_id()
|
||||||
|
.delete(&order.order_id);
|
||||||
|
order.status = RuntimeProfileRechargeOrderStatus::Paid;
|
||||||
|
order.paid_at = Some(paid_at);
|
||||||
|
order.provider_transaction_id = validated_input.provider_transaction_id;
|
||||||
|
order.points_delta = points_delta;
|
||||||
|
order.membership_expires_at = membership_expires_at;
|
||||||
|
ctx.db.profile_recharge_order().insert(order.clone());
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
build_profile_recharge_center_snapshot(ctx, &order.user_id),
|
||||||
|
build_profile_recharge_order_snapshot_from_row(&order),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_profile_recharge_purchase(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
user_id: &str,
|
||||||
|
product: &RuntimeProfileRechargeProductSnapshot,
|
||||||
|
order_created_at_micros: i64,
|
||||||
|
paid_at: Timestamp,
|
||||||
|
) -> Result<(i64, Option<Timestamp>), String> {
|
||||||
|
match product.kind {
|
||||||
|
RuntimeProfileRechargeProductKind::Points => {
|
||||||
|
let has_recharged = has_profile_points_recharged(ctx, user_id);
|
||||||
|
let points_delta =
|
||||||
|
resolve_runtime_profile_points_recharge_delta(product, has_recharged);
|
||||||
|
apply_profile_wallet_delta(
|
||||||
|
ctx,
|
||||||
|
user_id,
|
||||||
|
points_delta,
|
||||||
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
|
||||||
|
&build_runtime_profile_recharge_wallet_ledger_id(
|
||||||
|
user_id,
|
||||||
|
order_created_at_micros,
|
||||||
|
&product.product_id,
|
||||||
|
),
|
||||||
|
paid_at,
|
||||||
|
)?;
|
||||||
|
Ok((points_delta as i64, None))
|
||||||
|
}
|
||||||
|
RuntimeProfileRechargeProductKind::Membership => {
|
||||||
|
let expires_at = apply_profile_membership_purchase(
|
||||||
|
ctx,
|
||||||
|
user_id,
|
||||||
|
product.tier,
|
||||||
|
product.duration_days,
|
||||||
|
paid_at,
|
||||||
|
);
|
||||||
|
Ok((0, Some(expires_at)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn submit_profile_feedback_record(
|
fn submit_profile_feedback_record(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: RuntimeProfileFeedbackSubmissionInput,
|
input: RuntimeProfileFeedbackSubmissionInput,
|
||||||
@@ -3745,7 +3858,8 @@ fn build_profile_recharge_order_snapshot_from_row(
|
|||||||
amount_cents: row.amount_cents,
|
amount_cents: row.amount_cents,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
payment_channel: row.payment_channel.clone(),
|
payment_channel: row.payment_channel.clone(),
|
||||||
paid_at_micros: row.paid_at.to_micros_since_unix_epoch(),
|
paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()),
|
||||||
|
provider_transaction_id: row.provider_transaction_id.clone(),
|
||||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||||
points_delta: row.points_delta,
|
points_delta: row.points_delta,
|
||||||
membership_expires_at_micros: row
|
membership_expires_at_micros: row
|
||||||
|
|||||||
@@ -17,14 +17,12 @@ import type {
|
|||||||
PublicUserSummary,
|
PublicUserSummary,
|
||||||
} from '../../../packages/shared/src/contracts/auth';
|
} from '../../../packages/shared/src/contracts/auth';
|
||||||
import type {
|
import type {
|
||||||
|
CreateProfileRechargeOrderResponse,
|
||||||
ProfileReferralInviteCenterResponse,
|
ProfileReferralInviteCenterResponse,
|
||||||
ProfileTaskCenterResponse,
|
ProfileTaskCenterResponse,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||||
import {
|
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
|
||||||
ICP_RECORD_NUMBER,
|
|
||||||
ICP_RECORD_URL,
|
|
||||||
} from '../common/legalDocuments';
|
|
||||||
import {
|
import {
|
||||||
RpgEntryHomeView,
|
RpgEntryHomeView,
|
||||||
type RpgEntryHomeViewProps,
|
type RpgEntryHomeViewProps,
|
||||||
@@ -41,7 +39,9 @@ const {
|
|||||||
mockBuildReferralCenter,
|
mockBuildReferralCenter,
|
||||||
mockBuildTaskCenter,
|
mockBuildTaskCenter,
|
||||||
mockClaimRpgProfileTaskReward,
|
mockClaimRpgProfileTaskReward,
|
||||||
|
mockCreateRpgProfileRechargeOrder,
|
||||||
mockGetRpgProfileReferralInviteCenter,
|
mockGetRpgProfileReferralInviteCenter,
|
||||||
|
mockGetRpgProfileRechargeCenter,
|
||||||
mockGetRpgProfileTasks,
|
mockGetRpgProfileTasks,
|
||||||
mockGetRpgProfileWalletLedger,
|
mockGetRpgProfileWalletLedger,
|
||||||
mockRedeemRpgProfileReferralInviteCode,
|
mockRedeemRpgProfileReferralInviteCode,
|
||||||
@@ -137,6 +137,88 @@ const {
|
|||||||
},
|
},
|
||||||
center: buildClaimedTaskCenter(),
|
center: buildClaimedTaskCenter(),
|
||||||
})),
|
})),
|
||||||
|
mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [
|
||||||
|
{
|
||||||
|
productId: 'points_60',
|
||||||
|
title: '60光点',
|
||||||
|
priceCents: 600,
|
||||||
|
kind: 'points',
|
||||||
|
pointsAmount: 60,
|
||||||
|
bonusPoints: 60,
|
||||||
|
durationDays: 0,
|
||||||
|
badgeLabel: '首充双倍',
|
||||||
|
description: '首充送60光点',
|
||||||
|
tier: 'normal',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
membershipProducts: [
|
||||||
|
{
|
||||||
|
productId: 'member_month',
|
||||||
|
title: '月卡',
|
||||||
|
priceCents: 2800,
|
||||||
|
kind: 'membership',
|
||||||
|
pointsAmount: 0,
|
||||||
|
bonusPoints: 0,
|
||||||
|
durationDays: 30,
|
||||||
|
badgeLabel: '',
|
||||||
|
description: '30天会员',
|
||||||
|
tier: 'month',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
benefits: [
|
||||||
|
{
|
||||||
|
benefitName: '免光点回合数',
|
||||||
|
normalValue: '30',
|
||||||
|
monthValue: '100',
|
||||||
|
seasonValue: '100',
|
||||||
|
yearValue: '100',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
})),
|
||||||
|
mockCreateRpgProfileRechargeOrder: vi.fn(
|
||||||
|
async (): Promise<CreateProfileRechargeOrderResponse> => ({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-1',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60光点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'paid',
|
||||||
|
paymentChannel: 'mock',
|
||||||
|
paidAt: '2026-04-25T10:00:00Z',
|
||||||
|
providerTransactionId: null,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||||
center: buildReferralCenter({
|
center: buildReferralCenter({
|
||||||
invitedUsers: [],
|
invitedUsers: [],
|
||||||
@@ -219,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
|||||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||||
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
|
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
|
||||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
||||||
walletBalance: 0,
|
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
||||||
membership: {
|
|
||||||
status: 'normal',
|
|
||||||
tier: 'normal',
|
|
||||||
startedAt: null,
|
|
||||||
expiresAt: null,
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
pointProducts: [
|
|
||||||
{
|
|
||||||
productId: 'points_60',
|
|
||||||
title: '60光点',
|
|
||||||
priceCents: 600,
|
|
||||||
kind: 'points',
|
|
||||||
pointsAmount: 60,
|
|
||||||
bonusPoints: 60,
|
|
||||||
durationDays: 0,
|
|
||||||
badgeLabel: '首充双倍',
|
|
||||||
description: '首充送60光点',
|
|
||||||
tier: 'normal',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
membershipProducts: [
|
|
||||||
{
|
|
||||||
productId: 'member_month',
|
|
||||||
title: '月卡',
|
|
||||||
priceCents: 2800,
|
|
||||||
kind: 'membership',
|
|
||||||
pointsAmount: 0,
|
|
||||||
bonusPoints: 0,
|
|
||||||
durationDays: 30,
|
|
||||||
badgeLabel: '',
|
|
||||||
description: '30天会员',
|
|
||||||
tier: 'month',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
benefits: [
|
|
||||||
{
|
|
||||||
benefitName: '免光点回合数',
|
|
||||||
normalValue: '30',
|
|
||||||
monthValue: '100',
|
|
||||||
seasonValue: '100',
|
|
||||||
yearValue: '100',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
latestOrder: null,
|
|
||||||
hasPointsRecharged: false,
|
|
||||||
})),
|
|
||||||
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
|
||||||
order: {
|
|
||||||
orderId: 'order-1',
|
|
||||||
productId: 'points_60',
|
|
||||||
productTitle: '60光点',
|
|
||||||
kind: 'points',
|
|
||||||
amountCents: 600,
|
|
||||||
status: 'paid',
|
|
||||||
paymentChannel: 'mock',
|
|
||||||
paidAt: '2026-04-25T10:00:00Z',
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../ResolvedAssetImage', () => ({
|
vi.mock('../ResolvedAssetImage', () => ({
|
||||||
@@ -906,6 +911,106 @@ 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 () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRechargeSuccess = vi.fn();
|
||||||
|
|
||||||
|
renderProfileView(onRechargeSuccess);
|
||||||
|
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||||
|
await user.click(
|
||||||
|
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||||
|
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
|
||||||
|
await user.click(screen.getByRole('button', { name: /60光点/u }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||||
|
'points_60',
|
||||||
|
'mock',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('已到账')).toBeTruthy();
|
||||||
|
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||||
|
const navigateTo = vi.fn((options: { url: string }) => {
|
||||||
|
const url = new URL(`https://mini.test${options.url}`);
|
||||||
|
const requestId = url.searchParams.get('requestId');
|
||||||
|
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
|
});
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||||
|
order: {
|
||||||
|
orderId: 'order-wechat-1',
|
||||||
|
productId: 'points_60',
|
||||||
|
productTitle: '60光点',
|
||||||
|
kind: 'points',
|
||||||
|
amountCents: 600,
|
||||||
|
status: 'pending' as const,
|
||||||
|
paymentChannel: 'wechat_mp',
|
||||||
|
paidAt: null as string | null,
|
||||||
|
providerTransactionId: null,
|
||||||
|
createdAt: '2026-04-25T10:00:00Z',
|
||||||
|
pointsDelta: 0,
|
||||||
|
membershipExpiresAt: null,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
},
|
||||||
|
wechatMiniProgramPayParams: {
|
||||||
|
timeStamp: '1777110165',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=wx-prepay',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'signature',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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_mp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(navigateTo).toHaveBeenCalledWith({
|
||||||
|
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||||
|
expect(navigateUrl).toContain('order-wechat-1');
|
||||||
|
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
|
||||||
|
expect(await screen.findByText('支付已提交')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
@@ -1136,22 +1241,29 @@ test('profile page shows legal entries and ICP record link', async () => {
|
|||||||
expect(
|
expect(
|
||||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u }))
|
expect(
|
||||||
.toBeTruthy();
|
within(shortcutRegion).getByRole('button', { name: /每日任务/u }),
|
||||||
expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u }))
|
).toBeTruthy();
|
||||||
.toBeTruthy();
|
expect(
|
||||||
expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u }))
|
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||||
.toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(within(shortcutRegion).getByRole('button', { name: /反馈/u }))
|
expect(
|
||||||
.toBeTruthy();
|
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||||
expect(within(legalRegion).getByRole('button', { name: /用户协议/u }))
|
expect(
|
||||||
.toBeTruthy();
|
within(legalRegion).getByRole('button', { name: /用户协议/u }),
|
||||||
expect(within(legalRegion).getByRole('button', { name: /隐私政策/u }))
|
).toBeTruthy();
|
||||||
.toBeTruthy();
|
expect(
|
||||||
expect(within(legalRegion).getByRole('button', { name: /免责声明/u }))
|
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||||
.toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(legalRegion).getByRole('button', { name: /免责声明/u }),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
const recordLink = within(legalRegion).getByRole('link', {
|
const recordLink = within(legalRegion).getByRole('link', {
|
||||||
name: ICP_RECORD_NUMBER,
|
name: ICP_RECORD_NUMBER,
|
||||||
@@ -1160,7 +1272,9 @@ test('profile page shows legal entries and ICP record link', async () => {
|
|||||||
expect(recordLink.getAttribute('target')).toBe('_blank');
|
expect(recordLink.getAttribute('target')).toBe('_blank');
|
||||||
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
||||||
|
|
||||||
await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u }));
|
await user.click(
|
||||||
|
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||||
|
);
|
||||||
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1423,7 +1537,8 @@ test('mobile discover keeps baby object match works in edutainment channel only'
|
|||||||
await user.click(babyObjectMatchButton);
|
await user.click(babyObjectMatchButton);
|
||||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
const searchInput =
|
||||||
|
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||||
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
||||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ import type {
|
|||||||
ProfilePlayedWorkSummary,
|
ProfilePlayedWorkSummary,
|
||||||
ProfilePlayStatsResponse,
|
ProfilePlayStatsResponse,
|
||||||
ProfileReferralInviteCenterResponse,
|
ProfileReferralInviteCenterResponse,
|
||||||
|
ProfileRechargeCenterResponse,
|
||||||
|
ProfileRechargeProduct,
|
||||||
|
WechatMiniProgramPayParams,
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
ProfileTaskCenterResponse,
|
ProfileTaskCenterResponse,
|
||||||
ProfileTaskItem,
|
ProfileTaskItem,
|
||||||
@@ -67,7 +70,9 @@ import {
|
|||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import {
|
import {
|
||||||
claimRpgProfileTaskReward,
|
claimRpgProfileTaskReward,
|
||||||
|
createRpgProfileRechargeOrder,
|
||||||
getRpgProfileReferralInviteCenter,
|
getRpgProfileReferralInviteCenter,
|
||||||
|
getRpgProfileRechargeCenter,
|
||||||
getRpgProfileTasks,
|
getRpgProfileTasks,
|
||||||
getRpgProfileWalletLedger,
|
getRpgProfileWalletLedger,
|
||||||
redeemRpgProfileReferralInviteCode,
|
redeemRpgProfileReferralInviteCode,
|
||||||
@@ -199,8 +204,11 @@ 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';
|
||||||
|
|
||||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||||
|
type RechargeTab = 'points' | 'membership';
|
||||||
|
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||||
type DiscoverChannel =
|
type DiscoverChannel =
|
||||||
| 'recommend'
|
| 'recommend'
|
||||||
| 'today'
|
| 'today'
|
||||||
@@ -2141,7 +2149,9 @@ function ProfileLegalSection({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenDocument(document.id)}
|
onClick={() => onOpenDocument(document.id)}
|
||||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
||||||
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
|
index > 0
|
||||||
|
? 'border-t border-[var(--platform-subpanel-border)]'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
@@ -2484,6 +2494,254 @@ function formatWalletLedgerAmount(amountDelta: number) {
|
|||||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRechargePrice(priceCents: number) {
|
||||||
|
const yuan = priceCents / 100;
|
||||||
|
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWechatMiniProgramWebView() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return (
|
||||||
|
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||||
|
params.get('clientType') === 'mini_program'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWechatPayResultHash() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHash = window.location.hash.replace(/^#/, '');
|
||||||
|
if (!rawHash.includes('wx_pay_result=')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(rawHash);
|
||||||
|
params.delete('wx_pay_result');
|
||||||
|
const nextHash = params.toString();
|
||||||
|
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`;
|
||||||
|
window.history.replaceState(null, '', nextUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWechatMiniProgramPayment(
|
||||||
|
payload: WechatMiniProgramPayParams | null | undefined,
|
||||||
|
orderId: string,
|
||||||
|
) {
|
||||||
|
const miniProgram = window.wx?.miniProgram;
|
||||||
|
if (
|
||||||
|
!payload ||
|
||||||
|
!miniProgram ||
|
||||||
|
typeof miniProgram.navigateTo !== 'function'
|
||||||
|
) {
|
||||||
|
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||||
|
}
|
||||||
|
const navigateTo = miniProgram.navigateTo;
|
||||||
|
|
||||||
|
return new Promise<WechatMiniProgramPaymentStatus>((resolve) => {
|
||||||
|
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
window.location.hash.replace(/^#/, ''),
|
||||||
|
);
|
||||||
|
const result = params.get('wx_pay_result') ?? '';
|
||||||
|
const [resultRequestId, status] = result.split(':');
|
||||||
|
if (resultRequestId !== requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('hashchange', handleHashChange);
|
||||||
|
resolve(
|
||||||
|
status === 'success'
|
||||||
|
? 'success'
|
||||||
|
: status === 'cancel'
|
||||||
|
? 'cancel'
|
||||||
|
: 'fail',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
|
navigateTo({
|
||||||
|
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
||||||
|
fail(error) {
|
||||||
|
window.removeEventListener('hashchange', handleHashChange);
|
||||||
|
console.error('[wechat-pay] navigateTo failed', error);
|
||||||
|
resolve('fail');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function RechargeProductCard({
|
||||||
|
product,
|
||||||
|
submittingProductId,
|
||||||
|
onBuy,
|
||||||
|
}: {
|
||||||
|
product: ProfileRechargeProduct;
|
||||||
|
submittingProductId: string | null;
|
||||||
|
onBuy: (product: ProfileRechargeProduct) => void;
|
||||||
|
}) {
|
||||||
|
const submitting = submittingProductId === product.productId;
|
||||||
|
const value =
|
||||||
|
product.kind === 'points'
|
||||||
|
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}光点`
|
||||||
|
: `${product.durationDays}天`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBuy(product)}
|
||||||
|
disabled={Boolean(submittingProductId)}
|
||||||
|
className="platform-subpanel platform-interactive-card relative min-h-[7.25rem] rounded-[1.15rem] px-3.5 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{product.badgeLabel ? (
|
||||||
|
<span className="platform-pill platform-pill--warm absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 text-[10px]">
|
||||||
|
{product.badgeLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
{product.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
|
||||||
|
{formatRechargePrice(product.priceCents)}
|
||||||
|
</span>
|
||||||
|
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
||||||
|
{submitting ? '处理中' : '购买'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileRechargeModal({
|
||||||
|
center,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
submittingProductId,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
onBuy,
|
||||||
|
}: {
|
||||||
|
center: ProfileRechargeCenterResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
success: string | null;
|
||||||
|
submittingProductId: string | null;
|
||||||
|
activeTab: RechargeTab;
|
||||||
|
onTabChange: (tab: RechargeTab) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onBuy: (product: ProfileRechargeProduct) => void;
|
||||||
|
}) {
|
||||||
|
const products =
|
||||||
|
activeTab === 'points'
|
||||||
|
? (center?.pointProducts ?? [])
|
||||||
|
: (center?.membershipProducts ?? []);
|
||||||
|
const memberLabel =
|
||||||
|
center?.membership.status === 'active'
|
||||||
|
? center.membership.expiresAt
|
||||||
|
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
|
||||||
|
: '会员已生效'
|
||||||
|
: '普通用户';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||||
|
<div className="platform-recharge-modal w-full max-w-[34rem] overflow-hidden rounded-[1.4rem]">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black">账户充值</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
{center
|
||||||
|
? `${center.walletBalance}光点 · ${memberLabel}`
|
||||||
|
: '读取中'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭账户充值"
|
||||||
|
onClick={onClose}
|
||||||
|
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange('points')}
|
||||||
|
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
|
||||||
|
>
|
||||||
|
光点充值
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange('membership')}
|
||||||
|
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
|
||||||
|
>
|
||||||
|
会员卡
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="platform-profile-error mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||||
|
<div>{error}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : products.length > 0 ? (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{products.map((product) => (
|
||||||
|
<RechargeProductCard
|
||||||
|
key={product.productId}
|
||||||
|
product={product}
|
||||||
|
submittingProductId={submittingProductId}
|
||||||
|
onBuy={onBuy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
暂无可购买套餐
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WalletLedgerModal({
|
function WalletLedgerModal({
|
||||||
ledger,
|
ledger,
|
||||||
fallbackBalance,
|
fallbackBalance,
|
||||||
@@ -3184,6 +3442,16 @@ export function RpgEntryHomeView({
|
|||||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||||
|
const [rechargeCenter, setRechargeCenter] =
|
||||||
|
useState<ProfileRechargeCenterResponse | null>(null);
|
||||||
|
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
||||||
|
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||||
|
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
|
||||||
|
const [activeRechargeTab, setActiveRechargeTab] =
|
||||||
|
useState<RechargeTab>('points');
|
||||||
|
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||||
|
useState<string | null>(null);
|
||||||
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
||||||
const [walletLedger, setWalletLedger] =
|
const [walletLedger, setWalletLedger] =
|
||||||
useState<ProfileWalletLedgerResponse | null>(null);
|
useState<ProfileWalletLedgerResponse | null>(null);
|
||||||
@@ -3725,6 +3993,100 @@ export function RpgEntryHomeView({
|
|||||||
setIsWalletLedgerOpen(true);
|
setIsWalletLedgerOpen(true);
|
||||||
loadWalletLedger();
|
loadWalletLedger();
|
||||||
};
|
};
|
||||||
|
const loadRechargeCenter = () => {
|
||||||
|
setRechargeError(null);
|
||||||
|
setIsLoadingRechargeCenter(true);
|
||||||
|
void getRpgProfileRechargeCenter()
|
||||||
|
.then(setRechargeCenter)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setRechargeCenter(null);
|
||||||
|
setRechargeError(
|
||||||
|
error instanceof Error ? error.message : '读取账户充值失败',
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoadingRechargeCenter(false));
|
||||||
|
};
|
||||||
|
const openRechargeModal = () => {
|
||||||
|
if (!authUi?.user) {
|
||||||
|
authUi?.openLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRechargeOpen(true);
|
||||||
|
setRechargeSuccess(null);
|
||||||
|
loadRechargeCenter();
|
||||||
|
};
|
||||||
|
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||||
|
if (submittingRechargeProductId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentChannel = isWechatMiniProgramWebView()
|
||||||
|
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||||
|
: 'mock';
|
||||||
|
setSubmittingRechargeProductId(product.productId);
|
||||||
|
setRechargeError(null);
|
||||||
|
setRechargeSuccess(null);
|
||||||
|
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
||||||
|
const status = await requestWechatMiniProgramPayment(
|
||||||
|
response.wechatMiniProgramPayParams,
|
||||||
|
response.order.orderId,
|
||||||
|
);
|
||||||
|
if (status === 'cancel') {
|
||||||
|
setRechargeCenter(response.center);
|
||||||
|
setRechargeSuccess('支付已取消');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status !== 'success') {
|
||||||
|
throw new Error('微信支付未完成');
|
||||||
|
}
|
||||||
|
setRechargeSuccess('支付已提交');
|
||||||
|
loadRechargeCenter();
|
||||||
|
} else {
|
||||||
|
setRechargeCenter(response.center);
|
||||||
|
setRechargeSuccess('已到账');
|
||||||
|
}
|
||||||
|
void onRechargeSuccess?.();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||||
|
})
|
||||||
|
.finally(() => setSubmittingRechargeProductId(null));
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRechargeOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWechatPayResult = () => {
|
||||||
|
const result = new URLSearchParams(
|
||||||
|
window.location.hash.replace(/^#/, ''),
|
||||||
|
).get('wx_pay_result');
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [, status] = result.split(':');
|
||||||
|
if (status === 'success') {
|
||||||
|
setRechargeSuccess('支付已提交');
|
||||||
|
loadRechargeCenter();
|
||||||
|
void onRechargeSuccess?.();
|
||||||
|
clearWechatPayResultHash();
|
||||||
|
} else if (status === 'cancel') {
|
||||||
|
setRechargeSuccess('支付已取消');
|
||||||
|
clearWechatPayResultHash();
|
||||||
|
} else {
|
||||||
|
setRechargeError('微信支付未完成');
|
||||||
|
clearWechatPayResultHash();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handleWechatPayResult);
|
||||||
|
handleWechatPayResult();
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('hashchange', handleWechatPayResult);
|
||||||
|
}, [isRechargeOpen, onRechargeSuccess]);
|
||||||
const loadTaskCenter = () => {
|
const loadTaskCenter = () => {
|
||||||
setTaskCenterError(null);
|
setTaskCenterError(null);
|
||||||
setIsLoadingTaskCenter(true);
|
setIsLoadingTaskCenter(true);
|
||||||
@@ -4919,13 +5281,13 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openRewardCodeModal}
|
onClick={openRechargeModal}
|
||||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||||
>
|
>
|
||||||
<Ticket className="h-4 w-4" />
|
<Coins className="h-4 w-4" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-bold">兑换码</div>
|
<div className="text-xs font-bold">充值</div>
|
||||||
<div className="text-[10px] opacity-80">光点</div>
|
<div className="text-[10px] opacity-80">光点/会员</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||||
</button>
|
</button>
|
||||||
@@ -5013,6 +5375,18 @@ export function RpgEntryHomeView({
|
|||||||
icon={Star}
|
icon={Star}
|
||||||
onClick={openTaskCenterPanel}
|
onClick={openTaskCenterPanel}
|
||||||
/>
|
/>
|
||||||
|
<ProfileShortcutButton
|
||||||
|
label="充值"
|
||||||
|
subLabel="光点/会员"
|
||||||
|
icon={Coins}
|
||||||
|
onClick={openRechargeModal}
|
||||||
|
/>
|
||||||
|
<ProfileShortcutButton
|
||||||
|
label="兑换码"
|
||||||
|
subLabel="福利奖励"
|
||||||
|
icon={Ticket}
|
||||||
|
onClick={openRewardCodeModal}
|
||||||
|
/>
|
||||||
<ProfileShortcutButton
|
<ProfileShortcutButton
|
||||||
label="邀请好友"
|
label="邀请好友"
|
||||||
subLabel={
|
subLabel={
|
||||||
@@ -5455,6 +5829,20 @@ export function RpgEntryHomeView({
|
|||||||
onClose={() => setIsRewardCodeOpen(false)}
|
onClose={() => setIsRewardCodeOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
const rechargeModal: ReactNode = isRechargeOpen ? (
|
||||||
|
<ProfileRechargeModal
|
||||||
|
center={rechargeCenter}
|
||||||
|
isLoading={isLoadingRechargeCenter}
|
||||||
|
error={rechargeError}
|
||||||
|
success={rechargeSuccess}
|
||||||
|
submittingProductId={submittingRechargeProductId}
|
||||||
|
activeTab={activeRechargeTab}
|
||||||
|
onTabChange={setActiveRechargeTab}
|
||||||
|
onClose={() => setIsRechargeOpen(false)}
|
||||||
|
onRetry={loadRechargeCenter}
|
||||||
|
onBuy={buyRechargeProduct}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!isDesktopLayout) {
|
if (!isDesktopLayout) {
|
||||||
const isMobileRecommendTab = activeTab === 'home';
|
const isMobileRecommendTab = activeTab === 'home';
|
||||||
@@ -5537,6 +5925,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{rewardCodeModal}
|
{rewardCodeModal}
|
||||||
|
{rechargeModal}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<ProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
@@ -5667,6 +6056,7 @@ export function RpgEntryHomeView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rewardCodeModal}
|
{rewardCodeModal}
|
||||||
|
{rechargeModal}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<ProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
|
|||||||
@@ -538,8 +538,9 @@ describe('authService', () => {
|
|||||||
const sessions = await getAuthSessions();
|
const sessions = await getAuthSessions();
|
||||||
|
|
||||||
expect(sessions).toHaveLength(1);
|
expect(sessions).toHaveLength(1);
|
||||||
expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']);
|
const [session] = sessions;
|
||||||
expect(sessions[0].sessionCount).toBe(2);
|
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
|
||||||
|
expect(session?.sessionCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('revokes a single auth session by backend route', async () => {
|
it('revokes a single auth session by backend route', async () => {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function getRpgProfileRechargeCenter(
|
|||||||
|
|
||||||
export function createRpgProfileRechargeOrder(
|
export function createRpgProfileRechargeOrder(
|
||||||
productId: string,
|
productId: string,
|
||||||
|
paymentChannel = 'mock',
|
||||||
options: RuntimeRequestOptions = {},
|
options: RuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
||||||
@@ -97,7 +98,7 @@ export function createRpgProfileRechargeOrder(
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
|
body: JSON.stringify({ productId, paymentChannel }),
|
||||||
},
|
},
|
||||||
'充值失败',
|
'充值失败',
|
||||||
options,
|
options,
|
||||||
|
|||||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -3,3 +3,15 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_DEBUG_MODE?: string;
|
readonly VITE_DEBUG_MODE?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
wx?: {
|
||||||
|
miniProgram?: {
|
||||||
|
navigateTo?: (options: {
|
||||||
|
url: string;
|
||||||
|
fail?: (error: { errMsg?: string }) => void;
|
||||||
|
}) => void;
|
||||||
|
postMessage?: (message: unknown) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user