chore: sync local project changes

This commit is contained in:
Shen Wei
2026-04-27 16:26:07 +08:00
parent dfcf7de003
commit 5854781fa8
144 changed files with 12849 additions and 12330 deletions

View File

@@ -2,7 +2,7 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
**版本**: 2.0 **最后更新**: 2026-04-25
**版本**: 2.1 **最后更新**: 2026-04-27
**定位**: 本文档是 Fonrey 项目技术栈的**总索引**。所有跨模块的技术决策、版本约束、目录规范、禁止项在此定稿;**单一模块的具体技术方案**数据模型、服务层、HTMX 交互、Celery 任务等)见各自子文档(见 §9 索引)。
---
@@ -133,10 +133,10 @@ apps/property/
| 登录认证 | [`登录管理技术方案.md`](./登录管理技术方案.md) | `PRD/登录管理/` | `DATA_MODEL/DATA_MODEL_LOGIN.md` |
| 权限管理 | [`权限管理系统技术方案.md`](./权限管理系统技术方案.md) | `PRD/权限管理/` | `DATA_MODEL/DATA_MODEL_PERMISSION.md` |
| 房源管理 | [`房源管理技术方案.md`](./房源管理技术方案.md) | `PRD/房源管理/` | `DATA_MODEL/DATA_MODEL_PROPERTY.md` |
| 客源管理 | _待补充_ | `PRD/客源管理/` | `DATA_MODEL/DATA_MODEL_CLIENT.md` |
| 楼盘管理 | _待补充_ | `PRD/房源管理/`(含楼盘) | `DATA_MODEL/DATA_MODEL_COMPLEX.md` |
| 组织人事 | _待补充_ | `PRD/组织人事管理/` | `DATA_MODEL/DATA_MODEL_ORG.md` |
| 系统设置 | _待补充_ | `PRD/系统配置/``PRD/系统管理/` | `DATA_MODEL/DATA_MODEL_PUBLIC.md` |
| 客源管理 | [`客源管理技术方案.md`](./客源管理技术方案.md) | `PRD/客源管理/` | `DATA_MODEL/DATA_MODEL_CLIENT.md` |
| 楼盘管理 | [`楼盘管理技术方案.md`](./楼盘管理技术方案.md) | `PRD/房源管理/`(含楼盘) | `DATA_MODEL/DATA_MODEL_COMPLEX.md` |
| 组织人事 | [`组织人事技术方案.md`](./组织人事技术方案.md) | `PRD/组织人事管理/` | `DATA_MODEL/DATA_MODEL_ORG.md` |
| 系统设置 | [`系统设置技术方案.md`](./系统设置技术方案.md) | `PRD/系统配置/``PRD/系统管理/` | `DATA_MODEL/DATA_MODEL_SETTING.md` |
| 客户端发布 | 见本文档 §7 | `PRD/发布管理/客户端发布管理模块PRD.md` | — |
**总览数据模型**[`DATA_MODEL/DATA_MODEL.md`](../DATA_MODEL/DATA_MODEL.md)
@@ -144,7 +144,51 @@ apps/property/
---
## 9. 测试策略
## 9. 模块技术方案一致性矩阵15 标准章节)
> 目的:确保各模块技术方案采用同构模板,便于 AI Agent 与开发同学横向查阅、执行与回归。
### 9.1 标准章节骨架(统一实现标准)
| 标准章节编号 | 标准章节名 |
|---|---|
| 1 | 文档定位与边界 |
| 2 | 范围定义 |
| 3 | 模块架构边界 |
| 4 | API 设计原则 |
| 5 | 端点清单(核心) |
| 6 | 关键 API 规范(请求/响应) |
| 7 | HTMX 交互约定 |
| 8 | 权限与数据范围 |
| 9 | 异步任务与缓存策略 |
| 10 | 性能与可靠性约束 |
| 11 | 安全与合规 |
| 12 | 错误码建议 |
| 13 | 测试映射 |
| 14 | 落地顺序建议 |
| 15 | 文档同步规则 |
### 9.2 模块覆盖情况2026-04-27
| 模块 | 技术方案文档 | 15 章节覆盖 | 备注 |
|---|---|---:|---|
| 登录认证 | `登录管理技术方案.md` | 15/15 | 完全覆盖 |
| 权限管理 | `权限管理系统技术方案.md` | 15/15 | 完全覆盖 |
| 房源管理 | `房源管理技术方案.md` | 15/15 | 完全覆盖 |
| 客源管理 | `客源管理技术方案.md` | 15/15 | 完全覆盖 |
| 楼盘管理 | `楼盘管理技术方案.md` | 15/15 | 完全覆盖 |
| 组织人事 | `组织人事技术方案.md` | 15/15 | 完全覆盖 |
| 系统设置 | `系统设置技术方案.md` | 15/15 | 完全覆盖 |
### 9.3 使用规则(对 AI Agent 生效)
- 新增模块技术方案时,必须按上表 15 章节骨架创建,不得自定义主结构。
- 若模块存在特殊子节,可在对应主章节下扩展 `x.y`,但不得删除主章节。
- PRD/TASK 范围变化后,先更新模块文档,再回填本矩阵覆盖状态。
---
## 10. 测试策略
> **完整测试规范**见:[`测试规范.md`](./测试规范.md)。本节仅列关键结论。
@@ -183,12 +227,13 @@ Fonrey 采用 AI vibe coding 模式开发,测试是保证每日迭代质量的
---
## 10. 文档维护原则
## 11. 文档维护原则
- 本文档仅记录**跨模块共识**与**模块索引**,不展开模块细节
- 模块技术方案在子文档中维护,并通过 §8 表格回链
- 任何技术栈变更(替换组件、升级主版本、新增外部服务)须同步更新本文档 §2、§5、§6
- 新增模块时,先在 §4 目录结构补位,再在 §8 索引登记子文档
- 测试规范变更须同步更新 §9 关键结论,完整细节在 [`测试规范.md`](./测试规范.md) 中维护
- 测试规范变更须同步更新 §10 关键结论,完整细节在 [`测试规范.md`](./测试规范.md) 中维护
- 15 章节统一模板发生变更时,须先更新 §9 标准章节骨架,再同步各模块文档

View File

