Files
ai-kefu-admin/app/platform/routes/tenant_routes.py
T
浅念 9b97eafa1c feat(middleware): /t/<slug>/ 路径前缀解析(与 Host 模式共存)
- TenantHostMiddleware 增路径前缀分支:raw_path 直判 slug 段(未编码
  [a-z0-9-],%61uto/大写/点号不匹配落 Host 解析);仅带尾斜杠形态剥离;
  path 定长切割(保留前缀尾斜杠为剩余路径领头斜杠),raw_path 按解码
  path 重编码重算(非长度截断),orig_path 留痕;未知 404/ws 1008、
  停用 503/ws 1013 与 Host 模式同语义
- _PREFIX_RE 字符类取自 registry.TENANT_SLUG_RE(掐 ^$ 锚)单一事实源;
  tenant_routes 的 SLUG_RE 换源同常量别名 import(删本地定义与 import re)
- 评审折入:registry 空 sites 分支注释纠正为生产可达(DB 源全站点停用
  合法状态),test_registry 用例 docstring 同步;conftest ADMIN_PASSWORD
  注释补硬编码登录用例警示
- 新增 TestPathPrefixRouting 13 用例(全链/拒绝族/停用/site 覆盖/豁免
  优先/direct-ASGI 改写钉位/WS 双例/路径优先)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 17:27:01 +08:00

511 lines
21 KiB
Python

