1
This commit is contained in:
@@ -24,6 +24,9 @@ const SMS_CODE_LENGTH: usize = 6;
|
||||
const SMS_CODE_TTL_MINUTES: i64 = 5;
|
||||
const SMS_CODE_COOLDOWN_SECONDS: u64 = 60;
|
||||
const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5;
|
||||
const DISPLAY_NAME_MIN_CHARS: usize = 2;
|
||||
const DISPLAY_NAME_MAX_CHARS: usize = 20;
|
||||
const AVATAR_DATA_URL_MAX_CHARS: usize = 400_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AuthLoginMethod {
|
||||
@@ -44,6 +47,7 @@ pub struct AuthUser {
|
||||
pub public_user_code: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: AuthLoginMethod,
|
||||
pub binding_status: AuthBindingStatus,
|
||||
@@ -85,6 +89,18 @@ pub struct ChangePasswordResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[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 UpdateProfileResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ResetPasswordInput {
|
||||
pub phone_number: String,
|
||||
@@ -316,6 +332,9 @@ pub enum PasswordEntryError {
|
||||
InvalidPhoneNumber,
|
||||
InvalidPasswordLength,
|
||||
InvalidPublicUserCode,
|
||||
InvalidDisplayName,
|
||||
InvalidAvatarDataUrl,
|
||||
EmptyProfileUpdate,
|
||||
InvalidCredentials,
|
||||
UserNotFound,
|
||||
Store(String),
|
||||
@@ -572,6 +591,25 @@ impl PasswordEntryService {
|
||||
|
||||
Ok(ChangePasswordResult { user })
|
||||
}
|
||||
|
||||
pub fn update_profile(
|
||||
&self,
|
||||
input: UpdateProfileInput,
|
||||
) -> Result<UpdateProfileResult, PasswordEntryError> {
|
||||
let display_name = input.display_name.map(validate_display_name).transpose()?;
|
||||
let avatar_url = input.avatar_url.map(validate_avatar_data_url).transpose()?;
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshSessionService {
|
||||
@@ -1345,6 +1383,7 @@ 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,
|
||||
@@ -1392,6 +1431,7 @@ 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,
|
||||
@@ -1442,6 +1482,7 @@ impl InMemoryAuthStore {
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: normalize_optional_string(profile.avatar_url.clone()),
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Wechat,
|
||||
binding_status: AuthBindingStatus::PendingBindPhone,
|
||||
@@ -1544,7 +1585,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();
|
||||
state
|
||||
.wechat_identity_by_provider_uid
|
||||
@@ -1570,6 +1611,9 @@ impl InMemoryAuthStore {
|
||||
{
|
||||
stored_user.user.display_name = display_name.to_string();
|
||||
}
|
||||
if let Some(avatar_url) = next_avatar_url {
|
||||
stored_user.user.avatar_url = Some(avatar_url);
|
||||
}
|
||||
stored_user.user.clone()
|
||||
};
|
||||
self.persist_wechat_state(&state)?;
|
||||
@@ -1604,6 +1648,37 @@ impl InMemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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()))?;
|
||||
|
||||
let Some(stored_user) = state
|
||||
.users_by_username
|
||||
.values_mut()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
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)?;
|
||||
Ok(Some(next_user))
|
||||
}
|
||||
|
||||
fn upsert_phone_code(
|
||||
&self,
|
||||
code: StoredPhoneCode,
|
||||
@@ -2144,7 +2219,12 @@ impl fmt::Display for PasswordEntryError {
|
||||
match self {
|
||||
Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"),
|
||||
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
|
||||
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
|
||||
Self::InvalidPublicUserCode => f.write_str("陶泥号格式不正确"),
|
||||
Self::InvalidDisplayName => {
|
||||
f.write_str("昵称需要为 2 到 20 位中文、英文、数字或下划线")
|
||||
}
|
||||
Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"),
|
||||
Self::EmptyProfileUpdate => f.write_str("昵称或头像至少修改一项"),
|
||||
Self::InvalidCredentials => f.write_str("手机号或密码错误"),
|
||||
Self::UserNotFound => f.write_str("用户不存在"),
|
||||
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
|
||||
@@ -2219,6 +2299,9 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidDisplayName
|
||||
| PasswordEntryError::InvalidAvatarDataUrl
|
||||
| PasswordEntryError::EmptyProfileUpdate
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::UserNotFound
|
||||
| PasswordEntryError::PasswordHash(_) => {
|
||||
@@ -2234,6 +2317,9 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidDisplayName
|
||||
| PasswordEntryError::InvalidAvatarDataUrl
|
||||
| PasswordEntryError::EmptyProfileUpdate
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::UserNotFound => PhoneAuthError::Store("用户仓储读取失败".to_string()),
|
||||
}
|
||||
@@ -2245,6 +2331,9 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
|
||||
PasswordEntryError::InvalidPhoneNumber
|
||||
| PasswordEntryError::InvalidPasswordLength
|
||||
| PasswordEntryError::InvalidPublicUserCode
|
||||
| PasswordEntryError::InvalidDisplayName
|
||||
| PasswordEntryError::InvalidAvatarDataUrl
|
||||
| PasswordEntryError::EmptyProfileUpdate
|
||||
| PasswordEntryError::InvalidCredentials
|
||||
| PasswordEntryError::UserNotFound
|
||||
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
|
||||
@@ -2279,6 +2368,56 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_display_name(display_name: String) -> Result<String, PasswordEntryError> {
|
||||
let normalized =
|
||||
normalize_required_string(&display_name).ok_or(PasswordEntryError::InvalidDisplayName)?;
|
||||
let char_count = normalized.chars().count();
|
||||
if !(DISPLAY_NAME_MIN_CHARS..=DISPLAY_NAME_MAX_CHARS).contains(&char_count) {
|
||||
return Err(PasswordEntryError::InvalidDisplayName);
|
||||
}
|
||||
if !normalized.chars().all(is_allowed_display_name_char) {
|
||||
return Err(PasswordEntryError::InvalidDisplayName);
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn is_allowed_display_name_char(character: char) -> bool {
|
||||
character.is_ascii_alphanumeric()
|
||||
|| character == '_'
|
||||
|| ('\u{4E00}'..='\u{9FFF}').contains(&character)
|
||||
}
|
||||
|
||||
fn validate_avatar_data_url(avatar_url: String) -> Result<String, PasswordEntryError> {
|
||||
let normalized =
|
||||
normalize_required_string(&avatar_url).ok_or(PasswordEntryError::InvalidAvatarDataUrl)?;
|
||||
if normalized.len() > AVATAR_DATA_URL_MAX_CHARS {
|
||||
return Err(PasswordEntryError::InvalidAvatarDataUrl);
|
||||
}
|
||||
|
||||
let Some((header, payload)) = normalized.split_once(',') else {
|
||||
return Err(PasswordEntryError::InvalidAvatarDataUrl);
|
||||
};
|
||||
if !matches!(
|
||||
header,
|
||||
"data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64"
|
||||
) {
|
||||
return Err(PasswordEntryError::InvalidAvatarDataUrl);
|
||||
}
|
||||
if payload.is_empty()
|
||||
|| !payload.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric()
|
||||
|| character == '+'
|
||||
|| character == '/'
|
||||
|| character == '='
|
||||
})
|
||||
{
|
||||
return Err(PasswordEntryError::InvalidAvatarDataUrl);
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
async fn verify_stored_password_user(
|
||||
existing_user: StoredPasswordUser,
|
||||
password: &str,
|
||||
@@ -2360,7 +2499,7 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
|
||||
format!("{prefix}_{sequence:08}")
|
||||
}
|
||||
|
||||
// 公开叙世号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
fn build_public_user_code(sequence: u64) -> String {
|
||||
format!("SY-{sequence:08}")
|
||||
}
|
||||
@@ -2586,6 +2725,65 @@ mod tests {
|
||||
assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_update_profile_changes_display_name_and_avatar() {
|
||||
let store = build_store();
|
||||
let service = build_password_service(store);
|
||||
let created = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138010".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("dev registration should create user")
|
||||
.user;
|
||||
let avatar_data_url = "data:image/png;base64,aGVsbG8=".to_string();
|
||||
|
||||
let updated = service
|
||||
.update_profile(UpdateProfileInput {
|
||||
user_id: created.id.clone(),
|
||||
display_name: Some("旅人甲_01".to_string()),
|
||||
avatar_url: Some(avatar_data_url.clone()),
|
||||
})
|
||||
.expect("profile should update")
|
||||
.user;
|
||||
|
||||
assert_eq!(updated.display_name, "旅人甲_01");
|
||||
assert_eq!(updated.avatar_url, Some(avatar_data_url));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_entry_update_profile_rejects_empty_or_invalid_payload() {
|
||||
let store = build_store();
|
||||
let service = build_password_service(store);
|
||||
let created = service
|
||||
.execute_with_dev_registration(PasswordEntryInput {
|
||||
phone_number: "13800138011".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("dev registration should create user")
|
||||
.user;
|
||||
|
||||
let empty_error = service
|
||||
.update_profile(UpdateProfileInput {
|
||||
user_id: created.id.clone(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
})
|
||||
.expect_err("empty profile update should fail");
|
||||
let invalid_name_error = service
|
||||
.update_profile(UpdateProfileInput {
|
||||
user_id: created.id,
|
||||
display_name: Some("旅人-甲".to_string()),
|
||||
avatar_url: None,
|
||||
})
|
||||
.expect_err("invalid display name should fail");
|
||||
|
||||
assert_eq!(empty_error, PasswordEntryError::EmptyProfileUpdate);
|
||||
assert_eq!(invalid_name_error, PasswordEntryError::InvalidDisplayName);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn phone_user_can_set_password_then_login() {
|
||||
let store = build_store();
|
||||
|
||||
Reference in New Issue
Block a user