Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md

471 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Fonrey — 登录与账号认证数据模型DATA_MODEL_LOGIN
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
---
## 一、领域概览Domain Overview
### 核心概念
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一和普通员工账号username 固定为手机号)。
- **LoginAttempt登录尝试记录**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
- **PasswordResetToken密码重置令牌**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
- **PasswordHistory历史密码记录**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
### 关键业务规则
1. **账号与员工强绑定**:每个登录账号 **必须**`org.Staff` 中的员工档案 1:1 绑定Tenant Admin 例外,可不绑定)。
2. **用户名规则差异化**
- Tenant Admin由平台运营自定义字母开头6~30 位,含字母/数字/下划线)
- 普通员工:**固定为员工手机号**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 ── PasswordResetToken (密码重置令牌)
├── 1:N ── PasswordHistory (历史密码记录)
└── M:1 ── UserAccount.created_by (创建人自引用)
```
### Schema 归属
| 表 | Schema 位置 | 说明 |
|----|------------|------|
| `user_accounts` | 租户 Schema | 账号数据按租户隔离username 唯一性在 Schema 维度生效 |
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
> **注意**Tenant ID 验证相关逻辑在 **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) -- 同租户内邮箱唯一(可为 NULLNULL 不参与唯一性校验)
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 Adminusername 由平台运营自定义字母开头6~30 位)
注意:此表位于租户 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` | `PRIMARY KEY` | — | 自增主键 |
| `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` | 失败原因;可选值见下方枚举 |
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
**`failure_reason` 枚举值**
| 值 | 含义 |
|----|------|
| `wrong_password` | 用户名或密码错误 |
| `wrong_captcha` | 行为验证码失败 |
| `account_locked` | 账号已锁定 |
| `account_disabled` | 账号已停用 |
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
#### 索引
```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', '租户不存在'),
]
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 `password_reset_tokens` — 密码重置令牌表(租户 Schema
**表说明**用于通过邮件找回密码的一次性令牌。单次有效30 分钟过期。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成86 字符),全局唯一 |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True防止重放攻击 |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
#### 索引
```sql
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
```
#### Django Model 定义
```python
class PasswordResetToken(models.Model):
"""
密码重置令牌。
安全约束:
- Token 单次有效is_used=True 后立即失效)
- 有效期 30 分钟
- 同一账号 1 小时内最多生成 3 个服务层限频Redis 计数)
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_reset_tokens'
indexes = [
models.Index(fields=['user_id']),
]
def is_valid(self) -> bool:
from django.utils import timezone
return not self.is_used and timezone.now() < self.expires_at
```
---
### 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 分钟自动清零 |
| `recover_email:{email}` | STRING计数 | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
| `recover_reset:{account_id}` | STRING计数 | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
| `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 | 平台运营在系统后台开通租户时 | 平台运营 | 自定义字母开头6~30 位) | 平台运营自定义 |
| 普通员工 | 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_password_reset_tokens.py # PasswordResetToken 表
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 字符串字段保证审计完整性 |
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |