This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions

View File

@@ -1,7 +1,8 @@
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
use crate::runtime::{
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
upsert_profile_played_work, PublicWorkLikeRecordInput,
};
use crate::*;
@@ -123,6 +124,32 @@ pub fn record_big_fish_play(
}
}
#[spacetimedb::procedure]
pub fn record_big_fish_like(
ctx: &mut ProcedureContext,
input: BigFishWorkLikeRecordInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn remix_big_fish_work(
ctx: &mut ProcedureContext,
@@ -712,6 +739,60 @@ pub(crate) fn record_big_fish_play_tx(
list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input())
}
pub(crate) fn record_big_fish_like_tx(
ctx: &ReducerContext,
input: BigFishWorkLikeRecordInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
let session_id = input.session_id.trim();
let user_id = input.user_id.trim();
if session_id.is_empty() || user_id.is_empty() {
return Err("big_fish like 参数不能为空".to_string());
}
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&session_id.to_string())
.filter(|row| row.stage == BigFishCreationStage::Published)
.ok_or_else(|| "big_fish 已发布作品不存在,无法点赞".to_string())?;
let inserted_like = record_public_work_like(
ctx,
PublicWorkLikeRecordInput {
source_type: "big-fish".to_string(),
owner_user_id: session.owner_user_id.clone(),
profile_id: session.session_id.clone(),
user_id: user_id.to_string(),
liked_at_micros: input.liked_at_micros,
},
)?;
if inserted_like {
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
play_count: session.play_count,
remix_count: session.remix_count,
like_count: session.like_count.saturating_add(1),
published_at: session.published_at,
created_at: session.created_at,
updated_at: liked_at,
};
replace_big_fish_session(ctx, &session, next_session);
}
list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input())
}
fn remix_big_fish_work_tx(
ctx: &ReducerContext,
input: BigFishWorkRemixInput,

View File

@@ -2217,6 +2217,27 @@ pub fn record_custom_world_profile_play(
}
}
#[spacetimedb::procedure]
pub fn record_custom_world_profile_like(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfileLikeRecordInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry: Some(gallery_entry),
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_custom_world_works(
ctx: &mut ProcedureContext,
@@ -2856,7 +2877,9 @@ fn list_custom_world_profile_snapshots(
Ok(entries)
}
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
fn build_custom_world_profile_list_snapshot(
row: &CustomWorldProfile,
) -> CustomWorldProfileSnapshot {
let mut snapshot = build_custom_world_profile_snapshot(row);
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
snapshot
@@ -3256,6 +3279,89 @@ fn record_custom_world_profile_play_record(
))
}
fn record_custom_world_profile_like_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfileLikeRecordInput,
) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> {
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() {
return Err("custom_world like 参数不能为空".to_string());
}
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
let inserted_like = record_public_work_like(
ctx,
crate::runtime::PublicWorkLikeRecordInput {
source_type: "custom-world".to_string(),
owner_user_id: existing.owner_user_id.clone(),
profile_id: existing.profile_id.clone(),
user_id: user_id.to_string(),
liked_at_micros: input.liked_at_micros,
},
)?;
if !inserted_like {
let gallery_entry = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&existing.profile_id)
.filter(|row| row.owner_user_id == existing.owner_user_id)
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
.ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?;
return Ok((build_custom_world_profile_snapshot(&existing), gallery_entry));
}
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: existing.publication_status,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count.saturating_add(1),
author_display_name: existing.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: liked_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
Ok((
build_custom_world_profile_snapshot(&inserted),
gallery_entry,
))
}
fn list_custom_world_work_snapshots(
ctx: &ReducerContext,
input: CustomWorldWorksListInput,

View File

@@ -123,6 +123,7 @@ macro_rules! migration_tables {
profile_referral_relation,
profile_played_world,
public_work_play_daily_stat,
public_work_like,
profile_membership,
profile_recharge_order,
profile_save_archive,
@@ -794,6 +795,25 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("like_count".to_string())
.or_insert_with(|| serde_json::Value::from(0));
// 中文注释:拼图多关卡字段晚于旧作品表加入,旧迁移包留空并由读取层补出首关。
object
.entry("levels_json".to_string())
.or_insert_with(|| serde_json::Value::from(""));
// 中文注释:作品名称/描述从旧关卡名/画面摘要拆出,旧行保留旧值做兼容回填。
let fallback_title = object
.get("level_name")
.cloned()
.unwrap_or_else(|| serde_json::Value::from(""));
object
.entry("work_title".to_string())
.or_insert(fallback_title);
let fallback_description = object
.get("summary")
.cloned()
.unwrap_or_else(|| serde_json::Value::from(""));
object
.entry("work_description".to_string())
.or_insert(fallback_description);
}
}
next_value

