From c8efdbcfc9a1c248c77ffd50aadc7fc6520cf447 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 1 Jul 2026 15:39:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E6=9C=BA=E5=8F=B7?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=B4=A6=E5=8F=B7=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把手机号、昵称和头像回填并固定到 user_account。 停止新写入依赖 auth_identity 的账号资料旧列。 新增重复手机号账号库内合并 procedure,避免线上全量导出扫描。 更新 SpacetimeDB 绑定和账号资料职责文档。 --- docs/project-memory/shared-memory/pitfalls.md | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + server-rs/crates/module-auth/src/lib.rs | 20 +- .../spacetime-client/src/module_bindings.rs | 10 + ...uplicate_phone_account_merge_group_type.rs | 17 + ...uplicate_phone_account_merge_input_type.rs | 18 + ...one_account_merge_procedure_result_type.rs | 20 + ...ate_phone_account_merge_table_stat_type.rs | 18 + ...erge_duplicate_phone_accounts_procedure.rs | 59 + .../spacetime-module/src/auth/procedures.rs | 173 +- .../crates/spacetime-module/src/migration.rs | 1675 ++++++++++++++++- 11 files changed, 1996 insertions(+), 16 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_group_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_table_stat_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/merge_duplicate_phone_accounts_procedure.rs diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7d4aa7e20..f94f1543e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -91,6 +91,7 @@ - 现象:后台把私有兑换码配给某个陶泥号或手机号后,用户用同一手机号登录兑换仍提示 `该兑换码不适用于当前账号`。 - 原因:认证表里可能存在同一手机号的多条 `user_account`。如果认证工作集重建 `phone_to_user_id` 时让 `user_account.phone_number_e164` 后写覆盖前写,当前登录态会漂到没有 `auth_identity` 的重复账号,而兑换码白名单仍指向另一个内部 `user_id`。 - 处理:重建认证工作集时以 `auth_identity(provider="phone")` 指向的账号作为手机号索引权威,`user_account.phone_number_e164` 只补没有 identity 的手机号;旧 `auth_store_snapshot` 只允许在正式认证表为空时一次性转移到正式表,随后清空,不再作为运行期回灌来源;Bearer / refresh session 本进程未命中时不要再从 SpacetimeDB 导出整包快照刷新内存。线上止血先核对失败请求附近的 current session `user_id` 与兑换码 `allowed_user_ids`,不要只看手机号展示值。 +- 约束:`auth_identity` 只保存登录入口身份键;手机号、昵称和头像的正式资料真相在 `user_account.phone_number_e164` / `display_name` / `avatar_url`。旧 `auth_identity.phone_e164` / `display_name` / `avatar_url` 只能作为历史回填来源,不能继续让新写入依赖这些列。 - 验证:`cargo test -p spacetime-module auth_export -- --nocapture` 应覆盖同手机号重复账号时手机号索引优先指向有 phone identity 的账号;`api-server` 中不应再存在运行期 `refresh_auth_store_from_spacetime` 调用。 - 关联:`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-module/src/auth/tables.rs`、`server-rs/crates/module-auth/src/lib.rs`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8df6c4fdb..ec6e41d55 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -283,6 +283,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthIdentity` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +- 职责:只表达登录入口身份键到 `user_account.user_id` 的绑定;`provider_uid` 保存手机号 E.164 或微信 openid,`provider_union_id` 保存微信 unionid。账户资料以 `user_account.phone_number_e164`、`user_account.display_name`、`user_account.avatar_url` 为准,`auth_identity.phone_e164` / `display_name` / `avatar_url` 仅保留旧行兼容,不再作为写入或恢复真相。 ### `auth_store_projection_meta` diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index b34f4d188..2d469f938 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1543,7 +1543,7 @@ impl InMemoryAuthStore { // 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。 identity.provider_uid = next_provider_uid.clone(); identity.display_name = next_display_name.clone(); - identity.avatar_url = next_avatar_url; + identity.avatar_url = next_avatar_url.clone(); identity.provider_union_id = next_provider_union_id.clone(); if next_session_key.is_some() { identity.session_key = next_session_key.clone(); @@ -1576,6 +1576,9 @@ impl InMemoryAuthStore { if let Some(display_name) = next_display_name.clone() { stored_user.user.wechat_display_name = Some(display_name); } + if let Some(avatar_url) = next_avatar_url.clone() { + stored_user.user.avatar_url = Some(avatar_url); + } stored_user.user.clone() }; self.persist_wechat_state(&state)?; @@ -1825,7 +1828,12 @@ impl InMemoryAuthStore { let pending_wechat_display_name = submitted_wechat_display_name .clone() .or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone())) - .or_else(|| normalize_optional_string(pending_user.user.wechat_display_name)); + .or_else(|| { + normalize_optional_string(pending_user.user.wechat_display_name.clone()) + }); + let pending_wechat_avatar_url = + normalize_optional_string(pending_wechat_identity.avatar_url.clone()) + .or_else(|| normalize_optional_string(pending_user.user.avatar_url.clone())); state.users_by_username.remove(&pending_username); state.wechat_identity_by_provider_uid.insert( @@ -1849,7 +1857,13 @@ impl InMemoryAuthStore { .ok_or(PhoneAuthError::UserNotFound)?; target_user.user.wechat_bound = true; target_user.user.wechat_account = Some(pending_wechat_account); - target_user.user.wechat_display_name = pending_wechat_display_name; + target_user.user.wechat_display_name = pending_wechat_display_name.clone(); + if let Some(display_name) = pending_wechat_display_name { + target_user.user.display_name = display_name; + } + if target_user.user.avatar_url.is_none() { + target_user.user.avatar_url = pending_wechat_avatar_url; + } if target_user.user.phone_number.is_none() { target_user.user.phone_number = target_user.phone_number.clone(); } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index c0691341c..94ee65649 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -357,6 +357,10 @@ 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; @@ -632,6 +636,7 @@ 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; @@ -1585,6 +1590,10 @@ 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; @@ -1860,6 +1869,7 @@ 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 new file mode 100644 index 000000000..bf4068886 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_group_type.rs @@ -0,0 +1,17 @@ +// 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 new file mode 100644 index 000000000..a745722a0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_input_type.rs @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 000000000..46943598a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_procedure_result_type.rs @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 000000000..e4be6e03c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/duplicate_phone_account_merge_table_stat_type.rs @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 000000000..983b1e070 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/merge_duplicate_phone_accounts_procedure.rs @@ -0,0 +1,59 @@ +// 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/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index a04181d83..34161c780 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -43,6 +43,45 @@ fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool { !snapshot.users_by_username.is_empty() } +fn normalize_optional_snapshot_string(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn merge_identity_profile_into_user( + mut user: AuthUserSnapshot, + identity_profile: Option<(Option, Option)>, +) -> AuthUserSnapshot { + let Some((display_name, avatar_url)) = identity_profile else { + return user; + }; + + if let Some(display_name) = normalize_optional_snapshot_string(display_name) { + let current_display_name = user.display_name.trim(); + let is_masked_phone_display_name = user + .phone_number_masked + .as_deref() + .map(str::trim) + .is_some_and(|masked| masked == current_display_name); + if current_display_name.is_empty() || is_masked_phone_display_name { + user.display_name = display_name; + } + } + + if user + .avatar_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + user.avatar_url = normalize_optional_snapshot_string(avatar_url); + } + + user +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreSnapshotImportRecord { pub imported_user_count: u32, @@ -132,10 +171,26 @@ fn import_auth_store_snapshot_value_tx( let mut imported_user_count = 0_u32; let mut imported_identity_count = 0_u32; let mut imported_refresh_session_count = 0_u32; + let mut wechat_profile_by_user_id = std::collections::HashMap::new(); + + for identity in parsed.wechat_identity_by_provider_uid.values() { + let entry = wechat_profile_by_user_id + .entry(identity.user_id.clone()) + .or_insert_with(|| (None, None)); + if entry.0.is_none() { + entry.0 = identity.display_name.clone(); + } + if entry.1.is_none() { + entry.1 = identity.avatar_url.clone(); + } + } for stored_user in parsed.users_by_username.into_values() { - let user = stored_user.user; - let user_id = user.id.clone(); + let user_id = stored_user.user.id.clone(); + let user = merge_identity_profile_into_user( + stored_user.user, + wechat_profile_by_user_id.remove(&user_id), + ); if ctx.db.user_account().user_id().find(&user_id).is_some() { ctx.db.user_account().user_id().delete(&user_id); } @@ -177,7 +232,7 @@ fn import_auth_store_snapshot_value_tx( provider: "phone".to_string(), provider_uid: phone_number.clone(), provider_union_id: None, - phone_e164: Some(phone_number), + phone_e164: None, display_name: None, avatar_url: None, }); @@ -206,8 +261,8 @@ fn import_auth_store_snapshot_value_tx( provider_uid: identity.provider_uid, provider_union_id: identity.provider_union_id, phone_e164: None, - display_name: identity.display_name, - avatar_url: identity.avatar_url, + display_name: None, + avatar_url: None, }); imported_identity_count += 1; } @@ -326,6 +381,18 @@ fn build_auth_store_snapshot_from_rows( .iter() .map(|user| user.user_id.clone()) .collect::>(); + let user_profile_by_id = users + .iter() + .map(|user| { + ( + user.user_id.clone(), + ( + normalize_optional_snapshot_string(Some(user.display_name.clone())), + user.avatar_url.clone(), + ), + ) + }) + .collect::>(); let mut phone_identity_by_user_id = std::collections::HashMap::new(); let mut phone_user_id_by_phone = std::collections::HashMap::new(); let mut wechat_identity_by_provider_uid = std::collections::HashMap::new(); @@ -339,10 +406,14 @@ fn build_auth_store_snapshot_from_rows( match identity.provider.as_str() { "phone" => { let user_id = identity.user_id.clone(); - let phone_number = identity - .phone_e164 - .clone() - .unwrap_or_else(|| identity.provider_uid.clone()); + let phone_number = if identity.provider_uid.trim().is_empty() { + identity.phone_e164.clone().unwrap_or_default() + } else { + identity.provider_uid.clone() + }; + if phone_number.trim().is_empty() { + continue; + } phone_identity_by_user_id.insert(user_id.clone(), phone_number.clone()); phone_user_id_by_phone.insert(phone_number, user_id); } @@ -350,14 +421,18 @@ fn build_auth_store_snapshot_from_rows( if let Some(union_id) = identity.provider_union_id.clone() { user_id_by_provider_union_id.insert(union_id, identity.user_id.clone()); } + let (display_name, avatar_url) = user_profile_by_id + .get(&identity.user_id) + .cloned() + .unwrap_or((None, None)); wechat_identity_by_provider_uid.insert( identity.provider_uid.clone(), StoredWechatIdentitySnapshot { user_id: identity.user_id, provider_uid: identity.provider_uid, provider_union_id: identity.provider_union_id, - display_name: identity.display_name, - avatar_url: identity.avatar_url, + display_name, + avatar_url, }, ); } @@ -553,7 +628,7 @@ mod tests { provider: "phone".to_string(), provider_uid: "+8613800008000".to_string(), provider_union_id: None, - phone_e164: Some("+8613800008000".to_string()), + phone_e164: Some("+8613999999999".to_string()), display_name: None, avatar_url: None, }; @@ -577,6 +652,80 @@ mod tests { ); } + #[test] + fn auth_import_backfills_identity_profile_into_user_account() { + let user = AuthUserSnapshot { + id: "user_wechat".to_string(), + public_user_code: "SY-00000024".to_string(), + username: "phone_wechat".to_string(), + display_name: "138****8000".to_string(), + avatar_url: None, + phone_number_masked: Some("138****8000".to_string()), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: true, + token_version: 1, + user_tags: vec![], + }; + + let merged = merge_identity_profile_into_user( + user, + Some(( + Some("微信昵称".to_string()), + Some("https://example.com/avatar.png".to_string()), + )), + ); + + assert_eq!(merged.display_name, "微信昵称"); + assert_eq!( + merged.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + } + + #[test] + fn auth_export_reads_wechat_profile_from_user_account() { + let user = UserAccount { + user_id: "user_wechat".to_string(), + public_user_code: "SY-00000025".to_string(), + username: "wechat_user".to_string(), + display_name: "账户昵称".to_string(), + avatar_url: Some("https://example.com/account-avatar.png".to_string()), + phone_number_masked: None, + phone_number_e164: None, + login_method: "wechat".to_string(), + binding_status: "pending_bind_phone".to_string(), + wechat_bound: true, + password_hash: String::new(), + password_login_enabled: false, + token_version: 1, + user_tags: Some(vec![]), + }; + let identity = AuthIdentity { + identity_id: "authi_wechat_openid_001".to_string(), + user_id: "user_wechat".to_string(), + provider: "wechat".to_string(), + provider_uid: "openid_001".to_string(), + provider_union_id: Some("union_001".to_string()), + phone_e164: None, + display_name: Some("旧身份昵称".to_string()), + avatar_url: Some("https://example.com/identity-avatar.png".to_string()), + }; + + let snapshot = build_auth_store_snapshot_from_rows(vec![user], vec![identity], vec![]) + .expect("auth rows should export"); + let identity = snapshot + .wechat_identity_by_provider_uid + .get("openid_001") + .expect("wechat identity should export"); + + assert_eq!(identity.display_name.as_deref(), Some("账户昵称")); + assert_eq!( + identity.avatar_url.as_deref(), + Some("https://example.com/account-avatar.png") + ); + } + #[test] fn auth_export_next_user_id_follows_public_user_code_for_uuid_user_ids() { let users = vec![ diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ef6bffff4..e467903cb 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::HashSet; +use std::collections::{HashMap, HashSet}; use crate::bark_battle::tables::{ bark_battle_draft_config, bark_battle_leaderboard_entry, bark_battle_personal_best_projection, @@ -156,6 +156,35 @@ 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, @@ -522,6 +551,1604 @@ 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, @@ -1514,3 +3141,49 @@ 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\"}" + ); + } +}