# Fonrey — 登录与账号认证数据模型(DATA_MODEL_LOGIN) > **所属系统**: Fonrey 房产经纪管理系统 > **版本**: v1.0 > **日期**: 2026-04-24 > **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理 > **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v2.0) > **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md` --- ## 变更历史 | 日期 | 变更人 | 变更内容 | |---|---|---| | 2026-04-30 | Atlas | 补充“变更历史”章节(文档治理) | ## 一、领域概览(Domain Overview) ### 核心概念 - **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一,username 固定为联系人手机号)和普通员工账号(username 固定为员工手机号)。 - **LoginAttempt(登录尝试记录)**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。 - **PasswordResetToken(密码重置令牌)**:~~通过邮件找回密码时生成的一次性令牌~~ 已废弃,详见 `SmsOtpRecord`。 - **SmsOtpRecord(短信验证码记录)**:找回密码和手机验证码登录时向用户手机号发送的 6 位一次性验证码。用 `scene` 字段区分场景(`password_reset` / `login`),有效期按场景不同(找回密码 10 分钟,验证码登录 5 分钟);找回密码验证通过后颁发 `sms_reset_token`(有效期 15 分钟),验证码登录验证通过后直接颁发 Session Token;使用后立即作废。 - **PasswordHistory(历史密码记录)**:保存最近 3 次密码哈希,用于防止重复使用历史密码。 ### 关键业务规则 1. **账号与员工强绑定**:每个登录账号 **必须** 与 `org.Staff` 中的员工档案 1:1 绑定(Tenant Admin 例外,可不绑定)。 2. **用户名规则统一为手机号**: - Tenant Admin:**固定为该租户联系人的手机号**(11 位数字),来源于 `public.tenants.contact_phone`,全局唯一,创建后不可更改 - 普通员工:**固定为员工手机号**(11 位数字),同租户内唯一,创建后不可变更 3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。 4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。 5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。 6. **不支持自助注册**:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。 --- ## 二、实体关系 ``` UserAccount │ ├── 1:1 ── org.Staff (实名绑定,普通员工必须) ├── 1:N ── LoginAttempt (登录审计记录) ├── 1:N ── SmsOtpRecord (短信验证码记录,找回密码用) ├── 1:N ── PasswordHistory (历史密码记录) └── M:1 ── UserAccount.created_by (创建人自引用) ``` ### Schema 归属 | 表 | Schema 位置 | 说明 | |----|------------|------| | `user_accounts` | 租户 Schema | 账号数据按租户隔离,username 唯一性在 Schema 维度生效 | | `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 | | `sms_otp_records` | 租户 Schema | 短信验证码记录,找回密码用 | | `password_histories` | 租户 Schema | 历史密码与账号绑定 | > **注意**:Tenant Code 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。 --- ## 三、Schema 定义 ### 3.1 `user_accounts` — 账号主表(租户 Schema) **表说明**:系统登录主体,每个租户内独立隔离,`username` 唯一性约束在 Schema 维度生效。 #### 字段定义 | 字段名 | 类型 | 约束 | 默认值 | 说明 | |--------|------|------|--------|------| | `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) | | `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名;普通员工为手机号(11 位数字);Tenant Admin 为自定义字符串;创建后不可更改 | | `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` | | `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;完全可选,在本系统无任何必须业务用途;若填写则在同租户内唯一 | | `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 | | `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 | | `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定(1:1);普通员工必须有值;Tenant Admin 可为空 | | `is_tenant_admin` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否为该租户的超级管理账号;每个租户最多 1 个(应用层约束) | | `status` | `VARCHAR(10)` | `NOT NULL`, `CHECK(status IN ('active','disabled','locked'))` | `'active'` | 账号状态;`locked` 为密码错误锁定,30 分钟自动恢复 | | `is_initial_password` | `BOOLEAN` | `NOT NULL` | `TRUE` | 初始密码标记;True 时登录成功后强制跳转修改密码页,不可跳过 | | `last_login` | `TIMESTAMPTZ` | `NULL` | `NULL` | 最后登录时间 | | `locked_until` | `TIMESTAMPTZ` | `NULL` | `NULL` | 锁定到期时间;到期后应用层将 status 恢复 active | | `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 账号创建时间 | | `updated_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 最后更新时间(触发器维护) | | `created_by` | `BIGINT` | `FK → user_accounts.id`, `NULL` | `NULL` | 创建人;普通员工由 Tenant Admin 创建;Tenant Admin 由平台运营创建(可为 NULL) | #### 唯一性约束 ```sql UNIQUE (username) -- Schema 内唯一,跨租户不冲突(django-tenants 机制保障) UNIQUE (email) -- 同租户内邮箱唯一(可为 NULL,NULL 不参与唯一性校验) UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文) UNIQUE (staff_id) -- 员工档案 1:1 绑定 ``` #### 索引 ```sql CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username); CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL; CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL; CREATE INDEX idx_user_accounts_status ON user_accounts (status); CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id); ``` #### Django Model 定义 ```python # apps/accounts/models.py from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.db import models class UserAccountManager(BaseUserManager): def create_user(self, username, password, **extra_fields): if not username: raise ValueError("username 不能为空") user = self.model(username=username, **extra_fields) user.set_password(password) user.save(using=self._db) return user class UserAccount(AbstractBaseUser): """ 租户级用户账号。 - 普通员工:username 固定为员工手机号(11 位数字) - Tenant Admin:username 固定为该租户联系人手机号(来源于 public.tenants.contact_phone) 注意:此表位于租户 Schema,username 唯一性约束在 Schema 维度生效。 """ username = models.CharField(max_length=30) email = models.EmailField(null=True, blank=True) phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文 phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引 staff = models.OneToOneField( 'org.Staff', null=True, blank=True, on_delete=models.SET_NULL, related_name='account', ) is_tenant_admin = models.BooleanField(default=False) status = models.CharField( max_length=10, choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')], default='active', ) is_initial_password = models.BooleanField(default=True) last_login = models.DateTimeField(null=True, blank=True) locked_until = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.SET_NULL, related_name='created_accounts', ) USERNAME_FIELD = 'username' REQUIRED_FIELDS = [] objects = UserAccountManager() class Meta: db_table = 'user_accounts' # Schema 内唯一约束 constraints = [ models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'), ] def __str__(self): return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})" def is_locked(self) -> bool: """检查账号是否处于锁定状态(含自动过期判断)""" from django.utils import timezone if self.status == 'locked': if self.locked_until and timezone.now() >= self.locked_until: # 锁定已到期,应用层自动恢复(实际由 service 层处理) return False return True return False ``` --- ### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema) **表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。 #### 字段定义 | 字段名 | 类型 | 约束 | 默认值 | 说明 | |--------|------|------|--------|------| | `id` | `BIGSERIAL` | `NOT NULL` | — | 自增主键(与 attempted_at 组成复合 PK) | | `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间(分区键,必须在复合主键中) | | `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) | | `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6) | | `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-Agent(Electron 版本信息) | | `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 | | `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 | > ⚠️ **分区说明**:`login_attempts` 为高写入审计表,采用 `PARTITION BY RANGE (attempted_at)` 按月分区。主键为 `(id, attempted_at)` 复合主键(分区表规范:主键必须包含分区键)。 **DDL**: ```sql CREATE TABLE login_attempts ( id BIGSERIAL NOT NULL, attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键 username VARCHAR(30) NOT NULL, ip_address INET NOT NULL, user_agent TEXT, success BOOLEAN NOT NULL, failure_reason VARCHAR(30) CHECK (failure_reason IS NULL OR failure_reason IN ( 'wrong_password','wrong_captcha','account_locked', 'account_disabled','tenant_not_found', 'wrong_otp','otp_expired' )), PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键 ) PARTITION BY RANGE (attempted_at); CREATE TABLE login_attempts_2026_04 PARTITION OF login_attempts FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); CREATE TABLE login_attempts_2026_05 PARTITION OF login_attempts FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT; ``` **`failure_reason` 枚举值**: | 值 | 含义 | |----|------| | `wrong_password` | 用户名或密码错误 | | `wrong_captcha` | 行为验证码失败 | | `account_locked` | 账号已锁定 | | `account_disabled` | 账号已停用 | | `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) | | `wrong_otp` | 短信验证码错误 | | `otp_expired` | 短信验证码已过期 | #### 索引 ```sql CREATE INDEX idx_login_attempts_username ON login_attempts (username); CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address); CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC); -- 复合索引:按账号查询最近失败记录(锁定判断场景) CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC); ``` #### Django Model 定义 ```python class LoginAttempt(models.Model): """ 登录尝试审计记录。 - 合规保留周期:≥ 90 天 - 注意:failure_reason 不得存储密码明文(含错误密码) """ FAILURE_REASONS = [ ('wrong_password', '用户名或密码错误'), ('wrong_captcha', '行为验证码失败'), ('account_locked', '账号已锁定'), ('account_disabled', '账号已停用'), ('tenant_not_found', '租户不存在'), ('wrong_otp', '短信验证码错误'), ('otp_expired', '短信验证码已过期'), ] username = models.CharField(max_length=30) ip_address = models.GenericIPAddressField() user_agent = models.TextField(null=True, blank=True) success = models.BooleanField() failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS) attempted_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'login_attempts' indexes = [ models.Index(fields=['username']), models.Index(fields=['ip_address']), models.Index(fields=['-attempted_at']), models.Index(fields=['username', 'success', '-attempted_at'], name='idx_login_attempts_fail_check'), ] def __str__(self): return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}" ``` --- ### 3.3 `sms_otp_records` — 短信验证码记录表(租户 Schema) **表说明**:记录找回密码和手机验证码登录两个场景中,向用户手机号发送的短信验证码及其状态。用 `scene` 字段区分场景(`password_reset` / `login`),有效期和发送频率上限按场景独立控制。原 `password_reset_tokens`(邮件令牌)已废弃,由本表替代。 #### 字段定义 | 字段名 | 类型 | 约束 | 默认值 | 说明 | |--------|------|------|--------|------| | `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 | | `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 | | `phone_hash` | `VARCHAR(64)` | `NOT NULL` | — | 收码手机号 SHA-256 哈希(与账号的 `phone_hash` 一致,冗余存储便于限频查询) | | `scene` | `VARCHAR(20)` | `NOT NULL` | — | 使用场景:`password_reset`(找回密码)或 `login`(验证码登录) | | `otp_hash` | `VARCHAR(128)` | `NOT NULL` | — | 验证码 PBKDF2 哈希(禁止明文存储,服务端校验时重新哈希比对) | | `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`password_reset`:`created_at + 10 分钟`;`login`:`created_at + 5 分钟`) | | `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已验证通过;通过后立即置 True,防止重放攻击 | | `verify_attempts` | `SMALLINT` | `NOT NULL` | `0` | 已尝试验证次数;≥ 5 次后本条记录强制作废(`is_used = TRUE`) | | `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 | #### 索引 ```sql CREATE INDEX idx_sms_otp_user ON sms_otp_records (user_id, created_at DESC); CREATE INDEX idx_sms_otp_phone ON sms_otp_records (phone_hash, created_at DESC); CREATE INDEX idx_sms_otp_active ON sms_otp_records (user_id, expires_at) WHERE is_used = FALSE; ``` #### Django Model 定义 ```python class SmsOtpRecord(models.Model): """ 短信验证码记录(找回密码 + 验证码登录共用)。 安全约束: - OTP 明文仅在发送短信时生成,不持久化;仅存 PBKDF2 哈希 - 单条记录验证次数 ≥ 5 次后强制作废(is_used=True) - 同一手机号 1 小时内按 scene 独立限频(password_reset ≤ 5 次,login ≤ 10 次) """ SCENE_CHOICES = [ ('password_reset', '找回密码'), ('login', '验证码登录'), ] user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='sms_otp_records') phone_hash = models.CharField(max_length=64) # SHA-256 哈希,不暴露手机号原文 scene = models.CharField(max_length=20, choices=SCENE_CHOICES) # 区分场景 otp_hash = models.CharField(max_length=128) # PBKDF2 哈希,禁止明文存储 expires_at = models.DateTimeField() # password_reset: +10min; login: +5min is_used = models.BooleanField(default=False) verify_attempts = models.SmallIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'sms_otp_records' indexes = [ models.Index(fields=['user', '-created_at']), models.Index(fields=['phone_hash', '-created_at']), ] def is_valid(self) -> bool: from django.utils import timezone return not self.is_used and self.verify_attempts < 5 and timezone.now() < self.expires_at def mark_used(self): self.is_used = True self.save(update_fields=['is_used']) ``` --- ### 3.4 `password_histories` — 历史密码记录表(租户 Schema) **表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。 #### 字段定义 | 字段名 | 类型 | 约束 | 默认值 | 说明 | |--------|------|------|--------|------| | `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 | | `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 | | `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 | | `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) | #### 索引 ```sql CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC); ``` #### Django Model 定义 ```python class PasswordHistory(models.Model): """ 历史密码记录,每个账号保留最近 N 条(默认 3 条)。 新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025)。 """ user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories') password_hash = models.CharField(max_length=128) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'password_histories' ordering = ['-created_at'] indexes = [ models.Index(fields=['user', '-created_at']), ] ``` --- ## 四、Redis 缓存结构(辅助状态,非持久化) 以下 Redis Key 不存入 PostgreSQL,属于运行时状态,需与数据库状态保持最终一致: | Key 格式 | 类型 | TTL | 说明 | |----------|------|-----|------| | `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token;验证通过后生成 `captcha_pass_token` | | `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 | | `login_fail:{tenant_id}:{username}` | STRING(计数) | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定;TTL 30 分钟自动清零 | | `sms_limit:password_reset:{phone}` | STRING(计数) | 1 小时 | 找回密码场景:同一手机号短信发送次数;上限 **5 次**/小时 | | `sms_limit:login:{phone}` | STRING(计数) | 1 小时 | 验证码登录场景:同一手机号短信发送次数;上限 **10 次**/小时 | | `sms_reset_token:{token}` | STRING(user_id) | 15 分钟 | 验证码通过后颁发的一次性重置凭证;用于步骤三提交新密码;使用后立即删除 | | `tenant_verify_ip:{ip}` | STRING(计数) | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 | > **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化,Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。 --- ## 五、账号创建流程与状态机 ### 5.1 账号状态机 ``` ┌─────────────────────────────────────┐ │ 账号生命周期状态机 │ └─────────────────────────────────────┘ [创建账号] │ is_initial_password=True, status=active ▼ [初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页] │ │ 修改成功 │ ▼ │ [正常使用态] │ status=active │ is_initial_password=False │ ├── 密码错误 ≥ 5 次 ──────────────────► [锁定态] │ status=locked │ locked_until = now+30min │ │ │ ┌───────────────┤ │ │ 30分钟到期 │ 管理员手动解锁 │ ▼ ▼ │ [正常使用态] ◄─── [管理员操作] │ └── 员工离职 / 管理员停用 ──► [停用态] status=disabled │ 员工复职/管理员恢复 │ ▼ [正常使用态] ``` ### 5.2 账号创建触发时机 | 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 | |----------|----------|--------|--------------|---------| | Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | **固定为该租户联系人手机号**(11 位数字,来源于 `public.tenants.contact_phone`) | **系统统一固定初始密码**(如 `Fonrey@2025`,由平台部署配置设定) | | 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统(Tenant Admin 触发) | 固定为员工手机号(11 位) | 系统统一初始密码(部署配置) | --- ## 六、关联约束与数据完整性 ### 6.1 与 `org.Staff` 的关联规则 ``` org_staff (1) ──── (0..1) user_accounts ``` - 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active - Tenant Admin:`staff_id` **可为空**(平台运营账号可不绑定实名档案) - 员工离职时(`org.Staff.status` → `resigned`),触发账号 `status` → `disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖) - 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留 ### 6.2 跨 App 依赖方向 ``` accounts ──► org (单向依赖:accounts.UserAccount.staff_id → org.Staff) org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查) ``` > **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。 --- ## 七、迁移说明(Django Migrations) ### 初始迁移顺序 ``` 0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在) 0002_login_attempts.py # LoginAttempt 表 0003_sms_otp_records.py # SmsOtpRecord 表(替代原 password_reset_tokens) 0004_password_histories.py # PasswordHistory 表 ``` ### 注意事项 - `accounts` App 的迁移依赖 `org` App(`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org` 在 `accounts` 之前 - 所有迁移均在**租户 Schema** 下执行(`django-tenants` 的 `migrate_schemas` 命令) - 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择) --- ## 八、设计决策说明(ADR) | 决策 | 选择 | 理由 | |------|------|------| | 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 | | `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 | | 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) | | `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username) | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 | | 找回密码机制 | 短信验证码(`SmsOtpRecord`) | 经纪人普遍无邮箱;手机号是本系统唯一已知联系方式,短信核验更直接;废弃邮件找回路径,减少外部依赖(SMTP) | | 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 | | 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |