修复手机号重复账号合并

把手机号、昵称和头像回填并固定到 user_account。

停止新写入依赖 auth_identity 的账号资料旧列。

新增重复手机号账号库内合并 procedure,避免线上全量导出扫描。

更新 SpacetimeDB 绑定和账号资料职责文档。
This commit is contained in:
2026-07-01 15:39:24 +08:00
parent d50ecbc492
commit c8efdbcfc9
11 changed files with 1996 additions and 16 deletions
@@ -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`
@@ -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`
+17 -3
View File
@@ -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();
}
@@ -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;
@@ -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<String>,
}
impl __sdk::InModule for DuplicatePhoneAccountMergeGroup {
type Module = super::RemoteModule;
}
@@ -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<DuplicatePhoneAccountMergeGroup>,
pub dry_run: bool,
}
impl __sdk::InModule for DuplicatePhoneAccountMergeInput {
type Module = super::RemoteModule;
}
@@ -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<DuplicatePhoneAccountMergeTableStat>,
pub error_message: Option<String>,
}
impl __sdk::InModule for DuplicatePhoneAccountMergeProcedureResult {
type Module = super::RemoteModule;
}
@@ -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;
}
@@ -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<DuplicatePhoneAccountMergeProcedureResult, __sdk::InternalError>,
) + 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<DuplicatePhoneAccountMergeProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, DuplicatePhoneAccountMergeProcedureResult>(
"merge_duplicate_phone_accounts",
MergeDuplicatePhoneAccountsArgs { input },
__callback,
);
}
}
@@ -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<String>) -> Option<String> {
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<String>, Option<String>)>,
) -> 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::<std::collections::HashSet<_>>();
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::<std::collections::HashMap<_, _>>();
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![
File diff suppressed because it is too large Load Diff