Files
ai-kefu-admin/app/platform/routes/tenant_routes.py
T
浅念 0c25287c19 feat: 密钥掩码哨兵(__SET__)四处响应 + 站点管理权限放开租户管理员
- config_routes: push.api_key/ai.api_key/email.smtp.password 掩码助手
  (MASKED="__SET__";非空才掩,空串/None 原样)。作用于 GET config、
  GET versions/{n}、POST rollback 响应、GET settings(计划四处),并盖
  PUT/reset/PUT settings 的写回显——哨兵还原后的库值不经任何响应外泄。
- PUT 哨兵还原: 密钥字段收到 __SET__ → 当前快照原值替换后再写版本
  (前端"不改就原样传回");传新值照常覆盖;空串传回仍=清空(既有语义
  不变)。settings 同款(密钥名含 key/password/secret,递归嵌套 dict;
  设置 key 本身命中也算)。库行/版本历史永远存原值。
- tenant_routes: 站点/域名四端点(POST sites/PATCH sites/POST domains/
  DELETE domains)require_super → _tenant_guard;租户生命周期三端点
  (POST /tenants/GET 列表/PATCH /tenants/{slug})保持超管专属。
- 测试: 掩码四处响应+落库分离断言(_current_snapshot 直查库)、哨兵
  往返/新值/清空/嵌套路径/未设还原 None、settings 标量+嵌套+非密钥
  字面量;租户管理员四端点自助(201/200)+他租户 403+生命周期仍 403。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 13:39:23 +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 列(重置出厂按它取对应档案组)。
"""
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,
other_push_prefixes,
write_config_version,
)
from app.platform.registry import _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")
SLUG_RE = re.compile(r"^[a-z0-9-]+$")
# ---- 校验助手 -------------------------------------------------------------------
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}