27 KiB
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 次密码哈希,用于防止重复使用历史密码。
关键业务规则
- 账号与员工强绑定:每个登录账号 必须 与
org.Staff中的员工档案 1:1 绑定(Tenant Admin 例外,可不绑定)。 - 用户名规则统一为手机号:
- Tenant Admin:固定为该租户联系人的手机号(11 位数字),来源于
public.tenants.contact_phone,全局唯一,创建后不可更改 - 普通员工:固定为员工手机号(11 位数字),同租户内唯一,创建后不可变更
- Tenant Admin:固定为该租户联系人的手机号(11 位数字),来源于
- 初始密码强制修改:新账号及密码重置后,
is_initial_password = True,首次登录必须修改密码,不可跳过。 - 账号锁定机制:同一账号连续密码错误 ≥ 5 次,状态置为
locked,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。 - 员工离职联动:员工离职时,对应账号的
status自动置为disabled,不可登录,历史操作记录保留。 - 不支持自助注册:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。
二、实体关系
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) |
唯一性约束
UNIQUE (username) -- Schema 内唯一,跨租户不冲突(django-tenants 机制保障)
UNIQUE (email) -- 同租户内邮箱唯一(可为 NULL,NULL 不参与唯一性校验)
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
UNIQUE (staff_id) -- 员工档案 1:1 绑定
索引
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 定义
# 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:
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 |
短信验证码已过期 |
索引
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 定义
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() |
创建时间 |
索引
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 定义
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() |
记录时间(密码修改时间) |
索引
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
Django Model 定义
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(由orgApp 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 表
注意事项
accountsApp 的迁移依赖orgApp(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 故障时锁定状态不丢失 |