Files
ai-kefu-admin/app/platform/routes/tenant_routes.py
T
浅念 6839cb94d2 fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录
- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与
  文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry
  索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status
  键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用
  恢复;源级直测两源过滤。
- I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手
  (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占
  → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示
  走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变
  语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。
- I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段
  默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示;
  CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。
- I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回
  api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为
  仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新
  (rollback 现为唯一豁免口)。

全量回归: 571 passed(基线 566 + 新增 5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:40:21 +08:00

507 lines
20 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 列——写时**整字典赋值*,不做
按键局部更新(局部改不标记脏,静默丢写)。
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(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}