"""多租户中间件:从请求头 X-Tenant-Id 识别租户,注入 request.tenant。 策略: 1. 读取 Header `X-Tenant-Id`,默认为 settings.TENANT_DEFAULT 2. 查询 Tenant 对象;找不到则 400 3. 把 request.tenant 注入;让 ViewSet 通过 TenantScopedModel 自动加过滤 未识别租户的请求 = 400 Bad Request。 注意:Django middleware 在 ASGI 下也是同步调用的(除非是纯 async middleware), 但 ORM 查询必须走 sync_to_async,因为下游 view 是 async。 """ from django.conf import settings from django.http import JsonResponse from asgiref.sync import sync_to_async from .models import Tenant class TenantMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # 公开路径跳过租户识别(ping、admin、auth/token 等) path = request.path if path.startswith("/admin/") or path in ( "/api/v1/ping/", "/api/v1/auth/token/", "/api/v1/auth/token/refresh/", "/api/v1/auth/register/", ): return self.get_response(request) tenant_code = request.headers.get( settings.TENANT_HEADER, settings.TENANT_DEFAULT ) # 在 sync_to_async 里跑 ORM 查询(即便下游 view 是 async,中间件本身在 ASGI 下也是同步调用) # 但保险起见同步包一层 request.tenant = tenant_code # 先存 code,view 里再解析 # 真正解析 Tenant 由 async 视图完成(request.tenant_or_error) return self.get_response(request) def get_tenant_sync(tenant_code): """同步 ORM 查询 Tenant(供 async view 调用)。""" return Tenant.objects.get(code=tenant_code, is_active=True) async def resolve_tenant(tenant_code): """async 解析 Tenant;找不到抛 Tenant.DoesNotExist。""" return await sync_to_async(get_tenant_sync)(tenant_code) # ============================================================ # 演示租户只读保护(批次 C2) # ============================================================ # 演示租户内允许的写操作白名单(登录/刷新/进入演示本身) DEMO_WRITE_ALLOWLIST = { "/api/v1/auth/token/", "/api/v1/auth/token/refresh/", "/api/v1/auth/register/", "/api/v1/demo/enter/", "/api/v1/ping/", } # 商城客户端允许写:客户在演示账套里要能体验"下单 → 业务员确认"闭环。 # 仅放行客户端端点;后台端的确认/驳回/授权/账号管理仍被只读拦截, # 且订单只落在演示租户内(不影响其他租户数据)。 DEMO_WRITE_PREFIX_ALLOWLIST = ( "/api/v1/storefront/login/", "/api/v1/storefront/orders/", ) SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"} class DemoReadOnlyMiddleware: """演示租户(code=demo)下拦截写入。 判定依据是请求头租户 + HTTP 方法(不依赖前端自觉、也不依赖 token 里的 claim), 这样即便有人拿演示 token 手工 curl 也无法改数据。 """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if request.method in SAFE_METHODS: return self.get_response(request) path = request.path if path.startswith("/admin/") or path in DEMO_WRITE_ALLOWLIST: return self.get_response(request) if path.startswith(DEMO_WRITE_PREFIX_ALLOWLIST): return self.get_response(request) from django.conf import settings as dj_settings tenant_code = request.headers.get( dj_settings.TENANT_HEADER, dj_settings.TENANT_DEFAULT ) if tenant_code == "demo": return JsonResponse( { "code": "demo_read_only", "detail": "演示账套为只读模式,写入操作已被拒绝。" "注册免费账号即可创建你自己的账套。", }, status=403, ) return self.get_response(request)