@@ -0,0 +1,415 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 客源管理技术方案
**版本**: 1.0
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
**关联 PRD**: `PRD/客源管理/客源管理模块PRD.md`v1.4
**关联数据模型**: `DATA_MODEL/DATA_MODEL_CLIENT.md`(本方案不重复 DDL
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
**最后更新**: 2026-04-27
---
## 一、文档定位与边界
本文件聚焦客源模块的:
1. 服务边界与模块协作
2. API 端点设计P0/P1 兼容)
3. HTMX 局刷与页面分片约定
4. 权限与数据范围控制
5. 异步任务、缓存、性能与测试映射
> **不在本文件展开**数据表字段、DDL、索引、触发器。以 `DATA_MODEL_CLIENT.md` 为唯一权威。
---
## 二、范围定义(以 TASK.md 为准)
### 2.1 P0 必须覆盖Phase 1
`US-CLIENT-001 ~ US-CLIENT-017`
- 新增私客、列表筛选、详情、需求编辑、跟进、带看
- 联系人管理、等级/状态变更、转公客/转成交/转无效
- 相关员工管理
- 自动转公、重复检测
### 2.2 非 P0仅预留端点不实现复杂能力
- AI 行为解读深度模型
- 新房推荐高级排序引擎
- 公客/成交客完整运营闭环P1/P2
---
## 三、模块架构边界
## 3.1 模块职责(`apps/client`
- 客源主流程:新增、列表、详情、状态流转
- 客源关联流程:联系人、需求、跟进、带看、智能配房结果
- 质量控制:重复检测、自动转公、操作审计
## 3.2 外部依赖
| 依赖模块 | 依赖内容 | 依赖方式 |
|---|---|---|
| `apps/org` | 员工、组织树、归属人/首录人 | FK + Service |
| `apps/property` | 配房候选房源、带看房源关联 | 只读查询 + FK |
| `apps/permission` | 角色与数据范围权限 | PermissionChecker + ScopeQueryBuilder |
| `apps/setting` | 可配置枚举(来源、跟进目的)与查重规则 | TenantSettingsService |
| `core/encryption.py` | 手机号/证件号加密与哈希 | 统一工具调用 |
| `core/cache.py` | 列表缓存、重复计数缓存、任务进度 | Redis |
| `Celery` | 自动转公、重复检测统计、配房重算、导出 | 异步任务 |
| `Cloudflare R2` | 跟进图片、带看附件 | 预签名上传 |
---
## 四、API 设计原则
1. 页面路由与数据 API 分离:
- 页面:`/client/...`
- 数据:`/api/client/...`
2. 列表筛选、Tab 加载、弹窗提交优先 HTMX。
3. 手机号明文永不落库;默认脱敏显示。
4. 重复检测与自动转公走异步批处理 + 局部实时校验组合。
5. 所有状态变更必须写操作日志(不可省略)。
---
## 五、端点清单(核心)
## 5.1 页面路由SSR
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/client/list/` | GET | 是 | 客源列表主页面(私客/公客/成交客 Tab 容器) |
| `/client/create/` | GET | 是 | 新增私客页面 |
| `/client/{client_id}/` | GET | 是 | 客源详情主页面 |
| `/client/{client_id}/edit/` | GET | 是 | 编辑客源页面 |
| `/client/{client_id}/operation-logs/` | GET | 是 | 客源操作日志页面 |
## 5.2 HTMX 片段端点
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/client/fragments/list-table/` | POST | 列表筛选/排序/分页局刷 | HTML |
| `/client/fragments/repeat-counters/` | GET | 顶部重复统计局刷 | HTML |
| `/client/{id}/fragments/tab/{tab}/` | GET | 详情 Tab 懒加载(需求/跟进/带看/配房) | HTML |
| `/client/{id}/fragments/contact-panel/` | GET | 联系人面板局刷 | HTML |
| `/client/{id}/fragments/staff-panel/` | GET | 相关员工面板局刷 | HTML |
| `/client/{id}/fragments/follow-timeline/` | POST | 跟进筛选后局刷 | HTML |
> fragment 端点必须校验 `HX-Request=true`,非 HTMX 请求返回 400。
## 5.3 JSON APIP0 必需)
| 端点 | 方法 | 权限 code建议 | 说明 |
|---|---|---|---|
| `/api/client/` | POST | `client.private.create.allow` | 新增私客US-001 |
| `/api/client/list/query/` | POST | `client.private.view.scope` | 私客列表筛选US-002 |
| `/api/client/{id}/detail/` | GET | `client.private.view.scope` | 详情聚合US-004 |
| `/api/client/{id}/requirements/` | PATCH | `client.private.edit.allow` | 编辑需求US-005 |
| `/api/client/{id}/follow-logs/` | POST | `client.private.follow.create.allow` | 写入跟进US-006 |
| `/api/client/{id}/follow-logs/query/` | POST | `client.private.follow.view.scope` | 跟进查询US-006 |
| `/api/client/{id}/viewings/` | POST | `client.private.viewing.create.allow` | 新增带看US-007 |
| `/api/client/{id}/viewings/query/` | POST | `client.private.viewing.view.scope` | 带看查询US-007 |
| `/api/client/{id}/contacts/` | POST | `client.private.contact.create.allow` | 新增联系人US-008 |
| `/api/client/{id}/contacts/{contact_id}/` | PATCH | `client.private.contact.edit.allow` | 编辑联系人US-008 |
| `/api/client/{id}/grade/change/` | POST | `client.private.grade.change.allow` | 改等级US-009 |
| `/api/client/{id}/status/change/` | POST | `client.private.status.change.allow` | 改状态US-010 |
| `/api/client/{id}/transfer-public/` | POST | `client.private.transfer_public.allow` | 手动转公客US-011 |
| `/api/client/{id}/transfer-transacted/` | POST | `client.private.transfer_transacted.allow` | 转成交US-012 |
| `/api/client/{id}/mark-invalid/` | POST | `client.private.mark_invalid.allow` | 转无效US-013 |
| `/api/client/{id}/base-info/` | PATCH | `client.private.edit.allow` | 编辑基础信息US-014 |
| `/api/client/{id}/related-staff/` | PATCH | `client.private.related_staff.edit.allow` | 修改首录/归属人US-015 |
| `/api/client/duplicate/check/` | POST | `client.private.create.allow` | 手机号实时重复检测US-001/017 |
| `/api/client/duplicate/summary/` | GET | `client.private.view.scope` | 重复统计(私客-成交、公客US-017 |
| `/api/client/matches/{id}/query/` | GET | `client.private.view.scope` | 智能配房结果查询US-020预留P0可简版 |
| `/api/client/export/jobs/` | POST | `client.private.export.scope` | 导出任务创建US-002 |
| `/api/client/export/jobs/{job_id}/` | GET | `client.private.export.scope` | 导出任务状态 |
| `/api/client/export/jobs/{job_id}/download/` | GET | `client.private.export.scope` | 导出下载 |
---
## 六、关键 API 规范(请求/响应)
## 6.1 新增私客
`POST /api/client/`
```json
{
"contacts": [
{
"name": "李雷",
"gender": "male",
"phone_country_code": "+86",
"phone": "13800000000",
"phone2": null,
"wechat": "lilei_wechat"
}
],
"base_info": {
"status": "buying",
"property_usage": "residential",
"grade": "B",
"source": "store_reception"
}
}
```
成功 `201`
```json
{
"id": "uuid",
"client_no": "KY202604270001",
"redirect_url": "/client/uuid/",
"message": "保存成功"
}
```
## 6.2 列表查询Keyset
`POST /api/client/list/query/`
```json
{
"client_type": "private",
"tab": "buying",
"keyword": "1380000",
"filters": {
"grade": ["A", "B"],
"status": ["buying", "buy_rent"],
"budget": {"min": 200, "max": 500}
},
"sort": {"field": "last_follow_at", "order": "desc"},
"pagination": {"mode": "keyset", "cursor": "opaque_cursor", "limit": 20}
}
```
## 6.3 手机号重复检测
`POST /api/client/duplicate/check/`
```json
{
"phone_country_code": "+86",
"phone": "13800000000",
"scene": "create",
"client_id": null
}
```
响应:
```json
{
"duplicated": true,
"scope": "dept",
"hits": [
{
"client_id": "uuid",
"client_type": "private",
"owner_name": "王店长",
"created_at": "2026-04-27T10:00:00+08:00"
}
]
}
```
## 6.4 状态与转化类接口(统一协议)
- 改状态:`/status/change/`
- 转公:`/transfer-public/`
- 转成交:`/transfer-transacted/`
- 转无效:`/mark-invalid/`
统一请求字段:
```json
{
"reason": "客户需求变化",
"payload": {}
}
```
额外示例(转成交):
```json
{
"reason": "已签约",
"payload": {
"transacted_date": "2026-04-27",
"transacted_price": "365.00",
"transacted_type": "bought",
"property_id": "uuid"
}
}
```
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求:`HX-Request: true`
- 成功:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 跳转:`HX-Redirect: /client/{id}/`
## 7.2 模板分片命名
| 场景 | 模板 |
|---|---|
| 列表 | `templates/client/fragments/list_table.html` |
| 重复计数条 | `templates/client/fragments/repeat_counters.html` |
| 跟进时间线 | `templates/client/fragments/follow_timeline.html` |
| 带看时间线 | `templates/client/fragments/viewing_timeline.html` |
| 联系人侧栏 | `templates/client/fragments/contact_panel.html` |
| 相关员工侧栏 | `templates/client/fragments/staff_panel.html` |
---
## 八、权限与数据范围
## 8.1 最小权限矩阵P0
| 能力 | 权限 code |
|---|---|
| 查看私客列表范围 | `client.private.view.scope` |
| 新增私客 | `client.private.create.allow` |
| 编辑私客信息 | `client.private.edit.allow` |
| 写跟进 | `client.private.follow.create.allow` |
| 查看跟进 | `client.private.follow.view.scope` |
| 管理联系人 | `client.private.contact.create.allow` / `client.private.contact.edit.allow` |
| 改等级/改状态 | `client.private.grade.change.allow` / `client.private.status.change.allow` |
| 转公/转成交/转无效 | `client.private.transfer_public.allow` / `client.private.transfer_transacted.allow` / `client.private.mark_invalid.allow` |
| 导出 | `client.private.export.scope` |
## 8.2 数据范围叠加逻辑
最终可见数据 = `权限 scope``client_type 过滤``业务状态过滤`
- 禁止不带 `client_type` 的全量查询
- 私客、公客、成交客必须分流查询
## 8.3 敏感信息查看审计
查看号码动作必须:
1. 校验查看权限与日限额(若配置)
2. 仅本次响应返回明文
3. 自动写 `client_follow_logs(log_type='sensitive_view')`
4. `sensitive_view` 记录不可删除
---
## 九、异步任务与缓存策略
## 9.1 状态机与规则
- `private -> public`(手动转公或自动转公)
- `private -> transacted`(转成交)
- `private/public/transacted -> invalid`(状态字段,不改变历史)
必须通过 service 校验状态机,禁止跳转。
### 最低规则P0
- `buying <-> buy_rent`
- `renting <-> buy_rent`
- `buying/renting/buy_rent -> paused`
- `paused -> buying/renting`
- 任意可跟进状态 -> `invalid`(必须填写原因)
## 9.2 Celery 任务
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `client_auto_transfer_public_task` | 每小时 | 超时未跟进私客自动转公US-016 |
| `client_duplicate_summary_task` | 每日/按需 | 重复统计US-017 |
| `client_match_recompute_task` | 需求变更后 | 重新计算配房结果 |
| `client_export_task` | 导出任务创建后 | 异步导出 Excel |
> 所有任务必须传入 `tenant_schema_name` 并在任务开头切 schema。
## 9.3 Redis Key 规范
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:client:list:query:{hash}` | 60s | 热门列表筛选缓存 |
| `{schema}:client:repeat:summary` | 300s | 顶部重复计数缓存 |
| `{schema}:client:detail:{id}` | 120s | 详情聚合缓存 |
| `{schema}:client:export:{job_id}` | 24h | 导出任务状态 |
---
## 十、性能与可靠性约束
1. 客源列表、跟进时间线全部使用 Keyset 分页。
2. 高频筛选列建立组合索引(见数据模型文档)。
3. 手机号、证件号统一加密 + hash 索引;禁止明文。
4. 附件上传限制:`bmp/jpg/jpeg/png/gif`20MB/文件。
5. 导出、重复统计、自动转公均走异步,禁止阻塞请求线程。
---
## 十一、安全与合规
1. 手机号、证件号默认脱敏显示,明文查看需权限与审计。
2. 跟进/转化等关键动作必须记录操作者与来源。
3. 重复检测接口不得泄露超出权限范围的完整客户信息。
---
## 十二、错误码建议
| code | HTTP | 场景 |
|---|---|---|
| `CLIENT_NOT_FOUND` | 404 | 客源不存在或无权限 |
| `CLIENT_DUPLICATED_PHONE` | 409 | 号码重复(按租户规则) |
| `CLIENT_INVALID_TRANSITION` | 400 | 非法状态/类型流转 |
| `CLIENT_CONTACT_PHONE_INVALID` | 400 | 联系人电话格式错误 |
| `CLIENT_PERMISSION_DENIED` | 403 | 权限不足 |
| `CLIENT_EXPORT_JOB_NOT_READY` | 409 | 导出未完成 |
---
## 十三、测试映射P0
| US | 最低覆盖 |
|---|---|
| US-CLIENT-001 | 新增成功/必填失败/重复检测提示 |
| US-CLIENT-002 | 组合筛选 + Keyset 分页 + 导出 |
| US-CLIENT-003 | 批量操作权限与审计 |
| US-CLIENT-004~006 | 详情/需求/跟进流程 |
| US-CLIENT-007~008 | 带看与联系人新增编辑 |
| US-CLIENT-009~013 | 等级/状态/转公/转成交/转无效 |
| US-CLIENT-014~015 | 编辑完整信息 + 相关员工变更 |
| US-CLIENT-016 | 自动转公定时任务 |
| US-CLIENT-017 | 重复统计任务与查询接口 |
测试落点:`tests/integration/client/test_us_client.py`
---
## 十四、落地顺序建议
1. 列表查询 + scope 权限US-002
2. 新增私客 + 重复检测US-001/017
3. 详情 + 需求/跟进US-004~006
4. 联系人/带看US-007/008
5. 状态与转化US-009~013
6. 自动任务与导出US-016/017 + 导出)
---
## 十五、文档同步规则
- 枚举变更同步:`DATA_MODEL/ENUMS.md`
- 权限 code 变更同步:`DATA_MODEL/DATA_MODEL_PERMISSION.md`
- 新增配置项同步:`DATA_MODEL/DATA_MODEL_SETTING.md`
- API 变更同步:本文件 + 对应 PRD 验收条目

View File

@@ -26,7 +26,7 @@
---
## 二、P0 范围(与 Task Board 对齐
## 二、范围定义(以 TASK.md 为准
本方案覆盖 `PRD/TASK.md` 中房源 P0 User Story
@@ -73,7 +73,7 @@
---
## 四、API 设计原则P0
## 四、API 设计原则
1. **页面端点与数据端点分离**
- 页面SSR + HTMX 容器):`/property/...`
@@ -391,7 +391,7 @@
---
## 十二、错误码约定(房源模块)
## 十二、错误码建议
| code | HTTP | 场景 |
|---|---|---|
@@ -436,7 +436,7 @@
---
## 十五、与其他文档同步规则
## 十五、文档同步规则
- 枚举变更:同步 `DATA_MODEL/ENUMS.md`
- 权限 code 变更:同步 `DATA_MODEL/DATA_MODEL_PERMISSION.md`

View File

@@ -1,678 +1,262 @@
**For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 权限管理系统技术方案建议
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
**版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis
# Fonrey 权限管理技术方案
**版本**: 2.1
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
**关联 PRD**: `PRD/权限管理/权限管理模块PRD.md`
**关联数据模型**: `DATA_MODEL/DATA_MODEL_PERMISSION.md`(本方案不重复 DDL
**最后更新**: 2026-04-27
---
![[IMG-20260424204148583.png]]
## 一、选型结论:为什么不用现有库
在回答五个核心需求之前,先明确**不推荐**使用哪些常见方案:
## 一、文档定位与边界
|方案|为何不适用|
本文件仅定义权限模块的:
1. RBAC + 个人覆盖架构边界
2. 权限解析/合并与门禁约束
3. API 端点设计(页面 / HTMX / JSON
4. 缓存一致性与审计策略
5. 错误码与测试映射
> 不在本文件展开权限相关表字段与索引。数据结构以 `DATA_MODEL_PERMISSION.md` 为唯一权威。
---
## 二、范围定义(以 P0 为准)
### 2.1 P0 必须覆盖
- 角色管理(创建/编辑/停用)
- 角色权限配置BOOLEAN/SCOPE/INTEGER
- 人员绑定角色
- 人员权限覆盖(稀疏覆盖)
- 权限快照解析与缓存失效
- 业务接口统一门禁能力
### 2.2 预留(非本期强制)
- 权限变更审批流
- 临时授权(时效权限)
- 跨租户角色模板市场
---
## 三、模块架构边界
## 3.1 模块职责(`apps/permission`
- 权限定义读取与检索
- 角色权限配置与人员绑定
- 权限快照解析、缓存、失效
- 门禁 Decorator / Mixin 输出
- 权限变更审计写入
## 3.2 核心合并规则
多角色叠加采用最宽松原则:
| 值类型 | 合并规则 |
|---|---|
|`django.contrib.auth` 原生权限|权限值只有 Boolean不支持范围型枚举和数值型|
|`django-guardian`|面向行级Row-level权限是对象权限模型与本项目需求错位|
|`django-rules`|基于函数规则,适合纯 Python 逻辑判断,无法存储枚举/数字值|
|Casbin / OPA|重型策略引擎,引入额外运维复杂度,与 `django-tenants` 集成困难|
| BOOLEAN | OR任一为 true 则 true |
| SCOPE | 取最大范围self < group < store < area < region < company |
| INTEGER | 取最大值,`0` 表示不限制 |
**推荐方案:自定义 RBAC + 个人覆盖层Hybrid Permission Model**
解析优先级:`个人覆盖 > 角色合并 > 默认值`
这是唯一能同时满足五个需求的路径,实现量适中,完全在 Django ORM 生态内,与 `django-tenants` 天然兼容。
![[Pasted image 20260424165102.png]]
## 3.3 外部依赖
以下是方案的核心要点汇总:
**为什么不用现有库**Django 原生权限只支持 Boolean`django-guardian` 是行级权限模型,两者都无法满足你的「范围型枚举 + 数值型」需求,因此推荐完全自定义实现。
**五个需求对应的设计决策**
**① 基于角色分配权限** — 标准 RBAC 三表:`PermissionDef`(权限目录,开发者维护,放 shared schema`Role`(角色模板)→ `RolePermission`(角色 × 权限 → 值)
**② 个人权限调整** — 增加 `StaffPermissionOverride` 表,**稀疏覆盖设计**:只存储与角色默认值不同的项,不复制全量权限。解析优先级:个人覆盖 > 角色合并 > 系统默认。
**③ 多种值类型** — 统一用 `JSONField` 存储,格式统一为 `{"v": value}``PermissionDef.value_type` 标记类型(`BOOLEAN`/`SCOPE`/`INTEGER`),前端根据类型渲染 Toggle/下拉/数字框。范围型的可选枚举(如某权限只有三档选项)存在 `scope_choices` JSON 数组中。
**④ 多角色叠加规则** — 采用**最宽松原则**PRD 6.4 倾向确认):`BOOLEAN``OR``SCOPE` 取枚举最大值(本人 < 本组 < 本门店 < 本区域 < 全公司)、`INTEGER` 取最大值(`0=不限制`是最宽松值)。
**⑤ 契合技术栈** — Redis 缓存员工权限快照(变更时主动 `invalidate`HTMX 按模块懒加载权限面板(避免一次渲染 300+ 条Alpine.js 管理 Toggle/下拉本地状态;与 `django-tenants` 的集成方式:`PermissionDef``shared_apps`,其余所有表进 `tenant_apps`
文档中还包含完整的 Model 代码、权限解析引擎、缓存策略、HTMX 集成示例和「权限与角色不一致」标记逻辑的实现。
| 依赖模块 | 用途 |
|---|---|
| `apps/org` | 员工状态、组织路径、`is_system_admin` 短路 |
| `core/cache.py` | 权限快照缓存与版本控制 |
| 各业务模块 | 调用门禁函数校验权限 |
---
## 二、五个需求对应设计
## 四、API 设计原则
### 需求 1基于角色来创建权限并分配RBAC 基础层)
1. PermissionDef 以只读为主,角色与覆盖可编辑。
2. 权限面板按模块分片加载,避免一次渲染超大集合。
3. 任何权限写操作后必须触发缓存失效。
4. 全部变更强制写审计(前后值、操作者、来源)。
5. API 统一结构化错误码,禁止透出内部异常栈。
采用标准 RBAC 三表结构:`PermissionDef`(权限定义)→ `Role`(角色)→ `RolePermission`(角色权限值)。
---
- `PermissionDef` 是**权限目录**,由开发者维护,存储权限的元信息(名称、所属模块、值类型、可选范围等
- `Role` 是**权限模板**,管理员在 UI 上创建,如"高级业务员"、"分行经理"
- `RolePermission` 存储角色对每个权限项的**具体配置值**
## 五、端点清单(核心
### 需求 2个人用户可在角色权限基础上再进行个性化调整个人覆盖层
## 5.1 页面路由SSR
增加 `StaffPermissionOverride` 表,只存储**与角色默认值不同的权限项**(稀疏覆盖,不复制全量)。
**解析优先级**:个人覆盖值 > 角色合并值 > 系统默认值
### 需求 3权限值涉及多种数据类型多态值存储
使用 `JSONField` 统一存储权限值,通过 `PermissionDef.value_type` 字段标记类型,前端根据类型渲染不同控件:
|`value_type`|存储格式|UI 控件|示例|
| 路径 | 方法 | 权限 code | 说明 |
|---|---|---|---|
|`BOOLEAN`|`{"v": true}`|Toggle|是否显示今日新上房源|
|`SCOPE`|`{"v": "store"}`|下拉选择|查看私客范围(本人/本组/本门店/...|
|`INTEGER`|`{"v": 50}`|数字输入框|每日最多查看联系人数|
| `/permission/roles/` | GET | `system.role.manage.allow` | 角色列表页 |
| `/permission/roles/{role_id}/` | GET | `system.role.manage.allow` | 角色详情与配置页 |
| `/permission/staff/` | GET | `system.role.manage.allow` | 人员权限列表页 |
| `/permission/staff/{staff_id}/` | GET | `system.role.manage.allow` | 人员权限详情页 |
范围型SCOPE的**可选枚举值**在 `PermissionDef.scope_choices` 中定义JSON 数组),不同权限项的可选范围不同(如某项只有「本人/本门店/全公司」三档)。
## 5.2 HTMX 片段端点
### 需求 4多角色权限叠加规则并集/最宽松原则)
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/permission/fragments/role-table/` | POST | 角色筛选局刷 | HTML 片段 |
| `/permission/fragments/role-permissions/{role_id}/` | GET | 角色权限局刷 | HTML 片段 |
| `/permission/fragments/staff-permissions/{staff_id}/` | GET | 人员有效权限矩阵局刷 | HTML 片段 |
PRD 第 6 节已明确倾向:**取权限并集(最宽松原则)**。具体合并逻辑:
## 5.3 JSON APIP0
|值类型|合并规则|示例|
| 端点 | 方法 | 权限 code | 说明 |
|---|---|---|---|
| `/api/permission/defs/query/` | POST | `system.role.manage.allow` | 权限定义检索 |
| `/api/permission/roles/` | POST | `system.role.manage.allow` | 新增角色 |
| `/api/permission/roles/{id}/` | PATCH | `system.role.manage.allow` | 编辑角色 |
| `/api/permission/roles/{id}/permissions/batch-upsert/` | POST | `system.role.manage.allow` | 批量保存角色权限 |
| `/api/permission/staff/{staff_id}/roles/assign/` | POST | `system.role.manage.allow` | 人员绑定角色 |
| `/api/permission/staff/{staff_id}/overrides/batch-upsert/` | POST | `system.role.manage.allow` | 批量保存人员覆盖 |
| `/api/permission/staff/{staff_id}/effective/` | GET | `system.role.manage.allow` | 查询有效权限快照 |
| `/api/permission/cache/invalidate/` | POST | `system.role.manage.allow` | 手动失效缓存 |
---
## 六、关键 API 规范(请求/响应)
## 6.1 批量保存角色权限
`POST /api/permission/roles/{id}/permissions/batch-upsert/`
```json
{
"items": [
{"code": "property.list.view.scope", "value": {"v": "store"}},
{"code": "property.create.allow", "value": {"v": true}},
{"code": "property.owner_phone.view.daily_limit", "value": {"v": 50}}
]
}
```
## 6.2 批量保存人员覆盖
`POST /api/permission/staff/{staff_id}/overrides/batch-upsert/`
```json
{
"items": [
{"code": "property.list.view.scope", "value": {"v": "self"}},
{"code": "property.owner_phone.view.daily_limit", "value": {"v": 20}}
]
}
```
规则:
- 仅存储与角色合并值不同的条目
- 若覆盖值回到合并值,应删除覆盖记录
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求头:`HX-Request: true`
- 成功提示:`HX-Trigger` toast(success)
- 失败提示:`HX-Trigger` toast(error)
## 7.2 模板分片命名
- `templates/permission/fragments/role_table.html`
- `templates/permission/fragments/role_permissions_panel.html`
- `templates/permission/fragments/staff_permissions_panel.html`
---
## 八、权限与数据范围
## 8.1 门禁函数标准
- `permission_required(code)`BOOLEAN
- `scope_required(code, min_scope)`SCOPE
- `limit_required(code, required_value)`INTEGER
## 8.2 强约束
- 禁止业务模块直接查权限表自行判定
- `is_system_admin=true` 可短路,但仍需落审计
---
## 九、异步任务与缓存策略
## 9.1 异步任务(可选)
| 任务 | 触发时机 | 说明 |
|---|---|---|
|`BOOLEAN`|`OR` — 任一角色开启则生效|角色A关闭、角色B开启 → 最终**开启**|
|`SCOPE`|`MAX` — 取范围最大的值|角色A=本组、角色B=本门店 → 最终**本门店**|
|`INTEGER`|`MAX` — 取最大数值;`0` 表示不限制,是最宽松值|角色A=20、角色B=50 → **50**角色A=20、角色B=0 → **0不限制**|
| `permission_rebuild_snapshot_task` | 批量角色变更后 | 后台重建快照(大租户优化) |
SCOPE 值的大小关系定义为:`无 < 本人 < 本组 < 本门店 < 本区域 < 全公司`
## 9.2 Redis Key 规范
### 需求 5契合当前技术栈的实现方案
- 数据层PostgreSQL + `JSONField`,与 `django-tenants` Schema 隔离完全兼容
- 缓存层Redis 存储员工权限快照,变更时主动失效
- 视图层Django 视图 + HTMX 局部刷新权限编辑面板Alpine.js 管理 Toggle/下拉状态
- 权限校验:自定义 `permission_required` 装饰器 + Mixin替代 Django 原生权限系统
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:perm:v{version}:staff:{staff_id}` | 1800s | 员工权限快照 |
| `{schema}:perm:version` | 永久 | 快照版本号 |
| `{schema}:perm:role:{role_id}:staff_ids` | 1800s | 角色关联员工索引 |
---
## 三、数据模型设计(完整)
## 十、性能与可靠性约束
```python
# apps/permissions/models.py
from django.db import models
from django.contrib.postgres.fields import ArrayField
class ScopeLevel(models.IntegerChoices):
"""范围型权限的枚举值,数值越大权限越宽"""
NONE = 0, ""
SELF = 1, "本人"
GROUP = 2, "本组"
STORE = 3, "本门店"
REGION = 4, "本区域"
COMPANY = 5, "全公司"
class ValueType(models.TextChoices):
BOOLEAN = "BOOLEAN", "开关型"
SCOPE = "SCOPE", "范围型"
INTEGER = "INTEGER", "数值型"
class PermissionModule(models.TextChoices):
HOME = "home", "首页"
PROPERTY = "property", "房源"
NEW_HOUSE = "new_house", "新房"
CLIENT = "client", "客源"
TRANSACTION = "transaction","交易"
DATA = "data", "数据"
MARKETING = "marketing", "营销"
HR = "hr", "人事OA"
CONTRACT = "contract", "合同"
TRINET = "trinet", "三网"
SYSTEM = "system", "系统"
MOBILE = "mobile", "移动端"
SMART_STORE = "smart_store","智能门店"
RECHARGE = "recharge", "在线充值"
class PermissionDef(models.Model):
"""
权限定义表(开发者维护,系统内置,不随租户变化)
此表在 django-tenants shared schema 中,所有租户共用。
"""
code = models.CharField(max_length=100, unique=True) # 如 "client.view_private_scope"
module = models.CharField(max_length=50, choices=PermissionModule.choices)
sub_module = models.CharField(max_length=50, blank=True) # 如 "二手&租赁"
group_name = models.CharField(max_length=100) # 分组标题,如 "私客基础权限"
name = models.CharField(max_length=200) # 显示名称
description = models.TextField(blank=True)
value_type = models.CharField(max_length=20, choices=ValueType.choices)
# 范围型权限的可选枚举值JSON 存储 ScopeLevel 的 value 列表)
# 例:[1, 2, 3, 5] 表示只提供「本人/本组/本门店/全公司」四个选项
scope_choices = models.JSONField(default=list, blank=True)
# 系统最小默认值
default_value = models.JSONField(default=dict) # {"v": false} / {"v": 0} / {"v": 0}
sort_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["module", "sub_module", "sort_order"]
def __str__(self):
return f"[{self.module}] {self.name}"
class RoleCategory(models.TextChoices):
AGENT = "agent", "置业顾问"
MANAGER = "manager", "店管"
DIRECTOR= "director","总经"
class Role(models.Model):
"""
角色(租户内数据,在 tenant schema 中)
"""
name = models.CharField(max_length=100)
category = models.CharField(max_length=50, choices=RoleCategory.choices)
description = models.TextField(blank=True)
# 引用来源角色(从哪个角色模板复制)
template_role = models.ForeignKey(
"self", null=True, blank=True,
on_delete=models.SET_NULL,
related_name="derived_roles"
)
created_by = models.ForeignKey("org.Staff", on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ("name",) # 租户内角色名唯一
def __str__(self):
return self.name
class RolePermission(models.Model):
"""
角色的权限配置(角色 × 权限项 → 值)
只存储非默认值的项,减少存储量。
"""
role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="permissions")
permission_def = models.ForeignKey(PermissionDef, on_delete=models.CASCADE)
value = models.JSONField() # {"v": true} | {"v": "store"} | {"v": 50}
class Meta:
unique_together = ("role", "permission_def")
class StaffRole(models.Model):
"""
员工 ↔ 角色(多对多,支持一人多角色)
"""
staff = models.ForeignKey("org.Staff", on_delete=models.CASCADE, related_name="staff_roles")
role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="staff_roles")
assigned_at = models.DateTimeField(auto_now_add=True)
assigned_by = models.ForeignKey(
"org.Staff", on_delete=models.SET_NULL, null=True,
related_name="role_assignments_made"
)
class Meta:
unique_together = ("staff", "role")
class StaffPermissionOverride(models.Model):
"""
员工个人权限覆盖(稀疏存储,只记录与角色不同的项)
这是「个性化调整」的核心表。
"""
staff = models.ForeignKey("org.Staff", on_delete=models.CASCADE, related_name="permission_overrides")
permission_def = models.ForeignKey(PermissionDef, on_delete=models.CASCADE)
value = models.JSONField()
# 记录是谁修改的,为后续日志奠基
modified_by = models.ForeignKey(
"org.Staff", on_delete=models.SET_NULL, null=True,
related_name="permission_overrides_made"
)
modified_at = models.DateTimeField(auto_now=True)
note = models.TextField(blank=True) # 管理员备注
class Meta:
unique_together = ("staff", "permission_def")
```
- 权限快照查询目标:`p95 < 30ms`(命中缓存)
- 角色保存后传播目标:`< 3s`(含缓存失效)
- 大范围权限调整采用版本号惰性失效,避免全量删 key 风暴
---
## 四、权限解析引擎
## 十一、安全与合规
```python
# apps/permissions/services/resolver.py
import json
import redis
from django.conf import settings
from .models import PermissionDef, RolePermission, StaffPermissionOverride, ValueType, ScopeLevel
_redis = redis.Redis.from_url(settings.REDIS_URL)
CACHE_TTL = 3600 # 1小时变更时主动失效
def _merge_values(value_type: str, values: list) -> dict:
"""
多角色权限值合并:最宽松原则
values: 每个角色的原始值字典列表,如 [{"v": true}, {"v": false}]
"""
raw = [v["v"] for v in values if v]
if not raw:
return None
if value_type == ValueType.BOOLEAN:
return {"v": any(raw)} # OR
if value_type == ValueType.SCOPE:
# 将 scope 字符串转为整数比较,取最大
scope_map = {s.label: s.value for s in ScopeLevel}
int_vals = [scope_map.get(r, 0) for r in raw]
best = max(int_vals)
# 转回字符串
label_map = {s.value: s.name.lower() for s in ScopeLevel}
return {"v": label_map.get(best, "none")}
if value_type == ValueType.INTEGER:
# 0 = 不限制(最宽松),否则取最大值
if 0 in raw:
return {"v": 0}
return {"v": max(raw)}
return values[0]
def get_resolved_permissions(staff_id: int, tenant_schema: str) -> dict:
"""
获取员工完整权限快照(含缓存)
返回格式: {"permission_code": {"v": value}, ...}
"""
cache_key = f"perm:{tenant_schema}:{staff_id}"
cached = _redis.get(cache_key)
if cached:
return json.loads(cached)
snapshot = _build_permission_snapshot(staff_id)
_redis.setex(cache_key, CACHE_TTL, json.dumps(snapshot))
return snapshot
def _build_permission_snapshot(staff_id: int) -> dict:
"""构建员工权限快照(不含缓存逻辑)"""
from apps.permissions.models import StaffRole
# Step 1: 获取员工所有角色
role_ids = list(
StaffRole.objects.filter(staff_id=staff_id).values_list("role_id", flat=True)
)
# Step 2: 拉取所有权限定义
all_defs = {p.code: p for p in PermissionDef.objects.filter(is_active=True)}
# Step 3: 从角色权限表聚合,按 permission_def 分组
role_perms_qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def")
role_values_by_code: dict[str, list] = {}
for rp in role_perms_qs:
code = rp.permission_def.code
role_values_by_code.setdefault(code, []).append(rp.value)
# Step 4: 合并多角色值
merged: dict[str, dict] = {}
for code, values in role_values_by_code.items():
pdef = all_defs.get(code)
if pdef:
merged[code] = _merge_values(pdef.value_type, values)
# Step 5: 填入未配置的权限默认值
for code, pdef in all_defs.items():
if code not in merged:
merged[code] = pdef.default_value
# Step 6: 叠加个人覆盖(最高优先级)
overrides = StaffPermissionOverride.objects.filter(staff_id=staff_id).select_related("permission_def")
for override in overrides:
merged[override.permission_def.code] = override.value
return merged
def invalidate_staff_cache(staff_id: int, tenant_schema: str):
"""权限变更后调用此方法清除缓存"""
cache_key = f"perm:{tenant_schema}:{staff_id}"
_redis.delete(cache_key)
def invalidate_role_cache(role_id: int, tenant_schema: str):
"""角色权限变更后,清除所有使用该角色的员工缓存"""
from apps.permissions.models import StaffRole
staff_ids = StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True)
keys = [f"perm:{tenant_schema}:{sid}" for sid in staff_ids]
if keys:
_redis.delete(*keys)
```
1. 权限台仅授权管理员访问。
2. 每次变更必须记录前后值 JSON、操作者、IP、时间。
3. 系统预置关键角色只允许停用,不允许物理删除。
4. 不允许直接向前端暴露无权访问权限项明细。
---
## 五、权限检查工具Views 层集成)
## 十二、错误码建议
```python
# apps/permissions/checks.py
from functools import wraps
from django.http import HttpResponseForbidden
from .services.resolver import get_resolved_permissions
from .models import ScopeLevel
class PermissionChecker:
"""
在 View 或模板中使用的权限检查器
用法:
checker = PermissionChecker(request.staff, request.tenant.schema_name)
if checker.can("client.view_private_scope", min_scope="store"):
...
"""
def __init__(self, staff, tenant_schema: str):
self._staff = staff
self._perms = get_resolved_permissions(staff.id, tenant_schema)
def get(self, code: str):
"""获取权限原始值"""
entry = self._perms.get(code, {})
return entry.get("v")
def is_enabled(self, code: str) -> bool:
"""布尔型:是否开启"""
return bool(self.get(code))
def has_scope(self, code: str, min_scope: str) -> bool:
"""
范围型:是否达到最低所需范围
min_scope: "self" | "group" | "store" | "region" | "company"
"""
scope_value = self.get(code)
if scope_value is None:
return False
scope_map = {s.name.lower(): s.value for s in ScopeLevel}
actual = scope_map.get(scope_value, 0)
required = scope_map.get(min_scope, 0)
return actual >= required
def get_limit(self, code: str) -> int:
"""数值型获取上限0=不限制)"""
v = self.get(code)
return v if isinstance(v, int) else 0
def is_unlimited(self, code: str) -> bool:
"""数值型:是否不限制"""
return self.get_limit(code) == 0
def permission_required(code: str, value_check=None):
"""
View 装饰器:检查权限
value_check: 可选的 lambda接收权限值返回 bool
示例:
@permission_required("client.view_private_scope",
lambda v: ScopeLevel[v.upper()].value >= ScopeLevel.STORE.value)
def my_view(request): ...
"""
def decorator(view_func):
@wraps(view_func)
def wrapped(request, *args, **kwargs):
checker = PermissionChecker(request.staff, request.tenant.schema_name)
val = checker.get(code)
if value_check:
ok = value_check(val)
else:
ok = bool(val)
if not ok:
return HttpResponseForbidden("权限不足")
return view_func(request, *args, **kwargs)
return wrapped
return decorator
```
| code | HTTP | 场景 |
|---|---|---|
| `PERMISSION_ROLE_NOT_FOUND` | 404 | 角色不存在 |
| `PERMISSION_DEF_NOT_FOUND` | 404 | 权限项不存在 |
| `PERMISSION_VALUE_TYPE_MISMATCH` | 400 | 值类型不匹配 |
| `PERMISSION_SCOPE_INVALID` | 400 | scope 非法 |
| `PERMISSION_DENIED` | 403 | 无访问权限 |
---
## 六、Django Admin / View 层权限编辑
## 十三、测试映射P0
### 保存角色权限HTMX 请求)
```python
# apps/permissions/views.py
from django.views import View
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from .models import Role, RolePermission, PermissionDef
from .services.resolver import invalidate_role_cache
class RolePermissionSaveView(View):
"""
接收角色权限编辑页的 HTMX 保存请求
POST /permissions/roles/<role_id>/save/
body: {"perms": {"client.view_private_scope": {"v": "store"}, ...}}
"""
def post(self, request, role_id):
role = get_object_or_404(Role, pk=role_id)
data = json.loads(request.body)
perm_data = data.get("perms", {})
for code, value in perm_data.items():
pdef = PermissionDef.objects.get(code=code)
RolePermission.objects.update_or_create(
role=role,
permission_def=pdef,
defaults={"value": value}
)
# 清除所有使用该角色的员工缓存
invalidate_role_cache(role.id, request.tenant.schema_name)
# 返回 HTMX 片段Toast 提示
return HttpResponse(
'<div x-data x-init="$dispatch(\'toast\', {msg: \'保存成功\', type: \'success\'})" />'
)
```
### 保存个人权限覆盖
```python
class StaffPermissionOverrideSaveView(View):
"""
保存员工个人权限覆盖
POST /permissions/staff/<staff_id>/override/
"""
def post(self, request, staff_id):
from apps.org.models import Staff
from .models import StaffPermissionOverride
from .services.resolver import invalidate_staff_cache
staff = get_object_or_404(Staff, pk=staff_id)
data = json.loads(request.body)
for code, value in data.get("overrides", {}).items():
pdef = PermissionDef.objects.get(code=code)
StaffPermissionOverride.objects.update_or_create(
staff=staff,
permission_def=pdef,
defaults={"value": value, "modified_by": request.staff}
)
invalidate_staff_cache(staff.id, request.tenant.schema_name)
return HttpResponse('<div x-data x-init="$dispatch(\'toast\', {msg: \'个人权限已更新\'})" />')
```
---
## 七、「权限与角色权限不一致」标记逻辑
```python
# apps/permissions/services/consistency.py
def get_staff_inconsistent_permission_codes(staff_id: int, tenant_schema: str) -> list[str]:
"""
返回该员工中与其角色默认权限不一致的 permission code 列表
用于在人员列表中标记橙色「不一致」状态
"""
from apps.permissions.models import StaffPermissionOverride, StaffRole, RolePermission
from .resolver import _merge_values, _build_permission_snapshot
# 只看个人覆盖了哪些
overrides = dict(
StaffPermissionOverride.objects.filter(staff_id=staff_id)
.values_list("permission_def__code", "value")
)
if not overrides:
return []
# 构建纯角色合并结果(不含个人覆盖)
role_ids = list(StaffRole.objects.filter(staff_id=staff_id).values_list("role_id", flat=True))
role_perms_qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def")
role_values_by_code: dict[str, list] = {}
for rp in role_perms_qs:
code = rp.permission_def.code
role_values_by_code.setdefault(code, []).append(rp.value)
inconsistent = []
for code, override_val in overrides.items():
pdef = PermissionDef.objects.filter(code=code).first()
if not pdef:
continue
role_merged = _merge_values(pdef.value_type, role_values_by_code.get(code, []))
if role_merged is None:
role_merged = pdef.default_value
if override_val != role_merged:
inconsistent.append(code)
return inconsistent
def staff_has_inconsistency(staff_id: int, tenant_schema: str) -> bool:
"""快捷方法:员工是否存在个人权限不一致"""
return len(get_staff_inconsistent_permission_codes(staff_id, tenant_schema)) > 0
```
---
## 八、前端集成HTMX + Alpine.js
### 权限编辑页骨架(按模块懒加载)
```html
<!-- templates/permissions/staff_permission_edit.html -->
<div x-data="{ activeModule: 'client' }">
<!-- 左侧模块导航 -->
<nav>
{% for module in modules %}
<button
@click="activeModule = '{{ module.code }}'"
:class="activeModule === '{{ module.code }}' ? 'active' : ''"
hx-get="/permissions/staff/{{ staff.id }}/module/{{ module.code }}/"
hx-target="#perm-panel"
hx-trigger="click"
hx-swap="innerHTML">
{{ module.name }}
</button>
{% endfor %}
</nav>
<!-- 右侧权限配置面板HTMX 懒加载,避免一次性渲染数百条) -->
<div id="perm-panel"
hx-get="/permissions/staff/{{ staff.id }}/module/client/"
hx-trigger="load">
<div class="loading-spinner">加载中...</div>
</div>
</div>
```
### 权限项组件(范围型下拉)
```html
<!-- templates/permissions/partials/perm_scope_item.html -->
<div x-data="{ editing: false, value: '{{ current_value }}' }" class="perm-item">
<span class="perm-name">{{ pdef.name }}</span>
<span class="perm-desc text-muted">{{ pdef.description }}</span>
<!-- 范围型下拉 -->
<select
x-model="value"
hx-post="/permissions/staff/{{ staff.id }}/override/"
hx-vals='js:{"overrides": {"{{ pdef.code }}": {"v": $el.value}}}'
hx-trigger="change"
hx-swap="none">
{% for choice in pdef.scope_choices_display %}
<option value="{{ choice.value }}" {% if choice.value == current_value %}selected{% endif %}>
{{ choice.label }}
</option>
{% endfor %}
</select>
<!-- 编辑按钮打开 Drawer -->
<button
hx-get="/permissions/pdef/{{ pdef.id }}/drawer/?staff_id={{ staff.id }}"
hx-target="#perm-drawer"
hx-trigger="click">
编辑
</button>
</div>
```
---
## 九、目录结构建议
```
apps/permissions/
├── models.py # 所有权限相关 Model见第三节
├── admin.py # Django AdminPermissionDef 管理
├── views.py # Role/Staff 权限保存、模块面板加载
├── urls.py
├── services/
│ ├── resolver.py # 权限解析引擎(含 Redis 缓存)
│ ├── consistency.py # 不一致标记逻辑
│ └── merger.py # 多角色值合并函数(单独抽离方便单测)
├── templatetags/
│ └── permission_tags.py # 模板标签:{% has_perm "client.view_private" %}
├── fixtures/
│ └── permission_defs.json # 初始权限目录数据
└── migrations/
```
---
## 十、关键风险与缓解
|风险|应对方案|
| 场景 | 最低覆盖 |
|---|---|
|角色权限变更未实时生效|`invalidate_role_cache()` 在保存时同步调用,清除所有相关员工缓存|
|`PermissionDef` 数据量大300+条)前端渲染慢|左侧模块导航驱动 HTMX 懒加载,每次只渲染当前模块|
|INTEGER 型 `0=不限制` 语义混淆|`is_unlimited()` 工具方法封装判断,避免散落在业务代码中|
|多角色 SCOPE 合并需要枚举顺序一致|`ScopeLevel` 使用 `IntegerChoices`,排序由整数值保证,不依赖字符串比较|
|`django-tenants` Schema 隔离|`PermissionDef` 放入 `shared_apps``Role/RolePermission/StaffRole/Override` 放入 `tenant_apps`|
|角色被删除时员工无角色|删除前校验 `StaffRole.objects.filter(role=role).exists()`,阻止删除并提示迁移|
| 多角色合并 | BOOLEAN/SCOPE/INTEGER 合并正确 |
| 个人覆盖 | 覆盖优先级正确,冗余覆盖可清理 |
| 缓存一致性 | 写后失效,读取最新快照 |
| 门禁三态 | 200 / 403 / 302 覆盖完整 |
测试文件:`tests/integration/permission/test_us_permission.py`
---
## 十一、迁移执行顺序
## 十四、落地顺序建议
1. 创建 `PermissionDef` fixture所有权限定义约 300 条)并执行 `loaddata`
2. 建立 `Role``RolePermission``StaffRole``StaffPermissionOverride`
3. `org.Staff` 增加权限相关的属性方法(`get_permission_checker()`
4. 部署 `CACHE_TTL``invalidate_*` 调用
5. 实现管理 UI人员列表 → 角色管理 → 个人权限编辑)
1. 先实现 Resolver + Cache 能力
2. 再做角色与角色权限编辑
3. 再做人员绑定与覆盖配置
4. 最后统一替换业务模块门禁调用
---
_文档版本 v1.0 | 生成时间 2026-04-24_
## 十五、文档同步规则
- 权限数据结构变更:同步 `DATA_MODEL_PERMISSION.md`
- 新增权限 code同步 `DATA_MODEL/ENUMS.md` 与权限种子
- API 变更:同步本文件与权限 PRD 验收条目

View File

@@ -0,0 +1,355 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 楼盘管理技术方案
**版本**: 1.0
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
**关联 PRD**: `PRD/房源管理/楼盘管理模块PRD.md`v1.0
**关联数据模型**: `DATA_MODEL/DATA_MODEL_COMPLEX.md`(本方案不重复 DDL
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
**最后更新**: 2026-04-27
---
## 一、文档定位与边界
本文件只定义楼盘模块的:
1. 服务边界与模块协作
2. API 端点设计(重点)
3. HTMX 局刷协议
4. 权限接入、异步任务、缓存与性能约束
5. 测试与验收映射
> **不在本文件展开**`complexes/buildings/room_units/districts/business_areas/schools` 表结构、索引、触发器。以 `DATA_MODEL_COMPLEX.md` 为唯一权威来源。
---
## 二、范围定义(以 TASK.md 为准)
### 2.1 P0 必须覆盖
- US-COMPLEX-001管理员录入与维护楼盘基础信息
- US-COMPLEX-002经纪人查看楼盘列表与详情
- US-COMPLEX-003管理员维护区域管理城区/商圈)
### 2.2 P1/P2 预留(端点可预留,不在当前强制交付)
- US-COMPLEX-010楼盘照片管理
- US-COMPLEX-011楼盘价格走势维护
- US-COMPLEX-012周边配套/学校管理
- US-COMPLEX-020应用数据标准
---
## 三、模块架构边界
## 3.1 模块职责(`apps/complex`
- 维护楼盘基础档案(楼盘、别名、商圈、学校、地铁关联)
- 维护楼栋与房号结构(供房源录入精准归位)
- 提供楼盘搜索、联想、详情聚合与区域筛选能力
- 承担区域与学校主数据管理P0 侧重城区/商圈)
## 3.2 外部依赖
| 依赖模块 | 依赖内容 | 依赖方式 |
|---|---|---|
| `apps/property` | 房源数量统计、楼盘详情联动跳转 | 只读聚合查询 |
| `apps/client` | 客源区域筛选项复用(城区/商圈) | 读取共享主数据 |
| `apps/org` | 操作人信息审计 | staff_id 写审计 |
| `apps/permission` | 楼盘查看/编辑/区域维护权限校验 | PermissionChecker |
| `core/cache.py` | 楼盘联想缓存、区域字典缓存 | Redis key 带 schema 前缀 |
| `Celery` | 完整度重算、价格趋势重算、批量任务 | 异步任务 |
| 地图服务(后续) | 周边配套查询、坐标纠偏 | 适配器层P1 |
## 3.3 分层约束
- `views.py` 仅做参数校验、鉴权、响应拼装
- 业务规则(锁校验、命名约束、合并规则)全部下沉 `services/`
- 任何写操作必须记录审计日志(操作人、前后值、时间)
- 耗时 >500ms 的批量操作与重算流程必须异步化
---
## 四、API 设计原则
1. 页面路由与数据 API 分离:
- 页面:`/complex/...`
- 数据:`/api/complex/...`
2. 列表筛选、Tab 切换、矩阵加载优先 HTMX 局刷。
3. 楼盘名称/地址修改遵循数据治理规则:
- `name` 不允许普通编辑页直接改
- `address` 走纠错流程
4. 锁定状态前置校验:`lock_info/lock_building/lock_room/lock_standard_room`
5. 统一错误协议:
- JSON: `{"error":"...","code":"..."}`
- HTMX: 片段 + `HX-Trigger` toast
---
## 五、端点清单(核心)
## 5.1 页面路由SSR + HTMX 容器)
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/complex/list/` | GET | 是 | 楼盘列表主页面(含完整度面板与筛选容器) |
| `/complex/create/` | GET | 是 | 新增楼盘页面 |
| `/complex/{complex_id}/` | GET | 是 | 楼盘详情主页面(多 Tab 容器) |
| `/complex/{complex_id}/edit/` | GET | 是 | 编辑楼盘页面 |
| `/complex/region/` | GET | 是 | 区域管理(城区/商圈)页面 |
## 5.2 HTMX 片段端点
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/complex/fragments/list-table/` | POST | 列表筛选/排序/分页局刷 | HTML |
| `/complex/fragments/completeness-panel/` | GET | 顶部完整度指标面板局刷 | HTML |
| `/complex/{id}/fragments/tab/{tab}/` | GET | 详情 Tab 懒加载 | HTML |
| `/complex/{id}/fragments/building-list/` | POST | 楼栋管理列表局刷 | HTML |
| `/complex/{id}/fragments/structure-matrix/` | POST | 结构矩阵局刷 | HTML |
| `/complex/region/fragments/district-table/` | POST | 城区列表局刷 | HTML |
| `/complex/region/fragments/business-area-table/` | POST | 商圈列表局刷 | HTML |
> fragment 端点必须校验 `HX-Request=true`,否则返回 400。
## 5.3 JSON APIP0
| 端点 | 方法 | 权限 code建议 | 说明 |
|---|---|---|---|
| `/api/complex/` | POST | `complex.base.create.allow` | 新增楼盘US-001 |
| `/api/complex/list/query/` | POST | `complex.base.view.scope` | 楼盘列表查询US-002 |
| `/api/complex/{id}/detail/` | GET | `complex.base.view.scope` | 楼盘详情聚合US-002 |
| `/api/complex/{id}/base-info/` | PATCH | `complex.base.edit.allow` | 编辑楼盘基础信息US-001 |
| `/api/complex/{id}/lock/release/` | POST | `complex.lock.release.allow` | 解锁楼盘(锁权限) |
| `/api/complex/{id}/buildings/query/` | POST | `complex.base.view.scope` | 楼栋列表查询 |
| `/api/complex/{id}/buildings/` | POST | `complex.building.edit.allow` | 新增楼栋 |
| `/api/complex/{id}/room-units/query/` | POST | `complex.base.view.scope` | 结构矩阵查询 |
| `/api/complex/regions/districts/query/` | POST | `complex.region.manage.allow` | 城区列表查询US-003 |
| `/api/complex/regions/districts/` | POST | `complex.region.manage.allow` | 新增城区US-003 |
| `/api/complex/regions/districts/{id}/` | PATCH | `complex.region.manage.allow` | 编辑城区 |
| `/api/complex/regions/business-areas/query/` | POST | `complex.region.manage.allow` | 商圈列表查询US-003 |
| `/api/complex/regions/business-areas/` | POST | `complex.region.manage.allow` | 新增商圈US-003 |
| `/api/complex/regions/business-areas/{id}/` | PATCH | `complex.region.manage.allow` | 编辑商圈 |
| `/api/complex/regions/business-areas/merge/` | POST | `complex.region.manage.allow` | 合并商圈 |
| `/api/complex/regions/districts/merge/` | POST | `complex.region.manage.allow` | 合并城区 |
---
## 六、关键 API 规范(请求/响应)
## 6.1 新增楼盘
`POST /api/complex/`
```json
{
"name": "万科城市花园",
"district_id": "uuid",
"address": "上海市闵行区XX路XX号",
"address_summary": "XX路XX弄",
"property_usage_types": ["residential"],
"building_structure": "unit_room",
"business_area_ids": ["uuid1", "uuid2"],
"school_ids": ["uuid3"]
}
```
成功 `201`
```json
{
"id": "uuid",
"message": "保存成功",
"redirect_url": "/complex/uuid/"
}
```
## 6.2 楼盘列表查询
`POST /api/complex/list/query/`
```json
{
"keyword": "万科",
"filters": {
"district_ids": ["uuid"],
"usage_types": ["residential"],
"has_coordinate": true
},
"sort": {"field": "updated_at", "order": "desc"},
"pagination": {"mode": "keyset", "cursor": "opaque_cursor", "limit": 20}
}
```
## 6.3 编辑楼盘基础信息
`PATCH /api/complex/{id}/base-info/`
```json
{
"address_summary": "海波路1000弄",
"built_years": [2005, 2008],
"ownership_category": ["commodity_residential"],
"remarks": "已校验最新物业信息"
}
```
规则:
-`lock_info=true` 且无解锁权限,返回 403
- `name` 字段不允许通过该接口修改,返回 400
## 6.4 新增商圈
`POST /api/complex/regions/business-areas/`
```json
{
"district_id": "uuid",
"name": "江桥新城"
}
```
规则:
- `district_id + name` 唯一
- 商圈必须归属城区
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求:`HX-Request: true`
- 成功:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 跳转:`HX-Redirect: /complex/{id}/`
## 7.2 模板分片命名
| 场景 | 模板 |
|---|---|
| 楼盘列表 | `templates/complex/fragments/list_table.html` |
| 完整度面板 | `templates/complex/fragments/completeness_panel.html` |
| 楼栋列表 | `templates/complex/fragments/building_list.html` |
| 结构矩阵 | `templates/complex/fragments/structure_matrix.html` |
| 城区列表 | `templates/complex/fragments/district_table.html` |
| 商圈列表 | `templates/complex/fragments/business_area_table.html` |
---
## 八、权限与数据范围
## 8.1 最小权限矩阵P0 建议)
| 能力 | 权限 code |
|---|---|
| 查看楼盘列表/详情范围 | `complex.base.view.scope` |
| 新增/编辑楼盘基础信息 | `complex.base.create.allow` / `complex.base.edit.allow` |
| 解锁楼盘 | `complex.lock.release.allow` |
| 楼栋与结构维护 | `complex.building.edit.allow` |
| 区域管理(城区/商圈) | `complex.region.manage.allow` |
## 8.2 数据域规则
最终可见数据 = `scope 过滤``业务过滤`(启用状态、软删过滤)
- 所有查询必须 `deleted_at IS NULL`
- 区域管理默认只展示 `is_active=true`,可选查看停用项
---
## 九、异步任务与缓存策略
## 9.1 Celery 任务
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `complex_completeness_recalc_task` | 数据管理员点击“重新计算” | 重算完整度指标面板 |
| `complex_price_trend_refresh_task` | 夜间定时 | 价格走势数据聚合T+1 |
| `complex_merge_cleanup_task` | 合并楼盘后 | 处理关联表与索引重建 |
> 任务参数必须包含 `tenant_schema_name`,任务开头显式切 schema。
## 9.2 Redis Key 规范
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:complex:list:query:{hash}` | 60s | 热门筛选缓存 |
| `{schema}:complex:detail:{id}` | 120s | 详情聚合缓存 |
| `{schema}:complex:completeness:panel` | 300s | 完整度指标缓存 |
| `{schema}:complex:region:districts` | 300s | 城区字典缓存 |
| `{schema}:complex:region:business_areas:{district_id}` | 300s | 商圈字典缓存 |
---
## 十、性能与可靠性约束
1. 列表查询强制 Keyset 分页,禁止 OFFSET 大页深翻。
2. 楼盘检索优先使用 `search_vector` + `trgm` 索引。
3. 详情页按 Tab 懒加载,避免一次拉全量聚合。
4. 合并与批量改动使用事务;失败必须回滚。
5. 区域与楼盘维护接口需限流,防止误操作风暴。
---
## 十一、安全与合规
- 楼盘锁定状态必须在服务层强校验,不允许绕过。
- 所有写操作记录审计操作人、IP、前后值。
- 地图类外部 API 调用不得暴露密钥到前端。
- 删除有关联数据(楼盘、区域)必须返回阻断型错误提示。
---
## 十二、错误码建议
| code | HTTP | 场景 |
|---|---|---|
| `COMPLEX_NOT_FOUND` | 404 | 楼盘不存在或无权限 |
| `COMPLEX_LOCKED_INFO` | 403 | 楼盘信息锁定不可编辑 |
| `COMPLEX_NAME_EDIT_FORBIDDEN` | 400 | 禁止直接修改楼盘名称 |
| `COMPLEX_REGION_REQUIRED` | 400 | 商圈未归属城区 |
| `COMPLEX_REGION_DUPLICATED` | 409 | 城区/商圈重名冲突 |
| `COMPLEX_PERMISSION_DENIED` | 403 | 权限不足 |
---
## 十三、测试映射
### 13.1 P0 User Story 映射
| User Story | 最低覆盖 |
|---|---|
| US-COMPLEX-001 | 新增/编辑楼盘成功、必填校验、锁定校验 |
| US-COMPLEX-002 | 列表关键词筛选、分页、详情 Tab 懒加载 |
| US-COMPLEX-003 | 城区商圈新增编辑、归属约束、冲突校验 |
测试文件:`tests/integration/complex/test_us_complex.py`
### 13.2 强制测试约束
- 集成测试使用 `TenantClient`
- HTMX 请求携带 `HTTP_HX_REQUEST=true`
- 权限三态覆盖200 / 403 / 302
- 外部服务(地图/R2/Redis全部 mock
---
## 十四、落地顺序建议
1. 先打通楼盘列表查询 + 详情聚合US-COMPLEX-002
2. 再完成新增/编辑与锁校验US-COMPLEX-001
3. 最后完成区域管理US-COMPLEX-003
4. P1 再接照片、价格走势、学校管理
---
## 十五、文档同步规则
- 枚举变更:同步 `DATA_MODEL/ENUMS.md`
- 权限 code 变更:同步 `DATA_MODEL/DATA_MODEL_PERMISSION.md`
- 数据结构变更:同步 `DATA_MODEL/DATA_MODEL_COMPLEX.md`
- API 变更:同步本文件与 `PRD/TASK.md` 对应条目

View File

@@ -1,92 +1,95 @@
> **For AI assistants**: Read this entire file before writing any test code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 测试规范TEST_SPEC
> **For AI assistants**: Read this entire file before writing any test code. All decisions here are final. Do not suggest alternatives unless asked. Every new feature or User Story implementation must be accompanied by corresponding tests as defined in this document.
**版本**: 1.0 **最后更新**: 2026-04-26
**定位**: 本文档定义 Fonrey 项目的完整测试策略包含测试分层、工具选型、目录结构、多租户测试约定、HTMX 测试约定、CI 自动化配置及 AI 辅助编码时的测试要求。
**版本**: 1.1
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + django-tenants + PostgreSQL 16 + Redis + Celery + HTMX + Playwright
**关联文档**: `TECH_STACK/TECH_STACK.md``PRD/TASK.md`、各模块技术方案(登录/权限/房源/客源/楼盘/组织人事/系统管理)
**最后更新**: 2026-04-27
---
## 1. 测试目标
## 一、文档定位与边界
Fonrey 采用 AI vibe coding 模式开发AI 负责生成功能代码,**测试是保证每日迭代质量的唯一安全网**。测试体系须满足
本文件定义项目统一测试标准
- 每个 P0 User Story 完成后,对应测试同步产出
- 每日自动运行全量测试套件,输出可读报告
- 测试失败时AI 可根据报告自主定位并修复问题
- 测试环境与生产环境技术栈完全一致(同样使用 PostgreSQL + django-tenants
1. 测试分层与覆盖率目标
2. 测试目录与夹具fixture约定
3. 多租户与 HTMX 场景测试规范
4. CI 执行基线与失败处理流程
5. AI 辅助开发的“测试随功能交付”硬约束
**覆盖率基准目标**
> 本文件不替代模块级测试设计。每个业务模块的案例细节以对应技术方案和 PRD AC 为准。
---
## 二、测试目标与覆盖基线
Fonrey 采用 AI 驱动迭代,测试是质量兜底。所有 P0 User Story 必须做到“功能 + 测试”同步交付。
### 2.1 覆盖率目标
| 层级 | 最低目标 |
|------|---------|
| `core/` 核心基础模块 | ≥ 90% |
|---|---|
| `core/` 基础模块 | ≥ 90% |
| `apps/*/services/` 业务逻辑层 | ≥ 80% |
| `apps/*/views.py` 视图层 | ≥ 70% |
| `apps/*/views*` 接口与视图层 | ≥ 70% |
| `apps/*/tasks.py` 异步任务 | ≥ 70% |
| E2E 核心用户旅程 | 5 条必须全部通过 |
| E2E 核心旅程 | 5 条全部通过 |
### 2.2 质量门禁
- 每个 P0 US 对应至少一个集成测试场景集。
- PR 合并前:单元 + 集成必须全绿。
- `main/develop`:每日自动跑全量(含 E2E 核心旅程)。
---
## 2. 测试分层架构
Fonrey 采用三层测试体系,从底层向上覆盖:
## 三、测试分层架构
```
┌─────────────────────────────────────────┐
E2E 测试(用户行为模拟) │ ← Playwright
│ 覆盖5 条核心用户旅程 │
│ E2E 测试(用户旅程) │ ← Playwright
├─────────────────────────────────────────┤
集成测试(API / View 层) │ ← pytest-django TenantClient
│ 覆盖:所有 P0 User Story 的 HTTP 接口 │
│ 集成测试(HTTP / View / Service / DB │ ← pytest-django + TenantClient
├─────────────────────────────────────────┤
单元测试(逻辑单元 │ ← pytest-django + factory_boy
│ 覆盖core/、services/、tasks.py │
│ 单元测试(逻辑) │ ← pytest + factory_boy + mock
└─────────────────────────────────────────┘
```
**三层分工原则**
### 3.1 单元测试
- **单元测试**:不启动 HTTP server不依赖浏览器速度最快测试单一函数/类的逻辑正确性
- **集成测试**:使用 Django 测试客户端,验证完整请求-响应链路View → Service → DB不启动真实 HTTP server
- **E2E 测试**:启动真实 Django dev server用浏览器驱动验证真实用户操作流程速度最慢只覆盖核心旅程
- 目标:服务层、工具层、任务函数的逻辑正确性
- 约束:不启动真实 HTTP,不依赖外部网络。
### 3.2 集成测试
- 目标验证完整请求链路View → Service → DB
- 约束:必须使用 `TenantClient`,禁止 Django 原生 `Client()`
### 3.3 E2E 测试
- 目标:验证真实用户关键路径。
- 约束:只覆盖核心旅程,避免把所有细节都堆到 E2E。
---
## 3. 工具选型
## 四、工具选型与依赖
### 3.1 工具清单
| 类型 | 工具 | 版本建议 | 用途 |
|---|---|---|---|
| 测试框架 | `pytest` | ≥ 8.x | 统一运行器 |
| Django 集成 | `pytest-django` | ≥ 4.x | DB/Client/Settings |
| 数据工厂 | `factory_boy` | ≥ 3.x | 生成测试数据 |
| 假数据 | `Faker` | ≥ 25.x | 中文业务数据 |
| Mock | `pytest-mock` | ≥ 3.x | 外部依赖打桩 |
| HTTP Mock | `responses` | ≥ 0.25.x | 三方 HTTP 隔离 |
| 覆盖率 | `pytest-cov` | ≥ 5.x | coverage 报告 |
| 并行 | `pytest-xdist` | ≥ 3.x | 加速单测/集成 |
| E2E | `playwright` + `pytest-playwright` | ≥ 1.44 / ≥ 0.5 | 浏览器自动化 |
| 类型 | 工具 | 版本 | 用途 |
|------|------|------|------|
| 测试框架 | `pytest` | ≥ 8.x | 统一测试运行器 |
| Django 集成 | `pytest-django` | ≥ 4.x | Django 数据库、Client、设置管理 |
| 测试数据工厂 | `factory_boy` | ≥ 3.x | 创建测试用 Model 实例,避免手写 fixture |
| 假数据生成 | `Faker` | ≥ 25.x | 生成中文姓名、手机号、地址等假数据 |
| Mock 工具 | `pytest-mock` | ≥ 3.x | Mock 外部依赖R2、Redis、邮件服务 |
| HTTP Mock | `responses` | ≥ 0.25.x | Mock 第三方 HTTP 请求Cloudflare API 等) |
| E2E 测试 | `playwright` (Python) | ≥ 1.44.x | 浏览器自动化 |
| E2E 集成 | `pytest-playwright` | ≥ 0.5.x | Playwright 的 pytest 插件 |
| 覆盖率 | `pytest-cov` | ≥ 5.x | 生成代码覆盖率报告 |
| 并行加速 | `pytest-xdist` | ≥ 3.x | 多进程并行运行单元/集成测试 |
### 3.2 安装依赖
所有测试依赖统一放在 `requirements/test.txt`
```
pytest>=8.0
pytest-django>=4.8
pytest-mock>=3.12
pytest-cov>=5.0
pytest-xdist>=3.5
pytest-playwright>=0.5
factory_boy>=3.3
Faker>=25.0
responses>=0.25
```
安装命令:
安装基线:
```bash
pip install -r requirements/test.txt
@@ -95,46 +98,35 @@ playwright install chromium
---
## 4. 目录结构
## 五、目录结构约定
```
fonrey/
── tests/
├── conftest.py # 全局 fixtures租户、用户、客户端
├── settings_test.py # 测试专用 Django settings
├── factories/ # factory_boy 工厂
│ ├── __init__.py
│ ├── tenant_factory.py # Tenant、域名
│ ├── account_factory.py # Staff、Account
│ ├── org_factory.py # OrgUnit
│ ├── permission_factory.py # Role、Permission
── complex_factory.py # Complex、Building
│ ├── property_factory.py # Property、FollowUpLog
── client_factory.py # Client、ClientFollowUp
├── unit/ # 单元测试
│ ├── test_encryption.py # PII 加密/解密
── test_soft_delete.py # 软删除 Manager
│ ├── test_permission_service.py
│ ├── test_property_service.py
│ ├── test_client_service.py
── test_celery_tasks.py # Celery 任务(同步模式)
├── integration/ # 集成测试(按 User Story 分文件)
│ ├── account/
│ │ └── test_us_account.py # US-ACCOUNT-001~003
── permission/
│ │ └── test_us_permission.py # US-PERMISSION-001~005
├── complex/
│ │ └── test_us_complex.py
│ ├── property/
│ │ └── test_us_property.py # US-PROPERTY-001~008
│ ├── client/
│ │ └── test_us_client.py # US-CLIENT-001~017
│ ├── org/
│ │ └── test_us_org.py
│ └── setting/
│ └── test_us_setting.py
└── e2e/ # E2E 测试(核心用户旅程)
├── conftest.py # E2E 专用 fixtureslive_server、page
```text
tests/
── conftest.py
├── settings_test.py
├── factories/
├── tenant_factory.py
│ ├── account_factory.py
│ ├── permission_factory.py
│ ├── complex_factory.py
│ ├── property_factory.py
│ ├── client_factory.py
── org_factory.py
├── unit/
── test_encryption.py
├── test_soft_delete.py
│ ├── test_*_service.py
── test_celery_tasks.py
├── integration/
│ ├── account/test_us_account.py
│ ├── permission/test_us_permission.py
── complex/test_us_complex.py
├── property/test_us_property.py
│ ├── client/test_us_client.py
│ ├── org/test_us_org.py
── setting/test_us_setting.py
└── e2e/
├── conftest.py
├── test_journey_login.py
├── test_journey_property.py
├── test_journey_client.py
@@ -144,160 +136,87 @@ fonrey/
---
## 5. 多租户测试约定
## 六、多租户测试约定(强制)
这是 Fonrey 测试中最重要的约定。`django-tenants` 的 Schema 隔离在测试中必须正确处理,否则测试结果不可信。
### 6.1 核心原则
### 5.1 核心原则
1. 所有 DB 测试在租户 schema 上下文执行。
2. 业务数据禁止直接在 `public` schema 断言。
3. 事务隔离默认开启,测试间不得共享可变状态。
4. 测试请求必须经 `TenantClient` 发出。
- **所有集成测试和单元测试**(涉及数据库的)必须在租户 Schema 上下文中执行
- 严禁在 `public` Schema 下直接操作业务数据
- 每个测试函数执行后,数据库状态自动回滚(`pytest-django``db` fixture 保证事务隔离)
- 禁止测试之间共享可变状态(禁止 `module``session` 级别的数据库 fixtures除非明确只读
### 6.2 标准 fixture最小集合
### 5.2 租户 Fixture 规范
- `tenant`
- `tenant_client`
- `admin_user`
- `staff_user`
- `authenticated_client`
全局 `conftest.py` 必须提供以下标准 fixtures
### 6.3 禁止事项
```python
# tests/conftest.py规范示意非最终代码
@pytest.fixture(scope="session")
def tenant():
"""
创建一个测试租户session 级别,全程复用同一个 Schema
使用 django_tenants.test.client.TenantClient 配套使用。
"""
@pytest.fixture
def tenant_client(tenant):
"""
返回绑定到测试租户的 TenantClient 实例。
等价于 Django 的 Client(),但自动切换到租户 Schema。
所有集成测试的 HTTP 请求必须通过此 client 发出。
"""
@pytest.fixture
def staff_user(tenant):
"""普通员工用户,已完成登录态(含 Cookie/Session"""
@pytest.fixture
def admin_user(tenant):
"""系统管理员用户"""
@pytest.fixture
def authenticated_client(tenant_client, staff_user):
"""已登录状态的 TenantClient"""
```
### 5.3 禁止事项
- ❌ 禁止在测试中使用 Django 原生 `Client()`,必须使用 `TenantClient`
- ❌ 禁止在测试中手动 `SET search_path`,由 fixtures 统一管理
- ❌ 禁止跨租户数据访问断言(每个测试只能操作自己的租户数据)
- 禁止手工 `SET search_path`
- 禁止跨租户数据断言
- 禁止在集成测试用 Django 原生 `Client()`
---
## 6. 单元测试规范
## 七、单元测试规范
### 6.1 适用范围
### 7.1 覆盖范围
单元测试覆盖以下代码,**不依赖 HTTP 请求,速度要求 < 100ms/个**
| 目标代码 | 测试文件位置 |
|---------|------------|
| 代码范围 | 示例文件 |
|---|---|
| `core/encryption.py` | `tests/unit/test_encryption.py` |
| `core/models/base.py`软删除、ActiveManager | `tests/unit/test_soft_delete.py` |
| `apps/*/services/` 所有 service 函数 | `tests/unit/test_*_service.py` |
| `apps/*/tasks.py` Celery 任务 | `tests/unit/test_celery_tasks.py` |
| `core/cache.py` Redis key 工具函数 | `tests/unit/test_cache.py` |
| `core/models/base.py` | `tests/unit/test_soft_delete.py` |
| `apps/*/services/` | `tests/unit/test_*_service.py` |
| `apps/*/tasks.py` | `tests/unit/test_celery_tasks.py` |
### 6.2 factory_boy 规范
### 7.2 Celery 测试模式
每个 Django Model 必须有对应的 Factory集中放在 `tests/factories/`
- Factory 类名统一为 `{ModelName}Factory`
- 使用 `faker` 生成中文假数据(姓名、手机号、地址)
- 手机号字段必须使用未加密的明文值传入 factoryfactory 内部触发 Model 的加密逻辑)
- Factory 之间通过 `SubFactory` 表达依赖关系,禁止在 Factory 内部硬编码 ID
### 6.3 Celery 任务测试规范
所有 Celery 任务测试必须在同步模式下运行,在 `settings_test.py` 中配置:
`tests/settings_test.py` 必须启用
```python
# tests/settings_test.py
CELERY_TASK_ALWAYS_EAGER = True # 任务同步执行
CELERY_TASK_EAGER_PROPAGATES = True # 同步模式下抛出真实异常
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
```
调用方式统一使用 `.apply()` 而非 `.delay()``.apply_async()`
并统一使用
```python
# 正确
result = my_task.apply(args=[...])
# 禁止在测试中使用
my_task.delay(...)
my_task.apply_async(...)
result = some_task.apply(args=[...])
```
### 6.4 PII 加密测试要求
### 7.3 PII 相关必测点
`test_encryption.py` 必须覆盖以下场景:
- 加密后的密文与明文不同
- 相同明文每次加密产生不同密文GCM nonce 随机性
- 解密后的明文与原始明文完全一致
- 加密字段的 SHA-256 hash 索引值具有确定性(相同明文产生相同 hash
- 解密错误(篡改密文)抛出可识别异常
- 密文不等于明文
- 同明文重复加密产生不同密文(随机 nonce
- 解密结果与原文一致
- 哈希索引稳定(同明文同 hash
---
## 7. 集成测试规范
## 八、集成测试规范
### 7.1 适用范围
### 8.1 请求模式
集成测试覆盖完整的 HTTP 请求-响应链路,每个 P0 User Story 至少对应一个集成测试文件。
| 类型 | Header | 预期 |
|---|---|---|
| 普通页面请求 | 无 | 完整 HTML |
| HTMX 局刷请求 | `HTTP_HX_REQUEST=true` | HTML 片段 |
### 7.2 HTMX 请求约定
### 8.2 权限覆盖最小集
Fonrey 的 View 层分为两种响应模式,测试必须对应覆盖:
每个受保护接口必须覆盖:
| 请求类型 | Header | 预期响应 |
|---------|--------|---------|
| 普通页面请求 | 无 | 完整 HTML`<html>`, `<head>`, `<body>` |
| HTMX 局部请求 | `HTTP_HX_REQUEST: true` | 局部 HTML 片段(不含完整页面结构) |
1. 有权限:`200`
2. 无权限:`403`
3. 未登录:`302`
HTMX 请求在 `TenantClient` 中发送方式:
### 8.3 User Story 映射基线
```python
# HTMX 局部请求(规范示意)
response = authenticated_client.get(
'/properties/',
HTTP_HX_REQUEST='true'
)
# 验证返回局部 HTML不含完整页面标签
assert '<html' not in response.content.decode()
assert response.status_code == 200
```
### 7.3 权限验证覆盖要求
每个受权限保护的 View集成测试必须覆盖以下场景缺一不可
1. **有权限用户**:返回 200响应内容符合预期
2. **无权限用户**:返回 403
3. **未登录用户**:返回 302 重定向到登录页
4. **数据域隔离**(如适用):只能看到自己权限范围内的数据
### 7.4 P0 User Story 测试覆盖映射
每个 User Story 的集成测试须覆盖其 **验收标准Acceptance Criteria** 中的所有条目:
| User Story 文件 | 集成测试文件 |
|----------------|------------|
| US 范围 | 测试文件 |
|---|---|
| US-ACCOUNT-001~003 | `tests/integration/account/test_us_account.py` |
| US-PERMISSION-001~005 | `tests/integration/permission/test_us_permission.py` |
| US-COMPLEX-001~003 | `tests/integration/complex/test_us_complex.py` |
@@ -306,306 +225,121 @@ assert response.status_code == 200
| US-ORG-001~003 | `tests/integration/org/test_us_org.py` |
| US-SETTING-001 | `tests/integration/setting/test_us_setting.py` |
### 7.5 外部服务 Mock 规范
### 8.4 外部依赖 Mock 规范
集成测试中必须 Mock 所有外部 I/O禁止真实调用
| 外部依赖 | Mock 方式 |
|---------|---------|
| Cloudflare R2 文件上传 | `pytest-mock` mock `boto3.client` |
| Redis | 使用 `fakeredis` 替代真实 Redis |
| 邮件发送 | Django `django.test.utils.override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')` |
| Celery 任务 | `CELERY_TASK_ALWAYS_EAGER=True`(见 §6.3 |
- R2mock `boto3.client`
- Redisfakeredis / locmem cache
- 邮件locmem backend
- 第三方 HTTP`responses` 全量拦截
---
## 8. E2E 测试规范
## 九、E2E 测试规范
### 8.1 适用范围与原则
### 9.1 核心旅程(必须)
E2E 测试成本高、速度慢,**只覆盖核心用户旅程,不追求全覆盖**。以下 5 条旅程为必须通过项,任意一条失败即视为阻塞级问题:
| 编号 | 旅程 | 对应模块 |
|---|---|---|
| J-01 | 登录 → 进入首页 | 登录 |
| J-02 | 录入房源 → 上传图片 → 查看列表 | 房源 |
| J-03 | 录入客源 → 添加跟进 | 客源 |
| J-04 | 无权限访问受限页面 | 权限 |
| J-05 | 创建员工 → 分配角色 → 新员工登录 | 组织人事 + 权限 |
| # | 旅程名称 | 对应模块 |
|---|---------|---------|
| J-01 | 登录 → 进入首页 | 登录管理 |
| J-02 | 录入房源 → 上传照片 → 查看列表 | 房源管理 |
| J-03 | 录入客源 → 添加跟进记录 | 客源管理 |
| J-04 | 无权限员工访问受限页面 → 看到 403 提示 | 权限管理 |
| J-05 | 管理员创建员工 → 分配角色 → 新员工登录验证 | 组织人事 + 权限 |
### 9.2 Playwright 约束
### 8.2 Playwright 技术约定
- 默认 Chromium
- CI 使用 headless
- 禁止 `wait_for_timeout()` 固定等待
- 优先语义等待:`wait_for_url` / `expect(locator)` / `networkidle`
- 浏览器:仅使用 Chromium与 Electron 内核一致)
- 运行模式CI 环境用 `headless=True`;本地调试可用 `headless=False`
- 等待策略:**禁止使用 `page.wait_for_timeout()`(固定等待)**,必须使用语义等待:
- `page.wait_for_url(pattern)` — 等待导航完成
- `expect(locator).to_be_visible()` — 等待元素出现
- `page.wait_for_load_state('networkidle')` — 等待 HTMX 请求完成
- 选择器优先级:`role` > `text` > `placeholder` > `data-testid` > CSS 选择器(可维护性从高到低)
- 断言使用 `expect()` 而非原生 `assert`,获得更清晰的错误输出
### 9.3 HTMX 页面注意事项
### 8.3 HTMX 页面的 E2E 注意事项
HTMX 局部更新后DOM 发生变化但页面 URL 可能不变。等待策略:
HTMX 更新后 URL 可不变,断言前必须等待请求完成:
```python
# 触发 HTMX 请求后等待网络空闲HTMX 请求完成)
page.click('button:has-text("筛选")')
page.wait_for_load_state('networkidle')
# 再断言 DOM 内容
expect(page.locator('.property-list')).to_contain_text('...')
```
### 8.4 E2E 测试数据管理
- E2E 测试使用独立的测试租户(在 `tests/e2e/conftest.py` 中创建)
- 每次 E2E 测试套件运行前,重置测试租户数据至初始种子状态
- 禁止 E2E 测试依赖其他 E2E 测试的产出数据(每条旅程测试自行准备数据)
---
## 9. 测试配置文件
## 十、测试配置基线
### 9.1 pytest.ini
### 10.1 `pytest.ini`
```ini
[pytest]
DJANGO_SETTINGS_MODULE = tests.settings_test
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--tb=short
--strict-markers
-q
addopts = --tb=short --strict-markers -q
markers =
unit: 单元测试(不访问数据库)
integration: 集成测试(访问数据库,使用 TenantClient
e2e: E2E 测试(启动真实服务,需要浏览器)
slow: 耗时超过 5 秒的测试
unit
integration
e2e
slow
```
### 9.2 tests/settings_test.py 关键配置
### 10.2 `tests/settings_test.py` 关键
```python
# 继承主 settings覆盖以下配置
DATABASES = {
# 使用独立的测试数据库CI 中由环境变量注入)
}
# Celery 同步模式
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# 使用内存缓存(避免依赖真实 Redis
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# 邮件使用内存后端
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
# 文件存储使用本地临时目录(非 R2
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = '/tmp/fonrey_test_media/'
# 关闭密码哈希加速(测试中不需要高强度 hash
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
# 禁用 DEBUG贴近生产环境
DEBUG = False
```
- Celery eager 模式开启
- Cache 使用测试后端locmem/fakeredis
- 邮件使用 locmem backend
- 媒体文件使用临时目录
- `DEBUG=False`(贴近生产)
---
## 10. CI 自动化运行
## 十一、CI 自动化运行
### 10.1 GitHub Actions 配置
### 11.1 触发策略
每日凌晨 2 点自动运行全量测试套件,并在每次 push 到 `main` / `develop` 分支时触发:
- 每日定时全量测试
- `main/develop` 每次 push 触发
```yaml
# .github/workflows/daily-test.yml
name: Daily Test Suite
### 11.2 流水线拆分
on:
schedule:
- cron: '0 18 * * *' # UTC 18:00 = 北京时间次日 02:00
push:
branches: [main, develop]
1. `unit-and-integration`
2. `e2e`(依赖前者成功后执行)
jobs:
unit-and-integration:
name: Unit & Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: fonrey_test
POSTGRES_USER: fonrey
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements/test.txt
- name: Run unit tests
run: pytest tests/unit -m unit --cov=core --cov=apps -q
- name: Run integration tests
run: pytest tests/integration -m integration -q
- name: Upload coverage report
uses: codecov/codecov-action@v4
### 11.3 最低产物
e2e:
name: E2E Tests (Core Journeys)
runs-on: ubuntu-latest
needs: unit-and-integration
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements/test.txt
- run: playwright install chromium --with-deps
- name: Run E2E tests
run: pytest tests/e2e -m e2e -q
- name: Upload E2E screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: tests/e2e/screenshots/
```
### 10.2 本地每日运行
开发机本地运行全量测试:
```bash
# scripts/daily_test.sh
#!/bin/bash
set -e
echo "=============================="
echo " Fonrey Daily Test Runner"
echo "=============================="
echo ""
echo "[1/3] 单元测试..."
pytest tests/unit -m unit -q --tb=short
echo ""
echo "[2/3] 集成测试..."
pytest tests/integration -m integration -q --tb=short
echo ""
echo "[3/3] E2E 核心旅程测试..."
pytest tests/e2e -m e2e -q --tb=short
echo ""
echo "[覆盖率报告]"
pytest tests/unit tests/integration \
--cov=core --cov=apps \
--cov-report=term-missing \
--cov-fail-under=70 \
-q --tb=no
echo ""
echo "=============================="
echo " All tests passed."
echo "=============================="
```
- 覆盖率报告(终端 + 平台上传)
- E2E 失败截图 artifact
---
## 11. AI 辅助编码时的测试要求
## 十二、AI 协作测试要求
在 Fonrey vibe coding 流程中,每次 AI 完成一个 User Story 的功能代码后,**必须同步产出对应测试**,不允许欠测试债。
每个 User Story 实现后,必须同时补齐:
### 11.1 每个 User Story 的测试产出清单
- Factory如缺失
- Service 单元测试(正常 + 至少2个边界
- View/API 集成测试(覆盖全部 AC
- 权限三态与 HTMX/普通请求双形态
AI 完成功能代码后,须产出以下内容(缺一不可)
修复顺序
- [ ] 相关 Model 的 `factory_boy` Factory若尚未存在
- [ ] Service 层的单元测试(正常路径 + 至少 2 个边界/异常场景)
- [ ] View 层的集成测试(覆盖 PRD 验收标准中所有 AC 条目)
- [ ] 权限场景覆盖(有权限 / 无权限 / 未登录,见 §7.3
- [ ] HTMX 局部请求与完整页面请求分别测试(见 §7.2
### 11.2 触发 AI 生成测试的标准 Prompt 模板
```
基于刚才实现的 [US-XXX-NNN],请为其生成完整测试:
1. factory_boy 工厂(如尚未存在)
- 文件位置tests/factories/{app}_factory.py
- 使用 Faker 生成中文假数据
2. Service 层单元测试
- 文件位置tests/unit/test_{app}_service.py
- 覆盖正常路径 + 至少 2 个边界/异常场景
- 使用 pytest-mock mock 外部依赖
3. View 层集成测试
- 文件位置tests/integration/{app}/test_us_{module}.py
- 覆盖 PRD 中该 US 的所有 AC 条目
- 使用 TenantClient 发送请求
- HTMX 请求和普通请求分别覆盖
- 权限场景:有权限 / 无权限403 / 未登录302
所有测试须遵循 TECH_STACK/测试规范.md 中的约定。
租户 fixtures 从 tests/conftest.py 导入,不要重复定义。
```
### 11.3 测试失败时的修复流程
CI 测试失败后AI 修复流程:
1. 读取失败的测试输出(`--tb=short` 格式)
2. 定位失败原因(逻辑错误 / 数据错误 / 环境依赖问题)
3. **优先修复功能代码**(测试是需求的正式表达,不轻易修改测试)
4. 仅当测试本身有误(如 AC 理解错误)时才修改测试,并注明修改原因
5. 修复后本地重跑对应测试套件确认通过,再提交
1. 先修功能代码
2. 仅当测试确实错误才改测试
3. 本地复跑通过后再提交
---
## 12. 禁止项Do NOT
## 十三、禁止项Do NOT
- ❌ 禁止使用 Django 原生 `Client()`,必须使用 `TenantClient`
- ❌ 禁止使用固定等待 `time.sleep()` `page.wait_for_timeout()`
- ❌ 禁止测试直接调用真实外部服务R2、邮件、第三方 API
- 禁止测试之间共享可变数据(避免测试顺序依赖)
- ❌ 禁止在测试中硬编码 Tenant ID、UUID、时间戳
- ❌ 禁止 E2E 测试依赖其他 E2E 测试产出的数据
- ❌ 禁止跳过权限验证场景(无权限 / 未登录场景必须覆盖)
- ❌ 禁止在功能代码未完成时先写空测试(`pass` 占位)后忘记补全
- 禁止 Django 原生 `Client()` 进行租户集成测试
- 禁止固定等待`sleep` / `wait_for_timeout`
- 禁止真实调用外部服务
- 禁止测试之间共享可变数据
- 禁止无权限/未登录场景缺失
- 禁止空测试占位后不补全
---
## 13. 文档索引
## 十四、文档同步规则
| 文档 | 说明 |
|------|------|
| [`TECH_STACK.md`](./TECH_STACK.md) | 技术栈总纲 |
| [`登录管理技术方案.md`](./登录管理技术方案.md) | 登录模块技术细节 |
| [`权限管理系统技术方案.md`](./权限管理系统技术方案.md) | 权限模块技术细节 |
| [`../PRD/TASK.md`](../PRD/TASK.md) | P0 User Story 清单(测试覆盖基准) |
| [`../DATA_MODEL/DATA_MODEL.md`](../DATA_MODEL/DATA_MODEL.md) | 数据模型总览factory 设计参考) |
- 新增/调整 User Story同步 `PRD/TASK.md` 与集成测试映射
- 模块 API 变更:同步对应模块技术方案
- 测试目录变更:同步本文件目录结构与 CI 脚本
- 新增测试基建fixture/工具):同步 `AGENTS.md` 与本文件

View File

@@ -1,711 +1,281 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 登录管理系统技术方案
**版本**: 2.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis + Celery
**关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
**关联数据模型**: `Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`
**最后更新**: 2026-04-25v2.0 补充服务层设计、HTMX 交互模式、Celery 任务、错误处理规范)
# Fonrey 登录管理技术方案
> **For AI assistants**: Read this entire file before writing any code.
> All decisions here are final. Do not suggest alternatives unless asked.
**版本**: 3.1
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`
**关联数据模型**: `DATA_MODEL/DATA_MODEL_LOGIN.md`(本方案不重复 DDL
**最后更新**: 2026-04-27
---
## 一、模块定位与架构边界
## 一、文档定位与边界
登录管理模块(`accounts` App负责多租户环境下的身份识别、认证、账号安全及凭据找回。
本文件仅定义登录模块的:
### 架构层级边界
1. 模块边界与服务职责
2. API 端点设计(页面 / HTMX / JSON
3. 登录安全策略(验证码、锁定、会话、找回)
4. 缓存与异步任务策略
5. 错误码与测试映射
| 层级 | 位置 | 说明 |
|------|------|------|
| Tenant ID 验证 | `shared_apps`Public Schema | 属于平台基础服务,在 `public` schema 下运行,无需租户切换 |
| 账号认证、找回密码等 | 租户 SchemaTenant Schema | 通过请求域名 `{tenant_slug}.fonrey.com` 自动切换,`django-tenants` 中间件处理 |
| Electron 客户端 | 前端 | 负责 Tenant ID 本地缓存、Session 管理、页面加载 |
### 模块依赖关系
```
accounts
├── 依赖 → org (Staff 实名绑定,单向依赖)
├── 依赖 → core.encryption (手机号加密)
├── 依赖 → core.cache (Redis 工具封装)
├── 依赖 → shared.tenants (Tenant ID 验证Public Schema)
└── 被依赖 ← org (离职联动,通过 Service 层调用)
```
> 不在本文件展开表字段、索引、DDL。数据结构以 `DATA_MODEL_LOGIN.md` 为唯一权威。
---
## 二、依赖与技术选型
## 二、范围定义(以 P0 为准)
| 依赖项 | 版本/方案 | 用途 | 说明 |
|--------|-----------|------|------|
| `django.contrib.auth` | Django 内置 | 用户认证基础框架 | 扩展 `AbstractBaseUser`**不直接使用** `User` 模型username 唯一性约束在租户 Schema 维度生效 |
| `django-tenants` | 已有 | 多租户隔离 | `UserAccount` 在租户 SchemaTenant 验证接口在 `shared_apps` |
| `PostgreSQL` | 已有 | 数据持久化 | Schema 级别隔离租户数据 |
| `Redis` | 必须 | 多用途缓存 | 滑块验证 TokenTTL 3min、登录失败计数TTL 30min、密码重置频率限制 |
| `Celery` | 必须 | 异步任务队列 | 邮件发送异步处理,防止登录/找回接口超时(邮件发送可能耗时 > 500ms |
| `Pillow` | 必须(若自研验证码) | 图片处理 | 生成拼图背景图(抠出缺口)+ 拼图碎片,输出 Base64 |
| `django-ratelimit` 或自定义中间件 | 必须 | 接口限流 | Tenant 验证、登录、找回密码接口均需限流 |
| `electron-store` 或 AES 加密文件 | Electron 侧 | 本地持久化 | 加密存储 Tenant ID不存明文路径为 `app.getPath('userData')` |
| `secrets` (Python 标准库) | Python 内置 | Token 生成 | 使用 `secrets.token_urlsafe(64)` 生成密码重置 Token86 字符) |
### 2.1 P0 必须覆盖
### 滑块验证码方案选型(待确认,见开放问题
- Tenant ID 校验Public Schema
- 用户名 + 密码登录Tenant Schema
- 验证码挑战与一次性 pass token
- 连续失败锁定与解锁机制
- 找回用户名 / 重置密码
- 首次登录强制改密
- 安全登出与会话销毁
| 方案 | 优点 | 缺点 |
|------|------|------|
| 自研Pillow + 前端拖拽组件) | 完全可控,无外部依赖,数据合规性好 | 需维护图库,需自己实现轨迹检测算法 |
| 第三方服务(极验 GeeTest / 网易易盾) | 开箱即用,安全性更高 | 引入外部依赖,有数据合规风险,需评估 |
### 2.2 预留(非本期强制)
**当前方案**:暂按自研设计,后端负责人需在开发启动前确认最终选型。
- MFAOTP / 短信)
- 企业 SSOOAuth2 / SAML
- 设备指纹与风险评分
---
## 三、目录结构
## 三、模块架构边界
```
fonrey/apps/
└── accounts/ # 账号认证管理(租户级 App
├── models.py # UserAccount, LoginAttempt, PasswordResetToken, PasswordHistory
├── views/
│ ├── auth.py # 登录/登出视图HTMX 响应)
│ ├── captcha.py # 滑块验证码视图
│ └── recovery.py # 找回用户名/密码视图
├── urls.py
├── serializers.py # API 序列化JSON 接口,供 Electron 前端使用)
├── forms.py # 登录表单、找回密码表单
├── templates/
│ └── accounts/
│ ├── login.html # 登录页(含滑块验证码区域)
│ ├── tenant_verify.html # Tenant 识别页(首次启动)
│ ├── change_password.html # 强制修改初始密码页
│ ├── recover_username.html # 找回用户名页
│ ├── recover_password.html # 找回密码(步骤 1身份验证
│ └── reset_password.html # 重置密码(步骤 2设置新密码
└── services/
├── auth.py # 认证逻辑:滑块验证、账号锁定、登录流程
├── captcha.py # 验证码生成与校验Pillow 或第三方)
├── recovery.py # 找回用户名/密码逻辑(含 Celery 任务触发)
├── password.py # 密码复杂度校验、历史密码比对
└── tenant.py # Tenant 验证逻辑(属于 shared_appsPublic Schema
## 3.1 模块职责(`apps/account`
fonrey/shared/ # Public Schema Appdjango-tenants shared_apps
└── tenants/
├── models.py # TenantModel, Domain
└── views.py # tenant/verify/ 接口(在公共 Schema 下)
```
- 登录认证入口与 Session 建立
- 密码策略、锁定策略、登录防刷策略执行
- 找回流程与一次性重置令牌管理
- 登录/登出/失败审计事件写入
## 3.2 多租户分层职责
| 层级 | Schema | 职责 |
|---|---|---|
| Tenant ID 校验 | Public | 校验租户标识、域名映射、租户状态 |
| 登录认证 | Tenant | 账号鉴权、失败计数、会话建立 |
| 密码找回 | Tenant | 身份确认、令牌签发与核销 |
## 3.3 外部依赖
| 依赖模块 | 用途 |
|---|---|
| `apps/org` | 员工状态联动(离职/冻结禁止登录) |
| `core/encryption.py` | 手机号等敏感信息加密与脱敏 |
| `core/cache.py` | 验证码票据、失败计数、频控缓存 |
| `Celery` | 找回消息异步发送、安全日报任务 |
---
## 四、数据模型
## 四、API 设计原则
> **数据模型完整定义已迁移至** `Project/fonrey/DATA_MODEL/DATA_MODEL_LOGIN.md`,本节仅保留技术实现视角的关键说明。
### 4.1 表归属汇总
| 表名 | Schema | 说明 |
|------|--------|------|
| `user_accounts` | 租户 Schema | 账号主表,`username` 唯一性在 Schema 维度生效 |
| `login_attempts` | 租户 Schema | 登录审计,保留 ≥ 90 天 |
| `password_reset_tokens` | 租户 Schema | 一次性重置令牌30 分钟过期 |
| `password_histories` | 租户 Schema | 最近 3 次密码哈希,防重用 |
### 4.2 关键约束汇总
- `username` 唯一性约束仅在当前租户 Schema 内生效(`django-tenants` 隔离机制),**不同租户可以有相同 username**
- 密码存储使用 Django 默认 `PBKDF2+SHA256``make_password`**后端不得明文存储或传输**
- `phone_enc` 字段使用 `core.encryption` AES-256-GCM 加密存储;`phone_hash` SHA-256 哈希用于唯一性校验
- `locked_until` 字段持久化锁定到期时间,防止 Redis 故障导致锁定状态丢失
1. 登录链路固定三段Tenant 校验 → 验证码 → 账号认证。
2. 最小暴露原则:账号不存在与密码错误统一返回。
3. 认证状态以数据库为准Redis 仅做加速与频控。
4. 安全相关写操作后立即失效缓存,不依赖 TTL。
5. HTMX 返回片段JSON 返回结构化错误体。
---
## 五、服务层设计Service Layer
## 五、端点清单(核心
### 5.1 `services/auth.py` — 核心认证服务
## 5.1 页面路由SSR
```python
# apps/accounts/services/auth.py
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/account/tenant/verify/` | GET | 否 | 首次租户识别页 |
| `/account/login/` | GET | 否 | 登录页 |
| `/account/change-password/` | GET | 是 | 首登强制改密页 |
| `/account/recover-username/` | GET | 否 | 找回用户名页 |
| `/account/recover-password/` | GET | 否 | 找回密码页 |
class AuthService:
## 5.2 HTMX 片段端点
LOGIN_FAIL_LIMIT = 5 # 连续失败次数触发锁定
LOCK_DURATION_MINUTES = 30 # 锁定时长(分钟)
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/account/fragments/login-form/` | GET | 登录表单局刷 | HTML 片段 |
| `/account/fragments/captcha/` | GET | 验证码区块刷新 | HTML 片段 |
| `/account/fragments/recover-step/` | GET | 找回步骤局刷 | HTML 片段 |
@classmethod
def authenticate(cls, username: str, password: str, captcha_pass_token: str,
tenant_id: str, ip_address: str, user_agent: str) -> dict:
"""
完整登录流程:
1. 校验 captcha_pass_token一次性凭证Redis 查询后立即删除)
2. 查询账号(不存在则记录审计日志,返回通用错误)
3. 检查账号状态locked / disabled
4. 校验密码
5. 登录成功后:更新 last_login清零失败计数返回账号信息
6. 失败时:递增失败计数,超限触发锁定
## 5.3 JSON APIP0
Returns:
{'success': True, 'user': UserAccount, 'is_initial_password': bool}
{'success': False, 'error_code': str, 'error_message': str}
"""
...
| 端点 | 方法 | 说明 |
|---|---|---|
| `/api/account/tenant/verify/` | POST | 校验 tenant_id 与可用性 |
| `/api/account/captcha/generate/` | POST | 生成验证码 challenge |
| `/api/account/captcha/verify/` | POST | 校验 challenge 并签发 pass token |
| `/api/account/login/` | POST | 登录并建立 session |
| `/api/account/logout/` | POST | 登出并销毁 session |
| `/api/account/password/change/` | POST | 登录态改密 |
| `/api/account/username/recover/` | POST | 找回用户名 |
| `/api/account/password/recover/request/` | POST | 申请重置密码令牌 |
| `/api/account/password/recover/reset/` | POST | 使用令牌重置密码 |
@classmethod
def _check_lock_status(cls, user: 'UserAccount') -> bool:
"""检查账号锁定状态,自动解锁已到期的锁定"""
...
---
@classmethod
def _increment_fail_count(cls, tenant_id: str, username: str) -> int:
"""递增失败计数,返回当前计数;超限时触发账号锁定"""
...
## 六、关键 API 规范(请求/响应)
@classmethod
def _trigger_lock(cls, user: 'UserAccount') -> None:
"""触发账号锁定status=locked, locked_until=now+30min"""
...
## 6.1 登录
@classmethod
def unlock_account(cls, user: 'UserAccount') -> None:
"""管理员手动解锁账号"""
...
```
`POST /api/account/login/`
### 5.2 `services/captcha.py` — 验证码服务
```python
# apps/accounts/services/captcha.py
class CaptchaService:
CAPTCHA_TTL_SECONDS = 180 # 验证会话有效期3分钟
PASS_TOKEN_TTL_SECONDS = 180 # 通过凭证有效期3分钟
@classmethod
def generate(cls) -> dict:
"""
生成滑块拼图验证码。
Returns:
```json
{
'session_token': str, # Redis Key uuid供前端提交时携带
'background_b64': str, # 背景图含缺口Base64
'puzzle_b64': str, # 拼图碎片 Base64
'gap_y': int, # 缺口 Y 坐标(前端定位碎片初始位置)
"tenant_id": "fonrey-sh",
"username": "agent_001",
"password": "******",
"captcha_pass_token": "token"
}
注意:缺口 X 坐标gap_x不返回给前端服务端保存在 Redis。
"""
...
@classmethod
def verify(cls, session_token: str, slide_x: int, trajectory: list) -> dict:
"""
校验滑动结果。
Args:
session_token: generate() 返回的会话标识
slide_x: 用户最终滑动距离px
trajectory: 滑动轨迹,格式 [{'x': int, 'y': int, 't': int}, ...]
Returns:
{'pass': True, 'pass_token': str} # 通过pass_token 用于登录接口
{'pass': False, 'message': str} # 失败,前端自动刷新拼图
校验规则:
1. 位置偏差abs(slide_x - gap_x) <= 5px
2. 轨迹特征:存在加速→减速曲线,拒绝匀速/程序化轨迹
"""
...
```
### 5.3 `services/recovery.py` — 找回账号服务
成功 `200`
```python
# apps/accounts/services/recovery.py
class RecoveryService:
RESET_LINK_EXPIRE_MINUTES = 30
MAX_EMAILS_PER_HOUR = 3
@classmethod
def request_username_recovery(cls, email: str) -> None:
"""
发起找回用户名。
- 无论邮箱是否存在,统一返回「如该邮箱已绑定账号,您将收到邮件」
- 邮箱存在时:触发 Celery 任务异步发送邮件
- 限频:同一邮箱 1 小时内最多 3 次Redis 计数)
"""
...
@classmethod
def request_password_reset(cls, username: str, email: str) -> None:
"""
发起找回密码(步骤 1
- 无论匹配结果,统一返回「如信息匹配,重置链接将发送至邮箱」(防枚举)
- 匹配成功时:生成 PasswordResetToken触发 Celery 异步发送邮件
- 限频:同一账号 1 小时内最多 3 次Redis 计数)
"""
...
@classmethod
def reset_password(cls, token_str: str, new_password: str) -> dict:
"""
重置密码(步骤 2
Returns:
{'success': True}
{'success': False, 'error_code': 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'PASSWORD_REUSED'}
操作顺序:
1. 查询并校验 tokenis_used=False, expires_at > now
2. 校验密码复杂度
3. 校验历史密码(最近 3 次)
4. 更新密码哈希is_initial_password=False
5. 标记 token is_used=True
6. 清除该账号所有有效 Session强制重新登录
7. 写入 PasswordHistory
"""
...
```json
{
"message": "登录成功",
"redirect_url": "/home/"
}
```
### 5.4 `services/password.py` — 密码规则服务
失败码:`ACCOUNT_LOGIN_INVALID_CREDENTIAL` / `ACCOUNT_LOCKED` / `ACCOUNT_CAPTCHA_INVALID`
```python
# apps/accounts/services/password.py
## 6.2 申请重置密码
class PasswordService:
`POST /api/account/password/recover/request/`
MIN_LENGTH = 8
MAX_LENGTH = 32
HISTORY_COUNT = 3 # 保留最近 N 条历史密码
```json
{
"tenant_id": "fonrey-sh",
"username": "agent_001",
"contact": "138****0000"
}
```
@classmethod
def validate_complexity(cls, password: str) -> list[str]:
"""
校验密码复杂度。
Returns: 错误列表(空列表表示通过)
规则:
- 长度 8~32 位
- 必须包含字母(区分大小写)
- 必须包含数字
"""
...
@classmethod
def check_history(cls, user: 'UserAccount', new_password: str) -> bool:
"""
检查新密码是否与最近 3 次历史密码重复。
Returns: True允许使用/ False与历史重复
"""
...
@classmethod
def save_history(cls, user: 'UserAccount', password_hash: str) -> None:
"""
保存新密码哈希至历史记录,超出 HISTORY_COUNT 时删除最旧记录。
"""
...
```
- 频率限制(建议 5 次/小时)
- 统一返回文案,避免枚举账号存在性
---
## 六、Celery 异步任务
## 七、HTMX 交互约定
```python
# apps/accounts/tasks.py
## 7.1 Header 约定
from celery import shared_task
- 请求头:`HX-Request: true`
- 成功触发:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败触发:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 登录成功:`HX-Redirect: /home/`
@shared_task(
name='accounts.send_username_recovery_email',
max_retries=3,
default_retry_delay=60, # 失败后 60 秒重试
)
def send_username_recovery_email(email: str, username: str, company_name: str) -> None:
"""
发送找回用户名邮件。
失败时自动重试最多 3 次3 次均失败则写入告警日志Sentry
邮件内容:用户名 + 发送时间 + 联系管理员说明。
"""
...
## 7.2 模板分片命名
@shared_task(
name='accounts.send_password_reset_email',
max_retries=3,
default_retry_delay=60,
)
def send_password_reset_email(email: str, reset_link: str, company_name: str,
expires_at: str) -> None:
"""
发送密码重置链接邮件。
失败时自动重试最多 3 次3 次均失败则写入告警日志Sentry
邮件内容重置链接30分钟有效+ 安全说明。
"""
...
```
> **重试策略**邮件发送失败时不向前端返回错误用户已看到「邮件已发送」提示在后台静默重试3 次重试均失败后通过 Sentry 上报告警,管理员可在后台查看 Token 手动告知用户。
- `templates/account/fragments/login_form.html`
- `templates/account/fragments/captcha_panel.html`
- `templates/account/fragments/recover_step.html`
---
## 七、接口清单
## 八、权限与数据范围
| 接口 | 方法 | Schema 位置 | 是否需要鉴权 | 限流规则 | 响应格式 | 说明 |
|------|------|------------|------------|---------|---------|------|
| `/api/auth/tenant/verify/` | POST | Publicshared | 否 | 每 IP 每分钟 ≤ 10 次 | JSON | Tenant ID 验证 |
| `/api/auth/captcha/` | GET | Tenant | 否 | — | JSON | 获取滑块拼图验证码 |
| `/api/auth/captcha/verify/` | POST | Tenant | 否 | — | JSON | 提交滑动轨迹,返回一次性通过凭证 |
| `/api/auth/login/` | POST | Tenant | 否 | 每 IP 每分钟 ≤ 20 次 | JSON | 账号密码登录 |
| `/api/auth/logout/` | POST | Tenant | 是 | — | JSON | 登出,使服务端 Session 失效 |
| `/api/auth/password/change/` | POST | Tenant | 是 | — | JSON / HTMX | 强制修改初始密码(登录后跳转) |
| `/api/auth/recover/username/` | POST | Tenant | 否 | 每邮箱每小时 ≤ 3 次 | JSON / HTMX | 发起找回用户名(发送邮件) |
| `/api/auth/recover/password/request/` | POST | Tenant | 否 | 每账号每小时 ≤ 3 次 | JSON / HTMX | 发起找回密码(发送重置链接邮件) |
| `/api/auth/recover/password/reset/` | POST | Tenant | 否Token 鉴权) | — | JSON / HTMX | 提交新密码,使用 PasswordResetToken 校验 |
| `/api/auth/login/phone/` | POST | Tenant | 否 | — | JSON | **预留**v2 实现,手机验证码登录 |
| `/api/auth/wechat/qrcode/` | GET | Tenant | 否 | — | JSON | **预留**v2 实现,获取微信二维码 |
| `/api/auth/wechat/callback/` | POST | Tenant | 否 | — | JSON | **预留**v2 实现,微信扫码回调 |
## 8.1 访问控制
### 7.1 Tenant 验证接口规范
- 匿名可访问:租户校验、登录、找回相关端点
- 登录后访问:改密、登出
- 首登未改密用户仅允许访问改密页面
```
POST /api/auth/tenant/verify/
## 8.2 审计字段要求
Request Body:
{
"tenant_id": "202500010001" // 固定 12 位纯数字
}
Response 200 (验证通过):
{
"valid": true,
"tenant_name": "XX房产经纪有限公司",
"tenant_logo_url": "https://cdn.fonrey.com/tenants/xxx/logo.png",
"login_url": "https://xxx.fonrey.com/auth/login/"
}
Response 200 (验证失败):
{
"valid": false,
"error_code": "TENANT_NOT_FOUND",
"message": "识别码无效"
}
```
> 失败响应统一返回 HTTP 200不区分「未找到」与「已禁用」防止枚举攻击。
### 7.2 登录接口规范
```
POST /api/auth/login/
Request Body:
{
"username": "string",
"password": "string",
"captcha_pass_token": "string" // 滑块验证通过后的一次性凭证UUID
}
Response 200 (登录成功):
{
"success": true,
"token": "...",
"user": {
"id": 1,
"username": "...",
"display_name": "...",
"is_initial_password": false
}
}
Response 200 (登录失败):
{
"success": false,
"error_code": "WRONG_CREDENTIALS" | "ACCOUNT_LOCKED" | "ACCOUNT_DISABLED" | "CAPTCHA_INVALID",
"message": "...",
"lock_remaining_seconds": 1800 // 仅 ACCOUNT_LOCKED 时返回
}
```
> **注意**`WRONG_CREDENTIALS` 不区分「用户名错误」与「密码错误」,防止枚举攻击。
### 7.3 验证码接口规范
```
GET /api/auth/captcha/
Response 200:
{
"session_token": "uuid-string", // 提交验证时携带
"background_b64": "data:image/png;base64,...", // 带缺口的背景图
"puzzle_b64": "data:image/png;base64,...", // 拼图碎片
"gap_y": 120, // 缺口 Y 坐标(用于定位碎片初始位置)
"width": 320, // 背景图宽度px
"height": 160 // 背景图高度px
}
POST /api/auth/captcha/verify/
Request Body:
{
"session_token": "uuid-string",
"slide_x": 185, // 最终滑动距离px
"trajectory": [
{"x": 0, "y": 0, "t": 0},
{"x": 20, "y": 1, "t": 80},
{"x": 185, "y": 2, "t": 1200}
]
}
Response 200 (验证通过):
{
"pass": true,
"pass_token": "uuid-string" // 一次性凭证TTL 3分钟登录时携带
}
Response 200 (验证失败):
{
"pass": false,
"message": "验证失败,请重新拖动"
}
```
登录与找回相关操作至少记录:
- tenant_schema
- username或脱敏标识
- ip / user_agent
- result_code
- created_at
---
## 八、前端交互模式HTMX + Alpine.js
## 九、异步任务与缓存策略
### 8.1 页面结构说明
## 9.1 Celery 任务
登录相关页面均为**全页面渲染**Server-Side RenderedElectron 客户端通过 `BrowserWindow.loadURL()` 加载完整 HTML。登录流程中的局部交互如验证码刷新、错误提示通过 HTMX 局部刷新实现。
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `account_send_recover_message_task` | 找回请求成功后 | 异步发送邮件/短信 |
| `account_security_digest_task` | 定时任务 | 汇总锁定、失败、异常登录统计 |
### 8.2 登录页核心交互
## 9.2 Redis Key 规范
```html
<!-- 登录页accounts/login.html -->
<!-- 滑块验证码区域Alpine.js 管理状态) -->
<div x-data="captchaWidget()" x-init="loadCaptcha()">
<!-- 背景图 + 拼图 -->
<div class="captcha-container">
<img :src="backgroundSrc" alt="验证图片">
<div class="puzzle-piece"
:style="`left: ${slideX}px; top: ${gapY}px`"
:src="puzzleSrc">
</div>
</div>
<!-- 滑块轨道 -->
<div class="slider-track"
@mousedown="startSlide($event)"
@touchstart="startSlide($event)">
<div class="slider-thumb" :class="{'verified': passed, 'shake': failed}">
<span x-show="!passed && !failed">拖动完成拼图</span>
<span x-show="passed" class="text-green-500">验证通过 ✓</span>
</div>
</div>
<!-- 刷新按钮 -->
<button @click="loadCaptcha()" type="button">🔄</button>
</div>
<!-- 登录表单HTMX 提交) -->
<form hx-post="/api/auth/login/"
hx-target="#login-feedback"
hx-swap="innerHTML"
hx-indicator="#login-spinner">
<input type="hidden" name="captcha_pass_token" x-bind:value="passToken">
<input type="text" name="username" placeholder="请输入用户名">
<input type="password" name="password" placeholder="请输入密码">
<button type="submit" :disabled="!passed">登录</button>
</form>
<div id="login-feedback"></div>
<div id="login-spinner" class="htmx-indicator">登录中...</div>
```
> **Alpine.js 职责**:管理验证码状态(加载中/通过/失败)、滑动轨迹记录、`pass_token` 绑定到表单隐藏字段。
> **HTMX 职责**:表单提交、错误反馈局部渲染(`hx-target="#login-feedback"`)。
### 8.3 HTMX 响应片段规范
登录接口在 HTMX 请求时(`HX-Request: true` Header返回 HTML 片段而非 JSON`hx-target` 局部替换:
**登录成功**(服务端返回 302 重定向HTMX 通过 `HX-Redirect` Header 处理):
```python
# views/auth.py
if request.headers.get('HX-Request'):
response = HttpResponse()
response['HX-Redirect'] = '/dashboard/' # 跳转首页
return response
```
**登录失败**(返回错误提示 HTML 片段):
```html
<!-- 服务端渲染的错误片段 -->
<div class="text-red-500 text-sm mt-2">
用户名或密码错误,请重新输入
</div>
```
**初始密码状态**(登录成功但需修改密码):
```python
if request.headers.get('HX-Request'):
response = HttpResponse()
response['HX-Redirect'] = '/auth/password/change/'
return response
```
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:account:captcha:{challenge_id}` | 180s | 验证码挑战态 |
| `{schema}:account:captcha:pass:{token}` | 180s | 验证通过一次性票据 |
| `{schema}:account:login_fail:{username}` | 1800s | 登录失败计数 |
| `{schema}:account:recover:rate:{username}` | 3600s | 找回频控 |
---
## 九、Redis Key 规范
## 十、性能与可靠性约束
| 用途 | Key 格式 | 类型 | TTL | 说明 |
|------|----------|------|-----|------|
| 滑块验证会话(含缺口位置) | `captcha_session:{uuid}` | HASH | 3 分钟 | 存储 `gap_x`, `session_token`;验证后立即删除 |
| 滑块验证通过凭证 | `captcha_pass:{uuid}` | STRING | 3 分钟 | 登录接口验证后立即删除(单次有效) |
| 登录失败计数 | `login_fail:{tenant_id}:{username}` | STRING | 30 分钟 | 计数 ≥ 5 时触发锁定TTL 30 分钟自动清零 |
| 找回邮件发送频率 | `recover_email:{email}` | STRING | 1 小时 | 记录已发送次数,上限 3 次/小时 |
| 密码重置 Token 生成频率 | `recover_reset:{user_id}` | STRING | 1 小时 | 同一账号生成次数,上限 3 次/小时 |
| Tenant ID 限流 | `tenant_verify_ip:{ip}` | STRING | 1 分钟 | 计数 ≥ 10 时拒绝请求 |
> **故障恢复**Redis 重启后,登录失败计数归零(用户可正常登录);账号锁定状态由 `user_accounts.locked_until` 持久化保证,不依赖 Redis。
- 登录链路接口目标:`p95 < 300ms`(不含外部消息发送)
- 找回请求接口目标:`p95 < 400ms`(消息发送异步化)
- 缓存故障时系统应降级到 DB 校验,但保留频控硬兜底
- 验证码组件不可用时,应返回明确错误并禁止跳过登录防护
---
## 十、安全机制设计
## 十、安全与合规
### 10.1 滑块拼图验证码
- **图片生成**`Pillow` 从预置图库随机抽取背景图,服务端随机生成缺口位置,抠出缺口并生成拼图碎片,两者分别以 Base64 返回前端
- **缺口位置保护**`gap_x`(水平位置)仅存于服务端 Redis**不返回给前端**;前端通过 `slide_x` 提交,服务端对比 `gap_x` 校验
- **轨迹校验**(双重判断):
- **位置偏差**`abs(slide_x - gap_x) ≤ 5px`
- **轨迹特征**:速度变化曲线存在加速→减速(人类滑动特征),拒绝匀速/程序化轨迹
- **独立计数**:验证码失败**不计入**账号密码错误次数,两者独立计数
- **单次有效**`captcha_pass_token` TTL 3 分钟,登录接口校验后立即删除
### 10.2 账号锁定机制
```
同一账号连续密码错误 ≥ 5 次:
1. Redis `login_fail:{tenant_id}:{username}` 计数达到阈值
2. 更新 user_accounts.status = 'locked'
3. 设置 user_accounts.locked_until = now() + 30min
4. 锁定状态下,登录接口直接返回 ACCOUNT_LOCKED不再校验密码
解锁条件(任一满足):
A. locked_until 到期:应用层在下次登录时检测,自动恢复 status=active
B. Tenant Admin 手动解锁:调用 AuthService.unlock_account()
```
### 10.3 密码安全
| 规则 | 说明 |
|------|------|
| 存储哈希 | Django `PBKDF2+SHA256``make_password` |
| 传输安全 | 强制 HTTPS前端**不加密**密码HTTPS 层保证) |
| 复杂度 | 长度 8~32 位,必须包含字母(区分大小写)+ 数字;建议特殊符号(非强制) |
| 历史密码 | 不得与最近 3 次历史密码哈希相同(含系统固定初始密码) |
| Session 有效期 | 默认 8 小时;可由 Tenant Admin 在「系统设置」中调整 |
### 10.4 防枚举攻击设计
| 场景 | 防御措施 |
|------|---------|
| 登录失败 | 不区分「用户名错误」与「密码错误」,统一返回 `WRONG_CREDENTIALS` |
| 找回用户名/密码 | 无论邮箱/用户名是否存在,统一返回相同响应文案 |
| Tenant ID 验证 | 不区分「租户不存在」与「租户已禁用」IP 限流每分钟 ≤ 10 次 |
| 密码重置 Token | Token 使用 `secrets.token_urlsafe(64)` 生成86 字符),不可预测 |
### 10.5 密码重置流程安全要点
- Token 由 `secrets.token_urlsafe(64)` 生成86 字符,全局唯一
- 单次有效:使用后立即标记 `is_used=True`(先标记再执行,防止并发重放)
- 有效期 30 分钟(`expires_at = created_at + timedelta(minutes=30)`
- 重置成功后:清除该账号所有有效 Session强制重新登录
- 重置成功后:`is_initial_password = False`,写入 `PasswordHistory`
1. 密码仅允许 Django 安全哈希PBKDF2 / Argon2
2. 连续失败 N 次锁定(建议 5 次30 分钟)。
3. 重置令牌一次性使用,过期失效,不可复用。
4. 登出必须销毁会话,旧页面刷新需重定向登录。
5. 敏感字段仅脱敏返回,禁止明文日志输出。
---
## 十一、Electron 客户端约定
## 十二、错误码建议
| 约定项 | 规格 |
|--------|------|
| Tenant ID 存储 | `electron-store``app.getPath('userData')` + AES 加密文件,**不存明文** |
| Session Token 存储 | 内存(`global` 变量)+ Chromium `session` Cookie**不写入磁盘明文文件** |
| 登录页加载方式 | 主进程根据 Tenant ID 构建 `https://{tenant_slug}.fonrey.com/auth/login/`,通过 `BrowserWindow.loadURL()` 加载 |
| 多标签页 | 同一 `BrowserWindow` 内所有页面共享同一 Session Cookie |
| 客户端登出 | 调用 `POST /api/auth/logout/` 使服务端 Session 失效 + 清除 Chromium Session Cookie |
| 窗口关闭 | Session 保留(不自动登出),下次打开若 Session 未过期则直接进入系统 |
| 强制更新 | 客户端版本低于服务端 `min_required_version` 时,阻断登录流程,展示更新提示(详见发布管理模块 PRD |
| Tenant ID 缓存校验 | 非首次启动时,客户端向服务端发起缓存 Tenant ID 有效性校验(`POST /api/auth/tenant/verify/`);无效则清除缓存,重新显示 Tenant 识别界面 |
| code | HTTP | 场景 |
|---|---|---|
| `ACCOUNT_TENANT_INVALID` | 400 | tenant 无效或停用 |
| `ACCOUNT_CAPTCHA_INVALID` | 400 | 验证码失败/过期 |
| `ACCOUNT_LOGIN_INVALID_CREDENTIAL` | 401 | 账号或密码错误 |
| `ACCOUNT_LOCKED` | 423 | 账号锁定中 |
| `ACCOUNT_PASSWORD_WEAK` | 400 | 新密码不满足复杂度 |
| `ACCOUNT_RESET_TOKEN_INVALID` | 400 | 重置令牌无效/已使用 |
---
## 十二、多租户隔离要点
## 十三、测试映射P0
- `UserAccount``LoginAttempt``PasswordResetToken``PasswordHistory` 均位于**租户 Schema 内**,数据完全隔离
- `username` 唯一性约束在 Schema 维度生效,不同租户可以存在相同 username
- Tenant 验证接口(`/api/auth/tenant/verify/`)位于 **Public Schema**`shared_apps`),查询 `TenantModel`
- 登录等接口通过请求域名(`{tenant_slug}.fonrey.com`)自动切换 Schema`django-tenants` 中间件处理,**无需手动切换**
- 所有接口禁止跨租户数据访问ORM 查询范围自动限制在当前 Schema
| 场景 | 最低覆盖 |
|---|---|
| Tenant 校验 | 有效/无效/停用租户 |
| 登录链路 | 验证码通过、失败锁定、解锁后登录 |
| 找回流程 | 频控、令牌一次性、过期处理 |
| 首登改密 | 未改密不可进入业务页 |
| 登出 | 会话销毁、回退重定向 |
测试文件:`tests/integration/account/test_us_account.py`
---
## 十三、错误处理规范
## 十四、落地顺序建议
### 13.1 标准错误码
| Error Code | HTTP Status | 含义 | 前端显示文案 |
|------------|-------------|------|-------------|
| `WRONG_CREDENTIALS` | 200 | 用户名或密码错误 | 「用户名或密码错误,请重新输入」 |
| `ACCOUNT_LOCKED` | 200 | 账号已锁定 | 「账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁」 |
| `ACCOUNT_DISABLED` | 200 | 账号已停用 | 「账号已停用,请联系您的管理员」 |
| `CAPTCHA_INVALID` | 200 | 验证码凭证无效/已过期 | 「验证码已失效,请重新验证」 |
| `CAPTCHA_FAIL` | 200 | 滑块位置/轨迹校验失败 | 「验证失败,请重新拖动」 |
| `TENANT_NOT_FOUND` | 200 | Tenant ID 无效 | 「识别码无效,请联系您的系统管理员获取正确的识别码」 |
| `TOKEN_INVALID` | 200 | 重置 Token 无效或已使用 | 「链接已过期或已使用,请重新申请」 |
| `TOKEN_EXPIRED` | 200 | 重置 Token 已过期 | 「链接已过期,请重新申请」 |
| `PASSWORD_TOO_WEAK` | 200 | 密码不符合复杂度 | 逐条显示不满足的规则 |
| `PASSWORD_REUSED` | 200 | 密码与历史密码相同 | 「新密码不能与最近 3 次历史密码相同」 |
> **设计原则**:所有登录相关接口统一返回 HTTP 200通过 `error_code` 字段区分业务错误,避免 HTTP 状态码暴露系统行为(防止通过 4xx/5xx 枚举账号状态)。
### 13.2 异常监控
- 所有未预期异常5xx通过 Sentry 上报,含 `tenant_id``username`(脱敏)、堆栈信息
- 邮件发送 Celery 任务 3 次重试失败后,上报 Sentry 告警并记录 `task_id`,管理员可在系统后台查询
1. 先实现 tenant 校验 + 登录主链路
2. 再接验证码 + 失败锁定
3. 再实现找回用户名/密码
4. 最后补安全摘要任务与审计报表
---
## 十四、已知风险与缓解措施
## 十五、文档同步规则
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|---------|
| 滑块验证被机器模拟轨迹绕过 | 低 | 高 | 服务端同时校验位置偏差 + 轨迹曲线特征,拒绝匀速/程序化轨迹;后续可引入设备指纹 |
| Tenant ID 枚举攻击 | 低 | 中 | 限流(每 IP 每分钟 ≤ 10 次);响应不区分「未找到」与「已禁用」 |
| 密码重置 Token 泄露 | 低 | 高 | 单次有效 + 30 分钟过期 + HTTPS 传输 |
| 邮件发送失败 | 中 | 中 | 异步任务自动重试 3 次;失败写入 Sentry 告警;管理员可通过后台查看 Token 手动告知用户 |
| 多端并发登录 | 高(正常场景) | 低 | 本期允许v2 可在 Token 引入版本号实现踢出策略 |
| Redis 故障导致锁定状态丢失 | 低 | 中 | `locked_until` 字段持久化至 PostgreSQLRedis 故障不影响锁定判断 |
---
## 十五、开放问题(开发启动前必须确认)
| # | 问题 | 负责人 | 截止 |
|---|------|--------|------|
| 1 | 邮件服务商选型SendGrid / 阿里云邮件推送 / SMTP 自建? | 后端负责人 + 运维 | 开发启动前 |
| 2 | 滑块验证码方案自研Pillow还是第三方极验 / 网易易盾)? | 后端负责人 + 安全 | 开发启动前 |
| 3 | Session 有效期默认值 8 小时,是否允许 Tenant Admin 自行配置? | 产品经理 | 开发启动前 |
| 4 | 账号锁定后是否自动发邮件通知用户和/或管理员? | 产品经理 | 开发启动前 |
| 5 | 历史密码校验范围:最近 3 次是否足够?是否增加「不得与用户名相同」规则? | 产品经理 | 开发启动前 |
---
## 十六、明确禁止
- ❌ 不得使用 Django 原生 `User` 模型,必须扩展 `AbstractBaseUser`
- ❌ 不得在全局 Schema 创建 `UserAccount` 表(必须在租户 Schema 内)
- ❌ 不得明文存储或传输密码
- ❌ 不得在 `LoginAttempt` 记录中存储密码明文(含错误密码)
- ❌ 不得在前端做密码哈希HTTPS 层保证传输安全)
- ❌ 不得将 Session Token 写入 Electron 磁盘明文文件
- ❌ 不得在找回账号/密码响应中区分「邮箱存在」与「邮箱不存在」(防止枚举)
-`PasswordResetToken` 不得重复使用(`is_used=True` 后立即失效)
- ❌ 登录失败响应不得区分「用户名错误」与「密码错误」
- ❌ 不得将 `gap_x`(缺口水平位置)返回给前端(防止绕过验证)
- ❌ 耗时超过 500ms 的操作(如邮件发送)必须通过 Celery 异步执行,不得在请求线程中同步等待
- 登录数据结构调整:同步 `DATA_MODEL_LOGIN.md`
- 安全策略或门禁调整:同步 `权限管理系统技术方案.md`
- API 变更:同步本文件与登录 PRD 验收条目

