fix: repair api server merge fallout

This commit is contained in:
kdletters
2026-05-02 14:18:12 +08:00
parent 8f4ca9abfa
commit 9b5aa25fe9
16 changed files with 330 additions and 37 deletions

View File

@@ -29,6 +29,11 @@ pub struct ChangePasswordResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResetPasswordResult {
pub user: AuthUser,
@@ -76,6 +81,7 @@ pub struct ConsumeWechatAuthStateResult {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatPhoneResult {
pub user: AuthUser,
pub activated_new_user: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -29,6 +29,13 @@ pub struct ResetPasswordInput {
pub new_password: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProfileInput {
pub user_id: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SendPhoneCodeInput {
pub phone_number: String,

View File

@@ -55,11 +55,15 @@ pub struct AuthUser {
pub public_user_code: String,
pub username: String,
pub display_name: String,
#[serde(default)]
pub avatar_url: Option<String>,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
pub token_version: u64,
#[serde(default)]
pub created_at: String,
}
/// 规范化后的手机号快照。

View File

@@ -8,6 +8,9 @@ use std::{error::Error, fmt};
pub enum PasswordEntryError {
InvalidPhoneNumber,
InvalidPasswordLength,
InvalidDisplayName,
InvalidAvatarDataUrl,
EmptyProfileUpdate,
InvalidPublicUserCode,
InvalidCredentials,
UserNotFound,
@@ -61,6 +64,9 @@ impl fmt::Display for PasswordEntryError {
match self {
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidDisplayName => f.write_str("昵称格式不正确"),
Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"),
Self::EmptyProfileUpdate => f.write_str("请至少修改昵称或头像"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
Self::UserNotFound => f.write_str("用户不存在"),
@@ -135,6 +141,9 @@ pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSess
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound
@@ -150,6 +159,9 @@ pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> Ph
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
@@ -161,6 +173,9 @@ pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> L
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidPhoneNumber
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidDisplayName
| PasswordEntryError::InvalidAvatarDataUrl
| PasswordEntryError::EmptyProfileUpdate
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::UserNotFound

View File

@@ -1,4 +1,4 @@
mod application;
mod application;
mod commands;
mod domain;
mod errors;
@@ -203,6 +203,30 @@ impl PasswordEntryService {
.map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user }))
}
pub fn update_profile(
&self,
input: UpdateProfileInput,
) -> Result<UpdateProfileResult, PasswordEntryError> {
let display_name = match input.display_name {
Some(value) => Some(normalize_profile_display_name(value.as_str())?),
None => None,
};
let avatar_url = match input.avatar_url {
Some(value) => Some(normalize_profile_avatar_url(value.as_str())?),
None => None,
};
if display_name.is_none() && avatar_url.is_none() {
return Err(PasswordEntryError::EmptyProfileUpdate);
}
let user = self
.store
.update_user_profile(&input.user_id, display_name, avatar_url)?
.ok_or(PasswordEntryError::UserNotFound)?;
Ok(UpdateProfileResult { user })
}
pub async fn change_password(
&self,
input: ChangePasswordInput,
@@ -594,11 +618,14 @@ impl PhoneAuthService {
return Err(PhoneAuthError::UserStateMismatch);
}
let merged_user = self
let (merged_user, activated_new_user) = self
.store
.bind_wechat_phone_to_user(&input.user_id, normalized_phone)?;
Ok(BindWechatPhoneResult { user: merged_user })
Ok(BindWechatPhoneResult {
user: merged_user,
activated_new_user,
})
}
}
@@ -985,6 +1012,36 @@ impl InMemoryAuthStore {
.cloned())
}
fn update_user_profile(
&self,
user_id: &str,
display_name: Option<String>,
avatar_url: Option<String>,
) -> Result<Option<AuthUser>, PasswordEntryError> {
let mut state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
for stored_user in state.users_by_username.values_mut() {
if stored_user.user.id != user_id {
continue;
}
if let Some(display_name) = display_name {
stored_user.user.display_name = display_name;
}
if let Some(avatar_url) = avatar_url {
stored_user.user.avatar_url = Some(avatar_url);
}
let next_user = stored_user.user.clone();
self.persist_password_state(&state)?;
return Ok(Some(next_user));
}
Ok(None)
}
fn create_phone_user(
&self,
phone_number: PhoneNumberSnapshot,
@@ -1001,6 +1058,9 @@ impl InMemoryAuthStore {
));
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
@@ -1011,11 +1071,13 @@ impl InMemoryAuthStore {
public_user_code,
username: username.clone(),
display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Phone,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
@@ -1048,6 +1110,9 @@ impl InMemoryAuthStore {
return Err(PasswordEntryError::InvalidCredentials);
}
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
@@ -1058,11 +1123,13 @@ impl InMemoryAuthStore {
public_user_code,
username: username.clone(),
display_name,
avatar_url: None,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
token_version: 1,
created_at,
};
state
.phone_to_user_id
@@ -1091,11 +1158,15 @@ impl InMemoryAuthStore {
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
})?;
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let avatar_url = normalize_optional_string(profile.avatar_url.clone());
let display_name = profile
.display_name
.as_deref()
@@ -1108,11 +1179,13 @@ impl InMemoryAuthStore {
public_user_code,
username: username.clone(),
display_name,
avatar_url: avatar_url.clone(),
phone_number_masked: None,
login_method: AuthLoginMethod::Wechat,
binding_status: AuthBindingStatus::PendingBindPhone,
wechat_bound: true,
token_version: 1,
created_at,
};
state.users_by_username.insert(
username,
@@ -1128,7 +1201,7 @@ impl InMemoryAuthStore {
provider_uid: normalize_required_string(&profile.provider_uid).unwrap_or_default(),
provider_union_id: normalize_optional_string(profile.provider_union_id),
display_name: normalize_optional_string(profile.display_name),
avatar_url: normalize_optional_string(profile.avatar_url),
avatar_url,
};
if let Some(provider_union_id) = identity.provider_union_id.clone() {
state
@@ -1454,7 +1527,7 @@ impl InMemoryAuthStore {
&self,
pending_user_id: &str,
phone_number: PhoneNumberSnapshot,
) -> Result<AuthUser, PhoneAuthError> {
) -> Result<(AuthUser, bool), PhoneAuthError> {
let mut state = self
.inner
.lock()
@@ -1501,7 +1574,7 @@ impl InMemoryAuthStore {
let next_user = target_user.user.clone();
self.persist_phone_state(&state)?;
return Ok(next_user);
return Ok((next_user, false));
}
state
@@ -1520,7 +1593,7 @@ impl InMemoryAuthStore {
let next_user = stored_user.user.clone();
self.persist_phone_state(&state)?;
Ok(next_user)
Ok((next_user, true))
}
fn find_session_by_refresh_token_hash(
@@ -1819,6 +1892,40 @@ async fn verify_stored_password_user(
})
}
fn normalize_profile_display_name(value: &str) -> Result<String, PasswordEntryError> {
let Some(display_name) = normalize_required_string(value) else {
return Err(PasswordEntryError::InvalidDisplayName);
};
let length = display_name.chars().count();
if !(2..=20).contains(&length) {
return Err(PasswordEntryError::InvalidDisplayName);
}
if !display_name.chars().all(|character| {
character == '_'
|| character.is_ascii_alphanumeric()
|| is_common_chinese_character(character)
}) {
return Err(PasswordEntryError::InvalidDisplayName);
}
Ok(display_name)
}
fn normalize_profile_avatar_url(value: &str) -> Result<String, PasswordEntryError> {
let Some(avatar_url) = normalize_required_string(value) else {
return Err(PasswordEntryError::InvalidAvatarDataUrl);
};
if !avatar_url.starts_with("data:image/") || !avatar_url.contains(";base64,") {
return Err(PasswordEntryError::InvalidAvatarDataUrl);
}
Ok(avatar_url)
}
fn is_common_chinese_character(character: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&character)
}
fn build_random_password_seed() -> String {
format!(
"seed_{}_{}",