View File

@@ -1,22 +1,26 @@
use crate::runtime::{
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
upsert_profile_played_work, PublicWorkLikeRecordInput,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
PuzzleLeaderboardSubmitInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile,
replace_puzzle_level, resolve_puzzle_grid_size, select_next_profile, selected_puzzle_level,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -72,11 +76,14 @@ pub struct PuzzleWorkProfileRow {
owner_user_id: String,
source_session_id: Option<String>,
author_display_name: String,
work_title: String,
work_description: String,
level_name: String,
summary: String,
theme_tags_json: String,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
levels_json: String,
publication_status: PuzzlePublicationStatus,
play_count: u32,
anchor_pack_json: String,
@@ -225,6 +232,27 @@ pub fn compile_puzzle_agent_draft(
}
}
/// 保存拼图入口表单草稿。
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
#[spacetimedb::procedure]
pub fn save_puzzle_form_draft(
ctx: &mut ProcedureContext,
input: PuzzleFormDraftSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_generated_images(
ctx: &mut ProcedureContext,
@@ -393,6 +421,25 @@ pub fn get_puzzle_gallery_detail(
}
}
#[spacetimedb::procedure]
pub fn record_puzzle_work_like(
ctx: &mut ProcedureContext,
input: PuzzleWorkLikeInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn remix_puzzle_work(
ctx: &mut ProcedureContext,
@@ -572,6 +619,7 @@ fn create_puzzle_agent_session_tx(
ensure_message_missing(ctx, &input.welcome_message_id)?;
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text));
let initial_form_draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text));
ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
@@ -581,7 +629,7 @@ fn create_puzzle_agent_session_tx(
progress_percent: 0,
stage: PuzzleAgentStage::CollectingAnchors,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: None,
draft_json: Some(serialize_json(&initial_form_draft)),
last_assistant_reply: Some(input.welcome_message_text.clone()),
published_profile_id: None,
created_at,
@@ -595,6 +643,13 @@ fn create_puzzle_agent_session_tx(
text: input.welcome_message_text,
created_at,
});
upsert_puzzle_draft_work_profile(
ctx,
&input.session_id,
&input.owner_user_id,
&initial_form_draft,
input.created_at_micros,
)?;
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
@@ -730,9 +785,12 @@ fn compile_puzzle_agent_draft_tx(
input: PuzzleDraftCompileInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
if row.seed_text.trim().is_empty() {
return Err("请先填写拼图作品信息".to_string());
}
let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text));
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft(&anchor_pack, &messages);
let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text));
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
// 每次编译结果页时同步 upsert保证后续能按 source_session_id 恢复聊天。
upsert_puzzle_draft_work_profile(
@@ -772,6 +830,59 @@ fn compile_puzzle_agent_draft_tx(
)
}
fn save_puzzle_form_draft_tx(
ctx: &TxContext,
input: PuzzleFormDraftSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
if row.stage != PuzzleAgentStage::CollectingAnchors {
return get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
);
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text));
let draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text));
upsert_puzzle_draft_work_profile(
ctx,
&input.session_id,
&input.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: input.seed_text,
current_turn: row.current_turn,
progress_percent: 0,
stage: PuzzleAgentStage::CollectingAnchors,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: row.last_assistant_reply.clone(),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn save_puzzle_generated_images_tx(
ctx: &TxContext,
input: PuzzleGeneratedImagesSaveInput,
@@ -783,18 +894,22 @@ fn save_puzzle_generated_images_tx(
if candidates.is_empty() {
return Err("拼图候选图不能为空".to_string());
}
replace_generated_candidate(&mut draft, candidates);
draft.generation_status = "ready".to_string();
if let Some(selected) = draft
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
replace_generated_candidate(&mut next_level.candidates, candidates);
next_level.generation_status = "ready".to_string();
if let Some(selected) = next_level
.candidates
.iter()
.find(|entry| entry.selected)
.cloned()
{
draft.selected_candidate_id = Some(selected.candidate_id);
draft.cover_image_src = Some(selected.image_src);
draft.cover_asset_id = Some(selected.asset_id);
next_level.selected_candidate_id = Some(selected.candidate_id);
next_level.cover_image_src = Some(selected.image_src);
next_level.cover_asset_id = Some(selected.asset_id);
}
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
@@ -843,8 +958,38 @@ fn select_puzzle_cover_image_tx(
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let draft = deserialize_draft_required(&row.draft_json)?;
let draft =
apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?;
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let level_draft = PuzzleResultDraft {
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
level_name: target_level.level_name.clone(),
summary: draft.summary.clone(),
theme_tags: draft.theme_tags.clone(),
forbidden_directives: draft.forbidden_directives.clone(),
creator_intent: draft.creator_intent.clone(),
anchor_pack: draft.anchor_pack.clone(),
candidates: target_level.candidates.clone(),
selected_candidate_id: target_level.selected_candidate_id.clone(),
cover_image_src: target_level.cover_image_src.clone(),
cover_asset_id: target_level.cover_asset_id.clone(),
generation_status: target_level.generation_status.clone(),
levels: vec![target_level.clone()],
form_draft: None,
};
let selected_level_draft = apply_selected_candidate(level_draft, &input.candidate_id)
.map_err(|error| error.to_string())?;
let next_level = module_puzzle::PuzzleDraftLevel {
level_id: target_level.level_id,
level_name: target_level.level_name,
picture_description: target_level.picture_description,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
cover_image_src: selected_level_draft.cover_image_src,
cover_asset_id: selected_level_draft.cover_asset_id,
generation_status: selected_level_draft.generation_status,
};
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
@@ -894,9 +1039,12 @@ fn publish_puzzle_work_tx(
let draft = deserialize_draft_required(&row.draft_json)?;
let draft = apply_publish_overrides_to_draft(
&draft,
input.work_title.clone(),
input.work_description.clone(),
input.level_name.clone(),
input.summary.clone(),
input.theme_tags.clone(),
deserialize_optional_levels_input(input.levels_json.as_deref())?,
)
.map_err(|error| error.to_string())?;
let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id);
@@ -981,23 +1129,58 @@ fn update_puzzle_work_tx(
if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
return Err("拼图标签数量不合法".to_string());
}
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
.map(|levels| {
normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string())
})
.transpose()?
.unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default());
let preview_draft = PuzzleResultDraft {
work_title: input.work_title.clone(),
work_description: input.work_description.clone(),
level_name: input.level_name.clone(),
summary: input.summary.clone(),
theme_tags: theme_tags.clone(),
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
candidates: levels
.first()
.map(|level| level.candidates.clone())
.unwrap_or_default(),
selected_candidate_id: levels
.first()
.and_then(|level| level.selected_candidate_id.clone()),
cover_image_src: input.cover_image_src.clone(),
cover_asset_id: input.cover_asset_id.clone(),
generation_status: levels
.first()
.map(|level| level.generation_status.clone())
.unwrap_or_else(|| "idle".to_string()),
levels: levels.clone(),
form_draft: None,
};
let next_row = PuzzleWorkProfileRow {
profile_id: row.profile_id.clone(),
work_id: row.work_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
work_title: input.work_title,
work_description: input.work_description,
level_name: input.level_name,
summary: input.summary,
theme_tags_json: serialize_json(&theme_tags),
cover_image_src: input.cover_image_src,
cover_asset_id: input.cover_asset_id,
levels_json: serialize_json(&levels),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
publish_ready: build_result_preview(&preview_draft, Some(&row.author_display_name))
.publish_ready,
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
published_at: row.published_at,
@@ -1103,6 +1286,76 @@ fn get_puzzle_gallery_detail_tx(
)
}
fn record_puzzle_work_like_tx(
ctx: &TxContext,
input: PuzzleWorkLikeInput,
) -> Result<PuzzleWorkProfile, String> {
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if profile_id.is_empty() || user_id.is_empty() {
return Err("拼图 like 参数不能为空".to_string());
}
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?;
let inserted_like = record_public_work_like(
ctx,
PublicWorkLikeRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
user_id: user_id.to_string(),
liked_at_micros: input.liked_at_micros,
},
)?;
let current_row = if inserted_like {
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
let next_row = PuzzleWorkProfileRow {
profile_id: row.profile_id.clone(),
work_id: row.work_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count.saturating_add(1),
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
created_at: row.created_at,
updated_at: liked_at,
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图点赞更新失败".to_string())?
} else {
row
};
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
&current_row,
ctx.timestamp.to_micros_since_unix_epoch(),
)
}
fn remix_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkRemixInput,
@@ -1154,11 +1407,14 @@ fn remix_puzzle_work_tx(
owner_user_id: source.owner_user_id.clone(),
source_session_id: source.source_session_id.clone(),
author_display_name: source.author_display_name.clone(),
work_title: source.work_title.clone(),
work_description: source.work_description.clone(),
level_name: source.level_name.clone(),
summary: source.summary.clone(),
theme_tags_json: source.theme_tags_json.clone(),
cover_image_src: source.cover_image_src.clone(),
cover_asset_id: source.cover_asset_id.clone(),
levels_json: source.levels_json.clone(),
publication_status: source.publication_status,
play_count: source.play_count,
remix_count: source.remix_count.saturating_add(1),
@@ -1172,6 +1428,8 @@ fn remix_puzzle_work_tx(
);
let draft = PuzzleResultDraft {
work_title: source_profile.work_title.clone(),
work_description: source_profile.work_description.clone(),
level_name: source_profile.level_name.clone(),
summary: source_profile.summary.clone(),
theme_tags: source_profile.theme_tags.clone(),
@@ -1183,6 +1441,8 @@ fn remix_puzzle_work_tx(
cover_image_src: source_profile.cover_image_src.clone(),
cover_asset_id: source_profile.cover_asset_id.clone(),
generation_status: "ready".to_string(),
levels: source_profile.levels.clone(),
form_draft: None,
};
ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow {
session_id: target_session_id.to_string(),
@@ -1212,11 +1472,14 @@ fn remix_puzzle_work_tx(
owner_user_id: target_owner_user_id.to_string(),
source_session_id: Some(target_session_id.to_string()),
author_display_name: input.author_display_name.trim().to_string(),
work_title: source_profile.work_title,
work_description: source_profile.work_description,
level_name: source_profile.level_name,
summary: source_profile.summary,
theme_tags_json: serialize_json(&source_profile.theme_tags),
cover_image_src: source_profile.cover_image_src,
cover_asset_id: source_profile.cover_asset_id,
levels_json: serialize_json(&source_profile.levels),
publication_status: PuzzlePublicationStatus::Draft,
play_count: 0,
remix_count: 0,
@@ -1259,10 +1522,14 @@ fn start_puzzle_run_tx(
if entry_profile_row.publication_status != PuzzlePublicationStatus::Published {
return Err("入口拼图作品未发布".to_string());
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? {
entry_profile = profile_for_single_level(&entry_profile, &level);
}
let started_at_ms = micros_to_millis(input.started_at_micros);
let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
.map_err(|error| error.to_string())?;
let mut run =
module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
.map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1502,13 +1769,11 @@ fn use_puzzle_runtime_prop_tx(
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = match input.prop_kind.as_str() {
"freezeTime" | "freeze_time" => {
module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?
}
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,
@@ -1551,12 +1816,20 @@ fn submit_puzzle_leaderboard_entry_tx(
.current_level
.as_ref()
.ok_or_else(|| "拼图关卡不存在".to_string())?;
if current_level.profile_id != input.profile_id {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
if input.profile_id.trim().is_empty() {
return Err("提交成绩的拼图作品不能为空".to_string());
}
if current_level.grid_size != input.grid_size {
if input.grid_size != 3 && input.grid_size != 4 {
return Err("提交成绩的网格规格无效".to_string());
}
let matches_service_level =
current_level.profile_id == input.profile_id && current_level.grid_size == input.grid_size;
if current_level.profile_id == input.profile_id && current_level.grid_size != input.grid_size {
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
}
if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
let nickname = input.nickname.trim();
if nickname.is_empty() {
@@ -1588,20 +1861,42 @@ fn submit_puzzle_leaderboard_entry_tx(
&input.owner_user_id,
10,
);
if let Some(level) = run.current_level.as_mut() {
if matches_service_level {
if let Some(level) = run.current_level.as_mut() {
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
// 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。
level.status = PuzzleRuntimeLevelStatus::Cleared;
level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros));
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
}
run.cleared_level_count = run.cleared_level_count.max(run.current_level_index);
} else {
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
// 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。
level.status = PuzzleRuntimeLevelStatus::Cleared;
level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros));
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
// 前端通过 local-next-level 推进到第二关后,服务端旧 run 可能仍停在上一关。
// 此时只返回真实榜单,前端会把榜单合并回当前本地关卡,不能用旧棋盘覆盖前端状态。
log::info!(
"puzzle leaderboard submitted for frontend-only level: run_id={}, service_profile_id={}, submitted_profile_id={}",
input.run_id,
current_level.profile_id,
input.profile_id
);
}
run.cleared_level_count = run.cleared_level_count.max(run.current_level_index);
run.leaderboard_entries = leaderboard_entries;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) -> bool {
run.recommended_next_profile_id
.as_ref()
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|| run
.played_profile_ids
.iter()
.any(|played_profile_id| played_profile_id == profile_id)
}
fn build_puzzle_agent_session_snapshot(
ctx: &TxContext,
row: &PuzzleAgentSessionRow,
@@ -1658,11 +1953,22 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
work_title: if row.work_title.trim().is_empty() {
row.level_name.clone()
} else {
row.work_title.clone()
},
work_description: if row.work_description.trim().is_empty() {
row.summary.clone()
} else {
row.work_description.clone()
},
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags: deserialize_theme_tags(&row.theme_tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels: build_profile_levels_from_row(row)?,
publication_status: row.publication_status,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
@@ -1677,6 +1983,64 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
})
}
fn build_profile_levels_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<Vec<module_puzzle::PuzzleDraftLevel>, String> {
let levels = deserialize_levels_json(&row.levels_json)?;
if !levels.is_empty() {
return Ok(levels);
}
Ok(vec![module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
generation_status: if row.cover_image_src.is_some() {
"ready".to_string()
} else {
"idle".to_string()
},
}])
}
fn selected_profile_level(
profile: &PuzzleWorkProfile,
level_id: Option<&str>,
) -> Result<Option<module_puzzle::PuzzleDraftLevel>, String> {
let Some(level_id) = level_id.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}) else {
return Ok(None);
};
profile
.levels
.iter()
.find(|level| level.level_id == level_id)
.cloned()
.map(Some)
.ok_or_else(|| "入口拼图关卡不存在".to_string())
}
fn profile_for_single_level(
profile: &PuzzleWorkProfile,
level: &module_puzzle::PuzzleDraftLevel,
) -> PuzzleWorkProfile {
let mut next_profile = profile.clone();
next_profile.level_name = level.level_name.clone();
next_profile.cover_image_src = level.cover_image_src.clone();
next_profile.cover_asset_id = level.cover_asset_id.clone();
next_profile.levels = vec![level.clone()];
next_profile
}
fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) {
let stable_suffix = session_id
.strip_prefix("puzzle-session-")
@@ -1706,6 +2070,20 @@ fn upsert_puzzle_draft_work_profile(
if existing.publication_status == PuzzlePublicationStatus::Published {
return Ok(());
}
let mut profile = create_work_profile(
work_id,
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
existing.author_display_name.clone(),
draft,
updated_at_micros,
)
.map_err(|error| error.to_string())?;
profile.play_count = existing.play_count;
profile.remix_count = existing.remix_count;
profile.like_count = existing.like_count;
return upsert_puzzle_work_profile(ctx, profile);
}
let profile = create_work_profile(
work_id,
@@ -1869,11 +2247,14 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
owner_user_id: profile.owner_user_id,
source_session_id: profile.source_session_id,
author_display_name: profile.author_display_name,
work_title: profile.work_title,
work_description: profile.work_description,
level_name: profile.level_name,
summary: profile.summary,
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&profile.levels),
publication_status: profile.publication_status,
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
@@ -1898,11 +2279,14 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
owner_user_id: profile.owner_user_id,
source_session_id: profile.source_session_id,
author_display_name: profile.author_display_name,
work_title: profile.work_title,
work_description: profile.work_description,
level_name: profile.level_name,
summary: profile.summary,
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&profile.levels),
publication_status: profile.publication_status,
play_count: profile.play_count,
remix_count: profile.remix_count,
@@ -1988,11 +2372,14 @@ fn increment_puzzle_profile_play_count(
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count.saturating_add(1),
remix_count: row.remix_count,
@@ -2029,11 +2416,11 @@ fn upsert_puzzle_profile_played_work(
}
fn replace_generated_candidate(
draft: &mut PuzzleResultDraft,
candidates_slot: &mut Vec<PuzzleGeneratedImageCandidate>,
candidates: Vec<PuzzleGeneratedImageCandidate>,
) {
// 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。
draft.candidates = candidates
*candidates_slot = candidates
.into_iter()
.take(1)
.map(|mut candidate| {
@@ -2197,7 +2584,11 @@ fn deserialize_anchor_pack(value: &str) -> Result<PuzzleAnchorPack, String> {
fn deserialize_optional_draft(value: &Option<String>) -> Result<Option<PuzzleResultDraft>, String> {
value
.as_ref()
.map(|raw| json_from_str(raw).map_err(|error| format!("拼图 draft JSON 非法: {error}")))
.map(|raw| {
json_from_str(raw)
.map(normalize_puzzle_draft)
.map_err(|error| format!("拼图 draft JSON 非法: {error}"))
})
.transpose()
}
@@ -2209,6 +2600,22 @@ fn deserialize_theme_tags(value: &str) -> Result<Vec<String>, String> {
json_from_str(value).map_err(|error| format!("拼图 theme_tags JSON 非法: {error}"))
}
fn deserialize_levels_json(value: &str) -> Result<Vec<module_puzzle::PuzzleDraftLevel>, String> {
if value.trim().is_empty() {
return Ok(Vec::new());
}
json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}"))
}
fn deserialize_optional_levels_input(
value: Option<&str>,
) -> Result<Option<Vec<module_puzzle::PuzzleDraftLevel>>, String> {
value
.map(|raw| deserialize_levels_json(raw))
.transpose()
.map(|levels| levels.filter(|items| !items.is_empty()))
}
fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
json_from_str(value).map_err(|error| format!("拼图 run snapshot JSON 非法: {error}"))
}
@@ -2217,8 +2624,8 @@ fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
mod tests {
use super::*;
use module_puzzle::{
PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack,
recommendation_score, tag_similarity_score,
PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft,
empty_anchor_pack, recommendation_score, tag_similarity_score,
};
#[test]
@@ -2248,6 +2655,20 @@ mod tests {
.expect("candidates should build");
let draft = apply_selected_candidate(
PuzzleResultDraft {
levels: vec![module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: draft.level_name.clone(),
picture_description: draft
.levels
.first()
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
candidates: candidates.clone(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
}],
candidates,
..draft
},

View File

@@ -139,6 +139,22 @@ pub struct PublicWorkPlayDailyStat {
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = public_work_like,
index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])),
index(accessor = by_public_work_like_user, btree(columns = [user_id]))
)]
pub struct PublicWorkLike {
#[primary_key]
pub(crate) like_id: String,
// 中文注释source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) user_id: String,
pub(crate) liked_at: Timestamp,
}
pub(crate) struct ProfilePlayedWorkUpsertInput {
pub(crate) user_id: String,
pub(crate) world_key: String,
@@ -157,6 +173,14 @@ pub(crate) struct PublicWorkPlayRecordInput {
pub(crate) played_at_micros: i64,
}
pub(crate) struct PublicWorkLikeRecordInput {
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) user_id: String,
pub(crate) liked_at_micros: i64,
}
#[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership {
#[primary_key]
@@ -778,6 +802,39 @@ pub(crate) fn record_public_work_play(
Ok(())
}
pub(crate) fn record_public_work_like(
ctx: &ReducerContext,
input: PublicWorkLikeRecordInput,
) -> Result<bool, String> {
let source_type = input.source_type.trim();
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if source_type.is_empty()
|| owner_user_id.is_empty()
|| profile_id.is_empty()
|| user_id.is_empty()
{
return Err("public_work_like 参数不能为空".to_string());
}
let like_id = build_public_work_like_id(source_type, profile_id, user_id);
if ctx.db.public_work_like().like_id().find(&like_id).is_some() {
return Ok(false);
}
ctx.db.public_work_like().insert(PublicWorkLike {
like_id,
source_type: source_type.to_string(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
user_id: user_id.to_string(),
liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros),
});
Ok(true)
}
pub(crate) fn count_recent_public_work_plays(
ctx: &ReducerContext,
source_type: &str,
@@ -817,6 +874,10 @@ fn build_public_work_play_daily_stat_id(
format!("{source_type}:{profile_id}:{played_day}")
}
fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String {
format!("{source_type}:{profile_id}:{user_id}")
}
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
if ctx
.db