文档修改
This commit is contained in:
@@ -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_id(UUID)前缀(必须按 §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 ✅ |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user