458371a73d
## 变更内容 - 新增 `profile_wallet_refund_outbox` 表与 enqueue/process procedure,退款主路径进入 SpacetimeDB。 - 外部生成失败事务、inline 资产失败和跨节点 worker 统一使用库内 outbox,按 ledger 幂等并在事务内完成退款与删除。 - SpacetimeDB 完全不可达时才写本机 emergency spool,恢复时重新入库;兼容旧 spool 文件并保留 attempt 追踪。 - 更新 SpacetimeDB migration、生成 bindings、架构文档、运维恢复说明和项目决策记录。 ## 验证 - `cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` - api-server / spacetime-client / spacetime-module / module-runtime 定向测试 - `npm run check:spacetime-schema` - `npm run check:spacetime-runtime-access` - `npm run check:server-rs-ddd` - `npm run check:encoding` - `git diff --check` Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/204 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
376 lines
11 KiB
Rust
376 lines
11 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, Serialize, Deserialize)]
|
|
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, Serialize, Deserialize)]
|
|
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>,
|
|
#[serde(default)]
|
|
pub phone_codes: Vec<AuthStoreProjectionPhoneCode>,
|
|
#[serde(default)]
|
|
pub wechat_states: Vec<AuthStoreProjectionWechatState>,
|
|
/// 当前进程工作集所基于的正式投影版本,用于事务内 CAS。
|
|
#[serde(default)]
|
|
pub base_updated_at_micros: i64,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AuthStoreProjectionPhoneCode {
|
|
pub phone_number: String,
|
|
pub scene: String,
|
|
pub verify_code_hash: String,
|
|
pub expires_at: String,
|
|
pub last_sent_at: String,
|
|
pub failed_attempts: u32,
|
|
pub provider_out_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AuthStoreProjectionWechatState {
|
|
pub wechat_state_id: String,
|
|
pub state_token: String,
|
|
pub redirect_path: String,
|
|
pub scene: String,
|
|
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,
|
|
}
|
|
|
|
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())
|
|
}
|