This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -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();