From 5b70ec6af7b3a32b62fd05002f8ed977d8ab2582 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 06:40:40 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1H5=E4=B8=8ENative=E5=85=85=E5=80=BC=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 54 ++- package-lock.json | 450 +++++++++++++++++- package.json | 2 + packages/shared/src/contracts/runtime.ts | 17 +- .../crates/api-server/src/runtime_profile.rs | 302 +++++++++++- server-rs/crates/api-server/src/wechat_pay.rs | 430 ++++++++++++++++- .../crates/module-runtime/src/commands.rs | 2 +- server-rs/crates/module-runtime/src/domain.rs | 2 + server-rs/crates/module-runtime/src/errors.rs | 2 + server-rs/crates/module-runtime/src/lib.rs | 13 + .../crates/shared-contracts/src/runtime.rs | 70 +++ .../RpgEntryHomeView.recharge.test.tsx | 289 +++++++++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 210 ++++++-- src/services/payment/paymentPlatform.test.ts | 63 +++ src/services/payment/paymentPlatform.ts | 76 +++ src/services/payment/paymentRedirect.ts | 3 + src/services/rpg-entry/rpgProfileClient.ts | 19 +- 18 files changed, 1890 insertions(+), 122 deletions(-) create mode 100644 src/services/payment/paymentPlatform.test.ts create mode 100644 src/services/payment/paymentPlatform.ts create mode 100644 src/services/payment/paymentRedirect.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3d5493df..7b276d70 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -64,6 +64,14 @@ - 验证方式:执行 `npm run typecheck`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`,后端联调仍用 `npm run api-server` 和 `/healthz`。 - 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 +## 2026-05-15 微信充值默认真实渠道与 mock 禁用 + +- 背景:账户充值扩展到普通商户直连 H5 与 Native 支付后,旧的“非小程序默认 mock”会把真实用户充值静默导向测试通道。 +- 决策:默认支付渠道只由设备平台隔离层解析为 `wechat_mp` / `wechat_h5` / `wechat_native`:小程序走 `wechat_mp`,移动网页含微信内 H5 走 `wechat_h5`,桌面网页走 `wechat_native` 二维码。`paymentChannel` 缺失或未知直接 `400`;生产/真实支付配置拒绝 `mock`,只有自动测试或显式 mock 测试配置可手动传 `paymentChannel = "mock"`。真实微信渠道必须在 `WECHAT_PAY_ENABLED=true` 且 `WECHAT_PAY_PROVIDER=real` 下单,禁止由 mock provider 返回 H5/Native/小程序 mock 支付载荷。所有微信渠道仍以后端通知与服务端查单入账。 +- 影响范围:`src/services/payment/paymentPlatform.ts`、`RpgEntryHomeView` 充值弹窗、`api-server` 充值订单接口、`WechatPayClient` H5/Native 下单、共享 recharge contracts。 +- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 2026-05-13 修改密码后全设备强制下线 - 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 7d16625d..83ed3fab 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -9,7 +9,7 @@ 1. `泥点充值` 2. `会员卡充值` -前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。普通 H5 / 本地联调继续使用 `mock` 渠道:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。微信小程序 web-view 使用 `wechat_mp` 渠道:创建订单时只写入 `pending` 订单并返回小程序 `wx.requestPayment` 参数,真实到账以后端微信支付通知为准。 +前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,移动网页使用 `wechat_h5`,桌面网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。 ## 2. 产品规则 @@ -56,22 +56,25 @@ ```json { "productId": "points_300", - "paymentChannel": "mock" + "paymentChannel": "wechat_h5" } ``` 行为: 1. 校验 `productId` -2. `paymentChannel = "mock"` 时后端创建已支付订单 -3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 +2. 缺少 `paymentChannel` 或传入未知渠道时直接返回 `400`,不得再默认解释为 `mock` +3. 生产/真实支付配置拒绝 `paymentChannel = "mock"`;mock 只服务自动测试或显式 mock 测试配置 + - `wechat_mp` / `wechat_h5` / `wechat_native` 必须在 `WECHAT_PAY_ENABLED=true` 且 `WECHAT_PAY_PROVIDER=real` 时才允许下单;`WECHAT_PAY_PROVIDER=mock` 不能为真实微信渠道返回 mock 支付载荷。 +4. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 - JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 -4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 -5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` +5. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址 +6. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码 +7. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷 兼容路径:`POST /api/runtime/profile/recharge/orders` -响应里的 `wechatMiniProgramPayParams` 只在微信小程序支付渠道返回,字段直接对应 `wx.requestPayment`: +响应里的支付字段只在对应微信支付渠道返回。`wechatMiniProgramPayParams` 字段直接对应 `wx.requestPayment`: ```json { @@ -85,11 +88,24 @@ } ``` +H5 与 Native 响应: + +```json +{ + "wechatH5Payment": { + "h5Url": "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=..." + }, + "wechatNativePayment": { + "codeUrl": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=..." + } +} +``` + ### 3.3 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` -需要 Bearer JWT。该接口用于小程序支付页返回 web-view 后的主动查单确认,不替代微信支付通知: +需要 Bearer JWT。该接口用于微信支付返回页面或 Native 扫码后的主动查单确认,不替代微信支付通知: -1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态。 +1. 后端读取本地 `profile_recharge_order` 并校验订单归属、支付渠道和当前状态;`wechat_mp` / `wechat_h5` / `wechat_native` 都可确认,`mock` 不能走该接口。 2. 若订单已是 `paid`,直接返回订单与账户中心快照。 3. 若订单仍是 `pending`,后端调用微信支付按商户订单号查单接口。 4. 只有微信查单返回 `trade_state = "SUCCESS"` 时,才调用统一入账 procedure 把订单改为 `paid` 并写入钱包流水或会员状态。 @@ -127,7 +143,7 @@ | 变量 | 说明 | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `WECHAT_PAY_ENABLED` | 是否启用微信支付客户端 | -| `WECHAT_PAY_PROVIDER` | `mock` 或 `real` | +| `WECHAT_PAY_PROVIDER` | `mock` 或 `real`;真实微信渠道只能使用 `real`,`mock` 只允许显式测试渠道 | | `WECHAT_PAY_MCH_ID` | 微信支付商户号 | | `WECHAT_PAY_MERCHANT_SERIAL_NO` | 商户 API 证书序列号,用于请求微信支付签名头 | | `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PRIVATE_KEY_PATH` | 商户 API 私钥 | @@ -142,19 +158,23 @@ 2. 弹窗顶部标题为 `账户充值`,右上角关闭。 3. 默认打开 `泥点充值`,可切换到 `会员卡充值`。 4. 点击套餐后调用下单接口,按钮进入处理中状态;小程序环境走 native 支付页拉起 `wx.requestPayment`,支付页返回后刷新 `profileDashboard`。 + - 支付渠道由 `src/services/payment/paymentPlatform.ts` 统一判断,业务 UI 不内联区分小程序、手机网页和桌面网页。 - 小程序 web-view 内的 H5 只负责加载微信 JS-SDK 并通过 `wx.miniProgram.navigateTo` 跳转到 `/pages/wechat-pay/index`;实际支付必须在小程序 native 页调用 `wx.requestPayment`,不要切换为 H5 支付产品。 - native 支付页通过 `wx_pay_result=:success|cancel|fail` 回填 web-view;H5 在 `hashchange`、`focus`、`pageshow` 和 `visibilitychange` 中都会尝试消费该结果,避免小程序返回 web-view 时没有触发单一事件导致状态不刷新。 - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 -5. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 -6. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 -7. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 +5. 移动网页含微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序。 +6. 桌面网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额。 +7. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 +8. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 +9. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 ## 5. 验收 1. 普通用户打开弹窗能看到泥点与会员套餐。 -2. 泥点购买后余额增加,流水来源为 `points_recharge`。 -3. 首充赠送只在首次泥点充值时生效。 -4. 会员购买后会员状态与到期时间立即更新。 -5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 +2. 默认平台通道只会解析到 `wechat_mp` / `wechat_h5` / `wechat_native`,不会解析到 `mock`。 +3. 泥点购买后余额增加,流水来源为 `points_recharge`。 +4. 首充赠送只在首次泥点充值时生效。 +5. 会员购买后会员状态与到期时间立即更新。 +6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 diff --git a/package-lock.json b/package-lock.json index aa3187a3..9008c606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^17.2.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "three": "^0.184.0", @@ -23,6 +24,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", @@ -1679,6 +1681,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2174,7 +2186,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2183,7 +2194,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2373,6 +2383,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -2444,11 +2463,21 @@ "node": "*" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2459,8 +2488,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2553,6 +2581,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2614,6 +2651,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2688,6 +2731,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -3262,6 +3311,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -3558,6 +3616,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4276,6 +4343,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4304,7 +4380,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -4384,6 +4459,15 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -4488,6 +4572,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -4547,6 +4648,21 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -4700,6 +4816,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4756,11 +4878,24 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -6674,6 +6809,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6699,6 +6840,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6741,11 +6896,104 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7716,6 +7964,15 @@ "undici-types": "~6.21.0" } }, + "@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8048,14 +8305,12 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -8171,6 +8426,11 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, "caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -8215,11 +8475,20 @@ "get-func-name": "^2.0.2" } }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "requires": { "color-name": "~1.1.4" } @@ -8227,8 +8496,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "combined-stream": { "version": "1.0.8", @@ -8301,6 +8569,11 @@ "ms": "^2.1.3" } }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + }, "decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -8346,6 +8619,11 @@ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true }, + "dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -8401,6 +8679,11 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -8800,6 +9083,11 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -9010,6 +9298,11 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9454,6 +9747,11 @@ "p-limit": "^3.0.2" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9475,8 +9773,7 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, "path-is-absolute": { "version": "1.0.1", @@ -9537,6 +9834,11 @@ } } }, + "pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" + }, "postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -9599,6 +9901,16 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "requires": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + } + }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -9635,6 +9947,16 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -9742,6 +10064,11 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9786,11 +10113,20 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, "strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -10724,6 +11060,11 @@ "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10740,6 +11081,16 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10765,11 +11116,78 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0f77d5f0..336544a3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dotenv": "^17.2.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "three": "^0.184.0", @@ -63,6 +64,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 97a82ada..784728f0 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -147,6 +147,14 @@ export type WechatMiniProgramPayParams = { paySign: string; }; +export type WechatH5Payment = { + h5Url: string; +}; + +export type WechatNativePayment = { + codeUrl: string; +}; + export type CreateProfileRechargeOrderRequest = { productId: string; paymentChannel?: string; @@ -156,6 +164,8 @@ export type CreateProfileRechargeOrderResponse = { order: ProfileRechargeOrder; center: ProfileRechargeCenterResponse; wechatMiniProgramPayParams?: WechatMiniProgramPayParams | null; + wechatH5Payment?: WechatH5Payment | null; + wechatNativePayment?: WechatNativePayment | null; }; export type ConfirmWechatProfileRechargeOrderResponse = { @@ -242,7 +252,12 @@ export type RedeemProfileRewardCodeResponse = { export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; -export type AnalyticsGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; +export type AnalyticsGranularity = + | 'day' + | 'week' + | 'month' + | 'quarter' + | 'year'; export type ProfileTaskStatus = | 'incomplete' | 'claimable' diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 987a8cf1..a0ed8af8 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1,12 +1,14 @@ use axum::{ Json, extract::{Extension, Path, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::Response, }; use module_runtime::{ AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, @@ -64,8 +66,8 @@ use crate::{ request_context::RequestContext, state::AppState, wechat_pay::{ - WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros, - map_wechat_pay_error, + WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request, + current_unix_micros, map_wechat_pay_error, }, }; @@ -189,13 +191,14 @@ pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); - let payment_channel = payload - .payment_channel - .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); - let payment_channel = payment_channel.trim().to_string(); + let payment_channel = normalize_recharge_payment_channel(payload.payment_channel) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + validate_recharge_payment_channel(&state, &payment_channel) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; let created_at_micros = current_unix_micros(); let (center, order) = state .spacetime_client() @@ -236,6 +239,43 @@ pub async fn create_profile_recharge_order( } else { None }; + let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 { + Some( + state + .wechat_pay_client() + .create_h5_order(build_wechat_web_payment_request( + order.order_id.clone(), + order.product_title.clone(), + order.amount_cents, + resolve_wechat_pay_client_ip(&headers), + )) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?, + ) + } else { + None + }; + let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + { + Some( + state + .wechat_pay_client() + .create_native_order(build_wechat_web_payment_request( + order.order_id.clone(), + order.product_title.clone(), + order.amount_cents, + resolve_wechat_pay_client_ip(&headers), + )) + .await + .map_err(|error| { + runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) + })?, + ) + } else { + None + }; Ok(json_success_body( Some(&request_context), @@ -243,6 +283,8 @@ pub async fn create_profile_recharge_order( order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), wechat_mini_program_pay_params, + wechat_h5_payment, + wechat_native_payment, }, )) } @@ -271,11 +313,11 @@ pub async fn confirm_wechat_profile_recharge_order( AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), )); } - if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM { + if !is_wechat_recharge_payment_channel(&order.payment_channel) { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("该充值订单不是微信小程序支付订单"), + .with_message("该充值订单不是微信支付订单"), )); } if order.status == RuntimeProfileRechargeOrderStatus::Paid { @@ -885,6 +927,93 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr error.into_response_with_context(Some(request_context)) } +fn normalize_recharge_payment_channel(raw: Option) -> Result { + raw.map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空") + }) +} + +fn validate_recharge_payment_channel( + state: &AppState, + payment_channel: &str, +) -> Result<(), AppError> { + if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { + if is_recharge_mock_channel_allowed(state) { + return Ok(()); + } + + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("生产充值不允许使用 mock 支付渠道")); + } + + if is_wechat_recharge_payment_channel(payment_channel) { + validate_real_wechat_recharge_payment_provider(state)?; + return Ok(()); + } + + Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效")) +} + +fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> { + if !state.config.wechat_pay_enabled { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("微信支付真实渠道暂未启用")); + } + + if state + .config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case("real") + { + return Ok(()); + } + + Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("真实微信支付渠道不能使用 mock 支付配置")) +} + +fn is_recharge_mock_channel_allowed(state: &AppState) -> bool { + if cfg!(test) { + return state + .config + .wechat_pay_provider + .trim() + .eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK); + } + + false +} + +fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { + matches!( + payment_channel, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + ) +} + +fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String { + headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .unwrap_or("127.0.0.1") + .to_string() +} + async fn resolve_wechat_identity_for_payment( state: &AppState, user_id: &str, @@ -1503,6 +1632,153 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(r#"{"productId":"points_60"}"#)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!(payload["error"]["message"], "充值支付渠道不能为空"); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_unknown_payment_channel_before_spacetime() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"card"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!(payload["error"]["message"], "充值支付渠道无效"); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_mock_when_pay_provider_is_real() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_provider: "real".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"mock"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "生产充值不允许使用 mock 支付渠道" + ); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "真实微信支付渠道不能使用 mock 支付配置" + ); + } + #[tokio::test] async fn profile_feedback_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1720,7 +1996,11 @@ mod tests { } async fn seed_authenticated_state() -> AppState { - let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build"); + seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await + } + + async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState { + let state = AppState::new(config).expect("state should build"); state .seed_test_phone_user_with_password("13800138104", "secret123") .await diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index 63b15b8d..c3b38abd 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -14,7 +14,9 @@ use ring::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; -use shared_contracts::runtime::WechatMiniProgramPayParamsResponse; +use shared_contracts::runtime::{ + WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse, +}; use shared_kernel::offset_datetime_to_unix_micros; use time::OffsetDateTime; use tracing::{info, warn}; @@ -37,6 +39,10 @@ const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32; const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; +const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45; +const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi"; +const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5"; +const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native"; #[derive(Clone, Debug)] pub enum WechatPayClient { @@ -57,6 +63,8 @@ pub struct RealWechatPayClient { api_v3_key: String, notify_url: String, jsapi_endpoint: String, + h5_endpoint: String, + native_endpoint: String, query_order_endpoint_base: String, } @@ -68,6 +76,14 @@ pub struct WechatMiniProgramOrderRequest { pub payer_openid: String, } +#[derive(Clone, Debug)] +pub struct WechatWebOrderRequest { + pub order_id: String, + pub description: String, + pub amount_cents: u64, + pub payer_client_ip: String, +} + #[derive(Clone, Debug)] pub struct WechatPayNotifyOrder { pub out_trade_no: String, @@ -110,6 +126,45 @@ struct WechatJsapiPayer<'a> { openid: &'a str, } +#[derive(Serialize)] +struct WechatH5OrderRequest<'a> { + appid: &'a str, + mchid: &'a str, + description: &'a str, + out_trade_no: &'a str, + notify_url: &'a str, + amount: WechatJsapiAmount, + scene_info: WechatH5SceneInfo<'a>, +} + +#[derive(Serialize)] +struct WechatH5SceneInfo<'a> { + payer_client_ip: &'a str, + h5_info: WechatH5Info, +} + +#[derive(Serialize)] +struct WechatH5Info { + #[serde(rename = "type")] + kind: &'static str, +} + +#[derive(Serialize)] +struct WechatNativeOrderRequest<'a> { + appid: &'a str, + mchid: &'a str, + description: &'a str, + out_trade_no: &'a str, + notify_url: &'a str, + amount: WechatJsapiAmount, + scene_info: WechatNativeSceneInfo<'a>, +} + +#[derive(Serialize)] +struct WechatNativeSceneInfo<'a> { + payer_client_ip: &'a str, +} + #[derive(Deserialize)] struct WechatJsapiOrderResponse { prepay_id: Option, @@ -117,6 +172,20 @@ struct WechatJsapiOrderResponse { message: Option, } +#[derive(Deserialize)] +struct WechatH5OrderResponse { + h5_url: Option, + code: Option, + message: Option, +} + +#[derive(Deserialize)] +struct WechatNativeOrderResponse { + code_url: Option, + code: Option, + message: Option, +} + #[derive(Deserialize)] struct WechatPayNotifyBody { #[serde(default)] @@ -222,6 +291,10 @@ impl WechatPayClient { &config.wechat_pay_jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT", )?; + let h5_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?; + let native_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_NATIVE_PATH)?; let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?; Ok(Self::Real(Arc::new(RealWechatPayClient { @@ -235,6 +308,8 @@ impl WechatPayClient { api_v3_key, notify_url, jsapi_endpoint, + h5_endpoint, + native_endpoint, query_order_endpoint_base, }))) } @@ -250,6 +325,28 @@ impl WechatPayClient { } } + pub async fn create_h5_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_h5_payment(&request.order_id)), + Self::Real(client) => client.create_h5_order(request).await, + } + } + + pub async fn create_native_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_native_payment(&request.order_id)), + Self::Real(client) => client.create_native_order(request).await, + } + } + pub fn parse_notify( &self, headers: &HeaderMap, @@ -304,13 +401,8 @@ impl RealWechatPayClient { .map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?; let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); let nonce = create_nonce()?; - let authorization = self.build_authorization( - "POST", - "/v3/pay/transactions/jsapi", - ×tamp, - &nonce, - &body, - )?; + let authorization = + self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, ×tamp, &nonce, &body)?; let response = with_wechat_pay_jsapi_headers( self.client .post(&self.jsapi_endpoint) @@ -350,6 +442,147 @@ impl RealWechatPayClient { self.build_pay_params(&prepay_id) } + async fn create_h5_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + validate_web_order_request(self, &request)?; + let amount_total = i64::try_from(request.amount_cents) + .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; + let body = serde_json::to_string(&WechatH5OrderRequest { + appid: &self.app_id, + mchid: &self.mch_id, + description: &request.description, + out_trade_no: &request.order_id, + notify_url: &self.notify_url, + amount: WechatJsapiAmount { + total: amount_total, + currency: "CNY", + }, + scene_info: WechatH5SceneInfo { + payer_client_ip: &request.payer_client_ip, + h5_info: WechatH5Info { kind: "Wap" }, + }, + }) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 H5 请求序列化失败:{error}")) + })?; + let response_text = self + .post_wechat_json( + &self.h5_endpoint, + WECHAT_PAY_H5_PATH, + body, + "微信支付 H5 下单请求失败", + ) + .await?; + let payload = + serde_json::from_str::(&response_text).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 H5 下单响应解析失败:{error}")) + })?; + let h5_url = payload + .h5_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::Upstream( + payload + .message + .or(payload.code) + .unwrap_or_else(|| "微信支付未返回 h5_url".to_string()), + ) + })?; + + Ok(WechatH5PaymentResponse { h5_url }) + } + + async fn create_native_order( + &self, + request: WechatWebOrderRequest, + ) -> Result { + validate_web_order_request(self, &request)?; + let amount_total = i64::try_from(request.amount_cents) + .map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?; + let body = serde_json::to_string(&WechatNativeOrderRequest { + appid: &self.app_id, + mchid: &self.mch_id, + description: &request.description, + out_trade_no: &request.order_id, + notify_url: &self.notify_url, + amount: WechatJsapiAmount { + total: amount_total, + currency: "CNY", + }, + scene_info: WechatNativeSceneInfo { + payer_client_ip: &request.payer_client_ip, + }, + }) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 Native 请求序列化失败:{error}")) + })?; + let response_text = self + .post_wechat_json( + &self.native_endpoint, + WECHAT_PAY_NATIVE_PATH, + body, + "微信支付 Native 下单请求失败", + ) + .await?; + let payload = + serde_json::from_str::(&response_text).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付 Native 下单响应解析失败:{error}")) + })?; + let code_url = payload + .code_url + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::Upstream( + payload + .message + .or(payload.code) + .unwrap_or_else(|| "微信支付未返回 code_url".to_string()), + ) + })?; + + Ok(WechatNativePaymentResponse { code_url }) + } + + async fn post_wechat_json( + &self, + endpoint: &str, + canonical_path: &str, + body: String, + request_error_prefix: &str, + ) -> Result { + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = + self.build_authorization("POST", canonical_path, ×tamp, &nonce, &body)?; + let response = with_wechat_pay_json_headers( + self.client + .post(endpoint) + .header("Authorization", authorization), + &self.platform_serial_no, + ) + .body(body) + .send() + .await + .map_err(|error| { + WechatPayError::RequestFailed(format!("{request_error_prefix}:{error}")) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付响应读取失败:{error}")) + })?; + if !status.is_success() { + return Err(WechatPayError::Upstream(format!( + "微信支付下单失败:HTTP {status},{response_text}" + ))); + } + + Ok(response_text) + } + fn build_authorization( &self, method: &str, @@ -618,6 +851,20 @@ pub fn build_wechat_payment_request( } } +pub fn build_wechat_web_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_client_ip: String, +) -> WechatWebOrderRequest { + WechatWebOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_client_ip, + } +} + pub fn current_unix_micros() -> i64 { let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; i64::try_from(value).unwrap_or(i64::MAX) @@ -664,6 +911,24 @@ fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { } } +fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse { + WechatH5PaymentResponse { + h5_url: format!( + "https://mock.wechat-pay.local/h5?out_trade_no={}", + urlencoding::encode(order_id) + ), + } +} + +fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse { + WechatNativePaymentResponse { + code_url: format!( + "weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}", + hex_sha256(order_id.as_bytes()) + ), + } +} + fn parse_mock_notify(body: &[u8]) -> Result { let value = serde_json::from_slice::(body).map_err(|error| { WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) @@ -744,6 +1009,20 @@ fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result Result { + let url = Url::parse(jsapi_endpoint) + .map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?; + let origin = url + .origin() + .ascii_serialization() + .trim_end_matches('/') + .to_string(); + Ok(format!("{origin}{transaction_path}")) +} + fn normalize_out_trade_no(value: &str) -> Result { let value = value.trim(); validate_out_trade_no(value)?; @@ -794,6 +1073,49 @@ fn validate_jsapi_order_request( Ok(()) } +fn validate_web_order_request( + client: &RealWechatPayClient, + request: &WechatWebOrderRequest, +) -> Result<(), WechatPayError> { + validate_non_empty_max_chars( + &client.app_id, + WECHAT_PAY_APP_ID_MAX_CHARS, + "微信支付 appid", + )?; + if !client.app_id.starts_with("wx") { + return Err(WechatPayError::InvalidConfig( + "微信支付 appid 必须使用已绑定的微信 AppID".to_string(), + )); + } + validate_non_empty_max_chars( + &client.mch_id, + WECHAT_PAY_MCH_ID_MAX_CHARS, + "微信支付 mchid", + )?; + if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) { + return Err(WechatPayError::InvalidConfig( + "微信支付 mchid 必须是数字字符串".to_string(), + )); + } + validate_non_empty_max_chars( + &request.description, + WECHAT_PAY_DESCRIPTION_MAX_CHARS, + "微信支付商品描述", + )?; + validate_out_trade_no(&request.order_id)?; + if request.amount_cents == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付金额必须大于 0 分".to_string(), + )); + } + validate_non_empty_max_chars( + &request.payer_client_ip, + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "微信支付 payer_client_ip", + )?; + Ok(()) +} + fn validate_non_empty_max_chars( value: &str, max_chars: usize, @@ -1046,6 +1368,84 @@ mod tests { assert!(body.get("notifyUrl").is_none()); } + #[test] + fn h5_order_request_uses_wechat_required_scene_info() { + let body = serde_json::to_value(WechatH5OrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + scene_info: WechatH5SceneInfo { + payer_client_ip: "203.0.113.10", + h5_info: WechatH5Info { kind: "Wap" }, + }, + }) + .expect("H5 order request should serialize"); + + assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10"); + assert_eq!(body["scene_info"]["h5_info"]["type"], "Wap"); + assert_eq!(body["amount"]["currency"], "CNY"); + assert!(body.get("sceneInfo").is_none()); + assert!(body["scene_info"].get("payerClientIp").is_none()); + } + + #[test] + fn native_order_request_uses_code_url_response_shape() { + let body = serde_json::to_value(WechatNativeOrderRequest { + appid: "wx-test-app", + mchid: "1900000001", + description: "陶泥儿 - 60泥点", + out_trade_no: "rcgtest001", + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify", + amount: WechatJsapiAmount { + total: 600, + currency: "CNY", + }, + scene_info: WechatNativeSceneInfo { + payer_client_ip: "203.0.113.10", + }, + }) + .expect("Native order request should serialize"); + let response = serde_json::from_value::(json!({ + "code_url": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test" + })) + .expect("Native order response should deserialize"); + + assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10"); + assert_eq!( + response.code_url.as_deref(), + Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test") + ); + } + + #[test] + fn transaction_endpoints_reuse_configured_wechat_pay_origin() { + let h5_endpoint = resolve_wechat_pay_transaction_endpoint( + "https://pay-gateway.example.com/v3/pay/transactions/jsapi", + WECHAT_PAY_H5_PATH, + ) + .expect("H5 endpoint should resolve"); + let native_endpoint = resolve_wechat_pay_transaction_endpoint( + "https://pay-gateway.example.com/v3/pay/transactions/jsapi", + WECHAT_PAY_NATIVE_PATH, + ) + .expect("Native endpoint should resolve"); + + assert_eq!( + h5_endpoint, + "https://pay-gateway.example.com/v3/pay/transactions/h5" + ); + assert_eq!( + native_endpoint, + "https://pay-gateway.example.com/v3/pay/transactions/native" + ); + } + #[test] fn jsapi_order_request_rejects_provider_field_limit_violations() { assert!(validate_out_trade_no("abc12").is_err()); @@ -1074,6 +1474,20 @@ mod tests { validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid") .is_err() ); + validate_non_empty_max_chars( + "203.0.113.10", + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "payer_client_ip", + ) + .expect("short client ip should pass"); + assert!( + validate_non_empty_max_chars( + &"1".repeat(46), + WECHAT_PAY_CLIENT_IP_MAX_CHARS, + "payer_client_ip", + ) + .is_err() + ); } #[test] diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index e3249e64..4f763b5f 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -263,7 +263,7 @@ pub fn build_runtime_profile_recharge_order_create_input( return Err(RuntimeProfileFieldError::UnknownRechargeProduct); } let payment_channel = normalize_required_string(payment_channel) - .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); + .ok_or(RuntimeProfileFieldError::MissingPaymentChannel)?; Ok(RuntimeProfileRechargeOrderCreateInput { user_id, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index e327f28d..7d386182 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -34,6 +34,8 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp"; +pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5"; +pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native"; pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10; pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200; pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40; diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 59efc6d0..1745a51a 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -74,6 +74,7 @@ pub enum RuntimeProfileFieldError { TaskAlreadyClaimed, MissingOrderId, MissingProductId, + MissingPaymentChannel, MissingWorldKey, MissingBottomTab, MissingCheckpointSessionId, @@ -136,6 +137,7 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), + Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index f9da30bf..7c2d21a0 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -702,6 +702,19 @@ mod tests { assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct); } + #[test] + fn build_recharge_order_input_rejects_missing_payment_channel() { + let error = build_runtime_profile_recharge_order_create_input( + "user-1".to_string(), + "points_60".to_string(), + " ".to_string(), + 1, + ) + .expect_err("missing payment channel should fail"); + + assert_eq!(error, RuntimeProfileFieldError::MissingPaymentChannel); + } + #[test] fn runtime_profile_identity_helpers_keep_existing_key_shape() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index e36ac817..8586af2c 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -239,6 +239,18 @@ pub struct WechatMiniProgramPayParamsResponse { pub pay_sign: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WechatH5PaymentResponse { + pub h5_url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WechatNativePaymentResponse { + pub code_url: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileRechargeCenterResponse { @@ -266,6 +278,10 @@ pub struct CreateProfileRechargeOrderResponse { pub center: ProfileRechargeCenterResponse, #[serde(default)] pub wechat_mini_program_pay_params: Option, + #[serde(default)] + pub wechat_h5_payment: Option, + #[serde(default)] + pub wechat_native_payment: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1327,6 +1343,60 @@ mod tests { assert_eq!(payload.payment_channel, None); } + #[test] + fn create_profile_recharge_order_response_serializes_web_wechat_payloads() { + let order = ProfileRechargeOrderResponse { + order_id: "rcgtest001".to_string(), + product_id: "points_60".to_string(), + product_title: "60泥点".to_string(), + kind: "points".to_string(), + amount_cents: 600, + status: "pending".to_string(), + payment_channel: "wechat_native".to_string(), + paid_at: None, + provider_transaction_id: None, + created_at: "2026-05-15T10:00:00Z".to_string(), + points_delta: 0, + membership_expires_at: None, + }; + let center = ProfileRechargeCenterResponse { + wallet_balance: 0, + membership: ProfileMembershipResponse { + status: "normal".to_string(), + tier: "normal".to_string(), + started_at: None, + expires_at: None, + updated_at: None, + }, + point_products: vec![], + membership_products: vec![], + benefits: vec![], + latest_order: None, + has_points_recharged: false, + }; + let payload = serde_json::to_value(CreateProfileRechargeOrderResponse { + order, + center, + wechat_mini_program_pay_params: None, + wechat_h5_payment: Some(WechatH5PaymentResponse { + h5_url: "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb".to_string(), + }), + wechat_native_payment: Some(WechatNativePaymentResponse { + code_url: "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test".to_string(), + }), + }) + .expect("payload should serialize"); + + assert_eq!( + payload["wechatH5Payment"]["h5Url"], + json!("https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb") + ); + assert_eq!( + payload["wechatNativePayment"]["codeUrl"], + json!("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test") + ); + } + #[test] fn profile_feedback_response_uses_camel_case_fields() { let payload = serde_json::to_value(SubmitProfileFeedbackResponse { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 7a45adee..a361e871 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -37,6 +37,8 @@ import { } from './rpgEntryWorldPresentation'; const { + mockQrCodeToDataUrl, + mockRedirectToPaymentUrl, mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, @@ -48,6 +50,8 @@ const { mockGetRpgProfileWalletLedger, mockRedeemRpgProfileReferralInviteCode, } = vi.hoisted(() => { + const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR'); + const redirectToPaymentUrl = vi.fn(); const buildReferralCenter = ( overrides: Partial = {}, ): ProfileReferralInviteCenterResponse => ({ @@ -119,6 +123,8 @@ const { }); return { + mockQrCodeToDataUrl: qrCodeToDataUrl, + mockRedirectToPaymentUrl: redirectToPaymentUrl, mockBuildReferralCenter: buildReferralCenter, mockBuildTaskCenter: buildTaskCenter, mockGetRpgProfileReferralInviteCenter: vi.fn(async () => @@ -343,6 +349,16 @@ vi.mock('../../services/authService', () => ({ updateAuthProfile: mockUpdateAuthProfile, })); +vi.mock('qrcode', () => ({ + default: { + toDataURL: mockQrCodeToDataUrl, + }, +})); + +vi.mock('../../services/payment/paymentRedirect', () => ({ + redirectToPaymentUrl: mockRedirectToPaymentUrl, +})); + mockUpdateAuthProfile.mockResolvedValue({ id: 'user-1', publicUserCode: '100001', @@ -584,19 +600,32 @@ function buildBabyObjectMatchEntry( } function mockDesktopLayout() { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + configurable: true, + value: 0, + }); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, - value: vi.fn().mockImplementation(() => ({ - matches: true, - media: '(min-width: 1024px)', - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation((query: string) => { + const normalizedQuery = query.replace(/\s/g, ''); + return { + matches: + normalizedQuery.includes('min-width:1024px') || + normalizedQuery.includes('min-width:1024'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); } @@ -924,7 +953,9 @@ afterEach(() => { vi.unstubAllGlobals(); window.wx = undefined; document - .querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]') + .querySelectorAll( + 'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]', + ) .forEach((script) => script.remove()); mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockBuildReferralCenter(), @@ -975,6 +1006,8 @@ afterEach(() => { wechatBound: false, createdAt: new Date().toISOString(), }); + mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR'); + mockRedirectToPaymentUrl.mockReset(); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, @@ -1011,11 +1044,45 @@ test('opens wallet ledger modal from narrative coin card', async () => { expect(screen.getByText('+30')).toBeTruthy(); }); -test('profile recharge modal buys points through mock channel outside mini program', async () => { +test('profile recharge modal shows native qr code on desktop web by default', async () => { const user = userEvent.setup(); - const onRechargeSuccess = vi.fn(); + mockDesktopLayout(); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-native-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test', + }, + }); - renderProfileView(onRechargeSuccess); + renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), @@ -1028,14 +1095,96 @@ test('profile recharge modal buys points through mock channel outside mini progr await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', - 'mock', + 'wechat_native', ); }); + expect(await screen.findByText('微信扫码支付')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy(); + }); + expect(mockQrCodeToDataUrl).toHaveBeenCalledWith( + 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test', + expect.objectContaining({ width: 180 }), + ); + expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull(); +}); + +test('profile recharge modal jumps to h5 payment on mobile web by default', async () => { + const user = userEvent.setup(); + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + media: '(max-width: 767px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-h5-1', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_h5', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatH5Payment: { + h5Url: + 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5', + }, + }); + + renderProfileView(); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + await user.click(await screen.findByRole('button', { name: /60泥点/u })); + + await waitFor(() => { + expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'points_60', + 'wechat_h5', + ); + }); + expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith( + 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5', + ); expect( - await screen.findByRole('dialog', { name: '支付成功' }), + await screen.findByRole('dialog', { name: '正在打开微信支付' }), ).toBeTruthy(); - expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); - expect(onRechargeSuccess).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull(); }); test('profile recharge modal posts requestPayment params in mini program web-view', async () => { @@ -1118,9 +1267,12 @@ test('profile recharge modal posts requestPayment params in mini program web-vie }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); + expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith( + 'points_60', + 'mock', + ); + expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled(); expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', @@ -1266,9 +1418,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); }); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); @@ -1354,9 +1504,7 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); - expect( - await screen.findByRole('dialog', { name: '支付成功' }), - ).toBeTruthy(); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); }); test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { @@ -1452,6 +1600,93 @@ test('profile recharge modal releases submitting state after cancelled wechat pa expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); }); +test('profile native qr confirmation refreshes only after server reports paid', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + mockDesktopLayout(); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-native-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_native', + paidAt: null, + providerTransactionId: null, + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-paid', + }, + }); + mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-native-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid' as const, + paymentChannel: 'wechat_native', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-native-1', + createdAt: '2026-04-25T10:00:00Z', + pointsDelta: 120, + membershipExpiresAt: null, + }, + center: { + walletBalance: 120, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: true, + }, + }); + + renderProfileView(onRechargeSuccess); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + await user.click(await screen.findByRole('button', { name: /60泥点/u })); + await user.click(await screen.findByRole('button', { name: '我已支付' })); + + await waitFor(() => { + expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-native-paid', + ); + }); + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + test('profile daily task shortcut opens task center and claims reward', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index f48429df..7ae68662 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -42,6 +42,7 @@ import { useRef, useState, } from 'react'; +import QRCode from 'qrcode'; import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; @@ -57,6 +58,7 @@ import type { ProfileReferralInviteCenterResponse, ProfileRechargeCenterResponse, ProfileRechargeProduct, + WechatNativePayment, WechatMiniProgramPayParams, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, @@ -73,6 +75,13 @@ import { updateAuthProfile, } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; +import { + resolveProfileRechargePaymentChannel, + WECHAT_H5_PAYMENT_CHANNEL, + WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, + WECHAT_NATIVE_PAYMENT_CHANNEL, +} from '../../services/payment/paymentPlatform'; +import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect'; import { claimRpgProfileTaskReward, confirmWechatRpgProfileRechargeOrder, @@ -217,9 +226,9 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; -const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; +const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; type RechargeTab = 'points' | 'membership'; @@ -235,6 +244,10 @@ type RechargePaymentResult = { title: string; message: string; }; +type NativeWechatPaymentState = WechatNativePayment & { + orderId: string; + isConfirming: boolean; +}; type DiscoverChannel = | 'recommend' | 'today' @@ -2338,18 +2351,6 @@ function formatRechargePrice(priceCents: number) { return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`; } -function isWechatMiniProgramWebView() { - if (typeof window === 'undefined') { - return false; - } - - const params = new URLSearchParams(window.location.search); - return ( - params.get('clientRuntime') === 'wechat_mini_program' || - params.get('clientType') === 'mini_program' - ); -} - function clearWechatPayResultHash() { if (typeof window === 'undefined') { return; @@ -2496,6 +2497,36 @@ async function confirmWechatRechargeOrderUntilSettled( return latestResponse; } +function useWechatNativeQrCode(codeUrl: string | null) { + const [qrImageUrl, setQrImageUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + setQrImageUrl(null); + if (!codeUrl) { + return () => { + cancelled = true; + }; + } + + void QRCode.toDataURL(codeUrl, { + errorCorrectionLevel: 'M', + margin: 1, + width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE, + }).then((dataUrl) => { + if (!cancelled) { + setQrImageUrl(dataUrl); + } + }); + + return () => { + cancelled = true; + }; + }, [codeUrl]); + + return qrImageUrl; +} + function RechargeProductCard({ product, submittingProductId, @@ -2546,22 +2577,29 @@ function ProfileRechargeModal({ isLoading, error, submittingProductId, + nativePayment, activeTab, onTabChange, onClose, onRetry, onBuy, + onConfirmNativePayment, }: { center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; submittingProductId: string | null; + nativePayment: NativeWechatPaymentState | null; activeTab: RechargeTab; onTabChange: (tab: RechargeTab) => void; onClose: () => void; onRetry: () => void; onBuy: (product: ProfileRechargeProduct) => void; + onConfirmNativePayment: () => void; }) { + const nativeQrImageUrl = useWechatNativeQrCode( + nativePayment?.codeUrl ?? null, + ); const products = activeTab === 'points' ? (center?.pointProducts ?? []) @@ -2650,6 +2688,33 @@ function ProfileRechargeModal({ 暂无可购买套餐 )} + + {nativePayment ? ( +
+
微信扫码支付
+
+ {nativeQrImageUrl ? ( + 微信 Native 支付二维码 + ) : ( + + 生成中 + + )} +
+ +
+ ) : null} @@ -3420,6 +3485,8 @@ export function RpgEntryHomeView({ const [rechargeError, setRechargeError] = useState(null); const [rechargePaymentResult, setRechargePaymentResult] = useState(null); + const [nativeWechatPayment, setNativeWechatPayment] = + useState(null); const [activeRechargeTab, setActiveRechargeTab] = useState('points'); const [submittingRechargeProductId, setSubmittingRechargeProductId] = @@ -3941,14 +4008,12 @@ export function RpgEntryHomeView({ }) .finally(() => setIsLoadingRechargeCenter(false)); }; - const refreshRechargeState = useCallback( - () => { - loadRechargeCenter(); - setSubmittingRechargeProductId(null); - pendingWechatRechargeOrderIdRef.current = null; - }, - [loadRechargeCenter], - ); + const refreshRechargeState = useCallback(() => { + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + setNativeWechatPayment(null); + }, [loadRechargeCenter]); const handleWechatPayResult = useCallback(() => { const payResult = readWechatPayResultFromHash(); if (!payResult) { @@ -4036,11 +4101,11 @@ export function RpgEntryHomeView({ return; } - const paymentChannel = isWechatMiniProgramWebView() - ? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL - : 'mock'; + const paymentChannel = resolveProfileRechargePaymentChannel(); setSubmittingRechargeProductId(product.productId); setRechargeError(null); + setRechargePaymentResult(null); + setNativeWechatPayment(null); void createRpgProfileRechargeOrder(product.productId, paymentChannel) .then(async (response) => { if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) { @@ -4051,24 +4116,105 @@ export function RpgEntryHomeView({ ); setRechargeCenter(response.center); return; - } else { + } + if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) { + const h5Url = response.wechatH5Payment?.h5Url?.trim(); + if (!h5Url) { + throw new Error('微信 H5 支付链接生成失败'); + } + pendingWechatRechargeOrderIdRef.current = response.order.orderId; setRechargeCenter(response.center); setRechargePaymentResult({ - kind: 'success', - title: '支付成功', - message: '已到账,账户状态已刷新。', + kind: 'pending', + title: '正在打开微信支付', + message: '完成支付后返回页面确认到账状态。', }); - pendingWechatRechargeOrderIdRef.current = null; - setSubmittingRechargeProductId(null); + redirectToPaymentUrl(h5Url); + return; } - void onRechargeSuccess?.(); + if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) { + const codeUrl = response.wechatNativePayment?.codeUrl?.trim(); + if (!codeUrl) { + throw new Error('微信 Native 支付二维码生成失败'); + } + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + setRechargeCenter(response.center); + setNativeWechatPayment({ + orderId: response.order.orderId, + codeUrl, + isConfirming: false, + }); + setSubmittingRechargeProductId(null); + return; + } + + throw new Error('充值支付渠道无效'); }) .catch((error: unknown) => { pendingWechatRechargeOrderIdRef.current = null; + setNativeWechatPayment(null); setRechargeError(error instanceof Error ? error.message : '充值失败'); setSubmittingRechargeProductId(null); }); }; + const confirmNativeWechatPayment = useCallback(() => { + if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { + return; + } + + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: true } + : current, + ); + setRechargePaymentResult({ + kind: 'pending', + title: '正在确认支付', + message: '正在查询微信支付到账状态。', + }); + void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId) + .then((response) => { + const isPaid = response.order.status === 'paid'; + setRechargeCenter(response.center); + setRechargePaymentResult( + isPaid + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '等待微信确认', + message: '暂时没能确认到账状态,请稍后再试。', + }, + ); + if (isPaid) { + setNativeWechatPayment(null); + pendingWechatRechargeOrderIdRef.current = null; + void onRechargeSuccess?.(); + } else { + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: false } + : current, + ); + } + }) + .catch(() => { + setRechargePaymentResult({ + kind: 'pending', + title: '等待微信确认', + message: '暂时没能确认到账状态,请稍后再试。', + }); + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: false } + : current, + ); + }) + .finally(() => setSubmittingRechargeProductId(null)); + }, [nativeWechatPayment, onRechargeSuccess]); useEffect(() => { const handleResume = () => { handleWechatPayResult(); @@ -5878,11 +6024,13 @@ export function RpgEntryHomeView({ isLoading={isLoadingRechargeCenter} error={rechargeError} submittingProductId={submittingRechargeProductId} + nativePayment={nativeWechatPayment} activeTab={activeRechargeTab} onTabChange={setActiveRechargeTab} onClose={() => setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} + onConfirmNativePayment={confirmNativeWechatPayment} /> ) : null; const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? ( diff --git a/src/services/payment/paymentPlatform.test.ts b/src/services/payment/paymentPlatform.test.ts new file mode 100644 index 00000000..7debab5f --- /dev/null +++ b/src/services/payment/paymentPlatform.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; + +import { + resolveProfileRechargePaymentChannel, + WECHAT_H5_PAYMENT_CHANNEL, + WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, + WECHAT_NATIVE_PAYMENT_CHANNEL, +} from './paymentPlatform'; + +describe('resolveProfileRechargePaymentChannel', () => { + test('小程序运行态选择 wechat_mp', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '?clientRuntime=wechat_mini_program' }, + navigator: { userAgent: 'Mozilla/5.0 (iPhone)' }, + }), + ).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL); + }); + + test('移动网页选择 wechat_h5', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', + }, + }), + ).toBe(WECHAT_H5_PAYMENT_CHANNEL); + }); + + test('微信内 H5 首版仍选择 wechat_h5', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile', + }, + }), + ).toBe(WECHAT_H5_PAYMENT_CHANNEL); + }); + + test('桌面网页选择 wechat_native', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + matchMedia: () => ({ matches: false }) as unknown as MediaQueryList, + }), + ).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL); + }); + + test('默认路径永远不会解析成 mock', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { userAgent: '' }, + matchMedia: () => ({ matches: false }) as unknown as MediaQueryList, + }), + ).not.toBe('mock'); + }); +}); diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts new file mode 100644 index 00000000..3c4e21f3 --- /dev/null +++ b/src/services/payment/paymentPlatform.ts @@ -0,0 +1,76 @@ +export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; +export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5'; +export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native'; +export const MOCK_PAYMENT_CHANNEL = 'mock'; + +export type ProfileRechargeWechatPaymentChannel = + | typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL + | typeof WECHAT_H5_PAYMENT_CHANNEL + | typeof WECHAT_NATIVE_PAYMENT_CHANNEL; + +type PaymentPlatformNavigator = Pick; + +export type PaymentPlatformContext = { + location?: Pick | null; + navigator?: Partial | null; + matchMedia?: Window['matchMedia'] | null; +}; + +export function resolveProfileRechargePaymentChannel( + context: PaymentPlatformContext = {}, +): ProfileRechargeWechatPaymentChannel { + const location = + context.location ?? + (typeof window !== 'undefined' ? window.location : null); + const navigatorLike = + context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null); + const matchMedia = + context.matchMedia ?? + (typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia.bind(window) + : null); + + if (isWechatMiniProgramRuntime(location)) { + return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL; + } + + if (isMobileWebRuntime(navigatorLike, matchMedia)) { + return WECHAT_H5_PAYMENT_CHANNEL; + } + + return WECHAT_NATIVE_PAYMENT_CHANNEL; +} + +export function isManualMockPaymentChannel(paymentChannel: string) { + return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL; +} + +function isWechatMiniProgramRuntime( + location: Pick | null | undefined, +) { + const params = new URLSearchParams(location?.search ?? ''); + return ( + params.get('clientRuntime') === 'wechat_mini_program' || + params.get('clientType') === 'mini_program' + ); +} + +function isMobileWebRuntime( + navigatorLike: Partial | null | undefined, + matchMedia: Window['matchMedia'] | null | undefined, +) { + const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? ''; + if ( + /android|iphone|ipad|ipod|mobile|micromessenger|windows phone/u.test( + userAgent, + ) + ) { + return true; + } + + if ((navigatorLike?.maxTouchPoints ?? 0) > 1) { + return true; + } + + return Boolean(matchMedia?.('(max-width: 767px)').matches); +} diff --git a/src/services/payment/paymentRedirect.ts b/src/services/payment/paymentRedirect.ts new file mode 100644 index 00000000..923e1c54 --- /dev/null +++ b/src/services/payment/paymentRedirect.ts @@ -0,0 +1,3 @@ +export function redirectToPaymentUrl(url: string) { + window.location.assign(url); +} diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index a0b9a479..f5942c80 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) { ); } -export function getRpgProfileWalletLedger( - options: RuntimeRequestOptions = {}, -) { +export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/wallet-ledger', { method: 'GET' }, @@ -91,7 +89,7 @@ export function getRpgProfileRechargeCenter( export function createRpgProfileRechargeOrder( productId: string, - paymentChannel = 'mock', + paymentChannel: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( @@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive( worldKey: string, options: RuntimeRequestOptions = {}, ) { - const response = await requestRpgRuntimeJson( - `/profile/save-archives/${encodeURIComponent(worldKey)}`, - { method: 'POST' }, - '恢复存档失败', - options, - ); + const response = + await requestRpgRuntimeJson( + `/profile/save-archives/${encodeURIComponent(worldKey)}`, + { method: 'POST' }, + '恢复存档失败', + options, + ); return { entry: response.entry, From c94f22e26c696a7c52b51d853118843e24820dc0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 08:43:21 +0800 Subject: [PATCH 2/7] feat: gate recharge payment by login device --- .hermes/shared-memory/decision-log.md | 8 + ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 21 +- .../OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md | 16 +- .../crates/api-server/src/auth_session.rs | 25 ++- .../crates/api-server/src/refresh_session.rs | 1 + .../crates/api-server/src/runtime_profile.rs | 189 +++++++++++++++++- server-rs/crates/platform-auth/README.md | 19 +- server-rs/crates/platform-auth/src/lib.rs | 102 +++++++++- .../RpgEntryHomeView.recharge.test.tsx | 86 ++++++-- src/components/rpg-entry/RpgEntryHomeView.tsx | 48 +++-- src/services/payment/paymentPlatform.test.ts | 55 +++++ src/services/payment/paymentPlatform.ts | 29 ++- 12 files changed, 527 insertions(+), 72 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7b276d70..43cc51a3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-15 微信充值支付路径以后端 JWT 设备快照为准 + +- 背景:前端隐藏非微信环境充值入口只能改善体验,不能阻止用户手动调用 `/api/profile/recharge/orders` 并伪造 `paymentChannel`。 +- 决策:access JWT 只新增最小设备快照 `device.client_type/client_runtime/client_platform`,来源为登录或 refresh session 中的 `client_info`;不把完整 session、IP、UA 或设备列表塞进 JWT。真实微信充值下单必须由后端按 JWT 设备快照拦截:小程序 `wechat_mp` 只允许 `mini_program`,手机微信内网页 `wechat_h5` 只允许 `wechat_h5 + ios/android`,桌面微信内网页 `wechat_native` 只允许 `wechat_h5 + windows/macos/linux`。非微信环境前端不显示充值入口,改显示兑换码入口。 +- 影响范围:`platform-auth` JWT claims、`api-server` auth session/refresh session 签发、`runtime_profile` 充值订单接口、前端支付平台隔离层和“我的”页常用功能区。 +- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p api-server profile_recharge_order --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式 - 背景:抓大鹅结果页 `素材配置 > 物品` 需要在不改变玩法物品映射的前提下,批量重新生成已存在物品的 2D 五视角图片。 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 83ed3fab..8d6eab64 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -9,7 +9,7 @@ 1. `泥点充值` 2. `会员卡充值` -前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,移动网页使用 `wechat_h5`,桌面网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。 +前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。支付接入固定为普通商户直连模式:小程序 web-view 使用 `wechat_mp`,手机微信内网页使用 `wechat_h5`,桌面微信内网页使用 `wechat_native` 二维码。生产默认永远不走 `mock`;只有自动测试或显式测试配置手动传 `paymentChannel = "mock"` 时才允许 mock 即时入账。所有真实微信渠道的到账事实以后端微信支付通知和服务端查单为准。 ## 2. 产品规则 @@ -64,13 +64,18 @@ 1. 校验 `productId` 2. 缺少 `paymentChannel` 或传入未知渠道时直接返回 `400`,不得再默认解释为 `mock` -3. 生产/真实支付配置拒绝 `paymentChannel = "mock"`;mock 只服务自动测试或显式 mock 测试配置 +3. 后端根据 Bearer JWT 中的 `device` 快照拦截真实微信渠道,不能只信任前端隐藏入口或请求体传入的 `paymentChannel` + - `wechat_mp` 只允许 `device.client_type = "mini_program"` + - `wechat_h5` 只允许 `device.client_type = "wechat_h5"` 且 `device.client_platform` 为 `ios` 或 `android` + - `wechat_native` 只允许 `device.client_type = "wechat_h5"` 且 `device.client_platform` 为 `windows`、`macos` 或 `linux` + - 缺少设备快照、普通浏览器设备、手机微信手动传 Native、桌面微信手动传 H5 都返回 `403` +4. 生产/真实支付配置拒绝 `paymentChannel = "mock"`;mock 只服务自动测试或显式 mock 测试配置 - `wechat_mp` / `wechat_h5` / `wechat_native` 必须在 `WECHAT_PAY_ENABLED=true` 且 `WECHAT_PAY_PROVIDER=real` 时才允许下单;`WECHAT_PAY_PROVIDER=mock` 不能为真实微信渠道返回 mock 支付载荷。 -4. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 +5. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 - JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 -5. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址 -6. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码 -7. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷 +6. `paymentChannel = "wechat_h5"` 时调用微信支付 H5 下单,返回 `wechatH5Payment.h5Url`,前端跳转到该地址 +7. `paymentChannel = "wechat_native"` 时调用微信支付 Native 下单,返回 `wechatNativePayment.codeUrl`,前端在充值弹窗内把 `codeUrl` 渲染成二维码;该渠道只面向桌面微信内网页 +8. 所有微信真实渠道订单不提前发泥点或会员,只返回待支付订单、账户中心快照与对应支付载荷 兼容路径:`POST /api/runtime/profile/recharge/orders` @@ -164,8 +169,8 @@ H5 与 Native 响应: - `success` 只表示微信客户端支付流程返回成功,前端随后调用 `POST /api/profile/recharge/orders/{order_id}/wechat/confirm` 由服务端查单确认;只有通知或服务端查单确认为 `SUCCESS` 才入账。 - 小程序返回后,前端会对确认接口做短轮询,覆盖微信通知/查单结果与 web-view 恢复之间的秒级时间差;只有确认响应里的订单状态变成 `paid` 后,才触发父级 `profileDashboard` 刷新,确保“我的”页泥点卡片读取到最新余额。 - `cancel` 和 `fail` 只复位按钮、刷新账户中心并通过全局支付结果模态展示,不调用入账逻辑。 -5. 移动网页含微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序。 -6. 桌面网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额。 +5. 手机微信内 H5 首版统一走 `wechat_h5`:拿到 `h5Url` 后跳转;若微信内 H5 调起失败,失败态提示用户改用系统浏览器或小程序。 +6. 桌面微信内网页走 `wechat_native`:充值弹窗展示二维码,用户扫码后点击“我已支付”,前端调用 confirm 接口短轮询确认;确认前不刷新父级余额。 7. 支付结果使用页面级全局模态展示,不写回商品卡片或账户充值弹窗内部;充值弹窗只负责套餐选择、加载失败和下单失败。 8. 弹窗内不写大段说明文案,只保留必要金额、泥点、会员权益和操作状态。 9. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。 diff --git a/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md b/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md index 6a81f721..49642eab 100644 --- a/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md +++ b/docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md @@ -116,6 +116,7 @@ | `phone_verified` | 是 | 是否已完成手机号验证。 | | `binding_status` | 是 | 账号绑定状态,固定为 `active`、`pending_bind_phone`。 | | `display_name` | 否 | 当前展示名快照,用于少量上游日志/观测;不是授权依据。 | +| `device` | 否 | 登录时采集到的最小设备快照,仅保留 `client_type`、`client_runtime`、`client_platform`,用于后端按设备拦截真实微信支付路径。 | ## 6. 关键字段定义 @@ -264,7 +265,7 @@ 4. refresh token hash 5. 风控状态、captcha 状态、封禁剩余时间 6. 完整用户资料对象 -7. 审计日志、设备列表、IP、UA +7. 审计日志、完整设备对象或列表、IP、UA 原因: @@ -284,6 +285,11 @@ "phone_verified": false, "binding_status": "pending_bind_phone", "display_name": "微信旅人", + "device": { + "client_type": "wechat_h5", + "client_runtime": "wechat_embedded_browser", + "client_platform": "ios" + }, "iat": 1713657600, "exp": 1713664800 } @@ -302,7 +308,8 @@ 1. 签发 access token 2. 校验 `iss/exp/sub/sid/provider/roles/ver` -3. 解析 claims 为统一 Rust 结构 +3. 规范化并透传 `device` 最小设备快照 +4. 解析 claims 为统一 Rust 结构 ### 9.2 `module-auth` @@ -344,6 +351,7 @@ | 无 | `phone_verified` | 新增,表达手机号验证状态。 | | 无 | `binding_status` | 新增,表达待绑手机/已激活状态。 | | 无 | `display_name` | 可选新增。 | +| 无 | `device` | 可选新增,承载最小设备快照,不包含完整 session 或敏感原始环境。 | ## 11. Express / Axum 请求上下文映射 @@ -361,6 +369,7 @@ Rust 侧建议升级为统一 claims 结构,例如: 5. `token_version` 6. `phone_verified` 7. `binding_status` +8. `device` 说明: @@ -384,7 +393,8 @@ Rust 侧建议升级为统一 claims 结构,例如: 1. `iss/sub/sid/provider/roles` 已明确冻结 2. access token 与 refresh session 的职责边界已切开 3. Axum、`platform-auth`、`module-auth`、`SpacetimeDB` 的使用边界已明确 -4. 后续可以直接按这份文档实现签发、校验与身份透传 +4. 登录设备快照的最小字段已明确,且可用于支付路径拦截 +5. 后续可以直接按这份文档实现签发、校验与身份透传 ## 14. 依据文件 diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index e06c27f3..90b1325e 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -1,19 +1,19 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; use module_auth::{ - AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError, + AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionClientInfo, + RefreshSessionError, }; use platform_auth::{ - AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, + AccessTokenClaims, AccessTokenClaimsInput, AccessTokenDeviceInfo, AuthProvider, BindingStatus, build_refresh_session_clear_cookie, build_refresh_session_set_cookie, create_refresh_session_token, hash_refresh_session_token, sign_access_token, }; use time::OffsetDateTime; use crate::session_client::SessionClientContext; -use crate::{ - http_error::AppError, request_context::RequestContext, state::AppState, - tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path, -}; +#[cfg(not(test))] +use crate::tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path; +use crate::{http_error::AppError, request_context::RequestContext, state::AppState}; #[derive(Debug, Clone)] pub struct SignedAuthSession { @@ -81,6 +81,7 @@ pub fn create_auth_session( user, &session.session.session_id, Some(&session_provider), + Some(&session.session.client_info), )?; Ok(SignedAuthSession { @@ -94,8 +95,9 @@ pub fn sign_access_token_for_user( user: &AuthUser, session_id: &str, session_provider_override: Option<&AuthLoginMethod>, + client_info: Option<&RefreshSessionClientInfo>, ) -> Result { - let access_claims = AccessTokenClaims::from_input( + let access_claims = AccessTokenClaims::from_input_with_device( AccessTokenClaimsInput { user_id: user.id.clone(), session_id: session_id.to_string(), @@ -106,6 +108,7 @@ pub fn sign_access_token_for_user( binding_status: map_binding_status(&user.binding_status), display_name: Some(user.display_name.clone()), }, + client_info.map(map_access_token_device_info), state.auth_jwt_config(), OffsetDateTime::now_utc(), ) @@ -182,3 +185,11 @@ fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> Bindin module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone, } } + +fn map_access_token_device_info(client_info: &RefreshSessionClientInfo) -> AccessTokenDeviceInfo { + AccessTokenDeviceInfo { + client_type: client_info.client_type.clone(), + client_runtime: client_info.client_runtime.clone(), + client_platform: client_info.client_platform.clone(), + } +} diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 28d0432c..9276ce1b 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -54,6 +54,7 @@ pub async fn refresh_session( &rotated.user, &rotated.session.session_id, Some(&rotated.session.issued_by_provider), + Some(&rotated.session.client_info), )?; record_daily_login_tracking_event_after_auth_success( &state, diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a0ed8af8..8374c9ce 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -197,6 +197,8 @@ pub async fn create_profile_recharge_order( let user_id = authenticated.claims().user_id().to_string(); let payment_channel = normalize_recharge_payment_channel(payload.payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; + validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_payment_channel(&state, &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let created_at_micros = current_unix_micros(); @@ -956,6 +958,34 @@ fn validate_recharge_payment_channel( Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效")) } +fn validate_recharge_device_for_payment_channel( + claims: &platform_auth::AccessTokenClaims, + payment_channel: &str, +) -> Result<(), AppError> { + if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { + return Ok(()); + } + + if !is_wechat_recharge_payment_channel(payment_channel) { + return Ok(()); + } + + let is_supported_device = match payment_channel { + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => { + claims.is_wechat_mini_program_device() + } + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(), + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(), + _ => false, + }; + if is_supported_device { + return Ok(()); + } + + Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("当前登录设备不支持充值,请在微信环境内登录后重试")) +} + fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> { if !state.config.wechat_pay_enabled { return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) @@ -1738,7 +1768,7 @@ mod tests { } #[tokio::test] - async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { + async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), @@ -1749,6 +1779,133 @@ mod tests { let token = issue_access_token(&state); let app = build_router(state); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "当前登录设备不支持充值,请在微信环境内登录后重试" + ); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "当前登录设备不支持充值,请在微信环境内登录后重试" + ); + } + + #[tokio::test] + async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = + issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/recharge/orders") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["message"], + "真实微信支付渠道不能使用 mock 支付配置" + ); + } + + #[tokio::test] + async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { + let state = seed_authenticated_state_with_config(AppConfig { + wechat_pay_enabled: true, + wechat_pay_provider: "mock".to_string(), + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + }) + .await; + let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5"); + let app = build_router(state); + let response = app .oneshot( Request::builder() @@ -2043,4 +2200,34 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + + fn issue_wechat_h5_access_token( + state: &AppState, + client_platform: &str, + session_id: &str, + ) -> String { + let claims = AccessTokenClaims::from_input_with_device( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: state + .seed_test_refresh_session_for_user_id("user_00000001", session_id), + provider: AuthProvider::Wechat, + roles: vec!["user".to_string()], + token_version: 2, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("微信资料页用户".to_string()), + }, + Some(platform_auth::AccessTokenDeviceInfo { + client_type: "wechat_h5".to_string(), + client_runtime: "wechat_embedded_browser".to_string(), + client_platform: client_platform.to_string(), + }), + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } } diff --git a/server-rs/crates/platform-auth/README.md b/server-rs/crates/platform-auth/README.md index eff1d349..5853fb63 100644 --- a/server-rs/crates/platform-auth/README.md +++ b/server-rs/crates/platform-auth/README.md @@ -17,7 +17,7 @@ 本阶段已经完成 JWT 基础能力首版落地: 1. 新增 `JwtConfig`,统一管理 `issuer`、`secret` 与 access token TTL。 -2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。 +2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name/device` 映射到 Rust 结构。 3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。 4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。 5. 新增 `RefreshCookieConfig`、`RefreshCookieSameSite` 与 `read_refresh_session_token(...)`,统一 refresh cookie 读取口径。 @@ -36,13 +36,14 @@ 1. `JwtConfig::new(...)` 2. `AccessTokenClaims::from_input(...)` -3. `sign_access_token(...)` -4. `verify_access_token(...)` -5. `RefreshCookieConfig::new(...)` -6. `read_refresh_session_token(...)` -7. `AuthProvider` -8. `BindingStatus` -9. `RefreshCookieSameSite` +3. `AccessTokenClaims::from_input_with_device(...)` +4. `sign_access_token(...)` +5. `verify_access_token(...)` +6. `RefreshCookieConfig::new(...)` +7. `read_refresh_session_token(...)` +8. `AuthProvider` +9. `BindingStatus` +10. `RefreshCookieSameSite` ## 4. 配置口径 @@ -67,7 +68,7 @@ 1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。 2. `sub` 必须是稳定 `user_id`,`sid` 必须是会话 ID,不能退化为一次 token 的随机 ID。 -3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。 +3. 不允许把手机号、openid、refresh token hash、风控状态、完整设备对象、IP、UA 等敏感或高频变化字段塞进 JWT;`device` 只允许保存支付拦截需要的最小设备快照。 4. 鉴权状态最终由 `module-auth` 与 `crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。 5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。 diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index ffa21519..c0efd58a 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -66,6 +66,15 @@ pub enum BindingStatus { PendingBindPhone, } +// JWT 里只保留一份规范化后的设备快照,用于后端按登录设备拦截充值路径。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AccessTokenDeviceInfo { + pub client_type: String, + pub client_runtime: String, + pub client_platform: String, +} + // 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct AccessTokenClaimsInput { @@ -92,6 +101,8 @@ pub struct AccessTokenClaims { pub binding_status: BindingStatus, #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device: Option, pub iat: u64, pub exp: u64, } @@ -1481,11 +1492,21 @@ impl AccessTokenClaims { input: AccessTokenClaimsInput, config: &JwtConfig, issued_at: OffsetDateTime, + ) -> Result { + Self::from_input_with_device(input, None, config, issued_at) + } + + pub fn from_input_with_device( + input: AccessTokenClaimsInput, + device: Option, + config: &JwtConfig, + issued_at: OffsetDateTime, ) -> Result { let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?; let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?; let roles = normalize_roles(input.roles)?; let display_name = normalize_optional_field(input.display_name); + let device = device.map(|device| device.normalize()).transpose()?; let issued_at_unix = issued_at.unix_timestamp(); if issued_at_unix < 0 { @@ -1515,6 +1536,7 @@ impl AccessTokenClaims { phone_verified: input.phone_verified, binding_status: input.binding_status, display_name, + device, iat: issued_at_unix as u64, exp: expires_at_unix as u64, }; @@ -1535,6 +1557,46 @@ impl AccessTokenClaims { self.ver } + pub fn client_type(&self) -> Option<&str> { + self.device + .as_ref() + .map(|device| device.client_type.as_str()) + } + + pub fn client_platform(&self) -> Option<&str> { + self.device + .as_ref() + .map(|device| device.client_platform.as_str()) + } + + pub fn is_wechat_mini_program_device(&self) -> bool { + matches!(self.client_type(), Some("mini_program")) + } + + pub fn is_wechat_h5_device(&self) -> bool { + matches!(self.client_type(), Some("wechat_h5")) + } + + pub fn is_wechat_payment_device(&self) -> bool { + self.is_wechat_mini_program_device() || self.is_wechat_h5_device() + } + + pub fn is_mobile_device(&self) -> bool { + matches!(self.client_platform(), Some("ios" | "android")) + } + + pub fn is_desktop_device(&self) -> bool { + matches!(self.client_platform(), Some("windows" | "macos" | "linux")) + } + + pub fn is_mobile_wechat_browser_device(&self) -> bool { + self.is_wechat_h5_device() && self.is_mobile_device() + } + + pub fn is_desktop_wechat_browser_device(&self) -> bool { + self.is_wechat_h5_device() && self.is_desktop_device() + } + pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> { if self.iss.trim() != config.issuer() { return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致")); @@ -1543,6 +1605,9 @@ impl AccessTokenClaims { normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?; normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?; normalize_roles(self.roles.clone())?; + if let Some(device) = &self.device { + device.validate()?; + } if self.exp <= self.iat { return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat")); @@ -1552,6 +1617,38 @@ impl AccessTokenClaims { } } +impl AccessTokenDeviceInfo { + pub fn normalize(self) -> Result { + Ok(Self { + client_type: normalize_required_field( + self.client_type, + "JWT device.client_type 不能为空", + )?, + client_runtime: normalize_required_field( + self.client_runtime, + "JWT device.client_runtime 不能为空", + )?, + client_platform: normalize_required_field( + self.client_platform, + "JWT device.client_platform 不能为空", + )?, + }) + } + + pub fn validate(&self) -> Result<(), JwtError> { + normalize_required_field(self.client_type.clone(), "JWT device.client_type 不能为空")?; + normalize_required_field( + self.client_runtime.clone(), + "JWT device.client_runtime 不能为空", + )?; + normalize_required_field( + self.client_platform.clone(), + "JWT device.client_platform 不能为空", + )?; + Ok(()) + } +} + pub fn sign_access_token( claims: &AccessTokenClaims, config: &JwtConfig, @@ -2129,10 +2226,7 @@ mod tests { let phone_info = payload.phone_info.expect("phone info should exist"); assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000")); - assert_eq!( - phone_info.pure_phone_number.as_deref(), - Some("13800138000") - ); + assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000")); assert_eq!(phone_info.country_code.as_deref(), Some("86")); } diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a361e871..875055f2 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -401,6 +401,8 @@ vi.mock('../ResolvedAssetImage', () => ({ })); const originalMatchMedia = window.matchMedia; +const originalUserAgent = navigator.userAgent; +const originalMaxTouchPoints = navigator.maxTouchPoints; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; @@ -629,6 +631,37 @@ function mockDesktopLayout() { }); } +function mockWechatDesktopLayout() { + mockDesktopLayout(); + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/8.0', + }); +} + +function mockWechatMobileLayout() { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit MicroMessenger/8.0 Mobile', + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + media: '(max-width: 767px)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + function renderProfileView( onRechargeSuccess = vi.fn(), profileDashboardOverrides: Partial< @@ -1013,6 +1046,14 @@ afterEach(() => { writable: true, value: originalMatchMedia, }); + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + configurable: true, + value: originalMaxTouchPoints, + }); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, writable: true, @@ -1046,7 +1087,7 @@ test('opens wallet ledger modal from narrative coin card', async () => { test('profile recharge modal shows native qr code on desktop web by default', async () => { const user = userEvent.setup(); - mockDesktopLayout(); + mockWechatDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-1', @@ -1111,24 +1152,7 @@ test('profile recharge modal shows native qr code on desktop web by default', as test('profile recharge modal jumps to h5 payment on mobile web by default', async () => { const user = userEvent.setup(); - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', - }); - Object.defineProperty(window, 'matchMedia', { - configurable: true, - writable: true, - value: vi.fn().mockImplementation(() => ({ - matches: true, - media: '(max-width: 767px)', - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); + mockWechatMobileLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-h5-1', @@ -1603,7 +1627,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa test('profile native qr confirmation refreshes only after server reports paid', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); - mockDesktopLayout(); + mockWechatDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-paid', @@ -1687,6 +1711,23 @@ test('profile native qr confirmation refreshes only after server reports paid', expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); +test('non-wechat profile shows reward code instead of recharge entry', async () => { + const user = userEvent.setup(); + + renderProfileView(); + + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + expect( + within(shortcutRegion).queryByRole('button', { name: /充值/u }), + ).toBeNull(); + expect( + within(shortcutRegion).getByRole('button', { name: /兑换码/u }), + ).toBeTruthy(); + await user.click(within(shortcutRegion).getByRole('button', { name: /兑换码/u })); + expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy(); + expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); +}); + test('profile daily task shortcut opens task center and claims reward', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); @@ -1925,7 +1966,10 @@ test('opens reward code modal from profile action on mobile', async () => { const user = userEvent.setup(); renderProfileView(); - await user.click(screen.getByRole('button', { name: /兑换码/u })); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /兑换码/u }), + ); const modal = await screen.findByPlaceholderText('输入兑换码'); expect(modal).toBeTruthy(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 7ae68662..5247051e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -77,6 +77,7 @@ import { import { copyTextToClipboard } from '../../services/clipboard'; import { resolveProfileRechargePaymentChannel, + shouldShowRechargeEntry, WECHAT_H5_PAYMENT_CHANNEL, WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL, WECHAT_NATIVE_PAYMENT_CHANNEL, @@ -3468,6 +3469,7 @@ export function RpgEntryHomeView({ hasUnreadDraftUpdate = false, }: RpgEntryHomeViewProps) { const authUi = useAuthUi(); + const showRechargeEntry = shouldShowRechargeEntry(); const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState(''); @@ -4096,6 +4098,14 @@ export function RpgEntryHomeView({ setIsRechargeOpen(true); loadRechargeCenter(); }; + const openRechargeOrRewardCodeModal = () => { + if (showRechargeEntry) { + openRechargeModal(); + return; + } + + openRewardCodeModal(); + }; const buyRechargeProduct = (product: ProfileRechargeProduct) => { if (submittingRechargeProductId) { return; @@ -5463,13 +5473,21 @@ export function RpgEntryHomeView({ @@ -5558,17 +5576,19 @@ export function RpgEntryHomeView({ onClick={openTaskCenterPanel} /> - + {showRechargeEntry ? ( + + ) : null} { ).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL); }); + test('桌面微信内网页选择 wechat_native', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit MicroMessenger/8.0', + }, + matchMedia: () => ({ matches: false }) as unknown as MediaQueryList, + }), + ).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL); + }); + test('默认路径永远不会解析成 mock', () => { expect( resolveProfileRechargePaymentChannel({ @@ -61,3 +75,44 @@ describe('resolveProfileRechargePaymentChannel', () => { ).not.toBe('mock'); }); }); + +describe('shouldShowRechargeEntry', () => { + test('小程序运行态显示充值入口', () => { + expect( + shouldShowRechargeEntry({ + location: { search: '?clientRuntime=wechat_mini_program' }, + navigator: { userAgent: 'Mozilla/5.0 (iPhone)' }, + }), + ).toBe(true); + }); + + test('微信内网页显示充值入口', () => { + expect( + shouldShowRechargeEntry({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile', + }, + }), + ).toBe(true); + }); + + test('普通浏览器不显示充值入口', () => { + expect( + shouldShowRechargeEntry({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', + }, + }), + ).toBe(false); + expect( + shouldShowRechargeEntry({ + location: { search: '' }, + navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + }), + ).toBe(false); + }); +}); diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts index 3c4e21f3..93b26b8a 100644 --- a/src/services/payment/paymentPlatform.ts +++ b/src/services/payment/paymentPlatform.ts @@ -16,6 +16,20 @@ export type PaymentPlatformContext = { matchMedia?: Window['matchMedia'] | null; }; +export function shouldShowRechargeEntry( + context: PaymentPlatformContext = {}, +) { + const location = + context.location ?? (typeof window !== 'undefined' ? window.location : null); + const navigatorLike = + context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null); + + return ( + isWechatMiniProgramRuntime(location) || + isWechatBrowserRuntime(navigatorLike) + ); +} + export function resolveProfileRechargePaymentChannel( context: PaymentPlatformContext = {}, ): ProfileRechargeWechatPaymentChannel { @@ -55,16 +69,21 @@ function isWechatMiniProgramRuntime( ); } +function isWechatBrowserRuntime( + navigatorLike: Partial | null | undefined, +) { + return ( + navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ?? + false + ); +} + function isMobileWebRuntime( navigatorLike: Partial | null | undefined, matchMedia: Window['matchMedia'] | null | undefined, ) { const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? ''; - if ( - /android|iphone|ipad|ipod|mobile|micromessenger|windows phone/u.test( - userAgent, - ) - ) { + if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) { return true; } From 7a3b13756555ea5fb5c98d35f58554698bfcd07a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 15 May 2026 09:37:08 +0800 Subject: [PATCH 3/7] Refactor local dev stack scheduler --- .hermes/shared-memory/decision-log.md | 254 +-- .hermes/shared-memory/development-workflow.md | 214 ++- .hermes/shared-memory/pitfalls.md | 329 ++-- .hermes/shared-memory/team-conventions.md | 104 +- .../genarrative-admin-backoffice/SKILL.md | 16 +- .../SKILL.md | 50 +- README.md | 3 +- docs/README.md | 62 +- package.json | 12 +- scripts/api-server-dev.mjs | 292 ---- scripts/check-api-server-env.mjs | 4 +- .../check-visual-novel-vn12-acceptance.mjs | 2 +- scripts/dev-rust-stack.sh | 1102 ------------ scripts/dev-server/README.md | 6 +- scripts/dev-utils.mjs | 108 ++ ...i-server-dev.test.ts => dev-utils.test.ts} | 6 +- scripts/dev-web-rust.mjs | 172 -- scripts/dev.mjs | 1497 +++++++++++++++++ scripts/dev.test.ts | 244 +++ scripts/spacetime-logs-local.sh | 4 +- 20 files changed, 2393 insertions(+), 2088 deletions(-) delete mode 100644 scripts/api-server-dev.mjs delete mode 100644 scripts/dev-rust-stack.sh create mode 100644 scripts/dev-utils.mjs rename scripts/{api-server-dev.test.ts => dev-utils.test.ts} (96%) delete mode 100644 scripts/dev-web-rust.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/dev.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 20794bc4..c8893c4a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -11,98 +11,18 @@ - 决策:最终决定是什么 - 影响范围:涉及哪些模块/文档/流程 - 验证方式:如何确认决策仍有效 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:相关 PRD、技术文档、提交或 Issue ``` --- -## 2026-05-15 微信充值支付路径以后端 JWT 设备快照为准 - -- 背景:前端隐藏非微信环境充值入口只能改善体验,不能阻止用户手动调用 `/api/profile/recharge/orders` 并伪造 `paymentChannel`。 -- 决策:access JWT 只新增最小设备快照 `device.client_type/client_runtime/client_platform`,来源为登录或 refresh session 中的 `client_info`;不把完整 session、IP、UA 或设备列表塞进 JWT。真实微信充值下单必须由后端按 JWT 设备快照拦截:小程序 `wechat_mp` 只允许 `mini_program`,手机微信内网页 `wechat_h5` 只允许 `wechat_h5 + ios/android`,桌面微信内网页 `wechat_native` 只允许 `wechat_h5 + windows/macos/linux`。非微信环境前端不显示充值入口,改显示兑换码入口。 -- 影响范围:`platform-auth` JWT claims、`api-server` auth session/refresh session 签发、`runtime_profile` 充值订单接口、前端支付平台隔离层和“我的”页常用功能区。 -- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p api-server profile_recharge_order --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - -## 2026-05-15 抓大鹅结果页 UI 预览复用运行态布局 - -- 背景:抓大鹅结果页 `素材配置 > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实运行态顶部关卡卡片、右上设置入口、容器图定位及槽位样式出现漂移。 -- 决策:结果页 UI 预览只组合 `match3dRuntimeUiStyles` 中的运行态 HUD、棋盘、容器图和槽位样式常量;运行态尺寸或视觉层级调整时,同步由这些常量影响预览,不再在结果页单独维护另一套预览 UI。 -- 影响范围:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`、抓大鹅结果页测试和玩法链路文档。 -- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认预览顶部 HUD、设置入口、容器图定位和槽位样式与运行态一致。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 拼图拖拽视觉即时跟手且拼块裁切圆角 - -- 背景:拼图运行态在触控拖拽时不能有追手延迟,同时拼图片需要真实圆角裁切,合并后的 L 形 / 凹形块不能靠单格圆角拼接。 -- 决策:`PuzzleRuntimeShell` 拖拽视觉在 `pointermove` 期间直接写入当前可见拼块 DOM 的 `translate3d(...)`,拖拽阶段禁用 transform transition,不依赖后端回包、React 重渲染或 `requestAnimationFrame` 队列。拖动过程中不展示拼块选中态或底部“已选择”提示,选中状态只保留给点击交换语义。单块通过 `buildRoundedGridCellClipPath()` 裁切独立圆角;合并块视觉层必须用 SVG 原生 `clipPath` 裁切整体外轮廓,不只依赖 HTML `clip-path:url(#...)`,外凸角和内凹角分开计算半径,内凹角半径更大以避免移动端看起来仍是直角。 -- 影响范围:拼图运行态拖拽手感、`puzzleRuntimeShape` 圆角路径、拼图运行态测试和玩法链路文档。 -- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 拼图运行态点击弹层不使用像素素材框 - -- 背景:拼图运行态主界面已经切到平台主色主题,但提示和设置弹层仍复用像素九宫格素材框,和当前拼图视觉不一致。 -- 决策:拼图运行态的提示、设置等点击弹层跟随 `puzzle-runtime-*` 主色主题,使用普通圆角主题面板和 CSS 变量分层,不再套 `pixel-nine-slice` / `pixel-modal-shell` 或 `UI_CHROME.modalPanel`。 -- 影响范围:`PuzzleRuntimeShell` 的道具确认弹窗、设置弹窗、拼图运行态样式和玩法链路文档。 -- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,确认提示和设置 dialog 不包含像素框类名。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 拼图运行态壳层必须自带平台主题类 - -- 背景:`/puzzle` 直达页和运行态提前返回分支不一定挂在外层平台壳里,若壳层只依赖父级主题类,就会出现修改已经生效但页面看起来还是旧样式的错觉。 -- 决策:`PuzzleRuntimeShell` 自身在正常运行态和等待态都补齐 `platform-ui-shell platform-theme platform-theme--light|dark`,让直达页和平台内嵌页共享同一套主题变量。 -- 影响范围:`PuzzleRuntimeShell` 根容器、路由直达页、拼图运行态测试和玩法链路文档。 -- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx` 与 `npm run typecheck`,确认壳层 className 包含平台主题类。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 抓大鹅草稿自动编排不再要求背景音乐 - -- 背景:抓大鹅音频入口关闭后,草稿生成仍可能在后端自动编排中调用 Suno,导致“提交 Suno 背景音乐任务失败”阻断草稿生成。 -- 决策:`match3d_compile_draft` 的完成条件只包含 2D 五视角物品图片、UI 背景和容器图;点击音效只在 `generateClickSound=true` 的历史配置下作为可选补齐。背景音乐字段仅作为历史兼容传递,不再由草稿自动生成或补齐。 -- 影响范围:`api-server` Match3D 草稿资产编排、抓大鹅音频关闭口径、玩法链路文档。 -- 验证方式:执行 `cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 抓大鹅运行态右上角统一为设置入口 - -- 背景:抓大鹅运行态右上角原先直接暴露重新开始按钮,和拼图的设置入口口径不一致,也不利于把设置、重开和后续扩展动作收口到统一面板。 -- 决策:`Match3DRuntimeShell` 右上角改为设置按钮,点击后打开独立设置面板;重新开始动作仅放在设置面板内,结算弹层继续保留再来一局。拼图右上角设置图标同步改为非像素风 `lucide-react` `Settings` 图标。 -- 影响范围:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、对应测试和玩法链路文档。 -- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认拼图设置按钮不再渲染像素图片、抓大鹅右上角打开设置面板且面板内可重新开始。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 汪汪声浪和宝贝识物入口设为敬请期待 - -- 背景:当前需要暂时关闭汪汪声浪和宝贝识物两个模板的创建链路,但仍保留创作 Tab 中的模板占位。 -- 决策:`bark-battle` 与 `baby-object-match` 的默认创作入口配置调整为 `visible=true`、`open=false`、`badge=敬请期待`;SpacetimeDB 入口配置种子会把旧默认开放行纠偏为敬请期待,api-server 路由熔断覆盖 `/api/creation/bark-battle/*`、`/api/runtime/bark-battle/*` 和 `/api/creation/edutainment/baby-object-match/*`。 -- 影响范围:`module-runtime` 默认入口配置、`spacetime-module` 入口配置种子纠偏、`api-server` 入口路由熔断、创作玩法文档和入口配置排障记忆。 -- 验证方式:执行 `cargo test -p module-runtime default_creation_entry_types --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server creation_entry_config --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`、`npm run check:encoding`,并用 `npm run api-server` 后检查 `/healthz` 与 `/api/creation-entry/config`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 抓大鹅生成素材记录物品相对尺寸 - -- 背景:抓大鹅 2D 五视角素材此前只保存物品名称和图片,运行态所有生成素材显示尺寸一致;用户要求生成物品名称时同时给出合理的 `大 / 中 / 小` 相对尺寸,并把当前默认尺寸视为 `大`。 -- 决策:`match3d_compile_draft` 的生成计划 `items[]` 增加 `itemSize`,只允许 `大 / 中 / 小`;后端把该字段持久化到 `generatedItemAssets[].itemSize` 并通过 Agent / Works DTO 下发。历史缺失 `itemSize` 的素材按 `大` 兼容;模型缺失或返回非法值时按物品名称本地推断,仍无法判断时使用 `中`。运行态只用该字段缩放生成 2D 图片本体,不改变后端下发的布局半径、点击半径和规则真相。 -- 影响范围:`api-server` Match3D 草稿生成计划、`shared-contracts` 与 TS Match3D 作品契约、结果页素材合并、`Match3DRuntimeShell` 场内/托盘/飞行动画图片显示、Match3D PRD 与素材流水线技术文档。 -- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml`、`npm run typecheck`、`npm run check:encoding`,并定向跑 `Match3DRuntimeShell.test.tsx` 中尺寸和生成图片相关用例。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 - -## 2026-05-15 充值商品配置入库且首充按档位独立计算 - -- 背景:泥点充值原来依赖代码中的固定商品目录,首充双倍也按账号是否买过任一泥点统一隐藏,导致用户买过 `points_60` 后其它未购买档位也失去首充展示。 -- 决策:新增 `profile_recharge_product_config` 作为泥点和会员商品配置真相源,默认商品只在空库时播种;后台通过“充值商品”页维护配置。泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断,`hasPointsRecharged` 仅保留为账号是否发生过任一泥点充值的兼容字段,不再驱动所有商品展示或结算。 -- 影响范围:`module-runtime` 充值领域输入、`spacetime-module` 充值表与 procedure、`spacetime-client` bindings/facade、`api-server` `/admin/api/profile/recharge-products`、`apps/admin-web` 充值商品页、主站充值弹窗。 -- 验证方式:执行 `cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - ## 2026-05-14 创作页图像输入统一封装为图像组件 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 - 决策:通用图像输入 UI 统一使用 `src/components/common/CreativeImageInputPanel.tsx`。组件采用受控模式,只负责主图上传卡、画面描述输入、参考图缩略图与预览、AI 重绘开关、错误展示和提交按钮;外层页面负责文件读取/裁剪、历史素材弹层、计费确认、自动保存和具体后端请求。 - 影响范围:拼图创作入口、后续抓大鹅封面生成入口、其它需要复用图像输入链路的创作页。 - 验证方式:拼图入口交互测试继续覆盖四种路径;后续页面接入时只传入业务回调与文案,不复制上传卡和参考图缩略图实现。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/【前端体验】图像组件统一封装与复用边界-2026-05-14.md`。 ## 2026-05-14 汪汪声浪创作入口改为创作 Tab 内嵌轻配置 @@ -110,7 +30,7 @@ - 决策:`bark-battle` 的创作入口只在创作 Tab 内嵌渲染轻配置表单,入口点击只切到创作页并选中该模板,不再使用 `bark-battle-config` 独立阶段;runtime 退出时回到创作页并恢复汪汪声浪模板选中态。 - 影响范围:`PlatformEntryFlowShellImpl`、`BarkBattleConfigEditor`、`BarkBattleRuntimeShell`、入口配置说明和相关交互测试。 - 验证方式:创作 Tab 中点击汪汪声浪后直接看到内嵌表单,不应再出现单独配置页;发布进入 runtime 后退出应回到创作页的汪汪声浪模板。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`。 ## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏 @@ -118,7 +38,7 @@ - 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。 - 影响范围:`CustomWorldGenerationView`、拼图与抓大鹅创作入口调用处、移动端生成页体验文档。 - 验证方式:拼图与抓大鹅生成页在手机竖屏下只显示等待与计时双栏,步骤卡按顺序滑入;其它未传入隐藏参数的生成页继续保留原批次模块。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 ## 2026-05-14 移动端输入法弹出时平台画布不压缩 @@ -126,7 +46,7 @@ - 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,只通过 `--platform-keyboard-focus-offset` 上移画面聚焦当前输入框,并临时隐藏移动端底部 dock。 - 影响范围:主站平台壳、移动端创作首页底部输入框、后续所有复用 `.platform-viewport-shell` 的输入表单;业务组件不重复注册键盘适配。 - 验证方式:手机竖屏点击输入框,画布不压缩,输入框移动到输入法上方;输入法关闭后画布回位,底部 dock 恢复。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 ## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式 @@ -134,7 +54,7 @@ - 决策:继续复用 `POST /api/creation/match3d/works/{profileId}/item-assets`,请求体通过 `mode = "replace"` 表达替换模式;前端面板预填当前素材名称,只提交仍能匹配到已有素材的名称。后端只替换匹配素材的 `imageSrc/imageObjectKey/imageViews/status/error`,保留原 `itemId`、列表顺序、模型兼容字段、UI 背景、历史背景音乐和点击音效字段;未匹配名称不计费、不新增、不持久化。 - 影响范围:Match3D 结果页素材配置、前端/后端 shared contracts、`api-server` Match3D item-assets 编排、运行态物品类型映射和素材生成技术文档。 - 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`cargo test -p api-server match3d_item_asset --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d_regenerated_asset --manifest-path server-rs\Cargo.toml`、`npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-14 拼图与抓大鹅音频生成入口临时关闭 @@ -142,7 +62,7 @@ - 决策:拼图 `compile_puzzle_draft` 不再自动生成背景音乐,结果页素材配置只保留 `UI`;抓大鹅 `match3d_compile_draft` 和批量新增只生成 2D 图片、背景和容器 UI,不再调用 Suno/Vidu,结果页隐藏 `背景音乐` 子 Tab 与点击音效生成控件;通用 `/api/creation/audio/*` 当前整体返回 `410 Gone`。历史已写入的 `backgroundMusic` / `clickSound` 字段保留,运行态继续兼容播放旧音频。 - 影响范围:`api-server` 拼图/抓大鹅草稿编排、通用创作音频路由、拼图/抓大鹅结果页、生成进度模型、相关技术文档。 - 验证方式:执行拼图/抓大鹅结果页定向测试、生成进度单测、`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-14 抓大鹅物品素材 sheet 改用 VectorEngine Gemini @@ -150,7 +70,7 @@ - 决策:抓大鹅物品素材 sheet 生图固定走 VectorEngine `POST {VECTOR_ENGINE_BASE_URL}/v1beta/models/gemini-3-pro-image-preview:generateContent?key={VECTOR_ENGINE_API_KEY}`,请求体使用 `contents[].parts[].text` 与 `generationConfig.responseModalities = ["TEXT", "IMAGE"]`、`imageConfig.aspectRatio = "1:1"`;响应从 `candidates[].content.parts[].inlineData.data` / `inline_data.data` 读取 base64 图片。封面、9:16 纯背景图、1:1 容器 UI 图、切图、OSS、扣费和运行态消费链路保持不变;音频以后续“拼图与抓大鹅音频生成入口临时关闭”决策为准。 - 影响范围:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/config.rs`、`deploy/env/api-server.env.example`、抓大鹅素材生成技术文档。 - 验证方式:执行 `cargo test -p api-server match3d_material_sheet --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d_vector_engine_gemini --manifest-path server-rs\Cargo.toml`、`cargo check -p api-server --manifest-path server-rs\Cargo.toml`、`npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-14 草稿页作品卡对齐分类页列表 @@ -158,7 +78,7 @@ - 决策:草稿页作品卡统一收口为与分类页一致的横向列表卡结构,左侧承载标题/状态/类型/摘要与必要数据,右侧显示带透明度的封面图;移动端保持单列列表,网页端使用两到三列卡片式网格,避免宽屏长条列表。不再常驻“继续创作”“查看详情”“查看进度”等右侧动作按钮。原有删除、分享、积分激励、公开统计、未读红点全部保留,其中删除与分享进入左滑操作层,常态不显示删除按钮,也不得透出删除底层。生成中的作品在整卡上加半透明蒙版、旋转等待符号和“生成中...”标识,但不移除任何原有信息。 - 影响范围:`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldWorkCard.tsx`、相关样式与测试、草稿页 UI 文档。 - 验证方式:草稿页作品卡与分类页列表视觉口径保持一致;`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 2026-05-14 补充:草稿页作品卡不再用“草稿 / 已发布”文字标识状态,改为图标化 UI 状态点;作品封面直接铺到卡片右半区并从右向左渐隐;已发布作品右上角常驻分享图标;草稿长按弹出删除面板,已发布长按弹出分享和删除面板。 @@ -168,23 +88,15 @@ - 决策:运行期认证变更继续由 `module-auth` 生成一致内存快照,但 `api-server` 改为调用 `import_auth_store_snapshot_json` 直接覆盖导入 `user_account/auth_identity/refresh_session`;`auth_store_projection_meta/default` 只记录正式认证表最近一次导入时间;`upsert_auth_store_snapshot` 与 `import_auth_store_snapshot` 仅保留为旧库迁移和兜底入口。 - 影响范围:`spacetime-module` auth procedures/tables、`spacetime-client` auth facade/bindings、`api-server` 认证同步和启动恢复、SpacetimeDB 表目录与认证 Stage 3 文档。 - 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 ## 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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - -## 2026-05-15 微信充值默认真实渠道与 mock 禁用 - -- 背景:账户充值扩展到普通商户直连 H5 与 Native 支付后,旧的“非小程序默认 mock”会把真实用户充值静默导向测试通道。 -- 决策:默认支付渠道只由设备平台隔离层解析为 `wechat_mp` / `wechat_h5` / `wechat_native`:小程序走 `wechat_mp`,移动网页含微信内 H5 走 `wechat_h5`,桌面网页走 `wechat_native` 二维码。`paymentChannel` 缺失或未知直接 `400`;生产/真实支付配置拒绝 `mock`,只有自动测试或显式 mock 测试配置可手动传 `paymentChannel = "mock"`。真实微信渠道必须在 `WECHAT_PAY_ENABLED=true` 且 `WECHAT_PAY_PROVIDER=real` 下单,禁止由 mock provider 返回 H5/Native/小程序 mock 支付载荷。所有微信渠道仍以后端通知与服务端查单入账。 -- 影响范围:`src/services/payment/paymentPlatform.ts`、`RpgEntryHomeView` 充值弹窗、`api-server` 充值订单接口、`WechatPayClient` H5/Native 下单、共享 recharge contracts。 -- 验证方式:执行 `npm run test -- src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 验证方式:执行 `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 dev:api-server` 和 `/healthz`。 +- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 ## 2026-05-13 修改密码后全设备强制下线 @@ -192,15 +104,15 @@ - 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie;前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 - 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate`、`authService`、密码登录/重置技术文档。 - 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture`、`npm run test -- AuthGate.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`、`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`。 ## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 - 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 - 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 - 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 -- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run api-server` 检查 `/healthz`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 ## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格 @@ -208,7 +120,7 @@ - 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。 - 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。 - 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx`、`cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml`、`npm run typecheck`、`npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`。 ## 2026-05-12 拼图与抓大鹅草稿背景音乐按纯音乐自动生成 @@ -216,7 +128,7 @@ - 决策:复用通用 VectorEngine Suno 创作音频链路,不新增 SpacetimeDB 表;拼图音乐保存到首关 `PuzzleDraftLevel.backgroundMusic`,运行态通过 `PuzzleRuntimeLevelSnapshot.backgroundMusic` 下发;抓大鹅音乐保存到首个 `generatedItemAssets[].backgroundMusic`。两者草稿生成都使用 `title` 驱动、`prompt = ""`、`make_instrumental = true`;自动草稿阶段必须拿到可播放 `audioSrc` 才能返回成功,失败时停留在生成页并允许重试同一 session/profile。结果页内的手动重新生成继续作为已有草稿的补救入口。 - 影响范围:`api-server` 音频生成、拼图草稿编译、抓大鹅草稿编译、Puzzle/Match3D 结果页和运行态音频播放。 - 验证方式:检查草稿 response / work detail 中的 `backgroundMusic.audioSrc`,运行态开局后隐藏 audio 循环播放;执行音频相关后端 check、前端 typecheck 和编码检查。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 @@ -224,7 +136,7 @@ - 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 ## 2026-05-12 抓大鹅结果页素材编辑统一走作品级资产面板 @@ -232,7 +144,7 @@ - 决策:结果页 `作品信息` 的封面图点击打开独立面板,封面图面板对齐拼图入口上传卡。已有上传主图时,请求体传 `uploadedImageSrc`,AI 重绘走 VectorEngine `/v1/images/edits`;关闭 AI 重绘时只写回上传图,不调用生图。没有上传主图时,请求体传 `referenceImageSrcs`,可混合本地上传、物品素材和 UI 素材,多参考图作为 `gpt-image-2-all` generations 的 `image` 数组传入。生成结果统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets`。`素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`,复用草稿生成的 2D 素材图、5x5 切图、OSS 上传和可选点击音效链路,仅作用于新增物品,不新增 SpacetimeDB 表。 - 影响范围:Match3D 结果页、Match3D works shared contracts、`api-server` Match3D 作品路由、生成资产历史类型和草稿恢复路径。 - 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run typecheck`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-12 平台法律文档入口与登录协议确认 @@ -240,14 +152,14 @@ - 决策:法律文档内容读取 `media/files/*.md`,统一通过 `LegalDocumentModal` 独立弹窗展示;“我的”页常用功能区固定 3 列,设置入口下方展示法律信息和 `京ICP备2026025677号` 外链。登录弹窗用 `genarrative.auth.legal-consent.v1` 记录本机确认,首次未勾选时短信 / 密码登录按钮禁用,法律链接不自动勾选。 - 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 - 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 ## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 - 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 - 决策:小程序壳在 `pending_bind_phone` 时暂不打开 H5,先展示原生 `button open-type="getPhoneNumber"`;用户同意后把 `bindgetphonenumber` 返回的 `code` 作为 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`。后端通过微信 `stable_token` 与 `getuserphonenumber` 换取平台验证后的手机号,再复用现有微信待绑定账号合并逻辑并重新签发 active 系统 token。H5 旧短信验证码绑定流程继续作为非小程序环境兜底。 - 影响范围:`miniprogram/pages/web-view/index.*`、`server-rs/crates/platform-auth`、`server-rs/crates/api-server/src/wechat_auth.rs`、认证共享契约、微信小程序 web-view 壳技术文档。 - 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。 ## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地 @@ -255,7 +167,7 @@ - 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2-all` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。 - 影响范围:`packages/shared/src/contracts/edutainmentBabyDrawing.ts`、`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx`、`src/services/edutainment-baby-drawing/`、`src/routing/appRoutes.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`server-rs/crates/api-server/src/edutainment_baby_drawing.rs`、`src/index.css`、宝贝爱画 PRD 与技术方案。 - 验证方式:执行宝贝爱画 model/runtime/service/route 定向测试、`npm run typecheck`、定向 ESLint、`cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml` 和编码检查;真实魔法生成需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md`、`docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`。 ## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包 @@ -263,7 +175,7 @@ - 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。视觉包包含 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。 - 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。 - 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 ## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路 @@ -272,7 +184,7 @@ - 2026-05-12 补充:抓大鹅入口页新增 `generateClickSound` 开关,默认关闭;开启时 `match3d_compile_draft` 在生成首批 2D 物品素材后并行生成各物品点击音效,并继续复用通用创作音频路由的 OSS、资产绑定和扣费口径。 - 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。 - 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck`、`cargo test -p api-server vector_engine_audio_generation`、`cargo test -p shared-contracts creation_audio`、`cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`。 ## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入 @@ -280,7 +192,7 @@ - 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match`、`templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。 - 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。 - 验证方式:执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`、`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 @@ -288,7 +200,7 @@ - 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 ## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 @@ -296,7 +208,7 @@ - 决策:SpacetimeDB `creation_entry_type_config` 中 `square-hole.visible=false` 作为创作页统一开关;创作 Tab 模板入口、旧选择弹层、创作 Hub 卡带和创作页作品架都基于该开关隐藏方洞挑战。既有方洞详情、作品号、广场和运行态链路暂不删除,api-server 路由熔断只按 `open=false` 禁用玩法 API。 - 影响范围:SpacetimeDB 入口配置默认种子、`platformEntryCreationTypes`、`CustomWorldCreationHub`、`PlatformEntryFlowShellImpl` 以及创作入口相关文档和回归测试。 - 验证方式:执行入口配置、创作 Hub 和平台入口交互定向测试,确认看不到“方洞挑战” Tab、按钮和作品架条目。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 ## 2026-05-14 视觉小说从创作页入口隐藏 @@ -304,7 +216,7 @@ - 决策:SpacetimeDB `creation_entry_type_config` 默认种子中 `visual-novel.visible=false` 且 `open=false`;旧默认可见配置会被迁移为隐藏和关闭。前端继续只消费 `GET /api/creation-entry/config`,不得用硬编码恢复视觉小说模板入口。 - 影响范围:SpacetimeDB 入口配置默认种子、api-server 测试配置、创作页模板 Tab、创作 Hub 测试和创作入口文档。 - 验证方式:执行入口配置、创作 Hub、平台入口交互和 api-server 路由熔断定向测试,确认“视觉小说”不出现在创作页且 `/api/creation/visual-novel/*` 默认被熔断。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`、`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。 ## 2026-05-10 运行态输入设备抽象层全项目通用化 @@ -312,7 +224,7 @@ - 决策:前端运行态输入统一通过 `src/services/input-devices/` 承接,设备适配层只输出 `press / move / release / tap / drop` 等通用语义和通用坐标;玩法组件自己解释目标对象、落点和业务动作,输入层不得写拼图等玩法专用规则。 - 影响范围:拼图运行态鼠标/触控/mocap 输入、后续运行态设备接入、运行态输入技术文档与相关前端回归测试。 - 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts`、`npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`、`docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`。 ## 2026-05-11 前端调试模式统一判断 @@ -320,7 +232,7 @@ - 决策:前端新增 `src/config/debugMode.ts` 作为全局调试模式判断,默认跟随 Vite 开发态,允许 `VITE_DEBUG_MODE=true/false` 显式覆盖。2026-05-14 起,拼图运行态已临时移除 mocap 调用、体感光标和 mocap 调试面板;调试模式仍供其它局部诊断 UI 使用。 - 影响范围:前端局部调试 UI、拼图运行态 mocap 诊断面板、`.env.example` 和运行态输入技术文档。 - 验证方式:执行 `npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`。 ## 2026-05-10 儿童动作热身关直接消费 mocap 数据源 @@ -328,15 +240,15 @@ - 决策:热身关全流程直接接入 `useMocapInput`,通过本地 mocap WebSocket `/stream` 消费 `general.body.center_norm` 身体中心、`actions/action/gesture/gestures/event/name/type` 动作名,以及 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 手部坐标;位置步骤由身体中心推进,`wave_greeting`、`wave_left_hand`、`wave_right_hand` 和 `jump_once` 由 mocap 手势/轨迹推进。浏览器摄像头只作为背景层,动作数据源状态优先展示,键鼠仍作为本地调试兜底。 - 影响范围:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、对应单测与热身关技术文档。 - 验证方式:执行 `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 可握手、`http://127.0.0.1:3000/child-motion-demo` 可访问。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 ## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine - 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。 - 决策:所有 GPT-image-2 生图请求统一走 VectorEngine `POST /v1/images/generations`,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2-all`,请求体不再携带 `official_fallback`,参考图字段改为 `image`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 - 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。 -- 验证方式:执行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run api-server` + `/healthz` 做后端 smoke。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 验证方式:执行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run dev:api-server` + `/healthz` 做后端 smoke。 +- 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 ## 2026-05-08 Hyper3D Rodin Gen-2 只通过后端安全代理接入 @@ -344,7 +256,7 @@ - 决策:Hyper3D 统一走 `api-server` 的 `/api/assets/hyper3d/*` 鉴权路由,配置只读取 `HYPER3D_BASE_URL` / `HYPER3D_API_KEY` / `HYPER3D_MODEL_REQUEST_TIMEOUT_MS` 及兼容 `RODIN_*` 变量;生成提交、状态查询和下载列表都由后端代理。首版不写 SpacetimeDB、不确认 `asset_object`,下载链接后续由调用方决定是否进入 OSS 资产链。 - 影响范围:`api-server` 外部服务配置、Hyper3D route、`shared-contracts` / TS contract、前端 service、生产环境示例和外部服务环境变量文档。 - 验证方式:执行 `cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts hyper3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck` 和编码检查;真实 API smoke 只在本地私密环境配置 key 后手动执行。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联文档:`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 ## 2026-05-08 APIMart 接口统一携带 `official_fallback` @@ -354,7 +266,7 @@ - 决策:凡是仓库内调用 APIMart 的 OpenAI 兼容接口,请求体统一携带 `official_fallback: true`;其中图片生成请求直接固定写入,`platform-llm` 的 APIMart GPT-5 client 通过显式开关开启,不默认扩散到 Ark 等其它 provider。 - 影响范围:`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle.rs`、`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/platform-llm/src/lib.rs`、`.codex/skills/gpt-image-2-apimart/` 和相关技术文档。 - 验证方式:图片生成与 creative-agent APIMart 路径的单测都应断言 `official_fallback` 已写入请求 JSON;编码检查和相关 Rust 测试应持续通过。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md`、`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`。 ## 2026-05-07 server-rs Cargo 依赖集中到 workspace @@ -362,23 +274,23 @@ - 决策:`server-rs/Cargo.toml` 的 `[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;不再新增 `sha1`,OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。 - 影响范围:`server-rs/Cargo.toml`、所有 `server-rs/crates/*/Cargo.toml`、`platform-oss`、`platform-auth`、后续新增 Rust crate 或新增 Rust 依赖的开发流程。 - 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`。 ## 2026-05-08 资料页反馈提交必须走 Rust 后端与 SpacetimeDB - 背景:`/profile/feedback` 首版页面曾只做前端成功态,无法沉淀到用户账号和数据库,也容易与主站平台主题脱节。 - 决策:反馈提交统一走鉴权 HTTP 路由 `POST /api/profile/feedback`,由 `api-server` 取当前 access token 用户,调用 `spacetime-client` facade,再通过 `spacetime-module` procedure 写入私有表 `profile_feedback_submission`;前端只负责输入采集、Data URL 预览和提交元数据,不再保存 `File[]` 作为外部契约。 - 影响范围:`src/components/platform-entry/PlatformFeedbackView.tsx`、`src/services/rpg-entry/rpgProfileClient.ts`、`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts`、`api-server`、`module-runtime`、`spacetime-client`、`spacetime-module`、表目录与 bindings。 -- 验证方式:前端定向测试应覆盖 Data URL 预览与 `/api/profile/feedback` 请求体;后端变更需同步 `migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 和生成绑定;API smoke 使用 `npm run api-server` 和 `/healthz`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:前端定向测试应覆盖 Data URL 预览与 `/api/profile/feedback` 请求体;后端变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和生成绑定;API smoke 使用 `npm run dev:api-server` 和 `/healthz`。 +- 关联文档:`docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。 ## 2026-05-06 Maincloud 历史残留引用禁止再使用 - 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 -- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run api-server` 并检查 `/healthz`。 -- 影响范围:`AGENTS.md`、当前 `docs/` 融合文档、`.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。 +- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run dev:api-server` 并检查 `/healthz`。 +- 影响范围:`AGENTS.md`、`docs/technical/`、`.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。 - 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。 ## 2026-05-05 新手引导首版复用拼图本地运行时 @@ -386,7 +298,7 @@ - 决策:未登录首次访问由前端 localStorage 标记触发;生成入口走公开 BFF `POST /api/runtime/puzzle/onboarding/generate` 生成 1 关临时拼图;登录后保存走鉴权 BFF `POST /api/runtime/puzzle/onboarding/save`,由服务端创建当前用户拼图 agent session 并更新其草稿作品 profile;游玩阶段复用现有本地拼图运行时。 - 影响范围:平台入口首屏、新手引导 PRD、拼图 BFF、拼图作品契约与前端 puzzle runtime。 - 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`。 ## 2026-05-05 text-game 作为陶泥儿幕间文字游戏模板接入 @@ -394,7 +306,7 @@ - 决策:新增 `text-game` 作为陶泥儿 AI 原生文字游戏模板口径,展示名可用“幕间”或“幕间文字”;它与 `visual-novel` 分离,重点是 AI GM、自由行动、状态后果、长期记忆、章节目标和轻量剧本模拟器;入口、作品、发布、资产、钱包、埋点、存档和广场全部复用陶泥儿平台接口;禁止新增 replay、外部社区、外部支付、外部榜单和私有存档系统。 - 影响范围:后续 `text-game` shared contracts、`module-text-game`、SpacetimeDB 表、`api-server` 路由、前端入口 / workspace / result / runtime、平台作品架和发现聚合。 - 验证方式:后续落地时确认路由使用 `/api/creation/text-game/*` 与 `/api/runtime/text-game/*`;确认正式业务真相在 Rust / SpacetimeDB 后端;确认没有 `replay` 能力和外部平台功能误入;确认 `text-game` 不复用 `visual-novel` step 契约作为运行态真相。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md`。 ## 2026-05-05 2048 玩法模板采用 `twenty-forty-eight` 工程域 @@ -402,15 +314,15 @@ - 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`,Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。 - 影响范围:后续 SpacetimeDB 创作入口配置、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。 - 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`。 ## 2026-05-05 幸存者类玩法作为平台模板接入 - 背景:平台继续扩展新玩法模板,需要把幸存者 / 割草 / 轻度 Roguelite 类玩法纳入统一创作中心、作品架、广场和运行态体系,避免再起一套独立小游戏工程。 - 决策:新增 `survivor` 作为 Genarrative 平台玩法模板,统一使用 `server-rs + Axum + SpacetimeDB`,创作端、结果页、试玩、发布和运行态都复用平台接口;前端只负责表现和高频模拟,不承接正式规则真相。 -- 影响范围:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、后续 `survivor` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台作品闭环。 +- 影响范围:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`、后续 `survivor` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台作品闭环。 - 验证方式:后续落地时检查 `survivor` 入口、session、work profile、runtime run、checkpoint、升级候选和结算接口是否都落在平台统一链路内,并确认没有新增独立小游戏壳层。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`。 ## 2026-05-05 视觉小说 TXT 玩法只作为平台模板接入且删除回放 @@ -418,15 +330,15 @@ - 决策:`visual-novel` 只作为 Genarrative 视觉小说模板接入,保留想法 / 文档 / 空白创建、世界观 / 角色 / 场景 / 剧情阶段编辑、视觉小说 step 运行时、历史和重生成等模板能力;入口、作品、发布、资产、钱包、存档和广场全部使用 Genarrative 平台接口;彻底删除回放、分享回放、回放编译、回放路由、回放表和回放 UI。 - 影响范围:视觉小说 PRD、旧 TXT 文档口径、后续 `visual-novel` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台存档接入。 - 验证方式:后续落地时扫描前端、后端、契约、表和文档,确认不存在 `replay` 能力;确认视觉小说没有迁入外部平台账号、订单、会员、促销、后台、公开市场或私有存档系统;确认后端落在 `server-rs + Axum + SpacetimeDB`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md`、`docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md`。 ## 2026-05-05 视觉小说 VN-02 表与 spacetime-client facade 收口 - 背景:`visual-novel` 后续 API、创作工作台和运行时需要稳定的 SpacetimeDB schema 与 Rust facade,且必须延续“无回放、无私有存档”的产品边界。 - 决策:视觉小说首批数据库只落六张表:`visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event`;`visual_novel_runtime_event` 是 `public event` 审计事件表,不是 replay 数据源;运行历史只保存继续体验与历史重生成需要的 typed step 和快照哈希。`api-server` 后续接入必须经 `spacetime-client/src/visual_novel.rs` typed facade,不直接依赖生成 bindings。 -- 影响范围:`server-rs/crates/spacetime-module/src/visual_novel.rs`、`migration.rs`、`server-rs/crates/spacetime-client/src/visual_novel.rs`、`module_bindings/`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、VN-05 API 联调。 +- 影响范围:`server-rs/crates/spacetime-module/src/visual_novel.rs`、`migration.rs`、`server-rs/crates/spacetime-client/src/visual_novel.rs`、`module_bindings/`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`、VN-05 API 联调。 - 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`npm run check:encoding`;扫描视觉小说 schema / facade / 表目录确认没有 `replay` 表、路由或私有 save 表。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 ## 2026-05-05 视觉小说 VN-07 前端创作闭环按阶段边界落地 @@ -434,31 +346,31 @@ - 决策:VN-07 前端只接入口、Agent 工作台、可编辑 `VisualNovelResultDraft` 结果页和测试 run;`blank` 起点直接生成本地空白草稿进入结果页,`idea` / `document` 继续调用 `/api/creation/visual-novel/sessions`;结果页保存先更新当前 session 草稿,显式“编译草稿”才调用 `/compile`,测试 run 在真实 runtime 不可用时降级为本地 test run。 - 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/visual-novel-creation/`、`src/components/visual-novel-result/`、`packages/shared/src/contracts/visualNovel.ts`、视觉小说 PRD。 - 验证方式:执行前端 typecheck、视觉小说工作台 / 结果页定向测试和编码检查;确认未新增 replay、作品聚合或正式 runtime 能力。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 ## 2026-05-07 视觉小说 VN-11 负向扫描门禁 - 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。 - 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/`、`packages/shared/src/`、`server-rs/crates/`、`docs/` 与 `.hermes/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。 - 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。 -- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `.tmp/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`。 ## 2026-05-07 视觉小说 VN-12 采用单独验收门禁脚本 - 背景:VN-12 是视觉小说模板的全链路联调与自动化验收收口任务,需要把关键路径、API smoke、前端测试和报告输出固化成可复跑门禁,避免后续改动只靠手工口述结论。 -- 决策:新增 `npm run check:visual-novel-vn12`,由 `scripts/check-visual-novel-vn12-acceptance.mjs` 校验 PRD、VN-11 报告、关键前端测试、视觉小说 service client、`api-server` / `module-visual-novel` / `shared-contracts` 相关文件和路由命中,并生成 `.tmp/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`。 +- 决策:新增 `npm run check:visual-novel-vn12`,由 `scripts/check-visual-novel-vn12-acceptance.mjs` 校验 PRD、VN-11 报告、关键前端测试、视觉小说 service client、`api-server` / `module-visual-novel` / `shared-contracts` 相关文件和路由命中,并生成 `docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`。 - 影响范围:VN-12 验收、视觉小说后续回归、同类玩法的收口门禁模式。 - 验证方式:执行 `npm run check:visual-novel-vn12 -- --write-report`,报告应覆盖自动化验收清单、API smoke、前端关键路径、桌面/移动端检查说明和已执行命令;若脚本失败,直接回流到对应 owner 修复。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`。 ## 2026-05-07 视觉小说 VN-13 文档与交接收口 - 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。 -- 决策:视觉小说后续维护的正式入口固定为当前玩法链路文档、当前后端架构文档和 `npm run check:visual-novel-vn11` / `npm run check:visual-novel-vn12` 两个门禁;旧 TXT 迁移文档和旧视觉小说阶段文档不再作为实现目标。 +- 决策:视觉小说后续维护的正式入口固定为 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`SPACETIMEDB_TABLE_CATALOG.md`、`VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md`、`VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md` 和 `VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;旧 TXT 迁移文档仅保留历史参考地位。 - 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。 -- 验证方式:打开当前融合文档并运行 VN 门禁即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:打开上述文档即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`。 ## 2026-05-05 平台移动端一级 Tab 改为推荐/发现/创作/草稿/我的 @@ -466,7 +378,7 @@ - 决策:前端内部继续复用 `PlatformHomeTab` 的 `home/category/create/saves/profile` 状态值,但用户看到的一级 Tab 分别为“推荐/发现/创作/草稿/我的”;`home` 直接展示公开推荐流,`category` 承载发现页及排行子 Tab,`saves` 承载草稿作品架,原存档结构并入“我的-玩过”弹层。 - 影响范围:平台入口导航、移动端推荐页、发现页子 Tab、创作中心作品架、个人页玩过弹层、相关设计文档。 - 验证方式:检查移动端底部导航文案和顺序,确认登录态为“推荐/发现/创作/草稿/我的”,未登录态为“推荐/创作/发现”且创作居中;“推荐”无搜索/频道栏直出作品流,“发现”包含搜索/推荐/今日/分类/排行,“创作”只显示新建入口,“草稿”显示作品架,“我的-玩过”可恢复存档。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md`。 ## 2026-05-14 推荐页卡片主视觉优先于底部作者热区 @@ -474,7 +386,7 @@ - 决策:推荐页卡片底部信息区保持紧凑固定高度,切换手势仍只绑定在该区域;视觉主体高度优先扩展,不再让作者信息区占用过多首屏空间。 - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx` 的推荐页卡片布局,以及 `src/index.css` 中的推荐页卡片热区样式。 - 验证方式:移动端推荐页首屏应明显看到更大的作品内容区,底部作者信息区只保留紧凑一条,不再明显挤压运行态。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/PLATFORM_MOBILE_RECOMMEND_CARD_SAFE_SWIPE_LAYOUT_2026-05-12.md`。 ## 2026-05-05 创作 Tab 固定为智能创作首页,草稿 Tab 承接旧作品架 @@ -482,7 +394,7 @@ - 决策:`create` 只承载 `CreativeAgentHome` 智能创作首页与会话流,顶部品牌栏、问候、快捷胶囊、底部输入框和左侧抽屉是主结构;旧的新建作品类型卡不再在 `create` 里展示。原本的 RPG / 拼图 / 大鱼 / Match3D / 方洞 / 视觉小说作品架统一归到 `saves` 草稿 Tab。 - 影响范围:平台创作页布局、创作首页抽屉、草稿页作品架、相关交互测试、旧创作入口 helper。 - 验证方式:移动端点击“创作”直接看到智能创作首页;点击“草稿”看到旧作品架;旧模板入口不再从创作页出现。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 ## 2026-05-05 创意互动内容生成 Agent 采用 LangChain-Rust 六模块闭环 @@ -490,7 +402,7 @@ - 决策:新增方案改为基于 LangChain-Rust 的六模块 Agent 架构,核心模块是感知、思考、记忆、行动、反思、协作;首版只支持拼图玩法,但必须先展示多个拼图子模板候选,用户选择某个模板后,再确认该模板下的关卡模式、关卡数和预计积分范围,确认后才生成草稿;Agent 理解、规划和修订统一使用 APIMart Responses `gpt-5` 并支持文本/图像多模态输入;Agent 创作方式就是填充和修订模板草稿字段,表单化创作页与 Agent 自然语言修订都操作同一份 `PuzzleResultDraft`,且草稿可编辑字段只收敛为 `workTitle`、`workDescription`、`workTags`、`levels[].levelName`、`levels[].pictureDescription`、`levels[].pictureReference`;其中 `pictureReference` 已采用 `PuzzleDraftLevel.pictureReference` / Rust `picture_reference` 正式字段方案,不再走 metadata 过渡;单关卡/多关卡图片生成通过拼图模块 Tool 与模板协议实现;生成好的内容必须可立即试玩。 - 影响范围:创作中心入口、`platform-agent`、`module-creative-agent`、`module-puzzle` 拼图模板协议和工具、`shared-contracts`、`api-server` creative facade、SpacetimeDB creative agent 表、拼图玩法工具。 - 验证方式:后续落地时以创意互动内容生成 Agent 技术方案和 Phase 1 PRD 为编码依据,优先完成拼图 Phase 1,并执行 shared contracts、module、platform-agent、api-server、前端 typecheck 与编码检查。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`、`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。 ## 2026-05-05 creative-agent Task C 首版平台 PoC 已落地 @@ -498,15 +410,15 @@ - 决策:新增 `server-rs/crates/platform-agent` 作为独立 workspace crate,保留项目自有 `CreativeAgentExecutor`、工具注册表、回调事件和 mock executor;`platform-llm` 的 Responses 请求体扩展为可序列化 `input_text` / `input_image` content part。 - 影响范围:`server-rs/Cargo.toml`、`server-rs/crates/platform-agent`、`server-rs/crates/platform-llm`、任务 C 的后续 API / SSE 接入。 - 验证方式:`cargo check -p platform-agent`、`cargo test -p platform-agent`、`cargo test -p platform-llm responses_multimodal` 已通过。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`。 ## 2026-05-05 creative-agent Task E API / SSE facade 已落地 - 背景:Phase 1 需要先把创意 Agent 的 HTTP/SSE 门面接入 Rust `api-server`,用于前端工作区调用和拼图模板确认闭环。 - 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由;creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner;未确认模板前不创建拼图 session,`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session`;`gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client,不复用通用 `GENARRATIVE_LLM_API_KEY`。 - 影响范围:`server-rs/crates/api-server/src/creative_agent.rs`、`creative_agent_sse.rs`、`app.rs`、`state.rs`、`module-puzzle` creative template/tool、Phase 1 PRD。 -- 验证方式:`cargo check -p api-server`、`cargo test -p module-puzzle creative`、`cargo test -p api-server creative_agent`、`npm run api-server` 后检查 `/healthz`、`POST /api/runtime/creative-agent/sessions`、`POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo check -p api-server`、`cargo test -p module-puzzle creative`、`cargo test -p api-server creative_agent`、`npm run dev:api-server` 后检查 `/healthz`、`POST /api/runtime/creative-agent/sessions`、`POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`。 +- 关联文档:`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。 ## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择 @@ -514,23 +426,23 @@ - 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。画风卡片主视觉固定消费 `public/visual-novel-style-references/` 下由 VectorEngine `gpt-image-2-all` 生成的静态参考图,不在前端运行时现场调用生图接口。 - 影响范围:`VisualNovelAgentWorkspace`、`visualNovelEntryGeneration`、`PlatformEntryFlowShellImpl`、视觉小说 PRD 和创作 Tab 设计文档;不新增后端字段或数据库结构。 - 验证方式:执行 `npm run test -- VisualNovelAgentWorkspace`、视觉小说工作台相关 ESLint、`npx prettier --check` 和 `npm run check:encoding`;`npm run typecheck` 若失败需先区分是否来自无关 Match3D / RPG 既有改动。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。 ## 2026-05-10 用户标签只做后端白名单投影 - 背景:运营邀请码需要给账号打标签,但标签默认不能暴露到前端通用用户资料;拼图排行榜仅需展示特定标签。 - 决策:`user_account.user_tags` 保存账号标签,数据库默认 `None`,业务按空数组读取;后台预置邀请码使用后授予的标签不再使用独立列,统一存放并解析自 `profile_invite_code.metadata_json.userTags`,兼容读取 `user_tags`。通用登录态和个人资料不返回原始标签。首版只在拼图排行榜 `visibleTags` 中白名单投影 `北科`。 - 影响范围:用户认证表、邀请码后台、邀请兑换事务、拼图排行榜响应和 UI。 -- 验证方式:表结构变更需同步 `migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 和 SpacetimeDB bindings;后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:表结构变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 SpacetimeDB bindings;后端运行 `cargo check -p api-server`,后台运行 `npm run admin-web:typecheck`。 +- 关联文档:`docs/technical/USER_TAG_INVITE_AND_PUZZLE_LEADERBOARD_2026-05-10.md`。 ## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成 - 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。 - 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会按难度产出多视角 2D 物品图片并写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].imageViews[]`,默认积木只做兜底。 - 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息` 与 `素材配置` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。 -- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并用 `npm run dev:api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`;`docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md` 仅作历史参考。 ## 2026-05-12 抓大鹅物品种类从消除次数中拆出并改为 2D 五视角素材 @@ -538,7 +450,7 @@ - 决策:难度配置统一使用 `物品种类`:轻松 3、标准 9、进阶 15、硬核 21;历史硬核 `clearCount=20` 在运行态升为 21 组三消。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每个物品生成 5 个不同 2D 视角,单张 1K 素材图固定按 5x5 切割,最多承载 5 个物品;超过 5 个物品时由 `api-server` 自动分批并行生图。发布必须校验已生成 `image_ready` 且有 `imageViews[]` 或首图引用的素材数量满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。 - 影响范围:Match3D 结果页、运行态启动契约、`module-match3d` 初始 run 生成、SpacetimeDB start input / restart、发布校验和 Match3D 技术文档。 - 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`、相关后端 check / tests。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-07 移动端整页缩放由入口统一锁定 @@ -546,7 +458,7 @@ - 决策:主站入口统一使用 `viewport` 锁定 `minimum-scale=1.0`、`maximum-scale=1.0`、`user-scalable=no` 和 `viewport-fit=cover`,并在应用启动时调用 `lockMobileViewportZoom()` 拦截 iOS `gesture*` 与多指 `touchmove` 触发的页面级缩放。 - 影响范围:主站 `index.html`、`src/main.tsx`、后续所有依赖主入口的移动端游戏/画布页面;不要求每个画布组件重复实现缩放锁定。 - 验证方式:移动端打开主站后,双指捏合和快速双击不应再缩放整页;单指滚动、点击和组件内交互保持正常。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 ## 2026-05-07 视觉小说 VN-10 资产引用统一走平台资产对象 @@ -554,7 +466,7 @@ - 决策:VN 上传统一复用 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm`、`/api/assets/read-url`。文档上传后只把 `assetObjectId` 放入 `sourceAssetIds`,`seedText` 仅放截断摘要;封面、场景、角色、音乐只写 `/generated-*` 引用和平台 asset id。角色立绘写入 `imageAssets[].source = platform_asset`。运行时图片渲染统一使用 `ResolvedAssetImage` 换签。 - 影响范围:`src/services/visual-novel-creation/visualNovelAssetClient.ts`、`VisualNovelAgentWorkspace`、`VisualNovelResultView`、`VisualNovelRuntimeShell`、`server-rs/crates/api-server/src/visual_novel.rs`。 - 验证方式:VN 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`cargo test -p api-server visual_novel`、`cargo test -p api-server creation_agent_document_input`。 -- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。 ## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆 @@ -562,15 +474,15 @@ - 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。 - 影响范围:`AGENTS.md`、`.hermes/README.md`、`.hermes/shared-memory/`。 - 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `.hermes/shared-memory/` 文件。 -- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联文档:`.hermes/README.md`、`.hermes/shared-memory/team-conventions.md`。 ## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB - 背景:项目经历过 Node/Express/PostgreSQL、Go 试验、Rust/SpacetimeDB 等多条后端路线,旧路线文档容易造成开发歧义。 - 决策:新功能以后端当前基线为准:HTTP 门面使用 Rust `api-server` / Axum,业务真相使用 SpacetimeDB,领域和契约在 `server-rs` 多 crate 分层维护。 - 影响范围:所有后端、数据真相、运行时状态、创作结果、用户系统、资产、任务、埋点、后台 API 等相关开发。 -- 验证方式:开发前优先阅读当前后端架构文档;旧 `server-node`、Express、PostgreSQL、Go 方向只允许作为迁移参考。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 验证方式:开发前优先阅读 `CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`;旧 `server-node`、Express、PostgreSQL、Go 方向只允许作为迁移参考。 +- 关联文档:`docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md`、`AGENTS.md`。 ## 2026-04-28/29 server-rs DDD 分层与契约矩阵冻结 @@ -578,28 +490,28 @@ - 决策:按 DDD 总纲和 G1 契约/路由矩阵开发:`module-*` 承载领域,`spacetime-module` 承载表和事务,`spacetime-client` 承载 facade,`api-server` 承载 HTTP/SSE/BFF,`platform-*` 承载外部副作用,`shared-contracts` 承载 DTO。 - 影响范围:server-rs 全部 crate、前端 API client、SpacetimeDB schema、旧接口清理。 - 验证方式:执行任务前对照 DDD 总纲、并行任务清单、G1 矩阵;提交前运行相关 DDD 边界检查和定向测试。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联文档:`SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`、`SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`、`SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md`。 ## SpacetimeDB 表结构变更必须显式维护迁移与表目录 - 背景:SpacetimeDB 的 schema 迁移模型不同于 PostgreSQL,部分变更会触发冲突或拒绝自动迁移。 - 决策:凡涉及 table、reducer、procedure、row shape 或 binding 变化,必须同步 `migration.rs`、表目录和生成绑定;涉及 private 表迁移时按 JSON 导入导出和分片导入流程处理。 -- 影响范围:`server-rs/crates/spacetime-module`、`spacetime-client` bindings、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、部署/发布脚本。 -- 验证方式:发布前检查 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 清单,更新 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`,执行生成绑定和相关测试。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 影响范围:`server-rs/crates/spacetime-module`、`spacetime-client` bindings、`SPACETIMEDB_TABLE_CATALOG.md`、部署/发布脚本。 +- 验证方式:发布前检查 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 清单,更新 `SPACETIMEDB_TABLE_CATALOG.md`,执行生成绑定和相关测试。 +- 关联文档:`SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`SPACETIMEDB_TABLE_CATALOG.md`、`SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md`。 ## 生产部署切换到 systemd + Nginx + 自托管 SpacetimeDB - 背景:旧一体化启动脚本和历史 Jenkinsfile 已不再是生产发布唯一入口。 - 决策:生产部署以 systemd 托管 SpacetimeDB 与 Rust `api-server`,Nginx 负责站点和代理,生产 Jenkinsfile 按 web/api/stdB module/build/deploy/publish 拆分。 - 影响范围:部署脚本、服务器目录、维护模式、Jenkins、Nginx、systemd 服务。 -- 验证方式:生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证方式:生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 关联文档:`PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 个人任务与埋点首版边界冻结 - 背景:“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路,需要避免范围泛化。 - 决策:埋点原始事实进入 `tracking_event`,聚合投影进入 `tracking_daily_stat`;个人任务配置/进度/领奖/钱包分别进入 `profile_task_config`、`profile_task_progress`、`profile_task_reward_claim`、`profile_wallet_ledger`;首版个人任务 scope 仅支持 `user`。 - 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。 -- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询口径统一维护在当前开发运维文档。 -- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/` 和 `docs/tracking/`。 +- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 4e4442c8..49bff2ef 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -1,20 +1,40 @@ # 开发工作流 -更新时间:`2026-05-15` +> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。 -## 标准流程 +## 标准任务流程 ```text -同步代码 -> 读取 AGENTS.md -> 读取 .hermes/shared-memory -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / .hermes -> 提交 +同步代码 → 读取 AGENTS.md → 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交 ``` -当前 `docs/` 已压缩为少量融合文档。复杂任务优先读: +## 建议启动方式 -1. `docs/README.md` -2. `docs/【项目基线】当前产品与工程约束-2026-05-15.md` -3. `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` -4. `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` -5. `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` +在项目根目录启动 Hermes: + +```bash +cd /path/to/Genarrative +hermes +``` + +在本机当前常见路径为: + +```bash +/home/dsk/workspace/Genarrative +``` + +其他开发者以自己本地实际路径为准,不要把个人绝对路径写入共享文档作为通用规则。 + +## 开发前检查清单 + +- [ ] 当前分支是否正确 +- [ ] 是否已拉取最新代码 +- [ ] 是否阅读 `AGENTS.md` +- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件 +- [ ] 是否阅读 `README.md` 中的运行和检查命令 +- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README +- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档 +- [ ] 是否明确测试、验收和文档更新方式 ## 本地运行命令 @@ -24,108 +44,190 @@ npm install ``` -完整联调: +完整联调开发环境: ```bash npm run dev ``` +该命令会启动: + +- SpacetimeDB standalone +- Rust `api-server` +- 主站 Vite +- 后台 Vite + +开启自动刷新: + +```bash +npm run dev -- --watch +``` + +watch 模式只由外层调度器自动处理后端侧刷新:`spacetime-module` 改动后重新发布模块但不重启 standalone 宿主,`api-server` 改动后重启 Rust 进程。主站 Vite 与后台 Vite 的源码变化交给 Vite 自身 HMR,避免外层 watcher 监听到依赖缓存或临时文件后循环重启。 + +非 watch 模式下,`npm run dev` 终端支持输入 `rs spacetime`、`rs api-server`、`rs web`、`rs admin-web` 或 `rs all`。其中 `rs spacetime` 只会重新发布 `spacetime-module`,不会重启 standalone 宿主;其他模块仍按进程重启。 + +单独启动 SpacetimeDB: + +```bash +npm run dev:spacetime +``` + +单独启动 Rust API server: + +```bash +npm run dev:api-server +``` + 单独启动前端: ```bash npm run dev:web ``` -单独启动 Rust API server: +单独启动后台管理前端: ```bash -npm run api-server +npm run dev:admin-web ``` -后台前端: +`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 + +查看本地 Rust/SpacetimeDB 日志: + +```bash +npm run dev:spacetime:logs +``` + +后台管理前端: ```bash -npm run admin-web:dev npm run admin-web:build npm run admin-web:typecheck ``` -SpacetimeDB bindings: +SpacetimeDB bindings 生成: ```bash npm run spacetime:generate ``` -## 常用检查 +## 常用检查命令 + +- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。 + +编码检查: ```bash npm run check:encoding -npm run check:spacetime-schema -npm run check:server-rs-ddd -npm run lint:eslint -npm run typecheck -npm run test -npm run build -npm run check:content ``` -综合检查: +ESLint: + +```bash +npm run lint:eslint +``` + +类型检查: + +```bash +npm run typecheck +``` + +综合 lint: ```bash npm run lint +``` + +测试: + +```bash +npm run test +``` + +生产构建: + +```bash +npm run build +``` + +内容检查: + +```bash +npm run check:data +npm run check:overrides +npm run check:smoke +npm run check:content +``` + +全量检查: + +```bash npm run check ``` -视觉小说门禁: +DDD 边界检查: ```bash -npm run check:visual-novel-vn11 -npm run check:visual-novel-vn12 +npm run check:server-rs-ddd ``` -## 后端默认验证 +## 后端相关默认验证 -后端代码修改后按范围选择: +后端修改后,按 DDD 文档中的验收命令执行。涉及 API smoke 时: -- `cargo test -p --manifest-path server-rs/Cargo.toml` -- `cargo check -p api-server --manifest-path server-rs/Cargo.toml` -- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` -- `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` -- `npm run check:server-rs-ddd` -- `npm run api-server` 后请求 `/healthz` +- 使用 `npm run dev:api-server` 重新拉起后端。 +- 禁止使用 `npm run api-server:maincloud`、`npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。 +- 检查 `/healthz`。 +- 执行对应自动测试。 +- 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。 +- SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 +- 修改 SpacetimeDB schema 后运行 `npm run check:spacetime-schema`,用自动检查拦截缺 default、插入中间、字段删除/改名/重排/改类型,以及漏改迁移、表目录或绑定。 -涉及 SpacetimeDB table、reducer、procedure、row shape 或 bindings 时,还必须运行: +关键文档: -```bash -npm run spacetime:generate -npm run check:spacetime-schema -``` +- `docs/technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md` +- `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md` +- `docs/technical/SERVER_RS_DDD_PARALLEL_TASKLIST_2026-04-29.md` +- `docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md` +- `docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` +- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` -禁止使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +## 前端相关默认验证 -## 前端默认验证 - -前端修改后按范围选择: +前端修改后,应根据修改范围选择: - `npm run check:encoding` - `npm run lint:eslint` - `npm run typecheck` -- `npm run test -- <具体测试文件>` +- `npm run test` - 页面交互 smoke - 移动端视口检查 -UI 相关修改重点检查: +前端原则: -- 390px 左右移动端宽度不横向溢出。 -- 输入法弹出时平台画布不被压缩。 -- 弹窗、抽屉和独立面板没有实现成当前面板下方展开。 -- UI 不包含默认规则说明长文。 -- 私有图片和音频不裸请求 `/generated-*`。 +- 移动端优先,再兼容网页端。 +- 页面只展示后端返回的状态,不自行计算结论型业务状态。 +- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。 +- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 +- 不在 UI 中默认写功能说明类文本。 +- 弹出独立面板的交互不要实现成在当前面板下方追加内容。 -## 文档更新 +## 文档更新规则 -- 工程修改要同步更新当前 `docs/` 文档。 -- 新增稳定知识优先合并进现有 4 份文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。 -- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引。 -- 阶段性流水账、一次性计划和已关闭 TODO 不再作为长期仓库文档依据。 +- 工程修改要同步更新对应文档。 +- 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。 +- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。 - 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。 + +## 提交前建议让 Hermes 执行 + +```text +请检查当前 git diff,指出: +1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定; +2. 是否需要补充 docs; +3. 是否有长期知识需要写入 .hermes/shared-memory; +4. 建议的测试命令和提交信息。 +``` diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index e001f6ba..cc5a7a7c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,12 +22,12 @@ - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 -## 汪汪声浪重新开放时不要再回到独立配置阶段 +## 汪汪声浪入口不要再回到独立配置阶段 - 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。 - 原因:旧实现把 `bark-battle` 单独挂到 `bark-battle-config` selectionStage,而不是复用创作 Tab 里的模板区。 -- 处理:当前 `bark-battle` 入口为 `visible=true`、`open=false`,展示为“敬请期待”,api-server 会熔断 `/api/creation/bark-battle/*` 与 `/api/runtime/bark-battle/*`。后续重新开放时,入口点击只设置 `activeCreationFormType = 'bark-battle'` 并回到创作 Tab;`BarkBattleConfigEditor` 作为内嵌表单使用,默认隐藏返回按钮和页面标题;runtime `onExit` 重新回到创作 Tab 的汪汪声浪模板。 -- 验证:当前点击汪汪声浪不进入创作表单,直连创作 / 运行态 API 返回 `creation_entry_disabled`;重新开放时再覆盖内嵌表单与 runtime 返回路径。 +- 处理:入口点击只设置 `activeCreationFormType = 'bark-battle'` 并回到创作 Tab;`BarkBattleConfigEditor` 作为内嵌表单使用,默认隐藏返回按钮和页面标题;runtime `onExit` 重新回到创作 Tab 的汪汪声浪模板。 +- 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 ## 抓大鹅批量重新生成物品不要新增 itemId @@ -36,7 +36,7 @@ - 原因:重新生成和批量新增共用 `item-assets` 接口,如果前端不传 `mode = "replace"`,或后端替换时重新分配 `itemId` / 追加未匹配名称,就会破坏 `generatedItemAssets` 顺序和运行态类型映射。 - 处理:批量重新生成只提交当前素材列表中能匹配到的名称,并传 `mode = "replace"`;后端只对同名已有素材生成新图片,合并时保留原 `itemId`、`itemName`、模型兼容字段、UI 背景和历史音频字段,未匹配名称直接忽略且不计费。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖前端提交口径,`cargo test -p api-server match3d_item_asset --manifest-path server-rs\Cargo.toml` 和 `cargo test -p api-server match3d_regenerated_asset --manifest-path server-rs\Cargo.toml` 覆盖后端替换计划与身份保留。 -- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`packages/shared/src/contracts/match3dWorks.ts`、`server-rs/crates/shared-contracts/src/match3d_works.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 抓大鹅生成封面图不要覆盖物品素材或配置 @@ -44,7 +44,7 @@ - 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。 - 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。 -- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## OSS V4 签名时间和 bucket/object_key 兼容 @@ -60,7 +60,7 @@ - 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path;浏览器 `