文档修改

This commit is contained in:
Shen Wei
2026-04-29 15:43:49 +08:00
parent c3f9de5f9f
commit b2aadf771a
28 changed files with 7502 additions and 109 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.2 **最后更新**: 2026-04-27
**版本**: 2.3 **最后更新**: 2026-04-29
**定位**: 本文档是 Fonrey 项目技术栈的**总索引**。所有跨模块的技术决策、版本约束、目录规范、禁止项在此定稿;**单一模块的具体技术方案**数据模型、服务层、HTMX 交互、Celery 任务等)见各自子文档(见 §9 索引)。
---
@@ -94,6 +94,9 @@ apps/property/
- ❌ 客户端内嵌业务逻辑或本地数据库(壳应用原则)
- ❌ 跨租户 SQL 查询(必须经 `django-tenants` 中间件切换 Schema
- ❌ 在代码中硬编码密钥、Tenant ID、URL
- ❌ Celery 任务内手写 `connection.set_schema(...)`(必须用 `@tenant_task` 装饰器,见 §12
- ❌ 业务视图/服务层直接调用 `<Model>.objects.filter/get/all(...)`(必须用 `Model.scoped(staff)`,见 §14
- ❌ R2 对象 key 使用原始文件名或 tenant_idUUID前缀必须按 §13 路径模板)
---
@@ -238,4 +241,212 @@ Fonrey 采用 AI vibe coding 模式开发,测试是保证每日迭代质量的
- 测试规范变更须同步更新 §10 关键结论,完整细节在 [`测试规范.md`](./测试规范.md) 中维护
- 15 章节统一模板发生变更时,须先更新 §9 标准章节骨架,再同步各模块文档
---
## 12. Celery 多租户规范M-12
> **For AI assistants**: Every Celery task that touches tenant data MUST use the `@tenant_task` decorator defined here. No exceptions.
### 12.1 背景与风险
多模块技术方案均声明 Celery 任务签名带 `tenant_schema_name: str`,但 **缺乏统一封装**
Celery worker 复用进程池,相邻任务若未正确切换 `search_path`,会产生 **跨租户脏读/脏写**,且不报错。
### 12.2 `@tenant_task` 装饰器规范
位置:`core/celery_utils.py`(由架构师统一提供,禁止各模块自己实现)
```python
# core/celery_utils.py
import functools
import structlog
from django_tenants.utils import schema_context
from celery import current_task
logger = structlog.get_logger(__name__)
def tenant_task(schema_arg: str = "tenant_schema_name"):
"""
装饰器:在 Celery 任务执行前自动切换租户 Schema结束/异常后还原为 public。
使用方式:
@shared_task
@tenant_task(schema_arg="tenant_schema_name")
def export_properties(tenant_schema_name: str, ...):
... # 此处 search_path 已切换到目标 schema
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
schema = kwargs.get(schema_arg)
if not schema:
# 位置参数兼容:尝试从 args 中读取(按函数签名顺序)
import inspect
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if schema_arg in params:
idx = params.index(schema_arg)
schema = args[idx] if idx < len(args) else None
if not schema:
raise ValueError(
f"[tenant_task] '{schema_arg}' 参数缺失,任务 {func.__name__} 拒绝执行"
)
log = logger.bind(task=func.__name__, task_id=current_task.request.id, schema=schema)
log.info("tenant_task.start")
try:
with schema_context(schema):
result = func(*args, **kwargs)
log.info("tenant_task.success")
return result
except Exception as exc:
log.error("tenant_task.error", exc=str(exc))
raise
return wrapper
return decorator
```
### 12.3 强制约束
| 约束 | 说明 |
|---|---|
| **所有业务 Celery 任务** 必须使用 `@tenant_task` | 包括导出、图片处理、智能配房、分区维护任务 |
| 任务签名 **必须含 `tenant_schema_name: str`** 形参 | 位置或关键字均可,`schema_arg` 参数可自定义名称 |
| 装饰器顺序:先 `@shared_task`,后 `@tenant_task` | 确保 Celery 正常注册任务名 |
| **禁止** 在任务内部手写 `connection.set_schema(...)` | 统一走装饰器,禁止散落手写 |
| 平台级无租户任务(如 `partition_maintenance_task`| 直接 `with schema_context(target_schema):` 循环,不需要此装饰器 |
### 12.4 测试补充规范
- Celery 任务测试(`CELERY_TASK_ALWAYS_EAGER = True`**必须**断言 `schema_context` 被以目标 schema 调用
- 可用 `unittest.mock.patch("core.celery_utils.schema_context")` 拦截,断言 `call_args`
- 反例测试:传入空 `tenant_schema_name` 时,任务必须抛出 `ValueError`,不得静默执行
---
## 13. R2 对象存储路径规范M-13
> **For AI assistants**: All R2 object keys MUST follow the template table below. Never invent custom prefixes.
### 13.1 路径模板表
| 资源类型 | Key 模板 | 访问方式 | TTL / 生命周期 |
|---|---|---|---|
| **客户端发布包** | `releases/system/{version}/{filename}` | public-read | 永久(不自动删除) |
| **租户备份** | `backups/{tenant_schema}/{record_id}.tar.gz` | signed URL only | 90 天自动删除 |
| **租户导出** | `exports/{tenant_schema}/{task_id}.zip` | signed URL 24h | 7 天自动删除 |
| **房源图片** | `media/{tenant_schema}/property/{property_id}/{photo_id}.{ext}` | signed URL | 永久(随物理删除清理) |
| **跟进附件** | `media/{tenant_schema}/follow/{log_id}/{idx}.{ext}` | signed URL | 90 天自动删除 |
| **客源附件** | `media/{tenant_schema}/client/{client_id}/{idx}.{ext}` | signed URL | 永久(随物理删除清理) |
| **审计归档** | `exports/audit/{task_id}.csv` | signed URL only | 2 年(合规保留) |
### 13.2 关键约束
- 路径中 **禁止使用 `tenant_id`UUID**,统一用 `tenant_schema_name`(字符串),便于跨环境迁移与 bucket policy 配置
- `{ext}` 统一小写(`jpg` / `png` / `webp`),禁止 `.JPG`
- 文件名仅用 UUID / 整数索引,**禁止使用原始文件名**(防路径注入)
- Signed URL 生成统一通过 `core/storage.py``generate_presigned_url(key, expires_in)` 封装
### 13.3 Bucket Policy 摘要
| Prefix | Policy | 说明 |
|---|---|---|
| `releases/system/` | public-read | 客户端更新包CDN 加速 |
| `media/` | 无公开读,仅 signed | 所有租户媒体文件必须签名访问 |
| `backups/` | 无公开读,仅 signed | 备份包,严禁公开 |
| `exports/` | 无公开读,仅 signed | 导出包,签名 URL 有效期按上表 |
### 13.4 生命周期规则R2 Object Lifecycle
在 Cloudflare R2 控制台/API 配置以下规则:
```
Rule 1: backups/ prefix → Delete after 90 days
Rule 2: exports/{tenant}/ → Delete after 7 days
Rule 3: follow/ in media/ → Delete after 90 days (与 follow_logs 分区归档对齐)
Rule 4: exports/audit/ → Delete after 730 days (2 年合规保留)
```
---
## 14. ORM 数据范围强制规范M-14
> **For AI assistants**: Never use `Model.objects.filter(...)` directly in views or services. Always go through `scoped(staff)`.
### 14.1 背景
`DATA_MODEL/DATA_MODEL_PERMISSION.md` 已实现 `ScopeQueryBuilder`,但未强制约束使用入口。
模块技术方案 view 示例直接使用 `Property.objects.filter(...)`,可绕过权限控制。
### 14.2 强制规范
**所有业务 Model 必须暴露 `scoped(staff)` 入口,隐藏 `objects` Manager。**
```python
# core/models/scoped.py
from django.db import models
class ScopedManager(models.Manager):
"""
业务 Model 统一 Manager。
直接调用 Model.objects 将报错,强制使用 Model.scoped(staff)。
"""
def get_queryset(self):
raise RuntimeError(
f"[ScopedManager] 禁止直接调用 {self.model.__name__}.objects。"
f"请使用 {self.model.__name__}.scoped(staff) 经权限范围过滤。"
)
def scoped(self, staff):
"""
返回经 ScopeQueryBuilder 过滤后的 QuerySet。
staff: 当前登录员工实例含角色、org_unit、权限范围
"""
from apps.permission.scope import ScopeQueryBuilder
return ScopeQueryBuilder(staff).apply(super().get_queryset())
class ScopedModel(models.Model):
"""
所有业务 Model 继承此基类(而非 models.Model
"""
objects = ScopedManager()
class Meta:
abstract = True
```
### 14.3 豁免场景
| 场景 | 豁免条件 | 写法 |
|---|---|---|
| 系统/平台级操作(如分区维护) | 无租户身份,有明确运维场景 | `Model._default_manager.filter(...)` + 代码注释说明 |
| 迁移脚本 / seed factory | `RunPython` 或测试工厂 | `Model._default_manager.all()` |
| 测试内部 assert | 纯验证数据存在,非业务查询 | `Model._default_manager.get(pk=...)` |
### 14.4 Lint 规则pre-commit
`.pre-commit-config.yaml` 增加以下规则,阻断直接 `objects` 调用:
```yaml
- repo: local
hooks:
- id: no-raw-objects
name: "禁止直接使用 Model.objects业务代码"
language: pygrep
entry: '(?<!_default_manager)\.\bObjects\b|(?<!\._default_manager)\.objects\.(filter|all|get|exclude|create|update|delete)\b'
files: ^apps/.*\.py$
exclude: (tests/|migrations/|factories/)
types: [python]
```
### 14.5 权限边界测试矩阵
每个受权限保护的 Model集成测试必须覆盖以下 3 case
| Case | 说明 | 预期结果 |
|---|---|---|
| own | 员工查自己负责的数据 | 返回数据 ✅ |
| department | 店长查本部门数据 | 按角色返回 ✅ |
| cross_department_denied | 普通员工跨部门查询 | 空集或 403 ✅ |