Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md
2026-04-30 20:33:51 +08:00

27 KiB
Raw Blame History

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.Staff1: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 次,状态置为 locked30 分钟后自动恢复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 Schemashared_apps),使用 django-tenantsTenantModel,不在本文档范围内,详见 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)                -- 同租户内邮箱唯一(可为 NULLNULL 不参与唯一性校验)
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 Adminusername 固定为该租户联系人手机号(来源于 public.tenants.contact_phone
    注意:此表位于租户 Schemausername 唯一性约束在 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-AgentElectron 版本信息)
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_resetcreated_at + 10 分钟logincreated_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} STRINGuser_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 Adminstaff_id 可为空(平台运营账号可不绑定实名档案)
  • 员工离职时(org.Staff.statusresigned),触发账号 statusdisabled(由 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 Apporg.Staff 表须先创建),需在 INSTALLED_APPS 中确保 orgaccounts 之前
  • 所有迁移均在租户 Schema 下执行(django-tenantsmigrate_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 故障时锁定状态不丢失