"""TenantRegistry——进程内租户注册表(Phase 1 文件加载器)。 数据源:seed_profiles//tenant.json(站点/品牌/引擎配置)+ 同目录 wall.json(并入 snapshot["wall"],compile_wall 编译期显性炸)。Phase 2 换 platform.db 时本模块的对外接口不变(spec §5.3:后台保存→原子替换条目, 进行中的会话下一条消息即用新配置)。 关键行为: - resolve_site(host):host 小写去端口 → 域名索引 → (TenantRuntime, Site); 未知 Host → UnknownTenantError(spec §4:404,不做兜底租户)。 resolve(host) 只是它的便捷形式——双 API 一层实现,防两套解析漂移。 - 懒建:首次 resolve 才建 engine(WAL/pool=2)+ session_factory + ensure_schema, per-slug asyncio.Lock 串行,双检防并发双建;建库失败不留半开 engine。 - registry_reload(slug):重读磁盘→校验(坏 JSON/墙/域名冲突全在替换前抛, 旧条目不动)→原子替换 _by_slug 条目并重建域名索引→dispose 旧 engine (Windows 下不关=库文件句柄泄漏)。 测试/teardown 纪律:凡建过 engine 的 Registry 用完必须 await dispose_all() (Windows 文件锁,tmp 目录删不掉)。 """ import asyncio import json 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.wall_schema import CompiledWall, compile_wall from app.platform.tenant_config import Site, TenantConfig class UnknownTenantError(LookupError): """Host/域名无法映射到任何租户(调用方语义:聊天/公开端点 404)。""" 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/db_path/compiled_wall 加载期就绪;engine/ session_factory 由 Registry 首次 resolve 懒建;manager 由装配线注入 (Task 5b),prompts 由 Task 6 注入——本任务不含。""" config: TenantConfig db_path: pathlib.Path compiled_wall: CompiledWall 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): self._seed_dir: pathlib.Path | None = None self._data_dir: pathlib.Path | None = None self._by_slug: dict[str, TenantRuntime] = {} self._domain_index: dict[str, tuple[str, str]] = {} self._locks: dict[str, asyncio.Lock] = {} # ---- 加载 ----------------------------------------------------------- @classmethod def load_from_dir(cls, seed_dir, data_dir) -> "TenantRegistry": """seed_profiles//tenant.json(+同目录 wall.json)→ Registry。 加载期显性失败(不流入运行期):目录无任何 tenant.json(seed_dir 路径拼错→静默空注册表→全量 404,必须炸)、slug 与目录名不一致、 零站点、wall 编译错误、域名重复绑定。 """ reg = cls() reg._seed_dir = pathlib.Path(seed_dir) reg._data_dir = pathlib.Path(data_dir) tenant_jsons = sorted(reg._seed_dir.glob("*/tenant.json")) if not tenant_jsons: raise ValueError(f"{reg._seed_dir} 下没有任何 /tenant.json") for tenant_json in tenant_jsons: rt = reg._load_tenant(tenant_json.parent) if rt.config.slug in reg._by_slug: raise ValueError(f"租户 slug 重复: {rt.config.slug}") reg._by_slug[rt.config.slug] = rt reg._domain_index = reg._build_domain_index(reg._by_slug) return reg def _load_tenant(self, tenant_dir: pathlib.Path) -> TenantRuntime: slug = tenant_dir.name tenant_json = json.loads( (tenant_dir / "tenant.json").read_text(encoding="utf-8")) if tenant_json.get("slug") != slug: raise ValueError( f"{tenant_dir / 'tenant.json'}: slug 字段 " f"{tenant_json.get('slug')!r} 与目录名 {slug!r} 不一致") sites = tenant_json.get("sites") or [] if not sites: raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)") # wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/ # 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译) wall_cfg = json.loads( (tenant_dir / "wall.json").read_text(encoding="utf-8")) snapshot = dict(tenant_json.get("config") or {}) snapshot["wall"] = wall_cfg config = TenantConfig.from_snapshot( slug, tenant_json.get("name", slug), snapshot, sites=sites, status=tenant_json.get("status", "active")) return TenantRuntime( config=config, db_path=self._data_dir / f"{slug}.db", compiled_wall=compile_wall(wall_cfg)) 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 index.get(key, (slug, site.slug)) != (slug, site.slug): raise ValueError( f"域名 {key} 同时绑定 {index[key][0]} 与 " f"{slug} 的站点(域名不可重复绑定)") index[key] = (slug, site.slug) return index # ---- 解析(对外 API)-------------------------------------------------- async def resolve_site(self, host: str) -> tuple[TenantRuntime, Site]: """host → (TenantRuntime, Site)。首次 resolve 懒建该租户 engine。""" 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] await self._ensure_ready(rt) site = rt.site_by_slug(site_slug) if site is None: # 防御:索引与配置同源重建,正常不可达 raise UnknownTenantError( f"站点 {site_slug!r} 不存在于租户 {tenant_slug!r}") return rt, site async def resolve(self, host: str) -> TenantRuntime: """resolve_site 的便捷形式(双 API 一层实现)。""" return (await self.resolve_site(host))[0] # ---- 懒建 / 热加载 / 释放 --------------------------------------------- async def _ensure_ready(self, rt: TenantRuntime) -> TenantRuntime: """懒建 engine + session_factory + ensure_schema,per-slug Lock 串行。 双检:抢到锁后重看 engine——等锁期间前一个协程可能已建好。 建库失败 dispose 半开 engine 并保持未就绪,下次 resolve 重试。 """ if rt.engine is not None: return rt lock = self._locks.setdefault(rt.config.slug, asyncio.Lock()) async with lock: 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: """重读磁盘,原子替换条目;旧 runtime 若已建 engine,dispose 掉。 先全量校验(重读 tenant.json/wall.json + 候选域名索引)再替换—— 坏配置抛异常时旧条目原样服务,不留半更新状态。 """ if slug not in self._by_slug: raise UnknownTenantError(f"租户不存在: {slug!r}") old = self._by_slug[slug] rt = self._load_tenant(self._seed_dir / 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.engine is not None: await old.engine.dispose() old.engine = old.session_factory = None async def dispose_all(self) -> None: """关闭全部 engine 并复位为未就绪(测试 teardown 用——Windows 文件锁; 复位后若再 resolve 会重新懒建,可安全复用)。""" for rt in self._by_slug.values(): if rt.engine is not None: await rt.engine.dispose() rt.engine = rt.session_factory = None