View File

@@ -0,0 +1,265 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 系统管理(系统设置)技术方案
**版本**: 1.2
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
**关联 PRD**: `PRD/系统配置/系统配置模块PRD.md`
**关联数据模型**: `DATA_MODEL/DATA_MODEL_SETTING.md`(本方案不重复 DDL
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
**最后更新**: 2026-04-27
---
## 一、文档定位与边界
本文件仅定义系统设置模块的:
1. 页面与 API 端点设计
2. 配置读取服务与调用边界
3. 缓存一致性与审计策略
4. 错误码与测试映射
> 不在本文件展开 `lookup_groups/lookup_items/tenant_settings/field_requirement_rules` 的 DDL。数据结构以 `DATA_MODEL_SETTING.md` 为唯一权威。
---
## 二、范围定义US-SETTING-001
### 2.1 MVP 必须覆盖
- A管理员配置可选枚举值lookup items
- B管理员配置房源字段必填规则
- C管理员配置客源规则查重范围 + 必填字段)
### 2.2 预留(非本期强制)
- 财务/交易/合同参数配置
- 通知渠道与发布平台配置
- 高级规则引擎(跨模块联动)
---
## 三、模块架构边界
## 3.1 模块职责(`apps/setting`
- 为租户管理员提供配置台
- 为业务模块提供统一读取入口
- 负责写后缓存失效与审计记录
## 3.2 外部依赖
| 依赖模块 | 依赖内容 |
|---|---|
| `apps/property` | 读取房源来源与字段规则 |
| `apps/client` | 读取客源来源、跟进目的、查重规则 |
| `apps/permission` | 控制设置台与配置变更权限 |
| `core/cache.py` | 缓存键封装与失效工具 |
## 3.3 分层约束
- 业务模块禁止直查 setting 表
- 统一通过 `TenantSettingsService` 读取
- 设置写入后必须同步清理对应缓存键
---
## 四、API 设计原则
1. 固定枚举与可配置枚举分层管理。
2. 设置写入后下一次请求立即可见(主动失效)。
3. 所有配置接口默认管理员权限保护。
4. 批量规则写入以原子事务提交。
5. API 与 HTMX 响应都遵循统一错误码语义。
---
## 五、端点清单(核心)
## 5.1 页面路由SSR
| 路径 | 方法 | 权限 code | 说明 |
|---|---|---|---|
| `/setting/` | GET | `setting.console.view.allow` | 设置首页 |
| `/setting/lookup/` | GET | `setting.lookup.view.allow` | 枚举配置页 |
| `/setting/property-field-rules/` | GET | `setting.property_rules.view.allow` | 房源规则页 |
| `/setting/client-rules/` | GET | `setting.client_rules.view.allow` | 客源规则页 |
## 5.2 HTMX 片段端点
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/setting/fragments/lookup-groups/` | GET | 分组列表局刷 | HTML 片段 |
| `/setting/fragments/lookup-items/{group_id}/` | GET | 选项列表局刷 | HTML 片段 |
| `/setting/fragments/property-field-rule-matrix/` | GET | 规则矩阵局刷 | HTML 片段 |
| `/setting/fragments/client-rule-form/` | GET | 客源规则表单局刷 | HTML 片段 |
## 5.3 JSON APIMVP
| 端点 | 方法 | 权限 code | 说明 |
|---|---|---|---|
| `/api/setting/lookup/groups/` | GET | `setting.lookup.view.allow` | 获取分组 |
| `/api/setting/lookup/items/query/` | POST | `setting.lookup.view.allow` | 查询选项 |
| `/api/setting/lookup/items/` | POST | `setting.lookup.edit.allow` | 新增选项 |
| `/api/setting/lookup/items/{id}/` | PATCH | `setting.lookup.edit.allow` | 编辑选项 |
| `/api/setting/lookup/items/{id}/deactivate/` | POST | `setting.lookup.edit.allow` | 停用选项 |
| `/api/setting/lookup/items/reorder/` | POST | `setting.lookup.edit.allow` | 批量排序 |
| `/api/setting/property-field-rules/query/` | POST | `setting.property_rules.view.allow` | 查询规则矩阵 |
| `/api/setting/property-field-rules/batch-upsert/` | POST | `setting.property_rules.edit.allow` | 保存房源规则 |
| `/api/setting/client-rules/get/` | GET | `setting.client_rules.view.allow` | 获取客源规则 |
| `/api/setting/client-rules/update/` | POST | `setting.client_rules.edit.allow` | 更新客源规则 |
| `/api/setting/snapshot/` | GET | 已登录 | 业务录入统一快照入口 |
---
## 六、关键 API 规范(请求/响应)
## 6.1 新增可配置枚举选项
`POST /api/setting/lookup/items/`
```json
{
"module": "client",
"key": "source",
"value": "douyin",
"label_zh": "抖音",
"sort_order": 99
}
```
校验:
- 同组 `value` 不可重复
- `value` 限定 lowercase + `_`
## 6.2 批量保存房源字段规则
`POST /api/setting/property-field-rules/batch-upsert/`
```json
{
"module": "property",
"entity_type": "residential",
"trade_status": "sale",
"rules": [
{"field_key": "orientation", "requirement": "required"},
{"field_key": "parking_count", "requirement": "hidden"}
]
}
```
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求头:`HX-Request: true`
- 成功触发:`HX-Trigger` toast(success)
- 失败触发:`HX-Trigger` toast(error)
## 7.2 模板分片命名
- `templates/setting/fragments/lookup_groups.html`
- `templates/setting/fragments/lookup_items_table.html`
- `templates/setting/fragments/property_rule_matrix.html`
- `templates/setting/fragments/client_rule_form.html`
---
## 八、权限与数据范围
## 8.1 最小权限矩阵
| 能力 | 权限 code |
|---|---|
| 设置台查看 | `setting.console.view.allow` |
| 可配置枚举查看/编辑 | `setting.lookup.view.allow` / `setting.lookup.edit.allow` |
| 房源规则查看/编辑 | `setting.property_rules.view.allow` / `setting.property_rules.edit.allow` |
| 客源规则查看/编辑 | `setting.client_rules.view.allow` / `setting.client_rules.edit.allow` |
## 8.2 范围规则
- 设置项作用域默认为当前租户
- 不允许跨租户读取或写入配置
---
## 九、异步任务与缓存策略
## 9.1 异步任务(可选)
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `setting_rebuild_lookup_cache_task` | 大批量导入后 | 后台预热常用 lookup 缓存 |
## 9.2 Redis Key 规范
| Key | TTL | 触发失效 |
|---|---|---|
| `{schema}:setting:lookup:{module}.{key}` | 300s | lookup 新增/编辑/停用/排序 |
| `{schema}:setting:field_req:{module}.{entity_type}.{trade_status}` | 300s | 字段规则保存 |
| `{schema}:setting:kv:{category}.{key}` | 300s | tenant_settings 更新 |
| `{schema}:setting:client_rules` | 300s | client rules 更新 |
---
## 十、性能与可靠性约束
- 设置读取接口目标:`p95 < 150ms`(命中缓存)
- 批量规则写入目标:`p95 < 500ms`(中等规模)
- 缓存失效失败需上报 Sentry不回滚已提交事务
- 读路径在缓存不可用时可退化到 DB 查询
---
## 十一、安全与合规
1. 配置写接口仅管理员可用。
2. 每次写操作记录审计操作者、配置域、前后值、IP、时间。
3. 系统预置项(`is_system=true`)不可删除,可停用。
4. 历史业务数据不得因枚举停用被破坏。
---
## 十二、错误码建议
| code | HTTP | 场景 |
|---|---|---|
| `SETTING_LOOKUP_GROUP_NOT_FOUND` | 404 | 分组不存在 |
| `SETTING_LOOKUP_VALUE_DUPLICATED` | 409 | 选项 value 冲突 |
| `SETTING_LOOKUP_SYSTEM_ITEM_DELETE_FORBIDDEN` | 403 | 尝试删除系统预置项 |
| `SETTING_RULE_INVALID_REQUIREMENT` | 400 | 规则值非法 |
| `SETTING_PERMISSION_DENIED` | 403 | 权限不足 |
---
## 十三、测试映射P0
| 子场景 | 最低覆盖 |
|---|---|
| 001-A 参数配置 | 分组加载、增改停用、排序、生效验证 |
| 001-B 房源字段规则 | 查询矩阵、批量保存、录入页规则生效 |
| 001-C 客源规则 | 查重范围更新、必填更新、录入页生效 |
| 缓存一致性 | 写入后缓存失效,下一次读取为最新值 |
测试文件:`tests/integration/setting/test_us_setting.py`
---
## 十四、落地顺序建议
1. 先实现 `TenantSettingsService` 与失效框架
2. 再做 lookup items001-A
3. 再做 property field rules001-B
4. 最后做 client rules001-C与业务接线
---
## 十五、文档同步规则
- 新增/调整可配置枚举域:同步 `DATA_MODEL/ENUMS.md`
- 新增 setting key同步 `DATA_MODEL_SETTING.md`
- API 变更:同步本文件与系统配置 PRD

View File

@@ -0,0 +1,352 @@
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 组织人事技术方案
**版本**: 1.0
**项目**: Fonrey 房产经纪管理系统
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
**关联 PRD**: `PRD/组织人事管理/组织人事管理模块PRD.md`v1.2
**关联数据模型**: `DATA_MODEL/DATA_MODEL_ORG.md`(本方案不重复 DDL
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
**最后更新**: 2026-04-27
---
## 一、文档定位与边界
本文件只定义组织人事模块的:
1. 服务边界与模块协作
2. API 端点设计(重点)
3. HTMX 局刷协议
4. 权限接入、异动审计、缓存与性能约束
5. 测试与验收映射
> **不在本文件展开**`org_units/staff/staff_transfer_logs/staff_reward_punish/staff_accounts` 结构细节。以 `DATA_MODEL_ORG.md` 为唯一权威。
---
## 二、范围定义(以 TASK.md 为准)
### 2.1 P0 必须覆盖
- US-ORG-001管理员维护公司组织结构部门/门店树)
- US-ORG-002管理员查看与维护员工列表
- US-ORG-003管理员办理员工入职并创建系统账号
### 2.2 P1/P2 预留
- US-ORG-010员工离职与调动
- US-ORG-011员工通讯录
- US-ORG-012员工职务管理
- US-ORG-020全局异动记录
- US-ORG-021员工奖惩记录
- US-ORG-022门店分布地图
---
## 三、模块架构边界
## 3.1 模块职责(`apps/org`
- 管理组织树(公司→事业部→大区→区域→片区→门店→店组→职能)
- 管理员工主档(任职、联系方式、账号、状态)
- 管理入职与账号开通主流程
- 维护人事异动审计链路(入职/调动/离职/复职等)
## 3.2 外部依赖
| 依赖模块 | 依赖内容 | 依赖方式 |
|---|---|---|
| `apps/account` | 新员工账号创建、初始密码策略、登录状态联动 | Service 调用 |
| `apps/permission` | 组织/员工管理权限校验 | PermissionChecker |
| `apps/property` | 员工离职/调动时业务归属迁移(预留) | 异步任务 |
| `apps/client` | 客源归属迁移(预留) | 异步任务 |
| `core/encryption.py` | 手机号/证件号加密存储与脱敏 | 统一工具 |
| `core/cache.py` | 组织树缓存、员工选择器缓存 | Redis |
| `Celery` | 批量导出、跨模块归属迁移、异动统计重算 | 异步任务 |
## 3.3 分层约束
- `views.py` 仅做参数校验、权限门禁、响应组装
- 组织层级规则(店组必须挂门店、经纪人归属限制)必须在 `services/` 强校验
- 员工状态变更必须联动写 `staff_transfer_logs`(不可删除)
- 与账号相关操作通过 `AccountService`,禁止跨模块直写登录表
---
## 四、API 设计原则
1. 页面路由与数据 API 分离:
- 页面:`/org/...`
- 数据:`/api/org/...`
2. 组织树、员工列表、异动记录采用 HTMX 局刷,减少全页刷新。
3. 手机号/证件号默认脱敏展示,明文查看须权限与审计。
4. 账号状态(启用/冻结)与员工状态变更必须一致性联动。
5. 所有异动事件统一错误协议与日志结构,便于审计与报表。
---
## 五、端点清单(核心)
## 5.1 页面路由SSR + HTMX 容器)
| 路径 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/org/structure/` | GET | 是 | 组织结构主页面(组织树 + 员工列表) |
| `/org/departments/create/` | GET | 是 | 新增部门页面 |
| `/org/departments/{org_id}/` | GET | 是 | 部门详情 |
| `/org/departments/{org_id}/edit/` | GET | 是 | 编辑部门 |
| `/org/staff/{staff_id}/` | GET | 是 | 员工详情页(多 Tab |
| `/org/transfer-logs/` | GET | 是 | 全局异动记录页(预留) |
## 5.2 HTMX 片段端点
| 路径 | 方法 | 用途 | 返回 |
|---|---|---|---|
| `/org/fragments/org-tree/` | GET | 左侧组织树局刷 | HTML |
| `/org/fragments/staff-table/` | POST | 员工列表筛选/分页局刷 | HTML |
| `/org/staff/{id}/fragments/tab/{tab}/` | GET | 员工详情 Tab 懒加载 | HTML |
| `/org/fragments/department-staff-selector/` | GET | 选择器弹层局刷 | HTML |
> fragment 端点必须校验 `HX-Request=true`,否则 400。
## 5.3 JSON APIP0
| 端点 | 方法 | 权限 code建议 | 说明 |
|---|---|---|---|
| `/api/org/departments/tree/` | GET | `hr.org.view.scope` | 组织树加载US-001 |
| `/api/org/departments/` | POST | `hr.org.edit.allow` | 新增部门US-001 |
| `/api/org/departments/{id}/` | PATCH | `hr.org.edit.allow` | 编辑部门US-001 |
| `/api/org/departments/{id}/close/` | POST | `hr.org.edit.allow` | 关闭部门 |
| `/api/org/staff/query/` | POST | `hr.staff.view.scope` | 员工列表查询US-002 |
| `/api/org/staff/{id}/detail/` | GET | `hr.staff.view.scope` | 员工详情US-002 |
| `/api/org/staff/onboard/` | POST | `hr.staff.create.allow` | 办理入职+建账号US-003 |
| `/api/org/staff/{id}/base-info/` | PATCH | `hr.staff.edit.allow` | 编辑员工基础信息US-002 |
| `/api/org/staff/{id}/account/freeze/` | POST | `hr.staff.account.manage.allow` | 冻结账号(预留) |
| `/api/org/staff/{id}/account/unfreeze/` | POST | `hr.staff.account.manage.allow` | 解冻账号(预留) |
| `/api/org/staff/{id}/phone/view/` | POST | `hr.staff.phone.view.allow` | 查看完整手机号(审计) |
---
## 六、关键 API 规范(请求/响应)
## 6.1 新增部门
`POST /api/org/departments/`
```json
{
"name": "都市港湾店二组",
"parent_id": "uuid",
"type": "group",
"address_city": "上海",
"address_district": "闵行区",
"address_detail": "XX路XX号",
"manager_id": "uuid"
}
```
规则:
- `type=group` 时父节点必须为 `store`
- 违反层级约束返回 400
## 6.2 员工列表查询
`POST /api/org/staff/query/`
```json
{
"keyword": "张三",
"filters": {
"org_unit_ids": ["uuid"],
"status": ["active", "probation"],
"job_category": ["置业顾问"]
},
"sort": {"field": "updated_at", "order": "desc"},
"pagination": {"mode": "keyset", "cursor": "opaque_cursor", "limit": 20}
}
```
## 6.3 办理入职并创建账号
`POST /api/org/staff/onboard/`
```json
{
"name": "李雷",
"phone": "13800000000",
"org_unit_id": "uuid",
"job_title": "高级业务员",
"job_category": "置业顾问",
"role_codes": ["agent"],
"supervisor_id": "uuid"
}
```
成功 `201`
```json
{
"staff_id": "uuid",
"user_id": 12345,
"initial_password_sent": true,
"message": "入职办理成功"
}
```
联动要求:
- 创建 `staff`
- 创建/绑定 `auth_user`
- 写入一条 `staff_transfer_logs(transfer_type='onboard')`
## 6.4 查看员工手机号(审计)
`POST /api/org/staff/{id}/phone/view/`
```json
{
"reason": "入职资料核对"
}
```
规则:
- 必须有 `hr.staff.phone.view.allow`
- 返回明文仅本次有效
- 必须写入审计日志(操作者、时间、原因)
---
## 七、HTMX 交互约定
## 7.1 Header 约定
- 请求:`HX-Request: true`
- 成功:`HX-Trigger: {"toast":{"level":"success","message":"操作成功"}}`
- 失败:`HX-Trigger: {"toast":{"level":"error","message":"操作失败"}}`
- 跳转:`HX-Redirect: /org/staff/{id}/`
## 7.2 模板分片命名
| 场景 | 模板 |
|---|---|
| 组织树 | `templates/org/fragments/org_tree.html` |
| 员工列表 | `templates/org/fragments/staff_table.html` |
| 员工详情-异动记录 | `templates/org/fragments/staff_transfer_logs.html` |
| 部门人员选择器 | `templates/org/fragments/department_staff_selector.html` |
---
## 八、权限与数据范围
## 8.1 最小权限矩阵P0 建议)
| 能力 | 权限 code |
|---|---|
| 查看组织结构范围 | `hr.org.view.scope` |
| 编辑组织结构 | `hr.org.edit.allow` |
| 查看员工列表范围 | `hr.staff.view.scope` |
| 新建员工 | `hr.staff.create.allow` |
| 编辑员工 | `hr.staff.edit.allow` |
| 账号冻结/恢复 | `hr.staff.account.manage.allow` |
| 查看员工手机号 | `hr.staff.phone.view.allow` |
## 8.2 数据范围叠加
最终可见数据 = `scope 过滤``员工状态过滤``组织层级过滤`
- 系统管理员可走 `is_system_admin` 短路
- 普通管理员按 scope 限制组织子树
---
## 九、异步任务与缓存策略
## 9.1 Celery 任务
| 任务 | 触发时机 | 说明 |
|---|---|---|
| `org_staff_export_task` | 导出员工列表 | 异步导出 Excel |
| `org_transfer_affiliation_task` | 离职/调动后(预留) | 迁移房源/客源归属 |
| `org_transfer_summary_task` | 每日定时 | 异动统计重算 |
## 9.2 Redis Key 规范
| Key | TTL | 说明 |
|---|---|---|
| `{schema}:org:tree:{staff_id}` | 300s | 组织树缓存 |
| `{schema}:org:staff:list:{hash}` | 60s | 员工筛选缓存 |
| `{schema}:org:staff:detail:{staff_id}` | 120s | 员工详情聚合缓存 |
| `{schema}:org:selector:staff:{org_unit_id}` | 300s | 选择器缓存 |
写操作成功后必须主动失效相关 key。
---
## 十、性能与可靠性约束
1. 组织树查询基于 `path` 前缀与索引,避免递归 N+1。
2. 员工列表采用 Keyset 分页,避免深分页性能退化。
3. 员工详情按 Tab 懒加载,避免一次加载全量档案。
4. 批量操作(导入/导出/调动)需限流与幂等控制。
---
## 十一、安全与合规
1. 手机号与证件号全程加密存储,前端默认脱敏。
2. 以下动作必须写 `staff_transfer_logs`:入职、调动、离职、复职、上级变更、冻结/解冻。
3. `staff.status='resigned'``staff.status='frozen'` 时,`auth_user.is_active=False`
4. 恢复在职后才可重新启用账号。
---
## 十二、错误码建议
| code | HTTP | 场景 |
|---|---|---|
| `ORG_UNIT_NOT_FOUND` | 404 | 部门不存在或无权限 |
| `ORG_INVALID_HIERARCHY` | 400 | 组织层级违反规则(如店组不挂门店) |
| `STAFF_NOT_FOUND` | 404 | 员工不存在或无权限 |
| `STAFF_PHONE_DUPLICATED` | 409 | 手机号冲突 |
| `STAFF_ACCOUNT_CREATE_FAILED` | 500 | 账号创建失败 |
| `ORG_PERMISSION_DENIED` | 403 | 权限不足 |
---
## 十三、测试映射
### 13.1 P0 User Story 映射
| User Story | 最低覆盖 |
|---|---|
| US-ORG-001 | 组织树查询、新增/编辑部门、层级约束校验 |
| US-ORG-002 | 员工列表筛选、详情加载、权限过滤 |
| US-ORG-003 | 入职建档+账号创建、必填校验、异动日志写入 |
测试文件:`tests/integration/org/test_us_org.py`
### 13.2 强制测试约束
- 集成测试使用 `TenantClient`
- HTMX 请求携带 `HTTP_HX_REQUEST=true`
- 权限三态200 / 403 / 302
- 外部服务(短信/邮件/Redis/Celery全部 mock
---
## 十四、落地顺序建议
1. 先实现组织树与员工列表查询US-ORG-001/002
2. 再实现入职流程与账号开通US-ORG-003
3. 补齐审计与缓存失效机制
4. 再推进离职/调动、通讯录、奖惩等 P1/P2 能力
---
## 十五、文档同步规则
- 枚举变更:同步 `DATA_MODEL/ENUMS.md`
- 权限 code 变更:同步 `DATA_MODEL/DATA_MODEL_PERMISSION.md`
- 数据结构变更:同步 `DATA_MODEL/DATA_MODEL_ORG.md`
- API 变更:同步本文件与 `PRD/TASK.md` 对应条目

View File

@@ -0,0 +1,64 @@
# Fonrey P0 UI 设计任务总表(追踪版)
> 目标:让 `PRD/TASK.md` 中 P0 任务都有对应 UI 设计,并按统一流程逐个落地。
> 协作方式:**先 UI.md → 再静态页 HTML → 你评审反馈 → 我修改并回写 UI.md**。
---
## 1) 状态字段定义(统一)
- `待设计`:还未开始产出 UI.md
- `设计中`:正在输出/修改 UI.md
- `待评审`UI.md 或静态页已提交,等待你反馈
- `修改中`:根据你反馈进行迭代
- `已完成`UI.md 与静态页均通过评审并归档
---
## 2) 单任务标准流程(固定)
1. 读取对应 PRD/TASK/现有 UI_SYSTEM 规范与竞品截图(你提供)
2. 产出该任务 `*_UI.md`(信息架构、字段、状态、交互、异常态、组件映射)
3. 基于 UI.md 产出静态页 `*.html`
4. 你评审静态页并给反馈
5. 我按反馈修改静态页 + 回写 UI.md
6. 该任务标记为 `已完成`
> 设计基线:所有新页面默认包含 **Light / Dark / System** 主题切换方案与状态说明。
---
## 3) P0 缺口任务(按优先级执行)
| 序号 | 优先级 | 模块 | 覆盖 US | UI.md 目标文件 | HTML 目标文件 | 当前状态 | 下一步 |
|---|---|---|---|---|---|---|---|
| 01 | P0-A | 登录管理 | US-ACCOUNT-001~003 | `UI_DESIGN/登录管理/登录_UI.md` | `UI_DESIGN/登录_UI.html` | 设计中 | 先输出登录 UI.md第一个任务 |
| 02 | P0-A | 房源管理(新增) | US-PROPERTY-001 | `UI_DESIGN/房源管理/新增房源_UI.md` | `UI_DESIGN/新增房源_UI.html` | 待设计 | 完成任务01后开始 |
| 03 | P0-A | 房源管理(详情) | US-PROPERTY-003~008 | `UI_DESIGN/房源管理/房源详情_UI.md` | `UI_DESIGN/房源详情_UI.html` | 待设计 | 完成任务02后开始 |
| 04 | P0-B | 楼盘管理(列表) | US-COMPLEX-002 | `UI_DESIGN/楼盘管理/楼盘列表_UI.md` | `UI_DESIGN/楼盘列表_UI.html` | 待设计 | 完成任务03后开始 |
| 05 | P0-B | 楼盘管理(详情/维护) | US-COMPLEX-001 | `UI_DESIGN/楼盘管理/楼盘详情_UI.md` | `UI_DESIGN/楼盘详情_UI.html` | 待设计 | 完成任务04后开始 |
| 06 | P0-B | 楼盘管理(区域) | US-COMPLEX-003 | `UI_DESIGN/楼盘管理/区域管理_UI.md` | `UI_DESIGN/区域管理_UI.html` | 待设计 | 完成任务05后开始 |
| 07 | P0-C | 组织人事 | US-ORG-001~003 | `UI_DESIGN/组织人事管理/组织人事_UI.md` | `UI_DESIGN/组织人事_UI.html` | 待设计 | 完成任务06后开始 |
| 08 | P0-C | 权限管理 | US-PERMISSION-001~005 | `UI_DESIGN/权限管理/权限管理_UI.md` | `UI_DESIGN/权限管理_UI.html` | 待设计 | 完成任务07后开始 |
| 09 | P0-C | 系统配置 | US-SETTING-001-A/B/C | `UI_DESIGN/系统配置/系统配置_UI.md` | `UI_DESIGN/系统配置_UI.html` | 待设计 | 完成任务08后开始 |
---
## 4) 已有 UI保留并在后续做回归校对
| 模块 | 现有 UI.md | 当前状态 | 说明 |
|---|---|---|---|
| 客源管理 | `UI_DESIGN/客源管理/新增客源_UI.md` | 已有 | 后续按你反馈可增量调整 |
| 客源管理 | `UI_DESIGN/客源管理/客源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
| 客源管理 | `UI_DESIGN/客源管理/客源详情_UI.md` | 已有 | 后续按你反馈可增量调整 |
| 客源管理 | `UI_DESIGN/客源管理/编辑客源_UI.md` | 已有 | `US-CLIENT-014` 的新房/租房 Tab 边界待确认 |
| 房源管理 | `UI_DESIGN/房源管理/房源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
---
## 5) 本周执行节奏(建议)
- 每次只推进 1 个任务单元(避免并行导致反馈混淆)
- 每个任务至少经过 1 轮“静态页评审反馈”再收口
- 每完成 1 个任务,我会即时更新本表状态

View File

@@ -1,101 +0,0 @@
# Fonrey Web Coding 开工前缺失清单
> 记录时间2026-04-26
> 目的:在开始 Web Coding 落地前,把缺失但会直接卡住实施的文档一次补齐。
## 一、必须补齐的 6 份关键文档
### 1. 项目入口文档
- README.md / 项目总览
- 本地启动步骤
- 环境变量说明
- 数据库初始化与迁移说明
- 种子数据说明
### 2. ADR 架构决策记录
需要冻结以下决策:
- 登录态session / token / 混合方案
- django-tenants 本地开发模式
- 单租户 MVP 与多租户架构共存方式
- HTMX 页面局刷约定
- 目录结构最终落点
- 状态机与枚举字典权威来源
### 3. 枚举字典 / 状态字典
建议新增:
- ENUMS.md
- STATE_MACHINE.md
至少冻结:
- 客源状态
- 客源等级
- 房源状态
- 操作日志类型
- 租户状态机
要求统一:
- 中文值
- 英文值
- 数据库 CHECK 值
- UI 展示值
- 允许的状态迁移
### 4. 页面路由 + 组件映射
需要明确:
- 每个模块有哪些页面
- 每个页面对应什么 URL
- 每个页面复用哪些组件
- 哪些页面是列表 / 详情 / 弹窗 / 抽屉 / partial
- 每个页面的空态、加载态、错误态、权限态
### 5. API 契约规范
需要明确:
- 请求 / 响应格式
- 错误码规范
- 分页规范
- 搜索 / 筛选规范
- 上传规范
- 文件下载规范
- 权限拒绝返回规范
### 6. 本地开发与验证手册
需要明确:
- 本地环境启动方式
- PostgreSQL / Redis / Celery 启动方式
- django-tenants 初始化方式
- 测试租户创建方式
- 管理员账号 seed 方式
- 静态资源与对象存储本地替代方案
- dev / staging / production 配置差异
## 二、建议优先级
### P0先补不补就不能稳定开工
- 项目入口文档
- ADR
- 枚举字典 / 状态字典
- 页面路由 + 组件映射
### P1随后补直接影响实现质量
- API 契约规范
- 本地开发与验证手册
## 三、当前项目的直接风险
- 需求、数据模型、任务表已经较完整,但“可执行工程包”还不够
- Review 已指出枚举不一致、分页规范缺位、性能基准缺位等问题
- 没有启动手册和种子数据Web Coding 容易停留在文档层,无法稳定进入实现层
## 四、建议的落地顺序
1. 先补 README / 启动手册
2. 冻结 ADR
3. 冻结 ENUMS / STATE_MACHINE
4. 补页面路由与组件映射
5. 补 API 契约
6. 补本地开发与验证手册
7. 再开始正式 Web Coding
---
这份清单的目标不是增加文档数量,而是减少实现时的来回返工。

View File

@@ -0,0 +1,236 @@
#hermes #agent #Configuration
> 记录时间2026-04-27
> 场景Vibe Coding 多角色 Agent 配置
---
## 一、给 Agent 设置预设角色和项目背景
### 问题
每次对话都要在提示词里重复角色定义和项目背景,效率低。
### 解决方案:两个配置文件分工
#### `SOUL.md` — 角色人设(全局生效,跟着 Agent 走)
**路径:** `~/.hermes/SOUL.md`(默认 profile`~/.hermes/profiles/<名字>/SOUL.md`
适合放:
- 角色定位、身份定义
- 思维方式、技术偏好
- 沟通风格、语言习惯
```markdown
# 角色
你是一位资深软件架构师,有 10 年以上分布式系统设计经验。
## 核心职责
在任何 coding 任务开始前,先:
1. 分析需求,识别潜在的设计问题
2. 提出 2-3 种技术方案并给出 Trade-off 分析
3. 明确模块边界和接口设计
## 沟通风格
- 直接说结论,再给理由
- 用中文回复
```
#### `AGENTS.md` — 项目背景(项目级生效,放在项目根目录)
**路径:** `~/your-project/AGENTS.md`
适合放:
- 技术栈、架构说明
- 开发约定、命名规范
- 当前阶段、注意事项
```markdown
# 项目背景
## 技术架构
- Frontend: Next.js 14 (App Router) + Tailwind
- Backend: Python FastAPI部署在 Docker
- 数据库: PostgreSQLORM 用 SQLAlchemy
## 开发约定
- API 统一返回 `{data, error, meta}` 结构
- 不直接修改 migration 文件,用 Alembic 命令
```
#### 优先级总结
|内容类型|放哪里|
|---|---|
|角色人格、沟通风格|`SOUL.md`|
|项目架构、技术栈、约定|`AGENTS.md`(项目根目录)|
|临时切换风格|`/personality teacher` (会话级)|
---
## 二、创建多个独立 AgentProfiles
### 问题
想用不同角色做 vibe coding默认只有一个 Agent。
### 解决方案:使用 `hermes profile` 创建隔离实例
每个 Profile 拥有独立的SOUL.md、配置、记忆、会话历史、技能。
#### 创建 Profile
```bash
# 克隆当前配置(推荐,省去重新配置模型和 API key
hermes profile create nova --clone
hermes profile create werner --clone
# 给每个 profile 写专属的 SOUL.md
vim ~/.hermes/profiles/nova/SOUL.md
vim ~/.hermes/profiles/werner/SOUL.md
```
#### 启动方式
```bash
nova # 直接用 alias 启动Hermes 自动创建)
werner # 同上
hermes -p nova chat # 临时使用某个 profile不切换默认
```
#### 常用管理命令
```bash
hermes profile list # 查看所有 profiles
hermes profile show nova # 查看某个 profile 详情
hermes profile rename nova pm # 重命名
hermes profile delete nova # 删除(含所有记忆和会话,不可恢复)
hermes profile export nova # 备份
hermes -p nova doctor # 诊断某个 profile
```
#### 角色命名参考
|角色|推荐名字|来源|
|---|---|---|
|产品经理|`marty`|Marty Cagan《Inspired》作者|
|架构师|`werner`|Werner VogelsAmazon CTO|
|产品经理(备选)|`nova`|前瞻、探索感|
|架构师(备选)|`atlas`|扛起系统架构|
---
## 三、Telegram Bot 配置后无响应
### 问题
给新 Profile 配了 Telegram bot token在 Telegram 上发消息没有任何反应。
### 排查顺序
**1. Gateway 没有启动(最常见)**
配置 token ≠ gateway 在运行,新 profile 需要单独启动。
```bash
hermes -p nova gateway status # 检查状态
hermes -p nova gateway start # 启动
hermes -p nova gateway # 前台运行,实时看日志
hermes -p nova gateway install # gateway service 没有安装的前提下
```
**2. Token 没配在正确的 Profile 下**
每个 profile 有独立的 `.env`,默认 profile 的 token 不会共享。
```bash
cat ~/.hermes/profiles/nova/.env | grep TELEGRAM
# 应看到TELEGRAM_BOT_TOKEN=xxx
```
**3. 用户 ID 不在白名单**
Hermes 默认拒绝所有未授权用户的消息(安全默认值)。
```bash
# 在 .env 里加上自己的 Telegram User ID
TELEGRAM_ALLOWED_USERS=你的TelegramUserID
```
> 不知道自己 ID在 Telegram 搜索 `@userinfobot` 发消息获取。
**4. 同一个 token 被两个 gateway 同时使用**
会导致两个 gateway 互相冲突,都无法正常收消息。每个 Profile 必须使用独立的 Telegram Bot在 BotFather 分别创建)。
---
## 四、Gateway 连接 Telegram 超时(国内网络)
### 问题
Gateway 服务正常运行,但日志反复出现:
```
WARNING: Primary api.telegram.org connection failed
WARNING: Fallback IP 149.154.167.220 failed
Connect attempt N/8 failed: Timed out
```
### 原因
Telegram 在国内被封gateway 无法直连。
### 解决方案:配置代理
编辑对应 profile 的 `.env`
```bash
vim ~/.hermes/profiles/nova/.env
```
添加代理配置(端口换成本地代理实际端口):
```bash
# HTTP 代理Clash 默认 7890
HTTPS_PROXY=http://127.0.0.1:7890
HTTP_PROXY=http://127.0.0.1:7890
# SOCKS5 代理v2rayN 默认 10809
HTTPS_PROXY=socks5://127.0.0.1:7890
HTTP_PROXY=socks5://127.0.0.1:7890
```
重启 gateway
```bash
hermes -p nova gateway stop
hermes -p nova gateway start
hermes -p nova logs --tail # 观察是否连接成功
```
> **注意:** 每个 profile 的 `.env` 需要单独配置,互不共享。
---
## 快速参考:文件路径总览
```
~/.hermes/
├── SOUL.md # 默认 profile 的角色人设
├── profiles/
│ ├── nova/
│ │ ├── SOUL.md # nova 的角色人设(产品经理)
│ │ └── .env # nova 的 token、代理、白名单配置
│ └── werner/
│ ├── SOUL.md # werner 的角色人设(架构师)
│ └── .env # werner 的配置
~/your-project/
└── AGENTS.md # 项目背景(每个项目独立维护)
```