From 5ea9f0a120e0b3ea4cedfa890c6e66af2326410c Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 8 Jun 2026 15:47:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=89=A3=E9=99=A4=E5=88=9B=E4=BD=9C=E6=B3=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端创作表单泥点预校验改为读取入口契约配置 拼图和抓大鹅初始生成后端扣费改为解析后台配置 汪汪声浪初始三图生成按入口总成本拆分扣费 创作工作台按钮和确认弹窗展示后台配置泥点成本 补充泥点扣费回归测试并同步文档与共享记忆 --- .hermes/shared-memory/decision-log.md | 4 +- .hermes/shared-memory/pitfalls.md | 8 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- packages/shared/src/contracts/barkBattle.ts | 1 + .../crates/api-server/src/asset_billing.rs | 4 + .../crates/api-server/src/bark_battle.rs | 130 +++++++++++++++++- .../api-server/src/creation_entry_config.rs | 83 +++++++++++ .../crates/api-server/src/match3d/draft.rs | 26 +++- .../crates/api-server/src/puzzle/handlers.rs | 24 ++-- .../shared-contracts/src/bark_battle.rs | 3 + .../PlatformEntryFlowShellImpl.tsx | 58 +++++--- .../platformEntryCreationTypes.ts | 4 + ...gEntryFlowShell.agent.interaction.test.tsx | 31 ++++- .../UnifiedCreationWorkspace.tsx | 2 + ...ch3DCreationWorkspace.interaction.test.tsx | 27 ++++ .../workspaces/Match3DCreationWorkspace.tsx | 6 +- ...zzleCreationWorkspace.interaction.test.tsx | 28 ++++ .../workspaces/PuzzleCreationWorkspace.tsx | 6 +- .../barkBattleCreationClient.test.ts | 12 ++ .../barkBattleCreationClient.ts | 3 + 21 files changed, 425 insertions(+), 45 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 19f1ae03..1e958aca 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1349,10 +1349,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 20232dd9..b9d9c167 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。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 17429e6c..3925dcf6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -167,6 +167,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 4f522b80..8d697380 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -16,7 +16,7 @@ 统一创作入口覆盖当前可进入创作链路的已有模板:`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,供草稿页和失败重试页恢复使用。 @@ -254,7 +254,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`。 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/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/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/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/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 98e8a8b2..103bf594 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, @@ -418,12 +425,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 +442,7 @@ where request_context, owner_user_id, billing_asset_id, + points_cost, ) .await?; @@ -441,8 +450,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 +468,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 +478,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 +506,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 +516,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/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index e3e79aa4..ec10fb5c 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, @@ -655,6 +656,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(), @@ -718,6 +730,7 @@ 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() @@ -733,7 +746,7 @@ pub async fn execute_puzzle_agent_action( &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, @@ -761,8 +774,7 @@ pub async fn execute_puzzle_agent_action( .map(|draft| draft.work_title.clone()), status: GenerationResultSubscribeMessageStatus::Succeeded, - consumed_points: - PUZZLE_IMAGE_GENERATION_POINTS_COST, + consumed_points: background_points_cost, completed_at_micros: current_utc_micros(), page: Some("/pages/web-view/index".to_string()), }, @@ -1481,11 +1493,7 @@ pub async fn execute_puzzle_agent_action( owner_user_id: owner_user_id.clone(), work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), status: GenerationResultSubscribeMessageStatus::Succeeded, - consumed_points: if payload.ai_redraw.unwrap_or(true) { - PUZZLE_IMAGE_GENERATION_POINTS_COST - } else { - 0 - }, + consumed_points: operation_consumed_points, completed_at_micros: current_utc_micros(), page: Some("/pages/web-view/index".to_string()), }, 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/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index bedeb4d1..a5311c80 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -159,6 +159,7 @@ import { } from '../../services/big-fish-works'; import { type CreationEntryConfig, + DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, fetchCreationEntryConfig, } from '../../services/creationEntryConfigService'; import { @@ -686,10 +687,6 @@ async function buildRecommendRuntimeAuthOptions( return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; } -const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; -const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; -const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; - function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { const rawTime = entry.publishedAt ?? entry.updatedAt; const timestamp = new Date(rawTime).getTime(); @@ -4128,6 +4125,18 @@ export function PlatformEntryFlowShellImpl({ creationEntryTypes, 'visual-novel', ); + const resolveCreationEntryMudPointCost = useCallback( + (id: PlatformCreationTypeId) => + creationEntryTypes.find((item) => item.id === id)?.mudPointCost ?? + DEFAULT_UNIFIED_CREATION_MUD_POINT_COST, + [creationEntryTypes], + ); + const puzzleDraftGenerationPointCost = + resolveCreationEntryMudPointCost('puzzle'); + const match3DDraftGenerationPointCost = + resolveCreationEntryMudPointCost('match3d'); + const barkBattleDraftGenerationPointCost = + resolveCreationEntryMudPointCost('bark-battle'); const [profilePlayStats, setProfilePlayStats] = useState(null); const [profilePlayStatsError, setProfilePlayStatsError] = useState< @@ -6944,21 +6953,30 @@ export function PlatformEntryFlowShellImpl({ setPuzzleCreationError(null); setPuzzleError(null); return ensureEnoughDraftGenerationPointsFromServer( - PUZZLE_DRAFT_GENERATION_POINT_COST, + puzzleDraftGenerationPointCost, ); - }, [ensureEnoughDraftGenerationPointsFromServer]); + }, [ + ensureEnoughDraftGenerationPointsFromServer, + puzzleDraftGenerationPointCost, + ]); const preflightMatch3DDraftGeneration = useCallback(async () => { setMatch3DError(null); return ensureEnoughDraftGenerationPointsFromServer( - MATCH3D_DRAFT_GENERATION_POINT_COST, + match3DDraftGenerationPointCost, ); - }, [ensureEnoughDraftGenerationPointsFromServer]); + }, [ + ensureEnoughDraftGenerationPointsFromServer, + match3DDraftGenerationPointCost, + ]); const preflightBarkBattleDraftGeneration = useCallback(async () => { setBarkBattleError(null); return ensureEnoughDraftGenerationPointsFromServer( - BARK_BATTLE_DRAFT_GENERATION_POINT_COST, + barkBattleDraftGenerationPointCost, ); - }, [ensureEnoughDraftGenerationPointsFromServer]); + }, [ + barkBattleDraftGenerationPointCost, + ensureEnoughDraftGenerationPointsFromServer, + ]); const draftGenerationPointNoticeDescription = draftGenerationPointNotice ? draftGenerationPointNotice.title === '读取泥点余额失败' ? '当前表单不会丢失,关闭后可继续编辑,稍后再试。' @@ -7973,7 +7991,7 @@ export function PlatformEntryFlowShellImpl({ buildPendingPuzzleDraftMetadata(payload), ); if (shouldConsumePuzzleDraftPoints) { - adjustProfileWalletBalanceLocally(-PUZZLE_DRAFT_GENERATION_POINT_COST); + adjustProfileWalletBalanceLocally(-puzzleDraftGenerationPointCost); } selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = nextSession.sessionId; @@ -8126,7 +8144,7 @@ export function PlatformEntryFlowShellImpl({ return; } if (shouldConsumePuzzleDraftPoints) { - adjustProfileWalletBalanceLocally(PUZZLE_DRAFT_GENERATION_POINT_COST); + adjustProfileWalletBalanceLocally(puzzleDraftGenerationPointCost); } const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( @@ -8176,6 +8194,7 @@ export function PlatformEntryFlowShellImpl({ isViewingPuzzleGeneration, preflightPuzzleDraftGeneration, puzzleFlow, + puzzleDraftGenerationPointCost, refreshPuzzleShelf, recoverCompletedPuzzleDraftGeneration, refreshPlatformDashboardSilently, @@ -8235,7 +8254,7 @@ export function PlatformEntryFlowShellImpl({ nextSession.sessionId, buildPendingMatch3DDraftMetadata(payload), ); - adjustProfileWalletBalanceLocally(-MATCH3D_DRAFT_GENERATION_POINT_COST); + adjustProfileWalletBalanceLocally(-match3DDraftGenerationPointCost); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId; setSelectionStage('match3d-generating'); @@ -8378,7 +8397,7 @@ export function PlatformEntryFlowShellImpl({ await refreshMatch3DShelf().catch(() => undefined); } } - adjustProfileWalletBalanceLocally(MATCH3D_DRAFT_GENERATION_POINT_COST); + adjustProfileWalletBalanceLocally(match3DDraftGenerationPointCost); const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( generationState, @@ -8453,6 +8472,7 @@ export function PlatformEntryFlowShellImpl({ [ adjustProfileWalletBalanceLocally, match3dRuntimeAdapter, + match3DDraftGenerationPointCost, isViewingMatch3DGeneration, markDraftGenerating, markDraftFailed, @@ -18384,7 +18404,10 @@ export function PlatformEntryFlowShellImpl({ > ({ })); vi.mock('../../services/creationEntryConfigService', () => ({ + DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: 10, fetchCreationEntryConfig: vi.fn(), })); @@ -3963,7 +3982,7 @@ test('direct bark battle runtime public code opens published runtime', async () test('bark battle form checks mud points before creating image assets', async () => { const user = userEvent.setup(); vi.mocked(getProfileDashboard).mockResolvedValue({ - walletBalance: 2, + walletBalance: 5, totalPlayTimeMs: 0, playedWorldCount: 0, updatedAt: '2026-05-14T10:00:00.000Z', @@ -3980,7 +3999,7 @@ test('bark battle form checks mud points before creating image assets', async () const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'), + within(noticeDialog).getByText('本次需要 6 泥点,当前 5 泥点。'), ).toBeTruthy(); expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy(); expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull(); @@ -5309,7 +5328,7 @@ test('puzzle text-only form stays generating when compile starts background imag test('puzzle form checks mud points before creating a draft', async () => { const user = userEvent.setup(); vi.mocked(getProfileDashboard).mockResolvedValue({ - walletBalance: 1, + walletBalance: 7, totalPlayTimeMs: 0, playedWorldCount: 0, updatedAt: '2026-05-14T10:00:00.000Z', @@ -5323,7 +5342,7 @@ test('puzzle form checks mud points before creating a draft', async () => { const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'), + within(noticeDialog).getByText('本次需要 8 泥点,当前 7 泥点。'), ).toBeTruthy(); expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy(); expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull(); @@ -5334,7 +5353,7 @@ test('puzzle form checks mud points before creating a draft', async () => { test('match3d form checks mud points before creating a draft', async () => { const user = userEvent.setup(); vi.mocked(getProfileDashboard).mockResolvedValue({ - walletBalance: 9, + walletBalance: 11, totalPlayTimeMs: 0, playedWorldCount: 0, updatedAt: '2026-05-14T10:00:00.000Z', @@ -5350,7 +5369,7 @@ test('match3d form checks mud points before creating a draft', async () => { const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'), + within(noticeDialog).getByText('本次需要 12 泥点,当前 11 泥点。'), ).toBeTruthy(); expect(screen.getByText('抓大鹅工作区:missing-session')).toBeTruthy(); expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull(); diff --git a/src/components/unified-creation/UnifiedCreationWorkspace.tsx b/src/components/unified-creation/UnifiedCreationWorkspace.tsx index 2c282289..697fafbd 100644 --- a/src/components/unified-creation/UnifiedCreationWorkspace.tsx +++ b/src/components/unified-creation/UnifiedCreationWorkspace.tsx @@ -68,6 +68,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) { showBackButton={false} title={null} unifiedChrome + mudPointCost={props.spec.mudPointCost ?? undefined} /> ); @@ -86,6 +87,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) { showBackButton={false} title={null} unifiedChrome + mudPointCost={props.spec.mudPointCost ?? undefined} /> ); diff --git a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx index 856dbc5b..bbabde9e 100644 --- a/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx +++ b/src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx @@ -65,6 +65,13 @@ function confirmMatch3DPointCost() { fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' })); } +function confirmMatch3DPointCostText(text: string) { + const confirmDialog = screen.getByRole('dialog', { + name: '确认消耗泥点', + }); + expect(within(confirmDialog).getByText(text)).toBeTruthy(); +} + test('match3d workspace submits derived entry form payload instead of agent chat', () => { const onCreateFromForm = vi.fn(); const onExecuteAction = vi.fn(); @@ -112,6 +119,26 @@ test('match3d workspace submits derived entry form payload instead of agent chat expect(onExecuteAction).not.toHaveBeenCalled(); }); +test('match3d workspace shows configured mud point cost', () => { + render( + {}} + onExecuteAction={() => {}} + onCreateFromForm={() => {}} + mudPointCost={12} + />, + ); + + expect(screen.getByText('消耗12泥点')).toBeTruthy(); + fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), { + target: { value: '陶泥甜品店' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u })); + + confirmMatch3DPointCostText('消耗 12 泥点'); +}); + test('match3d workspace can defer visible chrome to the unified creation page', () => { const { container } = render( (() => resolveInitialFormState(session, initialFormPayload), @@ -324,7 +326,7 @@ export function Match3DCreationWorkspace({ )} 生成抓大鹅草稿 - 消耗10泥点 + 消耗{mudPointCost}泥点 @@ -345,7 +347,7 @@ export function Match3DCreationWorkspace({ 确认消耗泥点
- 消耗 10 泥点 + 消耗 {mudPointCost} 泥点
- 消耗 2 泥点 + 消耗 {mudPointCost} 泥点