1
This commit is contained in:
@@ -113,9 +113,26 @@ pub struct PuzzleGeneratedImageCandidate {
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleDraftLevel {
|
||||
pub level_id: String,
|
||||
pub level_name: String,
|
||||
pub picture_description: String,
|
||||
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
pub selected_candidate_id: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub generation_status: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleResultDraft {
|
||||
#[serde(default)]
|
||||
pub work_title: String,
|
||||
#[serde(default)]
|
||||
pub work_description: String,
|
||||
pub level_name: String,
|
||||
pub summary: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
@@ -127,6 +144,18 @@ pub struct PuzzleResultDraft {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub generation_status: String,
|
||||
#[serde(default)]
|
||||
pub levels: Vec<PuzzleDraftLevel>,
|
||||
#[serde(default)]
|
||||
pub form_draft: Option<PuzzleFormDraft>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleFormDraft {
|
||||
pub work_title: Option<String>,
|
||||
pub work_description: Option<String>,
|
||||
pub picture_description: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -202,11 +231,17 @@ pub struct PuzzleWorkProfile {
|
||||
pub owner_user_id: String,
|
||||
pub source_session_id: Option<String>,
|
||||
pub author_display_name: String,
|
||||
#[serde(default)]
|
||||
pub work_title: String,
|
||||
#[serde(default)]
|
||||
pub work_description: String,
|
||||
pub level_name: String,
|
||||
pub summary: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub levels: Vec<PuzzleDraftLevel>,
|
||||
pub publication_status: PuzzlePublicationStatus,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
@@ -334,6 +369,15 @@ pub struct PuzzleAgentSessionCreateInput {
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleFormDraftSaveInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub saved_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleAgentSessionGetInput {
|
||||
@@ -378,6 +422,7 @@ pub struct PuzzleDraftCompileInput {
|
||||
pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
}
|
||||
@@ -387,6 +432,7 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub struct PuzzleSelectCoverImageInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub candidate_id: String,
|
||||
pub selected_at_micros: i64,
|
||||
}
|
||||
@@ -399,9 +445,12 @@ pub struct PuzzlePublishInput {
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub author_display_name: String,
|
||||
pub work_title: Option<String>,
|
||||
pub work_description: Option<String>,
|
||||
pub level_name: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub theme_tags: Option<Vec<String>>,
|
||||
pub levels_json: Option<String>,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -429,11 +478,14 @@ pub struct PuzzleWorkDeleteInput {
|
||||
pub struct PuzzleWorkUpsertInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub work_title: String,
|
||||
pub work_description: String,
|
||||
pub level_name: String,
|
||||
pub summary: String,
|
||||
pub theme_tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub levels_json: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -450,12 +502,21 @@ pub struct PuzzleWorkRemixInput {
|
||||
pub remixed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleWorkLikeRecordInput {
|
||||
pub profile_id: String,
|
||||
pub user_id: String,
|
||||
pub liked_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PuzzleRunStartInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub level_id: Option<String>,
|
||||
pub started_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -690,10 +751,17 @@ pub fn empty_anchor_pack() -> PuzzleAnchorPack {
|
||||
|
||||
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack {
|
||||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||||
.or_else(|| normalize_required_string(seed_text))
|
||||
.unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string());
|
||||
if let Some((title, picture_description)) = parse_form_seed_text(&source) {
|
||||
return build_form_anchor_pack(title.as_str(), picture_description.as_str());
|
||||
.or_else(|| normalize_required_string(seed_text));
|
||||
let Some(source) = source else {
|
||||
return empty_anchor_pack();
|
||||
};
|
||||
if let Some(form_seed) = parse_form_seed_text(&source) {
|
||||
if form_seed.has_any_value() {
|
||||
return build_form_anchor_pack(
|
||||
form_seed.work_title.as_deref().unwrap_or(""),
|
||||
form_seed.picture_description.as_deref().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pack = empty_anchor_pack();
|
||||
@@ -711,22 +779,26 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
|
||||
}
|
||||
|
||||
pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack {
|
||||
let normalized_title =
|
||||
normalize_required_string(title).unwrap_or_else(|| "奇景拼图".to_string());
|
||||
let normalized_description =
|
||||
normalize_required_string(picture_description).unwrap_or_else(|| normalized_title.clone());
|
||||
let normalized_title = normalize_required_string(title);
|
||||
let normalized_description = normalize_required_string(picture_description);
|
||||
let mut pack = empty_anchor_pack();
|
||||
|
||||
pack.theme_promise.value = normalized_title.clone();
|
||||
pack.theme_promise.status = PuzzleAnchorStatus::Locked;
|
||||
pack.visual_subject.value = normalized_description.clone();
|
||||
pack.visual_subject.status = PuzzleAnchorStatus::Locked;
|
||||
if let Some(title) = normalized_title.as_ref() {
|
||||
pack.theme_promise.value = title.clone();
|
||||
pack.theme_promise.status = PuzzleAnchorStatus::Locked;
|
||||
}
|
||||
if let Some(description) = normalized_description.as_ref() {
|
||||
pack.visual_subject.value = description.clone();
|
||||
pack.visual_subject.status = PuzzleAnchorStatus::Locked;
|
||||
}
|
||||
pack.visual_mood.value = "清晰、适合拼图切块".to_string();
|
||||
pack.visual_mood.status = PuzzleAnchorStatus::Inferred;
|
||||
pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string();
|
||||
pack.composition_hooks.status = PuzzleAnchorStatus::Inferred;
|
||||
pack.tags_and_forbidden.value =
|
||||
build_form_tags_and_forbidden(normalized_title.as_str(), normalized_description.as_str());
|
||||
pack.tags_and_forbidden.value = build_form_tags_and_forbidden(
|
||||
normalized_title.as_deref().unwrap_or(""),
|
||||
normalized_description.as_deref().unwrap_or(""),
|
||||
);
|
||||
pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred;
|
||||
|
||||
pack
|
||||
@@ -766,13 +838,37 @@ pub fn build_creator_intent(
|
||||
pub fn compile_result_draft(
|
||||
anchor_pack: &PuzzleAnchorPack,
|
||||
messages: &[PuzzleAgentMessageSnapshot],
|
||||
) -> PuzzleResultDraft {
|
||||
compile_result_draft_from_seed(anchor_pack, messages, None)
|
||||
}
|
||||
|
||||
pub fn compile_result_draft_from_seed(
|
||||
anchor_pack: &PuzzleAnchorPack,
|
||||
messages: &[PuzzleAgentMessageSnapshot],
|
||||
seed_text: Option<&str>,
|
||||
) -> PuzzleResultDraft {
|
||||
let creator_intent = build_creator_intent(anchor_pack, messages);
|
||||
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
|
||||
let level_name = build_level_name(anchor_pack, &normalized_tags);
|
||||
let work_title = build_work_title(anchor_pack);
|
||||
let work_description = resolve_work_description(seed_text, anchor_pack);
|
||||
let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体");
|
||||
let level_name =
|
||||
build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1);
|
||||
let level = PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: level_name.clone(),
|
||||
picture_description,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
};
|
||||
PuzzleResultDraft {
|
||||
work_title,
|
||||
work_description: work_description.clone(),
|
||||
level_name,
|
||||
summary: build_result_summary(anchor_pack),
|
||||
summary: work_description,
|
||||
theme_tags: normalized_tags,
|
||||
forbidden_directives: creator_intent.forbidden_directives.clone(),
|
||||
creator_intent: Some(creator_intent),
|
||||
@@ -782,6 +878,79 @@ pub fn compile_result_draft(
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
levels: vec![level],
|
||||
form_draft: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_form_draft_from_seed(
|
||||
anchor_pack: &PuzzleAnchorPack,
|
||||
seed_text: Option<&str>,
|
||||
) -> PuzzleResultDraft {
|
||||
let form_seed = seed_text.and_then(parse_form_seed_text);
|
||||
build_form_draft_from_parts(
|
||||
anchor_pack,
|
||||
form_seed.as_ref().and_then(|seed| seed.work_title.clone()),
|
||||
form_seed
|
||||
.as_ref()
|
||||
.and_then(|seed| seed.work_description.clone()),
|
||||
form_seed.and_then(|seed| seed.picture_description),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_form_draft_from_parts(
|
||||
anchor_pack: &PuzzleAnchorPack,
|
||||
work_title: Option<String>,
|
||||
work_description: Option<String>,
|
||||
picture_description: Option<String>,
|
||||
) -> PuzzleResultDraft {
|
||||
let work_title = work_title.and_then(|value| normalize_required_string(&value));
|
||||
let work_description = work_description.and_then(|value| normalize_required_string(&value));
|
||||
let picture_description = picture_description.and_then(|value| normalize_required_string(&value));
|
||||
let title_for_tags = work_title.as_deref().unwrap_or("");
|
||||
let picture_for_tags = picture_description.as_deref().unwrap_or("");
|
||||
let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags));
|
||||
if tags.is_empty() {
|
||||
tags = vec!["拼图".to_string(), "插画".to_string(), "清晰构图".to_string()];
|
||||
}
|
||||
let level_name = picture_description
|
||||
.as_deref()
|
||||
.map(|value| build_level_name_from_picture(value, &tags, 1))
|
||||
.or_else(|| work_title.clone())
|
||||
.unwrap_or_else(|| "未命名拼图".to_string());
|
||||
let summary = work_description.clone().unwrap_or_default();
|
||||
let level = PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: level_name.clone(),
|
||||
picture_description: picture_description.clone().unwrap_or_default(),
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
};
|
||||
|
||||
// 中文注释:这是生成前的表单草稿,只用于创作中心恢复和表单回填,不进入发布就绪判断。
|
||||
PuzzleResultDraft {
|
||||
work_title: work_title.clone().unwrap_or_default(),
|
||||
work_description: summary.clone(),
|
||||
level_name,
|
||||
summary,
|
||||
theme_tags: tags,
|
||||
forbidden_directives: Vec::new(),
|
||||
creator_intent: None,
|
||||
anchor_pack: anchor_pack.clone(),
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
levels: vec![level],
|
||||
form_draft: Some(PuzzleFormDraft {
|
||||
work_title,
|
||||
work_description,
|
||||
picture_description,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,13 +1018,155 @@ pub fn apply_selected_candidate(
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft {
|
||||
if draft.work_title.trim().is_empty() {
|
||||
draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name);
|
||||
}
|
||||
if draft.work_description.trim().is_empty() {
|
||||
draft.work_description = draft.summary.clone();
|
||||
}
|
||||
if draft.levels.is_empty() {
|
||||
draft.levels = vec![PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: draft.level_name.clone(),
|
||||
picture_description: fallback_text(
|
||||
&draft.anchor_pack.visual_subject.value,
|
||||
&draft.summary,
|
||||
),
|
||||
candidates: draft.candidates.clone(),
|
||||
selected_candidate_id: draft.selected_candidate_id.clone(),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
cover_asset_id: draft.cover_asset_id.clone(),
|
||||
generation_status: draft.generation_status.clone(),
|
||||
}];
|
||||
}
|
||||
sync_primary_level_fields(&mut draft);
|
||||
draft
|
||||
}
|
||||
|
||||
pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
|
||||
if let Some(primary_level) = draft.levels.first() {
|
||||
draft.level_name = primary_level.level_name.clone();
|
||||
draft.candidates = primary_level.candidates.clone();
|
||||
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
|
||||
draft.cover_image_src = primary_level.cover_image_src.clone();
|
||||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||||
draft.generation_status = primary_level.generation_status.clone();
|
||||
}
|
||||
if draft.work_description.trim().is_empty() {
|
||||
draft.work_description = draft.summary.clone();
|
||||
}
|
||||
draft.summary = draft.work_description.clone();
|
||||
if draft.form_draft.is_some() {
|
||||
draft.form_draft = Some(PuzzleFormDraft {
|
||||
work_title: normalize_required_string(&draft.work_title),
|
||||
work_description: normalize_required_string(&draft.work_description),
|
||||
picture_description: draft
|
||||
.levels
|
||||
.first()
|
||||
.and_then(|level| normalize_required_string(&level.picture_description)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_puzzle_level(
|
||||
draft: &PuzzleResultDraft,
|
||||
level_id: Option<&str>,
|
||||
) -> Option<PuzzleDraftLevel> {
|
||||
let normalized = normalize_puzzle_draft(draft.clone());
|
||||
let requested_level_id = level_id.and_then(normalize_required_string);
|
||||
requested_level_id
|
||||
.as_deref()
|
||||
.and_then(|target_id| {
|
||||
normalized
|
||||
.levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == target_id)
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| normalized.levels.first().cloned())
|
||||
}
|
||||
|
||||
pub fn replace_puzzle_level(
|
||||
draft: &PuzzleResultDraft,
|
||||
level: PuzzleDraftLevel,
|
||||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||
let Some(index) = next_draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|entry| entry.level_id == level.level_id)
|
||||
else {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
};
|
||||
next_draft.levels[index] = level;
|
||||
sync_primary_level_fields(&mut next_draft);
|
||||
Ok(next_draft)
|
||||
}
|
||||
|
||||
pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft {
|
||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||
let next_index = next_draft.levels.len() + 1;
|
||||
let picture_description = next_draft
|
||||
.levels
|
||||
.first()
|
||||
.map(|level| level.picture_description.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| fallback_text(&next_draft.anchor_pack.visual_subject.value, "画面主体"));
|
||||
next_draft.levels.push(PuzzleDraftLevel {
|
||||
level_id: format!("puzzle-level-{next_index}"),
|
||||
level_name: build_level_name_from_picture(
|
||||
picture_description.as_str(),
|
||||
&next_draft.theme_tags,
|
||||
next_index,
|
||||
),
|
||||
picture_description,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
});
|
||||
sync_primary_level_fields(&mut next_draft);
|
||||
next_draft
|
||||
}
|
||||
|
||||
pub fn remove_puzzle_level(
|
||||
draft: &PuzzleResultDraft,
|
||||
level_id: &str,
|
||||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||
if next_draft.levels.len() <= 1 {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
let normalized_level_id =
|
||||
normalize_required_string(level_id).ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
next_draft
|
||||
.levels
|
||||
.retain(|level| level.level_id != normalized_level_id);
|
||||
if next_draft.levels.is_empty() {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
sync_primary_level_fields(&mut next_draft);
|
||||
Ok(next_draft)
|
||||
}
|
||||
|
||||
pub fn build_result_preview(
|
||||
draft: &PuzzleResultDraft,
|
||||
author_display_name: Option<&str>,
|
||||
) -> PuzzleResultPreviewEnvelope {
|
||||
let blockers = validate_publish_requirements(draft, author_display_name);
|
||||
let normalized_draft = normalize_puzzle_draft(draft.clone());
|
||||
if normalized_draft.form_draft.is_some() {
|
||||
return PuzzleResultPreviewEnvelope {
|
||||
draft: normalized_draft,
|
||||
blockers: Vec::new(),
|
||||
quality_findings: Vec::new(),
|
||||
publish_ready: false,
|
||||
};
|
||||
}
|
||||
let blockers = validate_publish_requirements(&normalized_draft, author_display_name);
|
||||
PuzzleResultPreviewEnvelope {
|
||||
draft: draft.clone(),
|
||||
draft: normalized_draft,
|
||||
blockers,
|
||||
quality_findings: Vec::new(),
|
||||
publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(),
|
||||
@@ -866,27 +1177,44 @@ pub fn validate_publish_requirements(
|
||||
draft: &PuzzleResultDraft,
|
||||
author_display_name: Option<&str>,
|
||||
) -> Vec<PuzzleResultPreviewBlocker> {
|
||||
let draft = normalize_puzzle_draft(draft.clone());
|
||||
let mut blockers = Vec::new();
|
||||
if normalize_required_string(&draft.level_name).is_none() {
|
||||
if normalize_required_string(&draft.work_title).is_none() {
|
||||
blockers.push(PuzzleResultPreviewBlocker {
|
||||
id: "missing-level-name".to_string(),
|
||||
code: "MISSING_LEVEL_NAME".to_string(),
|
||||
message: "关卡名不能为空".to_string(),
|
||||
id: "missing-work-title".to_string(),
|
||||
code: "MISSING_WORK_TITLE".to_string(),
|
||||
message: "作品名称不能为空".to_string(),
|
||||
});
|
||||
}
|
||||
if draft
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
if normalize_required_string(&draft.work_description).is_none() {
|
||||
blockers.push(PuzzleResultPreviewBlocker {
|
||||
id: "missing-cover-image".to_string(),
|
||||
code: "MISSING_COVER_IMAGE".to_string(),
|
||||
message: "正式拼图图片尚未确定".to_string(),
|
||||
id: "missing-work-description".to_string(),
|
||||
code: "MISSING_WORK_DESCRIPTION".to_string(),
|
||||
message: "作品描述不能为空".to_string(),
|
||||
});
|
||||
}
|
||||
for level in &draft.levels {
|
||||
if normalize_required_string(&level.level_name).is_none() {
|
||||
blockers.push(PuzzleResultPreviewBlocker {
|
||||
id: format!("missing-level-name-{}", level.level_id),
|
||||
code: "MISSING_LEVEL_NAME".to_string(),
|
||||
message: "关卡名不能为空".to_string(),
|
||||
});
|
||||
}
|
||||
if level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
blockers.push(PuzzleResultPreviewBlocker {
|
||||
id: format!("missing-cover-image-{}", level.level_id),
|
||||
code: "MISSING_COVER_IMAGE".to_string(),
|
||||
message: "正式拼图图片尚未确定".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|
||||
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
|
||||
{
|
||||
@@ -917,18 +1245,22 @@ pub fn create_work_profile(
|
||||
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
|
||||
let author_display_name = normalize_required_string(author_display_name)
|
||||
.ok_or(PuzzleFieldError::MissingAuthorDisplayName)?;
|
||||
let preview = build_result_preview(draft, Some(&author_display_name));
|
||||
let draft = normalize_puzzle_draft(draft.clone());
|
||||
let preview = build_result_preview(&draft, Some(&author_display_name));
|
||||
Ok(PuzzleWorkProfile {
|
||||
work_id,
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
source_session_id,
|
||||
author_display_name,
|
||||
work_title: draft.work_title.clone(),
|
||||
work_description: draft.work_description.clone(),
|
||||
level_name: draft.level_name.clone(),
|
||||
summary: draft.summary.clone(),
|
||||
theme_tags: normalize_theme_tags(draft.theme_tags.clone()),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
cover_asset_id: draft.cover_asset_id.clone(),
|
||||
levels: draft.levels.clone(),
|
||||
publication_status: PuzzlePublicationStatus::Draft,
|
||||
updated_at_micros,
|
||||
published_at_micros: None,
|
||||
@@ -946,14 +1278,18 @@ pub fn publish_work_profile(
|
||||
draft: &PuzzleResultDraft,
|
||||
published_at_micros: i64,
|
||||
) -> Result<PuzzleWorkProfile, PuzzleFieldError> {
|
||||
if !validate_publish_requirements(draft, Some(&profile.author_display_name)).is_empty() {
|
||||
let draft = normalize_puzzle_draft(draft.clone());
|
||||
if !validate_publish_requirements(&draft, Some(&profile.author_display_name)).is_empty() {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
profile.work_title = draft.work_title.clone();
|
||||
profile.work_description = draft.work_description.clone();
|
||||
profile.level_name = draft.level_name.clone();
|
||||
profile.summary = draft.summary.clone();
|
||||
profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone());
|
||||
profile.cover_image_src = draft.cover_image_src.clone();
|
||||
profile.cover_asset_id = draft.cover_asset_id.clone();
|
||||
profile.levels = draft.levels.clone();
|
||||
profile.publication_status = PuzzlePublicationStatus::Published;
|
||||
profile.publish_ready = true;
|
||||
profile.updated_at_micros = published_at_micros;
|
||||
@@ -965,22 +1301,39 @@ pub fn publish_work_profile(
|
||||
/// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。
|
||||
pub fn apply_publish_overrides_to_draft(
|
||||
draft: &PuzzleResultDraft,
|
||||
work_title: Option<String>,
|
||||
work_description: Option<String>,
|
||||
level_name: Option<String>,
|
||||
summary: Option<String>,
|
||||
theme_tags: Option<Vec<String>>,
|
||||
levels: Option<Vec<PuzzleDraftLevel>>,
|
||||
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
|
||||
let mut next_draft = draft.clone();
|
||||
let mut next_draft = normalize_puzzle_draft(draft.clone());
|
||||
|
||||
if let Some(next_work_title) = work_title
|
||||
&& let Some(normalized_work_title) = normalize_required_string(&next_work_title)
|
||||
{
|
||||
next_draft.work_title = normalized_work_title;
|
||||
}
|
||||
|
||||
if let Some(next_work_description) = work_description
|
||||
&& let Some(normalized_work_description) = normalize_required_string(&next_work_description)
|
||||
{
|
||||
next_draft.work_description = normalized_work_description;
|
||||
}
|
||||
|
||||
if let Some(next_level_name) = level_name
|
||||
&& let Some(normalized_level_name) = normalize_required_string(&next_level_name)
|
||||
{
|
||||
next_draft.level_name = normalized_level_name;
|
||||
if let Some(primary_level) = next_draft.levels.first_mut() {
|
||||
primary_level.level_name = normalized_level_name;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(next_summary) = summary
|
||||
&& let Some(normalized_summary) = normalize_required_string(&next_summary)
|
||||
{
|
||||
next_draft.summary = normalized_summary;
|
||||
next_draft.work_description = normalized_summary;
|
||||
}
|
||||
|
||||
if let Some(next_theme_tags) = theme_tags {
|
||||
@@ -993,9 +1346,41 @@ pub fn apply_publish_overrides_to_draft(
|
||||
next_draft.theme_tags = normalized_theme_tags;
|
||||
}
|
||||
|
||||
if let Some(next_levels) = levels {
|
||||
let normalized_levels = normalize_puzzle_levels(next_levels, &next_draft.theme_tags)?;
|
||||
next_draft.levels = normalized_levels;
|
||||
}
|
||||
|
||||
sync_primary_level_fields(&mut next_draft);
|
||||
Ok(next_draft)
|
||||
}
|
||||
|
||||
pub fn normalize_puzzle_levels(
|
||||
levels: Vec<PuzzleDraftLevel>,
|
||||
theme_tags: &[String],
|
||||
) -> Result<Vec<PuzzleDraftLevel>, PuzzleFieldError> {
|
||||
let mut normalized_levels = Vec::new();
|
||||
for (index, mut level) in levels.into_iter().enumerate() {
|
||||
let level_id = normalize_required_string(&level.level_id)
|
||||
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
|
||||
let picture_description = normalize_required_string(&level.picture_description)
|
||||
.unwrap_or_else(|| format!("第{}关画面", index + 1));
|
||||
let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| {
|
||||
build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1)
|
||||
});
|
||||
level.level_id = level_id;
|
||||
level.level_name = level_name;
|
||||
level.picture_description = picture_description;
|
||||
level.generation_status = normalize_required_string(&level.generation_status)
|
||||
.unwrap_or_else(|| "idle".to_string());
|
||||
normalized_levels.push(level);
|
||||
}
|
||||
if normalized_levels.is_empty() {
|
||||
return Err(PuzzleFieldError::InvalidOperation);
|
||||
}
|
||||
Ok(normalized_levels)
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
|
||||
if cleared_level_count >= 3 { 4 } else { 3 }
|
||||
}
|
||||
@@ -1683,25 +2068,54 @@ fn infer_tags_and_forbidden(source: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_form_seed_text(source: &str) -> Option<(String, String)> {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct PuzzleFormSeedParts {
|
||||
work_title: Option<String>,
|
||||
work_description: Option<String>,
|
||||
picture_description: Option<String>,
|
||||
}
|
||||
|
||||
impl PuzzleFormSeedParts {
|
||||
fn has_any_value(&self) -> bool {
|
||||
self.work_title.is_some()
|
||||
|| self.work_description.is_some()
|
||||
|| self.picture_description.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_form_seed_text(source: &str) -> Option<PuzzleFormSeedParts> {
|
||||
let normalized_source = source.trim();
|
||||
let title_marker = "拼图标题:";
|
||||
let description_marker = "画面描述:";
|
||||
let title_start = normalized_source.find(title_marker)? + title_marker.len();
|
||||
let description_start = normalized_source.find(description_marker)?;
|
||||
if description_start <= title_start {
|
||||
if normalized_source.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = normalize_required_string(&normalized_source[title_start..description_start]);
|
||||
let picture_description = normalize_required_string(
|
||||
&normalized_source[description_start + description_marker.len()..],
|
||||
);
|
||||
let title_marker = if normalized_source.contains("作品名称:") {
|
||||
"作品名称:"
|
||||
} else {
|
||||
"拼图标题:"
|
||||
};
|
||||
let parts = PuzzleFormSeedParts {
|
||||
work_title: extract_form_seed_value(normalized_source, title_marker),
|
||||
work_description: extract_form_seed_value(normalized_source, "作品描述:"),
|
||||
picture_description: extract_form_seed_value(normalized_source, "画面描述:"),
|
||||
};
|
||||
|
||||
match (title, picture_description) {
|
||||
(Some(title), Some(picture_description)) => Some((title, picture_description)),
|
||||
_ => None,
|
||||
}
|
||||
parts.has_any_value().then_some(parts)
|
||||
}
|
||||
|
||||
fn extract_form_seed_value(source: &str, marker: &str) -> Option<String> {
|
||||
let value_start = source.find(marker)? + marker.len();
|
||||
let value_end = ["作品名称:", "拼图标题:", "作品描述:", "画面描述:"]
|
||||
.into_iter()
|
||||
.filter(|next_marker| *next_marker != marker)
|
||||
.filter_map(|next_marker| {
|
||||
source[value_start..]
|
||||
.find(next_marker)
|
||||
.map(|index| value_start + index)
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(source.len());
|
||||
normalize_required_string(&source[value_start..value_end])
|
||||
}
|
||||
|
||||
fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String {
|
||||
@@ -1777,6 +2191,22 @@ fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String {
|
||||
seed_text
|
||||
.and_then(parse_form_seed_text)
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.work_description
|
||||
.or(parts.picture_description)
|
||||
.or(parts.work_title)
|
||||
})
|
||||
.unwrap_or_else(|| build_result_summary(anchor_pack))
|
||||
}
|
||||
|
||||
fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String {
|
||||
fallback_text(&anchor_pack.theme_promise.value, "奇景拼图")
|
||||
}
|
||||
|
||||
fn extract_forbidden_directive(source: &str) -> String {
|
||||
if let Some((_, tail)) = source.split_once(';') {
|
||||
return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string());
|
||||
@@ -1784,20 +2214,24 @@ fn extract_forbidden_directive(source: &str) -> String {
|
||||
"禁止标题字".to_string()
|
||||
}
|
||||
|
||||
fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String {
|
||||
if is_form_anchor_pack(anchor_pack)
|
||||
&& let Some(title) = normalize_required_string(&anchor_pack.theme_promise.value)
|
||||
{
|
||||
return title;
|
||||
fn build_level_name_from_picture(
|
||||
picture_description: &str,
|
||||
normalized_tags: &[String],
|
||||
level_index: usize,
|
||||
) -> String {
|
||||
let source = normalize_required_string(picture_description).unwrap_or_default();
|
||||
for keyword in [
|
||||
"猫", "狗", "神庙", "遗迹", "森林", "雨夜", "城市", "机械", "海", "花", "雪", "龙", "灯",
|
||||
"塔",
|
||||
] {
|
||||
if source.contains(keyword) {
|
||||
return format!("{keyword}画面");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tag) = normalized_tags.first() {
|
||||
return format!("{tag}拼图");
|
||||
return format!("{tag}第{level_index}关");
|
||||
}
|
||||
if let Some(subject) = normalize_required_string(&anchor_pack.visual_subject.value) {
|
||||
return subject.chars().take(8).collect::<String>();
|
||||
}
|
||||
"奇景拼图".to_string()
|
||||
format!("第{level_index}关")
|
||||
}
|
||||
|
||||
fn fallback_text(value: &str, fallback: &str) -> String {
|
||||
@@ -2432,15 +2866,28 @@ mod tests {
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
source_session_id: None,
|
||||
author_display_name: "作者".to_string(),
|
||||
work_title: format!("{profile_id} 作品"),
|
||||
work_description: "summary".to_string(),
|
||||
level_name: format!("{profile_id} 关"),
|
||||
summary: "summary".to_string(),
|
||||
theme_tags: tags.into_iter().map(|value| value.to_string()).collect(),
|
||||
cover_image_src: Some("/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
levels: vec![PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: format!("{profile_id} 关"),
|
||||
picture_description: "summary".to_string(),
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
}],
|
||||
publication_status: PuzzlePublicationStatus::Published,
|
||||
updated_at_micros: 100,
|
||||
published_at_micros: Some(100),
|
||||
play_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
publish_ready: true,
|
||||
@@ -2488,10 +2935,16 @@ mod tests {
|
||||
#[test]
|
||||
fn form_seed_locks_title_and_picture_description_as_primary_anchors() {
|
||||
let anchor_pack = infer_anchor_pack(
|
||||
"拼图标题:暖灯猫街\n画面描述:一只猫在雨夜灯牌下回头。",
|
||||
"作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。",
|
||||
None,
|
||||
);
|
||||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||
let draft = compile_result_draft_from_seed(
|
||||
&anchor_pack,
|
||||
&[],
|
||||
Some(
|
||||
"作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。",
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街");
|
||||
assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked);
|
||||
@@ -2500,8 +2953,14 @@ mod tests {
|
||||
anchor_pack.visual_subject.status,
|
||||
PuzzleAnchorStatus::Locked
|
||||
);
|
||||
assert_eq!(draft.level_name, "暖灯猫街");
|
||||
assert_eq!(draft.summary, "一只猫在雨夜灯牌下回头。");
|
||||
assert_eq!(draft.work_title, "暖灯猫街");
|
||||
assert_eq!(draft.work_description, "一套雨夜猫街主题拼图。");
|
||||
assert_eq!(draft.summary, "一套雨夜猫街主题拼图。");
|
||||
assert_eq!(draft.level_name, "猫画面");
|
||||
assert_eq!(
|
||||
draft.levels[0].picture_description,
|
||||
"一只猫在雨夜灯牌下回头。"
|
||||
);
|
||||
assert_eq!(
|
||||
draft
|
||||
.creator_intent
|
||||
@@ -2525,9 +2984,10 @@ mod tests {
|
||||
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
|
||||
);
|
||||
assert_eq!(
|
||||
draft.summary,
|
||||
draft.levels[0].picture_description,
|
||||
"一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。"
|
||||
);
|
||||
assert_eq!(draft.summary, draft.work_description);
|
||||
assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪"));
|
||||
assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜"));
|
||||
}
|
||||
@@ -2909,6 +3369,8 @@ mod tests {
|
||||
|
||||
let updated = apply_publish_overrides_to_draft(
|
||||
&draft,
|
||||
Some("雨夜猫塔作品".to_string()),
|
||||
Some("作品描述。".to_string()),
|
||||
Some("雨夜猫塔".to_string()),
|
||||
Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()),
|
||||
Some(vec![
|
||||
@@ -2916,10 +3378,12 @@ mod tests {
|
||||
"猫咪".to_string(),
|
||||
"遗迹".to_string(),
|
||||
]),
|
||||
None,
|
||||
)
|
||||
.expect("publish overrides should succeed");
|
||||
|
||||
assert_eq!(updated.level_name, "雨夜猫塔");
|
||||
assert_eq!(updated.work_title, "雨夜猫塔作品");
|
||||
assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。");
|
||||
assert_eq!(
|
||||
updated.theme_tags,
|
||||
@@ -2935,9 +3399,16 @@ mod tests {
|
||||
fn apply_publish_overrides_rejects_invalid_tag_count() {
|
||||
let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市"));
|
||||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||
let error =
|
||||
apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()]))
|
||||
.expect_err("invalid tag count should fail");
|
||||
let error = apply_publish_overrides_to_draft(
|
||||
&draft,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(vec!["蒸汽".to_string()]),
|
||||
None,
|
||||
)
|
||||
.expect_err("invalid tag count should fail");
|
||||
|
||||
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user