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:
Shen Wei
2026-04-27 15:31:48 +08:00
parent 4422c0eac8
commit 712a33fbac
21 changed files with 6466 additions and 2387 deletions

View File

@@ -2,8 +2,8 @@
# Fonrey 房产经纪管理系统 — DATA MODEL 设计文档
> **作者**: Backend Architect
> **版本**: v1.3
> **日期**: 2026-04-24v1.1 修复 S1/S2/S4v1.2 扩展 public schemav1.3 §三 DDL 迁至 DATA_MODEL_PUBLIC.md本文改为索引
> **版本**: v1.4
> **日期**: 2026-04-24v1.1 修复 S1/S2/S4v1.2 扩展 public schemav1.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 Dump24h 下载链接) |
| **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 Dump24h 下载链接) | §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/DELETEappend-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:1Celery 异步计算) | `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:1Celery 异步计算) | `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_modeREPLACE / 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

View File

@@ -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 |
---

View File

@@ -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) -- 同租户内邮箱唯一(可为 NULLNULL 不参与唯一性校验)
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
UNIQUE (staff_id) -- 员工档案 1:1 绑定
```
#### 索引
```sql
CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username);
CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL;
CREATE INDEX idx_user_accounts_status ON user_accounts (status);
CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id);
```
#### Django Model 定义
```python
# apps/accounts/models.py
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models
class UserAccountManager(BaseUserManager):
def create_user(self, username, password, **extra_fields):
if not username:
raise ValueError("username 不能为空")
user = self.model(username=username, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
class UserAccount(AbstractBaseUser):
"""
租户级用户账号。
- 普通员工username 固定为手机号11 位数字)
- Tenant Adminusername 由平台运营自定义字母开头6~30 位)
注意:此表位于租户 Schemausername 唯一性约束在 Schema 维度生效。
"""
username = models.CharField(max_length=30)
email = models.EmailField(null=True, blank=True)
phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文
phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引
staff = models.OneToOneField(
'org.Staff',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='account',
)
is_tenant_admin = models.BooleanField(default=False)
status = models.CharField(
max_length=10,
choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')],
default='active',
)
is_initial_password = models.BooleanField(default=True)
last_login = models.DateTimeField(null=True, blank=True)
locked_until = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'self',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='created_accounts',
)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
objects = UserAccountManager()
class Meta:
db_table = 'user_accounts'
# Schema 内唯一约束
constraints = [
models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'),
]
def __str__(self):
return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})"
def is_locked(self) -> bool:
"""检查账号是否处于锁定状态(含自动过期判断)"""
from django.utils import timezone
if self.status == 'locked':
if self.locked_until and timezone.now() >= self.locked_until:
# 锁定已到期,应用层自动恢复(实际由 service 层处理)
return False
return True
return False
```
---
### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema
**表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6 |
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-AgentElectron 版本信息) |
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
**`failure_reason` 枚举值**
| 值 | 含义 |
|----|------|
| `wrong_password` | 用户名或密码错误 |
| `wrong_captcha` | 行为验证码失败 |
| `account_locked` | 账号已锁定 |
| `account_disabled` | 账号已停用 |
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
#### 索引
```sql
CREATE INDEX idx_login_attempts_username ON login_attempts (username);
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address);
CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC);
-- 复合索引:按账号查询最近失败记录(锁定判断场景)
CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC);
```
#### Django Model 定义
```python
class LoginAttempt(models.Model):
"""
登录尝试审计记录。
- 合规保留周期:≥ 90 天
- 注意failure_reason 不得存储密码明文(含错误密码)
"""
FAILURE_REASONS = [
('wrong_password', '用户名或密码错误'),
('wrong_captcha', '行为验证码失败'),
('account_locked', '账号已锁定'),
('account_disabled', '账号已停用'),
('tenant_not_found', '租户不存在'),
]
username = models.CharField(max_length=30)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(null=True, blank=True)
success = models.BooleanField()
failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS)
attempted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'login_attempts'
indexes = [
models.Index(fields=['username']),
models.Index(fields=['ip_address']),
models.Index(fields=['-attempted_at']),
models.Index(fields=['username', 'success', '-attempted_at'],
name='idx_login_attempts_fail_check'),
]
def __str__(self):
return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}"
```
---
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema
**表说明**用于通过邮件找回密码的一次性令牌。单次有效30 分钟过期。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成86 字符),全局唯一 |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True防止重放攻击 |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
#### 索引
```sql
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
```
#### Django Model 定义
```python
class PasswordResetToken(models.Model):
"""
密码重置令牌。
安全约束:
- Token 单次有效is_used=True 后立即失效)
- 有效期 30 分钟
- 同一账号 1 小时内最多生成 3 个服务层限频Redis 计数)
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_reset_tokens'
indexes = [
models.Index(fields=['user_id']),
]
def is_valid(self) -> bool:
from django.utils import timezone
return not self.is_used and timezone.now() < self.expires_at
```
---
### 3.4 `password_histories` — 历史密码记录表(租户 Schema
**表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) |
#### 索引
```sql
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
```
#### Django Model 定义
```python
class PasswordHistory(models.Model):
"""
历史密码记录,每个账号保留最近 N 条(默认 3 条)。
新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories')
password_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_histories'
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', '-created_at']),
]
```
---
## 四、Redis 缓存结构(辅助状态,非持久化)
以下 Redis Key 不存入 PostgreSQL属于运行时状态需与数据库状态保持最终一致
| Key 格式 | 类型 | TTL | 说明 |
|----------|------|-----|------|
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token验证通过后生成 `captcha_pass_token` |
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
| `login_fail:{tenant_id}:{username}` | STRING计数 | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定TTL 30 分钟自动清零 |
| `recover_email:{email}` | STRING计数 | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
| `recover_reset:{account_id}` | STRING计数 | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
| `tenant_verify_ip:{ip}` | STRING计数 | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
---
## 五、账号创建流程与状态机
### 5.1 账号状态机
```
┌─────────────────────────────────────┐
│ 账号生命周期状态机 │
└─────────────────────────────────────┘
[创建账号]
│ is_initial_password=True, status=active
[初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页]
│ │ 修改成功
│ ▼
│ [正常使用态]
│ status=active
│ is_initial_password=False
├── 密码错误 ≥ 5 次 ──────────────────► [锁定态]
│ status=locked
│ locked_until = now+30min
│ │
│ ┌───────────────┤
│ │ 30分钟到期 │ 管理员手动解锁
│ ▼ ▼
│ [正常使用态] ◄─── [管理员操作]
└── 员工离职 / 管理员停用 ──► [停用态]
status=disabled
员工复职/管理员恢复
[正常使用态]
```
### 5.2 账号创建触发时机
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义字母开头6~30 位) | 平台运营自定义 |
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统Tenant Admin 触发) | 固定为员工手机号11 位) | 系统统一初始密码(部署配置) |
---
## 六、关联约束与数据完整性
### 6.1 与 `org.Staff` 的关联规则
```
org_staff (1) ──── (0..1) user_accounts
```
- 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active
- Tenant Admin`staff_id` **可为空**(平台运营账号可不绑定实名档案)
- 员工离职时(`org.Staff.status``resigned`),触发账号 `status``disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖)
- 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留
### 6.2 跨 App 依赖方向
```
accounts ──► org (单向依赖accounts.UserAccount.staff_id → org.Staff)
org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查)
```
> **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。
---
## 七、迁移说明Django Migrations
### 初始迁移顺序
```
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
0002_login_attempts.py # LoginAttempt 表
0003_password_reset_tokens.py # PasswordResetToken 表
0004_password_histories.py # PasswordHistory 表
```
### 注意事项
- `accounts` App 的迁移依赖 `org` App`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org``accounts` 之前
- 所有迁移均在**租户 Schema** 下执行(`django-tenants``migrate_schemas` 命令)
- 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择)
---
## 八、设计决策说明ADR
| 决策 | 选择 | 理由 |
|------|------|------|
| 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 |
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |
# Fonrey — 登录与账号认证数据模型DATA_MODEL_LOGIN
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
---
## 一、领域概览Domain Overview
### 核心概念
- **UserAccount用户账号**:系统登录主体,必须与员工档案(`org.Staff`1:1 绑定。分为 Tenant Admin超级管理账号每租户唯一和普通员工账号username 固定为手机号)。
- **LoginAttempt登录尝试记录**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
- **PasswordResetToken密码重置令牌**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
- **PasswordHistory历史密码记录**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
### 关键业务规则
1. **账号与员工强绑定**:每个登录账号 **必须**`org.Staff` 中的员工档案 1:1 绑定Tenant Admin 例外,可不绑定)。
2. **用户名规则差异化**
- Tenant Admin由平台运营自定义字母开头6~30 位,含字母/数字/下划线)
- 普通员工:**固定为员工手机号**11 位数字),创建后不可变更
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`30 分钟后自动恢复Tenant Admin 可提前手动解锁。
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
6. **不支持自助注册**:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。
---
## 二、实体关系
```
UserAccount
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
├── 1:N ── LoginAttempt (登录审计记录)
├── 1:N ── PasswordResetToken (密码重置令牌)
├── 1:N ── PasswordHistory (历史密码记录)
└── M:1 ── UserAccount.created_by (创建人自引用)
```
### Schema 归属
| 表 | Schema 位置 | 说明 |
|----|------------|------|
| `user_accounts` | 租户 Schema | 账号数据按租户隔离username 唯一性在 Schema 维度生效 |
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
> **注意**Tenant ID 验证相关逻辑在 **Public Schema**`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema
---
## 三、Schema 定义
### 3.1 `user_accounts` — 账号主表(租户 Schema
**表说明**:系统登录主体,每个租户内独立隔离,`username` 唯一性约束在 Schema 维度生效。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) |
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名普通员工为手机号11 位数字Tenant Admin 为自定义字符串;创建后不可更改 |
| `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` |
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
| `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 |
| `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 |
| `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定1:1普通员工必须有值Tenant Admin 可为空 |
| `is_tenant_admin` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否为该租户的超级管理账号;每个租户最多 1 个(应用层约束) |
| `status` | `VARCHAR(10)` | `NOT NULL`, `CHECK(status IN ('active','disabled','locked'))` | `'active'` | 账号状态;`locked` 为密码错误锁定30 分钟自动恢复 |
| `is_initial_password` | `BOOLEAN` | `NOT NULL` | `TRUE` | 初始密码标记True 时登录成功后强制跳转修改密码页,不可跳过 |
| `last_login` | `TIMESTAMPTZ` | `NULL` | `NULL` | 最后登录时间 |
| `locked_until` | `TIMESTAMPTZ` | `NULL` | `NULL` | 锁定到期时间;到期后应用层将 status 恢复 active |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 账号创建时间 |
| `updated_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 最后更新时间(触发器维护) |
| `created_by` | `BIGINT` | `FK → user_accounts.id`, `NULL` | `NULL` | 创建人;普通员工由 Tenant Admin 创建Tenant Admin 由平台运营创建(可为 NULL |
#### 唯一性约束
```sql
UNIQUE (username) -- Schema 内唯一跨租户不冲突django-tenants 机制保障)
UNIQUE (email) -- 同租户内邮箱唯一(可为 NULLNULL 不参与唯一性校验)
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
UNIQUE (staff_id) -- 员工档案 1:1 绑定
```
#### 索引
```sql
CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username);
CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL;
CREATE INDEX idx_user_accounts_status ON user_accounts (status);
CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id);
```
#### Django Model 定义
```python
# apps/accounts/models.py
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.db import models
class UserAccountManager(BaseUserManager):
def create_user(self, username, password, **extra_fields):
if not username:
raise ValueError("username 不能为空")
user = self.model(username=username, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
class UserAccount(AbstractBaseUser):
"""
租户级用户账号。
- 普通员工username 固定为手机号11 位数字)
- Tenant Adminusername 由平台运营自定义字母开头6~30 位)
注意:此表位于租户 Schemausername 唯一性约束在 Schema 维度生效。
"""
username = models.CharField(max_length=30)
email = models.EmailField(null=True, blank=True)
phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文
phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引
staff = models.OneToOneField(
'org.Staff',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='account',
)
is_tenant_admin = models.BooleanField(default=False)
status = models.CharField(
max_length=10,
choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')],
default='active',
)
is_initial_password = models.BooleanField(default=True)
last_login = models.DateTimeField(null=True, blank=True)
locked_until = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'self',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='created_accounts',
)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
objects = UserAccountManager()
class Meta:
db_table = 'user_accounts'
# Schema 内唯一约束
constraints = [
models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'),
]
def __str__(self):
return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})"
def is_locked(self) -> bool:
"""检查账号是否处于锁定状态(含自动过期判断)"""
from django.utils import timezone
if self.status == 'locked':
if self.locked_until and timezone.now() >= self.locked_until:
# 锁定已到期,应用层自动恢复(实际由 service 层处理)
return False
return True
return False
```
---
### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema
**表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6 |
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-AgentElectron 版本信息) |
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
**`failure_reason` 枚举值**
| 值 | 含义 |
|----|------|
| `wrong_password` | 用户名或密码错误 |
| `wrong_captcha` | 行为验证码失败 |
| `account_locked` | 账号已锁定 |
| `account_disabled` | 账号已停用 |
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
#### 索引
```sql
CREATE INDEX idx_login_attempts_username ON login_attempts (username);
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address);
CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC);
-- 复合索引:按账号查询最近失败记录(锁定判断场景)
CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC);
```
#### Django Model 定义
```python
class LoginAttempt(models.Model):
"""
登录尝试审计记录。
- 合规保留周期:≥ 90 天
- 注意failure_reason 不得存储密码明文(含错误密码)
"""
FAILURE_REASONS = [
('wrong_password', '用户名或密码错误'),
('wrong_captcha', '行为验证码失败'),
('account_locked', '账号已锁定'),
('account_disabled', '账号已停用'),
('tenant_not_found', '租户不存在'),
]
username = models.CharField(max_length=30)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(null=True, blank=True)
success = models.BooleanField()
failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS)
attempted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'login_attempts'
indexes = [
models.Index(fields=['username']),
models.Index(fields=['ip_address']),
models.Index(fields=['-attempted_at']),
models.Index(fields=['username', 'success', '-attempted_at'],
name='idx_login_attempts_fail_check'),
]
def __str__(self):
return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}"
```
---
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema
**表说明**用于通过邮件找回密码的一次性令牌。单次有效30 分钟过期。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成86 字符),全局唯一 |
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟` |
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True防止重放攻击 |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
#### 索引
```sql
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
```
#### Django Model 定义
```python
class PasswordResetToken(models.Model):
"""
密码重置令牌。
安全约束:
- Token 单次有效is_used=True 后立即失效)
- 有效期 30 分钟
- 同一账号 1 小时内最多生成 3 个服务层限频Redis 计数)
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_reset_tokens'
indexes = [
models.Index(fields=['user_id']),
]
def is_valid(self) -> bool:
from django.utils import timezone
return not self.is_used and timezone.now() < self.expires_at
```
---
### 3.4 `password_histories` — 历史密码记录表(租户 Schema
**表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。
#### 字段定义
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
| `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 |
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) |
#### 索引
```sql
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
```
#### Django Model 定义
```python
class PasswordHistory(models.Model):
"""
历史密码记录,每个账号保留最近 N 条(默认 3 条)。
新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025
"""
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories')
password_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'password_histories'
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', '-created_at']),
]
```
---
## 四、Redis 缓存结构(辅助状态,非持久化)
以下 Redis Key 不存入 PostgreSQL属于运行时状态需与数据库状态保持最终一致
| Key 格式 | 类型 | TTL | 说明 |
|----------|------|-----|------|
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token验证通过后生成 `captcha_pass_token` |
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
| `login_fail:{tenant_id}:{username}` | STRING计数 | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定TTL 30 分钟自动清零 |
| `recover_email:{email}` | STRING计数 | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
| `recover_reset:{account_id}` | STRING计数 | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
| `tenant_verify_ip:{ip}` | STRING计数 | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
---
## 五、账号创建流程与状态机
### 5.1 账号状态机
```
┌─────────────────────────────────────┐
│ 账号生命周期状态机 │
└─────────────────────────────────────┘
[创建账号]
│ is_initial_password=True, status=active
[初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页]
│ │ 修改成功
│ ▼
│ [正常使用态]
│ status=active
│ is_initial_password=False
├── 密码错误 ≥ 5 次 ──────────────────► [锁定态]
│ status=locked
│ locked_until = now+30min
│ │
│ ┌───────────────┤
│ │ 30分钟到期 │ 管理员手动解锁
│ ▼ ▼
│ [正常使用态] ◄─── [管理员操作]
└── 员工离职 / 管理员停用 ──► [停用态]
status=disabled
员工复职/管理员恢复
[正常使用态]
```
### 5.2 账号创建触发时机
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|----------|----------|--------|--------------|---------|
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义字母开头6~30 位) | 平台运营自定义 |
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统Tenant Admin 触发) | 固定为员工手机号11 位) | 系统统一初始密码(部署配置) |
---
## 六、关联约束与数据完整性
### 6.1 与 `org.Staff` 的关联规则
```
org_staff (1) ──── (0..1) user_accounts
```
- 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active
- Tenant Admin`staff_id` **可为空**(平台运营账号可不绑定实名档案)
- 员工离职时(`org.Staff.status``resigned`),触发账号 `status``disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖)
- 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留
### 6.2 跨 App 依赖方向
```
accounts ──► org (单向依赖accounts.UserAccount.staff_id → org.Staff)
org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查)
```
> **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。
---
## 七、迁移说明Django Migrations
### 初始迁移顺序
```
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
0002_login_attempts.py # LoginAttempt 表
0003_password_reset_tokens.py # PasswordResetToken 表
0004_password_histories.py # PasswordHistory 表
```
### 注意事项
- `accounts` App 的迁移依赖 `org` App`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org``accounts` 之前
- 所有迁移均在**租户 Schema** 下执行(`django-tenants``migrate_schemas` 命令)
- 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择)
---
## 八、设计决策说明ADR
| 决策 | 选择 | 理由 |
|------|------|------|
| 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 |
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |

View 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` | Publicshared |
| **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.cacheRedis统一租户前缀
├── 依赖 → org.Staffcreated_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 300s5 分钟)对应 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/*` | 录入场景的交易意图与房源全生命周期状态不同,避免耦合 |

View 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 编码前先读枚举标准,禁止各模块自行定义“影子枚举”