Files
Genarrative/server-rs/crates/module-auth/src/domain.rs
2026-05-14 14:21:17 +08:00

265 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 认证领域模型。
//!
//! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、
//! 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<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,
}
/// 规范化后的手机号快照。
#[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<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
/// 已绑定微信身份快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityRecord {
pub user_id: String,
pub provider_uid: String,
pub provider_union_id: Option<String>,
}
/// 微信授权 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<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
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<String>,
pub device_fingerprint: Option<String>,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
/// 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<String>,
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<String>,
pub updated_at_micros: Option<i64>,
}
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<PhoneNumberSnapshot, PhoneAuthError> {
let digits = raw_phone_number
.trim()
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
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<String, PhoneAuthError> {
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<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.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())
}