a43c654337
- POST /api/platform/tenants/{slug}/brand/avatar(require_tenant):
multipart 收文件,魔数判定类型(\x89PNG/\xff\xd8\xff/RIFF....WEBP,
优先于扩展名——改后缀骗不过,存盘扩展名取自内容)、≤2MB(413,
upload.size 预检免拷贝)、服务端 uuid4 重命名(原始文件名不参与
路径),落 data/uploads/<slug>/(目录懒建,gitignore 已忽略)
- 走 Task 6 配置版本管线(复用 config_routes 内部 helper):读当前
快照 → brand 组只替换 avatar 字段 → validate_group →
write_config_version(comment=头像上传) → registry_reload 热生效
- /api/public/avatar 三级解析链:uploads → seed 资产 → 静态兜底;
Phase 1 文件名穿越防线(拒 / \ . ..)扩到 uploads 段
- registry.uploads_root:自 data_dir.parent 派生(生产=<repo>/data/
uploads;测试注入 tmp data_dir 时自动落 tmp 内,隔离免污染)
- 新依赖 python-multipart(multipart 表单解析)
Co-Authored-By: Claude Code <noreply@anthropic.com>
322 lines
15 KiB
Python
322 lines
15 KiB
Python
"""TenantRegistry——进程内租户注册表(Phase 1 文件加载;Task 4 起数据源
|
|
抽象化)。
|
|
|
|
数据源经 TenantSource 协议注入(app/platform/tenant_source.py):
|
|
FileTenantSource(seed_profiles/,测试/出厂档案)与 DbTenantSource
|
|
(platform.db,生产 Task 11 起装配)输出同构 LoadedTenant(snapshot =
|
|
配置组 ⊕ wall ⊕ prompts),本模块对源差异无感知。load_from_dir 保持
|
|
为文件源便捷构造(既有调用零改动)。spec §5.3 的热加载语义不变:后台
|
|
保存→原子替换条目,进行中的会话下一条消息即用新配置。
|
|
|
|
关键行为:
|
|
- resolve_site(host):host 小写去端口 → 域名索引 → (TenantRuntime, Site);
|
|
未知 Host → UnknownTenantError(spec §4:404,不做兜底租户);租户停用 →
|
|
TenantDisabledError(Task 7 中间件映射为 503 中性"服务暂停"页,非 404)。
|
|
resolve(host) 只是它的便捷形式——双 API 一层实现,防两套解析漂移。
|
|
- 懒建:首次 resolve 才建 engine(WAL/pool=2)+ session_factory + ensure_schema,
|
|
per-slug asyncio.Lock 串行,锁内双检防并发双建;持锁后现势检查——条目
|
|
已被 reload 换掉则放弃本次建库、按当前条目重走(旧 runtime 永不产生
|
|
孤儿 engine);建库失败 dispose 半开 engine,保持未就绪下次重试。
|
|
- registry_reload(slug):经 source 重读(锁外),swap+dispose 段与懒建/
|
|
dispose_all 取同一把 per-slug 锁。upsert 语义(Task 4):slug 未加载但
|
|
source 已知(DB/文件新建租户)→ 载入插入;source 也不知道 →
|
|
UnknownTenantError。坏配置(无效 JSON/墙编译失败/零站点/域名冲突)在
|
|
替换前抛,旧条目原样服务;替换后 dispose 旧 engine(Windows 下不关=
|
|
库文件句柄泄漏)。
|
|
|
|
测试/teardown 纪律:凡建过 engine 的 Registry 用完必须 await dispose_all()
|
|
(Windows 文件锁,tmp 目录删不掉)。
|
|
"""
|
|
import asyncio
|
|
import pathlib
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import event
|
|
from sqlalchemy.ext.asyncio import (
|
|
AsyncEngine,
|
|
AsyncSession,
|
|
async_sessionmaker,
|
|
create_async_engine,
|
|
)
|
|
|
|
from app.engine.models import ensure_schema
|
|
from app.engine.services.ai_service import PromptProvider
|
|
from app.engine.wall_schema import CompiledWall, compile_wall
|
|
from app.platform.tenant_config import Site, TenantConfig
|
|
from app.platform.tenant_source import (
|
|
FileTenantSource,
|
|
LoadedTenant,
|
|
TenantSource,
|
|
)
|
|
|
|
|
|
class UnknownTenantError(LookupError):
|
|
"""Host/域名无法映射到任何租户(调用方语义:聊天/公开端点 404)。"""
|
|
|
|
|
|
class TenantDisabledError(LookupError):
|
|
"""租户 status != "active"(调用方语义:503 中性"服务暂停"页,非 404;
|
|
Task 7 中间件负责映射)。"""
|
|
|
|
|
|
def make_tenant_engine(db_path: pathlib.Path) -> AsyncEngine:
|
|
"""每租户 SQLite engine:pool=2 无溢出(单租户低并发),忙等 30s
|
|
(对齐 jc 现状:后台推送连接可能撞 SMTP 窗口写锁),打开即 WAL
|
|
(spec §6.1 硬纪律:读写不互斥,优于靠 timeout 硬扛)。"""
|
|
engine = create_async_engine(
|
|
f"sqlite+aiosqlite:///{db_path.as_posix()}",
|
|
pool_size=2, max_overflow=0,
|
|
connect_args={"timeout": 30})
|
|
|
|
@event.listens_for(engine.sync_engine, "connect")
|
|
def _wal(dbapi_conn, _): # dbapi_conn = aiosqlite 同步适配层
|
|
dbapi_conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
return engine
|
|
|
|
|
|
def _normalize_host(host: str) -> str:
|
|
"""Host 头归一:去空白/小写/去端口/去 FQDN 尾点(真实请求 Host:8018、
|
|
大写都可能出现);IPv6 字面量 [::1]:8018 取方括号内部分。"""
|
|
host = (host or "").strip().lower().rstrip(".")
|
|
if host.startswith("["):
|
|
return host.split("]", 1)[0].lstrip("[")
|
|
return host.split(":", 1)[0]
|
|
|
|
|
|
@dataclass
|
|
class TenantRuntime:
|
|
"""单租户运行时。
|
|
|
|
加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/
|
|
db_path(该租户 SQLite 文件路径)/seed_dir(文件源=租户目录;DB 源
|
|
None——avatar 解析链的 seed 资产段对其跳过)/compiled_wall(墙编译
|
|
产物)/prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
|
|
入口——$DEFLECTION_POOL 话术池注入的单一消费口)。
|
|
懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。
|
|
manager(Task 5b)由装配线另行构造注入,不在本类持有。
|
|
"""
|
|
|
|
config: TenantConfig
|
|
db_path: pathlib.Path
|
|
seed_dir: pathlib.Path | None
|
|
compiled_wall: CompiledWall
|
|
prompts: PromptProvider
|
|
engine: AsyncEngine | None = None
|
|
session_factory: async_sessionmaker | None = None
|
|
|
|
def site_by_slug(self, slug: str) -> Site | None:
|
|
"""按 slug 取站点(空域名站点如 es 靠嵌入代码 data-site 接入)。"""
|
|
for site in self.config.sites:
|
|
if site.slug == slug:
|
|
return site
|
|
return None
|
|
|
|
|
|
class TenantRegistry:
|
|
"""tenant slug → TenantRuntime;域名 → (tenant_slug, site_slug) 索引。
|
|
reload 重建索引而非增量维护——租户数是个位数,整建最简且无漂移。"""
|
|
|
|
def __init__(self, source: TenantSource, data_dir):
|
|
self._source = source
|
|
self._data_dir = pathlib.Path(data_dir)
|
|
self._by_slug: dict[str, TenantRuntime] = {}
|
|
self._domain_index: dict[str, tuple[str, str]] = {}
|
|
self._locks: dict[str, asyncio.Lock] = {}
|
|
# 头像上传根目录(Task 7):生产 data_dir = <repo>/tenants,与
|
|
# <repo>/data/ 是仓库根兄弟目录(spec 布局,.gitignore 已忽略 data/)
|
|
# → 取 data_dir.parent 派生;测试注入 tmp data_dir 时上传自动落
|
|
# tmp 内(DB 源夹具 data_dir=tmp/tenants → tmp/data/uploads),
|
|
# 隔离免污染仓库目录。布局若变,只改这一处。
|
|
self.uploads_root = self._data_dir.parent / "data" / "uploads"
|
|
|
|
# ---- 加载 -----------------------------------------------------------
|
|
|
|
@classmethod
|
|
def load_from_dir(cls, seed_dir, data_dir) -> "TenantRegistry":
|
|
"""文件源便捷构造(既有调用零改动):seed_profiles/<slug>/
|
|
tenant.json + wall.json + prompts/ → Registry,同步完成。
|
|
|
|
加载期显性失败(不流入运行期):目录无任何 tenant.json(seed_dir
|
|
路径拼错→静默空注册表→全量 404,必须炸)、无效 JSON、slug 与目录
|
|
名不一致、零站点、wall 编译错误、域名重复绑定、prompts 读取失败
|
|
(prompts/ 目录缺失或四件套不齐 → read_text 的 FileNotFoundError,
|
|
加载即炸,不流入运行期)。
|
|
"""
|
|
source = FileTenantSource(seed_dir)
|
|
reg = cls(source, data_dir)
|
|
reg._install(source.read_all())
|
|
return reg
|
|
|
|
async def load_all(self) -> None:
|
|
"""初始异步加载(DB 源等 async 源的装配入口;文件源走
|
|
load_from_dir 同步路径,不经过这里)。空源由 source 自己显性炸。"""
|
|
self._install(await self._source.load_all())
|
|
|
|
def _install(self, loaded: list[LoadedTenant]) -> None:
|
|
"""LoadedTenant 列表 → 全量替换 _by_slug + 域名索引(整体重建,
|
|
无半更新状态;冲突在赋值前抛,旧状态原样)。"""
|
|
if not loaded:
|
|
raise ValueError(f"数据源未返回任何租户: {self._source!r}")
|
|
by_slug: dict[str, TenantRuntime] = {}
|
|
for lt in loaded:
|
|
rt = self._build_runtime(lt)
|
|
if rt.config.slug in by_slug:
|
|
raise ValueError(f"租户 slug 重复: {rt.config.slug}")
|
|
by_slug[rt.config.slug] = rt
|
|
self._by_slug = by_slug
|
|
self._domain_index = self._build_domain_index(by_slug)
|
|
|
|
def _build_runtime(self, lt: LoadedTenant) -> TenantRuntime:
|
|
"""LoadedTenant → TenantRuntime(两源同构快照的单一消费点:
|
|
from_snapshot 出厂默认合并 / compile_wall 墙编译 /
|
|
PromptProvider.from_config 四件套)。"""
|
|
config = TenantConfig.from_snapshot(
|
|
lt.slug, lt.name, lt.snapshot,
|
|
sites=lt.sites, status=lt.status)
|
|
return TenantRuntime(
|
|
config=config,
|
|
db_path=self._data_dir / f"{lt.slug}.db",
|
|
seed_dir=lt.seed_dir,
|
|
compiled_wall=compile_wall(lt.snapshot.get("wall")),
|
|
prompts=PromptProvider.from_config(lt.snapshot.get("prompts")))
|
|
|
|
def _build_domain_index(
|
|
self, by_slug: dict[str, TenantRuntime]
|
|
) -> dict[str, tuple[str, str]]:
|
|
index: dict[str, tuple[str, str]] = {}
|
|
for slug, rt in by_slug.items():
|
|
for site in rt.config.sites:
|
|
for domain in site.domains:
|
|
key = _normalize_host(domain)
|
|
if not key:
|
|
continue
|
|
if key in index and index[key] != (slug, site.slug):
|
|
raise ValueError(
|
|
f"域名 {key} 已绑定 (tenant={index[key][0]}, "
|
|
f"site={index[key][1]}),不能同时绑定 "
|
|
f"(tenant={slug}, site={site.slug})")
|
|
index[key] = (slug, site.slug)
|
|
return index
|
|
|
|
# ---- 解析(对外 API)--------------------------------------------------
|
|
|
|
async def resolve_site(self, host: str) -> tuple[TenantRuntime, Site]:
|
|
"""host → (TenantRuntime, Site)。未知 Host → UnknownTenantError;
|
|
租户停用 → TenantDisabledError;首次 resolve 懒建该租户 engine。
|
|
返回的 runtime 以 _ensure_ready 的返回值为准(等锁期间条目可能
|
|
已被 reload 换成新配置)。"""
|
|
mapped = self._domain_index.get(_normalize_host(host))
|
|
if mapped is None:
|
|
raise UnknownTenantError(f"未知 Host: {host!r}")
|
|
tenant_slug, site_slug = mapped
|
|
rt = self._by_slug[tenant_slug]
|
|
if rt.config.status != "active":
|
|
raise TenantDisabledError(f"租户已停用: {tenant_slug!r}")
|
|
rt = await self._ensure_ready(rt)
|
|
site = rt.site_by_slug(site_slug)
|
|
if site is None: # reload 竞态:域名命中后站点被换掉,视同未知
|
|
raise UnknownTenantError(
|
|
f"站点 {site_slug!r} 不存在于租户 {tenant_slug!r}"
|
|
f"(可能已被 reload 移除)")
|
|
return rt, site
|
|
|
|
async def resolve(self, host: str) -> TenantRuntime:
|
|
"""resolve_site 的便捷形式(双 API 一层实现)。"""
|
|
return (await self.resolve_site(host))[0]
|
|
|
|
# ---- 装配期辅助(Task 5b: main.lifespan 逐租户启动清扫)------------------
|
|
|
|
def runtimes(self) -> tuple[TenantRuntime, ...]:
|
|
"""全部租户运行时的加载期快照(engine 仍懒建)——装配期遍历用
|
|
(lifespan 启动清扫逐租户跑)。返回快照 tuple:遍历中 registry_reload
|
|
换条目不影响本轮迭代。"""
|
|
return tuple(self._by_slug.values())
|
|
|
|
async def ensure_ready(self, rt: TenantRuntime) -> TenantRuntime:
|
|
"""懒建的公开入口(装配期批处理用)——与 resolve 走同一条就绪路径,
|
|
现势检查/双检/建库失败半开语义完全一致。"""
|
|
return await self._ensure_ready(rt)
|
|
|
|
# ---- 懒建 / 热加载 / 释放 ---------------------------------------------
|
|
|
|
async def _ensure_ready(self, rt: TenantRuntime) -> TenantRuntime:
|
|
"""懒建 engine + session_factory + ensure_schema,per-slug Lock 串行。
|
|
|
|
持锁后现势检查:_by_slug 条目已被 registry_reload 换掉 → 放弃本次
|
|
建库(否则旧 runtime 会产出无人 dispose 的孤儿 engine),按当前
|
|
条目重走;双检:等锁期间前一个协程可能已建好。建库失败 dispose
|
|
半开 engine 并保持未就绪,下次 resolve 重试。
|
|
"""
|
|
slug = rt.config.slug
|
|
while True:
|
|
if rt.engine is not None:
|
|
return rt
|
|
lock = self._locks.setdefault(slug, asyncio.Lock())
|
|
async with lock:
|
|
current = self._by_slug.get(slug)
|
|
if current is None:
|
|
raise UnknownTenantError(f"租户已不存在: {slug!r}")
|
|
if current is not rt:
|
|
rt = current # 条目已被 reload 换掉,按新条目重走
|
|
continue
|
|
if rt.engine is not None: # 双检:等锁期间已建好
|
|
return rt
|
|
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
engine = make_tenant_engine(rt.db_path)
|
|
try:
|
|
await ensure_schema(engine)
|
|
except Exception:
|
|
await engine.dispose()
|
|
raise
|
|
rt.engine = engine
|
|
rt.session_factory = async_sessionmaker(
|
|
engine, class_=AsyncSession, expire_on_commit=False)
|
|
return rt
|
|
|
|
async def registry_reload(self, slug: str) -> None:
|
|
"""经 source 重读单租户,原子替换条目;旧 runtime 若已建 engine,
|
|
dispose 掉。
|
|
|
|
upsert 语义(Task 4,评审修订):slug 不在 _by_slug 但 source 已知
|
|
(新建租户已落库/落盘)→ 载入插入——插入同样走 per-slug 锁 + 整建
|
|
域名索引,与替换完全同路;source 也不知道(slug 拼错/未保存)→
|
|
UnknownTenantError。
|
|
|
|
source 读取/校验在锁外,swap+dispose 段与 _ensure_ready/dispose_all
|
|
取同一把 per-slug 锁:懒建要么完整发生在替换前(dispose 得到旧
|
|
engine),要么持锁后发现条目已换而放弃——不存在"建到一半被换"
|
|
的孤儿窗口。坏配置在替换前抛,旧条目原样服务,不留半更新状态。
|
|
"""
|
|
try:
|
|
loaded = await self._source.load_tenant(slug)
|
|
except LookupError as exc:
|
|
raise UnknownTenantError(f"租户不存在: {slug!r}") from exc
|
|
rt = self._build_runtime(loaded)
|
|
lock = self._locks.setdefault(slug, asyncio.Lock())
|
|
async with lock:
|
|
old = self._by_slug.get(slug)
|
|
candidate = dict(self._by_slug)
|
|
candidate[slug] = rt
|
|
new_index = self._build_domain_index(candidate) # 冲突在此抛,状态未动
|
|
self._by_slug[slug] = rt # 原子替换/插入(锁内,无 await)
|
|
self._domain_index = new_index
|
|
if old is not None and old.engine is not None:
|
|
await old.engine.dispose()
|
|
old.engine = old.session_factory = None
|
|
|
|
async def dispose_all(self) -> None:
|
|
"""关闭全部 engine 并复位为未就绪(测试 teardown 用——Windows 文件锁;
|
|
复位后若再 resolve 会重新懒建,可安全复用)。
|
|
|
|
与懒建/reload 取同一把 per-slug 锁:dispose 期间该租户不会有并发
|
|
建库(否则新建的 engine 会被本次复位漏掉或反过来被关)。迭代前
|
|
快照 _locks——迭代中并发 resolve 可能 setdefault 新锁。
|
|
"""
|
|
for slug, lock in list(self._locks.items()):
|
|
async with lock:
|
|
rt = self._by_slug.get(slug)
|
|
if rt is not None and rt.engine is not None:
|
|
await rt.engine.dispose()
|
|
rt.engine = rt.session_factory = None
|