"""租户管理 API(Phase 2 Task 5):/api/platform/tenants*——超管的创建向导/
站点/域名/停用,GET 详情放宽到本租户管理员。
创建向导的快照复用路径(Task 4 审查指定,勿手搓拼装):
``FileTenantSource(SEED_DIR).read_tenant(profile).snapshot`` 直接产出首版
快照——auto/jiancai 原样入版本1;**blank** = jiancai 档案经 _blankify 去
身份组(brand 的 title/whatsapp/email/privacy_url、push.prefix、
engine.context_seed_keys 置出厂默认),**wall 与 prompts 组保留**(取
jiancai 档案)——compile_wall 要求 wall 全字段、PromptProvider 要求四件套,
DEFAULT_CONFIG 满足不了。
写操作后一律 registry_reload(slug)(Task 4 upsert 语义:新建租户未加载
也直接插入,创建即可用);停用走 reload 路径顺带 dispose 旧 engine
(引擎释放由 registry 负责,本层不碰)。
鉴权:租户生命周期(POST 创建 / GET 列表 / PATCH 停用改名)require_super;
GET /{slug} 详情与站点·域名四端点(POST sites / PATCH sites /
POST domains / DELETE domains)放宽 require_tenant(slug)(Phase 3 Task 1:
本租户管理员自助管理站点与域名,403 边界有测试钉住)。
安全纪律:
- 密码哈希/salt 绝不进响应——管理员序列化显式挑 {username, status}
(db.list_admin_users 的 M-5 警示);
- 创建出的租户管理员一律 must_change_password=True(引导密码首登改掉)。
M-2a 陷阱:Site.brand_overlay 是 ORM JSON 列——写时**整字典赋值*,不做
按键局部更新(局部改不标记脏,静默丢写)。
Task 6 增量:出厂档案快照助手(_profile_snapshot/_blankify)迁至
tenant_source.py(配置 API 的重置出厂同样消费);版本 1 写入改走
db.write_config_version(与配置 API 的 PUT/rollback/reset 同一版本写入
路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.platform.auth import (
UserContext,
hash_password,
platform_user,
require_super,
require_tenant,
)
from app.platform.db import (
AdminUser,
Site,
SiteDomain,
Tenant,
TenantConfigVersion,
other_push_prefixes,
write_config_version,
)
# slug 形态校验的单一事实源在 registry(TENANT_SLUG_RE——创建校验与
# 路径前缀解析 /t/<slug>/ 共用,永不漂移,spec 2026-10-19 §3.1);别名
# import 让本文件既有 SLUG_RE 引用零改动
from app.platform.registry import TENANT_SLUG_RE as SLUG_RE, _normalize_host
from app.platform.tenant_source import _profile_snapshot
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"])
PROFILES = ("auto", "jiancai", "blank")
STATUSES = ("active", "disabled")
# ---- 校验助手 -------------------------------------------------------------------
def _validate_slug(slug: str) -> str:
if not SLUG_RE.fullmatch(slug or ""):
raise HTTPException(
status_code=400, detail="slug 只允许小写字母、数字和连字符")
return slug
def _normalize_domain(domain: str) -> str:
"""入库前归一(小写/去端口/去尾点——与 resolve 的 host 归一同一事实源);
归一后为空 → 400。"""
normalized = _normalize_host(domain or "")
if not normalized:
raise HTTPException(status_code=400, detail="域名不能为空")
return normalized
def _check_name(name) -> None:
if not isinstance(name, str) or not 1 <= len(name) <= 128:
raise HTTPException(status_code=400, detail="name 须为 1-128 字符")
def _check_status(status) -> None:
if status not in STATUSES:
raise HTTPException(
status_code=400,
detail=f"status 只允许 {STATUSES}(实际 {status!r})")
def _patch_fields(body: dict, *, allow: tuple[str, ...]) -> dict:
"""PATCH 通用校验:slug 键一律 400(不可变);白名单外字段 400;显式
null 视为缺席;全空 → 400(无意义补丁)。返回只含出现且非 null 的字段。"""
if "slug" in body:
raise HTTPException(status_code=400, detail="slug 不可修改")
unknown = set(body) - set(allow)
if unknown:
raise HTTPException(
status_code=400,
detail=f"不可修改的字段: {', '.join(sorted(unknown))}")
fields = {k: v for k, v in body.items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="没有可更新字段")
return fields
async def _get_tenant_or_404(session: AsyncSession, slug: str) -> Tenant:
tenant = (await session.execute(
select(Tenant).where(Tenant.slug == slug))).scalar_one_or_none()
if tenant is None:
raise HTTPException(status_code=404, detail=f"租户不存在: {slug!r}")
return tenant
async def _get_site_or_404(
session: AsyncSession, tenant_slug: str, site_slug: str) -> Site:
site = (await session.execute(
select(Site).join(Tenant, Site.tenant_id == Tenant.id)
.where(Tenant.slug == tenant_slug, Site.slug == site_slug)
)).scalar_one_or_none()
if site is None:
raise HTTPException(
status_code=404,
detail=f"站点不存在: {tenant_slug}/{site_slug}")
return site
async def _assert_domain_free(session: AsyncSession, domain: str) -> None:
"""域名全局唯一预检:撞车 400 带现有归属(租户+站点)——调用方能一眼
看到域名被谁占了。"""
row = (await session.execute(
select(SiteDomain.domain, Tenant.slug, Site.slug)
.join(Site, SiteDomain.site_id == Site.id)
.join(Tenant, Site.tenant_id == Tenant.id)
.where(SiteDomain.domain == domain))).first()
if row is not None:
raise HTTPException(
status_code=400,
detail=f"域名 {domain} 已绑定租户 {row[1]!r} 的站点 {row[2]!r}")
async def _site_summary(session: AsyncSession, site: Site) -> dict:
domains = (await session.execute(
select(SiteDomain.domain).where(SiteDomain.site_id == site.id)
.order_by(SiteDomain.id))).scalars().all()
return {
"slug": site.slug, "name": site.name, "status": site.status,
"domains": list(domains),
"brand_overlay": dict(site.brand_overlay or {}),
}
def _admin_summary(admin: AdminUser) -> dict:
# 显式挑字段——password_hash/salt 绝不进响应(M-5)
return {"username": admin.username, "status": admin.status}
def _tenant_guard(slug: str, user: platform_user) -> UserContext:
"""路径参数版 require_tenant:复用 auth 的依赖工厂,slug 取自路径
(super 或本租户管理员放行,409 门经 platform_user 连锁)。"""
return require_tenant(slug)(user)
# ---- 请求模型 -------------------------------------------------------------------
class CreateTenantRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
slug: str = Field(min_length=1, max_length=64)
profile: str
admin_username: str = Field(min_length=1, max_length=64)
admin_password: str = Field(min_length=1, max_length=128)
domains: list[str] = Field(default_factory=list)
class CreateSiteRequest(BaseModel):
slug: str = Field(min_length=1, max_length=64)
name: str = Field(default="", max_length=128)
brand_overlay: dict = Field(default_factory=dict)
domains: list[str] = Field(default_factory=list)
class AddDomainRequest(BaseModel):
domain: str = Field(min_length=1, max_length=255)
# ---- 租户:创建 / 列表 / 详情 / 改名改状态 ------------------------------------------
@router.post("", status_code=201)
async def create_tenant(
body: CreateTenantRequest, request: Request,
user=Depends(require_super)):
"""创建向导:校验 slug/profile → 出厂档案快照 → 建租户+main 站点+
版本1+租户管理员(must_change_password=True)→ registry_reload(upsert,
创建即可用)。"""
_validate_slug(body.slug)
if body.profile not in PROFILES:
raise HTTPException(
status_code=400,
detail=f"未知 profile: {body.profile!r}(可选 {PROFILES})")
# 请求内去重(保序),免自撞唯一约束
domains = list(dict.fromkeys(
_normalize_domain(d) for d in body.domains))
try:
snapshot = _profile_snapshot(body.profile)
except LookupError as exc:
raise HTTPException(
status_code=400, detail=f"出厂档案缺失: {exc}") from exc
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
if (await session.execute(
select(Tenant.id).where(Tenant.slug == body.slug))).first():
raise HTTPException(
status_code=400, detail=f"slug 已存在: {body.slug!r}")
if (await session.execute(
select(AdminUser.id).where(
AdminUser.username == body.admin_username))).first():
raise HTTPException(
status_code=400,
detail=f"管理员用户名已被占用: {body.admin_username!r}")
for domain in domains:
await _assert_domain_free(session, domain)
# push.prefix 跨租户查重(终审 I-3):首版快照带出厂 prefix——同
# profile 第二个租户必撞(jiancai=ai-kefu-jc / auto=ai-kefu),
# spec E1 前缀全局唯一防 CRM 线索串号,创建期就拦。blank 出厂
# prefix 为空串,不参战(与 config_validation 同款空串豁免)。
prefix = (snapshot.get("push") or {}).get("prefix")
if (isinstance(prefix, str) and prefix
and prefix in await other_push_prefixes(session, None)):
raise HTTPException(
status_code=400,
detail=f"出厂档案的 push.prefix {prefix!r} 已被其他租户占用"
f"——请改用 blank profile(创建后经配置 API 改前缀)"
f"或先修改占用方前缀")
tenant = Tenant(slug=body.slug, name=body.name, status="active",
profile=body.profile)
session.add(tenant)
await session.flush()
site = Site(tenant_id=tenant.id, slug="main", name="main")
session.add(site)
await session.flush()
for domain in domains:
session.add(SiteDomain(domain=domain, site_id=site.id))
password_hash, salt = hash_password(body.admin_password)
session.add(AdminUser(
tenant_id=tenant.id,
username=body.admin_username,
password_hash=password_hash,
salt=salt,
must_change_password=True))
# 版本写入走公共 helper(Task 6:与配置 API 的 PUT/rollback/reset
# 同一条 MAX(version)+1 路径;新租户无历史版本 → 落版本 1)
version = await write_config_version(
session, tenant.id, snapshot,
updated_by=f"super#{user.user_id}",
comment=f"创建向导: profile={body.profile}")
assert version == 1
try:
await session.commit()
except IntegrityError as exc:
# 并发竞态兜底(预检与提交之间被人抢先):唯一约束落库层兜住
await session.rollback()
raise HTTPException(
status_code=409,
detail="并发冲突: slug/用户名/域名已被占用") from exc
await request.app.state.registry.registry_reload(body.slug)
return {
"slug": tenant.slug, "name": tenant.name, "status": tenant.status,
"profile": body.profile, "version": 1,
"site": {"slug": "main", "domains": domains},
"admin_username": body.admin_username,
}
@router.get("")
async def list_tenants(request: Request, user=Depends(require_super)):
"""租户列表:slug/name/status/域名数/当前版本号/管理员概要。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenants = (await session.execute(
select(Tenant).order_by(Tenant.slug))).scalars().all()
domain_counts = dict((await session.execute(
select(Site.tenant_id, func.count(SiteDomain.id))
.join(SiteDomain, SiteDomain.site_id == Site.id)
.group_by(Site.tenant_id))).all())
versions = dict((await session.execute(
select(TenantConfigVersion.tenant_id,
func.max(TenantConfigVersion.version))
.group_by(TenantConfigVersion.tenant_id))).all())
admins = (await session.execute(
select(AdminUser).where(AdminUser.tenant_id.is_not(None))
.order_by(AdminUser.id))).scalars().all()
admins_by_tenant: dict[int, list[dict]] = {}
for admin in admins:
admins_by_tenant.setdefault(admin.tenant_id, []).append(
_admin_summary(admin))
return {"items": [{
"slug": t.slug,
"name": t.name,
"status": t.status,
"domain_count": domain_counts.get(t.id, 0),
"current_version": versions.get(t.id),
"admins": admins_by_tenant.get(t.id, []),
} for t in tenants]}
@router.get("/{slug}")
async def tenant_detail(
slug: str, request: Request, user=Depends(_tenant_guard)):
"""租户详情:sites(含 domains/brand_overlay)/管理员用户名/当前版本。
鉴权放宽 require_tenant(slug)——租户管理员看自己概要。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
sites = (await session.execute(
select(Site).where(Site.tenant_id == tenant.id)
.order_by(Site.id))).scalars().all()
domains = (await session.execute(
select(SiteDomain)
.where(SiteDomain.site_id.in_([s.id for s in sites]))
.order_by(SiteDomain.id))).scalars().all()
current_version = (await session.execute(
select(func.max(TenantConfigVersion.version))
.where(TenantConfigVersion.tenant_id == tenant.id))).scalar()
admins = (await session.execute(
select(AdminUser).where(AdminUser.tenant_id == tenant.id)
.order_by(AdminUser.id))).scalars().all()
domains_by_site: dict[int, list[str]] = {}
for d in domains:
domains_by_site.setdefault(d.site_id, []).append(d.domain)
return {
"slug": tenant.slug,
"name": tenant.name,
"status": tenant.status,
"current_version": current_version,
"sites": [{
"slug": s.slug, "name": s.name, "status": s.status,
"domains": list(domains_by_site.get(s.id, [])),
"brand_overlay": dict(s.brand_overlay or {}),
} for s in sites],
"admins": [_admin_summary(a) for a in admins],
}
@router.patch("/{slug}")
async def patch_tenant(
slug: str, body: dict, request: Request, user=Depends(require_super)):
"""改名/停用复启用。slug 不可变(400);status≠active → registry reload
后 resolve 抛 TenantDisabledError,引擎释放由 reload 路径处理(dispose)。"""
fields = _patch_fields(body, allow=("name", "status"))
if "name" in fields:
_check_name(fields["name"])
if "status" in fields:
_check_status(fields["status"])
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
if "name" in fields:
tenant.name = fields["name"]
if "status" in fields:
tenant.status = fields["status"]
await session.commit()
result = {"slug": tenant.slug, "name": tenant.name,
"status": tenant.status}
await request.app.state.registry.registry_reload(slug)
return result
# ---- 站点:建站 / 改门面 -----------------------------------------------------------
@router.post("/{slug}/sites", status_code=201)
async def create_site(
slug: str, body: CreateSiteRequest, request: Request,
user=Depends(_tenant_guard)):
"""建站:slug/name/brand_overlay/domains 可选。brand_overlay 是 ORM
JSON 列——整字典赋值(M-2a)。鉴权 _tenant_guard(Phase 3 Task 1
放宽):本租户管理员可自助建站。"""
_validate_slug(body.slug)
domains = list(dict.fromkeys(
_normalize_domain(d) for d in body.domains))
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
if (await session.execute(
select(Site.id).where(Site.tenant_id == tenant.id,
Site.slug == body.slug))).first():
raise HTTPException(
status_code=400,
detail=f"站点 slug 已存在: {body.slug!r}")
for domain in domains:
await _assert_domain_free(session, domain)
site = Site(tenant_id=tenant.id, slug=body.slug,
name=body.name or body.slug,
brand_overlay=dict(body.brand_overlay))
session.add(site)
await session.flush()
for domain in domains:
session.add(SiteDomain(domain=domain, site_id=site.id))
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail="并发冲突: 站点 slug 或域名已被占用") from exc
result = await _site_summary(session, site)
await request.app.state.registry.registry_reload(slug)
return result
@router.patch("/{slug}/sites/{site_slug}")
async def patch_site(
slug: str, site_slug: str, body: dict, request: Request,
user=Depends(_tenant_guard)):
"""改站点:停用/改名/改 overlay(整字典赋值,旧键不残留)。站点 slug
同样不可变(400)。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
fields = _patch_fields(body, allow=("name", "status", "brand_overlay"))
if "name" in fields:
_check_name(fields["name"])
if "status" in fields:
_check_status(fields["status"])
if "brand_overlay" in fields and not isinstance(
fields["brand_overlay"], dict):
raise HTTPException(
status_code=400, detail="brand_overlay 必须是 JSON 对象")
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
site = await _get_site_or_404(session, slug, site_slug)
if "name" in fields:
site.name = fields["name"]
if "status" in fields:
site.status = fields["status"]
if "brand_overlay" in fields:
site.brand_overlay = dict(fields["brand_overlay"]) # M-2a 整赋值
await session.commit()
result = await _site_summary(session, site)
await request.app.state.registry.registry_reload(slug)
return result
# ---- 域名:绑定 / 解绑 ------------------------------------------------------------
@router.post("/{slug}/sites/{site_slug}/domains", status_code=201)
async def add_domain(
slug: str, site_slug: str, body: AddDomainRequest, request: Request,
user=Depends(_tenant_guard)):
"""域名绑定:全局唯一(撞车 400 带现有归属);绑定后 registry reload
生效。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
domain = _normalize_domain(body.domain)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
site = await _get_site_or_404(session, slug, site_slug)
await _assert_domain_free(session, domain)
session.add(SiteDomain(domain=domain, site_id=site.id))
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail=f"并发冲突: 域名 {domain} 已被占用") from exc
await request.app.state.registry.registry_reload(slug)
return {"domain": domain, "site": site_slug}
@router.delete("/{slug}/sites/{site_slug}/domains/{domain}")
async def delete_domain(
slug: str, site_slug: str, domain: str, request: Request,
user=Depends(_tenant_guard)):
"""域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。
鉴权 _tenant_guard(Phase 3 Task 1 放宽)。"""
domain = _normalize_domain(domain)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
site = await _get_site_or_404(session, slug, site_slug)
row = (await session.execute(
select(SiteDomain).where(SiteDomain.site_id == site.id,
SiteDomain.domain == domain)
)).scalar_one_or_none()
if row is None:
raise HTTPException(
status_code=404, detail=f"域名不存在: {domain!r}")
await session.delete(row)
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"ok": True, "domain": domain, "site": site_slug}