baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
"""多租户中间件:从请求头 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/",
):
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/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)