docs: 新增系统配置模块PRD及数据模型文档,更新TASK.md
- 新增 PRD/系统配置/系统配置模块PRD.md(v0.1 Draft) - MVP 范围:US-SETTING-001-A(Lookup Items)、B(房源字段必填规则)、C(客源录入规则) - 新增 PRD/系统配置/系统配置数据模型设计说明_for_Atlas.md - 新增 PRD/系统配置/系统配置参数数据.md(竞品参数数据) - 删除旧版 PRD/系统配置/系统配置.md(已被新PRD替代) - 新增 DATA_MODEL/DATA_MODEL_SETTING.md(系统配置数据模型) - 新增 DATA_MODEL/ENUMS.md(枚举定义与约定) - 新增 AGENTS.md(AI Agent 开发规范) - 更新 PRD/TASK.md:US-SETTING-001 拆分为 A/B/C 三个子任务,修正参考文档路径与验收标准 - 新增 VIBE_CODING_开工前缺失清单.md - 新增 TECH_STACK/房源管理技术方案.md - 更新 DATA_MODEL/DATA_MODEL.md、DATA_MODEL_CLIENT.md、DATA_MODEL_LOGIN.md - 更新 PRD/PRD_MVP.md、PRD/权限管理/权限管理模块PRD.md - 更新 TECH_STACK/TECH_STACK.md、权限管理系统技术方案.md - 更新 UI_DESIGN/preview.html、UI_SYSTEM/UI_SYSTEM.md - 新增 prompt/PRD - 为系统设置生成PRD设计文档.md、更新 prompt 模板
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
# Fonrey 房产经纪管理系统 — DATA MODEL 设计文档
|
||||
|
||||
> **作者**: Backend Architect
|
||||
> **版本**: v1.3
|
||||
> **日期**: 2026-04-24(v1.1 修复 S1/S2/S4;v1.2 扩展 public schema;v1.3 §三 DDL 迁至 DATA_MODEL_PUBLIC.md,本文改为索引)
|
||||
> **版本**: v1.4
|
||||
> **日期**: 2026-04-24(v1.1 修复 S1/S2/S4;v1.2 扩展 public schema;v1.3 §三 DDL 迁至 DATA_MODEL_PUBLIC.md,本文改为索引;v1.4 补充 LOGIN/PERMISSION 子文档引用、领域对象、租户 Schema 章节、Redis 缓存策略)
|
||||
> **技术栈**: Django 4.x + PostgreSQL + django-tenants + Redis
|
||||
> **设计目标**: 支撑 89,000+ 房源、多租户隔离、sub-100ms 查询、合规审计
|
||||
|
||||
@@ -17,23 +17,24 @@
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Instance │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ public schema │ │ tenant_abc │ │ tenant_xyz │ │
|
||||
│ │ (平台运营层) │ │ schema │ │ schema │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - tenants │ │ - properties │ │ - properties │ │
|
||||
│ │ - domains │ │ - clients │ │ - clients │ │
|
||||
│ │ - tenant_status_logs │ │ - complexes │ │ - complexes │ │
|
||||
│ │ - platform_admins │ │ - staff │ │ - staff │ │
|
||||
│ │ - admin_mfa_devices │ │ - org_units │ │ - org_units │ │
|
||||
│ │ - admin_sessions │ │ - ... │ │ - ... │ │
|
||||
│ │ - ip_whitelist │ └──────────────┘ └──────────────┘ │
|
||||
│ │ - platform_audit_logs │ │
|
||||
│ │ - backup_schedules │ │
|
||||
│ │ - backup_records │ │
|
||||
│ │ - export_tasks │ │
|
||||
│ │ - system_versions │ │
|
||||
│ │ - upgrade_events │ │
|
||||
│ ┌─────────────────────────┐ ┌──────────────────┐ ┌────────────┐ │
|
||||
│ │ public schema │ │ tenant_abc │ │ tenant_xyz │ │
|
||||
│ │ (平台运营层) │ │ schema │ │ schema │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - tenants │ │ - org_units │ │ (同左) │ │
|
||||
│ │ - domains │ │ - staff │ │ │ │
|
||||
│ │ - tenant_status_logs │ │ - complexes │ │ │ │
|
||||
│ │ - platform_admins │ │ - properties │ │ │ │
|
||||
│ │ - admin_mfa_devices │ │ - clients │ │ │ │
|
||||
│ │ - admin_sessions │ │ - user_accounts │ │ │ │
|
||||
│ │ - ip_whitelist │ │ - login_attempts │ │ │ │
|
||||
│ │ - platform_audit_logs │ │ - permission_defs│ │ │ │
|
||||
│ │ - backup_schedules │ │ - roles │ │ │ │
|
||||
│ │ - backup_records │ │ - staff_roles │ │ │ │
|
||||
│ │ - export_tasks │ │ - lookup_items │ │ │ │
|
||||
│ │ - system_versions │ │ - ... │ │ │ │
|
||||
│ │ - upgrade_events │ └──────────────────┘ └────────────┘ │
|
||||
│ │ - enum_labels │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -104,6 +105,7 @@
|
||||
| **ExportTask** | `public.export_tasks` | 数据导出异步任务(CSV/JSON/SQL Dump,24h 下载链接) |
|
||||
| **SystemVersion** | `public.system_versions` | 平台版本历史,唯一 current 版本约束 |
|
||||
| **UpgradeEvent** | `public.upgrade_events` | 升级/回滚事件,含灰度租户维度进度快照 |
|
||||
| **EnumLabel** | `public.enum_labels` | 固定枚举字典(英文 Key → 中文标签),所有租户共享,供前端下拉渲染、导出报表中文标签、日志快照使用 |
|
||||
|
||||
#### Tenant Schema(租户业务层)
|
||||
|
||||
@@ -121,6 +123,9 @@
|
||||
| **Client(客源)** | `clients` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 买家/租客档案,分私客/公客/成交客,含活跃度评分与自动公客转换机制 |
|
||||
| **Viewing(带看)** | `client_viewings` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 经纪人带客户看房的完整记录 |
|
||||
| **Match(配对)** | `client_property_matches` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 系统/人工推荐的客源↔房源配对 |
|
||||
| **UserAccount(用户账号)** | `user_accounts` → [DATA_MODEL_LOGIN.md](./DATA_MODEL_LOGIN.md) | 系统登录主体,与员工档案 1:1 绑定,含账号锁定/密码历史/登录审计 |
|
||||
| **PermissionDef(权限定义)** | `permission_defs` → [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限目录(约 300 条),驱动 Hybrid RBAC + Override 权限模型 |
|
||||
| **Role(业务角色)** | `roles` → [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限模板,含 4 大类别(置业顾问/店管/总经/运营/自定义) |
|
||||
|
||||
### 领域关系快速导航
|
||||
|
||||
@@ -149,6 +154,9 @@ OrgUnit (组织架构)
|
||||
| [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘/区域(districts, business_areas, complexes, buildings, room_units, schools 等) | ✅ 完成 |
|
||||
| [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 客源管理(clients, requirements, follow_logs, viewings, matches 等) | ✅ 完成 |
|
||||
| [DATA_MODEL_PROPERTY.md](./DATA_MODEL_PROPERTY.md) | 房源管理(properties 及配套 22 张表,含跟进/钥匙/委托/实勘/营销/产证/完成度/标签/收藏/保护/号码方审批等) | ✅ 完成 |
|
||||
| [DATA_MODEL_LOGIN.md](./DATA_MODEL_LOGIN.md) | 登录与账号认证(user_accounts, login_attempts, password_reset_tokens, password_histories + Redis 登录缓存) | ✅ 完成 |
|
||||
| [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限管理(permission_defs, roles, role_permissions, staff_roles, staff_permission_overrides, staff_data_scopes, permission_change_logs + Redis 权限缓存) | ✅ 完成 |
|
||||
| [ENUMS.md](./ENUMS.md) | 枚举字典(`public.enum_labels` 表设计 + 所有模块枚举定义 + 种子数据 SQL) | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -175,6 +183,7 @@ OrgUnit (组织架构)
|
||||
| `public.export_tasks` | 数据导出异步任务(CSV/JSON/SQL Dump,24h 下载链接) | §2.4 |
|
||||
| `public.system_versions` | 平台版本历史,部分唯一索引保证唯一 current | §2.5 |
|
||||
| `public.upgrade_events` | 升级/回滚事件,`tenant_progress` JSONB 快照各租户状态 | §2.5 |
|
||||
| `public.enum_labels` | 固定枚举字典(英文 Key → 中文标签),所有租户共享 | §2.6 |
|
||||
|
||||
**关键约束提示**:
|
||||
- `tenant_status_logs` / `platform_audit_logs` **无 deleted_at**,禁止 UPDATE/DELETE,append-only
|
||||
@@ -257,31 +266,31 @@ OrgUnit (组织架构)
|
||||
|
||||
**核心表概览**(开发时以 DATA_MODEL_PROPERTY.md 为准):
|
||||
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `properties` | 房源主表(系统核心,89,000+ 数据量) | `status`, `attribute`, `property_type`, `complex_id`, `sale_price`, `area`, `grade`, `completeness_score`, `search_vector` |
|
||||
| `property_contacts` | 业主/联系人(手机号 AES 加密+哈希索引) | `property_id`, `phone_enc`, `phone_hash`, `identity`, `is_number_holder` |
|
||||
| `listing_histories` | 挂牌历史快照(不可删除) | `property_id`, `listing_type`, `status`, `sale_price`, `seller_agent_snapshot` |
|
||||
| `price_changes` | 调价记录(不可删除) | `property_id`, `old_sale_price`, `new_sale_price`, `change_reason`, `changed_by` |
|
||||
| `follow_logs` | 跟进日志(6种类型,最高写入频率) | `property_id`, `log_type`, `content`, `is_deletable`, `operator_id` |
|
||||
| `follow_log_attachments` | 跟进附件(图片) | `follow_log_id`, `file_key`, `file_type` |
|
||||
| `follow_log_recordings` | 跟进录音 | `follow_log_id`, `file_key`, `duration_seconds` |
|
||||
| `property_keys` | 钥匙管理(机械钥匙/密码) | `property_id`, `key_type`, `holder_id`, `is_active` |
|
||||
| `key_attachments` | 钥匙附件 | `key_id`, `file_key` |
|
||||
| `commissions` | 委托管理(独家/非独家) | `property_id`, `commission_type`, `period_start`, `status` |
|
||||
| `commission_attachments` | 委托附件(身份证/产证/委托书) | `commission_id`, `category`, `file_key` |
|
||||
| `field_surveys` | 实勘管理(GPS 打卡) | `property_id`, `status`, `gps_latitude`, `gps_longitude`, `created_by` |
|
||||
| `survey_photos` | 实勘照片(按空间分类) | `survey_id`, `category`, `file_key`, `is_vr_screenshot` |
|
||||
| `property_photos` | 房源图片(经纪人管理,封面唯一约束) | `property_id`, `category`, `is_cover`, `file_key` |
|
||||
| `property_attachments` | 房源附件 | `property_id`, `category`, `file_key` |
|
||||
| `property_marketing` | 营销信息(1:1,卖点/业主心态/介绍) | `property_id`, `marketing_title`, `core_selling_points` |
|
||||
| `property_certificates` | 产证信息(1:1) | `property_id`, `cert_no`, `owner_name`, `land_nature` |
|
||||
| `property_completeness` | 维护完成度快照(1:1,Celery 异步计算) | `property_id`, `total_score`, `score_survey`, `score_commission`, ... |
|
||||
| `property_tags` | 标签字典(系统预置+运营自定义) | `name`, `color`, `is_system` |
|
||||
| `property_tag_relations` | 房源↔标签多对多 | `property_id`, `tag_id` |
|
||||
| `property_favorites` | 经纪人收藏房源 | `staff_id`, `property_id` |
|
||||
| `property_protections` | 保护房设置(1:1) | `property_id`, `is_protected`, `start_at`, `end_at` |
|
||||
| `number_holder_approvals` | 号码方变更审批 | `property_id`, `applicant_id`, `status` |
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
| ------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `properties` | 房源主表(系统核心,89,000+ 数据量) | `status`, `attribute`, `property_type`, `complex_id`, `sale_price`, `area`, `grade`, `completeness_score`, `search_vector` |
|
||||
| `property_contacts` | 业主/联系人(手机号 AES 加密+哈希索引) | `property_id`, `phone_enc`, `phone_hash`, `identity`, `is_number_holder` |
|
||||
| `listing_histories` | 挂牌历史快照(不可删除) | `property_id`, `listing_type`, `status`, `sale_price`, `seller_agent_snapshot` |
|
||||
| `price_changes` | 调价记录(不可删除) | `property_id`, `old_sale_price`, `new_sale_price`, `change_reason`, `changed_by` |
|
||||
| `follow_logs` | 跟进日志(6种类型,最高写入频率) | `property_id`, `log_type`, `content`, `is_deletable`, `operator_id` |
|
||||
| `follow_log_attachments` | 跟进附件(图片) | `follow_log_id`, `file_key`, `file_type` |
|
||||
| `follow_log_recordings` | 跟进录音 | `follow_log_id`, `file_key`, `duration_seconds` |
|
||||
| `property_keys` | 钥匙管理(机械钥匙/密码) | `property_id`, `key_type`, `holder_id`, `is_active` |
|
||||
| `key_attachments` | 钥匙附件 | `key_id`, `file_key` |
|
||||
| `commissions` | 委托管理(独家/非独家) | `property_id`, `commission_type`, `period_start`, `status` |
|
||||
| `commission_attachments` | 委托附件(身份证/产证/委托书) | `commission_id`, `category`, `file_key` |
|
||||
| `field_surveys` | 实勘管理(GPS 打卡) | `property_id`, `status`, `gps_latitude`, `gps_longitude`, `created_by` |
|
||||
| `survey_photos` | 实勘照片(按空间分类) | `survey_id`, `category`, `file_key`, `is_vr_screenshot` |
|
||||
| `property_photos` | 房源图片(经纪人管理,封面唯一约束) | `property_id`, `category`, `is_cover`, `file_key` |
|
||||
| `property_attachments` | 房源附件 | `property_id`, `category`, `file_key` |
|
||||
| `property_marketing` | 营销信息(1:1,卖点/业主心态/介绍) | `property_id`, `marketing_title`, `core_selling_points` |
|
||||
| `property_certificates` | 产证信息(1:1) | `property_id`, `cert_no`, `owner_name`, `land_nature` |
|
||||
| `property_completeness` | 维护完成度快照(1:1,Celery 异步计算) | `property_id`, `total_score`, `score_survey`, `score_commission`, ... |
|
||||
| `property_tags` | 标签字典(系统预置+运营自定义) | `name`, `color`, `is_system` |
|
||||
| `property_tag_relations` | 房源↔标签多对多 | `property_id`, `tag_id` |
|
||||
| `property_favorites` | 经纪人收藏房源 | `staff_id`, `property_id` |
|
||||
| `property_protections` | 保护房设置(1:1) | `property_id`, `is_protected`, `start_at`, `end_at` |
|
||||
| `number_holder_approvals` | 号码方变更审批 | `property_id`, `applicant_id`, `status` |
|
||||
|
||||
**关键约束提示**:
|
||||
- `property_contacts.phone_hash` 是重复房源检测的主要依据,录入前必须查重
|
||||
@@ -293,6 +302,83 @@ OrgUnit (组织架构)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 登录与账号认证(Login & Account)
|
||||
|
||||
> **详细模型** → 见 [`DATA_MODEL_LOGIN.md`](./DATA_MODEL_LOGIN.md)
|
||||
> 该文件为权威定义,包含完整字段、状态机、Redis 缓存结构和禁止操作。
|
||||
|
||||
**核心表概览**(开发时以 DATA_MODEL_LOGIN.md 为准):
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `user_accounts` | 账号主表(1:1 绑定 `org.Staff`),含加密手机号/哈希、状态机(active/locked/disabled)、初始密码标识 |
|
||||
| `login_attempts` | 登录审计日志(append-only,成功/失败均记录,无 FK 冗余存 username 保证历史完整) |
|
||||
| `password_reset_tokens` | 密码重置 Token(有效期 30 分钟,使用后立即标记 `is_used`) |
|
||||
| `password_histories` | 历史密码记录(保留最近 3 条,含初始密码,防止重复使用) |
|
||||
|
||||
**关键约束提示**:
|
||||
- `user_accounts` 主键用 `BIGSERIAL`(非 UUID),登录审计场景 BigInt 更高效
|
||||
- `user_accounts.phone_enc` AES-256-GCM 加密,`phone_hash` SHA-256 用于唯一索引
|
||||
- **禁止物理删除** `user_accounts`,离职员工只能 `status=disabled`
|
||||
- 账号锁定(5 次密码错误)→ `status=locked`,`locked_until=NOW()+30min`;Redis 仅计数,实际锁定以 DB 为准
|
||||
- Tenant Admin 的 `staff_id` 可为空(可无员工档案);普通员工 `staff_id` 必填且关联 active Staff
|
||||
- 员工离职(`org.Staff.status→resigned`)→ 应用层 Service 调用触发账号 `status→disabled`,**禁止循环 FK**
|
||||
- `password_reset_tokens` / `login_attempts` **无 deleted_at**,不可修改/删除
|
||||
|
||||
**Redis 辅助状态**(非持久化):
|
||||
|
||||
| Key 格式 | TTL | 说明 |
|
||||
|----------|-----|------|
|
||||
| `captcha_token:{uuid}` | 3 分钟 | 滑块验证会话 Token |
|
||||
| `captcha_pass:{uuid}` | 3 分钟 | 一次性通过凭证(验证后立即删除) |
|
||||
| `login_fail:{tenant_id}:{username}` | 30 分钟 | 连续密码错误计数;≥5 触发锁定 |
|
||||
| `recover_email:{email}` | 1 小时 | 找回邮件发送次数上限 3 次 |
|
||||
| `tenant_verify_ip:{ip}` | 1 分钟 | Tenant 验证接口 IP 限流;≥10 次拒绝 |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 权限管理(Permission & RBAC)
|
||||
|
||||
> **详细模型** → 见 [`DATA_MODEL_PERMISSION.md`](./DATA_MODEL_PERMISSION.md)
|
||||
> 该文件为权威定义,包含完整字段、权限解析算法、`ScopeQueryBuilder` 实现和禁止操作。
|
||||
|
||||
**权限模型概述**:Hybrid RBAC + Individual Override,支持 `BOOLEAN / SCOPE / INTEGER` 三类权限值,多角色合并规则 OR / MAX。
|
||||
|
||||
**核心表概览**(开发时以 DATA_MODEL_PERMISSION.md 为准):
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `permission_defs` | 权限目录(约 300 条,`PUBLIC Schema` 中 `shared_apps` 存储,所有租户共享),含模块/分组/值类型/默认值/上限类别 |
|
||||
| `roles` | 业务角色(每租户独立),5 种类别:`agent/store_manager/director/operator/custom`,含系统内置标识 |
|
||||
| `role_permissions` | 角色↔权限值(稀疏存储,仅存与 default_value 不同的项) |
|
||||
| `staff_roles` | 员工↔角色分配(N:M,含主角色标识 `is_primary`、有效期) |
|
||||
| `staff_permission_overrides` | 员工个人权限覆盖(稀疏存储,仅存与角色合并值不同的项),3 种 override_mode:REPLACE / RESTRICT / GRANT |
|
||||
| `staff_data_scopes` | 员工数据范围扩展(补充 SCOPE 权限之外的额外可读范围,如特殊跨门店授权) |
|
||||
| `permission_change_logs` | 权限变更不可变审计日志(append-only,禁止 UPDATE/DELETE) |
|
||||
|
||||
**关键约束提示**:
|
||||
- `permission_defs` 位于 **Public Schema**(`shared_apps`),所有租户共享;`roles` 及其余表属租户 Schema
|
||||
- **禁止硬删除** `permission_defs`,改用 `is_active=FALSE` 下线;`code` 字段不可修改
|
||||
- **禁止直接构造 Q 对象绕过 `ScopeQueryBuilder`**,会导致越权漏洞
|
||||
- `permission_change_logs` **无 deleted_at**,禁止 UPDATE/DELETE
|
||||
- 员工权限解析:`is_system_admin=TRUE` → 短路返回全权限;否则多角色 OR/MAX 合并后叠加 Override
|
||||
- `StaffPermissionOverride` 保存前必须做差异对比,**禁止存与角色合并值相同的冗余记录**(稀疏存储)
|
||||
- `staff_roles.is_primary` 唯一约束通过 Signal 维护,**禁止绕过**
|
||||
|
||||
**权限解析缓存**:
|
||||
|
||||
| Cache Key | TTL | 失效触发 |
|
||||
|-----------|-----|---------|
|
||||
| `perm:v{VER}:{schema}:{staff_id}` | 3600s | Override / StaffRole 变更 |
|
||||
| `perm:v{VER}:{schema}:role:{role_id}:staff_ids` | 3600s | 角色权限变更 → Pipeline 批量失效 |
|
||||
| `perm:inconsistent:{schema}:{staff_id}` | 300s | 同上 |
|
||||
| `perm:defs:{schema}` | 86400s | PermissionDef 变更(低频) |
|
||||
| `perm:role_applied_count:{schema}:{role_id}` | 600s | StaffRole 变更 |
|
||||
|
||||
> **版本号机制**:`CACHE_VERSION` 在 Django settings 中,升级 PermissionDef 结构时 bump,一键全局失效。
|
||||
|
||||
---
|
||||
|
||||
### 3.17 客源管理(Client Management)
|
||||
|
||||
> **详细模型** → 见 [`DATA_MODEL_CLIENT.md`](./DATA_MODEL_CLIENT.md)
|
||||
@@ -381,6 +467,73 @@ CREATE INDEX idx_saved_filters_staff ON saved_filters(staff_id, module);
|
||||
|
||||
---
|
||||
|
||||
### 3.19 枚举字典(Enum Labels)
|
||||
|
||||
> **权威定义** → 见 [`DATA_MODEL/ENUMS.md`](./ENUMS.md)
|
||||
> 本节为概览,开发时以 ENUMS.md 为准。
|
||||
|
||||
#### 表归属
|
||||
|
||||
`enum_labels` 位于 **Public Schema**(`shared_apps`),所有租户共享,**不属于任何租户 Schema**。
|
||||
|
||||
#### 核心表设计
|
||||
|
||||
```sql
|
||||
CREATE TABLE enum_labels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
domain VARCHAR(60) NOT NULL, -- 枚举域,格式:{模块}.{字段},如 client.status
|
||||
value VARCHAR(60) NOT NULL, -- 英文 Key(与数据库 CHECK 约束一致)
|
||||
label_zh VARCHAR(60) NOT NULL, -- 中文标签(前端展示用)
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
remark TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_enum_labels_domain_value ON enum_labels(domain, value);
|
||||
CREATE INDEX idx_enum_labels_domain ON enum_labels(domain, sort_order);
|
||||
```
|
||||
|
||||
#### 覆盖的枚举域(domain 清单)
|
||||
|
||||
| domain | 说明 | 对应表字段 |
|
||||
|--------|------|-----------|
|
||||
| `client.status` | 客源状态(7 态) | `clients.status` |
|
||||
| `client.grade` | 客源等级(5 档 + E) | `clients.grade` |
|
||||
| `client.purpose_type` | 需求类型 | `client_requirements.purpose_type` |
|
||||
| `client.usage` | 房源用途偏好 | `client_requirements.usage` |
|
||||
| `client.orientation` | 朝向偏好 | `client_requirements.orientation` |
|
||||
| `client.payment_method` | 付款方式 | `clients.payment_method` |
|
||||
| `property.status` | 房源状态 | `properties.status` |
|
||||
| `property.attribute` | 房源属性(公/私/保护) | `properties.attribute` |
|
||||
| `property.usage` | 房源用途 | `properties.usage` |
|
||||
| `property.grade` | 房源等级(5 档) | `properties.grade` |
|
||||
| `property.listing_type` | 挂牌类型 | `properties.listing_type` |
|
||||
| `property.decoration` | 装修程度 | `properties.decoration` |
|
||||
| `property.orientation` | 朝向 | `properties.orientation` |
|
||||
| `commission.type` | 委托类型 | `commissions.commission_type` |
|
||||
| `field_survey.status` | 实勘状态 | `field_surveys.status` |
|
||||
| `follow_log.log_type` | 跟进日志类型 | `follow_logs.log_type` |
|
||||
|
||||
#### 重要约定
|
||||
|
||||
- `enum_labels.value` 必须与对应表的 `CHECK` 约束完全一致,**两者必须同步修改**
|
||||
- 新增枚举值流程:① 修改 DDL `CHECK` 约束 → ② 插入 `enum_labels` 种子数据 → ③ 更新 `ENUMS.md`
|
||||
- `is_active = FALSE` 仅停用前端展示,**不得修改或删除已有 `value`**(历史数据引用不可破坏)
|
||||
- 前端下拉渲染**统一从 `enum_labels` 读取**,禁止在前端代码中硬编码中文标签
|
||||
|
||||
#### 与 `lookup_items` 的区别
|
||||
|
||||
| 对比维度 | `enum_labels` | `lookup_items` |
|
||||
|---------|---------------|----------------|
|
||||
| 用途 | 固定枚举的中文标签映射 | 运营可配置的动态选项(如跟进目的、来源渠道) |
|
||||
| 修改权限 | 仅开发/DBA | 运营人员后台配置 |
|
||||
| Schema 位置 | Public Schema(共享) | Tenant Schema(每租户独立) |
|
||||
| 典型示例 | 客源状态、房源等级 | 跟进目的、客户来源渠道 |
|
||||
|
||||
---
|
||||
|
||||
## 五、关键索引汇总与查询优化策略
|
||||
|
||||
### 4.1 房源列表页核心查询分析
|
||||
@@ -489,6 +642,21 @@ CREATE TRIGGER trg_update_last_followed
|
||||
# 枚举值/lookup(几乎不变)
|
||||
{schema}:lookup:{category_code} TTL: 86400 (24小时)
|
||||
|
||||
# 登录模块(详见 DATA_MODEL_LOGIN.md §四)
|
||||
captcha_token:{uuid} TTL: 180 (3分钟)
|
||||
captcha_pass:{uuid} TTL: 180 (3分钟)
|
||||
login_fail:{tenant_id}:{username} TTL: 1800 (30分钟,连续失败计数)
|
||||
recover_email:{email} TTL: 3600 (1小时,发送次数限流)
|
||||
recover_reset:{account_id} TTL: 3600 (1小时,Token 生成次数限流)
|
||||
tenant_verify_ip:{ip} TTL: 60 (1分钟,IP 限流)
|
||||
|
||||
# 权限模块(详见 DATA_MODEL_PERMISSION.md §六)
|
||||
perm:v{VER}:{schema}:{staff_id} TTL: 3600 (员工完整权限快照)
|
||||
perm:v{VER}:{schema}:role:{role_id}:staff_ids TTL: 3600 (角色→员工 ID 列表,批量失效用)
|
||||
perm:inconsistent:{schema}:{staff_id} TTL: 300 (权限不一致标记)
|
||||
perm:defs:{schema} TTL: 86400 (权限定义全量缓存)
|
||||
perm:role_applied_count:{schema}:{role_id} TTL: 600 (角色应用人数)
|
||||
|
||||
# 标签列表
|
||||
{schema}:tags:property TTL: 3600
|
||||
|
||||
|
||||
@@ -357,12 +357,12 @@ CREATE UNIQUE INDEX idx_favorite_folders_default ON client_favorite_folders(staf
|
||||
|
||||
### 3.10 client_folder_items — 收藏夹中的客源
|
||||
|
||||
| 字段 | 类型 | 约束 | 业务说明 |
|
||||
|------|------|------|----------|
|
||||
| folder_id | UUID | NOT NULL, FK→client_favorite_folders, CASCADE | |
|
||||
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
|
||||
| added_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
|
||||
| PRIMARY KEY | (folder_id, client_id) | | |
|
||||
| 字段 | 类型 | 约束 | 业务说明 |
|
||||
| ----------- | ---------------------- | --------------------------------------------- | ---- |
|
||||
| folder_id | UUID | NOT NULL, FK→client_favorite_folders, CASCADE | |
|
||||
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
|
||||
| added_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
|
||||
| PRIMARY KEY | (folder_id, client_id) | | |
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_folder_items_client ON client_folder_items(client_id);
|
||||
@@ -415,8 +415,7 @@ buying/renting/buy_or_rent
|
||||
### clients.grade(等级)
|
||||
|
||||
```
|
||||
A_urgent = A(急迫)
|
||||
A = A
|
||||
A = A(急迫)
|
||||
B = B(较强)
|
||||
C = C(一般,默认值)
|
||||
D = D(较弱)
|
||||
@@ -438,15 +437,15 @@ merge = 合并客源(被合并的记录保留日志)
|
||||
|
||||
### clients.activity_level(活跃度分层,系统计算)
|
||||
|
||||
| 值 | 含义 | 触发条件(示例,以运营配置为准) |
|
||||
|----|------|------|
|
||||
| `new_matched` | 新配偶 | 录入后 3 天内 |
|
||||
| `active_7d` | 7日活跃 | 最后跟进在 7 天内 |
|
||||
| `active_30d` | 30日活跃 | 最后跟进在 30 天内 |
|
||||
| `active_90d` | 90日活跃 | 最后跟进在 90 天内 |
|
||||
| `expiring` | 即将过期 | 距自动转公还有 N 天 |
|
||||
| `frozen` | 冻结(暂缓) | status = suspended |
|
||||
| `invalid` | 无效 | status = invalid |
|
||||
| 值 | 含义 | 触发条件(示例,以运营配置为准) |
|
||||
| ------------- | ------ | ------------------ |
|
||||
| `new_matched` | 新匹配 | 录入后 3 天内 |
|
||||
| `active_7d` | 7日活跃 | 最后跟进在 7 天内 |
|
||||
| `active_30d` | 30日活跃 | 最后跟进在 30 天内 |
|
||||
| `active_90d` | 90日活跃 | 最后跟进在 90 天内 |
|
||||
| `expiring` | 即将过期 | 距自动转公还有 N 天 |
|
||||
| `frozen` | 冻结(暂缓) | status = suspended |
|
||||
| `invalid` | 无效 | status = invalid |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,470 +1,470 @@
|
||||
# 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) -- 同租户内邮箱唯一(可为 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 由平台运营自定义(字母开头,6~30 位)
|
||||
注意:此表位于租户 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` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `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` | 失败原因;可选值见下方枚举 |
|
||||
| `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 故障时锁定状态不丢失 |
|
||||
# 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) -- 同租户内邮箱唯一(可为 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 由平台运营自定义(字母开头,6~30 位)
|
||||
注意:此表位于租户 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` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `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` | 失败原因;可选值见下方枚举 |
|
||||
| `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 故障时锁定状态不丢失 |
|
||||
|
||||
391
Project/fonrey/DATA_MODEL/DATA_MODEL_SETTING.md
Normal file
391
Project/fonrey/DATA_MODEL/DATA_MODEL_SETTING.md
Normal file
@@ -0,0 +1,391 @@
|
||||
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
||||
|
||||
# Fonrey — 系统配置模块数据模型(DATA_MODEL_SETTING)
|
||||
|
||||
> **定位**:本文件是 `apps/setting/` 模块的数据模型权威来源。
|
||||
> **版本**:v1.0 | **日期**:2026-04-27
|
||||
> **关联 PRD**:`PRD/系统配置/系统配置模块PRD.md`
|
||||
> **关联文档**:`DATA_MODEL/ENUMS.md`、`DATA_MODEL/DATA_MODEL.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、模块定位与架构边界
|
||||
|
||||
### 1.1 系统配置模块职责
|
||||
|
||||
系统配置模块(`apps/setting/`)负责管理三类性质不同的配置数据:
|
||||
|
||||
| 类型 | 说明 | 表 | Schema |
|
||||
| -------------- | ---------------- | --------------------------------------------- | -------------- |
|
||||
| **A. 固定系统枚举** | 平台级固定值域,所有租户共享 | `enum_labels` | Public(shared) |
|
||||
| **B. 可配置枚举** | 各租户选项不同,管理员可增删排序 | `lookup_groups` + `lookup_items` | Tenant |
|
||||
| **C. 行为规则与开关** | 标量配置开关 + 字段必填规则 | `tenant_settings` + `field_requirement_rules` | Tenant |
|
||||
|
||||
> **重要区分**:
|
||||
> - 类型 A (`enum_labels`) 已在 `DATA_MODEL/ENUMS.md` 完整定义,**本文件不重复**
|
||||
> - 类型 B/C 均存于 **租户 Schema**,由租户管理员通过界面维护
|
||||
> - `apps/setting/` 是 `tenant_apps`(**非** `shared_apps`)
|
||||
|
||||
### 1.2 依赖关系
|
||||
|
||||
```
|
||||
apps/setting/
|
||||
├── 依赖 → core.cache(Redis,统一租户前缀)
|
||||
├── 依赖 → org.Staff(created_by / updated_by FK)
|
||||
└── 被依赖 ← apps/property(读取字段规则、枚举选项)
|
||||
← apps/client(读取查重范围、枚举选项)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、可配置枚举表(类型 B)
|
||||
|
||||
### 2.1 `lookup_groups`(枚举分组)
|
||||
|
||||
每个分组代表一类可配置枚举(如「客源来源」「跟进目的」),由研发预置,租户管理员**不可新增或删除分组**,仅可管理分组内的选项。
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 可配置枚举分组(Tenant Schema)
|
||||
-- 研发预置,租户不可修改分组本身
|
||||
-- ============================================================
|
||||
CREATE TABLE lookup_groups (
|
||||
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(),
|
||||
UNIQUE (module, key)
|
||||
);
|
||||
```
|
||||
|
||||
**MVP 预置分组(种子数据)**:
|
||||
|
||||
| module | key | label_zh | description |
|
||||
|--------|-----|----------|-------------|
|
||||
| `client` | `source` | 客源来源 | 客源从何处获取,用于来源渠道分析 |
|
||||
| `client` | `follow_purpose` | 跟进目的 | 客源跟进时选择的目的分类 |
|
||||
| `property` | `source` | 房源来源 | 房源从何处获取 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `lookup_items`(枚举选项)
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 可配置枚举选项(Tenant Schema)
|
||||
-- 租户管理员可增删排序;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,
|
||||
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,
|
||||
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(),
|
||||
UNIQUE (group_id, value)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lookup_items_group_active
|
||||
ON lookup_items(group_id, is_active, sort_order);
|
||||
```
|
||||
|
||||
**关键约束**:
|
||||
- `is_system = TRUE` 的记录不允许物理删除(Service 层强制拦截)
|
||||
- `is_active = FALSE` 后:前端录入下拉不展示;历史已选该值的记录保留原值,展示时追加「(已停用)」后缀
|
||||
- `value` 一旦写入不允许修改(历史数据依赖);如需改名,停用旧项、新增新项
|
||||
|
||||
---
|
||||
|
||||
### 2.3 MVP 预置种子数据(`is_system = TRUE`)
|
||||
|
||||
以下选项在租户初始化时自动写入:
|
||||
|
||||
#### 客源来源(`client.source`)
|
||||
|
||||
| value | label_zh | sort_order |
|
||||
|-------|----------|------------|
|
||||
| `store_reception` | 门店接待 | 1 |
|
||||
| `old_client_referral` | 老客户转介绍 | 2 |
|
||||
| `stationed_dispatch` | 驻守派单 | 3 |
|
||||
| `walk_in` | 上门 | 4 |
|
||||
| `online_58` | 网络-58同城 | 5 |
|
||||
| `online_anjuke` | 网络-安居客 | 6 |
|
||||
| `wechat` | 微信 | 7 |
|
||||
| `friend_referral` | 朋友介绍 | 8 |
|
||||
|
||||
#### 跟进目的(`client.follow_purpose`)
|
||||
|
||||
| value | label_zh | sort_order |
|
||||
|-------|----------|------------|
|
||||
| `callback` | 回拨 | 1 |
|
||||
| `push_property` | 推房 | 2 |
|
||||
| `showing` | 带看 | 3 |
|
||||
| `maintain` | 维护 | 4 |
|
||||
| `other` | 其他 | 5 |
|
||||
|
||||
#### 房源来源(`property.source`)
|
||||
|
||||
| value | label_zh | sort_order |
|
||||
|-------|----------|------------|
|
||||
| `proactive_development` | 主动开发 | 1 |
|
||||
| `owner_walk_in` | 业主上门 | 2 |
|
||||
| `old_client_referral` | 老客户转介绍 | 3 |
|
||||
| `online_inquiry` | 网络来电 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 三、行为规则与开关(类型 C)
|
||||
|
||||
### 3.1 `tenant_settings`(标量配置键值表)
|
||||
|
||||
存储开关(bool)、阈值(int)、单选枚举(string)等标量类型配置项。
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 租户标量配置(键值对)(Tenant Schema)
|
||||
-- ============================================================
|
||||
CREATE TABLE tenant_settings (
|
||||
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(),
|
||||
UNIQUE (category, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenant_settings_category ON tenant_settings(category);
|
||||
```
|
||||
|
||||
**存储格式约定**:
|
||||
- `bool`:`{"v": true}` 或 `{"v": false}`
|
||||
- `int`:`{"v": 30}`
|
||||
- `string`:`{"v": "some_value"}`
|
||||
- `enum`:`{"v": "self", "choices": ["self", "dept", "company"]}` — `choices` 由代码硬编码,不存 DB
|
||||
|
||||
**MVP 阶段预置 key**:
|
||||
|
||||
| category | key | value_type | 默认值 | 说明 |
|
||||
|----------|-----|-----------|--------|------|
|
||||
| `client` | `duplicate_check_scope` | `enum` | `{"v": "self"}` | 新增私客查重范围:`self`(本人)/ `dept`(本部门)/ `company`(全公司) |
|
||||
|
||||
> 未来 P1 阶段可按需追加 key,无需修改表结构。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `field_requirement_rules`(字段必填规则表)
|
||||
|
||||
按「模块 × 房源用途 × 交易状态 × 字段」四元组确定一条规则,控制录入界面的字段显示状态。
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 字段必填/隐藏规则(Tenant Schema)
|
||||
-- MVP 仅支持 module='property'
|
||||
-- ============================================================
|
||||
CREATE TABLE field_requirement_rules (
|
||||
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'
|
||||
trade_status VARCHAR(20) NOT NULL, -- 交易大类:'sale'|'rent'|'sale_rent'|'*'(全部)
|
||||
CHECK (trade_status IN ('sale', 'rent', 'sale_rent', '*')),
|
||||
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(),
|
||||
UNIQUE (module, entity_type, trade_status, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_field_req_lookup
|
||||
ON field_requirement_rules(module, entity_type, trade_status);
|
||||
```
|
||||
|
||||
**与 `property.property_type` 对齐说明**:
|
||||
|
||||
`entity_type` 的值域与 `property.property_type` 的 CHECK 约束完全一致:
|
||||
|
||||
| entity_type | property_type label_zh |
|
||||
|-------------|----------------------|
|
||||
| `residential` | 住宅 |
|
||||
| `villa` | 别墅 |
|
||||
| `commercial_residential` | 商住 |
|
||||
| `shop` | 商铺 |
|
||||
| `office` | 写字楼 |
|
||||
| `other` | 其他 |
|
||||
|
||||
**`trade_status` 与 `property.status` 的映射关系**:
|
||||
|
||||
| trade_status | 对应 property.status 值 |
|
||||
|--------------|------------------------|
|
||||
| `sale` | `for_sale` |
|
||||
| `rent` | `for_rent` |
|
||||
| `sale_rent` | `for_sale_rent` |
|
||||
| `*` | 所有状态通用规则(fallback) |
|
||||
|
||||
> **重要**:`trade_status` 是录入场景的交易意图分类,不是 `property.status` 的完整枚举。规则匹配逻辑:先查精确匹配(`entity_type + trade_status`),不存在则查 `*` 通配规则。
|
||||
|
||||
**MVP 初始规则(研发预置,管理员可覆盖)**:
|
||||
|
||||
| module | entity_type | trade_status | field_key | requirement |
|
||||
|--------|-------------|--------------|-----------|-------------|
|
||||
| `property` | `residential` | `sale` | `orientation` | `optional` |
|
||||
| `property` | `residential` | `sale` | `decoration` | `optional` |
|
||||
| `property` | `residential` | `sale` | `floor` | `optional` |
|
||||
| `property` | `residential` | `sale` | `building_area` | `required` |
|
||||
| `property` | `residential` | `sale` | `inner_area` | `optional` |
|
||||
| `property` | `residential` | `sale` | `room_layout` | `required` |
|
||||
| `property` | `residential` | `rent` | `decoration` | `optional` |
|
||||
| `property` | `residential` | `rent` | `floor` | `optional` |
|
||||
| `property` | `residential` | `rent` | `building_area` | `required` |
|
||||
| `property` | `residential` | `rent` | `room_layout` | `required` |
|
||||
|
||||
**MVP 可配置字段范围(对应 PRD AC-4)**:
|
||||
|
||||
| field_key | 说明 | 字段类型 |
|
||||
|-----------|------|---------|
|
||||
| `orientation` | 朝向(`property.orientation` 枚举) | 枚举 |
|
||||
| `decoration` | 装修情况(`property.decoration` 枚举) | 枚举 |
|
||||
| `floor` | 所在楼层/总楼层 | 数值 |
|
||||
| `building_area` | 建筑面积(㎡) | 数值 |
|
||||
| `inner_area` | 套内面积(㎡) | 数值 |
|
||||
| `room_layout` | 房型(室/厅/卫) | 数值组 |
|
||||
| `ownership_years` | 产权年限(年) | 数值 |
|
||||
| `parking_count` | 车位数 | 数值 |
|
||||
|
||||
---
|
||||
|
||||
## 四、服务层设计
|
||||
|
||||
所有业务模块**禁止直接查询配置表**,必须通过统一服务层读取:
|
||||
|
||||
```python
|
||||
# apps/setting/services/tenant_settings_service.py
|
||||
|
||||
class TenantSettingsService:
|
||||
"""
|
||||
系统配置统一读取服务。
|
||||
所有配置均经 Redis 缓存,TTL 5min,写入时主动 invalidate。
|
||||
Redis Key 规范:{tenant_schema}:setting:{type}:{key}
|
||||
"""
|
||||
|
||||
def get(self, category: str, key: str, default=None):
|
||||
"""
|
||||
读取标量配置(tenant_settings 表)
|
||||
Cache Key: {tenant_schema}:setting:kv:{category}.{key}
|
||||
返回 JSONB value 字段中 'v' 的值(已解包)
|
||||
"""
|
||||
|
||||
def set(self, category: str, key: str, value, updated_by_id) -> None:
|
||||
"""
|
||||
写入标量配置 + 主动 invalidate 缓存
|
||||
"""
|
||||
|
||||
def get_lookup_items(self, module: str, key: str) -> list[dict]:
|
||||
"""
|
||||
获取可配置枚举选项(lookup_items 表)
|
||||
仅返回 is_active=True 的项,按 sort_order 排序
|
||||
Cache Key: {tenant_schema}:setting:lookup:{module}.{key}
|
||||
返回格式:[{"value": "walk_in", "label_zh": "上门", "is_system": True}, ...]
|
||||
"""
|
||||
|
||||
def get_field_requirements(
|
||||
self, module: str, entity_type: str, trade_status: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
获取字段必填规则
|
||||
匹配顺序:精确匹配(entity_type + trade_status) > 通配规则(trade_status='*')
|
||||
Cache Key: {tenant_schema}:setting:field_req:{module}.{entity_type}.{trade_status}
|
||||
返回格式:{"orientation": "optional", "decoration": "required", ...}
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Redis 缓存键规范
|
||||
|
||||
| 用途 | Cache Key | TTL | 失效触发 |
|
||||
|------|-----------|-----|---------|
|
||||
| 标量配置 | `{schema}:setting:kv:{category}.{key}` | 300s | `TenantSettingsService.set()` |
|
||||
| 可配置枚举 | `{schema}:setting:lookup:{module}.{key}` | 300s | 管理员保存 lookup_items |
|
||||
| 字段规则 | `{schema}:setting:field_req:{module}.{entity_type}.{trade_status}` | 300s | 管理员保存 field_requirement_rules |
|
||||
| 客源规则(整体) | `{schema}:setting:client_rules` | 300s | 任意客源规则变更 |
|
||||
|
||||
> TTL 300s(5 分钟)对应 PRD 成功指标「配置变更生效时延 ≤ 5 分钟」。
|
||||
|
||||
---
|
||||
|
||||
## 六、目录结构
|
||||
|
||||
```
|
||||
apps/setting/
|
||||
├── models/
|
||||
│ ├── lookup.py # LookupGroup, LookupItem
|
||||
│ └── setting.py # TenantSetting, FieldRequirementRule
|
||||
├── services/
|
||||
│ └── tenant_settings_service.py # 统一配置读取服务(禁止直接查表)
|
||||
├── views/
|
||||
│ ├── lookup_views.py # 参数配置页面(US-SETTING-001-A)
|
||||
│ ├── field_rule_views.py # 房源字段规则(US-SETTING-001-B)
|
||||
│ └── client_rule_views.py # 客源规则(US-SETTING-001-C)
|
||||
├── templates/setting/
|
||||
├── fixtures/
|
||||
│ ├── lookup_groups.json # 分组种子数据(3 组)
|
||||
│ ├── lookup_items.json # 选项种子数据(is_system=True)
|
||||
│ ├── tenant_settings.json # 默认配置种子数据
|
||||
│ └── field_requirement_rules.json # 默认字段规则
|
||||
├── migrations/
|
||||
│ ├── 0001_lookup_groups.py
|
||||
│ ├── 0002_lookup_items.py
|
||||
│ ├── 0003_tenant_settings.py
|
||||
│ └── 0004_field_requirement_rules.py
|
||||
└── urls.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、迁移执行顺序
|
||||
|
||||
```
|
||||
0001_lookup_groups # 先建分组表(无外键依赖)
|
||||
0002_lookup_items # 再建选项表(依赖 lookup_groups + staff)
|
||||
0003_tenant_settings # 独立,无外键依赖
|
||||
0004_field_requirement_rules # 独立,仅依赖 staff
|
||||
```
|
||||
|
||||
迁移执行后,通过 `call_command('loaddata', 'lookup_groups')` 等方式加载 fixtures 种子数据。
|
||||
|
||||
---
|
||||
|
||||
## 八、关键约束与禁止项
|
||||
|
||||
| 约束 | 规则 |
|
||||
|------|------|
|
||||
| 不可删除系统预制项 | `lookup_items.is_system = True` 的记录,Service 层硬拦截物理删除请求 |
|
||||
| 不可修改已有 value | `lookup_items.value` 写入后只读;修改请停用旧项 + 新增新项 |
|
||||
| 不可直接查询配置表 | 业务模块(property/client)**必须**通过 `TenantSettingsService` 读取,禁止 ORM 直查 |
|
||||
| entity_type 值域 | 必须与 `property.property_type` CHECK 约束完全一致(见第三章) |
|
||||
| Redis Key 前缀 | 必须携带租户 schema 前缀,格式:`{tenant_schema}:setting:{type}:{key}` |
|
||||
|
||||
---
|
||||
|
||||
## 九、设计决策(ADR)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 枚举两级架构 | `enum_labels`(Public/固定)+ `lookup_items`(Tenant/可配置)分离 | 保障系统一致性的同时给租户灵活度 |
|
||||
| `lookup_groups` 由研发预置 | 是 | 防止租户随意创建不规范分组,控制可配置范围边界 |
|
||||
| `tenant_settings` 使用 JSONB | 是 | 支持 bool/int/string 等多类型,无需为每类型单独建列 |
|
||||
| `field_requirement_rules` 不新增字段 | 是 | 规则层只控制「必填/选填/隐藏」,字段存在性由 DATA_MODEL_PROPERTY 决定 |
|
||||
| 服务层统一读取 | `TenantSettingsService` | 统一缓存管理,业务层与配置存储解耦 |
|
||||
| trade_status 不复用 property.status | 独立 `sale/rent/sale_rent/*` | 录入场景的交易意图与房源全生命周期状态不同,避免耦合 |
|
||||
762
Project/fonrey/DATA_MODEL/ENUMS.md
Normal file
762
Project/fonrey/DATA_MODEL/ENUMS.md
Normal file
@@ -0,0 +1,762 @@
|
||||
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
||||
|
||||
# Fonrey — 统一枚举字典(ENUMS)
|
||||
|
||||
> **定位**:本文件是 Fonrey 全局枚举标准(Public + Tenant)的统一实现基线。
|
||||
> **版本**:v2.1
|
||||
> **日期**:2026-04-27
|
||||
> **适用范围**:`DATA_MODEL_PUBLIC.md`、`DATA_MODEL_LOGIN.md`、`DATA_MODEL_ORG.md`、`DATA_MODEL_COMPLEX.md`、`DATA_MODEL_PROPERTY.md`、`DATA_MODEL_CLIENT.md`、`DATA_MODEL_PERMISSION.md`、`DATA_MODEL_SETTING.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、枚举分层标准(必须遵守)
|
||||
|
||||
Fonrey 采用两层枚举体系:
|
||||
|
||||
1. **固定枚举(Fixed Enum)**
|
||||
- 值域固定,受 `CHECK` 或强业务约束保护
|
||||
- 作为系统契约,不能随意改值
|
||||
- 可落地到 `public.enum_labels`(用于统一标签)
|
||||
|
||||
2. **可配置枚举(Configurable Enum)**
|
||||
- 值域由租户自行维护
|
||||
- 存储在 Tenant Schema:`lookup_groups` + `lookup_items`
|
||||
- **禁止**对业务表字段加固定 `CHECK IN (...)`
|
||||
|
||||
---
|
||||
|
||||
## 二、全局固定枚举(Public / 平台级)
|
||||
|
||||
### 2.1 tenant 生命周期
|
||||
|
||||
**domain**: `public.tenant.plan`
|
||||
- `basic`:基础版
|
||||
- `professional`:专业版
|
||||
- `enterprise`:企业版
|
||||
|
||||
**domain**: `public.tenant.status`
|
||||
- `creating`:创建中
|
||||
- `active`:正常
|
||||
- `suspended`:已挂起
|
||||
- `pending_delete`:待删除
|
||||
- `deleted`:已删除
|
||||
- `failed`:创建/初始化失败
|
||||
|
||||
**domain**: `public.tenant.suspended_reason`
|
||||
- `overdue`:欠费
|
||||
- `violation`:违规
|
||||
- `requested`:客户申请
|
||||
- `other`:其他
|
||||
|
||||
### 2.2 平台管理员
|
||||
|
||||
**domain**: `public.platform_admin.role`
|
||||
- `super_admin`:超级管理员
|
||||
- `ops_operator`:运营管理员
|
||||
- `read_only_auditor`:只读审计员
|
||||
|
||||
### 2.3 平台审计与备份导出
|
||||
|
||||
**domain**: `public.platform_audit.result`
|
||||
- `SUCCESS`:成功
|
||||
- `FAILED`:失败
|
||||
|
||||
**domain**: `public.backup_schedule.frequency`
|
||||
- `hourly`:每小时
|
||||
- `daily`:每日
|
||||
- `weekly`:每周
|
||||
|
||||
**domain**: `public.backup_schedule.storage_target`
|
||||
- `local`:本地存储
|
||||
- `s3`:Amazon S3
|
||||
- `r2`:Cloudflare R2
|
||||
- `gcs`:Google Cloud Storage
|
||||
|
||||
**domain**: `public.backup_record.trigger_type`
|
||||
- `auto`:自动触发
|
||||
- `manual`:手动触发
|
||||
- `pre_upgrade`:升级前触发
|
||||
- `pre_restore`:恢复前触发
|
||||
|
||||
**domain**: `public.backup_record.status`
|
||||
- `pending`:待执行
|
||||
- `in_progress`:执行中
|
||||
- `success`:成功
|
||||
- `failed`:失败
|
||||
|
||||
**domain**: `public.export_task.format`
|
||||
- `csv`:CSV
|
||||
- `json`:JSON
|
||||
- `sql_dump`:SQL 导出
|
||||
|
||||
**domain**: `public.export_task.status`
|
||||
- `pending`:待执行
|
||||
- `in_progress`:执行中
|
||||
- `done`:已完成
|
||||
- `failed`:失败
|
||||
|
||||
### 2.4 升级与发布(Public)
|
||||
|
||||
**domain**: `public.upgrade_event.event_type`
|
||||
- `upgrade`:升级
|
||||
- `rollback`:回滚
|
||||
|
||||
**domain**: `public.upgrade_event.upgrade_type`
|
||||
- `A_app`:A类应用升级
|
||||
- `B_schema`:B类数据库结构升级
|
||||
- `C_feature`:C类功能开关升级
|
||||
|
||||
**domain**: `public.upgrade_event.strategy`
|
||||
- `full`:全量发布
|
||||
- `canary`:灰度发布
|
||||
|
||||
**domain**: `public.upgrade_event.status`
|
||||
- `draft`:草稿
|
||||
- `pre_check`:预检查
|
||||
- `pre_backup`:预备份
|
||||
- `batch_running`:批次执行中
|
||||
- `batch_done`:批次完成
|
||||
- `halted`:已暂停
|
||||
- `succeeded`:已成功
|
||||
- `failed`:失败
|
||||
- `rollback_running`:回滚中
|
||||
- `rolled_back`:已回滚
|
||||
|
||||
**domain**: `public.upgrade_event.failure_policy`
|
||||
- `halt_batch`:失败即停止批次
|
||||
- `continue`:失败继续
|
||||
|
||||
**domain**: `public.client_release.platform`
|
||||
- `win32`:Windows 客户端
|
||||
|
||||
**domain**: `public.client_release.arch`
|
||||
- `x64`:x64 架构
|
||||
- `arm64`:ARM64 架构
|
||||
|
||||
**domain**: `public.client_release.release_type`
|
||||
- `normal`:普通更新
|
||||
- `force`:强制更新
|
||||
|
||||
**domain**: `public.client_release.status`
|
||||
- `draft`:草稿
|
||||
- `published`:已发布
|
||||
- `archived`:已归档
|
||||
|
||||
---
|
||||
|
||||
## 三、Tenant 固定枚举(模块级,值域统一)
|
||||
|
||||
> 说明:以下字段在 Tenant Schema 中存储,但值域为系统统一标准,属于“全局实现标准”。
|
||||
|
||||
## 3.1 登录认证(account/login)
|
||||
|
||||
**domain**: `login.user_account.status`
|
||||
- `active`:启用
|
||||
- `disabled`:停用
|
||||
- `locked`:锁定
|
||||
|
||||
**domain**: `login.login_attempt.failure_reason`
|
||||
- `wrong_password`:用户名或密码错误
|
||||
- `wrong_captcha`:验证码错误
|
||||
- `account_locked`:账号锁定
|
||||
- `account_disabled`:账号停用
|
||||
- `tenant_not_found`:租户不存在
|
||||
|
||||
---
|
||||
|
||||
## 3.2 组织人事(org)
|
||||
|
||||
**domain**: `org.org_unit.type`
|
||||
- `company`:公司
|
||||
- `division`:事业部
|
||||
- `region`:大区
|
||||
- `area`:区域
|
||||
- `district`:片区
|
||||
- `store`:门店
|
||||
- `group`:店组
|
||||
- `functional`:职能部门
|
||||
|
||||
**domain**: `org.org_unit.attribute`
|
||||
- `direct`:直营
|
||||
- `franchise`:加盟
|
||||
|
||||
**domain**: `org.staff.role`
|
||||
- `agent`:经纪人
|
||||
- `store_manager`:店长
|
||||
- `area_manager`:区域经理
|
||||
- `admin`:系统管理员
|
||||
- `operator`:运营/行政
|
||||
- `system`:系统账号
|
||||
|
||||
**domain**: `org.staff.status`
|
||||
- `active`:在职
|
||||
- `probation`:试用
|
||||
- `resigned`:离职
|
||||
- `frozen`:冻结
|
||||
|
||||
**domain**: `org.staff_personal_info.gender`
|
||||
- `male`:男
|
||||
- `female`:女
|
||||
- `unknown`:未知
|
||||
|
||||
**domain**: `org.staff_personal_info.id_type`
|
||||
- `id_card`:身份证
|
||||
- `passport`:护照
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `org.staff_transfer.transfer_type`
|
||||
- `onboard`:入职
|
||||
- `transfer`:调动
|
||||
- `resign`:离职
|
||||
- `rejoin`:复职
|
||||
- `supervisor_change`:上级变更
|
||||
- `role_change`:角色变更
|
||||
- `freeze`:冻结账号
|
||||
- `unfreeze`:恢复账号
|
||||
|
||||
**domain**: `org.staff_account.platform`
|
||||
- `fonrey`:房睿主账号
|
||||
- `58anjuke`:58安居客
|
||||
- `cnreic`:中国网络经纪人
|
||||
- `wechat_mp`:微信公众号
|
||||
|
||||
---
|
||||
|
||||
## 3.3 权限系统(permission)
|
||||
|
||||
**domain**: `permission.module`
|
||||
- `home`:首页
|
||||
- `property`:房源
|
||||
- `new_house`:新房
|
||||
- `client`:客源
|
||||
- `transaction`:交易
|
||||
- `data`:数据
|
||||
- `marketing`:营销
|
||||
- `hr`:人事OA
|
||||
- `contract`:合同
|
||||
- `trinet`:三网
|
||||
- `system`:系统
|
||||
- `mobile`:移动端
|
||||
- `smart_store`:智能门店
|
||||
- `recharge`:在线充值
|
||||
|
||||
**domain**: `permission.value_type`
|
||||
- `BOOLEAN`:开关型
|
||||
- `SCOPE`:范围型
|
||||
- `INTEGER`:数值型
|
||||
|
||||
**domain**: `permission.role_category`
|
||||
- `agent`:置业顾问
|
||||
- `store_manager`:店管
|
||||
- `director`:总经
|
||||
- `operator`:运营/行政
|
||||
- `custom`:自定义
|
||||
|
||||
**domain**: `permission.scope_level`
|
||||
- `none`:无
|
||||
- `self`:本人
|
||||
- `group`:本组
|
||||
- `store`:本门店
|
||||
- `area`:本区域
|
||||
- `region`:本大区
|
||||
- `company`:全公司
|
||||
|
||||
**domain**: `permission.override_mode`
|
||||
- `REPLACE`:覆盖
|
||||
- `RESTRICT`:限制
|
||||
- `GRANT`:授予
|
||||
|
||||
**domain**: `permission.data_scope_type`
|
||||
- `self`:本人
|
||||
- `group`:本组
|
||||
- `store`:本门店
|
||||
- `area`:本区域
|
||||
- `region`:本大区
|
||||
- `company`:全公司
|
||||
- `custom_unit`:自定义组织单元
|
||||
|
||||
**domain**: `permission.change_log.target_type`
|
||||
- `role`:角色
|
||||
- `role_permission`:角色权限
|
||||
- `staff_role`:员工角色
|
||||
- `staff_override`:员工权限覆盖
|
||||
- `staff_scope`:员工数据范围
|
||||
|
||||
**domain**: `permission.change_log.action`
|
||||
- `create`:创建
|
||||
- `update`:更新
|
||||
- `delete`:删除
|
||||
- `assign`:分配
|
||||
- `revoke`:撤销
|
||||
|
||||
---
|
||||
|
||||
## 3.4 楼盘区域(complex)
|
||||
|
||||
**domain**: `complex.school.type`
|
||||
- `primary`:小学
|
||||
- `middle`:初中
|
||||
- `high`:高中
|
||||
- `k9`:九年一贯制
|
||||
- `k12`:十二年一贯制
|
||||
|
||||
**domain**: `complex.school.nature`
|
||||
- `public`:公立
|
||||
- `private`:私立
|
||||
- `international`:国际
|
||||
|
||||
**domain**: `complex.school.level`
|
||||
- `normal`:普通
|
||||
- `key`:重点
|
||||
- `top`:名校
|
||||
|
||||
**domain**: `complex.building_type`
|
||||
- `slab`:板楼
|
||||
- `tower`:塔楼
|
||||
- `slab_tower`:板塔结合
|
||||
|
||||
**domain**: `complex.water_type`
|
||||
- `civil`:民水
|
||||
- `commercial`:商水
|
||||
|
||||
**domain**: `complex.electricity_type`
|
||||
- `civil`:民电
|
||||
- `commercial`:商电
|
||||
|
||||
**domain**: `complex.school_zone_type`
|
||||
- `guaranteed`:对口
|
||||
- `reference`:参考
|
||||
- `lottery`:摇号
|
||||
|
||||
**domain**: `complex.photo.category`
|
||||
- `complex`:楼盘图
|
||||
- `layout`:户型图
|
||||
- `vr`:VR图
|
||||
- `other`:其他
|
||||
|
||||
---
|
||||
|
||||
## 3.5 房源(property)
|
||||
|
||||
**domain**: `property.property_type`
|
||||
- `residential`:住宅
|
||||
- `villa`:别墅
|
||||
- `commercial_residential`:商住
|
||||
- `shop`:商铺
|
||||
- `office`:写字楼
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `property.status`
|
||||
- `for_sale`:出售
|
||||
- `for_rent`:出租
|
||||
- `for_sale_rent`:租售
|
||||
- `suspended`:暂缓
|
||||
- `sold_elsewhere`:他售
|
||||
- `rented_elsewhere`:他租
|
||||
- `sold`:成交
|
||||
- `unlisted`:未挂牌
|
||||
|
||||
**domain**: `property.attribute`
|
||||
- `public`:公盘
|
||||
- `private`:私盘
|
||||
- `special`:特盘
|
||||
- `sealed`:封盘
|
||||
|
||||
**domain**: `property.orientation`
|
||||
- `east`:东
|
||||
- `south`:南
|
||||
- `west`:西
|
||||
- `north`:北
|
||||
- `southeast`:东南
|
||||
- `northeast`:东北
|
||||
- `east_west`:东西
|
||||
- `south_north`:南北
|
||||
- `northwest`:西北
|
||||
- `southwest`:西南
|
||||
|
||||
**domain**: `property.decoration`
|
||||
- `rough`:毛坯
|
||||
- `plain`:清水
|
||||
- `simple`:简装
|
||||
- `medium`:中装
|
||||
- `fine`:精装
|
||||
- `luxury`:豪装
|
||||
|
||||
**domain**: `property.house_status`
|
||||
- `owner_occupied`:业主自住
|
||||
- `vacant`:空置
|
||||
- `tenant_occupied`:租客在住
|
||||
- `unknown`:未知
|
||||
|
||||
**domain**: `property.viewing_time`
|
||||
- `anytime`:随时看房
|
||||
- `by_appointment`:预约看房
|
||||
- `inconvenient`:不便看房
|
||||
|
||||
**domain**: `property.grade`
|
||||
- `A_urgent`:A(急迫)
|
||||
- `A`:A
|
||||
- `B`:B(较强)
|
||||
- `C`:C(一般)
|
||||
- `D`:D(较弱)
|
||||
|
||||
**domain**: `property.contact.gender`
|
||||
- `male`:先生
|
||||
- `female`:女士
|
||||
|
||||
**domain**: `property.contact.identity`
|
||||
- `owner`:业主
|
||||
- `contact`:联系人
|
||||
- `subletter`:转租人
|
||||
- `tenant`:租客
|
||||
- `agent`:代理人
|
||||
- `corporate`:企业法人
|
||||
|
||||
**domain**: `property.listing_history.listing_type`
|
||||
- `for_sale`:出售挂牌
|
||||
- `for_rent`:出租挂牌
|
||||
|
||||
**domain**: `property.listing_history.status`
|
||||
- `active`:生效中
|
||||
- `ended`:已结束
|
||||
|
||||
**domain**: `property.follow_log.log_type`
|
||||
- `written`:手写跟进
|
||||
- `modified`:修改跟进
|
||||
- `sensitive_op`:敏感操作
|
||||
- `sensitive_view`:敏感查看
|
||||
- `other`:其他
|
||||
- `system`:系统
|
||||
|
||||
**domain**: `property.follow_log.ai_tag`
|
||||
- `ai_for_sale`:AI判断可售
|
||||
- `ai_not_for_sale`:AI判断不可售
|
||||
|
||||
**domain**: `property.follow_attachment.file_type`
|
||||
- `bmp`:BMP
|
||||
- `jpg`:JPG
|
||||
- `png`:PNG
|
||||
- `svg`:SVG
|
||||
- `gif`:GIF
|
||||
|
||||
**domain**: `property.key.key_type`
|
||||
- `mechanical`:机械钥匙
|
||||
- `password`:密码钥匙
|
||||
|
||||
**domain**: `property.commission.owner_type`
|
||||
- `owner`:产权人本人
|
||||
- `authorized_third`:授权第三方
|
||||
|
||||
**domain**: `property.commission.status`
|
||||
- `active`:有效
|
||||
- `expired`:过期
|
||||
- `cancelled`:取消
|
||||
|
||||
**domain**: `property.commission_attachment.category`
|
||||
- `id_card`:身份证件
|
||||
- `property_cert`:产权证明
|
||||
- `commission_letter`:委托书
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `property.field_survey.status`
|
||||
- `draft`:草稿
|
||||
- `submitted`:已提交
|
||||
|
||||
**domain**: `property.survey_photo.category`
|
||||
- `layout`:户型图
|
||||
- `living_room`:客厅
|
||||
- `dining_room`:餐厅
|
||||
- `bedroom`:卧室
|
||||
- `bathroom`:卫生间
|
||||
- `kitchen`:厨房
|
||||
- `entrance`:入户
|
||||
- `balcony`:阳台
|
||||
- `study`:书房
|
||||
- `indoor_other`:室内其他
|
||||
- `outdoor`:室外
|
||||
|
||||
**domain**: `property.photo.category`
|
||||
- `cover`:封面
|
||||
- `entrance`:入户
|
||||
- `living_room`:客厅
|
||||
- `dining_room`:餐厅
|
||||
- `bedroom`:卧室
|
||||
- `bathroom`:卫生间
|
||||
- `kitchen`:厨房
|
||||
- `balcony`:阳台
|
||||
- `study`:书房
|
||||
- `indoor_other`:室内其他
|
||||
- `outdoor`:室外
|
||||
- `panorama`:全景
|
||||
|
||||
**domain**: `property.attachment.category`
|
||||
- `id_card`:身份证件
|
||||
- `property_cert`:产权证明
|
||||
- `commission_letter`:委托书
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `property.number_holder_approval.status`
|
||||
- `pending`:待审批
|
||||
- `approved`:已通过
|
||||
- `rejected`:已驳回
|
||||
|
||||
---
|
||||
|
||||
## 3.6 客源(client)
|
||||
|
||||
**domain**: `client.client_type`
|
||||
- `private`:私客
|
||||
- `public`:公客
|
||||
- `transacted`:成交客
|
||||
|
||||
**domain**: `client.status`
|
||||
- `buying`:求购
|
||||
- `renting`:求租
|
||||
- `buy_or_rent`:租购
|
||||
- `suspended`:暂缓
|
||||
- `bought`:已购
|
||||
- `rented_done`:已租
|
||||
- `public`:公客
|
||||
- `invalid`:无效
|
||||
|
||||
**domain**: `client.grade`
|
||||
- `A`:A(急迫)
|
||||
- `B`:B(较强)
|
||||
- `C`:C(一般)
|
||||
- `D`:D(较弱)
|
||||
- `E`:E(暂不关注)
|
||||
|
||||
**domain**: `client.property_usage`
|
||||
- `residential`:住宅
|
||||
- `villa`:别墅
|
||||
- `commercial_residential`:商住
|
||||
- `shop`:商铺
|
||||
- `office`:写字楼
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `client.buying_purpose`
|
||||
- `rigid`:刚需
|
||||
- `investment`:投资
|
||||
- `school_district`:学区
|
||||
- `upgrade`:改善
|
||||
- `commercial`:商用
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `client.payment_method`
|
||||
- `full`:全额
|
||||
- `mortgage`:商业贷款
|
||||
- `mortgage_fund`:商贷+公积金
|
||||
- `fund`:公积金
|
||||
|
||||
**domain**: `client.properties_owned`
|
||||
- `none`:无
|
||||
- `local_none`:本地无/外地有
|
||||
- `local_has`:本地有
|
||||
|
||||
**domain**: `client.id_type`
|
||||
- `id_card`:身份证
|
||||
- `passport`:护照
|
||||
- `hk_macao`:港澳通行证
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `client.transfer_to_public_type`
|
||||
- `manual`:手动转公
|
||||
- `auto`:自动转公
|
||||
- `marketing_jump`:营销客跳公
|
||||
- `resource_public`:资料客素公
|
||||
|
||||
**domain**: `client.invalid_reason`
|
||||
- `invalid_phone`:号码无效
|
||||
- `peer_agent`:同行
|
||||
- `ad`:广告推销
|
||||
- `no_intent`:无意向
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `client.transacted_type`
|
||||
- `bought`:我购
|
||||
- `rented`:我租
|
||||
|
||||
**domain**: `client.transacted_property_type`
|
||||
- `second_hand`:二手
|
||||
- `new_house`:新房
|
||||
|
||||
**domain**: `client.activity_level`
|
||||
- `new_matched`:新配对
|
||||
- `active_7d`:7日活跃
|
||||
- `active_30d`:30日活跃
|
||||
- `active_90d`:90日活跃
|
||||
- `expiring`:即将过期
|
||||
- `frozen`:暂缓中
|
||||
- `invalid`:无效
|
||||
|
||||
**domain**: `client.contact.gender`
|
||||
- `male`:先生
|
||||
- `female`:女士
|
||||
|
||||
**domain**: `client.requirement_type`
|
||||
- `second_hand`:二手
|
||||
- `new_house`:新房
|
||||
- `rental`:租房
|
||||
|
||||
**domain**: `client.floor_preference`
|
||||
- `no_first`:不要一楼
|
||||
- `low`:低楼层
|
||||
- `mid`:中楼层
|
||||
- `high`:高楼层
|
||||
- `no_top`:不要顶楼
|
||||
|
||||
**domain**: `client.orientation`
|
||||
- `east`:东
|
||||
- `south`:南
|
||||
- `west`:西
|
||||
- `north`:北
|
||||
|
||||
**domain**: `client.decoration`
|
||||
- `rough`:毛坯
|
||||
- `plain`:清水
|
||||
- `simple`:简装
|
||||
- `medium`:中装
|
||||
- `fine`:精装
|
||||
- `luxury`:豪装
|
||||
|
||||
**domain**: `client.building_age_range`
|
||||
- `within_5y`:5年内
|
||||
- `5_10y`:5-10年
|
||||
- `10_15y`:10-15年
|
||||
- `15_20y`:15-20年
|
||||
- `over_20y`:20年以上
|
||||
|
||||
**domain**: `client.follow_log.log_type`
|
||||
- `written`:写入跟进
|
||||
- `modified`:修改跟进
|
||||
- `sensitive_view`:敏感查看
|
||||
- `other`:其他
|
||||
- `system`:系统
|
||||
|
||||
**domain**: `client.viewing.viewing_type`
|
||||
- `appointment`:预约
|
||||
- `viewing`:带看
|
||||
- `revisit`:复看
|
||||
- `empty`:空看
|
||||
|
||||
**domain**: `client.viewing.client_intent`
|
||||
- `interested`:感兴趣
|
||||
- `not_interested`:不感兴趣
|
||||
- `negotiating`:谈判中
|
||||
- `cancelled`:取消
|
||||
|
||||
**domain**: `client.property_match.match_source`
|
||||
- `recorded`:录客配房
|
||||
- `system`:系统配房
|
||||
|
||||
**domain**: `client.property_match.match_group`
|
||||
- `quality_layout`:优质户型
|
||||
- `price_reduced`:降价
|
||||
- `hot`:热门
|
||||
- `newly_listed`:新上
|
||||
|
||||
**domain**: `client.property_match.status`
|
||||
- `suggested`:待推送
|
||||
- `shared`:已分享
|
||||
- `rejected`:已反馈不合适
|
||||
- `viewed`:客户已查看
|
||||
|
||||
**domain**: `client.status_log.change_type`
|
||||
- `status_change`:改状态
|
||||
- `grade_change`:改等级
|
||||
- `to_public`:转公客
|
||||
- `to_transacted`:转成交
|
||||
- `to_invalid`:转无效
|
||||
- `owner_change`:改归属人
|
||||
- `source_change`:改来源
|
||||
- `merge`:合并客源
|
||||
|
||||
---
|
||||
|
||||
## 3.7 系统配置(setting)
|
||||
|
||||
**domain**: `setting.tenant_setting.value_type`
|
||||
- `bool`:布尔
|
||||
- `int`:整数
|
||||
- `string`:字符串
|
||||
- `enum`:枚举
|
||||
|
||||
**domain**: `setting.field_rule.module`
|
||||
- `property`:房源
|
||||
- `client`:客源(预留)
|
||||
|
||||
**domain**: `setting.field_rule.entity_type`(与 `property.property_type` 对齐)
|
||||
- `residential`:住宅
|
||||
- `villa`:别墅
|
||||
- `commercial_residential`:商住
|
||||
- `shop`:商铺
|
||||
- `office`:写字楼
|
||||
- `other`:其他
|
||||
|
||||
**domain**: `setting.field_rule.trade_status`
|
||||
- `sale`:出售
|
||||
- `rent`:出租
|
||||
- `sale_rent`:租售
|
||||
- `*`:全部
|
||||
|
||||
**domain**: `setting.field_rule.requirement`
|
||||
- `required`:必填
|
||||
- `optional`:选填
|
||||
- `hidden`:隐藏
|
||||
|
||||
---
|
||||
|
||||
## 四、Tenant 可配置枚举字段清单(lookup_items 权威)
|
||||
|
||||
> 以下字段值域由 `lookup_items` 维护,属于租户级配置,不在业务表中写死 `CHECK`。
|
||||
|
||||
| domain(统一命名) | 对应字段 | 当前状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `client.source` | `clients.source` | ✅ 已落地 | 客源来源 |
|
||||
| `client.follow_purpose` | `client_follow_logs.purpose` | ✅ 已落地 | 客源跟进目的 |
|
||||
| `property.source` | `properties.source` | ✅ 已落地 | 房源来源 |
|
||||
| `property.follow_purpose` | `follow_logs.purpose` | 🔄 建议统一 | 房源跟进目的(建议与 `client.follow_purpose` 共享或独立分组) |
|
||||
| `property.commission_type` | `commissions.commission_type` | 🔄 待入组 | 委托类型(独家/非独家等) |
|
||||
| `client.match_feedback` | `client_property_matches.feedback` | 🔄 待入组 | 配房反馈原因 |
|
||||
| `org.reward_punish_category` | `staff_reward_punish.category` | 🔄 待入组 | 人事奖惩类别 |
|
||||
|
||||
### 4.1 lookup_groups 规范(Tenant Schema)
|
||||
|
||||
- `module`: 业务模块标识(如 `client` / `property` / `org`)
|
||||
- `key`: 领域键(如 `source` / `follow_purpose`)
|
||||
- 同一组内 `value` 不可重复(`UNIQUE(group_id, value)`)
|
||||
- `is_system = TRUE` 的项禁止物理删除(仅可停用)
|
||||
|
||||
---
|
||||
|
||||
## 五、统一实现约束
|
||||
|
||||
1. **固定枚举值不可改名**:只能新增或停用,禁止修改既有 value。
|
||||
2. **中文展示从字典取值**:前端/UI 不得硬编码中文。
|
||||
3. **可配置枚举不得加固定 CHECK**:防止租户自定义被数据库约束阻断。
|
||||
4. **跨模块同名枚举必须复用语义**:如 `status` 在不同领域必须使用 domain 区分,不允许混用。
|
||||
5. **缓存规范**:
|
||||
- 固定枚举:`public:enum_labels:{domain}`(建议 TTL 24h)
|
||||
- 可配置枚举:`{schema}:setting:lookup:{module}.{key}`(TTL 300s)
|
||||
|
||||
---
|
||||
|
||||
## 六、变更流程(必须同步)
|
||||
|
||||
新增或修改任一枚举时,必须同时更新:
|
||||
|
||||
1. 本文档 `ENUMS.md`
|
||||
2. 对应 `DATA_MODEL_*.md` 字段定义(CHECK / 注释 / 业务规则)
|
||||
3. `enum_labels` 种子数据(若为固定枚举)或 `lookup_groups/items` fixture(若为可配置枚举)
|
||||
4. 服务层缓存失效逻辑(Redis key)
|
||||
|
||||
---
|
||||
|
||||
## 七、与 ADR 的一致性说明
|
||||
|
||||
本文件已对齐以下冻结决策:
|
||||
|
||||
- 固定枚举与可配置枚举双轨并存
|
||||
- 状态机和值域以文档为权威来源
|
||||
- Tenant 级可配置枚举统一由 `setting` 模块托管
|
||||
- Agent 编码前先读枚举标准,禁止各模块自行定义“影子枚举”
|
||||
Reference in New Issue
Block a user