//! 认证领域模型。 //! //! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、 //! cookie 写入、JWT 签发和 HTTP 上下文都属于外层 adapter。 use serde::{Deserialize, Serialize}; use crate::errors::{PasswordEntryError, PhoneAuthError}; pub const PASSWORD_MIN_LENGTH: usize = 6; pub const PASSWORD_MAX_LENGTH: usize = 128; pub const SMS_CODE_LENGTH: usize = 6; pub const SMS_CODE_TTL_MINUTES: i64 = 5; pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; /// 用户最近一次完成认证的入口类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthLoginMethod { Password, Phone, Wechat, } impl AuthLoginMethod { pub fn as_str(&self) -> &'static str { match self { Self::Password => "password", Self::Phone => "phone", Self::Wechat => "wechat", } } } /// 账号是否已经完成必要绑定。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthBindingStatus { Active, PendingBindPhone, } impl AuthBindingStatus { pub fn as_str(&self) -> &'static str { match self { Self::Active => "active", Self::PendingBindPhone => "pending_bind_phone", } } } /// 认证用户快照。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthUser { pub id: String, pub public_user_code: String, pub username: String, pub display_name: String, #[serde(default)] pub avatar_url: Option, pub phone_number_masked: Option, pub login_method: AuthLoginMethod, pub binding_status: AuthBindingStatus, pub wechat_bound: bool, pub token_version: u64, #[serde(default)] pub created_at: String, } /// 规范化后的手机号快照。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneNumberSnapshot { pub e164: String, pub masked_national_number: String, } /// 手机验证码使用场景。 #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthScene { Login, BindPhone, ChangePhone, ResetPassword, } impl PhoneAuthScene { pub fn as_str(&self) -> &'static str { match self { Self::Login => "login", Self::BindPhone => "bind_phone", Self::ChangePhone => "change_phone", Self::ResetPassword => "reset_password", } } } /// 微信授权入口场景。 #[derive(Clone, Debug, PartialEq, Eq)] pub enum WechatAuthScene { Desktop, WechatInApp, } impl WechatAuthScene { pub fn as_str(&self) -> &'static str { match self { Self::Desktop => "desktop", Self::WechatInApp => "wechat_in_app", } } } /// 微信身份资料快照。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatIdentityProfile { pub provider_uid: String, pub provider_union_id: Option, pub display_name: Option, pub avatar_url: Option, } /// 已绑定微信身份快照。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatIdentityRecord { pub user_id: String, pub provider_uid: String, pub provider_union_id: Option, } /// 微信授权 state 快照。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatAuthStateRecord { pub wechat_state_id: String, pub state_token: String, pub redirect_path: String, pub scene: WechatAuthScene, pub request_user_agent: Option, pub expires_at: String, pub consumed_at: Option, pub created_at: String, pub updated_at: String, } /// refresh session 的客户端环境快照。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionClientInfo { pub client_type: String, pub client_runtime: String, pub client_platform: String, pub client_instance_id: Option, pub device_fingerprint: Option, pub device_display_name: String, pub mini_program_app_id: Option, pub mini_program_env: Option, pub user_agent: Option, pub ip: Option, } /// refresh session 快照。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RefreshSessionRecord { pub session_id: String, pub user_id: String, pub refresh_token_hash: String, pub issued_by_provider: AuthLoginMethod, pub client_info: RefreshSessionClientInfo, pub expires_at: String, pub revoked_at: Option, pub created_at: String, pub updated_at: String, pub last_seen_at: String, } /// Auth store 持久化快照记录。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthStoreSnapshotRecord { pub snapshot_json: Option, pub updated_at_micros: Option, } pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> { let length = password.chars().count(); if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) { return Err(PasswordEntryError::InvalidPasswordLength); } Ok(()) } pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { let verify_code = verify_code.trim(); if verify_code.len() != SMS_CODE_LENGTH || !verify_code .chars() .all(|character| character.is_ascii_digit()) { return Err(PhoneAuthError::InvalidVerifyCode); } Ok(()) } pub fn normalize_mainland_china_phone_number( raw_phone_number: &str, ) -> Result { let digits = raw_phone_number .trim() .chars() .filter(|character| character.is_ascii_digit()) .collect::(); if digits.len() != 11 || !digits.starts_with('1') { return Err(PhoneAuthError::InvalidPhoneNumber); } Ok(PhoneNumberSnapshot { e164: format!("+86{digits}"), masked_national_number: mask_phone_number(&digits), }) } pub fn mask_phone_number(phone_number: &str) -> String { format!("{}****{}", &phone_number[..3], &phone_number[7..11]) } pub fn build_national_phone_number(e164_phone_number: &str) -> Result { let digits = e164_phone_number.trim().trim_start_matches('+'); if let Some(national) = digits.strip_prefix("86") && national.len() == 11 { return Ok(national.to_string()); } Err(PhoneAuthError::InvalidPhoneNumber) } pub fn build_system_username(prefix: &str, sequence: u64) -> String { format!("{prefix}_{sequence:08}") } // 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。 pub fn build_public_user_code(sequence: u64) -> String { format!("SY-{sequence:08}") } pub fn normalize_public_user_code(input: &str) -> Result { let normalized = input .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .collect::() .to_ascii_uppercase(); let digits = normalized.strip_prefix("SY").unwrap_or(&normalized); if digits.is_empty() || digits.len() > 8 || !digits.chars().all(|character| character.is_ascii_digit()) { return Err(PasswordEntryError::InvalidPublicUserCode); } Ok(format!("SY-{digits:0>8}")) } pub fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String { format!("{}:{}", phone_number.trim(), scene.as_str()) }