diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 94ee65649..c0691341c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -357,10 +357,6 @@ pub mod delete_visual_novel_work_procedure; pub mod delete_wooden_fish_work_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod drop_square_hole_shape_procedure; -pub mod duplicate_phone_account_merge_group_type; -pub mod duplicate_phone_account_merge_input_type; -pub mod duplicate_phone_account_merge_procedure_result_type; -pub mod duplicate_phone_account_merge_table_stat_type; pub mod editor_asset_create_input_type; pub mod editor_asset_delete_input_type; pub mod editor_asset_folder_create_input_type; @@ -636,7 +632,6 @@ pub mod match_3_d_work_snapshot_type; pub mod match_3_d_work_update_input_type; pub mod match_3_d_works_list_input_type; pub mod match_3_d_works_procedure_result_type; -pub mod merge_duplicate_phone_accounts_procedure; pub mod npc_battle_interaction_procedure_result_type; pub mod npc_battle_interaction_result_type; pub mod npc_interaction_battle_mode_type; @@ -1590,10 +1585,6 @@ pub use delete_visual_novel_work_procedure::delete_visual_novel_work; pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drop_square_hole_shape_procedure::drop_square_hole_shape; -pub use duplicate_phone_account_merge_group_type::DuplicatePhoneAccountMergeGroup; -pub use duplicate_phone_account_merge_input_type::DuplicatePhoneAccountMergeInput; -pub use duplicate_phone_account_merge_procedure_result_type::DuplicatePhoneAccountMergeProcedureResult; -pub use duplicate_phone_account_merge_table_stat_type::DuplicatePhoneAccountMergeTableStat; pub use editor_asset_create_input_type::EditorAssetCreateInput; pub use editor_asset_delete_input_type::EditorAssetDeleteInput; pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput; @@ -1869,7 +1860,6 @@ pub use match_3_d_work_snapshot_type::Match3DWorkSnapshot; pub use match_3_d_work_update_input_type::Match3DWorkUpdateInput; pub use match_3_d_works_list_input_type::Match3DWorksListInput; pub use match_3_d_works_procedure_result_type::Match3DWorksProcedureResult; -pub use merge_duplicate_phone_accounts_procedure::merge_duplicate_phone_accounts; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; pub use npc_battle_interaction_result_type::NpcBattleInteractionResult; pub use npc_interaction_battle_mode_type::NpcInteractionBattleMode; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_group_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_group_type.rs deleted file mode 100644 index bf4068886..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_group_type.rs +++ /dev/null @@ -1,17 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub struct DuplicatePhoneAccountMergeGroup { - pub phone_e_164: String, - pub target_user_id: String, - pub source_user_ids: Vec, -} - -impl __sdk::InModule for DuplicatePhoneAccountMergeGroup { - type Module = super::RemoteModule; -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_input_type.rs deleted file mode 100644 index a745722a0..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_input_type.rs +++ /dev/null @@ -1,18 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::duplicate_phone_account_merge_group_type::DuplicatePhoneAccountMergeGroup; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub struct DuplicatePhoneAccountMergeInput { - pub groups: Vec, - pub dry_run: bool, -} - -impl __sdk::InModule for DuplicatePhoneAccountMergeInput { - type Module = super::RemoteModule; -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_procedure_result_type.rs deleted file mode 100644 index 46943598a..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_procedure_result_type.rs +++ /dev/null @@ -1,20 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::duplicate_phone_account_merge_table_stat_type::DuplicatePhoneAccountMergeTableStat; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub struct DuplicatePhoneAccountMergeProcedureResult { - pub ok: bool, - pub dry_run: bool, - pub table_stats: Vec, - pub error_message: Option, -} - -impl __sdk::InModule for DuplicatePhoneAccountMergeProcedureResult { - type Module = super::RemoteModule; -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_table_stat_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_table_stat_type.rs deleted file mode 100644 index e4be6e03c..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_table_stat_type.rs +++ /dev/null @@ -1,18 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub struct DuplicatePhoneAccountMergeTableStat { - pub table_name: String, - pub updated_row_count: u64, - pub deleted_row_count: u64, - pub inserted_row_count: u64, -} - -impl __sdk::InModule for DuplicatePhoneAccountMergeTableStat { - type Module = super::RemoteModule; -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/merge_duplicate_phone_accounts_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/merge_duplicate_phone_accounts_procedure.rs deleted file mode 100644 index 983b1e070..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/merge_duplicate_phone_accounts_procedure.rs +++ /dev/null @@ -1,59 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -use super::duplicate_phone_account_merge_input_type::DuplicatePhoneAccountMergeInput; -use super::duplicate_phone_account_merge_procedure_result_type::DuplicatePhoneAccountMergeProcedureResult; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct MergeDuplicatePhoneAccountsArgs { - pub input: DuplicatePhoneAccountMergeInput, -} - -impl __sdk::InModule for MergeDuplicatePhoneAccountsArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `merge_duplicate_phone_accounts`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait merge_duplicate_phone_accounts { - fn merge_duplicate_phone_accounts(&self, input: DuplicatePhoneAccountMergeInput) { - self.merge_duplicate_phone_accounts_then(input, |_, _| {}); - } - - fn merge_duplicate_phone_accounts_then( - &self, - input: DuplicatePhoneAccountMergeInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl merge_duplicate_phone_accounts for super::RemoteProcedures { - fn merge_duplicate_phone_accounts_then( - &self, - input: DuplicatePhoneAccountMergeInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, DuplicatePhoneAccountMergeProcedureResult>( - "merge_duplicate_phone_accounts", - MergeDuplicatePhoneAccountsArgs { input }, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 3cd2f1cc6..ef6bffff4 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -4,7 +4,7 @@ use crate::*; use serde::{Deserialize, Serialize}; use spacetimedb::sats::de::serde::DeserializeWrapper; use spacetimedb::sats::ser::serde::SerializeWrapper; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use crate::bark_battle::tables::{ bark_battle_draft_config, bark_battle_leaderboard_entry, bark_battle_personal_best_projection, @@ -156,35 +156,6 @@ pub struct DatabaseMigrationOperatorProcedureResult { pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct DuplicatePhoneAccountMergeInput { - pub groups: Vec, - pub dry_run: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct DuplicatePhoneAccountMergeGroup { - pub phone_e164: String, - pub target_user_id: String, - pub source_user_ids: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct DuplicatePhoneAccountMergeTableStat { - pub table_name: String, - pub updated_row_count: u64, - pub deleted_row_count: u64, - pub inserted_row_count: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct DuplicatePhoneAccountMergeProcedureResult { - pub ok: bool, - pub dry_run: bool, - pub table_stats: Vec, - pub error_message: Option, -} - #[derive(Serialize, Deserialize)] struct MigrationFile { schema_version: u32, @@ -551,1604 +522,6 @@ pub fn clear_database_migration_import_chunks( } } -#[spacetimedb::procedure] -pub fn merge_duplicate_phone_accounts( - ctx: &mut ProcedureContext, - input: DuplicatePhoneAccountMergeInput, -) -> DuplicatePhoneAccountMergeProcedureResult { - let caller = ctx.sender(); - let dry_run = input.dry_run; - match ctx.try_with_tx(|tx| merge_duplicate_phone_accounts_tx(tx, caller, input.clone())) { - Ok(table_stats) => DuplicatePhoneAccountMergeProcedureResult { - ok: true, - dry_run, - table_stats, - error_message: None, - }, - Err(error) => DuplicatePhoneAccountMergeProcedureResult { - ok: false, - dry_run, - table_stats: Vec::new(), - error_message: Some(error), - }, - } -} - -#[derive(Clone)] -struct NormalizedDuplicatePhoneAccountMergeGroup { - phone_e164: String, - target_user_id: String, - source_user_ids: Vec, -} - -type DuplicatePhoneMergeStats = HashMap; - -macro_rules! move_user_pk_merge_table_by_json { - ($ctx:expr, $stats:expr, $dry_run:expr, $source:expr, $target:expr, $table:ident) => {{ - let mut source_row = None; - let mut target_exists = false; - for row in $ctx.db.$table().iter() { - let value = row_to_json(&row)?; - let user_id = value.get("user_id").and_then(serde_json::Value::as_str); - if user_id == Some($source.as_str()) { - source_row = Some(row); - } else if user_id == Some($target.as_str()) { - target_exists = true; - } - } - - if let Some(row) = source_row { - if target_exists { - bump_duplicate_phone_merge_stat($stats, stringify!($table), 0, 1, 0); - if !$dry_run { - $ctx.db.$table().delete(row); - } - } else { - let mut next_value = row_to_json(&row)?; - let Some(object) = next_value.as_object_mut() else { - return Err(format!("{} 序列化结果不是对象", stringify!($table))); - }; - object.insert( - "user_id".to_string(), - serde_json::Value::String($target.to_string()), - ); - let mut warnings = Vec::new(); - let next_row = row_from_json(stringify!($table), &next_value, &mut warnings)?; - bump_duplicate_phone_merge_stat($stats, stringify!($table), 1, 1, 1); - if !$dry_run { - $ctx.db.$table().delete(row); - $ctx.db.$table().insert(next_row); - } - } - } - Ok::<(), String>(()) - }}; -} - -macro_rules! rebind_exact_account_refs_in_table { - ($ctx:expr, $stats:expr, $dry_run:expr, $source:expr, $target:expr, $table:ident) => {{ - let mut changed_rows = Vec::new(); - for row in $ctx.db.$table().iter() { - let mut next_value = row_to_json(&row)?; - let changed_value_count = - replace_exact_account_refs_in_json_value(&mut next_value, $source, $target)?; - if changed_value_count > 0 { - let mut warnings = Vec::new(); - let next_row = row_from_json(stringify!($table), &next_value, &mut warnings)?; - changed_rows.push((row, next_row)); - } - } - let changed_row_count = changed_rows.len() as u64; - if changed_row_count > 0 { - bump_duplicate_phone_merge_stat($stats, stringify!($table), changed_row_count, 0, 0); - if !$dry_run { - for (old_row, next_row) in changed_rows { - $ctx.db.$table().delete(old_row); - $ctx.db.$table().insert(next_row); - } - } - } - Ok::<(), String>(()) - }}; -} - -fn merge_duplicate_phone_accounts_tx( - ctx: &ReducerContext, - caller: Identity, - input: DuplicatePhoneAccountMergeInput, -) -> Result, String> { - require_migration_operator(ctx, caller)?; - let groups = normalize_duplicate_phone_account_merge_groups(input.groups)?; - let mut stats = DuplicatePhoneMergeStats::new(); - - for group in groups { - merge_one_duplicate_phone_account_group(ctx, &mut stats, input.dry_run, &group)?; - } - - Ok(finish_duplicate_phone_merge_stats(stats)) -} - -fn normalize_duplicate_phone_account_merge_groups( - groups: Vec, -) -> Result, String> { - if groups.is_empty() { - return Err("手机号账号合并计划不能为空".to_string()); - } - - let mut seen_sources = HashSet::new(); - let mut normalized = Vec::new(); - for group in groups { - let phone_e164 = group.phone_e164.trim().to_string(); - let target_user_id = group.target_user_id.trim().to_string(); - if phone_e164.is_empty() || target_user_id.is_empty() { - return Err("手机号账号合并计划包含空手机号或空目标用户".to_string()); - } - - let mut source_user_ids = Vec::new(); - let mut seen_group_sources = HashSet::new(); - for source in group.source_user_ids { - let source = source.trim().to_string(); - if source.is_empty() || source == target_user_id { - continue; - } - if !seen_sources.insert(source.clone()) { - return Err(format!("源用户重复出现在多个合并组: {source}")); - } - if seen_group_sources.insert(source.clone()) { - source_user_ids.push(source); - } - } - if source_user_ids.is_empty() { - return Err(format!("手机号 {phone_e164} 没有需要合并的源用户")); - } - - normalized.push(NormalizedDuplicatePhoneAccountMergeGroup { - phone_e164, - target_user_id, - source_user_ids, - }); - } - - Ok(normalized) -} - -fn merge_one_duplicate_phone_account_group( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) -> Result<(), String> { - merge_user_account_for_duplicate_phone(ctx, stats, dry_run, group)?; - merge_auth_identity_for_duplicate_phone(ctx, stats, dry_run, group)?; - let expected_wallet_balance = - merge_profile_dashboard_for_duplicate_phone(ctx, stats, dry_run, group); - merge_profile_wallet_ledger_for_duplicate_phone( - ctx, - stats, - dry_run, - group, - expected_wallet_balance, - )?; - merge_profile_invite_code_for_duplicate_phone(ctx, stats, dry_run, group); - merge_profile_redeem_codes_for_duplicate_phone(ctx, stats, dry_run, group); - merge_profile_referral_relation_for_duplicate_phone(ctx, stats, dry_run, group); - - for source_user_id in &group.source_user_ids { - move_user_pk_merge_table_by_json!( - ctx, - stats, - dry_run, - source_user_id, - &group.target_user_id, - profile_membership - )?; - move_user_pk_merge_table_by_json!( - ctx, - stats, - dry_run, - source_user_id, - &group.target_user_id, - runtime_snapshot - )?; - move_user_pk_merge_table_by_json!( - ctx, - stats, - dry_run, - source_user_id, - &group.target_user_id, - runtime_setting - )?; - move_user_pk_merge_table_by_json!( - ctx, - stats, - dry_run, - source_user_id, - &group.target_user_id, - player_progression - )?; - rebind_exact_account_refs_for_duplicate_phone( - ctx, - stats, - dry_run, - source_user_id, - &group.target_user_id, - )?; - } - - Ok(()) -} - -fn bump_duplicate_phone_merge_stat( - stats: &mut DuplicatePhoneMergeStats, - table_name: &str, - updated_row_count: u64, - deleted_row_count: u64, - inserted_row_count: u64, -) { - let entry = stats.entry(table_name.to_string()).or_insert((0, 0, 0)); - entry.0 = entry.0.saturating_add(updated_row_count); - entry.1 = entry.1.saturating_add(deleted_row_count); - entry.2 = entry.2.saturating_add(inserted_row_count); -} - -fn finish_duplicate_phone_merge_stats( - stats: DuplicatePhoneMergeStats, -) -> Vec { - let mut table_stats = stats - .into_iter() - .map( - |(table_name, (updated_row_count, deleted_row_count, inserted_row_count))| { - DuplicatePhoneAccountMergeTableStat { - table_name, - updated_row_count, - deleted_row_count, - inserted_row_count, - } - }, - ) - .collect::>(); - table_stats.sort_by(|left, right| left.table_name.cmp(&right.table_name)); - table_stats -} - -fn sanitize_account_merge_component(value: &str) -> String { - let sanitized = value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '_' - } - }) - .collect::(); - sanitized.trim_matches('_').to_string() -} - -fn replace_exact_account_refs_in_json_value( - value: &mut serde_json::Value, - source_user_id: &str, - target_user_id: &str, -) -> Result { - match value { - serde_json::Value::String(text) => { - if text == source_user_id { - *text = target_user_id.to_string(); - return Ok(1); - } - - let trimmed = text.trim(); - if !text.contains(source_user_id) - || !(trimmed.starts_with('{') || trimmed.starts_with('[')) - { - return Ok(0); - } - - let Ok(mut nested) = serde_json::from_str::(trimmed) else { - return Ok(0); - }; - let changed = replace_exact_account_refs_in_json_value( - &mut nested, - source_user_id, - target_user_id, - )?; - if changed > 0 { - *text = serde_json::to_string(&nested) - .map_err(|error| format!("嵌套 JSON 序列化失败: {error}"))?; - } - Ok(changed) - } - serde_json::Value::Array(items) => { - let mut changed = 0_u64; - for item in items { - changed = changed.saturating_add(replace_exact_account_refs_in_json_value( - item, - source_user_id, - target_user_id, - )?); - } - Ok(changed) - } - serde_json::Value::Object(fields) => { - let mut changed = 0_u64; - for item in fields.values_mut() { - changed = changed.saturating_add(replace_exact_account_refs_in_json_value( - item, - source_user_id, - target_user_id, - )?); - } - Ok(changed) - } - _ => Ok(0), - } -} - -fn merge_optional_text(target: &mut Option, source: Option) { - if target - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - { - return; - } - *target = source - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); -} - -fn merge_user_account_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) -> Result<(), String> { - let mut target = ctx - .db - .user_account() - .user_id() - .find(&group.target_user_id) - .ok_or_else(|| format!("目标用户不存在: {}", group.target_user_id))?; - let mut changed_target = false; - let mut merged_tags = target.user_tags.clone().unwrap_or_default(); - - for source_user_id in &group.source_user_ids { - let Some(source) = ctx.db.user_account().user_id().find(source_user_id) else { - continue; - }; - - bump_duplicate_phone_merge_stat(stats, "user_account", 0, 1, 0); - if !dry_run { - ctx.db.user_account().user_id().delete(source_user_id); - } - - merge_optional_text(&mut target.avatar_url, source.avatar_url.clone()); - if target - .phone_number_masked - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_none() - { - target.phone_number_masked = source.phone_number_masked.clone(); - } - if target.password_hash.trim().is_empty() && !source.password_hash.trim().is_empty() { - target.password_hash = source.password_hash.clone(); - } - target.password_login_enabled |= source.password_login_enabled; - target.wechat_bound |= source.wechat_bound; - target.token_version = target.token_version.max(source.token_version); - if target.display_name.trim().is_empty() - || target - .phone_number_masked - .as_deref() - .map(str::trim) - .is_some_and(|masked| masked == target.display_name.trim()) - { - let source_display_name = source.display_name.trim(); - if !source_display_name.is_empty() { - target.display_name = source_display_name.to_string(); - } - } - for tag in source.user_tags.unwrap_or_default() { - if !merged_tags.contains(&tag) { - merged_tags.push(tag); - } - } - changed_target = true; - } - - if target.phone_number_e164.as_deref() != Some(group.phone_e164.as_str()) { - target.phone_number_e164 = Some(group.phone_e164.clone()); - changed_target = true; - } - target.user_tags = Some(merged_tags); - - if changed_target { - bump_duplicate_phone_merge_stat(stats, "user_account", 1, 0, 0); - if !dry_run { - ctx.db.user_account().user_id().update(target); - } - } - - Ok(()) -} - -fn merge_auth_identity_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) -> Result<(), String> { - for source_user_id in &group.source_user_ids { - let rows = ctx - .db - .auth_identity() - .iter() - .filter(|row| row.user_id == *source_user_id) - .collect::>(); - for mut row in rows { - row.user_id = group.target_user_id.clone(); - row.phone_e164 = None; - row.display_name = None; - row.avatar_url = None; - bump_duplicate_phone_merge_stat(stats, "auth_identity", 1, 0, 0); - if !dry_run { - ctx.db.auth_identity().identity_id().update(row); - } - } - } - - let canonical_identity_id = format!( - "authi_phone_{}", - sanitize_account_merge_component(&group.phone_e164) - ); - let mut has_canonical_phone_identity = false; - let phone_rows = ctx - .db - .auth_identity() - .iter() - .filter(|row| { - row.provider == "phone" - && (row.provider_uid == group.phone_e164 - || row.phone_e164.as_deref() == Some(group.phone_e164.as_str())) - }) - .collect::>(); - - for mut row in phone_rows { - if row.identity_id == canonical_identity_id { - has_canonical_phone_identity = true; - row.user_id = group.target_user_id.clone(); - row.provider_uid = group.phone_e164.clone(); - row.provider_union_id = None; - row.phone_e164 = None; - row.display_name = None; - row.avatar_url = None; - bump_duplicate_phone_merge_stat(stats, "auth_identity", 1, 0, 0); - if !dry_run { - ctx.db.auth_identity().identity_id().update(row); - } - } else { - bump_duplicate_phone_merge_stat(stats, "auth_identity", 0, 1, 0); - if !dry_run { - ctx.db - .auth_identity() - .identity_id() - .delete(&row.identity_id); - } - } - } - - if !has_canonical_phone_identity { - bump_duplicate_phone_merge_stat(stats, "auth_identity", 0, 0, 1); - if !dry_run { - ctx.db.auth_identity().insert(AuthIdentity { - identity_id: canonical_identity_id, - user_id: group.target_user_id.clone(), - provider: "phone".to_string(), - provider_uid: group.phone_e164.clone(), - provider_union_id: None, - phone_e164: None, - display_name: None, - avatar_url: None, - }); - } - } - - Ok(()) -} - -fn merge_profile_dashboard_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) -> Option { - let target = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&group.target_user_id); - let mut has_dashboard = target.is_some(); - let mut wallet_balance = target.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let mut total_play_time_ms = target - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0); - let mut created_at = target - .as_ref() - .map(|row| row.created_at) - .unwrap_or(ctx.timestamp); - let mut updated_at = target - .as_ref() - .map(|row| row.updated_at) - .unwrap_or(ctx.timestamp); - - for source_user_id in &group.source_user_ids { - let Some(row) = ctx - .db - .profile_dashboard_state() - .user_id() - .find(source_user_id) - else { - continue; - }; - has_dashboard = true; - wallet_balance = wallet_balance.saturating_add(row.wallet_balance); - total_play_time_ms = total_play_time_ms.saturating_add(row.total_play_time_ms); - if row.created_at.to_micros_since_unix_epoch() < created_at.to_micros_since_unix_epoch() { - created_at = row.created_at; - } - if row.updated_at.to_micros_since_unix_epoch() > updated_at.to_micros_since_unix_epoch() { - updated_at = row.updated_at; - } - bump_duplicate_phone_merge_stat(stats, "profile_dashboard_state", 0, 1, 0); - if !dry_run { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(source_user_id); - } - } - - if !has_dashboard { - return None; - } - - if let Some(mut row) = target { - row.wallet_balance = wallet_balance; - row.total_play_time_ms = total_play_time_ms; - row.created_at = created_at; - row.updated_at = updated_at; - bump_duplicate_phone_merge_stat(stats, "profile_dashboard_state", 1, 0, 0); - if !dry_run { - ctx.db.profile_dashboard_state().user_id().update(row); - } - } else { - bump_duplicate_phone_merge_stat(stats, "profile_dashboard_state", 0, 0, 1); - if !dry_run { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: group.target_user_id.clone(), - wallet_balance, - total_play_time_ms, - created_at, - updated_at, - }); - } - } - - Some(wallet_balance) -} - -fn checked_i64_delta(left: u64, right: u64) -> Result { - let delta = left as i128 - right as i128; - if delta < i64::MIN as i128 || delta > i64::MAX as i128 { - return Err("钱包合并调整金额超出 i64 范围".to_string()); - } - Ok(delta as i64) -} - -fn merge_profile_wallet_ledger_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, - expected_wallet_balance: Option, -) -> Result<(), String> { - let adjustment_id = format!( - "account-merge:{}:{}", - group.target_user_id, - sanitize_account_merge_component(&group.phone_e164) - ); - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&adjustment_id) - .is_some() - { - bump_duplicate_phone_merge_stat(stats, "profile_wallet_ledger", 0, 1, 0); - if !dry_run { - ctx.db - .profile_wallet_ledger() - .wallet_ledger_id() - .delete(&adjustment_id); - } - } - - let member_user_ids = group - .source_user_ids - .iter() - .chain(std::iter::once(&group.target_user_id)) - .collect::>(); - let mut rows = ctx - .db - .profile_wallet_ledger() - .iter() - .filter(|row| { - row.wallet_ledger_id != adjustment_id && member_user_ids.contains(&row.user_id) - }) - .collect::>(); - rows.sort_by(|left, right| { - left.created_at - .to_micros_since_unix_epoch() - .cmp(&right.created_at.to_micros_since_unix_epoch()) - .then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id)) - }); - - let mut running_balance = 0_i128; - for mut row in rows { - running_balance += row.amount_delta as i128; - if running_balance < 0 || running_balance > u64::MAX as i128 { - return Err(format!("钱包账单合并后余额越界: {}", group.target_user_id)); - } - row.user_id = group.target_user_id.clone(); - row.balance_after = running_balance as u64; - bump_duplicate_phone_merge_stat(stats, "profile_wallet_ledger", 1, 0, 0); - if !dry_run { - ctx.db - .profile_wallet_ledger() - .wallet_ledger_id() - .update(row); - } - } - - if let Some(expected_wallet_balance) = expected_wallet_balance { - let current_balance = running_balance as u64; - if current_balance != expected_wallet_balance { - let amount_delta = checked_i64_delta(expected_wallet_balance, current_balance)?; - bump_duplicate_phone_merge_stat(stats, "profile_wallet_ledger", 0, 0, 1); - if !dry_run { - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: adjustment_id, - user_id: group.target_user_id.clone(), - amount_delta, - balance_after: expected_wallet_balance, - source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, - created_at: ctx.timestamp, - metadata_json: Some(format!( - "{{\"reason\":\"duplicate-phone-account-merge\",\"phoneE164\":\"{}\"}}", - group.phone_e164 - )), - }); - } - } - } - - Ok(()) -} - -fn merge_profile_invite_code_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) { - let target_exists = ctx - .db - .profile_invite_code() - .user_id() - .find(&group.target_user_id) - .is_some(); - let mut moved_source_to_target = target_exists; - - for source_user_id in &group.source_user_ids { - let Some(mut source) = ctx.db.profile_invite_code().user_id().find(source_user_id) else { - continue; - }; - - if moved_source_to_target { - bump_duplicate_phone_merge_stat(stats, "profile_invite_code", 0, 1, 0); - if !dry_run { - ctx.db - .profile_invite_code() - .user_id() - .delete(source_user_id); - } - continue; - } - - source.user_id = group.target_user_id.clone(); - source.updated_at = ctx.timestamp; - moved_source_to_target = true; - bump_duplicate_phone_merge_stat(stats, "profile_invite_code", 1, 1, 1); - if !dry_run { - ctx.db - .profile_invite_code() - .user_id() - .delete(source_user_id); - ctx.db.profile_invite_code().insert(source); - } - } -} - -fn merge_profile_redeem_codes_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) { - let member_user_ids = group - .source_user_ids - .iter() - .chain(std::iter::once(&group.target_user_id)) - .collect::>(); - let mut rows = ctx - .db - .profile_redeem_code_usage() - .iter() - .filter(|row| member_user_ids.contains(&row.user_id)) - .collect::>(); - rows.sort_by(|left, right| { - left.created_at - .to_micros_since_unix_epoch() - .cmp(&right.created_at.to_micros_since_unix_epoch()) - .then_with(|| left.usage_id.cmp(&right.usage_id)) - }); - - let mut kept_count_by_code = HashMap::new(); - let mut removed_by_code: HashMap = HashMap::new(); - for mut row in rows { - let allowed_use_count = ctx - .db - .profile_redeem_code() - .code() - .find(&row.code) - .map(|code| match code.mode { - RuntimeProfileRedeemCodeMode::Public => code.max_uses, - RuntimeProfileRedeemCodeMode::Unique | RuntimeProfileRedeemCodeMode::Private => 1, - }) - .unwrap_or(u32::MAX); - let kept_count = kept_count_by_code.entry(row.code.clone()).or_insert(0_u32); - if *kept_count >= allowed_use_count { - *removed_by_code.entry(row.code.clone()).or_insert(0) += 1; - bump_duplicate_phone_merge_stat(stats, "profile_redeem_code_usage", 0, 1, 0); - if !dry_run { - ctx.db - .profile_redeem_code_usage() - .usage_id() - .delete(&row.usage_id); - } - continue; - } - - *kept_count = kept_count.saturating_add(1); - if row.user_id != group.target_user_id { - row.user_id = group.target_user_id.clone(); - bump_duplicate_phone_merge_stat(stats, "profile_redeem_code_usage", 1, 0, 0); - if !dry_run { - ctx.db.profile_redeem_code_usage().usage_id().update(row); - } - } - } - - for (code, removed_count) in removed_by_code { - let Some(mut redeem_code) = ctx.db.profile_redeem_code().code().find(&code) else { - continue; - }; - redeem_code.global_used_count = redeem_code.global_used_count.saturating_sub(removed_count); - redeem_code.updated_at = ctx.timestamp; - bump_duplicate_phone_merge_stat(stats, "profile_redeem_code", 1, 0, 0); - if !dry_run { - ctx.db.profile_redeem_code().code().update(redeem_code); - } - } -} - -fn merge_profile_referral_relation_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - group: &NormalizedDuplicatePhoneAccountMergeGroup, -) { - let member_user_ids = group - .source_user_ids - .iter() - .chain(std::iter::once(&group.target_user_id)) - .collect::>(); - let mut rows = ctx - .db - .profile_referral_relation() - .iter() - .filter(|row| { - member_user_ids.contains(&row.invitee_user_id) - || member_user_ids.contains(&row.inviter_user_id) - }) - .collect::>(); - rows.sort_by(|left, right| { - left.bound_at - .to_micros_since_unix_epoch() - .cmp(&right.bound_at.to_micros_since_unix_epoch()) - .then_with(|| left.invitee_user_id.cmp(&right.invitee_user_id)) - }); - - let mut target_invitee_seen = ctx - .db - .profile_referral_relation() - .invitee_user_id() - .find(&group.target_user_id) - .is_some(); - for mut row in rows { - let old_invitee_user_id = row.invitee_user_id.clone(); - let old_inviter_user_id = row.inviter_user_id.clone(); - if member_user_ids.contains(&row.inviter_user_id) { - row.inviter_user_id = group.target_user_id.clone(); - } - if member_user_ids.contains(&row.invitee_user_id) { - row.invitee_user_id = group.target_user_id.clone(); - } - - if old_invitee_user_id != row.invitee_user_id && target_invitee_seen { - bump_duplicate_phone_merge_stat(stats, "profile_referral_relation", 0, 1, 0); - if !dry_run { - ctx.db - .profile_referral_relation() - .invitee_user_id() - .delete(&old_invitee_user_id); - } - continue; - } - - if old_invitee_user_id != row.invitee_user_id || old_inviter_user_id != row.inviter_user_id - { - target_invitee_seen = true; - bump_duplicate_phone_merge_stat(stats, "profile_referral_relation", 1, 1, 1); - if !dry_run { - ctx.db - .profile_referral_relation() - .invitee_user_id() - .delete(&old_invitee_user_id); - ctx.db.profile_referral_relation().insert(row); - } - } - } -} - -fn rebind_exact_account_refs_for_duplicate_phone( - ctx: &ReducerContext, - stats: &mut DuplicatePhoneMergeStats, - dry_run: bool, - source_user_id: &str, - target_user_id: &str, -) -> Result<(), String> { - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - refresh_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - ai_task - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - ai_task_stage - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - ai_text_chunk - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - ai_result_reference - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - ai_task_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - external_generation_job - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - external_generation_job_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - user_browse_history - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - tracking_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_task_progress - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_task_reward_claim - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_redeem_code - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_played_world - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - public_work_play_daily_stat - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - public_work_like - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_recharge_order - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_feedback_submission - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - profile_save_archive - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - chapter_progression - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - npc_state - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - story_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - story_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - inventory_slot - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - battle_state - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - treasure_record - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - quest_record - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - quest_log - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_agent_operation - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_draft_card - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - custom_world_gallery_entry - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - asset_object - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - asset_entity_binding - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - asset_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - editor_project - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - editor_canvas - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - editor_project_resource - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - editor_asset_folder - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - editor_asset - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_background_compile_task - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_leaderboard_entry - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_clear_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_clear_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_clear_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - puzzle_clear_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_draft_config - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_published_config - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_score_record - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_leaderboard_entry - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_work_stats_projection - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - bark_battle_personal_best_projection - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - match3d_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - match3d_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - match_3_d_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - match3d_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - jump_hop_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - jump_hop_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - jump_hop_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - jump_hop_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - jump_hop_leaderboard_entry - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - wooden_fish_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - wooden_fish_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - wooden_fish_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - wooden_fish_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - square_hole_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - square_hole_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - square_hole_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - square_hole_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_agent_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_work_profile - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_runtime_history_entry - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - visual_novel_runtime_event - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - big_fish_creation_session - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - big_fish_agent_message - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - big_fish_asset_slot - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - big_fish_runtime_run - )?; - rebind_exact_account_refs_in_table!( - ctx, - stats, - dry_run, - source_user_id, - target_user_id, - big_fish_event - )?; - Ok(()) -} - fn export_database_migration_to_file_inner( ctx: &mut ProcedureContext, input: DatabaseMigrationExportInput, @@ -2419,10 +792,9 @@ fn authorize_database_migration_operator_tx( note: String, ) -> Result<(), String> { let has_operator = ctx.db.database_migration_operator().iter().next().is_some(); - let has_valid_bootstrap_secret = require_migration_bootstrap_secret(bootstrap_secret).is_ok(); - if has_operator && !has_valid_bootstrap_secret { + if has_operator { require_migration_operator(ctx, caller)?; - } else if !has_operator { + } else { require_migration_bootstrap_secret(bootstrap_secret)?; } @@ -3142,49 +1514,3 @@ fn is_supported_migration_table(table_name: &str) -> bool { migration_tables!(supported_table_match) } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn duplicate_phone_merge_normalization_rejects_reused_source() { - let result = normalize_duplicate_phone_account_merge_groups(vec![ - DuplicatePhoneAccountMergeGroup { - phone_e164: "+8613000000001".to_string(), - target_user_id: "target-a".to_string(), - source_user_ids: vec!["source".to_string()], - }, - DuplicatePhoneAccountMergeGroup { - phone_e164: "+8613000000002".to_string(), - target_user_id: "target-b".to_string(), - source_user_ids: vec!["source".to_string()], - }, - ]); - - assert!(result.is_err()); - } - - #[test] - fn replace_exact_account_refs_updates_nested_json_strings_only_by_exact_value() { - let mut value = json!({ - "user_id": "source-user", - "metadata_json": "{\"owner\":\"source-user\",\"text\":\"prefix-source-user\"}", - "items": ["source-user", "prefix-source-user"] - }); - - let changed = - replace_exact_account_refs_in_json_value(&mut value, "source-user", "target-user") - .expect("replace account refs"); - - assert_eq!(changed, 3); - assert_eq!(value["user_id"], "target-user"); - assert_eq!(value["items"][0], "target-user"); - assert_eq!(value["items"][1], "prefix-source-user"); - assert_eq!( - value["metadata_json"], - "{\"owner\":\"target-user\",\"text\":\"prefix-source-user\"}" - ); - } -}