Files
Genarrative/server-rs/crates/module-auth/src/domain.rs
k88936 44748b7846
Project CI / Frontend tests (push) Successful in 34s
Project CI / Backend tests (push) Failing after 39s
Project CI / Repository checks (push) Successful in 52s
Project CI / Native shell tests (push) Successful in 2m4s
fix:前后端校验手机号区域码 (#104)
来自今早群友反映
before:
![shotmd-1784692399.jpg](/attachments/3ab36252-78f4-44e2-bb51-fca84badc79e)
and failed
after:
![shotmd-1784702966.jpg](/attachments/3eea7ec9-8aca-43f6-a675-1316162619d4)
![shotmd-1784702843.jpg](/attachments/29182a41-26cf-4954-be16-de060d844d9c)
![shotmd-1784787134.jpg](/attachments/c04a1981-c6bf-4a26-b5ad-d2af51d74a29)

* https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload 参考微信这个文档修改了微信返回的struct 手机号 区域码字段不应是Optional

* 登录注册改密码改绑定等 api 把单一 phone字段改成 country_code(可选,缺省为86) + pure_phone_number 分别进行了前后端校验

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/104
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-23 14:52:57 +08:00

344 lines
10 KiB
Rust

//! 认证领域模型。
//!
//! 这里只保留账号、登录方式、绑定状态等纯领域事实。文件持久化、真实短信发送、
//! 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;
pub const MAINLAND_CHINA_COUNTRY_CODE: &str = "86";
/// 用户最近一次完成认证的入口类型。
#[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>,
#[serde(default)]
pub phone_number: Option<String>,
pub phone_number_masked: Option<String>,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
#[serde(default)]
pub wechat_display_name: Option<String>,
#[serde(default)]
pub wechat_account: Option<String>,
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>,
pub session_key: Option<String>,
}
/// 已绑定微信身份快照。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatIdentityRecord {
pub user_id: String,
pub provider_uid: String,
pub provider_union_id: Option<String>,
pub session_key: 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 bind_user_id: 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,
}
/// module-auth 进程内工作集同步到数据库的 typed view。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreProjectionView {
pub updated_at_micros: i64,
pub users: Vec<AuthStoreProjectionUser>,
pub identities: Vec<AuthStoreProjectionIdentity>,
pub refresh_sessions: Vec<AuthStoreProjectionRefreshSession>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreProjectionUser {
pub user_id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub phone_number_masked: Option<String>,
pub phone_number_e164: Option<String>,
pub login_method: String,
pub binding_status: String,
pub wechat_bound: bool,
pub password_hash: String,
pub password_login_enabled: bool,
pub token_version: u64,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreProjectionIdentity {
pub identity_id: String,
pub user_id: String,
pub provider: String,
pub provider_uid: String,
pub provider_union_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStoreProjectionRefreshSession {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: String,
pub client_info_json: String,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
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(
pure_phone_number: &str,
) -> Result<PhoneNumberSnapshot, PhoneAuthError> {
let digits = pure_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 validate_mainland_china_country_code(
country_code: Option<&str>,
) -> Result<(), PhoneAuthError> {
match country_code {
None => Ok(()),
Some(country_code) if country_code.trim() == MAINLAND_CHINA_COUNTRY_CODE => Ok(()),
Some(_) => Err(PhoneAuthError::UnsupportedPhoneCountryCode),
}
}
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}")
}
pub fn build_wechat_username(display_name: &str, provider_uid: &str) -> String {
let normalized_display_name = display_name.trim();
let normalized_provider_uid = provider_uid.trim();
let fallback_display_name = if normalized_display_name.is_empty() {
"微信旅人"
} else {
normalized_display_name
};
let fallback_provider_uid = if normalized_provider_uid.is_empty() {
"openid"
} else {
normalized_provider_uid
};
format!("{fallback_display_name}_{fallback_provider_uid}")
}
// 公开陶泥号是稳定的公开检索键,不替代内部 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())
}