修复手机号重复账号合并
把手机号、昵称和头像回填并固定到 user_account。 停止新写入依赖 auth_identity 的账号资料旧列。 新增重复手机号账号库内合并 procedure,避免线上全量导出扫描。 更新 SpacetimeDB 绑定和账号资料职责文档。
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+59
@@ -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
Reference in New Issue
Block a user