登录模块审核
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
|
||||
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
|
||||
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v2.0)
|
||||
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
|
||||
|
||||
---
|
||||
@@ -13,17 +13,18 @@
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一)和普通员工账号(username 固定为手机号)。
|
||||
- **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一,username 固定为联系人手机号)和普通员工账号(username 固定为员工手机号)。
|
||||
- **LoginAttempt(登录尝试记录)**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
|
||||
- **PasswordResetToken(密码重置令牌)**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
|
||||
- **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:由平台运营自定义(字母开头,6~30 位,含字母/数字/下划线)
|
||||
- 普通员工:**固定为员工手机号**(11 位数字),创建后不可变更
|
||||
2. **用户名规则统一为手机号**:
|
||||
- Tenant Admin:**固定为该租户联系人的手机号**(11 位数字),来源于 `public.tenants.contact_phone`,全局唯一,创建后不可更改
|
||||
- 普通员工:**固定为员工手机号**(11 位数字),同租户内唯一,创建后不可变更
|
||||
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
|
||||
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。
|
||||
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
|
||||
@@ -38,7 +39,7 @@ UserAccount
|
||||
│
|
||||
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
|
||||
├── 1:N ── LoginAttempt (登录审计记录)
|
||||
├── 1:N ── PasswordResetToken (密码重置令牌)
|
||||
├── 1:N ── SmsOtpRecord (短信验证码记录,找回密码用)
|
||||
├── 1:N ── PasswordHistory (历史密码记录)
|
||||
└── M:1 ── UserAccount.created_by (创建人自引用)
|
||||
```
|
||||
@@ -49,10 +50,10 @@ UserAccount
|
||||
|----|------------|------|
|
||||
| `user_accounts` | 租户 Schema | 账号数据按租户隔离,username 唯一性在 Schema 维度生效 |
|
||||
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
|
||||
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
|
||||
| `sms_otp_records` | 租户 Schema | 短信验证码记录,找回密码用 |
|
||||
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
|
||||
|
||||
> **注意**:Tenant ID 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。
|
||||
> **注意**:Tenant Code 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。
|
||||
|
||||
---
|
||||
|
||||
@@ -69,7 +70,7 @@ UserAccount
|
||||
| `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` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
|
||||
| `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 可为空 |
|
||||
@@ -122,8 +123,8 @@ class UserAccountManager(BaseUserManager):
|
||||
class UserAccount(AbstractBaseUser):
|
||||
"""
|
||||
租户级用户账号。
|
||||
- 普通员工:username 固定为手机号(11 位数字)
|
||||
- Tenant Admin:username 由平台运营自定义(字母开头,6~30 位)
|
||||
- 普通员工:username 固定为员工手机号(11 位数字)
|
||||
- Tenant Admin:username 固定为该租户联系人手机号(来源于 public.tenants.contact_phone)
|
||||
注意:此表位于租户 Schema,username 唯一性约束在 Schema 维度生效。
|
||||
"""
|
||||
username = models.CharField(max_length=30)
|
||||
@@ -212,7 +213,8 @@ CREATE TABLE login_attempts (
|
||||
failure_reason VARCHAR(30)
|
||||
CHECK (failure_reason IS NULL OR failure_reason IN (
|
||||
'wrong_password','wrong_captcha','account_locked',
|
||||
'account_disabled','tenant_not_found'
|
||||
'account_disabled','tenant_not_found',
|
||||
'wrong_otp','otp_expired'
|
||||
)),
|
||||
PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (attempted_at);
|
||||
@@ -233,6 +235,8 @@ CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT;
|
||||
| `account_locked` | 账号已锁定 |
|
||||
| `account_disabled` | 账号已停用 |
|
||||
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
|
||||
| `wrong_otp` | 短信验证码错误 |
|
||||
| `otp_expired` | 短信验证码已过期 |
|
||||
|
||||
#### 索引
|
||||
|
||||
@@ -259,6 +263,8 @@ class LoginAttempt(models.Model):
|
||||
('account_locked', '账号已锁定'),
|
||||
('account_disabled', '账号已停用'),
|
||||
('tenant_not_found', '租户不存在'),
|
||||
('wrong_otp', '短信验证码错误'),
|
||||
('otp_expired', '短信验证码已过期'),
|
||||
]
|
||||
|
||||
username = models.CharField(max_length=30)
|
||||
@@ -284,9 +290,9 @@ class LoginAttempt(models.Model):
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema)
|
||||
### 3.3 `sms_otp_records` — 短信验证码记录表(租户 Schema)
|
||||
|
||||
**表说明**:用于通过邮件找回密码的一次性令牌。单次有效,30 分钟过期。
|
||||
**表说明**:记录找回密码和手机验证码登录两个场景中,向用户手机号发送的短信验证码及其状态。用 `scene` 字段区分场景(`password_reset` / `login`),有效期和发送频率上限按场景独立控制。原 `password_reset_tokens`(邮件令牌)已废弃,由本表替代。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
@@ -294,45 +300,61 @@ class LoginAttempt(models.Model):
|
||||
|--------|------|------|--------|------|
|
||||
| `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,防止重放攻击 |
|
||||
| `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 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;
|
||||
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 PasswordResetToken(models.Model):
|
||||
class SmsOtpRecord(models.Model):
|
||||
"""
|
||||
密码重置令牌。
|
||||
短信验证码记录(找回密码 + 验证码登录共用)。
|
||||
安全约束:
|
||||
- Token 单次有效(is_used=True 后立即失效)
|
||||
- 有效期 30 分钟
|
||||
- 同一账号 1 小时内最多生成 3 个(服务层限频,Redis 计数)
|
||||
- OTP 明文仅在发送短信时生成,不持久化;仅存 PBKDF2 哈希
|
||||
- 单条记录验证次数 ≥ 5 次后强制作废(is_used=True)
|
||||
- 同一手机号 1 小时内按 scene 独立限频(password_reset ≤ 5 次,login ≤ 10 次)
|
||||
"""
|
||||
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)
|
||||
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 = 'password_reset_tokens'
|
||||
db_table = 'sms_otp_records'
|
||||
indexes = [
|
||||
models.Index(fields=['user_id']),
|
||||
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 timezone.now() < self.expires_at
|
||||
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'])
|
||||
```
|
||||
|
||||
---
|
||||
@@ -387,8 +409,9 @@ class PasswordHistory(models.Model):
|
||||
| `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 次/小时 |
|
||||
| `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` 字段恢复锁定状态判断。
|
||||
@@ -435,8 +458,8 @@ class PasswordHistory(models.Model):
|
||||
### 5.2 账号创建触发时机
|
||||
|
||||
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|
||||
|----------|----------|--------|--------------|---------|
|
||||
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义(字母开头,6~30 位) | 平台运营自定义 |
|
||||
|----------|----------|--------|--------------|---------|
|
||||
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | **固定为该租户联系人手机号**(11 位数字,来源于 `public.tenants.contact_phone`) | **系统统一固定初始密码**(如 `Fonrey@2025`,由平台部署配置设定) |
|
||||
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统(Tenant Admin 触发) | 固定为员工手机号(11 位) | 系统统一初始密码(部署配置) |
|
||||
|
||||
---
|
||||
@@ -472,7 +495,7 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
|
||||
```
|
||||
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
|
||||
0002_login_attempts.py # LoginAttempt 表
|
||||
0003_password_reset_tokens.py # PasswordResetToken 表
|
||||
0003_sms_otp_records.py # SmsOtpRecord 表(替代原 password_reset_tokens)
|
||||
0004_password_histories.py # PasswordHistory 表
|
||||
```
|
||||
|
||||
@@ -492,5 +515,6 @@ org ──► accounts (反向触发,通过 Service 层调用,不通过 FK
|
||||
| `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 故障时锁定状态不丢失 |
|
||||
|
||||
@@ -877,25 +877,25 @@ _本文档版本 v1.1 | 作者: Backend Architect | 更新时间 2026-04-24_
|
||||
```sql
|
||||
-- permission_defs
|
||||
CREATE TABLE permission_defs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(150) NOT NULL UNIQUE,
|
||||
module VARCHAR(50) NOT NULL,
|
||||
sub_module VARCHAR(50) NOT NULL DEFAULT '',
|
||||
group_name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')),
|
||||
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
integer_min INTEGER,
|
||||
integer_max INTEGER,
|
||||
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb,
|
||||
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[],
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
code VARCHAR(150) NOT NULL UNIQUE, -- 权限码,格式 {module}.{sub_module}.{action}[.{qualifier}],全局唯一,创建后不可修改
|
||||
module VARCHAR(50) NOT NULL, -- 一级模块标识,如 property / client / org
|
||||
sub_module VARCHAR(50) NOT NULL DEFAULT '', -- 二级子模块标识;无子模块时为空字符串
|
||||
group_name VARCHAR(100) NOT NULL, -- 权限分组显示名称(管理界面分组展示用)
|
||||
name VARCHAR(200) NOT NULL, -- 权限项中文名称(管理界面展示)
|
||||
description TEXT NOT NULL DEFAULT '', -- 权限项说明(管理界面 tooltip 文案)
|
||||
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')), -- 权限值类型:BOOLEAN=开关 / SCOPE=数据范围 / INTEGER=数量上限
|
||||
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb, -- SCOPE 类型可选范围列表(JSON 数组);非 SCOPE 类型为空数组
|
||||
integer_min INTEGER, -- INTEGER 类型最小允许值;其他类型为 NULL
|
||||
integer_max INTEGER, -- INTEGER 类型最大允许值;其他类型为 NULL
|
||||
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb, -- 权限默认值,格式 {"v": false/scope_str/int}
|
||||
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[], -- 可配置此权限的角色分类白名单;空数组=无限制
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- 同分组内排序权重(数值越小越靠前)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=已下线,前端配置页隐藏
|
||||
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE, -- 是否已废弃;废弃后不可被新角色引用
|
||||
version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本号(每次更新+1)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
CONSTRAINT chk_code_format CHECK (code ~ '^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$')
|
||||
);
|
||||
CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE;
|
||||
@@ -903,18 +903,18 @@ CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_a
|
||||
|
||||
-- roles
|
||||
CREATE TABLE roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
|
||||
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
name VARCHAR(100) NOT NULL, -- 角色显示名称(同租户内唯一,软删除后不参与唯一性校验)
|
||||
category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')), -- 角色分类:agent=经纪人 / store_manager=门店管理 / director=区域管理 / operator=运营职能 / custom=自定义
|
||||
description TEXT NOT NULL DEFAULT '', -- 角色说明文案
|
||||
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 模板来源(自引用);从某内置角色克隆时记录;NULL=无模板
|
||||
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE, -- 是否系统内置角色;TRUE=不可删除、不可改名
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=角色已停用,员工不可再分配此角色
|
||||
created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 创建人(关联 staff 表);系统内置角色为 NULL
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
|
||||
deleted_at TIMESTAMPTZ -- 软删除时间戳,NULL=未删除,非NULL=已软删除
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_roles_name_active ON roles(name) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_roles_category ON roles(category) WHERE deleted_at IS NULL;
|
||||
@@ -922,13 +922,13 @@ CREATE INDEX idx_roles_template ON roles(template_role_id);
|
||||
|
||||
-- role_permissions
|
||||
CREATE TABLE role_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, -- 关联角色(角色删除则权限配置同步级联清除)
|
||||
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义(有角色引用时权限项不可删除)
|
||||
value JSONB NOT NULL, -- 权限配置值,格式 {"v": false/scope_str/int},与 permission_defs.value_type 对应
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL -- 最后修改人(关联 staff 表)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id);
|
||||
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
|
||||
@@ -936,14 +936,14 @@ CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id);
|
||||
|
||||
-- staff_roles
|
||||
CREATE TABLE staff_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
valid_from DATE,
|
||||
valid_until DATE
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则角色分配同步级联删除)
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, -- 角色 ID(有员工使用的角色不可删除)
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE, -- 是否主角色;每员工同时仅可有 1 个主角色(唯一索引保障)
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 角色分配时间(系统自动)
|
||||
assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 分配操作人(关联 staff 表);NULL=系统自动分配
|
||||
valid_from DATE, -- 角色有效期开始日期;NULL=立即生效
|
||||
valid_until DATE -- 角色有效期结束日期;NULL=永久有效
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id);
|
||||
CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE;
|
||||
@@ -951,32 +951,32 @@ CREATE INDEX idx_staff_roles_role ON staff_roles(role_id);
|
||||
|
||||
-- staff_permission_overrides
|
||||
CREATE TABLE staff_permission_overrides (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
|
||||
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT,
|
||||
value JSONB NOT NULL,
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则个人覆盖配置同步级联删除)
|
||||
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义
|
||||
value JSONB NOT NULL, -- 覆盖配置值,格式与 role_permissions.value 一致
|
||||
override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE'
|
||||
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')),
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
modified_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')), -- 覆盖模式:REPLACE=完全替换角色权限 / RESTRICT=向下收紧 / GRANT=向上提升
|
||||
reason TEXT NOT NULL DEFAULT '', -- 覆盖理由(操作审计留存)
|
||||
modified_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 修改操作人(关联 staff 表)
|
||||
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 修改时间(系统自动)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id);
|
||||
CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id);
|
||||
|
||||
-- staff_data_scopes
|
||||
CREATE TABLE staff_data_scopes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
|
||||
scope_type VARCHAR(20) NOT NULL
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID
|
||||
scope_type VARCHAR(20) NOT NULL -- 数据范围类型:self=本人 / group=小组 / store=门店 / area=大区 / region=区域 / company=全公司 / custom_unit=自定义单元
|
||||
CHECK (scope_type IN ('self','group','store','area','region','company','custom_unit')),
|
||||
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT,
|
||||
is_readable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_writable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT, -- 自定义组织单元;scope_type=custom_unit 时必填,其他为 NULL
|
||||
is_readable BOOLEAN NOT NULL DEFAULT TRUE, -- 是否有读权限
|
||||
is_writable BOOLEAN NOT NULL DEFAULT FALSE, -- 是否有写权限
|
||||
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 授权操作人(关联 staff 表)
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 授权时间(系统自动)
|
||||
expires_at TIMESTAMPTZ, -- 到期时间;NULL=永久有效
|
||||
reason TEXT NOT NULL DEFAULT '', -- 数据范围授权理由(操作审计留存)
|
||||
CONSTRAINT chk_custom_unit_has_org CHECK (
|
||||
(scope_type = 'custom_unit' AND org_unit_id IS NOT NULL) OR
|
||||
(scope_type <> 'custom_unit')
|
||||
@@ -988,22 +988,22 @@ CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expi
|
||||
|
||||
-- permission_change_logs (append-only, no deleted_at)
|
||||
CREATE TABLE permission_change_logs (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(), -- 主键(与 operated_at 组成复合主键,分区表要求)
|
||||
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
|
||||
target_type VARCHAR(30) NOT NULL
|
||||
target_type VARCHAR(30) NOT NULL -- 操作对象类型:role / role_permission / staff_role / staff_override / staff_scope
|
||||
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
|
||||
target_id UUID NOT NULL,
|
||||
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
|
||||
permission_code VARCHAR(150),
|
||||
action VARCHAR(20) NOT NULL
|
||||
target_id UUID NOT NULL, -- 操作对象 ID
|
||||
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, -- 被操作员工 ID(如分配/撤销角色时的目标员工)
|
||||
role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 被操作角色 ID
|
||||
permission_code VARCHAR(150), -- 操作涉及的权限码(冗余存储,避免关联查询)
|
||||
action VARCHAR(20) NOT NULL -- 操作类型:create=新建 / update=修改 / delete=删除 / assign=分配 / revoke=撤销
|
||||
CHECK (action IN ('create','update','delete','assign','revoke')),
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
|
||||
operator_ip INET,
|
||||
user_agent TEXT,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
old_value JSONB, -- 变更前值;create 时为 NULL
|
||||
new_value JSONB, -- 变更后值;delete 时为 NULL
|
||||
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, -- 操作人员工 ID(RESTRICT:操作记录保留,操作人不可删除)
|
||||
operator_ip INET, -- 操作人来源 IP
|
||||
user_agent TEXT, -- 操作人客户端 UA
|
||||
reason TEXT NOT NULL DEFAULT '', -- 操作理由(可选,审计留存)
|
||||
|
||||
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
|
||||
) PARTITION BY RANGE (operated_at);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Fonrey — Public Schema 数据模型
|
||||
|
||||
> **作者**: Backend Architect
|
||||
> **版本**: v1.2
|
||||
> **日期**: 2026-04-26
|
||||
> **版本**: v1.3
|
||||
> **日期**: 2026-04-30
|
||||
> **权威源**: 本文件是 `public` schema 所有表的唯一权威定义
|
||||
> **设计依据**: 系统管理模块 PRD(`PRD/系统管理/系统管理模块PRD.md`);客户端发布管理模块 PRD(`PRD/发布管理/客户端发布管理模块PRD.md`)
|
||||
> **索引文档**: [`DATA_MODEL.md §三`](./DATA_MODEL.md)(仅保留摘要索引,开发以本文件为准)
|
||||
@@ -87,11 +87,13 @@ PostgreSQL Instance
|
||||
-- 租户主表(每家房产经纪公司一条记录)
|
||||
CREATE TABLE public.tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符,创建后不可修改
|
||||
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符,创建后不可修改;命名规则:t_{uuid前8位},如 t_3f2a1b4c
|
||||
tenant_code CHAR(12) UNIQUE NOT NULL, -- 对外暴露的 12 位纯数字识别码,如 202500010001;用户登录时输入,由平台运营在注册时生成;创建后不可修改
|
||||
name VARCHAR(255) NOT NULL, -- 公司名称
|
||||
short_name VARCHAR(100), -- 简称/品牌名
|
||||
contact_name VARCHAR(100) NOT NULL, -- 主联系人姓名
|
||||
contact_email VARCHAR(254) NOT NULL, -- 联系邮箱(接收通知/欢迎邮件)
|
||||
contact_phone CHAR(11) NOT NULL, -- 联系人手机号(11位纯数字);系统开通租户时自动以此手机号创建 Tenant Admin 登录账号;必填
|
||||
contact_email VARCHAR(254), -- 联系邮箱(接收通知/欢迎邮件;选填,为空时 Tenant Admin 无法自助找回密码)
|
||||
region VARCHAR(100), -- 所在地区(省市,如「上海市」)
|
||||
plan VARCHAR(20) NOT NULL DEFAULT 'basic'
|
||||
CHECK (plan IN ('basic','professional','enterprise')),
|
||||
@@ -857,6 +859,7 @@ ORDER BY created_at DESC;
|
||||
| `UPDATE public.tenant_status_logs` | append-only 审计表 |
|
||||
| `DELETE FROM public.platform_audit_logs` | append-only 审计表 |
|
||||
| `UPDATE public.tenants SET schema_name = ...` | schema 名绑定 PG 物理 schema |
|
||||
| `UPDATE public.tenants SET tenant_code = ...` | 12 位识别码一旦发放给用户不可更改,避免经纪人无法登录 |
|
||||
| `UPDATE public.domains SET domain = ...` | 域名路由不可变 |
|
||||
| `UPDATE public.client_releases SET version = ...` | 版本号创建后不可修改,变更须新建记录 |
|
||||
| 在租户 schema 中创建 `client_releases` 副本 | 本表属于 public schema,多租户共享,禁止下沉到租户层 |
|
||||
|
||||
@@ -50,14 +50,14 @@ apps/setting/
|
||||
-- 研发预置,租户不可修改分组本身
|
||||
-- ============================================================
|
||||
CREATE TABLE lookup_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
module VARCHAR(50) NOT NULL, -- 'client' | 'property'
|
||||
key VARCHAR(100) NOT NULL, -- 'source' | 'follow_purpose'
|
||||
label_zh VARCHAR(50) NOT NULL, -- 界面显示名称,如「客源来源」
|
||||
description TEXT, -- 说明文案(前端 tooltip 使用)
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0, -- 排序权重(数值越小越靠前)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
UNIQUE (module, key)
|
||||
);
|
||||
```
|
||||
@@ -80,16 +80,16 @@ CREATE TABLE lookup_groups (
|
||||
-- 租户管理员可增删排序;is_system=True 的预制项不可物理删除
|
||||
-- ============================================================
|
||||
CREATE TABLE lookup_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES lookup_groups(id) ON DELETE CASCADE,
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
group_id UUID NOT NULL REFERENCES lookup_groups(id) ON DELETE CASCADE, -- 所属枚举分组(关联 lookup_groups)
|
||||
value VARCHAR(100) NOT NULL, -- 存储值,英文 snake_case(如 'door_to_door')
|
||||
label_zh VARCHAR(50) NOT NULL, -- 显示文本(如「上门」)
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- True=系统预制,不可删除,仅可停用
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE 后前端下拉不展示,历史数据保留并追加「(已停用)」后缀
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0, -- 排序权重(数值越小越靠前)
|
||||
created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 系统预制时为 NULL
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
UNIQUE (group_id, value)
|
||||
);
|
||||
|
||||
@@ -153,14 +153,14 @@ CREATE INDEX idx_lookup_items_group_active
|
||||
-- 租户标量配置(键值对)(Tenant Schema)
|
||||
-- ============================================================
|
||||
CREATE TABLE tenant_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
category VARCHAR(50) NOT NULL, -- 配置分类:'client' | 'property' | 'showroom'
|
||||
key VARCHAR(100) NOT NULL, -- 配置 key,如 'duplicate_check_scope'
|
||||
value JSONB NOT NULL, -- 存储任意类型(bool/int/str),如 {"v": "self"}
|
||||
value_type VARCHAR(20) NOT NULL -- 'bool' | 'int' | 'string' | 'enum'(用于前端渲染控件)
|
||||
CHECK (value_type IN ('bool', 'int', 'string', 'enum')),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
UNIQUE (category, key)
|
||||
);
|
||||
|
||||
@@ -193,7 +193,7 @@ CREATE INDEX idx_tenant_settings_category ON tenant_settings(category);
|
||||
-- MVP 仅支持 module='property'
|
||||
-- ============================================================
|
||||
CREATE TABLE field_requirement_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
|
||||
module VARCHAR(20) NOT NULL, -- 'property' | 'client'(MVP 只用 'property')
|
||||
entity_type VARCHAR(50) NOT NULL, -- 与 property.property_type CHECK 约束值对齐
|
||||
-- 'residential'|'villa'|'commercial_residential'|'shop'|'office'|'other'
|
||||
@@ -202,8 +202,8 @@ CREATE TABLE field_requirement_rules (
|
||||
field_key VARCHAR(50) NOT NULL, -- 字段 key,如 'orientation'|'decoration'|'floor'
|
||||
requirement VARCHAR(10) NOT NULL -- 规则值
|
||||
CHECK (requirement IN ('required', 'optional', 'hidden')),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
|
||||
UNIQUE (module, entity_type, trade_status, field_key)
|
||||
);
|
||||
|
||||
|
||||
111
Project/fonrey/DATA_MODEL/SCHEMA_CHANGES.md
Normal file
111
Project/fonrey/DATA_MODEL/SCHEMA_CHANGES.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Schema 变更记录
|
||||
|
||||
> **用途**:记录在初始 Model 创建之后新增/修改的字段,供架构师逐一更新 Migration 脚本。
|
||||
> **格式**:每条变更含目标表、变更类型、字段定义、变更原因、来源 PRD 依据。
|
||||
> **状态流转**:`待迁移` → `已迁移`(架构师完成 migration 后更新状态)
|
||||
|
||||
---
|
||||
|
||||
## 变更清单
|
||||
|
||||
| # | 状态 | 目标表 | 变更类型 | 字段名 | 提出日期 |
|
||||
|---|------|--------|----------|--------|----------|
|
||||
| 1 | 待迁移 | `public.tenants` | 新增字段 | `tenant_code` | 2026-04-30 |
|
||||
| 2 | 待迁移 | `public.tenants` | 新增字段 | `contact_phone` | 2026-04-30 |
|
||||
| 3 | 待迁移 | `public.tenants` | 字段修改 | `contact_email`(NOT NULL → 可 NULL) | 2026-04-30 |
|
||||
|
||||
---
|
||||
|
||||
## 变更详情
|
||||
|
||||
### #1 — `public.tenants` 新增 `tenant_code`
|
||||
|
||||
**状态**:待迁移
|
||||
**提出日期**:2026-04-30
|
||||
**目标表**:`public.tenants`(Public Schema,django-tenants 主租户表)
|
||||
|
||||
**字段定义**:
|
||||
```sql
|
||||
tenant_code CHAR(12) UNIQUE NOT NULL
|
||||
```
|
||||
|
||||
**注释**:对外暴露的 12 位纯数字识别码,如 `202500010001`;用户首次登录客户端时输入;由平台运营在注册租户时生成;创建后不可修改。
|
||||
|
||||
**推荐加在**:`schema_name` 字段之后,`name` 字段之前。
|
||||
|
||||
**约束要求**:
|
||||
- `CHAR(12)`:固定 12 位
|
||||
- `UNIQUE`:全局唯一
|
||||
- `NOT NULL`:注册时必填
|
||||
- 禁止 `UPDATE`(应用层 + DB 层双重约束,与 `schema_name` 同等级别)
|
||||
|
||||
**建议索引**:
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_tenants_tenant_code ON public.tenants(tenant_code);
|
||||
```
|
||||
|
||||
**变更原因**:
|
||||
登录 PRD §5.1.3 明确用户输入 12 位 Tenant Code 完成租户识别,服务端需通过该码查找对应 `schema_name`,原始 `public.tenants` 表中缺少此字段。
|
||||
|
||||
**PRD 依据**:
|
||||
`PRD/登录管理/用户登录管理模块PRD.md` §5.1.3 — Tenant Code 格式规范:
|
||||
> 格式:固定 12 位纯数字,如 `202500010001`
|
||||
|
||||
**登录查询示例**:
|
||||
```sql
|
||||
SELECT schema_name, name
|
||||
FROM public.tenants
|
||||
WHERE tenant_code = '202500010001'
|
||||
AND status = 'active';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### #2 — `public.tenants` 新增 `contact_phone`
|
||||
|
||||
**状态**:待迁移
|
||||
**提出日期**:2026-04-30
|
||||
**目标表**:`public.tenants`(Public Schema)
|
||||
|
||||
**字段定义**:
|
||||
```sql
|
||||
contact_phone CHAR(11) NOT NULL
|
||||
```
|
||||
|
||||
**注释**:联系人手机号(11位纯数字);系统开通租户时自动以此手机号创建 Tenant Admin 登录账号;必填字段,平台运营在创建租户时录入。
|
||||
|
||||
**推荐加在**:`contact_name` 字段之后,`contact_email` 字段之前。
|
||||
|
||||
**约束要求**:
|
||||
- `CHAR(11)`:固定 11 位手机号
|
||||
- `NOT NULL`:开通租户时必填
|
||||
|
||||
**变更原因**:
|
||||
登录 PRD v1.5 决策:Tenant Admin 账号统一以联系人手机号作为用户名,与普通员工账号规则对齐。需在 tenants 表增加手机号字段作为 Tenant Admin 账号创建的数据来源。
|
||||
|
||||
**PRD 依据**:
|
||||
`PRD/登录管理/用户登录管理模块PRD.md` §5.3.2 Tenant Admin 账号规格表
|
||||
|
||||
---
|
||||
|
||||
### #3 — `public.tenants.contact_email` 改为可 NULL
|
||||
|
||||
**状态**:待迁移
|
||||
**提出日期**:2026-04-30
|
||||
**目标表**:`public.tenants`(Public Schema)
|
||||
|
||||
**变更 SQL**:
|
||||
```sql
|
||||
ALTER TABLE public.tenants
|
||||
ALTER COLUMN contact_email DROP NOT NULL;
|
||||
```
|
||||
|
||||
**变更原因**:
|
||||
`contact_email` 原为 `NOT NULL`。由于 Tenant Admin 账号创建数据来源改为 `contact_phone`(手机号,必填),邮箱降级为选填,仅用于找回密码功能。为空时 Tenant Admin 无法自助找回密码,需联系平台运营处理。
|
||||
|
||||
**PRD 依据**:
|
||||
`PRD/登录管理/用户登录管理模块PRD.md` §5.3.2
|
||||
|
||||
---
|
||||
|
||||
*后续如有新增字段,按同样格式追加到本文件。*
|
||||
Reference in New Issue
Block a user