Files
ai-kefu-admin/app/platform/routes/tenant_routes.py
T
浅念 ec2e17d5e3 feat: 租户管理API(创建向导消费出厂档案/blank保留jc墙/域名/停用/slug不可变)
POST /api/platform/tenants: profile ∈ {auto,jiancai,blank}——首版快照复用
FileTenantSource(seed).read_tenant(profile).snapshot(Task 4 审查指定);
blank=jiancai 档案 _blankify 去身份组但保留 wall/prompts(compile_wall/
PromptProvider 的硬要求)。建 Tenant+main 站点+版本1+租户管理员
(must_change_password=True),创建后 registry_reload(upsert)立即可用。

站点 CRUD(brand_overlay 整字典赋值,M-2a)/域名绑定解绑(全局唯一,
撞车 400 带现有归属)/PATCH 租户(name/status;slug 不可变 400;停用走
reload 路径 dispose 引擎)。列表/详情管理员概要显式挑字段,凭据永不外泄。
鉴权: 写操作+列表 require_super;GET 详情放宽 require_tenant(slug)。

测试: db_source_app 夹具(registry 用 DbTenantSource 生产形态指向临时
platform.db,跨循环纪律: 直查走独立 NullPool 连接),16 项全绿,全量 519。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 09:20:28 +08:00

517 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 负责,本层不碰)。
鉴权:写操作与列表 require_super;GET /{slug} 详情放宽 require_tenant
(slug)(租户管理员看自己概要,403 边界有测试钉住)。
安全纪律:
- 密码哈希/salt 绝不进响应——管理员序列化显式挑 {username, status}
(db.list_admin_users 的 M-5 警示);
- 创建出的租户管理员一律 must_change_password=True(引导密码首登改掉)。
M-2a 陷阱:Site.brand_overlay 是 ORM JSON 列——写时**整字典赋值*,不做
按键局部更新(局部改不标记脏,静默丢写)。
"""
import copy
import json
import pathlib
import re
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
from app.platform.defaults import DEFAULT_CONFIG
from app.platform.registry import _normalize_host
from app.platform.tenant_source import FileTenantSource
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"])
# 出厂档案目录:与 app.main.SEED_DIR 同一锚定规则(仓库根/seed_profiles),
# 但不 import app.main——main 模块级挂路由,反向导入制造循环依赖。
SEED_DIR = pathlib.Path(__file__).resolve().parents[3] / "seed_profiles"
PROFILES = ("auto", "jiancai", "blank")
STATUSES = ("active", "disabled")
SLUG_RE = re.compile(r"^[a-z0-9-]+$")
# blank 身份组清理清单(brand 的这几个键置出厂默认;其余 brand 字段保留)
_IDENTITY_BRAND_KEYS = ("title", "whatsapp", "email", "privacy_url")
def _blankify(snapshot: dict) -> dict:
"""出厂档案快照 → blank 形态:身份组置出厂默认,其余(含 wall/prompts)
原样保留。深拷贝后改——纯函数纪律,绝不动入参。"""
out = copy.deepcopy(snapshot)
brand = dict(out.get("brand") or {})
for key in _IDENTITY_BRAND_KEYS:
brand[key] = DEFAULT_CONFIG["brand"][key]
out["brand"] = brand
push = dict(out.get("push") or {})
push["prefix"] = DEFAULT_CONFIG["push"]["prefix"]
out["push"] = push
engine = dict(out.get("engine") or {})
engine["context_seed_keys"] = dict(
DEFAULT_CONFIG["engine"]["context_seed_keys"])
out["engine"] = engine
return out
def _profile_snapshot(profile: str) -> dict:
"""profile → 首版快照:auto/jiancai 读对应出厂档案原样;blank 读 jiancai
档案再去身份组(wall/prompts 保留——见模块 docstring)。"""
base_slug = "jiancai" if profile == "blank" else profile
snapshot = FileTenantSource(SEED_DIR).read_tenant(base_slug).snapshot
return _blankify(snapshot) if profile == "blank" else snapshot
# ---- 校验助手 -------------------------------------------------------------------
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)
tenant = Tenant(slug=body.slug, name=body.name, status="active")
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))
session.add(TenantConfigVersion(
tenant_id=tenant.id,
version=1,
config_json=json.dumps(snapshot, ensure_ascii=False),
updated_by=f"super#{user.user_id}",
comment=f"创建向导: profile={body.profile}"))
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(require_super)):
"""建站:slug/name/brand_overlay/domains 可选。brand_overlay 是 ORM
JSON 列——整字典赋值(M-2a)。"""
_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(require_super)):
"""改站点:停用/改名/改 overlay(整字典赋值,旧键不残留)。站点 slug
同样不可变(400)。"""
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(require_super)):
"""域名绑定:全局唯一(撞车 400 带现有归属);绑定后 registry reload
生效。"""
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(require_super)):
"""域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。"""
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}