Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 13:59:28 +08:00
119 changed files with 10021 additions and 2230 deletions

View File

@@ -33,6 +33,12 @@ pub struct AuthStoreSnapshotProcedureResult {
pub error_message: Option<String>,
}
fn normalize_user_account_tags(
tags: Option<Vec<String>>,
) -> Result<Vec<String>, module_runtime::RuntimeProfileFieldError> {
module_runtime::normalize_profile_user_tags(tags.unwrap_or_default())
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotImportRecord {
pub imported_user_count: u32,
@@ -210,8 +216,10 @@ fn import_auth_store_snapshot_tx(
password_hash: stored_user.password_hash,
password_login_enabled: stored_user.password_login_enabled,
token_version: user.token_version,
user_tags: module_runtime::normalize_profile_user_tags(user.user_tags)
.map_err(|error| error.to_string())?,
user_tags: Some(
module_runtime::normalize_profile_user_tags(user.user_tags)
.map_err(|error| error.to_string())?,
),
});
imported_user_count += 1;
@@ -341,7 +349,8 @@ fn export_auth_store_snapshot_from_tables_tx(
binding_status: user.binding_status,
wechat_bound: user.wechat_bound,
token_version: user.token_version,
user_tags: user.user_tags,
user_tags: normalize_user_account_tags(user.user_tags)
.map_err(|error| error.to_string())?,
};
users_by_username.insert(
user.username,

View File

@@ -28,7 +28,8 @@ pub struct UserAccount {
pub(crate) password_hash: String,
pub(crate) password_login_enabled: bool,
pub(crate) token_version: u64,
pub(crate) user_tags: Vec<String>,
#[default(None::<Vec<String>>)]
pub(crate) user_tags: Option<Vec<String>>,
}
#[spacetimedb::table(

View File

@@ -443,17 +443,23 @@ fn compile_match3d_draft_tx(
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
validate_config(&config)?;
let tags = input
.tags_json
.as_deref()
.map(parse_tags)
.transpose()?
.filter(|items| !items.is_empty())
.unwrap_or_else(|| default_tags(&config.theme_text));
let game_name =
clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text));
let summary_text = clean_optional(&input.summary_text)
.unwrap_or_else(|| format!("{}主题的经典消除玩法。", config.theme_text));
let existing_work = ctx
.db
.match3d_work_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id);
let tags = resolve_compile_tags(
input.tags_json.as_deref(),
existing_work.as_ref(),
config.theme_text.as_str(),
)?;
let game_name = resolve_compile_game_name(
&input.game_name,
existing_work.as_ref(),
config.theme_text.as_str(),
);
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
let draft = Match3DDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: game_name.clone(),
@@ -464,6 +470,31 @@ fn compile_match3d_draft_tx(
difficulty: config.difficulty,
};
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let previous_publication_status = existing_work
.as_ref()
.map(|work| work.publication_status.clone())
.unwrap_or_else(|| MATCH3D_PUBLICATION_DRAFT.to_string());
let previous_play_count = existing_work
.as_ref()
.map(|work| work.play_count)
.unwrap_or(0);
let previous_published_at = existing_work.as_ref().and_then(|work| work.published_at);
let cover_image_src = resolve_compile_optional_text(
&input.cover_image_src,
existing_work
.as_ref()
.map(|work| work.cover_image_src.as_str()),
);
let cover_asset_id = resolve_compile_optional_text(
&input.cover_asset_id,
existing_work
.as_ref()
.map(|work| work.cover_asset_id.as_str()),
);
let work = Match3DWorkProfileRow {
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
@@ -473,18 +504,16 @@ fn compile_match3d_draft_tx(
theme_text: config.theme_text.clone(),
summary_text,
tags_json: to_json_string(&tags),
cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(),
cover_asset_id: clean_optional(&input.cover_asset_id).unwrap_or_default(),
cover_image_src,
cover_asset_id,
clear_count: config.clear_count,
difficulty: config.difficulty,
config_json: to_json_string(&config),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
publication_status: previous_publication_status,
play_count: previous_play_count,
updated_at: compiled_at,
published_at: None,
generated_item_assets_json: normalize_generated_item_assets_json(
input.generated_item_assets_json.as_deref(),
)?,
published_at: previous_published_at,
generated_item_assets_json,
};
upsert_work(ctx, work);
replace_session(
@@ -1255,6 +1284,68 @@ fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<St
Ok(Some(to_json_string(&parsed)))
}
fn resolve_generated_item_assets_json_for_compile(
input: Option<&str>,
existing_work: Option<&Match3DWorkProfileRow>,
) -> Result<Option<String>, String> {
if input.is_some() {
return normalize_generated_item_assets_json(input);
}
Ok(existing_work.and_then(|work| work.generated_item_assets_json.clone()))
}
fn resolve_compile_tags(
input_tags_json: Option<&str>,
existing_work: Option<&Match3DWorkProfileRow>,
theme_text: &str,
) -> Result<Vec<String>, String> {
input_tags_json
.or_else(|| existing_work.map(|work| work.tags_json.as_str()))
.map(parse_tags)
.transpose()
.map(|tags| {
tags.filter(|items| !items.is_empty())
.unwrap_or_else(|| default_tags(theme_text))
})
}
fn resolve_compile_game_name(
input_game_name: &Option<String>,
existing_work: Option<&Match3DWorkProfileRow>,
theme_text: &str,
) -> String {
clean_optional(input_game_name)
.or_else(|| {
existing_work
.map(|work| clean_string(&work.game_name, ""))
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| format!("{theme_text}抓大鹅"))
}
fn resolve_compile_summary_text(
input_summary_text: &Option<String>,
existing_work: Option<&Match3DWorkProfileRow>,
) -> String {
input_summary_text
.as_deref()
.map(str::trim)
.map(str::to_string)
.or_else(|| existing_work.map(|work| work.summary_text.clone()))
.unwrap_or_default()
.to_string()
}
fn resolve_compile_optional_text(input: &Option<String>, existing: Option<&str>) -> String {
clean_optional(input)
.or_else(|| {
existing
.map(|value| clean_string(value, ""))
.filter(|value| !value.is_empty())
})
.unwrap_or_default()
}
fn default_tags(theme_text: &str) -> Vec<String> {
normalize_tags(vec![
theme_text.to_string(),
@@ -1660,6 +1751,100 @@ mod tests {
);
}
#[test]
fn match3d_compile_without_asset_payload_preserves_existing_generated_assets() {
let existing = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: String::new(),
tags_json: "[\"水果\"]".to_string(),
cover_image_src: String::new(),
cover_asset_id: String::new(),
clear_count: 3,
difficulty: 3,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 3,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
};
let preserved =
resolve_generated_item_assets_json_for_compile(None, Some(&existing)).unwrap();
assert_eq!(
preserved.as_deref(),
existing.generated_item_assets_json.as_deref()
);
}
#[test]
fn match3d_compile_without_metadata_payload_preserves_existing_metadata() {
let existing = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "果园大鹅宴".to_string(),
theme_text: "水果".to_string(),
summary_text: "保留描述".to_string(),
tags_json: "[\"水果\",\"轻量休闲\"]".to_string(),
cover_image_src: "/cover.png".to_string(),
cover_asset_id: "cover-asset-1".to_string(),
clear_count: 3,
difficulty: 3,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 3,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: None,
};
let input_game_name = None;
let input_summary_text = None;
let input_tags_json = None;
let input_cover_image_src = None;
let input_cover_asset_id = None;
let tags = resolve_compile_tags(input_tags_json, Some(&existing), "水果").unwrap();
let game_name = resolve_compile_game_name(&input_game_name, Some(&existing), "水果");
let summary_text = resolve_compile_summary_text(&input_summary_text, Some(&existing));
let cover_image_src =
resolve_compile_optional_text(&input_cover_image_src, Some(&existing.cover_image_src));
let cover_asset_id =
resolve_compile_optional_text(&input_cover_asset_id, Some(&existing.cover_asset_id));
assert_eq!(game_name, "果园大鹅宴");
assert_eq!(summary_text, "保留描述");
assert_eq!(tags, vec!["水果".to_string(), "轻量休闲".to_string()]);
assert_eq!(cover_image_src, "/cover.png");
assert_eq!(cover_asset_id, "cover-asset-1");
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {

View File

@@ -1140,7 +1140,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
// 中文注释:账号标签字段晚于认证表加入,旧迁移包默认无标签。
object
.entry("user_tags".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
.or_insert(serde_json::Value::Null);
}
}
if table_name == "profile_invite_code" {
@@ -1149,10 +1149,6 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("metadata_json".to_string())
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
// 中文注释:邀请码授予标签字段晚于邀请表加入,旧迁移包默认不授予标签。
object
.entry("granted_user_tags".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
}
}
if table_name == "big_fish_creation_session" {

View File

@@ -1070,6 +1070,7 @@ fn select_puzzle_cover_image_tx(
level_name: target_level.level_name,
picture_description: target_level.picture_description,
picture_reference: target_level.picture_reference,
background_music: target_level.background_music,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
cover_image_src: selected_level_draft.cover_image_src,
@@ -2343,6 +2344,7 @@ fn build_profile_levels_from_row(
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
picture_reference: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: row.cover_image_src.clone(),
@@ -3248,7 +3250,11 @@ fn list_puzzle_leaderboard_entries(
.user_account()
.user_id()
.find(&row.user_id)
.map(|account| visible_runtime_profile_user_tags(&account.user_tags))
.map(|account| {
visible_runtime_profile_user_tags(
account.user_tags.as_deref().unwrap_or_default(),
)
})
.unwrap_or_default(),
rank: index as u32 + 1,
nickname: row.nickname,
@@ -3353,6 +3359,7 @@ mod tests {
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
picture_reference: None,
background_music: None,
candidates: candidates.clone(),
selected_candidate_id: None,
cover_image_src: None,

View File

@@ -31,9 +31,7 @@ pub struct CreationEntryTypeConfig {
}
#[spacetimedb::procedure]
pub fn get_creation_entry_config(
ctx: &mut ProcedureContext,
) -> CreationEntryConfigProcedureResult {
pub fn get_creation_entry_config(ctx: &mut ProcedureContext) -> CreationEntryConfigProcedureResult {
match ctx.try_with_tx(|tx| get_or_seed_creation_entry_config_snapshot(tx)) {
Ok(record) => CreationEntryConfigProcedureResult {
ok: true,
@@ -180,18 +178,129 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
ctx.db.creation_entry_type_config().insert(seed);
}
}
migrate_visual_novel_entry_from_old_open_default(ctx, now);
}
fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: Timestamp) {
let id = "visual-novel".to_string();
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
return;
};
// 中文注释:只纠偏旧默认种子,不覆盖后台入口开关里后续手动调整的视觉小说配置。
let still_old_default = row.title == "视觉小说"
&& row.subtitle == "分支叙事体验"
&& row.badge == "可创建"
&& row.image_src == "/creation-type-references/visual-novel.webp"
&& row.visible
&& row.open
&& row.sort_order == 60;
if !still_old_default {
return;
}
ctx.db
.creation_entry_type_config()
.id()
.update(CreationEntryTypeConfig {
badge: "敬请期待".to_string(),
open: false,
updated_at: now,
..row
});
}
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
vec![
build_creation_entry_type_seed("rpg", "文字冒险", "经典 RPG 体验", "内测", "/creation-type-references/rpg.webp", false, true, 10, now),
build_creation_entry_type_seed("big-fish", "摸鱼", "轻量闯关玩法", "可创建", "/creation-type-references/big-fish.webp", false, true, 20, now),
build_creation_entry_type_seed("puzzle", "拼图", "拼图关卡创作", "可创建", "/creation-type-references/puzzle.webp", true, true, 30, now),
build_creation_entry_type_seed("match3d", "抓大鹅", "3D 消除关卡", "可创建", "/creation-type-references/match3d.webp", true, true, 40, now),
build_creation_entry_type_seed("square-hole", "方洞", "形状投放挑战", "可创建", "/creation-type-references/square-hole.webp", true, true, 50, now),
build_creation_entry_type_seed("visual-novel", "视觉小说", "分支叙事体验", "可创建", "/creation-type-references/visual-novel.webp", true, true, 60, now),
build_creation_entry_type_seed("airp", "AI RPG", "原生角色扮演", "即将开放", "/creation-type-references/airp.webp", true, false, 70, now),
build_creation_entry_type_seed("creative-agent", "智能体创作", "对话式创作实验", "内测", "/creation-type-references/creative-agent.webp", false, true, 80, now),
build_creation_entry_type_seed(
"rpg",
"文字冒险",
"经典 RPG 体验",
"内测",
"/creation-type-references/rpg.webp",
false,
true,
10,
now,
),
build_creation_entry_type_seed(
"big-fish",
"摸鱼",
"轻量闯关玩法",
"可创建",
"/creation-type-references/big-fish.webp",
false,
true,
20,
now,
),
build_creation_entry_type_seed(
"puzzle",
"拼图",
"拼图关卡创作",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
30,
now,
),
build_creation_entry_type_seed(
"match3d",
"抓大鹅",
"3D 消除关卡",
"可创建",
"/creation-type-references/match3d.webp",
true,
true,
40,
now,
),
build_creation_entry_type_seed(
"square-hole",
"方洞",
"形状投放挑战",
"可创建",
"/creation-type-references/square-hole.webp",
false,
true,
50,
now,
),
build_creation_entry_type_seed(
"visual-novel",
"视觉小说",
"分支叙事体验",
"敬请期待",
"/creation-type-references/visual-novel.webp",
true,
false,
60,
now,
),
build_creation_entry_type_seed(
"airp",
"AI RPG",
"原生角色扮演",
"即将开放",
"/creation-type-references/airp.webp",
true,
false,
70,
now,
),
build_creation_entry_type_seed(
"creative-agent",
"智能体创作",
"对话式创作实验",
"内测",
"/creation-type-references/creative-agent.webp",
false,
true,
80,
now,
),
]
}

View File

@@ -192,7 +192,6 @@ pub struct ProfileInviteCode {
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
pub(crate) granted_user_tags: Vec<String>,
}
#[spacetimedb::table(
@@ -2201,7 +2200,8 @@ fn redeem_profile_referral_invite_code_record(
if inviter_code.user_id == invitee_user_id {
return Err("不能填写自己的邀请码".to_string());
}
let granted_user_tags = inviter_code.granted_user_tags.clone();
let invite_metadata_user_tags =
profile_invite_code_metadata_user_tags(&inviter_code.metadata_json)?;
let invitee_balance_after = apply_profile_wallet_delta(
ctx,
@@ -2248,7 +2248,7 @@ fn redeem_profile_referral_invite_code_record(
invitee_reward_granted: true,
bound_at,
});
merge_user_account_tags(ctx, &invitee_user_id, granted_user_tags)?;
merge_user_account_tags(ctx, &invitee_user_id, invite_metadata_user_tags)?;
Ok(RuntimeReferralRedeemSnapshot {
center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id),
@@ -2422,7 +2422,6 @@ fn admin_upsert_profile_invite_code_record(
input.admin_user_id,
input.invite_code,
input.metadata_json,
input.granted_user_tags,
input.starts_at_micros,
input.expires_at_micros,
input.updated_at_micros,
@@ -2459,7 +2458,6 @@ fn admin_upsert_profile_invite_code_record(
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
granted_user_tags: validated_input.granted_user_tags,
});
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
@@ -2476,7 +2474,6 @@ fn admin_upsert_profile_invite_code_record(
expires_at: validated_input
.expires_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
granted_user_tags: validated_input.granted_user_tags,
});
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
@@ -2603,7 +2600,6 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
updated_at: ctx.timestamp,
starts_at: None,
expires_at: None,
granted_user_tags: Vec::new(),
})
}
@@ -2622,14 +2618,33 @@ fn merge_user_account_tags(
return Err("用户不存在".to_string());
};
account.user_tags.extend(granted_tags);
let mut next_tags = account.user_tags.take().unwrap_or_default();
next_tags.extend(granted_tags);
account.user_tags =
normalize_profile_user_tags(account.user_tags).map_err(|error| error.to_string())?;
Some(normalize_profile_user_tags(next_tags).map_err(|error| error.to_string())?);
ctx.db.user_account().user_id().delete(&account.user_id);
ctx.db.user_account().insert(account);
Ok(())
}
fn profile_invite_code_metadata_user_tags(metadata_json: &str) -> Result<Vec<String>, String> {
let metadata = serde_json::from_str::<JsonValue>(metadata_json)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata.to_string())?;
let tags = metadata
.get("userTags")
.or_else(|| metadata.get("user_tags"))
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
normalize_profile_user_tags(tags).map_err(|error| error.to_string())
}
fn validate_profile_invite_code_redeem_time(
invite_code: &ProfileInviteCode,
now_micros: i64,
@@ -3654,7 +3669,6 @@ fn build_profile_invite_code_snapshot_from_row(
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
granted_user_tags: row.granted_user_tags.clone(),
starts_at_micros: row
.starts_at
.map(|value| value.to_micros_since_unix_epoch()),