ec2e17d5e3
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>
517 lines
21 KiB
Python
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}
|