fix: 收紧拼图发布资产门槛

This commit is contained in:
2026-06-04 03:50:09 +08:00
parent 46a36222cb
commit 4b4af11dbc
13 changed files with 226 additions and 27 deletions

View File

@@ -1419,6 +1419,14 @@
- 验证方式:`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 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-03 Public Work Presentation 收口
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。

View File

@@ -1245,8 +1245,8 @@
- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。
- 原因:拼图的待发布判定太弱,`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`
- 处理:前端恢复链路已收口到 `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 移动端放大溢出

View File

@@ -63,6 +63,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-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)。
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)。

View File

@@ -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`

View File

@@ -104,6 +104,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 的纯账号接口。

View File

@@ -20,6 +20,8 @@
拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。
后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady``ready_to_publish`
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``UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。

View File

@@ -4053,8 +4053,7 @@ mod tests {
.await
.expect("banners body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("banners payload should be json");
let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");

View File

@@ -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(),
@@ -1788,7 +1793,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

View File

@@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
session
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> 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,
)
})
}

View File

@@ -474,7 +474,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");
@@ -484,6 +484,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!(

View File

@@ -541,6 +541,17 @@ pub fn build_result_preview(
}
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> 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::<Vec<_>>();
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"
)
}));
}
}

View File

@@ -161,10 +161,9 @@ fn normalize_creation_entry_announcement_banner_value(
);
}
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
object.clone(),
))
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
let banner =
serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
normalize_creation_entry_event_banner_response(index, banner)
}
@@ -327,10 +326,7 @@ fn normalize_banner_html_code(
}
let lower_html_code = html_code.to_ascii_lowercase();
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
return Err(format!(
"{} 条 HTML 公告含有不允许的脚本代码",
index + 1
));
return Err(format!("{} 条 HTML 公告含有不允许的脚本代码", index + 1));
}
Ok(Some(html_code))

View File

@@ -172,18 +172,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
vec![
unified_creation_field("title", "text", "作品标题", true),
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
unified_creation_field(
"playerImageDescription",
"text",
"玩家形象描述",
true,
),
unified_creation_field(
"opponentImageDescription",
"text",
"对手形象描述",
true,
),
unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
unified_creation_field("onomatopoeia", "text", "拟声词", false),
unified_creation_field("difficultyPreset", "select", "难度", true),
],