1
This commit is contained in:
@@ -57,6 +57,8 @@ pub(super) struct AuthUserSnapshot {
|
||||
pub(super) binding_status: String,
|
||||
pub(super) wechat_bound: bool,
|
||||
pub(super) token_version: u64,
|
||||
#[serde(default)]
|
||||
pub(super) user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
|
||||
@@ -210,6 +210,8 @@ 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())?,
|
||||
});
|
||||
imported_user_count += 1;
|
||||
|
||||
@@ -339,6 +341,7 @@ 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,
|
||||
};
|
||||
users_by_username.insert(
|
||||
user.username,
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct UserAccount {
|
||||
pub(crate) password_hash: String,
|
||||
pub(crate) password_login_enabled: bool,
|
||||
pub(crate) token_version: u64,
|
||||
#[default(Vec::<String>::new())]
|
||||
pub(crate) user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
|
||||
@@ -19,6 +19,8 @@ use module_match3d::{
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_match3d_agent_session(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -439,7 +441,7 @@ fn compile_match3d_draft_tx(
|
||||
) -> Result<Match3DAgentSessionSnapshot, String> {
|
||||
require_non_empty(&input.profile_id, "match3d profile_id")?;
|
||||
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let config = parse_config(&session.config_json)?;
|
||||
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
|
||||
validate_config(&config)?;
|
||||
let tags = input
|
||||
.tags_json
|
||||
@@ -480,6 +482,9 @@ fn compile_match3d_draft_tx(
|
||||
play_count: 0,
|
||||
updated_at: compiled_at,
|
||||
published_at: None,
|
||||
generated_item_assets_json: normalize_generated_item_assets_json(
|
||||
input.generated_item_assets_json.as_deref(),
|
||||
)?,
|
||||
};
|
||||
upsert_work(ctx, work);
|
||||
replace_session(
|
||||
@@ -514,9 +519,12 @@ fn update_match3d_work_tx(
|
||||
let tags = parse_tags(&input.tags_json)?;
|
||||
let config = Match3DCreatorConfigSnapshot {
|
||||
theme_text: clean_string(&input.theme_text, "经典消除"),
|
||||
reference_image_src: parse_config_or_default(¤t.config_json).reference_image_src,
|
||||
..parse_config_or_default(¤t.config_json)
|
||||
};
|
||||
let config = Match3DCreatorConfigSnapshot {
|
||||
clear_count: input.clear_count,
|
||||
difficulty: input.difficulty,
|
||||
..config
|
||||
};
|
||||
validate_config(&config)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
@@ -538,6 +546,7 @@ fn update_match3d_work_tx(
|
||||
play_count: current.play_count,
|
||||
updated_at,
|
||||
published_at: current.published_at,
|
||||
generated_item_assets_json: current.generated_item_assets_json.clone(),
|
||||
};
|
||||
let snapshot = build_work_snapshot(&next)?;
|
||||
replace_work(ctx, ¤t, next);
|
||||
@@ -944,6 +953,9 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapsho
|
||||
published_at_micros: row
|
||||
.published_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
generated_item_assets_json: normalize_generated_item_assets_json(
|
||||
row.generated_item_assets_json.as_deref(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1157,6 +1169,7 @@ fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow {
|
||||
play_count: row.play_count,
|
||||
updated_at: row.updated_at,
|
||||
published_at: row.published_at,
|
||||
generated_item_assets_json: row.generated_item_assets_json.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,6 +1202,9 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
|
||||
reference_image_src: None,
|
||||
clear_count: 12,
|
||||
difficulty: 3,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,15 +1218,43 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
|
||||
config.difficulty = config
|
||||
.difficulty
|
||||
.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
|
||||
config.asset_style_id = normalize_optional_text(config.asset_style_id);
|
||||
config.asset_style_label = normalize_optional_text(config.asset_style_label);
|
||||
config.asset_style_prompt = normalize_optional_text(config.asset_style_prompt);
|
||||
config
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_match3d_generated_item_config(
|
||||
mut config: Match3DCreatorConfigSnapshot,
|
||||
) -> Match3DCreatorConfigSnapshot {
|
||||
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
|
||||
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
|
||||
config
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
|
||||
let parsed = parse_json::<Vec<String>>(value, "match3d tags_json")?;
|
||||
Ok(normalize_tags(parsed))
|
||||
}
|
||||
|
||||
fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed = parse_json::<serde_json::Value>(trimmed, "match3d generated_item_assets_json")?;
|
||||
if !parsed.is_array() {
|
||||
return Err("match3d generated_item_assets_json 必须是数组".to_string());
|
||||
}
|
||||
Ok(Some(to_json_string(&parsed)))
|
||||
}
|
||||
|
||||
fn default_tags(theme_text: &str) -> Vec<String> {
|
||||
normalize_tags(vec![
|
||||
theme_text.to_string(),
|
||||
@@ -1557,17 +1601,81 @@ mod tests {
|
||||
reference_image_src: None,
|
||||
clear_count: 4,
|
||||
difficulty: 3,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
}),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 0,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
published_at: None,
|
||||
generated_item_assets_json: None,
|
||||
};
|
||||
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
|
||||
assert_eq!(snapshot.total_item_count, 12);
|
||||
assert_eq!(snapshot.items.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_snapshot_keeps_generated_item_assets_json() {
|
||||
let work = 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: 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: 0,
|
||||
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 snapshot = build_work_snapshot(&work).expect("work snapshot should build");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.generated_item_assets_json.as_deref(),
|
||||
Some(
|
||||
r#"[{"imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
|
||||
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
|
||||
theme_text: "水果".to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: 20,
|
||||
difficulty: 8,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
});
|
||||
|
||||
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
|
||||
assert_eq!(config.difficulty, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_domain_click_bridge_clears_three_items() {
|
||||
let snapshot = Match3DRunSnapshot {
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct Match3DWorkProfileRow {
|
||||
pub(crate) play_count: u32,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
pub(crate) published_at: Option<Timestamp>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct Match3DDraftCompileInput {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub compiled_at_micros: i64,
|
||||
pub generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -223,6 +224,12 @@ pub struct Match3DCreatorConfigSnapshot {
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
#[serde(default)]
|
||||
pub asset_style_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_style_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub asset_style_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -287,6 +294,7 @@ pub struct Match3DWorkSnapshot {
|
||||
pub play_count: u32,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
pub generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1134,6 +1134,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("avatar_url".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
// 中文注释:账号标签字段晚于认证表加入,旧迁移包默认无标签。
|
||||
object
|
||||
.entry("user_tags".to_string())
|
||||
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
|
||||
}
|
||||
}
|
||||
if table_name == "profile_invite_code" {
|
||||
@@ -1142,6 +1146,10 @@ 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" {
|
||||
@@ -1214,6 +1222,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
.or_insert(fallback_description);
|
||||
}
|
||||
}
|
||||
if table_name == "match3d_work_profile" {
|
||||
if let Some(object) = next_value.as_object_mut() {
|
||||
// 中文注释:抓大鹅生成素材字段晚于基础作品表加入,旧迁移包按未生成素材兼容。
|
||||
object
|
||||
.entry("generated_item_assets_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
next_value
|
||||
}
|
||||
|
||||
|
||||
@@ -3218,6 +3218,13 @@ fn list_puzzle_leaderboard_entries(
|
||||
.take(limit)
|
||||
.enumerate()
|
||||
.map(|(index, row)| PuzzleLeaderboardEntry {
|
||||
visible_tags: ctx
|
||||
.db
|
||||
.user_account()
|
||||
.user_id()
|
||||
.find(&row.user_id)
|
||||
.map(|account| visible_runtime_profile_user_tags(&account.user_tags))
|
||||
.unwrap_or_default(),
|
||||
rank: index as u32 + 1,
|
||||
nickname: row.nickname,
|
||||
elapsed_ms: row.best_elapsed_ms,
|
||||
@@ -3483,12 +3490,14 @@ mod tests {
|
||||
rank: 0,
|
||||
nickname: "玩家 B".to_string(),
|
||||
elapsed_ms: 5200,
|
||||
visible_tags: Vec::new(),
|
||||
is_current_player: false,
|
||||
},
|
||||
PuzzleLeaderboardEntry {
|
||||
rank: 0,
|
||||
nickname: "玩家 A".to_string(),
|
||||
elapsed_ms: 3100,
|
||||
visible_tags: Vec::new(),
|
||||
is_current_player: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -191,6 +191,8 @@ pub struct ProfileInviteCode {
|
||||
pub(crate) starts_at: Option<Timestamp>,
|
||||
#[default(None::<Timestamp>)]
|
||||
pub(crate) expires_at: Option<Timestamp>,
|
||||
#[default(Vec::<String>::new())]
|
||||
pub(crate) granted_user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -2031,6 +2033,16 @@ fn redeem_profile_referral_invite_code_record(
|
||||
let invitee_user_id = validated_input.user_id;
|
||||
let invite_code = validated_input.invite_code;
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.user_account()
|
||||
.user_id()
|
||||
.find(&invitee_user_id)
|
||||
.is_none()
|
||||
{
|
||||
return Err("用户不存在".to_string());
|
||||
}
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.profile_referral_relation()
|
||||
@@ -2051,6 +2063,7 @@ 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 invitee_balance_after = apply_profile_wallet_delta(
|
||||
ctx,
|
||||
@@ -2097,6 +2110,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)?;
|
||||
|
||||
Ok(RuntimeReferralRedeemSnapshot {
|
||||
center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id),
|
||||
@@ -2270,6 +2284,7 @@ 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,
|
||||
@@ -2306,6 +2321,7 @@ 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));
|
||||
}
|
||||
@@ -2322,6 +2338,7 @@ 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))
|
||||
}
|
||||
@@ -2448,9 +2465,33 @@ 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(),
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_user_account_tags(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
granted_tags: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let granted_tags =
|
||||
normalize_profile_user_tags(granted_tags).map_err(|error| error.to_string())?;
|
||||
if granted_tags.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(mut account) = ctx.db.user_account().user_id().find(&user_id.to_string()) else {
|
||||
return Err("用户不存在".to_string());
|
||||
};
|
||||
|
||||
account.user_tags.extend(granted_tags);
|
||||
account.user_tags =
|
||||
normalize_profile_user_tags(account.user_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 validate_profile_invite_code_redeem_time(
|
||||
invite_code: &ProfileInviteCode,
|
||||
now_micros: i64,
|
||||
@@ -3475,6 +3516,7 @@ 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()),
|
||||
|
||||
Reference in New Issue
Block a user