diff --git a/.env.example b/.env.example index 2a1b98e2..4d8a1387 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,9 @@ WECHAT_MOCK_DISPLAY_NAME="微信旅人" WECHAT_MOCK_AVATAR_URL="" WECHAT_MINIPROGRAM_MESSAGE_TOKEN="" WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true" +WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU" +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal" # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 19f1ae03..0cbdb334 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-08 微信能力按领域收口 + +- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 +- 决策:`api-server` 内微信相关 HTTP/BFF 适配统一收在 `server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*`;`platform-wechat` 负责微信订阅消息、微信支付 V3、虚拟支付消息推送的协议 client、header、签名、验签、解密、mock 和 payload 解析;`api-server::wechat` 只负责 AppConfig 映射、Axum handler、用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。微信 OAuth / 小程序登录 provider 暂继续在 `platform-auth`,通过 `api-server::wechat::provider` 作为组合根 adapter 接入。 +- 影响范围:`server-rs/crates/api-server/src/wechat.rs`、`server-rs/crates/api-server/src/wechat/*`、`server-rs/crates/platform-wechat/src/*`、微信支付 / 订阅消息 / 小程序消息推送文档。 +- 验证方式:执行 `cargo check --manifest-path server-rs/Cargo.toml -p platform-wechat`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server`、微信相关定向测试和编码检查;新增微信协议细节优先落到 `platform-wechat`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 + ## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐 - 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。 @@ -121,6 +129,153 @@ - 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口 + +- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。 +- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。 +- 追加决策:跳一跳与敲木鱼草稿打开也归入同一 Draft Generation Shelf Model,新增 `resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`;壳层只按 intent 执行已发布详情、失败生成页恢复、持久化 generating 恢复、读取 detail 和敲木鱼失败 fallback stage 副作用。 +- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说 / 跳一跳 / 敲木鱼草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + +## 2026-06-04 Platform Public Code Search matcher / DTO 收口 + +- 背景:`resolvePlatformPublicCodeSearchPlan(...)` 已收口公开搜索顺序,但 `PlatformEntryFlowShellImpl.tsx` 仍内联 RPG by-code DTO 构造,以及拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪的 `isSame*PublicWorkCode` 匹配、公开可见性过滤与详情卡映射。 +- 决策:扩展 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `mapRpgPublicCodeSearchDetailToGalleryCard(...)` 和各 `resolve*PublicCodeSearchMatch(...)` 收口 per-play 公开码匹配与 DTO 映射;壳层只保留 gallery 刷新、详情打开、Bark Battle runtime 特例、用户查询和错误归航副作用。`M3D-*` 旧抓大鹅前缀在 `isSameMatch3DPublicWorkCode(...)` 中继续匹配。 +- 影响范围:平台首页搜索框、初始 `publicWorkCode` 恢复、各玩法公开作品号命中、RPG 公开作品 by-code 详情映射、Bark Battle runtime 内搜索启动。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/services/publicWorkCode.test.ts`、针对搜索 Module / 壳层 / publicWorkCode 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口 + +- 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。 +- 决策:扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,以 `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)` 返回纯打开计划和 notice keys;壳层只按 intent 执行网络读取、生成态 rebase、试玩启动、错误写入、路由 / stage 和 notice seen 副作用。 +- 影响范围:创作中心作品架打开拼图 / 抓大鹅草稿、公开码搜索强制打开抓大鹅草稿、生成完成后 ready 未读试玩、失败草稿恢复和后续 pending / persisted generating 判定。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + +## 2026-06-04 Bark Battle Work Cache 草稿状态收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。 +- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages`、`resolveBarkBattleDraftGenerationStatus`、`buildBarkBattleDraftConfigFromWorkSummary`、`buildBarkBattlePublishedConfigFromDraft`、`buildBarkBattlePublishedConfigFromWork`、`buildBarkBattlePublishSnapshot` 和 `mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。 +- 影响范围:Bark Battle 草稿生成完成、结果页保存、作品架摘要恢复草稿、草稿试玩、作品架 / 公开详情启动正式 runtime,以及后续 Bark Battle 资产字段或 ruleset 默认值调整。 +- 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`。 + +## 2026-06-04 Platform Recommend Runtime Auth Model 收口 + +- 背景:平台推荐 runtime 的 embedded 启动需要在匿名 Runtime Guest Token、已登录 background auth 和非 embedded 默认鉴权之间分流,拼图还额外维护 `isolated` / `default` runtime auth mode;旧规则散在顶层 helper 与多个启动 callback。 +- 决策:新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以 `resolvePlatformRecommendRuntimeAuthPlan(input)` 和 `shouldUsePlatformRecommendRuntimeGuestAuth(input)` 收口纯鉴权计划。壳层仍负责读取 `getStoredAccessToken()`、申请 `ensureRuntimeGuestToken()`、拼装 request options 和写入拼图 runtime auth mode。 +- 影响范围:推荐 Tab 内嵌 runtime 启动、拼图公开详情 isolated 入口、推荐运行态后续 action 的局部鉴权口径,以及后续新增可嵌入推荐 runtime 的玩法。 +- 验证方式:`npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md`。 + +## 2026-06-04 Platform Recommend Runtime Auto Start 收口 + +- 背景:推荐 runtime 自动启动 effect 同时判断桌面断点、stage、Tab、loading、推荐列表、active entry、ready 状态和启动中状态,导致壳层 effect 依赖过长且混合推荐流状态机知识。 +- 决策:扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`,只返回 `noop`、`clear` 或 `start(entry)`。平台壳只执行清空 active runtime state 或调用 `selectRecommendRuntimeEntry(entry)`。 +- 影响范围:移动端首页推荐 runtime 自动启动、推荐列表为空时清空状态、active entry ready 判定,以及后续新增推荐 runtime 玩法的启动时机。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、针对 Flow Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md`。 + +## 2026-06-04 Platform Creation Launch Model 收口 + +- 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 +- 决策:新增 `src/components/platform-entry/platformCreationLaunchModel.ts`,以 `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })` 收口创作入口启动意图。`airp` 返回 `noop` 且不触发 `prepareCreationLaunch()`;隐藏 `baby-object-match` 返回 blocked intent 且仍在 prepare 后显示 `EDUTAINMENT_HIDDEN_MESSAGE`;未知入口保持旧语义,先 prepare 后 no-op;已知入口返回稳定 launch target。壳层只执行 prepare、错误提示和 `runProtectedAction(...)`。 +- 影响范围:底部加号创作入口模板卡点击、入口可见性拦截、后续新增可启动模板的 launch target 接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md`。 + +## 2026-06-04 Platform Selection Stage Model 收口 + +- 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 +- 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`。 +- 追加决策:缺失草稿 / 作品 / run 时的阶段回退也归入 `platformSelectionStageModel.ts`,由 `resolveSelectionStageAfterMissingCreationState(params)` 统一判断 big-fish、match3d、square-hole、visual-novel 和 baby-object-match 的 result / runtime / gallery-detail 是否还能被当前状态支撑。壳层只汇总布尔事实并按输出 stage 跳转;big-fish、match3d、square-hole 的草稿事实固定来自 `Boolean(session?.draft)`,visual-novel 的 session draft 与 work draft 可独立支撑结果页,baby-object-match runtime 缺 draft 时直接回首页。 +- 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`。 +- 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`。 + +## 2026-06-04 Creation Work Delete Flow 收口 + +- 背景:平台入口作品架删除入口在 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物 handler 内重复计算确认标题、删除说明、草稿 notice key 与拼图派生稳定 ID,导致删除确认规则散在巨型壳层。 +- 决策:新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,以 `resolvePlatformCreationWorkDeleteConfirmationModel(input)` 收口作品架删除确认纯模型;输出 `id/title/detail/noticeKeys`。`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 Adapter,保留删除 API、刷新作品架 / 公开广场、错误状态、`markDraftNoticeSeen` 和页面跳转。 +- 影响范围:创作中心作品架删除确认弹窗、删除后生成 notice 清理、拼图稳定 result ID 清理、宝贝识物已发布删除说明,以及后续新增玩法作品架删除接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md`。 + +## 2026-06-03 平台入口公开作品详情 Strategy 收口 + +- 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 +- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用不搬入 Module。 +- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 +- 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 +- 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 +- 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 +- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口;Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。 +- 追加决策:自有公开作品编辑分流由 `resolvePlatformPublicWorkEditIntent(entry, deps)` 收口;Module 只返回可编辑草稿目标、需解析宝贝识物本地草稿 intent、旧 RPG gallery 编辑 intent 或原阻断文案。壳层仍执行登录保护、草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示;抓大鹅 public detail -> work mapper 仍作为 Adapter 注入,不复制 Match3D 素材归一规则。 +- 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 + +## 2026-06-03 平台入口弹窗状态规则收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。 +- 决策:新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module,统一导出 `normalizePlatformDialogMessage`、`formatPlatformDialogSource`、`resolvePlatformErrorDialog`、dismiss key builder、`resolveActivePlatformDialog`、`isBackgroundGenerationStillRunningMessage` 和 `PLATFORM_TASK_COMPLETION_MESSAGE`。平台壳只汇总候选、持有 React state,并在关闭弹窗时作为 Adapter 清理对应副作用 setter。 +- 影响范围:平台入口错误弹窗、任务完成弹窗、后台生成仍在处理识别、草稿生成完成 / 失败通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts`、`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、相关壳层交互测试、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md`。 + +## 2026-06-03 前端 SSE 客户端传输层统一收口 + +- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 +- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,OpenAI 兼容文本流通过 `readSseStream` 处理 `[DONE]` 哨兵,后续不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环。 +- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/llmClient.ts`、`src/services/creation-agent/creationAgentSse.ts`、`src/services/creative-agent/creativeAgentSse.ts`、`src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。 +- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 +- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 + +## 2026-06-03 平台入口公开作品流身份规则收口 + +- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 +- 决策:公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`buildPlatformPublicGalleryFeeds`、`resolvePlatformRecommendRuntimeStartIntent`、`isPlatformRecommendRuntimeReadyForEntry`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。公开作品流聚合负责 featured / latest、玩法可见性 gate、汪汪声浪 works fallback 和首屏 `slice(0, 6)`;推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;ready 判定只接布尔值与拼图 profile id,避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 +- 影响范围:平台入口推荐流、最新公开作品流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 + +## 2026-06-03 Work Shelf 打开动作交由 item Adapter + +- 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 +- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。`buildCreationWorkShelfItemsFromSources` 与 `CreationWorkShelfSourceAdapter` 作为 source registry Interface,统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序;旧 `buildCreationWorkShelfItems` 保留兼容,但内部改为组装 source adapters。 +- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、source registry、后续新增玩法作品架接入。 +- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 + +## 2026-06-03 Runtime Client Family 请求骨架收口 + +- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 +- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 +- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 +- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。 +- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。 +- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 + +## 2026-06-03 Public Gallery ViewModel 收口 + +- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。 +- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey`、`buildPublicCategoryGroups`、`getPlatformPublicEntries`、`getAllPlatformPublicEntries`、`getPlatformSearchableWorkIds`、`filterPlatformWorkSearchResults`、`isExactPublicWorkCodeSearch`、`filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries`、`getPlatformRankingMetricValue`、`getPlatformCategoryKindFilter`、`matchesPlatformCategoryKindFilter`、`sortPlatformCategoryEntries`、`getPlatformCategoryPrimaryMetric`、`parsePlatformEntryTimestamp` 和 `getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。 +- 影响范围:RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Profile Task ViewModel 收口 + +- 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案,导致任务展示规则和 JSX 缠在一起。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,把 `selectProfileTaskCenterTasks`、`selectProfileTaskCardTask`、`buildProfileTaskCardSummary`、`buildProfileTaskProgressLabel`、`getProfileTaskStatusLabel` 和 `getProfileTaskClaimButtonLabel` 收口为每日任务 ViewModel Interface。任务中心仍只展示一条 claimable / incomplete 优先任务,任务卡按可操作、claimed、非 disabled 的顺序兜底。 +- 影响范围:RPG 首页“每日任务”卡片、任务中心弹窗、后续任务状态和任务展示文案调整。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 @@ -280,9 +435,9 @@ ## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态 - 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。 -- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。推荐页拼图“下一关”必须走推荐页统一相邻作品切换流程,前端不得传递 `preferSimilarWork`,也不得让拼图 runtime 自己把当前 run handoff 到其它作品。 +- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。 - 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。 -- 验证方式:点击推荐页拼图“下一关”后,页面先保留 `puzzle-board`,且不出现 `加载中...` 占位;随后应调用推荐页统一下一作品启动逻辑,而不是调用 `advancePuzzleNextLevel(...)`。 +- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-05-24 创作入口页 banner 曾固定主题赛 @@ -321,7 +476,6 @@ - 背景:创作页顶部、banner 奖池和玩法卡消耗口径曾经混在一起,容易把活动奖池误认成账号余额,也让横向空间被外部边框和过大的卡片高度挤占。 - 决策:移动端创作 Tab 顶栏与 `陶泥儿` 品牌同一行只显示真实账户泥点数,数据直接取 `profileDashboard.walletBalance`;banner 内只展示赛事奖池,新增拼图主题创作赛和抓大鹅主题创作赛,两个主题奖池各 `1000` 泥点数;玩法卡封面右下角固定展示 `10-20泥点数`,列表外框取消,卡片高度和横向间距一起收紧。 -- 追加决策:创作页和草稿页顶栏右上泥点余额胶囊是补足泥点入口;当前环境开启充值入口时直接打开账户充值弹窗,否则打开运营兑换码弹窗,不再跳到账户面板或泥点账单。 - 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、创作页相关测试和玩法链路文档。 - 验证方式:移动端浏览器检查应看到创作顶栏余额、卡内分页点、内嵌横向 banner 和更紧凑的玩法卡;`CustomWorldCreationHub.test.tsx` 与 `RpgEntryHomeView.recharge.test.tsx` 的定向断言应保持通过。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -540,7 +694,7 @@ ## 2026-05-19 生产 provision 改为 Windows 下载包后由目标机本地安装 -- 后续更新:该口径先被 2026-06-01 Linux 优先方案取代,又在 2026-06-05 被 Server-Provision 专用口径覆盖;当前 `Genarrative-Server-Provision` 不走 Windows 下载阶段,也不在 Linux build 节点中转工具包,而是在目标 dev / release agent 内准备 `provision-tools/`。 +- 后续更新:该口径已被 `2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前 `Genarrative-Server-Provision` 不再走 Windows 下载阶段,而是在 Linux build 节点直接准备 `provision-tools/`。 - 背景:当前 `development` provision 目标实际就是 Linux agent `genarrative-build-01`,之前把 `Prepare Provision Tools` 放在 `linux && genarrative-build` 会让目标机自己连 GitHub 和 `install.spacetimedb.com`,违背“Windows 本机先下载再传到目标机”的运维要求。 - 决策:`Genarrative-Server-Provision` 拆成 Windows 下载阶段和 Linux 目标机安装阶段。Windows 节点的 `Download Provision Tool Archives` 只下载 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,通过 `stash/unstash` 传到目标 Linux 节点;目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,只消费已下载件生成 `provision-tools/`,缺包直接失败,不回退外网下载。 - 追加决策:Server-Provision 的 Windows helper 不再对 Jenkins `writeFile` 刚写出的 `.ps1` 做原地 UTF-8 BOM 重写,而是由显式 `powershell.exe` 按 UTF-8 读入脚本文本,并用 `ScriptBlock::Create(...)` 在内存中执行;这样既保留中文脚本内容,又避免同一个 workspace 脚本被立即重写时触发 `拒绝访问`。 @@ -665,9 +819,9 @@ ## 2026-05-14 移动端输入法弹出时平台画布不压缩 - 背景:平台根壳使用 `100dvh` 后,手机浏览器输入法弹出会让可见视口变小,导致创作首页、推荐页等固定游戏式画布被重新压缩。 -- 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,但不再通过 `--platform-keyboard-focus-offset` 全局上移画布,避免 H5 / 小程序 `web-view` 原生输入法避让和平台壳二次位移叠加。键盘打开时只记录 `data-mobile-keyboard-open`、设置底部 inset、隐藏移动端底部 dock,并把可能露出的 `html` / `body` / `#root` 背景切到平台浅色底。 +- 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,只通过 `--platform-keyboard-focus-offset` 上移画面聚焦当前输入框,并临时隐藏移动端底部 dock。 - 影响范围:主站平台壳、移动端创作首页底部输入框、后续所有复用 `.platform-viewport-shell` 的输入表单;业务组件不重复注册键盘适配。 -- 验证方式:手机竖屏点击输入框,画布不压缩也不整体弹起;输入法关闭后键盘状态清除,底部 dock 恢复。 +- 验证方式:手机竖屏点击输入框,画布不压缩,输入框移动到输入法上方;输入法关闭后画布回位,底部 dock 恢复。 - 关联文档:`docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 ## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式 @@ -1198,7 +1352,6 @@ ## 2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost -- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为目标部署 agent 全程执行,并禁止公网 Git fallback 与 build 节点工具包中转。 - 背景:生产流水线长期混用 Windows、Linux 和公网 Git 入口,导致构建 / 发布 / provision 的 checkout 口径分叉;同时 `Genarrative-Server-Provision` 还残留过 Windows 下载 helper,和当前 Linux 构建 / 发布部署路径不一致。 - 决策:生产 Jenkins 流水线统一把执行节点收口到 Linux label,`Pipeline script from SCM` 仍保留公网域名,但所有生产流水线首次 `GitSCM checkout` 先尝试 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;`Genarrative-Stdb-Module-Build`、`Genarrative-Server-Provision`、`Genarrative-Notify-Email` 也都切到 Linux 节点。`Genarrative-Server-Provision` 的工具准备不再依赖 Windows helper,而是在 Linux build 节点直接生成 `provision-tools/` 后交给后续 Linux 发布阶段。 - 影响范围:`jenkins/Jenkinsfile.production-*`、`scripts/jenkins-checkout-source.sh`、`scripts/prepare-server-provision-tools.sh`、生产运维文档。 @@ -1314,6 +1467,186 @@ - 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-03 Profile Dashboard Presentation 收口 + +- 背景:`RpgEntryHomeView.tsx` 同时承载个人数据卡、钱包 chip 与“玩过”弹窗,计数压缩、累计时长、单作品时长、玩法标签和作品号兜底散在页面 Implementation 内,修改展示口径时缺少稳定测试面。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts` 作为个人数据展示 Module,Interface 收口为 `buildProfileDashboardPresentation`、计数 / 时长格式化和“玩过”列表标签 / 作品号格式化函数;页面只消费结果并保留 UI 编排与点击处理。 +- 影响范围:RPG 首页“我的数据”卡片、移动端 / 桌面端钱包 chip、个人数据弹窗与“玩过”列表。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md`。 + +## 2026-06-03 Recommend Feed ViewModel 收口 + +- 背景:推荐 feed 与正式 runtime 的上一条 / 下一条选择分别在 `RpgEntryHomeView.tsx` 和 `PlatformEntryFlowShellImpl.tsx` 手写公开作品去重、隐藏内容过滤、active key 兜底和相邻回环,存在推荐预览与 runtime 口径漂移风险。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed Module Interface:`dedupePlatformPublicGalleryEntries`、`buildPlatformRecommendFeedEntries`、`selectPlatformRecommendFeedWindow`、`selectAdjacentPlatformRecommendEntry`;首页与 FlowShell 均消费该 Interface。 +- 影响范围:移动端首页推荐 swipe、发现页推荐频道、桌面推荐格、推荐 runtime 队列与上一条 / 下一条跳转。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Recommend Swipe Deck Model 收口 + +- 背景:移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,页面同时承载 DOM pointer 副作用和纯规则。 +- 决策:新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module,Interface 收口 `hasRecommendDragStarted`、`clampRecommendDragOffset`、`resolveRecommendDragCommitDirection`、`resolveRecommendCommitOffset`、`buildRecommendSwipeRailClassName`、`shouldAnimateRecommendSwipe` 与 `buildRecommendShareText`;页面仅保留 pointer capture、DOM 高度读取、动画 timer、clipboard 与 like/remix/open 副作用 Adapter。 +- 影响范围:移动端推荐首页 swipe 手势、上一条 / 下一条动画、推荐分享文案与未登录时的直接切换行为。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Ranking ViewModel 收口 + +- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_RANKING_TAB`、`PLATFORM_RANKING_TABS`、`getPlatformRankingTabConfig` 与 `getPlatformRankingMetric`;页面仅保留 active tab 状态和渲染。 +- 影响范围:发现页排行频道 tab 顺序、tab 文案、空态文案、排行项指标 label/value。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Category Option ViewModel 收口 + +- 背景:分类频道的筛选选项、排序选项、默认值、active label fallback 和排序循环仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,而玩法过滤、排序和主指标已经在 `rpgEntryPublicGalleryViewModel.ts`,同一分类 Interface 被拆成两处。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption`、`getPlatformCategorySortOption` 与 `getNextPlatformCategorySortMode`;页面仅保留当前筛选 / 排序状态和渲染。 +- 影响范围:发现页分类频道筛选弹窗、筛选按钮 label、排序按钮 label 与排序循环。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Match3D Runtime Profile 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内仍直接承载抓大鹅公开详情转 work、session draft 转 profile、生成背景资产提升、runtime active profile 选择和 run / profile / public detail 素材优先级,平台壳需要理解抓大鹅生成素材内部结构。 +- 决策:新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts` 作为抓大鹅 runtime profile Module,Interface 收口 `mapPublicWorkDetailToMatch3DWork`、`buildMatch3DProfileFromSession`、`normalizeMatch3DWorkForRuntimeUi`、`mapMatch3DWorksForRuntimeUi`、`promoteMatch3DGeneratedBackgroundAsset`、`hasMatch3DRuntimeAsset`、`hasMatch3DRuntimeBackgroundAsset`、`resolveActiveMatch3DRuntimeProfile` 与 runtime item/background/backgroundImage 解析函数;平台壳只保留启动 run、预加载、路由、错误和 state 编排。 +- 影响范围:抓大鹅作品架、公开详情试玩、推荐 runtime、正式 runtime 与草稿结果页试玩前素材规范化。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`。 + +## 2026-06-03 Draft Generation Shelf Model 收口 + +- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、作品详情更新回填、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。 +- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf Module,Interface 收口 `collectDraftNoticeKeys`、`getGenerationNoticeShelfKeys`、`createPendingDraftShelfState`、各玩法 `buildPending*Works`、`buildCreationWorkShelfRuntimeState`、`collectVisibleDraftNoticeKeys`、`hasUnreadDraftGenerationUpdates`、`mergePuzzleWorkSummary`、`mergeBigFishWorkSummary`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。 +- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、作品详情更新回填、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + +## 2026-06-03 Creation Hub Shelf Items Interface 收口 + +- 背景:`creationWorkShelf.ts` 已把各玩法作品映射为 `CreationWorkShelfItem.actions`,但 `CustomWorldCreationHub.tsx` 的生产 Interface 仍接收 raw items 与 open/delete/claim 回调列阵,新增玩法时 Hub props 继续膨胀。 +- 决策:`CustomWorldCreationHub.tsx` 生产 Interface 收敛为 `shelfItems: CreationWorkShelfItem[]` 与少量 UI 状态;`PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 actions;Hub 测试改经 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 shelf items,不让测试继续依赖旧浅 Interface。 +- 影响范围:创作 Tab / 草稿 Tab 作品架、RPG / 拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 视觉小说 / Bark Battle / 宝贝识物作品打开、删除、生成态与拼图奖励领取。 +- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts`、`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、相关 FlowShell creation hub 交互片段、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 + +## 2026-06-03 Creation URL State Model 收口 + +- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 +- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 +- 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip`、`mark-handled`、`wait`、`restore` 执行 ref 标记或进入原恢复副作用。 +- 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`;Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。 +- 追加决策:创作 URL 恢复的作品 / 草稿身份匹配谓词、以及跳一跳 / 敲木鱼恢复后的阶段落点也归入 `platformCreationUrlStateModel.ts`。身份匹配只允许非空目标值命中,避免 query 缺失时用空值误开草稿;壳层只把已读取的列表项、session 或 work 交给 Module 判定,然后执行对应打开 / restore 副作用。 +- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 + +## 2026-06-04 Platform Public Code Search Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调内联判断内部用户 ID、陶泥号、RPG 作品号、各玩法公开作品号前缀和 fallback 顺序,壳层同时承担纯搜索计划与网络 / 打开副作用。 +- 决策:新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `resolvePlatformPublicCodeSearchPlan(keyword)` 返回 `normalizedKeyword` 与 `steps`。`user_` / `user-` 只查用户 ID;玩法前缀直达对应作品;`CW` / 纯数字先查 RPG 作品再查陶泥号;普通关键词和 `SY` 保持既有用户号、RPG 作品、汪汪声浪、用户号兜底顺序。壳层只按 step 执行既有查找、详情打开、Bark Battle runtime 特例和 missing work 归航。 +- 影响范围:发现页 / 推荐页公开搜索、作品详情深链初始搜索、陶泥号命中面板、各玩法公开作品号直达。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Played Work Open Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调内联判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID、RPG fallback 详情和大鱼吃小鱼 fallback work,壳层同时承担打开意图与异步副作用。 +- 决策:新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,以 `resolvePlatformPlayedWorkOpenIntent(work)` 返回 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。Module 负责玩法别名、`worldKey` 前缀兜底、big-fish gallery miss `fallbackWork` 和 RPG `CustomWorldGalleryCard` payload;壳层继续负责关闭面板、刷新 gallery、命中真实作品、打开详情和错误提示。 +- 影响范围:个人“玩过作品”面板点击打开、拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 大鱼吃小鱼 / RPG 公开详情入口。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Generation Progress Tick Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 内联维护 stage 到小游戏生成状态的三元链,并额外手写视觉小说 `startedAtMs` / `phase` 特例,壳层同时承担纯判定与 interval 副作用。 +- 决策:新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,以 `resolvePlatformGenerationProgressTickDecision(input)` 返回 `{ activeKind, shouldTick }`。Module 负责 stage 到 kind 映射、小游戏状态缺失 / 终态判定、视觉小说轻量生成判定;壳层继续负责 `Date.now()`、`window.setInterval`、progress now state 写入和 cleanup。 +- 影响范围:拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼、宝贝识物和视觉小说生成页进度 tick。 +- 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Mini Game Session Mapping Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback、敲木鱼生成中摘要和 pending draft 默认值。 +- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildVisualNovelSessionFromWorkDetail`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail`、`buildWoodenFishGeneratingWorkSummary` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 +- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架摘要 / 作品架打开和敲木鱼草稿 detail 恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform RPG Agent Result Preview Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护 RPG Agent 结果页发布门禁展示修正和 result preview source label 映射,壳层需要理解 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和首幕 acts。 +- 决策:新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,收口 `buildPlatformRpgAgentResultPublishGateView` 与 `resolvePlatformRpgAgentResultPreviewSourceLabel`。Module 只做展示层纯判定;壳层继续负责 session/profile 编排、发布副作用和结果页 props 传递。 +- 影响范围:RPG Agent 结果页发布按钮门禁 blockers、publishReady 展示修正和预览来源 label。 +- 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Mini Game Draft Generation State Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护小游戏生成状态恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定,壳层同时承担 API / background task 副作用和 `MiniGameDraftGenerationState` 生命周期细节。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,收口恢复态、失败态、完成态、展示 rebase、拼图 progress phase 阈值和进度 metadata 合并。壳层继续负责 API、后台任务、React state 写入、作品架刷新、URL 和 stage 切换。 +- 追加决策:抓大鹅轮询作品素材时的旁路进度合并也归入该 Module,由 `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)` 统一统计可用图片素材、至少 5 个总素材计数、`match3d-generate-views` phase 推进和首个素材错误传播;壳层只负责轮询 session / work detail 与写入 state。 +- 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Mini Game Draft Payload Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、跳一跳 / 敲木鱼 payload 与 draft 优先级、Match3D config / draft / anchorPack 优先级和数字解析。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPuzzleWorkUpdatePayloadFromDraft`、`buildJumpHopDraftActionPayload`、`buildWoodenFishDraftActionPayload`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 +- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、跳一跳 / 敲木鱼生成与重生成 action、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Puzzle Draft Recovery Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的拼图恢复链路只要 cover 或候选图存在就会把恢复 session 抬为 ready,可能让缺关卡画面、UI spritesheet 或关卡背景的半成品直接进入结果页完成态。 +- 决策:新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,收口 `normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。恢复完成态必须同时具备首图、`levelSceneImage*`、`uiSpritesheetImage*` 与 `levelBackgroundImage*`;只有完整资产包成立时才把 draft 与首关 `generationStatus` 抬为 `ready`。 +- 影响范围:拼图生成完成后刷新恢复、拼图 background compile task 完成态写入和结果页自动打开。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Puzzle Runtime State Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 在拼图排行榜提交回包后内联合并服务端 run 快照,壳层需要理解 `PuzzleRunSnapshot` 中哪些字段由前端即时裁决、哪些字段只由服务端补齐。 +- 决策:新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,以 `mergePuzzleServiceRuntimeState(currentRun, serviceRun)` 收口服务端 run 合并规则。Module 保留当前前端关卡状态、棋盘和计时,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff;任一 run 缺 `currentLevel` 时直接返回当前 run。 +- 影响范围:拼图排行榜提交、推荐 runtime isolated / default 运行态回包合并、下一关同作品 / 相似作品 handoff,以及后续 Puzzle runtime 快照字段调整。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Puzzle Publish Asset Gate 收紧 + +- 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover,导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`。 +- 决策:`module-puzzle::validate_publish_requirements` 新增三类资产 blocker,要求每关具备 `level_scene_image_*`、`ui_spritesheet_image_*` 与 `level_background_image_*`;`api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 同步使用完整资产包判定。 +- 影响范围:拼图 result preview blockers、publishReady、标签生成后 session stage、从 action payload 构造 fallback session 的 ready 判定。 +- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-04 Platform Profile Wallet Delta Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。 +- 决策:新增 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,收口 `resolveProfileWalletBalance`、`adjustProfileDashboardWalletBalance` 与 `reconcileProfileWalletLocalDeltaWithServerDashboard`。壳层只保留 API 请求、React ref、state 写入和刷新触发副作用。 +- 影响范围:创作入口泥点展示、生成前泥点校验、扣点 / 返还后的个人 dashboard 乐观更新、后台刷新 dashboard 时的本地 delta 对账。 +- 验证方式:`npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-03 Public Work Presentation 收口 + +- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 +- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface:`describePlatformPublicWorkKind`、`formatPlatformCompactCount`、`resolvePlatformPublicWorkAuthorLookup` 与 `formatPlatformPublicAuthorAvatarLabel`;页面删除本地玩法类型、紧凑计数、公开作者 lookup 和头像首字实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`。 +- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果、桌面 hero 玩法 label、公开作者摘要缓存 key 与无头像首字兜底。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md`。 + +## 2026-06-03 Profile Funds ViewModel 收口 + +- 背景:个人资金展示规则散在 `RpgEntryHomeView.tsx`,且账单来源 label 表漏掉后端契约已有的 `puzzle_author_incentive_claim`,会把原始枚举值直接外显。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 Module,Interface 收口账单来源文案、金额正负号、余额兜底、充值价格、商品主值与会员摘要;页面保留弹窗布局、支付流程、微信渠道和订单轮询副作用。 +- 影响范围:泥点账单弹窗、充值商品卡片、账户充值弹窗会员摘要。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md`。 + ## 2026-05-26 前端不外露图片模型名 - 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 @@ -1349,10 +1682,10 @@ ## 2026-06-07 创作入口泥点消耗改由统一契约驱动 - 背景:创作入口玩法卡封面右下角长期固定显示 `10-20泥点数`,无法在后台按玩法调整,也容易和真实钱包余额或活动奖池混淆。 -- 决策:`creationTypes[].unifiedCreationSpec.mudPointCost` 作为入口卡泥点消耗数量字段,旧契约缺失时后端和前端都兜底为 `10`;入口卡由前端格式化为 `X泥点数` 展示,后端和后台不保存单位文案。该字段只表达入口卡展示数量,不替代各玩法提交、生成或发布链路中的真实扣费校验。 +- 决策:`creationTypes[].unifiedCreationSpec.mudPointCost` 作为入口卡泥点消耗数量字段,旧契约缺失时后端和前端都兜底为 `10`;入口卡由前端格式化为 `X泥点数` 展示,后端和后台不保存单位文案。该字段同时作为玩法新建草稿初始生成的扣费真相源,前端余额前置校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成必须读取同一份后台入口配置;结果页单图重生成、发布、道具使用和其它独立资产操作继续使用各自业务成本。 - 决策补充:后台创作入口开关页不再直接暴露统一创作契约 JSON textarea;页面按契约结构展示为卡片和字段列表,点击“修改契约”后通过弹窗表单编辑 `title`、`mudPointCost` 和 fields,再组装回统一契约 payload 保存。`workspaceStage`、`generationStage` 和 `resultStage` 属于内部阶段标识,后台不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的固定阶段映射自动带出。 - 影响范围:`shared-contracts` 的 `UnifiedCreationSpecResponse`、`/api/creation-entry/config` 响应、前端入口卡派生、后台入口开关页、玩法链路文档和创作入口回归测试。 -- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;关闭态卡片仍只显示 `暂未开放`。 +- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 461a1652..bb648e65 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 新建草稿扣费不能和入口卡泥点配置分离 + +- 现象:后台修改创作入口的 `mudPointCost` 后,入口卡和前置余额提示可能显示新数值,但用户真实钱包流水仍按代码常量扣除。 +- 原因:早期约定把 `creationTypes[].unifiedCreationSpec.mudPointCost` 只当展示字段,拼图、抓大鹅和汪汪声浪初始生成各自保留了 `2`、`10`、三次单图 `1` 的硬编码扣费路径。 +- 处理:新建草稿初始生成成本必须统一从 `GET /api/creation-entry/config` 的 `unifiedCreationSpec.mudPointCost` 解析;前端预校验、拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成同源。汪汪声浪结果页单图重新生成仍按单图资产操作成本,不套初始草稿总成本。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "mud points"`、`npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolves_mud_point_cost initial_generation_slot_cost_splits_creation_entry_total_cost -- --nocapture`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/match3d/draft.rs`、`server-rs/crates/api-server/src/bark_battle.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## generated 图片重复下载不要改成服务端本地磁盘缓存 - 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。 @@ -120,6 +128,14 @@ - 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`,并确认恢复生成中草稿后 `getPuzzleAgentSession` 不会因为进度刷新继续连发。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/usePlatformCreationAgentFlowController.ts`、`src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx`。 +## 小游戏恢复生成页不要只用请求 busy 判定是否生成中 + +- 现象:敲木鱼作品架里的生成中草稿点击进入生成页后,页面会显示“重新生成草稿”按钮,而不是继续显示素材生成中的等待态。 +- 原因:平台壳恢复 `generationStatus=generating` 草稿时会把 `isBusy` 置回 false,只保留 `MiniGameDraftGenerationState` 作为生成事实;生成页如果只把请求 busy 传给 `isGenerating`,共用生成页会误判为空闲态并展示重试按钮。 +- 处理:小游戏生成页的 `isGenerating` 必须由 `isBusy || isMiniGameDraftGenerating(generationState)` 推导;跳一跳、拼消消、敲木鱼等从作品架恢复的生成页都要使用同一口径。 +- 验证:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts` 应覆盖 `busy=false` 但敲木鱼 generation state 仍在生成中时继续隐藏重试入口。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/unified-creation/UnifiedGenerationPage.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 拼图试玩恢复 query 必须先切到运行态路径再写 - 现象:拼图试玩或正式运行态打开后,刷新会停在“正在进入拼图关卡”,或地址栏只有 `runtimeProfileId`,缺少草稿 `runtimeSessionId`。 @@ -1317,10 +1333,10 @@ ## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 -- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 -- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。 -- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。 -- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 +- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 +- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 也已收紧到同一完整资产包门槛。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成,后端也不应进入 `ready_to_publish` 或返回 `publishReady=true`。 +- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`。 ## WebGL 画布在高 DPR 移动端放大溢出 @@ -2075,6 +2091,14 @@ - 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 推荐页 ready 不能只等主图或首次 DOM 图片 + +- 现象:移动端推荐页卡面遮罩在作品主图加载后就渐隐,但游戏内 UI 图集、背景、道具图或换签中的 generated 图片还没有准备好,用户会看到运行态半成品或资源闪入。 +- 原因:推荐页 ready probe 如果只扫描首次挂载时已有的 ``,就会漏掉 React effect、`/api/assets/read-url` 换签、spritesheet 解析或后续 state 更新才新增的资源。 +- 处理:推荐页 runtime 遮罩必须持续观察运行态 DOM 内新增图片、内联 `background-image` 和 `data-runtime-resource-pending` 隐藏标记;各玩法对换签中、解析中的资源源头要暴露 pending 标记,失败后释放标记并交给玩法兜底,避免遮罩永久卡住。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend cover waits for async runtime resources beyond the main image|mobile recommend cover waits until runtime images are ready"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/common/RuntimeResourcePendingMarker.tsx`、`src/components/ResolvedAssetImage.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 拼图文字直创的 compile 回包不等于生成完成 - 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。 @@ -2098,3 +2122,19 @@ - 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view` 的 `page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。 - 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。 - 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 小程序订阅消息授权不要依赖 web-view bindmessage + +- 现象:拼图点击生成后,H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。 +- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。 +- 处理:不要在原生页 `onLoad` 自动触发 `wx.requestSubscribeMessage`,真机会闪页返回且不弹授权框。H5 在 `compile_puzzle_draft` 前应先进入生成进度态并立即发起生成 action,再通过微信 JS SDK `miniProgram.navigateTo` 非阻塞跳转到小程序原生订阅页尝试请求授权;用户接受、拒绝或返回都不能阻塞生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失进度页状态。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。 +- 验证:`npm run test -- src/services/wechatMiniProgramSubscribe.test.ts miniprogram/pages/subscribe-message/index.test.js`。 +- 关联:`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`miniprogram/pages/subscribe-message/index.shared.js`、`miniprogram/pages/web-view/index.js`。 + +## 微信订阅消息 time 字段不能用内部时间戳 + +- 现象:dev 服务器拼图资产生成终态后已经调用订阅消息发送,但日志出现 `微信订阅消息发送失败:argument invalid! data.time4.value invalid`,用户收不到生成结果通知。 +- 原因:微信模板 `time` 字段不接受内部微秒时间戳、秒级时间戳或带 `Z` / 时区后缀的字符串;发送 `1713686401.234567Z` 或类似 `2026-06-08 08:09:18Z` 会被微信拒绝。 +- 处理:`api-server` 构造生成结果订阅消息时,`time4` 固定格式化为北京时间 `YYYY-MM-DD HH:mm`;不要复用 `shared_kernel::format_timestamp_micros`。 +- 验证:`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`;dev 日志中不应再出现 `data.time4.value invalid`。 +- 关联:`server-rs/crates/api-server/src/wechat_subscribe_message.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 0bd60aaa..00c2f843 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,74 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段 AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。 +前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 + +平台入口公开作品身份、跨玩法去重、公开作品流聚合、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 + +创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用,规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。 + +创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 + +平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀、per-play 公开码匹配、详情卡 DTO 映射和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 + +个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 + +平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 + +平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session、敲木鱼生成中作品摘要和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 + +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 + +平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 + +平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 + +拼图排行榜提交回包后的服务端 run 快照合并收口到 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,只合并排行榜、run 身份、通关数上限和下一关 handoff,保留前端即时裁决的关卡状态与棋盘,规则见 [【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md)。 + +后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 + +平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 + +Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 + +平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。 + +平台首页推荐 runtime 自动启动的桌面 / Tab / stage / loading gate、active entry 查找、ready 判定和 clear/start/noop 决策收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md)。 + +RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 + +平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、跨玩法草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 + +平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 + +平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 + +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。 + +排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +个人资金展示的账单来源、金额正负号、余额兜底、充值价格、商品主值和会员摘要收口到 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts`,规则见 [【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileFundsViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md new file mode 100644 index 00000000..2db7284f --- /dev/null +++ b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md @@ -0,0 +1,46 @@ +# 【前端架构】Bark Battle Work Cache 草稿状态收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed` 与 `pending_assets` 的差异、`publishedAt` 兜底、作品摘要字段和草稿试玩配置默认值。 + +这些规则属于 Bark Battle 作品摘要与草稿缓存的纯模型。若留在平台壳层,后续发布、作品架刷新、公开详情启动或草稿试玩都容易重复一份字段清单。 + +## 决策 + +扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,作为 Bark Battle Work Cache **Module** 继续承接作品摘要缓存和草稿 runtime 配置规则。新增公开 **Interface**: + +- `hasBarkBattleDraftRequiredImages(draft)`:判断草稿是否已具备玩家形象、对手形象和竞技背景三图。 +- `resolveBarkBattleDraftGenerationStatus(draft, partialFailed)`:三图齐备返回 `ready`,否则按是否部分失败返回 `partial_failed` 或 `pending_assets`。 +- `buildBarkBattleDraftConfigFromWorkSummary(work)`:把作品架摘要恢复成可编辑 / 可试玩的 `BarkBattleDraftConfig`。 +- `buildBarkBattlePublishedConfigFromDraft(draft)`:把草稿结果页试玩所需配置映射为 `BarkBattlePublishedConfig`。 +- `buildBarkBattlePublishedConfigFromWork(work)`:把作品架 / 公开详情启动正式 runtime 所需配置映射为 `BarkBattlePublishedConfig`。 +- `buildBarkBattlePublishSnapshot(draft)`:拼装发布接口所需的最终草稿快照。 +- `mergeBarkBattlePublishedConfigAssets(published, draft)`:发布回包缺少三图字段时沿用结果页草稿图。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它只负责 API 请求、React state、URL、运行态 stage 切换和错误提示,不再持有 Bark Battle 三图完整性与 runtime config 字段清单。 + +## Interface 约束 + +- 草稿三图必须同时具备 `playerCharacterImageSrc`、`opponentCharacterImageSrc` 和 `uiBackgroundImageSrc` 的非空值,才视为 `ready`。 +- 未齐三图且 `partialFailed=true` 时返回 `partial_failed`,否则返回 `pending_assets`。 +- 作品摘要恢复草稿时,`draftId` 缺失回退 `workId`,`description` 来自 summary,三图 null 归一为 `undefined`,`configVersion=1` 且 `rulesetVersion='bark-battle-ruleset-v1'`。 +- 草稿试玩配置的 `workId` 优先使用草稿稳定 `workId`,缺失时回退 `draftId`。 +- 草稿试玩配置的 `configVersion` 与 `rulesetVersion` 使用草稿值,缺失时回退 `1` 与 `bark-battle-ruleset-v1`。 +- 已发布作品配置的 `publishedAt` 缺失时回退 `updatedAt`,保持旧 runtime 启动语义。 +- 发布快照只携带草稿已有的三图字段,不凭空补空字符串。 +- 发布接口回包缺少三图字段时,结果页草稿图继续作为 runtime 和作品摘要的兜底。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态、草稿配置或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。 +- **Leverage**:作品架草稿恢复、结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。 +- **Locality**:Bark Battle 资产完整性与配置映射集中到纯测试面,后续变更三图字段或规则集默认值时无需搜索巨型平台壳。 + +## 验收 + +- `npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/barkBattleWorkCache.ts src/components/platform-entry/barkBattleWorkCache.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..a6612bb7 --- /dev/null +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -0,0 +1,41 @@ +# CreationUrlStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载多玩法创作恢复 URL 的拼装规则:`sessionId`、`profileId`、`draftId`、`workId` 的优先级、拼图草稿 runtime query、以及空值归一化散在壳层 Implementation 内。平台壳因此需要理解各玩法快照结构,新增玩法或修复刷新恢复时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`、创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`、恢复目标身份匹配谓词,以及跳一跳 / 敲木鱼恢复后的阶段落点判定。 +- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 +- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。 + +## Interface 约束 + +- 创作恢复私有 query 只使用 `sessionId`、`profileId`、`draftId`、`workId`;不得新增说明性 query 字段。 +- 空字符串、全空白字符串统一视为 `null`,避免刷新恢复时写入无效私有参数。 +- work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。 +- 拼图 runtime query 独立使用 `mode`、`runtimeSessionId`、`runtimeProfileId`、`runtimeLevelId`、`publicWorkCode`,不与创作恢复 query 混写。 +- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 +- 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。 +- 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底,不执行网络请求、草稿打开、stage 切换或 URL 写回。 +- 作品 / 草稿身份匹配只允许非空目标值命中,避免 query 缺失时用 `null` / 空值误匹配到无效草稿。匹配谓词仍只判断身份,不触发列表读取或打开动作。 +- 跳一跳和敲木鱼的恢复阶段落点由 `resolveJumpHopCreationUrlRestoreStage` 与 `resolveWoodenFishCreationUrlRestoreStage` 决定;生成路径优先进入生成页,否则按是否恢复到 draft / work 落到结果页或工作台。 +- `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。 + +## Depth / Leverage / Locality + +- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state;各玩法字段优先级藏在 Module Implementation 内。 +- **Leverage**:新增或调整玩法恢复规则、恢复目标或恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。 +- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module,避免散落在页面壳、作品架和 runtime 打开逻辑中。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts` +- `npm run test -- src/services/creationUrlState.test.ts` +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationUrlStateModel.ts src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformDraftGenerationShelfModel.ts --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md b/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md new file mode 100644 index 00000000..ea4cd66d --- /dev/null +++ b/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md @@ -0,0 +1,33 @@ +# 【前端架构】Creation Work Delete Flow 收口计划 + +## 背景 + +平台入口作品架的删除入口覆盖 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物。此前 `PlatformEntryFlowShellImpl.tsx` 在每个删除 handler 内重复计算确认框标题、删除说明、草稿 notice key 和拼图派生稳定 ID。壳层既要理解每种玩法的作品身份,又要承接异步删除、刷新列表、错误状态和页面跳转,导致删除确认规则缺少稳定测试面。 + +该 **Interface** 过浅:页面只想展示“删除哪个作品、会从哪里移除、删除成功后清哪些生成 notice”,却必须知道 `workId` / `profileId` / `sourceSessionId` / `draftId`、`status` / `publicationStatus` / `publishStatus` 和宝贝识物特殊公开去向。 + +## 决策 + +新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts` 作为 Creation Work Delete Flow **Module**。其唯一公开 **Interface** 是 `resolvePlatformCreationWorkDeleteConfirmationModel(input)`,输入为带 `kind` 的 union,输出: + +- `id`:确认框和删除 busy 使用的稳定作品 ID。 +- `title`:确认框标题,含拼图、视觉小说和宝贝识物标题兜底。 +- `detail`:草稿 / 已发布删除说明,宝贝识物已发布使用“寓教于乐板块”文案。 +- `noticeKeys`:删除成功后应标记已读的草稿生成 notice keys,拼图包含 `buildPuzzleResultWorkId` / `buildPuzzleResultProfileId` 派生 key。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责鉴权保护、确认框 state、调用各玩法删除 API、清错误、刷新作品架 / 公开广场、`markDraftNoticeSeen` 和必要的页面跳转。`run` 不进入纯 **Module**,避免把网络副作用和 React state 写入藏入模型层。 + +## 约定 + +- 新玩法接入作品架删除时,先补齐后端删除链路、作品架 action 和本 **Module** 的确认模型,再开放删除按钮。 +- Jump Hop、Wooden Fish 和 Bark Battle 当前仅有作品架 action 预留,平台壳不传删除 handler;不得因本 Module 存在而默认开放删除。 +- 删除确认文案不得散回平台壳;若公开去向不是公开广场,应在本 **Module** 明确分支。 +- 草稿 notice key 的身份扩展必须复用 `collectDraftNoticeKeys`,保持 trim、去空和去重语义一致。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationWorkDeleteFlow.ts src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md new file mode 100644 index 00000000..2c508c5f --- /dev/null +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -0,0 +1,40 @@ +# 【前端架构】Draft Generation Shelf Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖、作品详情更新回填和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。 + +该 **Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。 + +## 决策 + +新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf **Module**。其 **Interface** 收口为: + +- `collectDraftNoticeKeys(kind, ids)` / `getGenerationNoticeShelfKeys(item)`:统一把玩法草稿身份映射为 notice key。 +- `createPendingDraftShelfState(...)` 与 `buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。 +- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 +- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 +- `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 +- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)`、`resolveVisualNovelDraftOpenIntent(...)`、`resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说、跳一跳和敲木鱼草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复、失败 fallback stage 和普通草稿恢复优先级。 +- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。 + +## 约定 + +- 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。 +- pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`,pending 占位必须让位。 +- 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。 +- 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 +- 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 +- 草稿打开 intent 只返回纯计划、notice keys 与必要稳定 ID,不创建失败生成态、不请求详情、不写 stage;这些仍由壳层 Adapter 执行。 +- 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"` +- 针对新 **Module** 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md new file mode 100644 index 00000000..a39d835b --- /dev/null +++ b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md @@ -0,0 +1,34 @@ +# 【前端架构】Match3D Runtime Profile 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时编排抓大鹅创作、作品详情、推荐 runtime 和正式 runtime。运行态启动前的 profile 规范化、公开详情转 work、生成背景资产提升、run / profile / public detail 优先级和 runtime 素材选择原本都在平台壳 **Implementation** 内,导致平台壳必须理解抓大鹅生成素材的内部结构。 + +## 决策 + +新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,作为抓大鹅 runtime profile **Module**。该 **Module** 的 **Interface** 收口为: + +- `mapPublicWorkDetailToMatch3DWork(entry)`:把公开作品详情映射为可启动 runtime 的 Match3D work,并补齐生成背景资产。 +- `buildMatch3DProfileFromSession(session)`:从创作 session draft 生成 runtime profile。 +- `normalizeMatch3DWorkForRuntimeUi(profile)` / `mapMatch3DWorksForRuntimeUi(profiles)`:统一作品列表进入 UI / runtime 前的素材规范化。 +- `promoteMatch3DGeneratedBackgroundAsset(profile)`:从 `generatedBackgroundAsset` 或 `generatedItemAssets[].backgroundAsset` 提升背景图、对象 key 与 prompt。 +- `hasMatch3DRuntimeAsset(profile.generatedItemAssets)` / `hasMatch3DRuntimeBackgroundAsset(profile)`:统一判断 runtime 是否具备物品与背景素材。 +- `resolveActiveMatch3DRuntimeProfile(run, runtimeProfile, profile)`:按 run 的 `profileId` 选择当前 profile,避免切屏时误用旧草稿。 +- `resolveMatch3DRuntimeGeneratedItemAssets(...)`、`resolveMatch3DRuntimeGeneratedBackgroundAsset(...)`、`resolveMatch3DRuntimeBackgroundImageSrc(...)`:统一 run / profile / public detail 的素材优先级。 + +`PlatformEntryFlowShellImpl.tsx` 只保留启动 run、预加载、路由、错误和 state 编排;抓大鹅素材规则集中到该 **Module**,提升 **Locality** 与测试 **Leverage**。 + +## 约定 + +- 公开详情补 runtime 素材时,只有 `profileId` 与 run 匹配才优先使用公开详情;错配时不得污染当前 run。 +- 当前启动时拿到的 `runtimeProfile` 优先于旧草稿 profile;若 run 指向旧草稿 profile,才使用草稿 profile。 +- 背景资产提升不得覆盖已有显式 `backgroundImageSrc` / `backgroundImageObjectKey` / `generatedBackgroundAsset`,只补缺。 +- 本 **Module** 只放纯 profile / asset 规则,不引入启动 run、预加载、URL、状态机或 UI 副作用。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"` +- `npm run typecheck` +- `npm run check:encoding` +- 针对新 Module 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 diff --git a/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bbcd2525 --- /dev/null +++ b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md @@ -0,0 +1,29 @@ +# 【前端架构】Platform Creation Launch Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的创作入口点击回调曾直接以内联 `if` 链判断 `airp` 占位、隐藏的 `baby-object-match`、RPG 与各小游戏工作台启动目标。壳层因此同时理解入口 ID、是否需要执行启动前准备、隐藏模板错误文案和具体工作台分流。 + +这类规则属于创作入口启动意图。壳层应只执行准备、错误提示和受保护动作,不应持有入口 ID 到工作台目标的长链判定。 + +## 决策 + +新增 `src/components/platform-entry/platformCreationLaunchModel.ts` 作为 Platform Creation Launch **Module**。其公开 **Interface** 为: + +- `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })`:输入后端入口配置下发的模板 ID 与幼教入口可见性,输出 `noop`、`blocked` 或 `launch` 意图。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:根据 intent 决定是否调用 `prepareCreationLaunch()`,对 blocked intent 写入 `sessionController.setCreationTypeError(...)`,对 launch intent 进入 `runProtectedAction(...)` 并调用具体工作台打开函数。 + +## 约定 + +- `airp` 是占位入口,必须在 `prepareCreationLaunch()` 之前返回 `noop`,避免触发新游戏初始化、返回目标复位或错误清理。 +- 隐藏的 `baby-object-match` 必须在 `prepareCreationLaunch()` 之后返回 blocked intent,错误文案仍使用 `EDUTAINMENT_HIDDEN_MESSAGE`。 +- 未知入口 ID 保持旧语义:先允许壳层执行启动前准备,再作为 `noop` 结束,避免改变未来后端配置异常时的准备流程。 +- 新增可启动模板时,先在本 **Module** 的 launch target union、目标集合和测试中列明,再在壳层 Adapter 中补具体启动函数。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationLaunchModel.ts src/components/platform-entry/platformCreationLaunchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..525d4669 --- /dev/null +++ b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md @@ -0,0 +1,46 @@ +# PlatformDialogStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载平台级错误 / 完成弹窗的纯状态规则:错误文案 trim、来源 label 与 id 拼接、后台生成仍在处理的识别、错误候选优先级、dismiss key 与生成完成文案都散在壳层 Implementation 内。壳层因此既要管理 React state 与副作用清理,又要记住弹窗判定细则;新增玩法错误或调整弹窗展示时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module。 +- Module Interface 收口: + - `normalizePlatformDialogMessage` + - `formatPlatformDialogSource` + - `isBackgroundGenerationStillRunningMessage` + - `resolvePlatformErrorDialog` + - `buildPlatformErrorDialogDismissKey` + - `buildPlatformTaskCompletionDialogDismissKey` + - `resolveActivePlatformDialog` + - `PLATFORM_TASK_COMPLETION_MESSAGE` + - `PlatformErrorDialogState`、`PlatformTaskFailureDialogState` 与 `PlatformTaskCompletionDialogState` +- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:汇总各玩法候选、持有 React state、关闭弹窗时清理对应 setter。副作用清理不下沉到 Module,避免把大量壳层 setter 变成浅 Interface。 + +## Interface 约束 + +- 错误与完成弹窗文案先 trim;空字符串或全空白字符串统一视为 `null`。 +- 来源格式固定为 `label + 空格 + trimmed id`;缺 id 时只返回 label。 +- 平台错误候选按数组顺序取第一个有效文案;候选本身只描述 `key/source/message`。 +- 错误 dismiss key 固定为 `key:source:message`;完成 dismiss key 固定为 `key:source:message:completedAtMs`,缺完成时间时补 `0`。 +- `resolveActivePlatformDialog` 只根据当前弹窗 dismiss key 与已记录 dismiss key 决定是否隐藏,不修改底层错误或完成状态。 +- 任务完成弹窗文案统一使用 `PLATFORM_TASK_COMPLETION_MESSAGE`,不得在壳层重复写同一中文 literal。 +- `closePlatformErrorDialog` 保持在壳层 Adapter;它负责按错误来源清理 `creationEntryConfigError`、玩法 error、作品详情 error 等副作用状态,不属于纯状态 Module。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入候选和 dismiss 记录,即可得到当前平台弹窗状态;文案归一、来源格式和 dismiss 规则藏在 Module Implementation 内。 +- **Leverage**:新增玩法错误来源时只需补候选;调整弹窗纯规则时优先改 Module 与单测。 +- **Locality**:平台错误弹窗、任务完成弹窗和后台生成 still-running 识别集中在一个小 Module,避免继续散落在大型平台壳 Implementation 内。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts` +- `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "background match3d draft failure notifies and reopens failed retry page|completed match3d draft notice first opens trial then reopens result|puzzle compile timeout shows failure dialog when reread session is still generating"` +- `npx eslint src/components/platform-entry/platformDialogStateModel.ts src/components/platform-entry/platformDialogStateModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md new file mode 100644 index 00000000..f9c5abe3 --- /dev/null +++ b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md @@ -0,0 +1,37 @@ +# 【前端架构】Platform Generation Progress Tick Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 曾以内联三元链按 `selectionStage` 选择拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼和宝贝识物的生成状态,并额外手写视觉小说的 `startedAtMs` / `phase` 判定。壳层因此既要维护 `setInterval` 副作用,又要记住每个生成页 stage 对应哪份进度状态。 + +生成进度是否需要 tick 是纯判定;`Date.now()`、`window.setInterval` 和进度时间 state 写入仍属于 React 壳层副作用。 + +## 决策 + +新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts` 作为 Platform Generation Progress Tick **Module**。其公开 **Interface** 为: + +- `resolvePlatformGenerationProgressTickDecision(input)`:输入当前 `selectionStage`、各小游戏 `MiniGameDraftGenerationState` 和视觉小说轻量生成状态,输出 `{ activeKind, shouldTick }`。 +- `PlatformGenerationProgressTickKind`:枚举可 tick 的生成类型,包含已有小游戏生成 kind 与 `visual-novel`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它把当前 state 组装给 Module,若 `shouldTick=false` 则不启动 interval;若为真,仍按旧逻辑立即写一次 `Date.now()`,再每 `500ms` 更新并在 effect cleanup 中清理 timer。 + +## Interface 约束 + +- 小游戏生成 stage 只读取匹配 kind 的 `MiniGameDraftGenerationState`;stage 与 state 不匹配时不 tick。 +- 小游戏状态缺失、`phase='ready'` 或 `phase='failed'` 时不 tick;其它 phase 按进行中处理。 +- `visual-novel-generating` 不强行转成 `MiniGameDraftGenerationState`,只在 `startedAtMs != null` 且 phase 非 `ready` / `failed` 时 tick。 +- 非生成 stage 即使传入可运行 state 也不 tick。 +- 本 Module 不计算进度、不重建 view state、不处理拼图 / 抓大鹅 background task 覆盖;这些仍按既有生成页和作品架模型处理。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费 `shouldTick`,stage 到 state 的映射和终态判定藏入 Module Implementation。 +- **Leverage**:新增生成页玩法时,先扩展 stage-to-kind 映射和单测,再让壳层 Adapter 传入对应 state。 +- **Locality**:生成进度 tick 规则集中到一个纯测试面,interval 副作用继续局部留在 React effect,避免把 timer 控制做成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts` +- `npx eslint src/components/platform-entry/platformGenerationProgressTickModel.ts src/components/platform-entry/platformGenerationProgressTickModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md new file mode 100644 index 00000000..1b6e6cc7 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md @@ -0,0 +1,47 @@ +# 【前端架构】Platform Mini Game Draft Generation State Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage,又要记住 `MiniGameDraftGenerationState` 的生命周期细节。 + +这些状态变换不读取 DOM,不请求网络,也不写 React state;它们属于平台层小游戏草稿生成状态 **Module**。壳层只应决定何时调用、把返回值写入对应 state。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts` 作为 Platform Mini Game Draft Generation State **Module**。其公开 **Interface** 为: + +- `createMiniGameDraftGenerationStateForRestoredDraft(kind, metadata?, startedAtMs?)`:为恢复的草稿重建生成态,并保留后端开始时间作为进度事实源。 +- `createFailedMiniGameDraftGenerationStateForRestoredDraft(kind, updatedAt, error, metadata?)`:恢复失败草稿时按后端 `updatedAt` 建立失败态。 +- `rebaseMiniGameDraftGenerationStateForDisplay(state)` 与 `rebaseMiniGameDraftBackgroundCompileTaskForDisplay(task)`:清理展示用 `finishedAtMs`,避免返回生成页后沿用结束态计时。 +- `createPuzzleDraftGenerationStateFromPayload(payload, session?)`、`resolvePuzzlePhaseFromSessionProgress(state, session)`、`mergePuzzleSessionProgressIntoGenerationState(state, session)`:集中处理拼图生成的 aiRedraw、后端进度百分比和 phase 推进。 +- `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)`:抓大鹅轮询到作品素材后,按可用图片数量推进生成页资产计数,并把首个素材错误传播到生成态。 +- `resolveFinishedMiniGameDraftGenerationState(state, phase, options?)`:统一完成 / 失败收尾的 `finishedAtMs`、错误与资产计数合并。 +- `isMiniGameDraftReady(state)` 与 `isMiniGameDraftGenerating(state)`:统一生成态轻量判定。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、background task、React state 写入、作品架刷新、URL 与 stage 切换。 + +## Interface 约束 + +- 恢复草稿状态必须允许调用方传入 `startedAtMs`;未传时使用当前时间,与旧逻辑一致。 +- 恢复失败状态必须通过 `resolveMiniGameDraftGenerationStartedAtMs(updatedAt)` 解析后端时间,并保留传入 metadata。 +- `resolveFinishedMiniGameDraftGenerationState` 只覆盖显式传入的 `error`、`completedAssetCount`、`totalAssetCount`;未传时沿用原 state。 +- 拼图 session 只有在 `draft` 存在且不是 `formDraft` 时才视为后端编译生成中 session,才写入 `puzzleProgressPercent` 并推进 phase。 +- 拼图进度阈值保持旧值:`>=96` 到 `puzzle-select-image`,`>=94` 到 `puzzle-ui-assets`,`>=88` 时按 `puzzleAiRedraw=false` 进入 `puzzle-level-scene`,否则进入 `puzzle-cover-image`。 +- phase 变化时 `puzzleActiveStepStartedAtMs` 使用 session `updatedAt` 解析值;phase 不变时保留旧值。 +- 抓大鹅资产旁路进度不得覆盖 `ready` 或 `failed` 终态;非终态下只统计有 `imageViews[].imageObjectKey` / `imageViews[].imageSrc`、顶层 `imageObjectKey` 或顶层 `imageSrc` 的素材。 +- 抓大鹅资产旁路进度的 `totalAssetCount` 至少为 `5`,保留当前五物品首批生成节奏;已有素材数量超过 `5` 时按真实素材数量展示。 +- 抓大鹅已有可用素材时 phase 推进到 `match3d-generate-views`;无可用素材时保留原 phase;首个素材错误写入 `error`,无素材错误时保留原错误。 +- 展示 rebase 只清理 `finishedAtMs`,不得修改 phase、error、资产计数或 metadata。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、抓大鹅素材计数、时间解析与计数合并藏入 Module Implementation。 +- **Leverage**:后续新增小游戏生成恢复、调整拼图后端进度阈值或改变抓大鹅素材批次展示时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。 +- **Locality**:小游戏生成状态规则集中到一个纯测试面,避免在大型壳层的 API callback、background task 和恢复流程中重复推理 `MiniGameDraftGenerationState`。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md new file mode 100644 index 00000000..cc6572cd --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md @@ -0,0 +1,52 @@ +# 【前端架构】Platform Mini Game Draft Payload Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、跳一跳 / 敲木鱼生成 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级、跳一跳 / 敲木鱼 payload 与 session draft 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 + +这些逻辑都是 DTO 变换;不读取 React state,不请求网络,也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts` 作为 Platform Mini Game Draft Payload **Module**。其公开 **Interface** 为: + +- `buildPuzzleFormPayloadFromWork(item)`:从拼图作品摘要恢复创作表单 payload。 +- `buildPuzzleFormPayloadFromSession(session)`:从拼图 session 恢复创作表单 payload。 +- `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload,仅接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`。 +- `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。 +- `buildPuzzleWorkUpdatePayloadFromDraft(draft)`:从拼图结果 draft 构造 `updatePuzzleWork(...)` 所需 payload。 +- `buildJumpHopDraftActionPayload(actionType, { payload, draft })`:从跳一跳表单 payload / session draft 构造生成或重生成 action。 +- `buildWoodenFishDraftActionPayload(actionType, { payload, draft })`:从敲木鱼表单 payload / session draft 构造生成或重生成 action。 +- `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。 +- `isPuzzleFormOnlyDraft(session)` 与 `isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。 +- `buildMatch3DFormPayloadFromSession(session)` 与 `buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。 +- `buildPendingMatch3DDraftMetadata(payload)`:从抓大鹅 payload 派生 pending metadata。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、Action 执行、background task、生成状态、错误提示、作品架和阶段切换。 + +## Interface 约束 + +- 拼图 work payload 的 `pictureDescription` 优先级固定为 `workDescription > summary > first level pictureDescription > levelName > workTitle > ''`。 +- 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`。 +- 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText`;`workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`。 +- 拼图 action 还原只接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`;其它 action 返回 `null`。 +- 拼图作品更新 payload 必须直接映射 `workTitle`、`workDescription`、`levelName`、`summary`、`themeTags`、`coverImageSrc`、`coverAssetId`,`levels` 缺失时回退空数组。 +- 跳一跳和敲木鱼生成 action payload 的字段优先级固定为表单 payload 优先,其次 session draft;重生成 action 只传 session draft 字段。 +- 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。 +- 空 form-only 草稿必须同时缺少 `seedText`、`formDraft.workTitle`、`formDraft.workDescription` 与 `formDraft.pictureDescription`。 +- 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack`;`anchorPack.clearCount` 与 `anchorPack.difficulty` 只接受有限数字字符串或数字。 +- 抓大鹅 work payload 的 `themeText` 优先 `themeText`,缺失回退 `gameName`。 +- pending metadata 只收非空 trim 后标题和摘要;抓大鹅 metadata 用 `themeText || seedText` 同时作为 title 和 summary。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、结果页 draft 更新字段、跳一跳 / 敲木鱼 action 字段、默认空资产和数字解析藏入 Module Implementation。 +- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段、跳一跳 / 敲木鱼生成 action 字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 +- **Locality**:表单恢复、作品更新与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameDraftPayloadModel.ts src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md new file mode 100644 index 00000000..1578d439 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md @@ -0,0 +1,47 @@ +# 【前端架构】Platform Mini Game Session Mapping Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。 + +这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为: + +- `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`。 +- `buildSquareHoleProfileFromSession(session)`:从方洞挑战 Agent session draft 构造草稿 `SquareHoleWorkProfile`,缺 session、缺 draft 或缺 profileId 时返回 `null`。 +- `buildVisualNovelSessionFromWorkDetail(work)`:从视觉小说 work detail 恢复 `VisualNovelAgentSessionSnapshot`,供草稿作品架回到结果页继续编辑。 +- `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。 +- `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session,并按 summary / fallback / profileId 决定 sessionId。 +- `buildWoodenFishGeneratingWorkSummary(session, payload?)`:从敲木鱼生成 session 和可选表单 payload 构造作品架生成中摘要。 +- `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session`、`set*Work`、`set*Run`、`createMiniGameDraftGenerationState(...)`、`writeCreationUrlState(...)`、`enterCreateTab()` 和 `setSelectionStage(...)`。 + +## Interface 约束 + +- 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。 +- 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`;workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。 +- 拼图 owner 缺省为 `current-user` / `玩家`;`publishReady` 来自 `session.resultPreview?.publishReady`。 +- 方洞 profile 的 `workId` 与 `profileId` 都来自 draft `profileId`;owner 固定为 `current-user`,`sourceSessionId` 来自 sessionId。 +- 方洞 profile 的 `updatedAt` 优先 session `updatedAt`,缺失时使用当前时间;`publicationStatus='draft'`、`playCount=0`、`publishedAt=null`,`publishReady` 来自 draft。 +- 视觉小说恢复 session 的 `sessionId` 优先归一化后的 `sourceSessionId`,为空时回退 `workId`;`status='ready'`,`messages=[]`,`pendingAction=null`,`sourceMode` 来自 draft,`updatedAt` 来自 summary。 +- 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。 +- 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`。 +- 敲木鱼生成中摘要的 `workId/profileId/sourceSessionId` 都来自 sessionId;标题、描述和标签优先表单 payload,其次 session draft,最后回退 `敲木鱼` / 空描述 / `['敲木鱼']`。 +- 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值、视觉小说 session fallback、敲木鱼生成中摘要和 pending draft 字段藏入 Module Implementation。 +- **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。 +- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复 / 生成中映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameSessionMappingModel.ts src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md new file mode 100644 index 00000000..554ec31a --- /dev/null +++ b/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md @@ -0,0 +1,39 @@ +# 【前端架构】Platform Played Work Open Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调曾在壳层内直接判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID 兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback。壳层因此同时承载纯打开意图与异步副作用,后续新增玩法或修正玩过作品身份时缺少稳定测试面。 + +个人“玩过作品”的点击规则属于打开意图。壳层应只关闭面板、读取 gallery、打开详情和写错误;玩法别名、目标 ID、fallback payload 应收口到纯 **Module**。 + +## 决策 + +新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts` 作为 Platform Played Work Open **Module**。其公开 **Interface** 为: + +- `resolvePlatformPlayedWorkOpenIntent(work)`:输入 `ProfilePlayedWorkSummary`,输出 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。 +- `PlatformPlayedWorkOpenIntent`:描述壳层可执行的打开动作;大鱼吃小鱼意图包含 `sessionId` 和 gallery miss 时使用的 `fallbackWork`,RPG 意图包含 `CustomWorldGalleryCard` 详情 payload。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `setIsProfilePlayStatsOpen(false)`、各玩法 `open*PublicWorkDetail`、`refreshBigFishGallery()`、大鱼 gallery 命中优先逻辑、`mapBigFishWorkToPublicWorkDetail(...)` 与错误 setter。 + +## Interface 约束 + +- `worldType` 只做小写归一,不 trim;`worldKey` 前缀匹配保持大小写敏感,延续旧行为。 +- `profileId` 使用 nullish 优先级:只在 `profileId` 为 `null` / `undefined` 时从 `worldKey` 前缀兜底;空字符串仍视为缺目标并返回 `noop`。 +- `puzzle` 打开时固定携带 `{ tab: 'profile' }`。 +- `match3d` / `match_3d`、`square-hole` / `square_hole`、`jump-hop` / `jump_hop`、`wooden-fish` / `wooden_fish`、`big-fish` / `big_fish` 均保持既有别名。 +- `big-fish` 缺 gallery 命中时使用 Module 生成的 `fallbackWork`,默认 `ownerUserId` 为空串、`authorDisplayName` 为 `worldSubtitle || '玩家'`、关卡和素材 ready 计数为 `0` / `false`。 +- 未识别的 `worldType` 仍按 RPG 公开详情打开;缺 `ownerUserId` 或缺 profile 目标时返回 `noop`。 + +## Depth / Leverage / Locality + +- **Depth**:调用方只消费一个打开 intent;玩法别名、目标 ID 兜底和 fallback payload 藏入 Module Implementation。 +- **Leverage**:新增“玩过作品”玩法时,先在 intent union、resolver 与单测中定义,再让壳层 Adapter 绑定对应打开副作用。 +- **Locality**:RPG fallback payload 与大鱼 fallback work 不再散落在大型壳层里,维护者可在纯测试中锁定字段契约。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts` +- `npx eslint src/components/platform-entry/platformPlayedWorkOpenModel.ts src/components/platform-entry/platformPlayedWorkOpenModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel"` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md new file mode 100644 index 00000000..f45a636e --- /dev/null +++ b/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md @@ -0,0 +1,32 @@ +# 【前端架构】Platform Profile Wallet Delta Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 仍内联维护个人钱包余额的本地 delta 规则:余额归一化、本地扣点 / 返还后的 dashboard 乐观更新,以及刷新服务端 dashboard 时如何抵消已经被服务端反映的本地 delta。 + +这些规则是纯展示状态计算,但留在平台壳层会让壳层同时理解钱包余额边界、整数截断、负数保护和服务端快照对账。 + +## 决策 + +新增 `platformProfileWalletDeltaModel.ts`,收口钱包余额本地 delta 的纯规则: + +- `resolveProfileWalletBalance(...)` 负责把 dashboard 余额归一为非负整数。 +- `adjustProfileDashboardWalletBalance(...)` 负责把本地 delta 应用到 dashboard,并刷新 `updatedAt`。 +- `reconcileProfileWalletLocalDeltaWithServerDashboard(...)` 负责在拿到新服务端 dashboard 后扣除已被服务端反映的本地借贷变化。 + +`PlatformEntryFlowShellImpl.tsx` 继续保留 API 请求、React ref、state 写入和刷新触发副作用。 + +## 接口约束 + +- 非数字、无穷值或空 dashboard 的余额按 `0` 处理。 +- 本地 delta 必须先 `Math.trunc`,余额不得低于 `0`。 +- 当服务端最新余额已经反映本地扣点时,剩余负 delta 应减少;已经全部反映时归零。 +- 当服务端最新余额已经反映本地返还 / 奖励时,剩余正 delta 应减少;已经全部反映时归零。 +- 服务端余额变化方向与本地 delta 相反时,不得错误抵消。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts` +- 针对新 Module 与 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint。 +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..602a185a --- /dev/null +++ b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md @@ -0,0 +1,40 @@ +# 【前端架构】Platform Public Code Search Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调曾直接在壳层内判断 `user_` / `user-`、`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB`、`CW`、纯数字和普通关键词的优先级。壳层因此既要持有搜索输入到查找顺序的纯规则,又要执行各玩法公开详情读取、用户读取、运行态启动和错误归航副作用。 + +公开搜索的“先查什么、失败后回退什么”是稳定的分流规则,应有独立测试面;壳层只应作为副作用 Adapter,按计划执行网络读取与打开动作。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts` 作为 Platform Public Code Search **Module**。其公开 **Interface** 为: + +- `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`。 +- `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id`、`public-user-code`、`rpg-work`、各玩法公开作品步骤与 `bark-battle-work`。 +- `mapRpgPublicCodeSearchDetailToGalleryCard(entry)`:把 RPG by-code 详情响应映射为公开作品卡,收口 `playCount` / `remixCount` / `likeCount` 的 `0` 兜底。 +- `resolve*PublicCodeSearchMatch(entries, keyword)`:统一各玩法公开作品列表的公开码匹配、公开可见性过滤和详情卡 DTO 映射;拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪都走此接口。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步;壳层不再重复维护 per-play `isSame*PublicWorkCode` 匹配和 DTO 映射。 + +## Interface 约束 + +- 空白搜索词返回 `null`,壳层不得进入搜索 loading。 +- `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。 +- `PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入并匹配 `M3` / 抓大鹅。 +- `CW` 与 `1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。 +- 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费短小的 `steps` 与 match result Interface,搜索前缀、优先级、回退顺序、per-play 匹配和 DTO 映射藏入 Module Implementation。 +- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表、matcher 和单测,再在壳层 Adapter 绑定对应网络读取与打开动作。 +- **Locality**:搜索计划与作品命中规则集中在一个纯 Module;UI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts` +- `npm run test -- src/services/publicWorkCode.test.ts` +- `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md new file mode 100644 index 00000000..37571dab --- /dev/null +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -0,0 +1,68 @@ +# PlatformPublicWorkDetailFlow 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 已把公开作品身份、去重和推荐 runtime kind 收口到 `platformPublicGalleryFlow.ts`,但统一作品详情入口仍在壳层 Implementation 内直接判断 RPG、拼图、跳一跳、敲木鱼、视觉小说和其它玩法。壳层既要知道哪些公开详情可直接使用当前 entry,又要知道哪些玩法必须先补读完整详情,还要按当前用户判断详情按钮是“编辑”还是“改造”。这些是纯决策规则,继续留在巨型壳层会削弱 Locality。 + +## 决策 + +- 新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts` 作为 Platform Public Work Detail Flow Module。 +- Module Interface 收口: + - `getPlatformPublicWorkDetailKind(entry)` + - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` + - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` + - `resolvePlatformPublicWorkEditIntent(entry, deps)` + - `resolvePlatformPublicWorkLikeIntent(entry)` + - `resolvePlatformPublicWorkRemixIntent(entry)` + - `resolvePlatformPublicWorkStartIntent(entry, deps)` + - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` + - `resolveActivePlatformPublicWorkAuthorEntry(args)` + - `map*WorkToPublicWorkDetail(...)` + - `mapPublicWorkDetailToPuzzleWork(entry)` + - `mapPublicWorkDetailToBigFishWork(entry)` + - `mapPublicWorkDetailToSquareHoleWork(entry)` + - `mapBarkBattlePublicDetailToWorkSummary(entry)` + - `resolveVisiblePuzzleDetailCoverCount(entry, run)` +- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 +- 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 +- `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 +- 公开详情启动、编辑、点赞和改造只抽“意图” Interface,不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、草稿恢复、busy 状态、缓存同步、stage 切换和错误 setter,避免形成伪 Seam。 + +## Interface 约束 + +- `getPlatformPublicWorkDetailKind` 只根据 `PlatformPublicGalleryCard` 的玩法判定 helper 归一 kind;没有 `sourceType` 的公开 RPG 作品回退为 `rpg`。 +- `resolvePlatformPublicWorkDetailOpenStrategy` 只表达“如何打开详情”,不执行网络请求或 state setter。 +- 拼图、跳一跳、敲木鱼、视觉小说需要按 `profileId` 补读完整详情;返回对应 `load-*` strategy。 +- 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。 +- RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 +- `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 +- `resolvePlatformPublicWorkEditIntent` 只表达自有公开作品编辑意图:大鱼吃小鱼、拼图、抓大鹅、方洞挑战、视觉小说和汪汪声浪在能定位原草稿时返回对应 draft open 目标;跳一跳、敲木鱼和缺草稿作品返回原阻断文案;宝贝识物只返回需解析本地草稿的 intent;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回编辑 intent。壳层仍执行草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示。 +- `resolvePlatformPublicWorkEditIntent` 的 `deps` 只接编辑决策所需的当前拼图详情、当前 RPG 详情、视觉小说作品缓存、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module,以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。 +- `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 +- `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage,旧 RPG gallery fallback 返回可执行 intent,其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。 +- `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent,否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。 +- `resolvePlatformPublicWorkStartIntent` 的 `deps` 只接启动决策所需的当前拼图详情、当前 RPG 详情、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module,以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。 +- `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 +- `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 +- `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 +- `mapPublicWorkDetailToPuzzleWork`、`mapPublicWorkDetailToBigFishWork`、`mapPublicWorkDetailToSquareHoleWork` 和 `mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。 +- `resolveVisiblePuzzleDetailCoverCount` 只表达拼图公开详情封面解锁规则:非拼图、无当前 run 或 run 不属于当前公开详情时只展示首图;当前 run 属于该公开详情时按 `clearedLevelCount + 1` 解锁,但至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 +- Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module,因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 / 编辑 deps,即可得到详情打开策略、动作模式、编辑 / 点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 +- **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 +- **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"` +- `npx eslint src/components/platform-entry/platformPublicWorkDetailFlow.ts src/components/platform-entry/platformPublicWorkDetailFlow.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md new file mode 100644 index 00000000..6301b5b1 --- /dev/null +++ b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md @@ -0,0 +1,44 @@ +# 【前端架构】Platform Puzzle Draft Recovery Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。 + +`.hermes/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。 + +本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。 + +## 决策 + +新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts` 作为 Platform Puzzle Draft Recovery **Module**。其公开 **Interface** 为: + +- `normalizeRecoveredPuzzleDraftSession(session)`:从恢复会话里补齐首图 cover、assetId 和 selectedCandidateId;只有完整资产包满足时,才把 draft 与首关 `generationStatus` 改为 `ready`。 +- `hasRecoverableGeneratedPuzzleDraft(session)`:判断恢复会话是否拥有完整首关资产包。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责拉取 session、写 background task、写 React state、打开结果页和切换 stage。 + +## Interface 约束 + +- 无 draft 时保持原 session,并判定不可恢复完成态。 +- 首图可来自 `draft.coverImageSrc`、首关 `coverImageSrc` 或选中 / 首个候选图。 +- 完整首关资产包必须同时具备: + - 首图 cover; + - `levelSceneImageSrc` 或 `levelSceneImageObjectKey`; + - `uiSpritesheetImageSrc` 或 `uiSpritesheetImageObjectKey`; + - `levelBackgroundImageSrc` 或 `levelBackgroundImageObjectKey`。 +- cover / assetId / selectedCandidateId 可按旧优先级从 draft、首关、候选图回填;但若完整资产包不满足,不得把 `generationStatus` 抬为 `ready`。 +- 只修复前端恢复判定,不改变拼图发布接口、后端 session stage 或后端 preview compiler。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以两个函数表达“恢复会话归一化”和“是否可作为生成完成态恢复”;完整资产门槛和候选图 fallback 藏入 Module Implementation。 +- **Leverage**:后续后端补齐发布门槛时,可用同一资产语言对齐前端恢复模型,避免壳层再散落条件判断。 +- **Locality**:拼图恢复判定集中到纯测试面,避免在异步恢复 callback 中把半成品 ready 规则继续隐身。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts` +- `npx eslint src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md new file mode 100644 index 00000000..3891a897 --- /dev/null +++ b/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md @@ -0,0 +1,36 @@ +# 【前端架构】Platform Puzzle Runtime State Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联 `mergePuzzleServiceRuntimeState(...)`,在拼图排行榜提交回包后,把服务端 run 快照合并回当前前端 run。此逻辑没有 React state、网络、URL 或弹窗副作用,却需要理解 `PuzzleRunSnapshot` 的局部真相分工:拼块布局、当前关卡状态和计时结果由前端即时裁决;服务端回包只补排行榜、run 身份、通关数上限和下一关 handoff。 + +若该合并规则继续留在平台壳,后续调整排行榜来源、相似作品下一关或本地 / 服务端 run 混合策略时,维护者必须翻大型壳层并同时避开大量副作用代码。 + +## 决策 + +新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts` 作为 Platform Puzzle Runtime State **Module**。公开 **Interface**: + +- `mergePuzzleServiceRuntimeState(currentRun, serviceRun)`:当双方都有 `currentLevel` 时,保留当前前端关卡状态与棋盘,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff;任一方缺 `currentLevel` 时返回当前 run。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责提交排行榜、读取回包、写 React state、刷新 archive 和错误提示,不再持有拼图 run 快照合并字段清单。 + +## Interface 约束 + +- 缺少 `currentRun.currentLevel` 或 `serviceRun.currentLevel` 时不得合并,直接返回当前 run。 +- `clearedLevelCount` 取当前 run 与服务端 run 的最大值,避免服务端较旧回包降低本地通关数。 +- 排行榜优先取 `serviceRun.currentLevel.leaderboardEntries`;为空时取 `serviceRun.leaderboardEntries`;两者皆空时保留当前关卡榜单。 +- `currentLevel` 的棋盘、状态、计时和关卡字段来自当前 run,不被服务端回包覆盖。 +- `runId`、`entryProfileId`、`recommendedNextProfileId`、`nextLevelMode`、`nextLevelProfileId`、`nextLevelId`、`recommendedNextWorks` 来自服务端 run。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入当前 run 与服务端 run,即取得合并后的稳定快照;排行榜来源、下一关 handoff 和前端局部真相保留规则藏入 Module Implementation。 +- **Leverage**:排行榜提交、后续相似作品推荐或服务端 run 字段变化时,先改纯 Module 与单测,壳层提交副作用不变。 +- **Locality**:拼图 runtime 快照合并规则集中到一个纯测试面,避免在平台壳中继续散落 `PuzzleRunSnapshot` 字段判断。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts` +- `npx eslint src/components/platform-entry/platformPuzzleRuntimeStateModel.ts src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md new file mode 100644 index 00000000..c2c8e32d --- /dev/null +++ b/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md @@ -0,0 +1,36 @@ +# 【前端架构】Platform Recommend Runtime Auth Model 收口计划 + +## 背景 + +平台首页推荐流会以 embedded runtime 方式启动跳一跳、抓大鹅、方洞挑战、拼图、敲木鱼、视觉小说、大鱼吃小鱼和汪汪声浪等玩法。旧规则散在 `PlatformEntryFlowShellImpl.tsx` 顶层 helper 与多个启动 callback:匿名访客应申请 Runtime Guest Token,已登录或已有 access token 时应走 background auth,非 embedded 正常启动则不改普通鉴权。拼图还额外维护 `isolated` / `default` runtime auth mode,容易与通用推荐流口径漂移。 + +## 决策 + +新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以纯 **Module** 收口推荐 runtime 鉴权计划: + +- `resolvePlatformRecommendRuntimeAuthPlan(input)`:返回 `requestKind` 为 `none`、`background` 或 `runtime-guest`,并给出拼图 runtime 应落到 `default` 还是 `isolated`。 +- `shouldUsePlatformRecommendRuntimeGuestAuth(input)`:只判断当前用户状态和是否允许 guest auth,不读取本地 token。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它读取 `getStoredAccessToken()`、调用 `ensureRuntimeGuestToken()`、拼装具体 request options,并在启动拼图时写入 `setPuzzleRuntimeAuthMode(...)`。 + +## Interface 约束 + +- 非 embedded 且未显式允许 runtime guest auth 时,计划为 `none`。 +- embedded 推荐 runtime 若无登录用户且无本地 access token,计划为 `runtime-guest`。 +- embedded 推荐 runtime 若已有登录用户或本地 access token,计划为 `background`。 +- 拼图公开详情要求 `authMode='isolated'` 时,匿名状态应返回 `runtime-guest` 且 `puzzleRuntimeAuthMode='isolated'`。 +- 拼图公开详情要求 `authMode='isolated'` 但已登录或已有 access token 时,应回到 `default`,避免把账号态伪装成匿名 isolated guest。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入 embedded、是否允许 guest、用户 ID 与本地 token 布尔值,即得 request 计划和拼图 runtime auth mode。 +- **Leverage**:所有推荐 runtime 启动复用同一鉴权矩阵;新增玩法只需消费计划,不再重写匿名 / 已登录分支。 +- **Locality**:guest token 选择规则集中在纯测试面,具体 token 获取和 request options 仍留在壳层副作用 Adapter。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/platformRecommendRuntimeAuthModel.ts src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md new file mode 100644 index 00000000..efb106f9 --- /dev/null +++ b/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md @@ -0,0 +1,40 @@ +# 【前端架构】Platform Recommend Runtime Auto Start 收口计划 + +## 背景 + +平台推荐页的 embedded runtime 会在移动端首页自动选择当前推荐作品并启动对应玩法。旧 `useEffect` 同时判断桌面断点、当前 stage、当前 Tab、平台 loading、推荐列表是否为空、active entry 是否仍存在、对应 runtime 是否 ready、是否已有启动请求,以及下一条 entry 应选谁。 + +这组判断是纯推荐流自动启动决策,但留在 `PlatformEntryFlowShellImpl.tsx` 会让 effect 依赖很长,也让后续新增玩法时容易把 ready 判定和启动时机混在副作用里。 + +## 决策 + +扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`: + +- `noop`:当前不需要改变推荐 runtime。 +- `clear`:推荐列表为空,壳层应清空 active entry、runtime kind 和错误。 +- `start`:壳层应调用既有 `selectRecommendRuntimeEntry(entry)` 启动指定作品。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责收集 React state、清空 state、调用 `selectRecommendRuntimeEntry(...)` 和执行各玩法 runtime 副作用。 + +## Interface 约束 + +- 桌面端、非 `platform` stage、非 `home` Tab 或平台仍在 loading 时返回 `noop`。 +- 推荐列表为空时返回 `clear`。 +- active entry 存在且对应 runtime 已 ready 时返回 `noop`。 +- 当前已有启动请求时返回 `noop`。 +- active entry 存在但未 ready 时返回 `start(activeEntry)`。 +- active key 缺失或已不在列表中时返回 `start(firstEntry)`。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费三态决策;列表查找、ready 判定和自动启动门禁藏入 Flow Module Implementation。 +- **Leverage**:后续推荐流新增玩法或改 ready 判定,只需补 `platformPublicGalleryFlow.ts` 的模型测试。 +- **Locality**:effect 只保留副作用动作,不再承载推荐流状态机知识。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/platformPublicGalleryFlow.ts src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md new file mode 100644 index 00000000..0b4c33c4 --- /dev/null +++ b/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md @@ -0,0 +1,38 @@ +# 【前端架构】Platform RPG Agent Result Preview Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护 RPG Agent 结果页的发布门禁展示规则:从 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和场景章节中反证服务端返回的 legacy blocker 是否已经被当前结果页 profile 补齐,并同时在壳层内把 result preview source 映射成展示标签。 + +这些逻辑不读取 React state,不请求网络,不写 URL,也不操作弹窗;它们属于 RPG Agent 结果预览展示的纯判定。壳层继续负责 session、profile、发布动作和结果页 props 编排。 + +## 决策 + +新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts` 作为 Platform RPG Agent Result Preview **Module**。其公开 **Interface** 为: + +- `buildPlatformRpgAgentResultPublishGateView(profile, fallbackBlockers, fallbackPublishReady)`:无 profile 时沿用服务端 fallback;有 profile 时过滤已经被当前 profile 结构字段满足的发布 blocker,并按剩余 blocker 重算展示态 `publishReady`。 +- `resolvePlatformRpgAgentResultPreviewSourceLabel(source)`:把 `published_profile`、`session_preview` 和未知 future source 映射成结果页预览来源标签。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它只把 `agentResultPreview` 与 `generatedCustomWorldProfile` 交给 Module,并将返回的 blocker / label 传入结果页组件。 + +## Interface 约束 + +- 无 profile 时不得自行修正 blocker,必须保留 fallback blocker message 与 fallback `publishReady`。 +- 有 profile 时只过滤已知结构 blocker:`publish_missing_world_hook`、`publish_missing_player_premise`、`publish_missing_core_conflict`、`publish_missing_main_chapter`、`publish_missing_first_act`。 +- 世界钩子兼容读取 `worldHook`、`creatorIntent.worldHook`、`anchorContent.worldPromise`、`anchorContent.worldPromise.hook` 和 `settingText`。 +- 玩家前提兼容读取 `playerPremise`、`creatorIntent.playerPremise`、`anchorContent.playerEntryPoint.openingIdentity`、`openingProblem`、`entryMotivation`。 +- 主章节兼容读取 `chapters`、`sceneChapterBlueprints`、`sceneChapters`;首幕读取 `sceneChapterBlueprints` / `sceneChapters` 下的 `acts`。 +- 未知 blocker code 不得被前端过滤;未知 source 保留“服务端预览”兜底,不做穷尽删除。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以两个函数取得发布门禁展示和 source label;profile 兼容字段路径、legacy blocker code 与兜底规则藏入 Module Implementation。 +- **Leverage**:后续后端调整 RPG result preview blocker 或新增 source 时,先改 Module 与单测,再让壳层 Adapter 保持结果页 props 编排不变。 +- **Locality**:RPG Agent 结果预览展示规则集中到一个纯测试面,避免在大型平台壳中继续混杂 profile 结构探测。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts` +- `npx eslint src/components/platform-entry/platformRpgAgentResultPreviewModel.ts src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bb235147 --- /dev/null +++ b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md @@ -0,0 +1,32 @@ +# 【前端架构】Platform Selection Stage Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。 + +此外,平台壳还曾在多个 `useEffect` 中分别判断 big-fish、match3d、square-hole、visual-novel、baby-object-match 缺少草稿、作品或 run 时应回工作台、结果页还是首页。这类“当前 stage 已不能被现有状态支撑”的规则同样属于 stage 纯判定,不应散在壳层。 + +此前这些规则以内联长否定串或多段相似 effect 维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单和状态缺失回退,缺少独立测试面。 + +## 决策 + +新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为: + +- `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage;可保留则原样返回,否则返回 `platform`。 +- `resolveSelectionStageAfterMissingCreationState(params)`:输入当前 `SelectionStage` 与各玩法“是否有 session / draft / run / work / formPayload”等可渲染事实,输出状态缺失后应停留的 stage;仍可展示则原样返回。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,或把当前 React state 汇总为布尔事实,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 + +## 约定 + +- 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record` 与测试中列明。 +- 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`。 +- 缺失状态回退只读取壳层传入的布尔事实,不直接读取玩法 session / work / run 对象。big-fish、match3d、square-hole 的草稿事实必须来自 `Boolean(session?.draft)`;visual-novel 的 session draft 与 work draft 可独立支撑结果页;baby-object-match runtime 缺 draft 时不看 formPayload,直接回 `platform`。 +- 此 **Module** 不清理 state、不调用路由、不触发登录弹窗,只表达纯 stage 决策。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts` +- `npx eslint src/components/platform-entry/platformSelectionStageModel.ts src/components/platform-entry/platformSelectionStageModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md new file mode 100644 index 00000000..9f41bbba --- /dev/null +++ b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md @@ -0,0 +1,30 @@ +# 【前端架构】Profile Dashboard Presentation 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的“我的数据”、钱包 chip 和“玩过”弹窗共用一批展示规则:泥点数量压缩、累计时长固定小时展示、单作品游玩时长压缩、作品类型标签和作品 ID 兜底。原先这些规则散在页面 **Implementation** 内,导致格式口径只能靠 UI 集成测试间接保护。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,作为个人数据展示 **Module**。该 **Module** 的 **Interface** 收口为: + +- `buildProfileDashboardPresentation(dashboard)`:统一生成钱包余额、钱包文案、累计时长文案和已玩数量文案。 +- `formatDashboardCount(value)`:统一泥点和计数压缩规则。 +- `formatTotalPlayTimeHours(playTimeMs)`:统一“累计游戏时长”固定小时口径。 +- `formatCompactPlayTime(playTimeMs)`:统一“玩过”单作品紧凑时长。 +- `formatPlayedWorkType(value)` 与 `formatPlayedWorkId(work)`:统一“玩过”列表里的玩法标签和作品号兜底。 + +`RpgEntryHomeView.tsx` 只消费这些 presentation 函数,保留卡片、弹窗和点击处理。个人数据展示规则的 **Locality** 转移到该 **Module** 与纯测试,后续修改计数、时长或作品类型标签不再穿透页面 JSX。 + +## 约定 + +- `formatDashboardCount` 与公开作品卡片的 `formatCompactCount` 不合并,二者展示口径不同。 +- “累计游戏时长”固定以小时展示,避免个人数据卡在分钟 / 天之间跳动。 +- “玩过”列表当前仍按历史契约用 `profileId || worldKey` 展示作品号;若后端未来下发 `publicWorkCode`,应在此 **Module** 改口径。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..70a2bf60 --- /dev/null +++ b/docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md @@ -0,0 +1,31 @@ +# 【前端架构】Profile Funds ViewModel 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 原先直接维护钱包账单来源文案、金额正负号、账单余额兜底、充值价格、充值商品主值和会员摘要文案。这些规则散在页面 **Implementation** 内,且已与 `ProfileWalletLedgerEntry.sourceType` 契约产生漂移:后端可返回 `puzzle_author_incentive_claim`,页面没有对应中文 label,会把原始枚举值外显给用户。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 **Module**。该 **Module** 的 **Interface** 收口为: + +- `getWalletLedgerSourceLabel(sourceType)`:统一账单来源中文文案,补齐 `puzzle_author_incentive_claim`。 +- `formatWalletLedgerAmount(amountDelta)`:统一账单金额正负号。 +- `buildWalletLedgerPresentation(ledger, fallbackBalance)`:统一余额兜底与账单行 presentation。 +- `formatRechargePrice(priceCents)` 与 `buildRechargeProductValueLabel(product)`:统一充值商品价格与主值文案。 +- `buildMembershipLabel(membership, formatTime)`:统一会员摘要文案,并保留页面现有时间格式 Adapter。 + +`RpgEntryHomeView.tsx` 只消费该 **Module** 输出,保留弹窗布局、支付流程、微信渠道和轮询副作用。资金展示规则的 **Locality** 收口到纯函数测试,后续新增账单来源或调整价格 / 会员文案时不再穿透页面 JSX。 + +## 约定 + +- 未知账单来源仍保留原始 sourceType 兜底,避免新后端枚举被空白吞掉。 +- 账单余额继续沿用既有口径:有账单时取第一条 `balanceAfter`,无账单时使用外部 fallback balance。 +- 本次只收展示 **Interface**,不迁移支付确认、微信跳转、订单轮询或弹窗状态。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"` +- 针对变更文件执行 ESLint +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..88193054 --- /dev/null +++ b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md @@ -0,0 +1,29 @@ +# 【前端架构】Profile Task ViewModel 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的“每日任务”卡片与任务弹窗共用同一批展示规则:任务优先级、可领取 / 未完成选择、进度 clamp、奖励兜底、状态标签和按钮文案。原先这些规则散在巨型页面 **Implementation** 中,UI JSX 既要渲染,又要知道任务状态排序和兜底口径。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,作为每日任务展示模型 **Module**。该 **Module** 的 **Interface** 收口为: + +- `selectProfileTaskCenterTasks(tasks)`:统一任务中心只展示一条可操作任务,按 claimable / incomplete 优先级并保持原始顺序。 +- `selectProfileTaskCardTask(tasks)`:统一任务卡兜底顺序,先可操作,再 claimed,再非 disabled。 +- `buildProfileTaskCardSummary(center)`:统一任务卡的奖励、阈值、进度百分比与动作文案。 +- `buildProfileTaskProgressLabel(task)`、`getProfileTaskStatusLabel(status)`、`getProfileTaskClaimButtonLabel(task, isClaiming)`:统一任务弹窗中的进度、状态和按钮文案。 + +`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留弹窗、按钮和点击处理。每日任务展示规则的 **Locality** 转移到 ViewModel **Module** 与纯测试,后续新增任务状态或修改展示优先级不再穿透 UI。 + +## 约定 + +- 任务中心只露出当前最需要用户处理的一条任务。 +- 任务进度必须按 `0..threshold` clamp,避免异常后端进度撑破卡片进度条。 +- `pause` / `claim` 等副作用仍留在页面和后端 client;ViewModel 只做展示派生。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..57c1de53 --- /dev/null +++ b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md @@ -0,0 +1,40 @@ +# 【前端架构】Public Gallery ViewModel 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 同时承担首页、发现、分类、排行、搜索和公开作品卡片渲染。公开作品的 category 分组、跨来源去重、搜索归一化、作品号匹配、时间戳解析和列表排序原本都放在页面巨型 **Implementation** 中,导致公开作品规则与 JSX 交错,新增玩法时难以判断该改页面、卡片还是平台入口规则。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,作为公开作品 ViewModel **Module**。该 **Module** 的 **Interface** 收口为: + +- `buildPublicGalleryCardKey(entry)`:复用平台公开作品身份规则,补齐 jump-hop / wooden-fish 等玩法 key。 +- `buildPublicCategoryGroups(featuredEntries, latestEntries)`:统一去重、标签兜底和分类排序。 +- `getPlatformPublicEntries(featuredEntries, latestEntries)` / `getAllPlatformPublicEntries(featuredEntries, latestEntries)`:统一公开作品合并规则。 +- `getPlatformSearchableWorkIds(entry)`、`filterPlatformWorkSearchResults(entries, keyword)` 与 `isExactPublicWorkCodeSearch(entries, keyword)`:统一搜索归一化、compact code 匹配和排序。 +- `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。 +- `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。 +- `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)`、`buildPlatformRankingEntries(entries, tab)` 与 `getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。 +- `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption(kindFilter)`、`getPlatformCategorySortOption(sortMode)` 与 `getNextPlatformCategorySortMode(sortMode)`:统一分类频道的筛选 / 排序选项、默认值、label 兜底和排序循环。 +- `getPlatformCategoryKindFilter(entry)`、`matchesPlatformCategoryKindFilter(entry, kindFilter)`、`sortPlatformCategoryEntries(entries, sortMode)` 与 `getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。 + +`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。 + +## 约定 + +- 公开作品身份 key 与平台入口推荐流保持一致,优先复用 `platformPublicGalleryFlow`。 +- 搜索应同时匹配作品号、`profileId`、`workId`、标题、作者、摘要和副标题。 +- 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。 +- 时间解析必须保留后端 `seconds.microsZ` 兼容。 +- 分类筛选与排序的选项顺序、默认值、中文 label 和“综合 -> 最新 -> 游玩 -> 点赞 -> 综合”循环属于 ViewModel **Interface**;页面只能消费该 **Interface**,不得在 `RpgEntryHomeView.tsx` 复写数组或 fallback 文案。 + +## 后续深化 + +下一步可把移动 / 桌面 discover feed 的数据准备继续迁入 ViewModel,但卡片 JSX 与交互状态仍留页面内。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md new file mode 100644 index 00000000..9d1d1d78 --- /dev/null +++ b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md @@ -0,0 +1,31 @@ +# 【前端架构】Public Work Presentation 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind` 与 `formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。公开作者 lookup key 与头像首字也曾由页面手写,页面既要知道公开作品作者来源优先级,又要知道 `code:` / `id:` 前缀约定。 + +## 决策 + +在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 **Interface**: + +- `describePlatformPublicWorkKind(entry)`:统一公开作品玩法类型 label,并继续复用 `formatPlatformWorkDisplayTag` 的 4 字截断口径。 +- `formatPlatformCompactCount(value)`:统一游玩、改造、点赞、排行和分类指标的紧凑数字展示。 +- `resolvePlatformPublicWorkAuthorLookup(entry)`:统一公开作者查询 lookup,优先使用 `authorPublicUserCode`,否则回退 `ownerUserId`,并用结构化 `{ key, source, value }` 避免页面复写前缀规则。 +- `formatPlatformPublicAuthorAvatarLabel(authorDisplayName)`:统一公开作者头像无图时的首字兜底。 + +`RpgEntryHomeView.tsx` 删除本地类型 label、紧凑计数、公开作者 lookup 与头像首字 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。认证请求、缓存和失败兜底仍留页面侧 Adapter;集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。 + +## 约定 + +- 紧凑计数保留既有口径:`10000` 显示 `1.0万`,`100000000` 显示 `1.0亿`,一万以下不加千分位。 +- 玩法类型 label 继续遵循 4 字展示限制,例如“大鱼吃小鱼”外显为“大鱼吃小”。 +- 公开作者 lookup 的 `key` 只用于缓存索引;真正调用公开用户 Adapter 时以 `source` 和 `value` 分发,页面不得解析 `code:` / `id:` 前缀。 +- 本次不迁移排行 metric label / value 配对;该规则属于集合排序 **Module** 的后续切片。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"` +- 针对变更文件执行 ESLint +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..049faea6 --- /dev/null +++ b/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md @@ -0,0 +1,32 @@ +# 【前端架构】Ranking ViewModel 收口计划 + +## 背景 + +平台发现页排行频道以 `PlatformRankingTab` 决定 tab 文案、空态文案、排序字段和指标展示。原先排序与指标取值在 `rpgEntryPublicGalleryViewModel.ts`,而 tab label、metric label 与 empty text 留在 `RpgEntryHomeView.tsx`,页面还用类型断言寻找 active config,导致同一个排行语义的 **Interface** 分散。 + +## 决策 + +在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口排行 **Interface**: + +- `DEFAULT_PLATFORM_RANKING_TAB` 与 `PLATFORM_RANKING_TABS`:统一 tab 顺序、tab label、metric label 与空态文案。 +- `getPlatformRankingTabConfig(tab)`:统一 active tab 配置兜底。 +- `getPlatformRankingMetric(entry, tab)`:统一 metric label 与 value,避免 label/value 漂移。 +- `buildPlatformRankingEntries(entries, tab)` 继续承载排序规则。 + +`RpgEntryHomeView.tsx` 只保留 active tab 状态、点击与渲染,不再理解“热门榜=游玩值”“新品榜=近 7 日值”等映射。排行规则的 **Locality** 收口到 PublicGallery ViewModel。 + +## 约定 + +- 默认排行 tab 保持 `hot`。 +- tab 顺序保持“热门榜 / 改造榜 / 新品榜 / 点赞榜”。 +- 排序口径保持:`hot=playCount`、`remix=remixCount`、`new=recentPlayCount7d`、`like=likeCount`。 +- “新品榜”仍按近 7 日游玩数排序,不改为发布时间排序。 +- 页面层继续保留最多显示 30 条的展示限制。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"` +- 针对变更文件执行 ESLint +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..3ccbf7c4 --- /dev/null +++ b/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md @@ -0,0 +1,31 @@ +# 【前端架构】Recommend Feed ViewModel 收口计划 + +## 背景 + +平台首页推荐 feed、发现页推荐频道、桌面推荐格和正式 runtime 的上一条 / 下一条选择共用一批展示规则:公开作品跨来源去重、过滤寓教于乐隐藏内容、按精选优先再最新兜底、active key 失效时回到首项、前后相邻条目回环且单条目不自循环。原先这些规则分别散在 `RpgEntryHomeView.tsx` 与 `PlatformEntryFlowShellImpl.tsx` 的 **Implementation** 内,导致推荐预览与正式 runtime 之间存在口径漂移风险。 + +## 决策 + +在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed **Interface**: + +- `dedupePlatformPublicGalleryEntries(entries)`:统一公开作品按 `buildPublicGalleryCardKey` 去重,后出现来源覆盖旧值。 +- `buildPlatformRecommendFeedEntries(featuredEntries, latestEntries)`:统一推荐 feed 的精选 + 最新合并、隐藏寓教于乐内容与去重顺序。 +- `selectPlatformRecommendFeedWindow(entries, activeEntryKey)`:统一推荐页当前项、上一项、下一项和 active key 失效兜底。 +- `selectAdjacentPlatformRecommendEntry(entries, direction, baseEntryKey)`:统一正式 runtime 上一条 / 下一条回环选择,并避免单作品自循环。 + +`RpgEntryHomeView.tsx` 不再自建 `Map` 或手写取模;`PlatformEntryFlowShellImpl.tsx` 的 runtime 推荐条目也改用同一 **Module**。推荐 feed 的 **Locality** 回到 PublicGallery ViewModel,页面与 runtime 只保留 UI、动画和启动副作用。 + +## 约定 + +- 推荐 feed 仍只展示普通公开作品;寓教于乐内容由独立频道控制,不进入推荐 runtime 队列。 +- 去重保留既有“后出现来源覆盖旧值、插入位置不变”的行为。 +- active key 缺失或失效时,展示窗口回到首个推荐作品;单个作品没有上一条 / 下一条预览。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"` +- 针对变更文件执行 ESLint +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md new file mode 100644 index 00000000..35d40059 --- /dev/null +++ b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md @@ -0,0 +1,42 @@ +# RecommendSwipeDeckModel 收口计划 + +## 背景 + +移动端推荐首页的纵向 swipe deck 曾把拖拽阈值、offset clamp、commit 方向、rail class 和分享文案直接放在 `RpgEntryHomeView.tsx` Implementation 内。页面因此同时理解 DOM pointer 副作用、动画副作用与推荐卡纯规则,后续调整手势阈值或分享文案时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module。 +- Module Interface 收口: + - `hasRecommendDragStarted` + - `clampRecommendDragOffset` + - `resolveRecommendDragCommitDirection` + - `resolveRecommendCommitOffset` + - `buildRecommendSwipeRailClassName` + - `shouldAnimateRecommendSwipe` + - `buildRecommendShareText` +- `RpgEntryHomeView.tsx` 保留 pointer capture、DOM 高度读取、`setTimeout`、clipboard、like/remix/open 等副作用 Adapter;推荐卡纯规则不再散落在页面 Implementation 内。 + +## Interface 约束 + +- swipe 阈值、commit 动画时长和 drag fallback limit 只从 Module 导出,不在页面重复定义。 +- `deltaY < 0` 表示上滑进入下一条,返回方向 `1`;`deltaY > 0` 表示下滑进入上一条,返回方向 `-1`。 +- 未达到 commit 阈值时必须返回 `null`,页面 Adapter 只负责把 offset 归零。 +- rail class 仅由 `offsetY` 与 `commitDirection` 决定,CSS class 名保持现有命名。 +- 分享文案只使用公开作品名、作品号和详情 URL;公开作品码解析与复制副作用仍在页面 Adapter。 + +## Depth / Leverage / Locality + +- **Depth**:页面传入少量数值或公开作品身份,即可得到拖拽状态、提交方向、动画 class 和分享文案。 +- **Leverage**:调整推荐 swipe 体验时只需改 Module 与单测,交互测试仍护页面 Adapter。 +- **Locality**:pointer 事件生命周期与纯规则分离,推荐卡手势和分享规则集中到一个小 Module。 + +## 验收 + +- `npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"` +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"` +- `npx eslint src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts --max-warnings 0` +- `npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md new file mode 100644 index 00000000..c1cb759d --- /dev/null +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -0,0 +1,32 @@ +# 【前端架构】Runtime Client Family 收口计划 + +## 背景 + +多个小游戏 runtime client 都重复实现路径编码、JSON header / body、runtime guest token、认证影响策略和重试参数。重复逻辑分散在各玩法文件后,新增玩法容易遗漏 guest auth 或 retry 语义,也让测试必须逐玩法检查同一请求骨架。 + +## 决策 + +新增 `src/services/runtimeRequest.ts`,作为 Runtime Client Family 的请求 **Module**。其 **Interface** 包含: + +- `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 +- `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 + +`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts`、`barkBattleRuntimeClient.ts`、`puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求、`jumpHopClient.ts` 与 `woodenFishClient.ts` 的正式 run 请求,以及 `visualNovelRuntimeClient.ts` 的公开列表、run 读取、history 读取和 regenerate JSON 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 + +## 约定 + +- Runtime client 不再手写 `encodeURIComponent` 拼 path;应优先使用 `buildRuntimeApiPath`。 +- Runtime JSON 请求不再手写 `Content-Type`、guest `Authorization` 和 `buildRuntimeGuestAuthOptions` 合并;应优先使用 `requestRuntimeJson`。 +- 玩法专属 payload 归一化、返回值适配和中文错误文案仍属于各玩法 client。 +- 每迁移一个 client,必须保留原导出函数名与原调用方契约。 + +## 后续深化 + +下一批可评估是否扩展 `requestRuntimeJson` 支持 `timeoutMs` / `signal`,再迁移 Visual Novel start 请求;Visual Novel SSE、平台存档、平台 checkpoint,以及 Puzzle `pause` / `props` 继续保留各自现有 auth / stream 语义,暂不纳入通用 JSON helper。 + +## 验证 + +- `npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md new file mode 100644 index 00000000..de90be6b --- /dev/null +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -0,0 +1,36 @@ +# SSE 客户端传输层收口约定 + +更新时间:`2026-06-03` + +## 背景 + +前端多个服务 client 需要读取 Server-Sent Events,包括创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态。旧实现分别在各自文件里手写事件边界查找、`TextDecoder` 解码、JSON 解析和流结束 flush,容易出现 CRLF / LF 边界不一致、UTF-8 多字节字符尾部丢失、错误事件处理漂移,以及长连接达到最终状态后没有及时释放的问题。 + +## 决策 + +前端 SSE 的传输层统一收口到 `src/services/sseStream.ts`: + +- `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。 +- `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。 +- 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。 +- OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。 + +## 落地范围 + +本次先收口以下客户端: + +- `src/services/aiService.ts` +- `src/services/creation-agent/creationAgentSse.ts` +- `src/services/creative-agent/creativeAgentSse.ts` +- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` +- `src/services/rpg-entry/rpgProfileClient.ts` +- `src/services/llmClient.ts` + +后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 + +## 验收 + +- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 +- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 +- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 +- `npm run typecheck` 不产生新的类型错误。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md new file mode 100644 index 00000000..5b1cfd6c --- /dev/null +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -0,0 +1,34 @@ +# 【前端架构】Work Shelf Module 收口计划 + +## 背景 + +创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter。后续深化前,`CustomWorldCreationHub.tsx` 虽已不再按玩法 `kind` 分发点击,但生产调用仍向 Hub 传入多玩法 raw items 与 open/delete/claim 回调列阵,Hub Interface 仍偏 shallow。 + +## 决策 + +`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。 + +`buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`,Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。 + +`CustomWorldCreationHub.tsx` 的生产 **Interface** 收敛为 `shelfItems: CreationWorkShelfItem[]` 加 `loading/error/onRetry/mode/recentWorkItems/onOpenShelfItem/deletingWorkId/claimingPuzzleProfileId` 等 UI 状态。平台壳 `PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 open/delete/claim actions 后再传给 Hub;Hub 不再接触各玩法 raw items、删除权限布尔值或玩法专属打开回调。 + +测试文件通过 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 `shelfItems`,避免测试继续强化生产 Hub 的旧浅 Interface。 + +此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: + +- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem[]`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。 +- **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 +- **Locality**:作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。 + +## 后续深化 + +`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让平台壳直接传入 source adapters,从而继续减少按玩法平铺的参数数量。`deletingWorkId` 与 `claimingPuzzleProfileId` 仍是 Hub UI 状态,可后续下沉到 shelf item/action busy state。 + +## 验证 + +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work can open detail view before deleting from detail page|creation hub published work enters existing detail view|creation hub published work card reveals delete action after card action reveal"` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md new file mode 100644 index 00000000..8d109725 --- /dev/null +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -0,0 +1,56 @@ +# 【前端架构】平台入口 Public Gallery Flow Module 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为: + +- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。 +- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。 +- `resolvePlatformRecommendRuntimeStartIntent(entry, deps)`:把公开作品卡映射为推荐 runtime 启动意图、错误落点和 embedded / returnStage 参数。 +- `isPlatformRecommendRuntimeReadyForEntry(entry, state)`:用标量 ready state 判定当前推荐 runtime 是否已能承接该公开作品。 +- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 +- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 +- `buildPlatformPublicGalleryFeeds(input)`:统一构造 `featuredEntries` 与 `latestEntries`,收口各玩法可见性 gate、mapper 矩阵、汪汪声浪 works fallback 和推荐首屏 `slice(0, 6)`。 + +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id,不把各玩法 run snapshot 类型拖入 Module。 + +## 玩法身份规则 + +- `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 使用自身 `sourceType` 作为 key kind。 +- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind,避免后续幼教类模板共用 `sourceType` 时互相覆盖。 +- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`。 +- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。 +- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。 +- 公开作品流聚合时,大鱼吃小鱼、宝贝识物和视觉小说必须受各自可见性 gate 控制;汪汪声浪优先用 gallery entries,gallery 为空时才从 works 中筛 `status === 'published'` 作为 fallback。 + +## 推荐 runtime 启动意图 + +- `resolvePlatformRecommendRuntimeStartIntent` 只表达推荐 runtime 的启动目标,不执行鉴权、运行态 API、错误 setter、缓存、request key 或 UI 状态更新。 +- 大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动 intent;RPG 维持当前无嵌入 runtime 的 `mark-ready` 行为。 +- 大鱼吃小鱼、拼图、抓大鹅、方洞挑战和汪汪声浪在公开卡无法拼出启动 work 时返回 `blocked`,同时给出 `errorTarget`,由壳层 Adapter 分发到对应玩法错误 setter。 +- 拼图优先使用同 `profileId` 的 `selectedPuzzleDetail`,否则从公开卡映射兼容 work 摘要。 +- 抓大鹅 public detail -> work mapper 必须作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护 `generatedItemAssets` 归一化与背景资产提升。推荐 runtime 固定沿用旧参数 `returnStage = 'work-detail'` 与 `embedded = true`。 +- 汪汪声浪优先使用推荐流已持有的 `barkBattleGalleryEntries`,再回退公开卡映射;不额外读取作品架列表。 + +## 推荐 runtime ready 判定 + +- `isPlatformRecommendRuntimeReadyForEntry` 先要求 `state.activeKind` 与当前公开作品的 `getPlatformRecommendRuntimeKind(entry)` 相同,否则返回 `false`。 +- 大鱼吃小鱼、跳一跳、敲木鱼、抓大鹅、方洞挑战和视觉小说只看对应 `has*Run` 布尔值,保持旧行为,不在本 Module 内解析 run snapshot。 +- 拼图只看 `puzzleRunEntryProfileId` 或 `puzzleRunCurrentLevelProfileId` 是否等于当前公开作品 `profileId`。 +- 汪汪声浪和 RPG 在 kind 匹配时沿用旧 `ready = true` 行为;宝贝识物只看 `hasBabyObjectMatchDraft`。 +- 若未来要修正同玩法旧 run 误判或 RPG 无嵌入 runtime 的旧行为,应另立行为变更任务;本 Module 先只收口现有规则。 + +## 后续深化 + +下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity、runtime kind、推荐 runtime 启动意图与 ready 判定的修改集中在一处。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md new file mode 100644 index 00000000..9b0f263b --- /dev/null +++ b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md @@ -0,0 +1,38 @@ +# 【后端架构】Puzzle Publish Asset Gate 收紧计划 + +## 背景 + +拼图前端恢复链路已由 `platformPuzzleDraftRecoveryModel.ts` 收紧:只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时,才把恢复草稿抬为完成态。但后端仍有两处待发布门槛偏弱: + +- `module-puzzle::validate_publish_requirements(...)` 只校验作品名、描述、标签、关卡名与 cover。 +- `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready(...)` 也只校验同一组轻字段,并据此把 session stage 置为 `ready_to_publish`。 + +这会让只有首图但缺关卡正式画面、UI spritesheet 或关卡背景的半成品显示为可发布或进入待发布 stage。 + +## 决策 + +后端拼图待发布门槛统一收紧到完整首关资产包: + +- `module-puzzle` 在 `validate_publish_requirements` 中新增资产 blocker,业务规则继续留在领域模块。 +- `api-server` 的 session snapshot ready 判定复用同一资产语言,避免标签生成后把半成品 session stage 改成 `ready_to_publish`。 +- 本切片不改 SpacetimeDB schema、不改 DTO 字段、不改路由、不改计费和发布动作副作用。 + +## 接口约束 + +- 仍保留既有作品名、描述、标签数量、关卡名、cover 校验。 +- 每个关卡必须具备: + - `cover_image_src`; + - `level_scene_image_src` 或 `level_scene_image_object_key`; + - `ui_spritesheet_image_src` 或 `ui_spritesheet_image_object_key`; + - `level_background_image_src` 或 `level_background_image_object_key`。 +- 缺正式关卡画面、UI spritesheet、关卡背景时,各自输出明确 blocker。 +- `build_result_preview(...).publish_ready` 与 `is_puzzle_session_snapshot_publish_ready(...)` 必须在同一类缺资产草稿上返回 false。 +- `api-server` 从 action payload 构造 fallback session 时,缺资产 levels snapshot 应停留 `image_refining`,不得进入 `ready_to_publish`。 + +## 验收 + +- `cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements` +- `cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation` +- `npm run check:encoding` +- `git diff --check` +- 修改后按仓规尝试 `npm run api-server` 拉起后端并确认 `/healthz`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 17429e6c..ed2a1676 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -22,7 +22,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` - HTTP 服务:`api-server`。 - 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 -- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-speech`。 +- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 @@ -35,6 +35,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 4. 后端访问 SpacetimeDB 必须经 `spacetime-client` facade。 5. HTTP 鉴权、BFF 聚合、SSE、外部模型编排、OSS 上传和第三方回调在 `api-server`。 6. 前端共享 DTO 通过 `shared-contracts` 和 `packages/shared` 对齐,不在页面内重新发明旧接口。 +7. 微信能力按两层收口:`server-rs/crates/platform-wechat` 承载微信协议 client、订阅消息 `stable_token` / `subscribeMessage.send`、微信支付 V3 / 虚拟支付消息推送的 HTTP header、签名、验签、解密、mock 响应和协议 payload 解析;`server-rs/crates/api-server/src/wechat.rs` 与 `wechat/*` 承载 Axum handler、AppConfig 到平台配置的映射、Genarrative 用户 / 订单 / 钱包 / SSE / 错误 envelope 编排。`platform-auth` 当前仍承载微信 OAuth / 小程序登录 provider 协议,`api-server::wechat::provider` 只作为组合根 adapter,不在业务 handler 内散落 provider 构造。 验证: @@ -106,6 +107,8 @@ npm run check:server-rs-ddd - `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。 - `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。 +拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements` 与 `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + 该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 `/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 @@ -167,6 +170,12 @@ npm run check:server-rs-ddd 7. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`。 8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 +## 创作入口泥点扣费契约 + +1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。 +2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。 +3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。 + ## 外部服务与资产 - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 587c3741..ac61896e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -55,6 +55,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 +微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图`、`敲木鱼`、`抓大鹅`;`number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。 + 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index 296fd2ed..61057acd 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -17,7 +17,9 @@ - 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx` - 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js` - API 契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs` -- 后端下单与签名:`server-rs/crates/api-server/src/runtime_profile.rs` +- 后端下单与订单编排:`server-rs/crates/api-server/src/runtime_profile.rs`、`server-rs/crates/api-server/src/wechat/pay.rs` +- 微信支付 / 虚拟支付协议适配:`server-rs/crates/platform-wechat/src/pay.rs` +- 微信订阅消息协议适配:`server-rs/crates/platform-wechat/src/subscribe_message.rs` - WebView 回流确认:`GET /api/profile/recharge/orders/{orderId}/wechat/events`、`POST /api/profile/recharge/orders/{orderId}/wechat/confirm` - 微信登录态保存:`server-rs/crates/platform-auth/src/lib.rs`、`server-rs/crates/module-auth/src/lib.rs` @@ -33,6 +35,9 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey> WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选> WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token> WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true +WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID=m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE=formal WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 ``` @@ -69,4 +74,5 @@ npm run check:encoding - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 +- 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 37c52002..31ea9b3d 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -1,22 +1,38 @@ # 平台入口与玩法链路 -更新时间:`2026-06-06` +更新时间:`2026-06-04` ## 平台创作入口 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 -当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 +当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。 创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。 -创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 +创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 + +生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick;视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。 + +拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。 + +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state,不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。 + +拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、form-only 空草稿判定、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 + +拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 + +后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + +平台入口个人钱包本地 delta 由 `platformProfileWalletDeltaModel.ts` 判定:余额归一、本地扣点 / 返还后的 dashboard 乐观更新,以及服务端 dashboard 刷新后的 delta 对账不得散落在平台壳层;壳层只负责 API、React ref 和 state 写入。 + +RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单、表头和入口卡泥点消耗数量由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,入口卡泥点消耗按 `unifiedCreationSpec.mudPointCost` 由前端格式化为 `X泥点数`,读取和保存时不再用入口名称或前端固定文案自动覆盖;需要改表头或入口卡消耗数量时应在后台契约结构卡片点击修改,并通过弹窗表单编辑 `title` 或 `mudPointCost` 字段,不再要求直接编辑 JSON。`workspaceStage`、`generationStage` 和 `resultStage` 属于内部阶段标识,后台弹窗不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的前端固定阶段映射自动带出。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底;H5 根节点在 `data-mobile-keyboard-open=true` 时必须把 `html` / `body` / `#root` 背景切到当前平台浅色底,但不得再用 `.platform-viewport-shell` 全局 `transform` 二次上推页面;小程序 `web-view` 页面原生宿主也必须使用浅色背景,不能沿用全局黑色 page 背景。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 -创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 +创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;校验成本必须读取同一份 `creationTypes[].unifiedCreationSpec.mudPointCost`,不能回到前端常量。余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。 @@ -61,14 +77,15 @@ 9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。 +12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys;平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。 发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。 -移动端底部导航的创作按钮在登录前后必须保持同一个图片化创作图标,不因登录态切换成加号。 +平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。 -发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;主题设置、账号与安全只放在通用设置弹窗下一级,不在外层单独占行;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度,外层卡片不展示“去完成”等行动按钮。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 +个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链。 -平台应用隐藏浏览器根节点 `html` / `body` / `#root` 和平台页面级滚动容器的最外层滚动条可见轨道;弹窗、列表、运行态侧栏等内部滚动容器继续使用原有滚动条样式或显式 `.scrollbar-hide` 控制。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 @@ -110,8 +127,9 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 图像输入复用 `CreativeImageInputPanel`。 - 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。 +- 历史图片选择弹窗只展示缩略图与生成时间,不展示从对象路径或文件名解析出的图片名称;选中历史图后内部兜底文案统一使用“历史素材”。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。文字直创的 `compile_puzzle_draft` 同步回包只表示已编译首关草稿并启动后台首图 / UI 资产生成;只要回包 session 仍缺正式 `draft.coverImageSrc`、首关 `coverImageSrc` 和候选图,前端必须继续保持生成中,不弹完成通知、不把草稿卡标记为 ready,也不得自动进入结果页或试玩。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进。`88/94/96` 只负责切换当前步骤,不作为总进度地板;总进度按已完成步骤权重加当前步骤内假进度推导,非完成态最多停在 `98%`。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。生成失败后,同一浏览器会话内的失败 notice 必须覆盖后端可能仍短暂返回的 `generationStatus=generating` 摘要,作品架保留对应草稿卡但不再显示“生成中”,点击后回到失败 / 重试状态。 - 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次图片生成调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用后端 session `updatedAt` 或作品摘要 `updatedAt` 作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。生成完成后若自动进入草稿试玩,进入 `/runtime/puzzle` 前必须先把 `/creation/puzzle/result` 和当前 `sessionId/profileId/workId` 写成浏览器历史前一站;运行态返回按钮和系统返回都应回到结果页,不得退回生成进度页或暴露重新生成入口。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 - 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。 @@ -124,18 +142,16 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 -- 推荐页本身不是登录门禁入口,平台首页默认落点也是推荐页;未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 +- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。 - 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 -- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜和下一关按钮。 -- 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。 -- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。 +- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 +- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 -- 推荐页作品点赞必须按前端全局公开作品 `sourceType` 联合类型明确分流;暂未接入点赞后端的玩法直接报“该作品类型暂不支持点赞”,不能显示开放兜底文案,也不能落入 RPG / custom-world 默认点赞路径。特别是 `WF-*` 敲木鱼作品不得调用 `/api/runtime/custom-world-gallery/.../like`。前端全局创作类型 / 公开作品类型定义以 `packages/shared/src/contracts/playTypes.ts` 为准,新增玩法必须先补类型再补推荐页、详情页、分类页和公开互动分支。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 ## 跳一跳 @@ -157,11 +173,9 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改; 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile; 5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; -6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮; +6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽; 7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 -生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks`、`paper-toy` 等工程值暴露给创作者。 - 运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口,HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌,以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 @@ -172,11 +186,11 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 -跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已完成但未发布草稿点击后必须通过私有创作接口 `GET /api/creation/jump-hop/works/{profile_id}` 读取完整详情并进入创作结果页;已发布作品点击后才通过公开运行态接口 `GET /api/runtime/jump-hop/works/{profile_id}` 读取完整详情再进入公开详情或运行态,该公开接口保持 published-only 校验。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 +跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,且必须持续观察后续新增图片、内联 `background-image` 和换签中的资源标记,不能只在首次挂载时扫描主图或封面;`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的同 run 相似作品推进不视为推荐作品切换,不能重新显示启动封面;如果需要跨公开作品进入下一关,则必须走推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品,`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。 ## 敲木鱼 @@ -253,7 +267,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 当前素材生成流水线: -1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。 +1. 点击生成前弹出泥点确认,草稿初始生成成本来自后台入口契约 `creationTypes[].unifiedCreationSpec.mudPointCost`;抓大鹅完整草稿生成按该值一次性预扣,汪汪声浪初始三张图按该值分摊到三次素材请求,结果页单图重新生成仍按单图资产操作计费。 2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。 3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 @@ -339,8 +353,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 -- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。 -- 试玩与正式 runtime:草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run;正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run,并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。 +- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。草稿三图完整性、`pending_assets` / `partial_failed` / `ready` 生成状态归一和作品摘要合并规则统一由 `barkBattleWorkCache.ts` 承接,平台壳只执行读取、刷新与 React state 副作用。 +- 试玩与正式 runtime:草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run;正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run,并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。发布快照拼装、发布回包缺图时沿用草稿图,以及草稿 / 已发布作品进入前端 runtime 前的 `BarkBattlePublishedConfig` 映射也统一由 `barkBattleWorkCache.ts` 提供,缺失 `publishedAt` 时仍按 `updatedAt` 兜底。 支持的创作者可替换内容: diff --git a/miniprogram/app.json b/miniprogram/app.json index 028137f2..dc8c7a3d 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,5 +1,9 @@ { - "pages": ["pages/web-view/index", "pages/wechat-pay/index"], + "pages": [ + "pages/web-view/index", + "pages/wechat-pay/index", + "pages/subscribe-message/index" + ], "window": { "navigationBarTitleText": "陶泥儿", "navigationBarBackgroundColor": "#0b0f14", diff --git a/miniprogram/config.js b/miniprogram/config.js index c521817f..d884a4d0 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -15,6 +15,10 @@ const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; // 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。 const MINI_PROGRAM_ENV = 'release'; +// 中文注释:AI 创作生成结果订阅消息模板,需与微信公众平台后台的模板 ID 保持一致。 +const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID = + 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 const WEB_VIEW_SOURCE_QUERY = { clientType: 'mini_program', @@ -25,6 +29,7 @@ module.exports = { API_BASE_URL, DEV_API_BASE_URL, DEV_WEB_VIEW_ENTRY_URL, + GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, diff --git a/miniprogram/pages/subscribe-message/index.js b/miniprogram/pages/subscribe-message/index.js new file mode 100644 index 00000000..52ce7ea2 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.js @@ -0,0 +1,10 @@ +/* global Page */ + +const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config'); +const { createSubscribeMessagePage } = require('./index.shared'); + +Page( + createSubscribeMessagePage(null, { + templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID, + }), +); diff --git a/miniprogram/pages/subscribe-message/index.json b/miniprogram/pages/subscribe-message/index.json new file mode 100644 index 00000000..46298a20 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "生成通知" +} diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/pages/subscribe-message/index.shared.js new file mode 100644 index 00000000..04107f64 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.shared.js @@ -0,0 +1,128 @@ +/* global wx */ + +const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result'; + +function appendSubscribeResult(url, result) { + const hashIndex = String(url || '').indexOf('#'); + const baseUrl = + hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || ''); + const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : ''; + const nextHash = rawHash + .split('&') + .filter((part) => part && !part.startsWith('wx_subscribe_result=')) + .concat(`wx_subscribe_result=${encodeURIComponent(result)}`) + .join('&'); + return `${baseUrl}#${nextHash}`; +} + +function buildSubscribeResultValue(requestId, status, reason) { + const segments = [requestId, status]; + if (reason) { + segments.push(encodeURIComponent(reason)); + } + return segments.join(':'); +} + +function notifyPreviousWebView(requestId, status, reason) { + const result = buildSubscribeResultValue(requestId, status, reason); + wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result); +} + +function resolveSubscribeStatus(result, templateId) { + return result && result[templateId] === 'accept' + ? 'success' + : 'skip'; +} + +function createSubscribeMessagePage(pageContext, options = {}) { + const templateId = String(options.templateId || '').trim(); + const notifyPageResult = (methodThis, status, reason) => { + const page = pageContext ?? methodThis; + const requestId = page.requestId || ''; + if (!requestId || page.hasNotifiedSubscribeResult) { + return; + } + page.hasNotifiedSubscribeResult = true; + notifyPreviousWebView(requestId, status, reason); + }; + + return { + data: { + title: '接收生成结果通知', + errorMessage: '', + requesting: false, + }, + + onLoad(query) { + const page = pageContext ?? this; + page.requestId = String(query.requestId || ''); + page.hasNotifiedSubscribeResult = false; + }, + + notifyResult(status, reason) { + notifyPageResult(this, status, reason); + }, + + requestSubscribe() { + const page = pageContext ?? this; + const requestId = page.requestId || ''; + if (!requestId) { + page.setData({ + errorMessage: '缺少订阅请求参数。', + }); + return; + } + if (!templateId) { + notifyPageResult(this, 'skip', 'missing_template_id'); + wx.navigateBack(); + return; + } + if (typeof wx.requestSubscribeMessage !== 'function') { + notifyPageResult(this, 'skip', 'unsupported'); + wx.navigateBack(); + return; + } + + page.setData({ + requesting: true, + errorMessage: '', + }); + wx.requestSubscribeMessage({ + tmplIds: [templateId], + success(result) { + notifyPageResult( + page, + resolveSubscribeStatus(result, templateId), + '', + ); + wx.navigateBack(); + }, + fail(error) { + notifyPageResult( + page, + 'skip', + error && error.errMsg ? error.errMsg : 'failed', + ); + wx.navigateBack(); + }, + }); + }, + + handleSkip() { + notifyPageResult(this, 'skip', 'user_skip'); + wx.navigateBack(); + }, + + onUnload() { + notifyPageResult(this, 'skip', 'page_unload'); + }, + }; +} + +module.exports = { + SUBSCRIBE_RESULT_STORAGE_KEY, + appendSubscribeResult, + buildSubscribeResultValue, + createSubscribeMessagePage, + resolveSubscribeStatus, +}; diff --git a/miniprogram/pages/subscribe-message/index.test.js b/miniprogram/pages/subscribe-message/index.test.js new file mode 100644 index 00000000..0922f933 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.test.js @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import subscribeMessageBridge from './index.shared.js'; + +const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + +const { + SUBSCRIBE_RESULT_STORAGE_KEY, + appendSubscribeResult, + buildSubscribeResultValue, + createSubscribeMessagePage, +} = subscribeMessageBridge; + +describe('subscribe-message mini program bridge', () => { + beforeEach(() => { + globalThis.wx = { + requestSubscribeMessage: vi.fn(), + setStorageSync: vi.fn(), + navigateBack: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => []); + }); + + test('requests subscribe message and stores result before returning', () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/#tab=create' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { + options.success?.({ + m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept', + }); + }); + const page = createSubscribeMessagePage( + { + setData: vi.fn(), + }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-1' }); + + page.requestSubscribe(); + + expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({ + tmplIds: [TEST_TEMPLATE_ID], + success: expect.any(Function), + fail: expect.any(Function), + }); + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + SUBSCRIBE_RESULT_STORAGE_KEY, + 'request-1:success', + ); + expect(previousPage.setData).not.toHaveBeenCalled(); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); + + test('skip action notifies previous web-view', () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + const page = createSubscribeMessagePage( + { + setData: vi.fn(), + }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-skip' }); + + page.handleSkip(); + + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + SUBSCRIBE_RESULT_STORAGE_KEY, + 'request-skip:skip:user_skip', + ); + expect(previousPage.setData).not.toHaveBeenCalled(); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); + + test('appendSubscribeResult replaces stale subscribe hash', () => { + expect( + appendSubscribeResult( + 'https://web.test/#old=1&wx_subscribe_result=old', + 'req:skip', + ), + ).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip'); + expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe( + 'req-1:skip:user_cancel', + ); + }); +}); diff --git a/miniprogram/pages/subscribe-message/index.wxml b/miniprogram/pages/subscribe-message/index.wxml new file mode 100644 index 00000000..116d68c4 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.wxml @@ -0,0 +1,19 @@ + diff --git a/miniprogram/pages/subscribe-message/index.wxss b/miniprogram/pages/subscribe-message/index.wxss new file mode 100644 index 00000000..03d571bf --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.wxss @@ -0,0 +1,58 @@ +.subscribe-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #0b0f14; + box-sizing: border-box; +} + +.subscribe-card { + width: 100%; + max-width: 560rpx; + padding: 36rpx; + border: 1rpx solid rgba(255, 255, 255, 0.14); + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.06); + box-sizing: border-box; +} + +.subscribe-title { + font-size: 34rpx; + font-weight: 600; + line-height: 1.35; + color: #f5f7fb; +} + +.subscribe-text { + margin-top: 16rpx; + font-size: 26rpx; + line-height: 1.55; + color: rgba(245, 247, 251, 0.72); +} + +.subscribe-text--danger { + color: #ffb4a9; +} + +.primary-button, +.ghost-button { + margin-top: 28rpx; + width: 100%; + border-radius: 8rpx; + font-size: 26rpx; + line-height: 2.6; +} + +.primary-button { + background: #f5f7fb; + color: #0b0f14; +} + +.ghost-button { + margin-top: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.24); + background: transparent; + color: rgba(245, 247, 251, 0.86); +} diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index db8f0233..c7d221dc 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -712,7 +712,7 @@ Page({ }, handleWebViewMessage(event) { - // 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。 + // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, diff --git a/packages/shared/src/contracts/barkBattle.ts b/packages/shared/src/contracts/barkBattle.ts index 18b23ef2..cd1f7aea 100644 --- a/packages/shared/src/contracts/barkBattle.ts +++ b/packages/shared/src/contracts/barkBattle.ts @@ -61,6 +61,7 @@ export interface BarkBattleWorkPublishRequest { export interface BarkBattleImageAssetGenerateRequest { slot: BarkBattleAssetSlot; draftId?: string | null; + billingPurpose?: 'initial_draft_generation' | null; config: BarkBattleConfigEditorPayload; } diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d54f8001..f285faaf 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -129,6 +129,7 @@ dependencies = [ "platform-llm", "platform-oss", "platform-speech", + "platform-wechat", "reqwest 0.12.28", "ring", "serde", @@ -2508,6 +2509,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "platform-wechat" +version = "0.1.0" +dependencies = [ + "aes", + "base64 0.22.1", + "cbc", + "hex", + "reqwest 0.12.28", + "ring", + "serde", + "serde_json", + "sha1", + "sha2", + "shared-contracts", + "time", + "tracing", + "url", + "urlencoding", +] + [[package]] name = "png" version = "0.18.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 8cbd5eea..cdc461bd 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", + "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", "crates/shared-contracts", @@ -85,6 +86,7 @@ platform-image = { path = "crates/platform-image", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } +platform-wechat = { path = "crates/platform-wechat", default-features = false } shared-contracts = { path = "crates/shared-contracts", default-features = false } shared-kernel = { path = "crates/shared-kernel", default-features = false } shared-logging = { path = "crates/shared-logging", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 0374defc..dc38ad00 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -44,6 +44,7 @@ platform-image = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +platform-wechat = { workspace = true } hmac = { workspace = true } ring = { workspace = true } serde = { workspace = true } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 05f4f6d1..759fa842 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -41,7 +41,7 @@ use crate::{ start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message, submit_visual_novel_message, update_visual_novel_work, }, - wechat_pay::{ + wechat::pay::{ handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, }, @@ -1507,8 +1507,7 @@ mod tests { #[tokio::test] async fn wooden_fish_session_creation_accepts_legacy_audio_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = - seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; + let seed_user = seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_audio_body"); let app = build_router(state); let request_body = format!( @@ -1548,8 +1547,7 @@ mod tests { #[tokio::test] async fn wooden_fish_actions_accept_legacy_audio_body_above_default_limit() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let seed_user = - seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; + let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_wooden_fish_action_body"); let app = build_router(state); let request_body = format!( diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index b8316e1a..613ce234 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -71,6 +71,10 @@ async fn consume_asset_operation_points( asset_id: &str, points_cost: u64, ) -> Result { + if points_cost == 0 { + return Ok(false); + } + let ledger_id = format!( "asset_operation_consume:{}:{}:{}", owner_user_id, asset_kind, asset_id diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index bab8c434..32cdc1a0 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, http::StatusCode, diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 392e894d..8e54b0a6 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -36,7 +36,7 @@ use time::{Duration as TimeDuration, OffsetDateTime}; use crate::{ api_response::json_success_body, - asset_billing::execute_billable_asset_operation, + asset_billing::execute_billable_asset_operation_with_cost, auth::AuthenticatedAccessToken, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -62,6 +62,8 @@ const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-"; const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-"; const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-"; const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle"; +const BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE: &str = "initial_draft_generation"; +const BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT: u64 = 3; const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60; const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024"; const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792"; @@ -303,11 +305,13 @@ pub async fn generate_bark_battle_image_asset( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string); - let result = execute_billable_asset_operation( + let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await; + let result = execute_billable_asset_operation_with_cost( &state, &owner_user_id, bark_battle_slot_asset_kind(&slot), asset_id.as_str(), + points_cost, async { generate_and_persist_bark_battle_image_asset( &state, @@ -328,6 +332,40 @@ pub async fn generate_bark_battle_image_asset( Ok(json_success_body(Some(&request_context), result)) } +async fn resolve_bark_battle_image_asset_points_cost( + state: &AppState, + payload: &BarkBattleImageAssetGenerateRequest, +) -> u64 { + if payload.billing_purpose.as_deref() + != Some(BARK_BATTLE_INITIAL_DRAFT_GENERATION_BILLING_PURPOSE) + { + return crate::asset_billing::ASSET_OPERATION_POINTS_COST; + } + + let total_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + BARK_BATTLE_PLAY_TYPE_ID, + BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT + * crate::asset_billing::ASSET_OPERATION_POINTS_COST, + ) + .await; + resolve_bark_battle_initial_generation_slot_points_cost(&payload.slot, total_cost) +} + +fn resolve_bark_battle_initial_generation_slot_points_cost( + slot: &BarkBattleAssetSlot, + total_cost: u64, +) -> u64 { + let base_cost = total_cost / BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT; + let remainder = total_cost % BARK_BATTLE_INITIAL_DRAFT_GENERATION_SLOT_COUNT; + let slot_index = match slot { + BarkBattleAssetSlot::PlayerCharacter => 0, + BarkBattleAssetSlot::OpponentCharacter => 1, + BarkBattleAssetSlot::UiBackground => 2, + }; + base_cost + u64::from(slot_index < remainder) +} + pub async fn publish_bark_battle_work( State(state): State, Extension(request_context): Extension, @@ -1661,6 +1699,94 @@ mod tests { ); } + #[test] + fn initial_generation_slot_cost_splits_creation_entry_total_cost() { + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::PlayerCharacter, + 1, + ), + 1, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::OpponentCharacter, + 1, + ), + 0, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::UiBackground, + 1, + ), + 0, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::PlayerCharacter, + 2, + ), + 1, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::OpponentCharacter, + 2, + ), + 1, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::UiBackground, + 2, + ), + 0, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::PlayerCharacter, + 6, + ), + 2, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::OpponentCharacter, + 6, + ), + 2, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::UiBackground, + 6, + ), + 2, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::PlayerCharacter, + 8, + ), + 3, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::OpponentCharacter, + 8, + ), + 3, + ); + assert_eq!( + resolve_bark_battle_initial_generation_slot_points_cost( + &BarkBattleAssetSlot::UiBackground, + 8, + ), + 2, + ); + } + #[test] fn draft_config_mapping_includes_stable_work_identity() { let request_context = RequestContext::new( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 3fe02061..e9f6ec68 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; +use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -100,6 +100,10 @@ pub struct AppConfig { pub wechat_mini_program_virtual_payment_sandbox_app_key: Option, pub wechat_mini_program_message_token: Option, pub wechat_mini_program_message_encoding_aes_key: Option, + pub wechat_mini_program_subscribe_message_enabled: bool, + pub wechat_mini_program_generation_result_template_id: Option, + pub wechat_mini_program_subscribe_message_endpoint: String, + pub wechat_mini_program_subscribe_message_state: String, pub wechat_mini_program_virtual_payment_env: u8, pub oss_bucket: Option, pub oss_endpoint: Option, @@ -250,6 +254,13 @@ impl Default for AppConfig { wechat_mini_program_virtual_payment_sandbox_app_key: None, wechat_mini_program_message_token: None, wechat_mini_program_message_encoding_aes_key: None, + wechat_mini_program_subscribe_message_enabled: true, + wechat_mini_program_generation_result_template_id: Some( + "m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(), + ), + wechat_mini_program_subscribe_message_endpoint: + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(), + wechat_mini_program_subscribe_message_state: "formal".to_string(), wechat_mini_program_virtual_payment_env: 0, oss_bucket: None, oss_endpoint: None, @@ -613,6 +624,26 @@ impl AppConfig { read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); config.wechat_mini_program_message_encoding_aes_key = read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]); + if let Some(enabled) = + read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"]) + { + config.wechat_mini_program_subscribe_message_enabled = enabled; + } + if let Some(template_id) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"]) + { + config.wechat_mini_program_generation_result_template_id = Some(template_id); + } + if let Some(endpoint) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"]) + { + config.wechat_mini_program_subscribe_message_endpoint = endpoint; + } + if let Some(state) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"]) + { + config.wechat_mini_program_subscribe_message_state = state; + } if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) && env <= 1 { @@ -1419,6 +1450,9 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); + std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); std::env::set_var("WECHAT_PAY_ENABLED", "true"); std::env::set_var("WECHAT_PAY_PROVIDER", "real"); @@ -1446,6 +1480,12 @@ mod tests { "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", ); + std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true"); + std::env::set_var( + "WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID", + "tmpl-generation-result", + ); + std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1"); } @@ -1497,6 +1537,14 @@ mod tests { .as_deref(), Some("sandbox-app-key-001") ); + assert!(config.wechat_mini_program_subscribe_message_enabled); + assert_eq!( + config + .wechat_mini_program_generation_result_template_id + .as_deref(), + Some("tmpl-generation-result") + ); + assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial"); assert_eq!(config.wechat_mini_program_virtual_payment_env, 1); unsafe { @@ -1514,6 +1562,9 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); + std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); } } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 81f95e93..9804bf83 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -126,6 +126,44 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { None } +pub(crate) fn resolve_creation_entry_mud_point_cost_from_config( + config: &CreationEntryConfigResponse, + creation_type_id: &str, + fallback_cost: u64, +) -> u64 { + config + .creation_types + .iter() + .find(|item| item.id == creation_type_id) + .and_then(|item| item.unified_creation_spec.as_ref()) + .map(|spec| u64::from(spec.mud_point_cost)) + .filter(|cost| *cost > 0) + .unwrap_or(fallback_cost) +} + +pub(crate) async fn resolve_creation_entry_mud_point_cost( + state: &AppState, + creation_type_id: &str, + fallback_cost: u64, +) -> u64 { + match state.get_creation_entry_config().await { + Ok(config) => resolve_creation_entry_mud_point_cost_from_config( + &config, + creation_type_id, + fallback_cost, + ), + Err(error) => { + tracing::warn!( + creation_type_id, + fallback_cost, + error = %error, + "读取创作入口泥点成本失败,回退到代码默认值" + ); + fallback_cost + } + } +} + fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -170,6 +208,7 @@ pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigRespon #[cfg(test)] mod tests { use super::*; + use shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST; #[test] fn resolves_new_creation_paths_to_creation_type_ids() { @@ -258,6 +297,50 @@ mod tests { assert_eq!(resolve_creation_entry_route_id("/healthz"), None); } + #[test] + fn resolves_mud_point_cost_from_unified_creation_spec() { + let mut config = test_creation_entry_config_response(); + let puzzle = config + .creation_types + .iter_mut() + .find(|item| item.id == "puzzle") + .expect("puzzle config should exist"); + let spec = puzzle + .unified_creation_spec + .as_mut() + .expect("puzzle unified spec should exist"); + spec.mud_point_cost = 8; + + assert_eq!( + resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2), + 8, + ); + } + + #[test] + fn resolves_mud_point_cost_with_fallback_for_legacy_config() { + let mut config = test_creation_entry_config_response(); + let puzzle = config + .creation_types + .iter_mut() + .find(|item| item.id == "puzzle") + .expect("puzzle config should exist"); + puzzle.unified_creation_spec = None; + + assert_eq!( + resolve_creation_entry_mud_point_cost_from_config(&config, "puzzle", 2), + 2, + ); + assert_eq!( + resolve_creation_entry_mud_point_cost_from_config( + &config, + "missing-play", + u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ), + u64::from(DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ); + } + #[test] fn test_creation_entry_config_response_opens_bark_battle() { let config = test_creation_entry_config_response(); diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index cd6d3240..634ece0e 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -37,11 +37,11 @@ use spacetime_client::{ CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, - CustomWorldLibraryEntryRecord, - CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, - CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, - CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, - CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, + CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput, + CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, + CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, + CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 74b93c70..d235f028 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,9 +10,9 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType}; #[cfg(test)] use image::ImageFormat; +use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 5c800414..953de5a5 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -9,8 +9,8 @@ use crate::{ #[allow(unused_imports)] pub(crate) use generated_asset_sheets_impl::{ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor, - GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage, - GeneratedAssetSheetUpload, + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 55914d7e..f76aa730 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -14,10 +14,9 @@ use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, - JumpHopRunResponse, - JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, - JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, - JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, + JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, + JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, + JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; @@ -45,6 +44,10 @@ use crate::{ }, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -150,27 +153,86 @@ pub async fn execute_jump_hop_action( let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; - maybe_generate_jump_hop_assets( - &state, - &request_context, - session_id.as_str(), - owner_user_id.as_str(), - &mut payload, - ) - .await?; - let response = state - .spacetime_client() - .execute_jump_hop_action(session_id, owner_user_id, payload) - .await - .map_err(|error| { - jump_hop_error_response( - &request_context, - JUMP_HOP_CREATION_PROVIDER, - map_jump_hop_client_error(error), - ) - })?; + let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); + let generation_points_cost = if is_compile_draft { + resolve_jump_hop_generation_points_cost(&state).await + } else { + 0 + }; + let result = async { + maybe_generate_jump_hop_assets( + &state, + &request_context, + session_id.as_str(), + owner_user_id.as_str(), + &mut payload, + ) + .await?; + state + .spacetime_client() + .execute_jump_hop_action(session_id, owner_user_id.clone(), payload) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + }) + } + .await; - Ok(json_success_body(Some(&request_context), response)) + match result { + Ok(response) => { + if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Ok(json_success_body(Some(&request_context), response)) + } + Err(response) => { + if is_compile_draft && response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } +} + +async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + JUMP_HOP_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await } pub async fn publish_jump_hop_work( @@ -232,10 +294,7 @@ pub async fn get_jump_hop_work_detail( ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() - .get_jump_hop_work_profile( - profile_id, - authenticated.claims().user_id().to_string(), - ) + .get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( @@ -260,10 +319,7 @@ pub async fn delete_jump_hop_work( ensure_non_empty(&request_context, &profile_id, "profileId")?; let works = state .spacetime_client() - .delete_jump_hop_work( - profile_id, - authenticated.claims().user_id().to_string(), - ) + .delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { jump_hop_error_response( diff --git a/server-rs/crates/api-server/src/login_options.rs b/server-rs/crates/api-server/src/login_options.rs index f71f4d51..19fe4bde 100644 --- a/server-rs/crates/api-server/src/login_options.rs +++ b/server-rs/crates/api-server/src/login_options.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, State}, }; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index bb1098de..480d88db 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -89,9 +89,7 @@ mod tracking_outbox; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; -mod wechat_auth; -mod wechat_pay; -mod wechat_provider; +mod wechat; mod wooden_fish; mod work_author; mod work_play_tracking; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 5f4f2e22..5517c6bd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, convert::Infallible, future::Future, @@ -84,6 +84,10 @@ use crate::{ vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 98e8a8b2..eb99ec38 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -163,6 +163,12 @@ pub(super) async fn compile_match3d_draft_for_session( .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); + let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + "match3d", + MATCH3D_DRAFT_GENERATION_POINTS_COST, + ) + .await; let compile_session_id = session_id.clone(); let compile_owner_user_id = owner_user_id.clone(); let compile_profile_id = profile_id.clone(); @@ -175,6 +181,7 @@ pub(super) async fn compile_match3d_draft_for_session( request_context, owner_user_id.as_str(), billing_asset_id.as_str(), + points_cost, async { let mut session = upsert_match3d_draft_snapshot( state, @@ -316,27 +323,56 @@ pub(super) async fn compile_match3d_draft_for_session( ) .await; - if let Err(response) = result.as_ref() - && response.status().is_server_error() - { - let failure_message = match3d_response_failure_message(response); - persist_failed_match3d_draft_generation( - state, - request_context, - authenticated, - compile_session_id, - compile_owner_user_id, - compile_profile_id, - compile_initial_game_name, - compile_requested_summary, - compile_initial_tags, - compile_requested_cover_image_src, - failure_message, - ) - .await; + match result { + Ok((session, generated_item_assets)) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id.clone(), + task_name: Some("抓大鹅".to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok((session, generated_item_assets)) + } + Err(response) if response.status().is_server_error() => { + let failure_message = match3d_response_failure_message(&response); + persist_failed_match3d_draft_generation( + state, + request_context, + authenticated, + compile_session_id, + compile_owner_user_id.clone(), + compile_profile_id, + compile_initial_game_name.clone(), + compile_requested_summary, + compile_initial_tags, + compile_requested_cover_image_src, + failure_message, + ) + .await; + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id, + task_name: Some("抓大鹅".to_string()), + work_name: Some(compile_initial_game_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Err(response) + } + Err(response) => Err(response), } - - result } #[allow(clippy::too_many_arguments)] @@ -418,12 +454,13 @@ fn match3d_response_failure_message(response: &Response) -> String { .unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status())) } -/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。 +/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按后台入口配置的泥点成本幂等预扣。 async fn execute_billable_match3d_draft_generation( state: &AppState, request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, + points_cost: u64, operation: Fut, ) -> Result where @@ -434,6 +471,7 @@ where request_context, owner_user_id, billing_asset_id, + points_cost, ) .await?; @@ -441,8 +479,13 @@ where Ok(value) => Ok(value), Err(response) => { if points_consumed { - refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id) - .await; + refund_match3d_draft_generation_points( + state, + owner_user_id, + billing_asset_id, + points_cost, + ) + .await; } Err(response) } @@ -454,6 +497,7 @@ async fn consume_match3d_draft_generation_points( request_context: &RequestContext, owner_user_id: &str, billing_asset_id: &str, + points_cost: u64, ) -> Result { let ledger_id = format!( "asset_operation_consume:{}:match3d_draft_generation:{}", @@ -463,7 +507,7 @@ async fn consume_match3d_draft_generation_points( .spacetime_client() .consume_profile_wallet_points( owner_user_id.to_string(), - MATCH3D_DRAFT_GENERATION_POINTS_COST, + points_cost, ledger_id, current_utc_micros(), ) @@ -491,6 +535,7 @@ async fn refund_match3d_draft_generation_points( state: &AppState, owner_user_id: &str, billing_asset_id: &str, + points_cost: u64, ) { let ledger_id = format!( "asset_operation_refund:{}:match3d_draft_generation:{}", @@ -500,7 +545,7 @@ async fn refund_match3d_draft_generation_points( .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), - MATCH3D_DRAFT_GENERATION_POINTS_COST, + points_cost, ledger_id, current_utc_micros(), ) diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index 784cdad2..5cb67df2 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Router, middleware, routing::{get, post}, }; @@ -16,7 +16,7 @@ use crate::{ phone_auth::{phone_login, send_phone_code}, refresh_session::refresh_session, state::AppState, - wechat_auth::{ + wechat::auth::{ bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login, }, }; diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 2ed65a3b..26cd3ab8 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -1,7 +1,6 @@ use axum::{ - middleware, + Router, middleware, routing::{get, post}, - Router, }; use crate::{ @@ -9,9 +8,8 @@ use crate::{ jump_hop::{ create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action, get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work, - get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, - list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, - start_jump_hop_run, + get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump, list_jump_hop_gallery, + list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, }, state::AppState, }; diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index e377e235..de9d5df7 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -24,9 +24,7 @@ pub fn router(state: AppState) -> Router { "/api/creation/wooden-fish/sessions", post(create_wooden_fish_session) // 中文注释:兼容旧小程序把参考图或录音 Data URL 放进创作 JSON 的请求;新前端音频会先直传 OSS。 - .layer(DefaultBodyLimit::max( - WOODEN_FISH_CREATION_BODY_LIMIT_BYTES, - )) + .layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -43,9 +41,7 @@ pub fn router(state: AppState) -> Router { "/api/creation/wooden-fish/sessions/{session_id}/actions", post(execute_wooden_fish_action) // 中文注释:compile/regenerate 会携带参考图旧兼容输入,避免 Axum 默认 2MB 先于 handler 拦截。 - .layer(DefaultBodyLimit::max( - WOODEN_FISH_CREATION_BODY_LIMIT_BYTES, - )) + .layer(DefaultBodyLimit::max(WOODEN_FISH_CREATION_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, @@ -98,4 +94,4 @@ pub fn router(state: AppState) -> Router { "/api/runtime/wooden-fish/gallery/{public_work_code}", get(get_wooden_fish_gallery_detail), ) -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 2acdd925..792fb881 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -1,7 +1,8 @@ -use axum::http::{HeaderValue, StatusCode}; +use axum::http::{HeaderValue, StatusCode}; use platform_auth::{AuthPlatformErrorKind, WechatProviderError}; use platform_llm::{LlmError, LlmErrorKind}; use platform_oss::{OssError, OssErrorKind}; +use platform_wechat::{WechatError, WechatErrorKind}; use serde_json::json; use crate::http_error::AppError; @@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError { AppError::from_status(status).with_message(error.to_string()) } +pub fn map_wechat_error(error: WechatError) -> AppError { + let status = match error.kind() { + WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE, + WechatErrorKind::RequestFailed + | WechatErrorKind::DeserializeFailed + | WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_message(error.to_string()) +} + pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError { match HeaderValue::from_str(&retry_after_seconds.to_string()) { Ok(value) => error.with_header("retry-after", value), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index dc1be22a..b428f2f2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::{BTreeMap, HashSet}, sync::{Mutex, OnceLock}, time::{Instant, SystemTime, UNIX_EPOCH}, @@ -58,16 +58,15 @@ use spacetime_client::{ PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, - PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, - PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, + PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, + PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, + PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; @@ -106,6 +105,10 @@ use crate::{ puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, state::{AppState, PuzzleApiState}, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_author::resolve_puzzle_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, }; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index b7a66dae..ddb1d6ea 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload( levels, form_draft: None, }; + let stage = if is_puzzle_session_snapshot_publish_ready(&draft) { + "ready_to_publish" + } else { + "image_refining" + }; Ok(PuzzleAgentSessionRecord { session_id: session_id.to_string(), seed_text: String::new(), current_turn: 0, progress_percent: 94, - stage: "ready_to_publish".to_string(), + stage: stage.to_string(), anchor_pack, draft: Some(draft), messages: Vec::new(), @@ -1764,7 +1769,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot( sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(94); - session.stage = "ready_to_publish".to_string(); + session.stage = if is_puzzle_session_snapshot_publish_ready(draft) { + "ready_to_publish".to_string() + } else { + "image_refining".to_string() + }; session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 873495f7..2fa1a265 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -589,6 +589,7 @@ pub async fn execute_puzzle_agent_action( let now = current_utc_micros(); let action = payload.action.trim().to_string(); let billing_asset_id = format!("{session_id}:{now}"); + let mut operation_consumed_points = 0; tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, @@ -617,13 +618,14 @@ pub async fn execute_puzzle_agent_action( let log_session_id = session_id.clone(); let log_owner_user_id = owner_user_id.clone(); async move { + let failed_at_micros = current_utc_micros(); let result = state .spacetime_client() .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), error_message, - failed_at_micros: current_utc_micros(), + failed_at_micros, }) .await; if let Err(error) = result { @@ -634,6 +636,20 @@ pub async fn execute_puzzle_agent_action( message = %error, "拼图草稿失败态回写失败,继续返回原始错误" ); + } else { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some("拼图".to_string()), + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; } } }; @@ -641,6 +657,17 @@ pub async fn execute_puzzle_agent_action( let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); + let puzzle_draft_generation_points_cost = if ai_redraw { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state.root_state(), + "puzzle", + PUZZLE_IMAGE_GENERATION_POINTS_COST, + ) + .await + } else { + 0 + }; + operation_consumed_points = puzzle_draft_generation_points_cost; let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), @@ -677,10 +704,7 @@ pub async fn execute_puzzle_agent_action( ); state .spacetime_client() - .get_puzzle_agent_session( - compile_session_id.clone(), - owner_user_id.clone(), - ) + .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) .await .map(mark_puzzle_initial_generation_started_snapshot) .map_err(map_puzzle_client_error) @@ -696,10 +720,9 @@ pub async fn execute_puzzle_agent_action( .map_err(map_puzzle_compile_error); match compiled_session { Ok(compiled_session) => { - let response_session = - mark_puzzle_initial_generation_started_snapshot( - compiled_session.clone(), - ); + let response_session = mark_puzzle_initial_generation_started_snapshot( + compiled_session.clone(), + ); let background_state = state.clone(); let background_request_context = request_context.clone(); let background_session_id = compile_session_id.clone(); @@ -708,20 +731,23 @@ pub async fn execute_puzzle_agent_action( let background_reference_image_src = primary_reference_image_src.map(str::to_string); let background_image_model = payload.image_model.clone(); + let background_points_cost = puzzle_draft_generation_points_cost; + let background_work_name = compiled_session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()); let background_billing_asset_id = format!("{background_session_id}:compile_puzzle_draft"); tokio::spawn(async move { - let operation_owner_user_id = - background_owner_user_id.clone(); - let background_root_state = - background_state.root_state().clone(); + let operation_owner_user_id = background_owner_user_id.clone(); + let background_root_state = background_state.root_state().clone(); let operation_state = background_state.clone(); let result = execute_billable_asset_operation_with_cost( &background_root_state, &background_owner_user_id, "puzzle_initial_image", &background_billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, + background_points_cost, async move { generate_puzzle_initial_cover_from_compiled_session( &operation_state, @@ -739,6 +765,23 @@ pub async fn execute_puzzle_agent_action( .await; match result { Ok(session) => { + send_generation_result_subscribe_message_after_completion( + &background_root_state, + GenerationResultSubscribeMessage { + owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), + work_name: session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: + GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: background_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, @@ -748,15 +791,15 @@ pub async fn execute_puzzle_agent_action( } Err(error) => { let error_message = error.body_text(); + let failed_at_micros = current_utc_micros(); let failure_result = background_state .spacetime_client() .mark_puzzle_draft_generation_failed( PuzzleDraftCompileFailureRecordInput { session_id: background_session_id.clone(), - owner_user_id: background_owner_user_id - .clone(), + owner_user_id: background_owner_user_id.clone(), error_message: error_message.clone(), - failed_at_micros: current_utc_micros(), + failed_at_micros, }, ) .await; @@ -768,6 +811,21 @@ pub async fn execute_puzzle_agent_action( message = %mark_error, "拼图首图后台生成失败态回写失败" ); + } else { + send_generation_result_subscribe_message_after_completion( + &background_root_state, + GenerationResultSubscribeMessage { + owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), + work_name: background_work_name.clone(), + status: + GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; } tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, @@ -778,9 +836,7 @@ pub async fn execute_puzzle_agent_action( ); } } - unregister_puzzle_background_compile_task( - &background_session_id, - ); + unregister_puzzle_background_compile_task(&background_session_id); }); Ok(response_session) } @@ -1428,6 +1484,26 @@ pub async fn execute_puzzle_agent_action( }; let session = session?; + if operation_type == "compile_puzzle_draft" + && session + .draft + .as_ref() + .is_some_and(|draft| draft.generation_status == "ready") + { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some("拼图".to_string()), + work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: operation_consumed_points, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index f49cc84e..c6ce0693 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot( session } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool { !draft.work_title.trim().is_empty() && !draft.work_description.trim().is_empty() @@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) + && has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) }) } diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index bec54d44..31ccf74c 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -469,7 +469,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { .expect("fallback session"); let draft = session.draft.expect("draft"); - assert_eq!(session.stage, "ready_to_publish"); + assert_eq!(session.stage, "image_refining"); assert_eq!(draft.work_title, "暖灯猫街作品"); assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); @@ -479,6 +479,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { ); } +#[test] +fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "雨夜猫街", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": "/generated/puzzle/cover.png", + "cover_asset_id": "asset-cover", + "level_scene_image_src": "/generated/puzzle/level-scene.png", + "level_scene_image_object_key": "generated/puzzle/level-scene.png", + "ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png", + "ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png", + "level_background_image_src": "/generated/puzzle/level-background.png", + "level_background_image_object_key": "generated/puzzle/level-background.png", + "generation_status": "ready", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: None, + candidate_count: Some(1), + should_auto_name_level: None, + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + level_name: None, + summary: Some("当前关卡画面。".to_string()), + theme_tags: Some(vec![ + "猫咪".to_string(), + "雨夜".to_string(), + "灯牌".to_string(), + ]), + levels_json: Some(levels_json.clone()), + }; + + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + assert_eq!(session.stage, "ready_to_publish"); +} + #[test] fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { assert_eq!( diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs index 4c66badb..77a6bbfc 100644 --- a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -9,12 +9,12 @@ use shared_contracts::{ puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse}, puzzle_works::PuzzleWorkSummaryResponse, }; +#[cfg(test)] +use tokio::sync::OwnedMutexGuard; use tokio::{ sync::{Mutex, MutexGuard, RwLock}, time, }; -#[cfg(test)] -use tokio::sync::OwnedMutexGuard; use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext}; diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index c02efa28..65d67d45 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -26,6 +26,7 @@ use module_runtime::{ RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; +use platform_wechat::pay::WechatPayNotifyOrder; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::Sha256; @@ -81,9 +82,9 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, - wechat_pay::{ - WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request, - current_unix_micros, map_wechat_pay_error, + wechat::pay::{ + build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros, + map_wechat_pay_error, }, }; @@ -3056,11 +3057,12 @@ mod tests { } fn issue_access_token(state: &AppState) -> String { + let user_id = test_authenticated_user_id(state); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), + user_id: user_id.clone(), session_id: state - .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"), + .seed_test_refresh_session_for_user_id(&user_id, "sess_runtime_profile"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, @@ -3081,11 +3083,11 @@ mod tests { client_platform: &str, session_id: &str, ) -> String { + let user_id = test_authenticated_user_id(state); 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), + user_id: user_id.clone(), + session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id), provider: AuthProvider::Wechat, roles: vec!["user".to_string()], token_version: 2, @@ -3105,4 +3107,13 @@ mod tests { sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } + + fn test_authenticated_user_id(state: &AppState) -> String { + state + .auth_user_service() + .get_user_by_public_user_code("SY-00000001") + .expect("test user lookup should succeed") + .expect("seeded test user should exist") + .id + } } diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index 45f0e05a..dcfd3ddd 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -81,12 +81,18 @@ use crate::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, }, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works"; const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime"; +const SQUARE_HOLE_TEMPLATE_ID: &str = "square-hole"; +const SQUARE_HOLE_TEMPLATE_NAME: &str = "方洞"; const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱"; const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能"; const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12; @@ -1112,14 +1118,24 @@ async fn compile_square_hole_draft_for_session( .as_ref() .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); - state + let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))); + let generation_points_cost = + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + SQUARE_HOLE_TEMPLATE_ID, + u64::from( + shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, + ), + ) + .await; + let result = state .spacetime_client() .compile_square_hole_draft(SquareHoleCompileDraftRecordInput { session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX), author_display_name: resolve_author_display_name(state, authenticated), - game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))), + game_name: resolved_game_name.clone(), summary_text: summary, tags_json, cover_image_src, @@ -1132,7 +1148,43 @@ async fn compile_square_hole_draft_for_session( SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) - }) + }); + match result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok(session) + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: resolved_game_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } } mod visual_assets; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e19693a6..68aa3805 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -10,12 +10,12 @@ use std::{ use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; +#[cfg(not(test))] +use module_auth::RefreshAuthStoreSnapshotResult; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, RefreshSessionService, WechatAuthService, WechatAuthStateService, }; -#[cfg(not(test))] -use module_auth::RefreshAuthStoreSnapshotResult; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; @@ -27,6 +27,7 @@ use platform_auth::{ }; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_oss::{OssClient, OssConfig, OssError}; +use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient}; use serde_json::Value; use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; @@ -38,8 +39,8 @@ use tracing::{info, warn}; use crate::config::AppConfig; use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::tracking_outbox::TrackingOutbox; -use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error}; -use crate::wechat_provider::build_wechat_provider; +use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; +use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID, }; @@ -251,6 +252,7 @@ pub struct AppStateInner { wechat_auth_state_service: WechatAuthStateService, wechat_auth_service: WechatAuthService, wechat_provider: WechatProvider, + wechat_client: WechatClient, wechat_pay_client: WechatPayClient, #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, @@ -385,8 +387,9 @@ impl AppState { WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); - let wechat_pay_client = - WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?; + let wechat_client = build_wechat_client(&config); + let wechat_pay_client = WechatPayClient::from_config(&build_wechat_pay_config(&config)) + .map_err(map_wechat_pay_init_error)?; let refresh_session_service = RefreshSessionService::new(auth_store.clone(), config.refresh_session_ttl_days); // AI 编排服务当前先挂接内存态 store,后续再按 task table / procedure 接到 SpacetimeDB 真相源。 @@ -424,6 +427,7 @@ impl AppState { wechat_auth_state_service, wechat_auth_service, wechat_provider, + wechat_client, wechat_pay_client, ai_task_service, spacetime_client, @@ -776,6 +780,10 @@ impl AppState { &self.wechat_provider } + pub fn wechat_client(&self) -> &WechatClient { + &self.wechat_client + } + pub fn wechat_pay_client(&self) -> &WechatPayClient { &self.wechat_pay_client } @@ -1333,6 +1341,17 @@ fn build_oss_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(OssClient::new(oss_config))) } +fn build_wechat_client(config: &AppConfig) -> WechatClient { + WechatClient::new(WechatConfig { + app_id: config.wechat_mini_program_app_id.clone(), + app_secret: config.wechat_mini_program_app_secret.clone(), + stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(), + subscribe_message_endpoint: config + .wechat_mini_program_subscribe_message_endpoint + .clone(), + }) +} + fn build_llm_client(config: &AppConfig) -> Result, AppStateInitError> { let Some(api_key) = config .llm_api_key diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index 08c1749b..228c147b 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -35,6 +35,10 @@ use crate::{ prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_author::resolve_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -1743,8 +1747,18 @@ async fn compile_visual_novel_session_inner( current_utc_iso().as_str(), ); let projection = project_draft_for_work(&draft, &profile_id)?; + let notification_work_name = projection.work_title.clone(); + let generation_points_cost = + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + VISUAL_NOVEL_RUNTIME_KIND, + u64::from( + shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, + ), + ) + .await; let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None); - let compiled_session = state + let compile_result = state .spacetime_client() .compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput { session_id: session_id.clone(), @@ -1759,9 +1773,43 @@ async fn compile_visual_novel_session_inner( compiled_at_micros: current_utc_micros(), }) .await - .map_err(|error| { - visual_novel_error_response(request_context, map_spacetime_error(error)) - })?; + .map_err(|error| visual_novel_error_response(request_context, map_spacetime_error(error))); + let compiled_session = match compile_result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + session + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + return Err(response); + } + }; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, owner_user_id) diff --git a/server-rs/crates/api-server/src/wechat.rs b/server-rs/crates/api-server/src/wechat.rs new file mode 100644 index 00000000..5d17d3bd --- /dev/null +++ b/server-rs/crates/api-server/src/wechat.rs @@ -0,0 +1,4 @@ +pub(crate) mod auth; +pub(crate) mod pay; +pub(crate) mod provider; +pub(crate) mod subscribe_message; diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs similarity index 99% rename from server-rs/crates/api-server/src/wechat_auth.rs rename to server-rs/crates/api-server/src/wechat/auth.rs index 165dc5e7..28446f33 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -1,4 +1,4 @@ -use axum::{ +use axum::{ Json, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode}, diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs new file mode 100644 index 00000000..438a439d --- /dev/null +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -0,0 +1,423 @@ +use axum::{ + Json, + extract::{Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use platform_wechat::pay::{ + WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig, + WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext, + parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload, + resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature, +}; +use serde::Serialize; +use serde_json::json; +use shared_kernel::offset_datetime_to_unix_micros; +use time::OffsetDateTime; +use tracing::{info, warn}; + +use crate::{config::AppConfig, http_error::AppError, state::AppState}; + +#[derive(Clone, Copy)] +enum VirtualPaymentNotifyResponseFormat { + Json, + Xml, +} + +#[derive(Serialize)] +struct ApiWechatVirtualPaymentNotifyResponse { + #[serde(rename = "ErrCode")] + err_code: i32, + #[serde(rename = "ErrMsg")] + err_msg: String, +} + +pub async fn handle_wechat_pay_notify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let notify = state + .wechat_pay_client() + .parse_notify(&headers, &body) + .map_err(map_wechat_pay_notify_error)?; + if notify.trade_state != "SUCCESS" { + info!( + order_id = notify.out_trade_no.as_str(), + trade_state = notify.trade_state.as_str(), + "收到非成功微信支付通知" + ); + return Ok(StatusCode::NO_CONTENT); + } + + let paid_at_micros = notify + .success_time + .as_deref() + .and_then(|value| shared_kernel::parse_rfc3339(value).ok()) + .map(offset_datetime_to_unix_micros) + .unwrap_or_else(current_unix_micros); + + state + .spacetime_client() + .mark_profile_recharge_order_paid( + notify.out_trade_no.clone(), + paid_at_micros, + notify.transaction_id.clone(), + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("确认微信支付订单失败:{error}")) + })?; + info!( + order_id = notify.out_trade_no.as_str(), + "微信支付通知已确认订单入账" + ); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn handle_wechat_virtual_payment_message_push_verify( + State(state): State, + Query(query): Query, +) -> Response { + let token = match read_wechat_message_push_config( + state.config.wechat_mini_program_message_token.as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", + ) { + Ok(token) => token, + Err(error) => return build_wechat_message_push_verify_error_response(error), + }; + let aes_key = match read_wechat_message_push_config( + state + .config + .wechat_mini_program_message_encoding_aes_key + .as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", + ) { + Ok(value) => value, + Err(error) => return build_wechat_message_push_verify_error_response(error), + }; + match resolve_wechat_message_push_verify_response( + token, + aes_key, + state + .config + .wechat_mini_program_app_id + .as_deref() + .or(state.config.wechat_app_id.as_deref()), + &query, + ) { + Ok(plaintext) => (StatusCode::OK, plaintext).into_response(), + Err(error) => build_wechat_message_push_verify_error_response(error), + } +} + +pub async fn handle_wechat_virtual_payment_notify( + State(state): State, + headers: HeaderMap, + Query(query): Query, + body: Bytes, +) -> Response { + let response_format = detect_virtual_payment_notify_response_format(&headers, &body); + let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) { + Ok(payload) => payload, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let token = match read_wechat_message_push_config( + state.config.wechat_mini_program_message_token.as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", + ) { + Ok(token) => token, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let aes_key = match read_wechat_message_push_config( + state + .config + .wechat_mini_program_message_encoding_aes_key + .as_deref(), + "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", + ) { + Ok(value) => value, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let signature = query + .msg_signature + .as_deref() + .or(query.signature.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); + let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); + if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() { + return build_virtual_payment_notify_error_response( + WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()), + response_format, + ); + } + if !verify_wechat_message_push_signature( + token, + timestamp, + nonce, + encrypted_payload.encrypt.as_str(), + signature, + ) { + return build_virtual_payment_notify_error_response( + WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()), + response_format, + ); + } + let notify_body = match decrypt_wechat_message_push_ciphertext( + aes_key, + encrypted_payload.encrypt.as_str(), + state + .config + .wechat_mini_program_app_id + .as_deref() + .or(state.config.wechat_app_id.as_deref()), + ) { + Ok(body) => body, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) { + Ok(notify) => notify, + Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + }; + if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" { + info!( + event = notify.event.as_str(), + order_id = notify.out_trade_no.as_str(), + "收到非订单入账虚拟支付推送" + ); + return build_virtual_payment_notify_success_response(response_format); + } + + let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros); + if state + .spacetime_client() + .mark_profile_recharge_order_paid( + notify.out_trade_no.clone(), + paid_at_micros, + notify.transaction_id.clone(), + ) + .await + .is_err() + { + warn!( + order_id = notify.out_trade_no.as_str(), + "确认微信虚拟支付订单失败" + ); + return build_virtual_payment_notify_error_response( + WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()), + response_format, + ); + } + + state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); + + info!( + event = notify.event.as_str(), + order_id = notify.out_trade_no.as_str(), + "微信虚拟支付推送已确认订单入账" + ); + + build_virtual_payment_notify_success_response(response_format) +} + +pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig { + WechatPayConfig { + enabled: config.wechat_pay_enabled, + provider: config.wechat_pay_provider.clone(), + app_id: config + .wechat_mini_program_app_id + .clone() + .or_else(|| config.wechat_app_id.clone()), + mch_id: config.wechat_pay_mch_id.clone(), + merchant_serial_no: config.wechat_pay_merchant_serial_no.clone(), + private_key_pem: config.wechat_pay_private_key_pem.clone(), + private_key_path: config.wechat_pay_private_key_path.clone(), + platform_public_key_pem: config.wechat_pay_platform_public_key_pem.clone(), + platform_public_key_path: config.wechat_pay_platform_public_key_path.clone(), + platform_serial_no: config.wechat_pay_platform_serial_no.clone(), + api_v3_key: config.wechat_pay_api_v3_key.clone(), + notify_url: config.wechat_pay_notify_url.clone(), + jsapi_endpoint: config.wechat_pay_jsapi_endpoint.clone(), + } +} + +pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { + match error { + WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付暂未启用") + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidConfig(message) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })) + } + WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(message) + .with_details(json!({ "provider": "wechat_pay" })), + WechatPayError::InvalidSignature(message) => { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("微信支付通知签名无效") + .with_details(json!({ "provider": "wechat_pay", "reason": message })) + } + } +} + +pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError { + crate::state::AppStateInitError::WechatPay(error.to_string()) +} + +pub fn build_wechat_payment_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_openid: String, +) -> WechatMiniProgramOrderRequest { + WechatMiniProgramOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_openid, + } +} + +pub fn 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) +} + +fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { + warn!(error = %error, "微信支付通知处理失败"); + map_wechat_pay_error(error) +} + +fn read_wechat_message_push_config<'a>( + value: Option<&'a str>, + key: &str, +) -> Result<&'a str, WechatPayError> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置"))) +} + +fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response { + let message = match error { + WechatPayError::Disabled => "微信消息推送暂未启用".to_string(), + WechatPayError::InvalidConfig(message) + | WechatPayError::InvalidRequest(message) + | WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) + | WechatPayError::InvalidSignature(message) => message, + }; + (StatusCode::BAD_REQUEST, message).into_response() +} + +fn build_virtual_payment_notify_error_response( + error: WechatPayError, + response_format: VirtualPaymentNotifyResponseFormat, +) -> Response { + warn!(error = %error, "微信虚拟支付通知处理失败"); + let message = match error { + WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(), + WechatPayError::InvalidConfig(message) + | WechatPayError::InvalidRequest(message) + | WechatPayError::RequestFailed(message) + | WechatPayError::Upstream(message) + | WechatPayError::Deserialize(message) + | WechatPayError::Crypto(message) + | WechatPayError::InvalidSignature(message) => message, + }; + build_virtual_payment_notify_response(response_format, 1, message) +} + +fn build_virtual_payment_notify_success_response( + response_format: VirtualPaymentNotifyResponseFormat, +) -> Response { + build_virtual_payment_notify_response(response_format, 0, "success") +} + +fn build_virtual_payment_notify_response( + response_format: VirtualPaymentNotifyResponseFormat, + err_code: i32, + err_msg: impl Into, +) -> Response { + let err_msg = err_msg.into(); + match response_format { + VirtualPaymentNotifyResponseFormat::Json => Json( + build_wechat_virtual_payment_notify_response(err_code, err_msg), + ) + .into_response(), + VirtualPaymentNotifyResponseFormat::Xml => { + let body = format!( + "{err_code}" + ); + let mut response = (StatusCode::OK, body).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=utf-8"), + ); + response + } + } +} + +fn build_wechat_virtual_payment_notify_response( + err_code: i32, + err_msg: impl Into, +) -> ApiWechatVirtualPaymentNotifyResponse { + ApiWechatVirtualPaymentNotifyResponse { + err_code, + err_msg: err_msg.into(), + } +} + +fn detect_virtual_payment_notify_response_format( + headers: &HeaderMap, + body: &[u8], +) -> VirtualPaymentNotifyResponseFormat { + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if content_type.contains("xml") { + return VirtualPaymentNotifyResponseFormat::Xml; + } + let body_trimmed = body + .iter() + .copied() + .skip_while(|byte| byte.is_ascii_whitespace()) + .next(); + match body_trimmed { + Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml, + _ => VirtualPaymentNotifyResponseFormat::Json, + } +} diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat/provider.rs similarity index 98% rename from server-rs/crates/api-server/src/wechat_provider.rs rename to server-rs/crates/api-server/src/wechat/provider.rs index 60722cb8..94c3a117 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat/provider.rs @@ -1,4 +1,4 @@ -use platform_auth::{ +use platform_auth::{ DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, diff --git a/server-rs/crates/api-server/src/wechat/subscribe_message.rs b/server-rs/crates/api-server/src/wechat/subscribe_message.rs new file mode 100644 index 00000000..8946afaf --- /dev/null +++ b/server-rs/crates/api-server/src/wechat/subscribe_message.rs @@ -0,0 +1,246 @@ +use std::collections::BTreeMap; + +use axum::http::StatusCode; +use platform_wechat::WechatSubscribeMessageRequest; +use time::{OffsetDateTime, UtcOffset}; +use tracing::{info, warn}; + +use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState}; + +const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成"; +const DEFAULT_WORK_NAME: &str = "AI创作作品"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GenerationResultSubscribeMessageStatus { + Succeeded, + Failed, +} + +#[derive(Clone, Debug)] +pub struct GenerationResultSubscribeMessage { + pub owner_user_id: String, + pub task_name: Option, + pub work_name: Option, + pub status: GenerationResultSubscribeMessageStatus, + pub consumed_points: u64, + pub completed_at_micros: i64, + pub page: Option, +} + +pub async fn send_generation_result_subscribe_message_after_completion( + state: &AppState, + message: GenerationResultSubscribeMessage, +) { + if let Err(error) = send_generation_result_subscribe_message(state, message).await { + warn!( + error = %error, + "微信小程序生成结果订阅消息发送失败,已忽略" + ); + } +} + +async fn send_generation_result_subscribe_message( + state: &AppState, + message: GenerationResultSubscribeMessage, +) -> Result<(), AppError> { + if !state.config.wechat_mini_program_subscribe_message_enabled { + return Ok(()); + } + let template_id = state + .config + .wechat_mini_program_generation_result_template_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("微信订阅消息模板 ID 未配置") + })?; + let user = state + .auth_user_service() + .get_user_by_id(&message.owner_user_id) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("读取微信订阅消息用户失败:{error}")) + })? + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在") + })?; + let openid = user + .wechat_account + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("用户未绑定微信小程序 openid") + })?; + + state + .wechat_client() + .send_subscribe_message(WechatSubscribeMessageRequest { + touser: openid.to_string(), + template_id: template_id.to_string(), + page: message + .page + .clone() + .or_else(|| Some("/pages/web-view/index".to_string())), + miniprogram_state: Some( + normalize_miniprogram_state( + &state.config.wechat_mini_program_subscribe_message_state, + ) + .to_string(), + ), + lang: Some("zh_CN".to_string()), + data: build_generation_result_template_data(&message), + }) + .await + .map_err(map_wechat_error)?; + + info!( + owner_user_id = %message.owner_user_id, + template_id, + "微信小程序生成结果订阅消息已发送" + ); + Ok(()) +} + +fn build_generation_result_template_data( + message: &GenerationResultSubscribeMessage, +) -> BTreeMap { + BTreeMap::from([ + ( + "thing1".to_string(), + truncate_template_value( + message + .task_name + .as_deref() + .unwrap_or(GENERATION_RESULT_TASK_NAME), + 20, + ), + ), + ( + "phrase2".to_string(), + truncate_template_value(message.status.template_status_label(), 5), + ), + ( + "time4".to_string(), + truncate_template_value( + &format_generation_completed_time(message.completed_at_micros), + 20, + ), + ), + ( + "thing5".to_string(), + truncate_template_value( + message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME), + 20, + ), + ), + ( + "number6".to_string(), + truncate_template_value(&message.consumed_points.to_string(), 32), + ), + ]) +} + +impl GenerationResultSubscribeMessageStatus { + fn template_status_label(self) -> &'static str { + match self { + Self::Succeeded => "已完成", + Self::Failed => "生成失败", + } + } +} + +fn truncate_template_value(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + let mut result = String::new(); + for character in trimmed.chars().take(max_chars) { + result.push(character); + } + if result.is_empty() { + DEFAULT_WORK_NAME.to_string() + } else { + result + } +} + +fn format_generation_completed_time(completed_at_micros: i64) -> String { + let seconds = completed_at_micros.div_euclid(1_000_000); + let Ok(utc_time) = OffsetDateTime::from_unix_timestamp(seconds) else { + return "1970-01-01 08:00".to_string(); + }; + let beijing_offset = UtcOffset::from_hms(8, 0, 0).unwrap_or(UtcOffset::UTC); + let local_time = utc_time.to_offset(beijing_offset); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + local_time.year(), + u8::from(local_time.month()), + local_time.day(), + local_time.hour(), + local_time.minute() + ) +} + +fn normalize_miniprogram_state(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "developer" | "develop" | "dev" => "developer", + "trial" => "trial", + _ => "formal", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_generation_result_template_uses_failed_status_and_zero_points() { + let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { + owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), + work_name: Some("首关拼图".to_string()), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: 1_762_000_000_000_000, + page: None, + }); + + assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败")); + assert_eq!(data.get("number6").map(String::as_str), Some("0")); + } + + #[test] + fn generation_result_template_time_uses_wechat_time_format() { + let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { + owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), + work_name: Some("首关拼图".to_string()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: 15, + completed_at_micros: 0, + page: None, + }); + + assert_eq!( + data.get("time4").map(String::as_str), + Some("1970-01-01 08:00") + ); + } + + #[test] + fn generation_result_template_uses_task_template_name() { + let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { + owner_user_id: "user-1".to_string(), + task_name: Some("敲木鱼".to_string()), + work_name: Some("功德木鱼".to_string()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: 10, + completed_at_micros: 0, + page: None, + }); + + assert_eq!(data.get("thing1").map(String::as_str), Some("敲木鱼")); + } +} diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a0e60220..a8c46668 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -1,4 +1,4 @@ -use std::{ +use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; @@ -43,6 +43,10 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, + wechat::subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, }; const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; @@ -147,6 +151,15 @@ pub async fn execute_wooden_fish_action( wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let author_display_name = resolve_author_display_name(&state, &authenticated); + let is_compile_draft = matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ); + let generation_points_cost = if is_compile_draft { + resolve_wooden_fish_generation_points_cost(&state).await + } else { + 0 + }; let result = execute_wooden_fish_action_with_generated_assets( &state, &request_context, @@ -160,21 +173,55 @@ pub async fn execute_wooden_fish_action( .as_ref() .err() .is_some_and(|response| response.status().is_server_error()) - && matches!( - payload.action_type, - shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft - ) + && is_compile_draft { - mark_wooden_fish_generation_failed( + let failed_at_micros = current_utc_micros(); + let work_name = + resolve_wooden_fish_notification_work_name(&state, &session_id, &owner_user_id).await; + if mark_wooden_fish_generation_failed( &state, &request_context, &session_id, owner_user_id.as_str(), author_display_name.as_str(), ) - .await; + .await + { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } } let response = result?; + if is_compile_draft && response.session.status == WoodenFishGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } Ok(json_success_body(Some(&request_context), response)) } @@ -588,13 +635,37 @@ async fn execute_wooden_fish_action_with_generated_assets( }) } +async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + WOODEN_FISH_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await +} + +async fn resolve_wooden_fish_notification_work_name( + state: &AppState, + session_id: &str, + owner_user_id: &str, +) -> Option { + state + .spacetime_client() + .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) + .await + .ok() + .and_then(|session| session.draft) + .map(|draft| draft.work_title) + .filter(|value| !value.trim().is_empty()) +} + async fn mark_wooden_fish_generation_failed( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, author_display_name: &str, -) { +) -> bool { if let Err(error) = state .spacetime_client() .mark_wooden_fish_generation_failed( @@ -612,7 +683,9 @@ async fn mark_wooden_fish_generation_failed( error = %error, "敲木鱼草稿生成失败后的状态回写失败" ); + return false; } + true } fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index df8b1c4f..eca056e2 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -541,6 +541,17 @@ pub fn build_result_preview( } } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, @@ -582,6 +593,36 @@ pub fn validate_publish_requirements( message: "正式拼图图片尚未确定".to_string(), }); } + if !has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-scene-image-{}", level.level_id), + code: "MISSING_LEVEL_SCENE_IMAGE".to_string(), + message: "正式关卡画面尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-ui-spritesheet-image-{}", level.level_id), + code: "MISSING_UI_SPRITESHEET_IMAGE".to_string(), + message: "UI spritesheet 尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-background-image-{}", level.level_id), + code: "MISSING_LEVEL_BACKGROUND_IMAGE".to_string(), + message: "关卡背景图尚未生成".to_string(), + }); + } } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT @@ -4011,4 +4052,37 @@ mod tests { .any(|blocker| blocker.code == "MISSING_LEVEL_NAME") ); } + + #[test] + fn validate_publish_requirements_requires_generated_level_asset_pack() { + let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.levels[0].cover_image_src = Some("/cover.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + let blocker_codes = blockers + .iter() + .map(|blocker| blocker.code.as_str()) + .collect::>(); + assert!(blocker_codes.contains(&"MISSING_LEVEL_SCENE_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_UI_SPRITESHEET_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_LEVEL_BACKGROUND_IMAGE")); + + draft.levels[0].level_scene_image_object_key = + Some("generated/puzzle/level-scene.png".to_string()); + draft.levels[0].ui_spritesheet_image_object_key = + Some("generated/puzzle/ui-spritesheet.png".to_string()); + draft.levels[0].level_background_image_object_key = + Some("generated/puzzle/level-background.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + assert!(!blockers.iter().any(|blocker| { + matches!( + blocker.code.as_str(), + "MISSING_LEVEL_SCENE_IMAGE" + | "MISSING_UI_SPRITESHEET_IMAGE" + | "MISSING_LEVEL_BACKGROUND_IMAGE" + ) + })); + } } diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml new file mode 100644 index 00000000..5d1dd7b8 --- /dev/null +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "platform-wechat" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +aes = { workspace = true } +base64 = { workspace = true } +cbc = { workspace = true } +hex = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +ring = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } +shared-contracts = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +urlencoding = { workspace = true } diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs new file mode 100644 index 00000000..e1c4d5a5 --- /dev/null +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -0,0 +1,11 @@ +pub mod pay; +pub mod subscribe_message; + +pub use pay::{ + WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayClient, + WechatPayConfig, WechatPayError, WechatPayNotifyOrder, WechatWebOrderRequest, +}; +pub use subscribe_message::{ + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT, + WechatClient, WechatConfig, WechatError, WechatErrorKind, WechatSubscribeMessageRequest, +}; diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/platform-wechat/src/pay.rs similarity index 81% rename from server-rs/crates/api-server/src/wechat_pay.rs rename to server-rs/crates/platform-wechat/src/pay.rs index 2b6cffd3..44c5aa51 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -1,38 +1,33 @@ -use std::{fs, path::Path, sync::Arc}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; use aes::Aes256; -use axum::{ - Json, - extract::{Query, State}, - http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, - response::{IntoResponse, Response}, -}; use base64::{ Engine as _, alphabet, engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD}, }; -use bytes::Bytes; use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; +use reqwest::header::HeaderMap; use ring::{ aead, rand::{SecureRandom, SystemRandom}, signature, }; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use sha1::Sha1; use sha2::{Digest, Sha256}; use shared_contracts::runtime::{ WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse, }; -use shared_kernel::offset_datetime_to_unix_micros; use std::convert::TryInto; use time::OffsetDateTime; -use tracing::{info, warn}; +use tracing::warn; use url::Url; -use crate::{http_error::AppError, state::AppState}; - const WECHAT_PAY_PROVIDER_MOCK: &str = "mock"; const WECHAT_PAY_PROVIDER_REAL: &str = "real"; const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048"; @@ -61,6 +56,23 @@ const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true), ); +#[derive(Clone, Debug)] +pub struct WechatPayConfig { + pub enabled: bool, + pub provider: String, + pub app_id: Option, + pub mch_id: Option, + pub merchant_serial_no: Option, + pub private_key_pem: Option, + pub private_key_path: Option, + pub platform_public_key_pem: Option, + pub platform_public_key_path: Option, + pub platform_serial_no: Option, + pub api_v3_key: Option, + pub notify_url: Option, + pub jsapi_endpoint: String, +} + #[derive(Clone, Debug)] pub enum WechatPayClient { Disabled, @@ -110,19 +122,11 @@ pub struct WechatPayNotifyOrder { } #[derive(Clone, Debug, PartialEq, Eq)] -struct WechatVirtualPaymentNotifyOrder { - out_trade_no: String, - transaction_id: Option, - paid_at_micros: Option, - event: String, -} - -#[derive(Serialize)] -pub struct WechatVirtualPaymentNotifyResponse { - #[serde(rename = "ErrCode")] - err_code: i32, - #[serde(rename = "ErrMsg")] - err_msg: String, +pub struct WechatVirtualPaymentNotifyOrder { + pub out_trade_no: String, + pub transaction_id: Option, + pub paid_at_micros: Option, + pub event: String, } #[derive(Debug)] @@ -276,30 +280,30 @@ struct WechatVirtualPaymentNotifyPayInfo { } #[derive(Debug, Deserialize)] -pub(crate) struct WechatMiniProgramMessagePushQuery { - signature: Option, - timestamp: Option, - nonce: Option, - echostr: Option, - msg_signature: Option, +pub struct WechatMiniProgramMessagePushQuery { + pub signature: Option, + pub timestamp: Option, + pub nonce: Option, + pub echostr: Option, + pub msg_signature: Option, } #[derive(Debug, Deserialize)] -struct WechatMiniProgramEncryptedMessage { +pub struct WechatMiniProgramEncryptedMessage { #[serde(rename = "ToUserName", alias = "to_user_name", default)] _to_user_name: Option, #[serde(rename = "Encrypt", alias = "encrypt")] - encrypt: String, + pub encrypt: String, } impl WechatPayClient { - pub fn from_config(config: &crate::config::AppConfig) -> Result { - if !config.wechat_pay_enabled { + pub fn from_config(config: &WechatPayConfig) -> Result { + if !config.enabled { return Ok(Self::Disabled); } if config - .wechat_pay_provider + .provider .trim() .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_MOCK) { @@ -307,7 +311,7 @@ impl WechatPayClient { } if !config - .wechat_pay_provider + .provider .trim() .eq_ignore_ascii_case(WECHAT_PAY_PROVIDER_REAL) { @@ -317,52 +321,43 @@ impl WechatPayClient { } let app_id = config - .wechat_mini_program_app_id + .app_id .as_ref() - .or(config.wechat_app_id.as_ref()) .map(|value| value.trim()) .filter(|value| !value.is_empty()) .ok_or_else(|| WechatPayError::InvalidConfig("微信支付缺少小程序 AppID".to_string()))? .to_string(); - let mch_id = required_config(config.wechat_pay_mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?; + let mch_id = required_config(config.mch_id.as_deref(), "WECHAT_PAY_MCH_ID")?; let merchant_serial_no = required_config( - config.wechat_pay_merchant_serial_no.as_deref(), + config.merchant_serial_no.as_deref(), "WECHAT_PAY_MERCHANT_SERIAL_NO", )?; let private_key_pem = read_private_key_pem( - config.wechat_pay_private_key_pem.as_deref(), - config.wechat_pay_private_key_path.as_deref(), + config.private_key_pem.as_deref(), + config.private_key_path.as_deref(), )?; let private_key = Arc::new(parse_rsa_private_key(&private_key_pem)?); let platform_public_key_pem = read_pem( - config.wechat_pay_platform_public_key_pem.as_deref(), - config.wechat_pay_platform_public_key_path.as_deref(), + config.platform_public_key_pem.as_deref(), + config.platform_public_key_path.as_deref(), "WECHAT_PAY_PLATFORM_PUBLIC_KEY_PEM 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH 未配置", "读取微信支付平台公钥失败", )?; let platform_public_key_der = parse_public_key_pem(&platform_public_key_pem)?; let platform_serial_no = required_config( - config.wechat_pay_platform_serial_no.as_deref(), + config.platform_serial_no.as_deref(), "WECHAT_PAY_PLATFORM_SERIAL_NO", )?; - let api_v3_key = required_config( - config.wechat_pay_api_v3_key.as_deref(), - "WECHAT_PAY_API_V3_KEY", - )?; + let api_v3_key = required_config(config.api_v3_key.as_deref(), "WECHAT_PAY_API_V3_KEY")?; if api_v3_key.as_bytes().len() != 32 { return Err(WechatPayError::InvalidConfig( "WECHAT_PAY_API_V3_KEY 必须是 32 字节字符串".to_string(), )); } - let notify_url = required_config( - config.wechat_pay_notify_url.as_deref(), - "WECHAT_PAY_NOTIFY_URL", - )?; + let notify_url = required_config(config.notify_url.as_deref(), "WECHAT_PAY_NOTIFY_URL")?; validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?; - let jsapi_endpoint = normalize_required_url( - &config.wechat_pay_jsapi_endpoint, - "WECHAT_PAY_JSAPI_ENDPOINT", - )?; + let jsapi_endpoint = + normalize_required_url(&config.jsapi_endpoint, "WECHAT_PAY_JSAPI_ENDPOINT")?; let h5_endpoint = resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?; let native_endpoint = @@ -833,293 +828,97 @@ impl RealWechatPayClient { } } -pub async fn handle_wechat_pay_notify( - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Result { - let notify = state - .wechat_pay_client() - .parse_notify(&headers, &body) - .map_err(map_wechat_pay_notify_error)?; - if notify.trade_state != "SUCCESS" { - info!( - order_id = notify.out_trade_no.as_str(), - trade_state = notify.trade_state.as_str(), - "收到非成功微信支付通知" - ); - return Ok(StatusCode::NO_CONTENT); - } - - let paid_at_micros = notify - .success_time - .as_deref() - .and_then(|value| shared_kernel::parse_rfc3339(value).ok()) - .map(offset_datetime_to_unix_micros) - .unwrap_or_else(current_unix_micros); - - state - .spacetime_client() - .mark_profile_recharge_order_paid( - notify.out_trade_no.clone(), - paid_at_micros, - notify.transaction_id.clone(), +fn with_wechat_pay_json_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + builder + .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) + .header( + reqwest::header::CONTENT_TYPE, + WECHAT_PAY_CONTENT_TYPE_HEADER, ) - .await - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(format!("确认微信支付订单失败:{error}")) - })?; - info!( - order_id = notify.out_trade_no.as_str(), - "微信支付通知已确认订单入账" - ); - - Ok(StatusCode::NO_CONTENT) + .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) + .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) } -pub async fn handle_wechat_virtual_payment_message_push_verify( - State(state): State, - Query(query): Query, -) -> Response { - let token = match read_wechat_message_push_config( - state.config.wechat_mini_program_message_token.as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", - ) { - Ok(token) => token, - Err(error) => return build_wechat_message_push_verify_error_response(error), - }; - let aes_key = match read_wechat_message_push_config( - state - .config - .wechat_mini_program_message_encoding_aes_key - .as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", - ) { - Ok(value) => value, - Err(error) => return build_wechat_message_push_verify_error_response(error), - }; - match resolve_wechat_message_push_verify_response( - token, - aes_key, - state - .config - .wechat_mini_program_app_id - .as_deref() - .or(state.config.wechat_app_id.as_deref()), - &query, - ) { - Ok(plaintext) => (StatusCode::OK, plaintext).into_response(), - Err(error) => build_wechat_message_push_verify_error_response(error), +fn with_wechat_pay_jsapi_headers( + builder: reqwest::RequestBuilder, + platform_serial_no: &str, +) -> reqwest::RequestBuilder { + with_wechat_pay_json_headers(builder, platform_serial_no) +} + +fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { + let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce_str = "mock-nonce".to_string(); + let package = format!("prepay_id=mock-{order_id}"); + let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes()); + + WechatMiniProgramPayParamsResponse { + time_stamp, + nonce_str, + package, + sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), + pay_sign, } } -pub async fn handle_wechat_virtual_payment_notify( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { - let response_format = detect_virtual_payment_notify_response_format(&headers, &body); - let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) { - Ok(payload) => payload, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let token = match read_wechat_message_push_config( - state.config.wechat_mini_program_message_token.as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", - ) { - Ok(token) => token, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let aes_key = match read_wechat_message_push_config( - state - .config - .wechat_mini_program_message_encoding_aes_key - .as_deref(), - "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", - ) { - Ok(value) => value, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let signature = query - .msg_signature - .as_deref() - .or(query.signature.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(""); - let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); - let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); - if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() { - return build_virtual_payment_notify_error_response( - WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()), - response_format, - ); - } - if !verify_wechat_message_push_signature( - token, - timestamp, - nonce, - encrypted_payload.encrypt.as_str(), - signature, - ) { - return build_virtual_payment_notify_error_response( - WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()), - response_format, - ); - } - let notify_body = match decrypt_wechat_message_push_ciphertext( - aes_key, - encrypted_payload.encrypt.as_str(), - state - .config - .wechat_mini_program_app_id - .as_deref() - .or(state.config.wechat_app_id.as_deref()), - ) { - Ok(body) => body, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) { - Ok(notify) => notify, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), - }; - if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" { - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "收到非订单入账虚拟支付推送" - ); - return build_virtual_payment_notify_success_response(response_format); - } - - let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros); - if state - .spacetime_client() - .mark_profile_recharge_order_paid( - notify.out_trade_no.clone(), - paid_at_micros, - notify.transaction_id.clone(), - ) - .await - .is_err() - { - warn!( - order_id = notify.out_trade_no.as_str(), - "确认微信虚拟支付订单失败" - ); - return build_virtual_payment_notify_error_response( - WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()), - response_format, - ); - } - - state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); - - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "微信虚拟支付推送已确认订单入账" - ); - - build_virtual_payment_notify_success_response(response_format) -} - -pub fn map_wechat_pay_error(error: WechatPayError) -> AppError { - match error { - WechatPayError::Disabled => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("微信支付暂未启用") - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::InvalidConfig(message) => { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })) - } - WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message(message) - .with_details(json!({ "provider": "wechat_pay" })), - WechatPayError::InvalidSignature(message) => { - AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("微信支付通知签名无效") - .with_details(json!({ "provider": "wechat_pay", "reason": message })) - } +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) + ), } } -pub fn map_wechat_pay_init_error(error: WechatPayError) -> crate::state::AppStateInitError { - crate::state::AppStateInitError::WechatPay(error.to_string()) -} - -pub fn build_wechat_payment_request( - order_id: String, - product_title: String, - amount_cents: u64, - payer_openid: String, -) -> WechatMiniProgramOrderRequest { - WechatMiniProgramOrderRequest { - order_id, - description: format!("陶泥儿 - {product_title}"), - amount_cents, - payer_openid, +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()) + ), } } -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, - } +fn parse_mock_notify(body: &[u8]) -> Result { + let value = serde_json::from_slice::(body).map_err(|error| { + WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) + })?; + Ok(WechatPayNotifyOrder { + out_trade_no: value + .get("outTradeNo") + .or_else(|| value.get("out_trade_no")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string()) + })? + .to_string(), + transaction_id: value + .get("transactionId") + .or_else(|| value.get("transaction_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + trade_state: value + .get("tradeState") + .or_else(|| value.get("trade_state")) + .and_then(Value::as_str) + .unwrap_or("SUCCESS") + .to_string(), + success_time: value + .get("successTime") + .or_else(|| value.get("success_time")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + }) } -pub fn current_unix_micros() -> i64 { - let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; - i64::try_from(value).unwrap_or(i64::MAX) -} - -fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { - warn!(error = %error, "微信支付通知处理失败"); - map_wechat_pay_error(error) -} - -fn read_wechat_message_push_config<'a>( - value: Option<&'a str>, - key: &str, -) -> Result<&'a str, WechatPayError> { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| WechatPayError::InvalidConfig(format!("{key} 未配置"))) -} - -fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Response { - let message = match error { - WechatPayError::Disabled => "微信消息推送暂未启用".to_string(), - WechatPayError::InvalidConfig(message) - | WechatPayError::InvalidRequest(message) - | WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) - | WechatPayError::InvalidSignature(message) => message, - }; - (StatusCode::BAD_REQUEST, message).into_response() -} - -fn resolve_wechat_message_push_verify_response( +pub fn resolve_wechat_message_push_verify_response( token: &str, aes_key: &str, expected_app_id: Option<&str>, @@ -1161,7 +960,7 @@ fn resolve_wechat_message_push_verify_response( Ok(echostr.to_string()) } -fn parse_wechat_mini_program_message_push_payload( +pub fn parse_wechat_mini_program_message_push_payload( body: &[u8], ) -> Result { serde_json::from_slice(body).map_err(|error| { @@ -1169,7 +968,7 @@ fn parse_wechat_mini_program_message_push_payload( }) } -fn verify_wechat_message_push_signature( +pub fn verify_wechat_message_push_signature( token: &str, timestamp: &str, nonce: &str, @@ -1184,7 +983,7 @@ fn verify_wechat_message_push_signature( expected.eq_ignore_ascii_case(signature) } -fn decrypt_wechat_message_push_ciphertext( +pub fn decrypt_wechat_message_push_ciphertext( encoding_aes_key: &str, ciphertext: &str, expected_app_id: Option<&str>, @@ -1302,7 +1101,7 @@ fn parse_wechat_message_push_plaintext( Ok(WechatMessagePushPlaintext { message, app_id }) } -fn parse_virtual_payment_notify( +pub fn parse_virtual_payment_notify( body: &[u8], ) -> Result { if let Ok(notify) = serde_json::from_slice::(body) { @@ -1402,184 +1201,6 @@ fn trim_virtual_payment_text_value(value: &str) -> String { trimmed.to_string() } -fn build_virtual_payment_notify_error_response( - error: WechatPayError, - response_format: VirtualPaymentNotifyResponseFormat, -) -> Response { - warn!(error = %error, "微信虚拟支付通知处理失败"); - let message = match error { - WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(), - WechatPayError::InvalidConfig(message) - | WechatPayError::InvalidRequest(message) - | WechatPayError::RequestFailed(message) - | WechatPayError::Upstream(message) - | WechatPayError::Deserialize(message) - | WechatPayError::Crypto(message) - | WechatPayError::InvalidSignature(message) => message, - }; - build_virtual_payment_notify_response(response_format, 1, message) -} - -fn build_virtual_payment_notify_success_response( - response_format: VirtualPaymentNotifyResponseFormat, -) -> Response { - build_virtual_payment_notify_response(response_format, 0, "success") -} - -fn build_virtual_payment_notify_response( - response_format: VirtualPaymentNotifyResponseFormat, - err_code: i32, - err_msg: impl Into, -) -> Response { - let err_msg = err_msg.into(); - match response_format { - VirtualPaymentNotifyResponseFormat::Json => Json( - build_wechat_virtual_payment_notify_response(err_code, err_msg), - ) - .into_response(), - VirtualPaymentNotifyResponseFormat::Xml => { - let body = format!( - "{err_code}" - ); - let mut response = (StatusCode::OK, body).into_response(); - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("application/xml; charset=utf-8"), - ); - response - } - } -} - -fn with_wechat_pay_json_headers( - builder: reqwest::RequestBuilder, - platform_serial_no: &str, -) -> reqwest::RequestBuilder { - builder - .header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER) - .header( - reqwest::header::CONTENT_TYPE, - WECHAT_PAY_CONTENT_TYPE_HEADER, - ) - .header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT) - .header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no) -} - -fn with_wechat_pay_jsapi_headers( - builder: reqwest::RequestBuilder, - platform_serial_no: &str, -) -> reqwest::RequestBuilder { - with_wechat_pay_json_headers(builder, platform_serial_no) -} - -fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse { - let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); - let nonce_str = "mock-nonce".to_string(); - let package = format!("prepay_id=mock-{order_id}"); - let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes()); - - WechatMiniProgramPayParamsResponse { - time_stamp, - nonce_str, - package, - sign_type: WECHAT_PAY_PAY_SIGN_TYPE.to_string(), - pay_sign, - } -} - -fn 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}")) - })?; - Ok(WechatPayNotifyOrder { - out_trade_no: value - .get("outTradeNo") - .or_else(|| value.get("out_trade_no")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WechatPayError::InvalidRequest("mock 微信支付通知缺少 outTradeNo".to_string()) - })? - .to_string(), - transaction_id: value - .get("transactionId") - .or_else(|| value.get("transaction_id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), - trade_state: value - .get("tradeState") - .or_else(|| value.get("trade_state")) - .and_then(Value::as_str) - .unwrap_or("SUCCESS") - .to_string(), - success_time: value - .get("successTime") - .or_else(|| value.get("success_time")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - }) -} - -fn build_wechat_virtual_payment_notify_response( - err_code: i32, - err_msg: impl Into, -) -> WechatVirtualPaymentNotifyResponse { - WechatVirtualPaymentNotifyResponse { - err_code, - err_msg: err_msg.into(), - } -} - -#[derive(Clone, Copy)] -enum VirtualPaymentNotifyResponseFormat { - Json, - Xml, -} - -fn detect_virtual_payment_notify_response_format( - headers: &HeaderMap, - body: &[u8], -) -> VirtualPaymentNotifyResponseFormat { - let content_type = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - if content_type.contains("xml") { - return VirtualPaymentNotifyResponseFormat::Xml; - } - let body_trimmed = body - .iter() - .copied() - .skip_while(|byte| byte.is_ascii_whitespace()) - .next(); - match body_trimmed { - Some(b'<') => VirtualPaymentNotifyResponseFormat::Xml, - _ => VirtualPaymentNotifyResponseFormat::Json, - } -} - fn required_config(value: Option<&str>, key: &str) -> Result { value .map(str::trim) @@ -1946,6 +1567,7 @@ impl std::error::Error for WechatPayError {} mod tests { use super::*; use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding}; + use serde_json::json; #[test] fn mock_pay_params_use_request_payment_shape() { diff --git a/server-rs/crates/platform-wechat/src/subscribe_message.rs b/server-rs/crates/platform-wechat/src/subscribe_message.rs new file mode 100644 index 00000000..0935554e --- /dev/null +++ b/server-rs/crates/platform-wechat/src/subscribe_message.rs @@ -0,0 +1,234 @@ +use std::{collections::BTreeMap, error::Error, fmt}; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::warn; +use url::Url; + +pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/stable_token"; +pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatConfig { + pub app_id: Option, + pub app_secret: Option, + pub stable_access_token_endpoint: String, + pub subscribe_message_endpoint: String, +} + +#[derive(Clone, Debug)] +pub struct WechatClient { + client: Client, + config: WechatConfig, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatSubscribeMessageRequest { + pub touser: String, + pub template_id: String, + pub page: Option, + pub miniprogram_state: Option, + pub lang: Option, + pub data: BTreeMap, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum WechatError { + InvalidConfig(String), + RequestFailed(String), + DeserializeFailed(String), + Upstream(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WechatErrorKind { + InvalidConfig, + RequestFailed, + DeserializeFailed, + Upstream, +} + +#[derive(Debug, Deserialize)] +struct WechatStableAccessTokenResponse { + access_token: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatSubscribeMessageResponse { + errcode: i64, + errmsg: Option, +} + +#[derive(Debug, Serialize)] +struct WechatTemplateDataValue { + value: String, +} + +impl WechatClient { + pub fn new(config: WechatConfig) -> Self { + Self { + client: Client::new(), + config, + } + } + + pub async fn send_subscribe_message( + &self, + request: WechatSubscribeMessageRequest, + ) -> Result<(), WechatError> { + let app_id = self + .config + .app_id + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; + let app_secret = self + .config + .app_secret + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; + + let access_token = self.request_access_token(app_id, app_secret).await?; + let mut send_url = + Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) + })?; + send_url + .query_pairs_mut() + .append_pair("access_token", &access_token); + + let data = request + .data + .into_iter() + .map(|(key, value)| (key, WechatTemplateDataValue { value })) + .collect::>(); + let payload = json!({ + "touser": request.touser, + "template_id": request.template_id, + "page": request.page, + "miniprogram_state": request.miniprogram_state, + "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), + "data": data, + }); + let response = self + .client + .post(send_url.as_str()) + .json(&payload) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息请求失败"); + WechatError::RequestFailed("微信订阅消息请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息响应解析失败"); + WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) + })?; + + if response.errcode != 0 { + return Err(WechatError::Upstream(format!( + "微信订阅消息发送失败:{}", + response.errmsg.unwrap_or_else(|| format!( + "subscribeMessage.send 返回错误 {}", + response.errcode + )) + ))); + } + + Ok(()) + } + + async fn request_access_token( + &self, + app_id: &str, + app_secret: &str, + ) -> Result { + let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) + })?; + let payload = self + .client + .post(url.as_str()) + .json(&json!({ + "grant_type": "client_credential", + "appid": app_id, + "secret": app_secret, + "force_refresh": false, + })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 请求失败"); + WechatError::RequestFailed("微信 stable_token 请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 响应解析失败"); + WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string()) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatError::Upstream(format!( + "微信 stable_token 返回错误:{}", + payload + .errmsg + .unwrap_or_else(|| format!("errcode={errcode}")) + ))); + } + + payload + .access_token + .and_then(|value| non_empty_owned(value)) + .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) + } +} + +impl WechatError { + pub fn kind(&self) -> WechatErrorKind { + match self { + Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig, + Self::RequestFailed(_) => WechatErrorKind::RequestFailed, + Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed, + Self::Upstream(_) => WechatErrorKind::Upstream, + } + } +} + +impl fmt::Display for WechatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(message) + | Self::RequestFailed(message) + | Self::DeserializeFailed(message) + | Self::Upstream(message) => f.write_str(message), + } + } +} + +impl Error for WechatError {} + +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn non_empty_owned(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} diff --git a/server-rs/crates/shared-contracts/src/bark_battle.rs b/server-rs/crates/shared-contracts/src/bark_battle.rs index 5fbef4f0..82161f84 100644 --- a/server-rs/crates/shared-contracts/src/bark_battle.rs +++ b/server-rs/crates/shared-contracts/src/bark_battle.rs @@ -169,6 +169,8 @@ pub struct BarkBattleImageAssetGenerateRequest { pub slot: BarkBattleAssetSlot, #[serde(default, skip_serializing_if = "Option::is_none")] pub draft_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub billing_purpose: Option, pub config: BarkBattleConfigEditorPayload, } @@ -823,6 +825,7 @@ mod tests { let request = BarkBattleImageAssetGenerateRequest { slot: BarkBattleAssetSlot::OpponentCharacter, draft_id: Some("bark-battle-draft-1".to_string()), + billing_purpose: None, config: BarkBattleConfigEditorPayload { title: "汪汪冠军杯".to_string(), description: Some(String::new()), diff --git a/src/components/ResolvedAssetImage.tsx b/src/components/ResolvedAssetImage.tsx index 3bccab70..5f662f7b 100644 --- a/src/components/ResolvedAssetImage.tsx +++ b/src/components/ResolvedAssetImage.tsx @@ -1,6 +1,7 @@ import React, { type ImgHTMLAttributes, useEffect, useState } from 'react'; import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl'; +import { RuntimeResourcePendingMarker } from './common/RuntimeResourcePendingMarker'; type ResolvedAssetImageProps = Omit< ImgHTMLAttributes, @@ -19,39 +20,50 @@ export function ResolvedAssetImage({ onError, ...rest }: ResolvedAssetImageProps) { - const { resolvedUrl } = useResolvedAssetReadUrl(src, { + const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, { refreshKey, }); + const normalizedSource = src?.trim() ?? ''; const normalizedFallbackSrc = fallbackSrc?.trim() ?? ''; const [useFallbackSrc, setUseFallbackSrc] = useState(false); const finalSrc = useFallbackSrc && normalizedFallbackSrc ? normalizedFallbackSrc : resolvedUrl || normalizedFallbackSrc; + const pendingMarker = ( + + ); useEffect(() => { setUseFallbackSrc(false); }, [normalizedFallbackSrc, resolvedUrl]); if (!finalSrc) { - return null; + return pendingMarker; } return ( - {alt} { - if ( - normalizedFallbackSrc && - !useFallbackSrc && - finalSrc !== normalizedFallbackSrc - ) { - setUseFallbackSrc(true); - } - onError?.(event); - }} - /> + <> + {pendingMarker} + {alt} { + if ( + normalizedFallbackSrc && + !useFallbackSrc && + finalSrc !== normalizedFallbackSrc + ) { + setUseFallbackSrc(true); + } + onError?.(event); + }} + /> + ); } diff --git a/src/components/common/RuntimeResourcePendingMarker.tsx b/src/components/common/RuntimeResourcePendingMarker.tsx new file mode 100644 index 00000000..d32b2b54 --- /dev/null +++ b/src/components/common/RuntimeResourcePendingMarker.tsx @@ -0,0 +1,29 @@ +type RuntimeResourcePendingMarkerProps = { + source?: string | null; + isPending?: boolean; + kind?: string; +}; + +export const RUNTIME_RESOURCE_PENDING_SELECTOR = + '[data-runtime-resource-pending="true"]'; + +export function RuntimeResourcePendingMarker({ + source, + isPending = true, + kind, +}: RuntimeResourcePendingMarkerProps) { + const normalizedSource = source?.trim() ?? ''; + if (!isPending || !normalizedSource) { + return null; + } + + return ( + @@ -345,7 +347,7 @@ export function Match3DCreationWorkspace({ 确认消耗泥点
- 消耗 10 泥点 + 消耗 {mudPointCost} 泥点
- 消耗 2 泥点 + 消耗 {mudPointCost} 泥点