6839cb94d2
- 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>
407 lines
17 KiB
Python
407 lines
17 KiB
Python
"""TenantSource 抽象与 DB 源(Phase 2 Task 4)。
|
||
|
||
两源输出同构 LoadedTenant:sites(tenant.json sites 形态,DB 源把
|
||
sites.brand_overlay 列映射进站点 "brand" 键)+ snapshot(配置组 ⊕ wall
|
||
⊕ prompts 四件套文本——文件源的行为增量:prompts 并入快照,两源对齐)。
|
||
|
||
registry 构造收 source:load_from_dir 退化为 File 源便捷构造(既有调用
|
||
零改动,既有 test_registry 全绿即证文件源行为等价);registry_reload 走
|
||
source 并支持 upsert——source 已知但未加载的 slug 载入插入(Task 5
|
||
创建租户后 reload 依赖此语义),source 也不知道 → UnknownTenantError。
|
||
|
||
Windows 纪律:建过 engine 的用例结尾 await engine.dispose()/dispose_all()
|
||
(文件锁,tmp 目录才删得掉)。
|
||
"""
|
||
import json
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from sqlalchemy import text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.engine.services.ai_service import PROMPTS_KEYS, PromptProvider
|
||
from app.platform.db import (
|
||
Site,
|
||
SiteDomain,
|
||
Tenant,
|
||
TenantConfigVersion,
|
||
ensure_platform_schema,
|
||
make_platform_engine,
|
||
)
|
||
from app.platform.registry import UnknownTenantError, TenantRegistry
|
||
from app.platform.tenant_config import TenantConfig
|
||
from app.platform.tenant_source import DbTenantSource, FileTenantSource
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
SEED_DIR = REPO_ROOT / "seed_profiles"
|
||
JC_DOMAIN = "db.jc.example.com" # DB 源测试专属域名(不与文件源种子撞)
|
||
|
||
|
||
def _jiancai_snapshot(prefix="ai-kefu-jc"):
|
||
"""版本 config_json 构造助手:seed jiancai 的 config 组 + wall.json +
|
||
prompts 四件套合并(Task 5 创建向导将产出同构内容)。"""
|
||
tenant = json.loads(
|
||
(SEED_DIR / "jiancai" / "tenant.json").read_text(encoding="utf-8"))
|
||
prompts_dir = SEED_DIR / "jiancai" / "prompts"
|
||
snapshot = dict(tenant["config"])
|
||
snapshot["push"] = dict(snapshot.get("push") or {}, prefix=prefix)
|
||
snapshot["wall"] = json.loads(
|
||
(SEED_DIR / "jiancai" / "wall.json").read_text(encoding="utf-8"))
|
||
snapshot["prompts"] = {
|
||
name: (prompts_dir / f"{name}.md").read_text(encoding="utf-8")
|
||
for name in PROMPTS_KEYS
|
||
}
|
||
return snapshot
|
||
|
||
|
||
async def _make_engine(tmp_path):
|
||
engine = make_platform_engine(tmp_path / "platform.db")
|
||
await ensure_platform_schema(engine)
|
||
return engine
|
||
|
||
|
||
async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
|
||
config_json=None, with_version=True, es_status="active"):
|
||
"""jiancai 租户 + main(JC_DOMAIN)+ es(brand_overlay)+ 版本行。"""
|
||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||
tenant = Tenant(slug="jiancai", name="HESUPIPE 建材", status="active")
|
||
session.add(tenant)
|
||
await session.flush()
|
||
main = Site(tenant_id=tenant.id, slug="main", name="main")
|
||
es = Site(tenant_id=tenant.id, slug="es", name="es",
|
||
status=es_status,
|
||
brand_overlay={"title": "HESUPIPE Soporte"})
|
||
session.add_all([main, es])
|
||
await session.flush()
|
||
session.add(SiteDomain(domain=JC_DOMAIN, site_id=main.id))
|
||
if with_version:
|
||
session.add(TenantConfigVersion(
|
||
tenant_id=tenant.id, version=version,
|
||
config_json=config_json if config_json is not None
|
||
else json.dumps(_jiancai_snapshot(prefix), ensure_ascii=False)))
|
||
await session.commit()
|
||
return tenant.id
|
||
|
||
|
||
async def _add_version(engine, tenant_id, version, prefix):
|
||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||
session.add(TenantConfigVersion(
|
||
tenant_id=tenant_id, version=version,
|
||
config_json=json.dumps(_jiancai_snapshot(prefix),
|
||
ensure_ascii=False)))
|
||
await session.commit()
|
||
|
||
|
||
async def _add_tenant(engine, slug, domain, prefix):
|
||
"""极简单租户(无 es 覆盖)——upsert 测试用。"""
|
||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||
tenant = Tenant(slug=slug, name=slug)
|
||
session.add(tenant)
|
||
await session.flush()
|
||
site = Site(tenant_id=tenant.id, slug="main", name="main")
|
||
session.add(site)
|
||
await session.flush()
|
||
session.add(SiteDomain(domain=domain, site_id=site.id))
|
||
session.add(TenantConfigVersion(
|
||
tenant_id=tenant.id, version=1,
|
||
config_json=json.dumps(_jiancai_snapshot(prefix),
|
||
ensure_ascii=False)))
|
||
await session.commit()
|
||
|
||
|
||
@pytest.fixture
|
||
async def platform_db_with_tenant(tmp_path):
|
||
"""tmp platform.db + jiancai(main+es 含 brand_overlay)+域名+版本1。"""
|
||
engine = await _make_engine(tmp_path)
|
||
await _add_jiancai(engine)
|
||
yield engine
|
||
await engine.dispose()
|
||
|
||
|
||
# ---- DB 源加载 ---------------------------------------------------------------
|
||
|
||
|
||
async def test_db_source_loads_tenant(platform_db_with_tenant):
|
||
src = DbTenantSource(platform_db_with_tenant)
|
||
loaded = await src.load_all()
|
||
assert [lt.slug for lt in loaded] == ["jiancai"]
|
||
lt = loaded[0]
|
||
assert lt.name == "HESUPIPE 建材" and lt.status == "active"
|
||
assert lt.snapshot["push"]["prefix"] == "ai-kefu-jc" # 快照组直读
|
||
assert "system" in lt.snapshot["prompts"] # prompts 嵌 config_json
|
||
assert lt.seed_dir is None # DB 源无种子目录
|
||
main = [s for s in lt.sites if s["slug"] == "main"][0]
|
||
es = [s for s in lt.sites if s["slug"] == "es"][0]
|
||
assert main["domains"] == [JC_DOMAIN]
|
||
assert es["brand"]["title"] == "HESUPIPE Soporte" # brand_overlay 生效
|
||
assert es["domains"] == []
|
||
|
||
|
||
async def test_db_source_load_tenant_single_and_unknown(
|
||
platform_db_with_tenant):
|
||
src = DbTenantSource(platform_db_with_tenant)
|
||
lt = await src.load_tenant("jiancai")
|
||
assert lt.slug == "jiancai"
|
||
with pytest.raises(LookupError, match="nope"):
|
||
await src.load_tenant("nope")
|
||
|
||
|
||
async def test_db_source_empty_db_raises(tmp_path):
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
with pytest.raises(ValueError, match="没有任何租户"):
|
||
await DbTenantSource(engine).load_all()
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_db_source_no_version_raises(tmp_path):
|
||
"""有租户无版本 → 显性 ValueError 带租户名(哪个租户缺版本一目了然)。"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
await _add_jiancai(engine, with_version=False)
|
||
with pytest.raises(ValueError) as excinfo:
|
||
await DbTenantSource(engine).load_all()
|
||
assert "jiancai" in str(excinfo.value) and "没有任何配置版本" in str(
|
||
excinfo.value)
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_db_source_bad_json_raises(tmp_path):
|
||
"""config_json 无效 JSON → 带租户名与版本号的清晰错误。
|
||
|
||
CHECK(json_valid) 挡住正常写入方,坏行只能来自约束启用前的历史/
|
||
外部直改——经 PRAGMA ignore_check_constraints 直插构造,防御路径
|
||
仍要显性报错。
|
||
"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
tid = await _add_jiancai(engine)
|
||
async with engine.connect() as conn:
|
||
await conn.exec_driver_sql("PRAGMA ignore_check_constraints=ON")
|
||
await conn.execute(
|
||
text("INSERT INTO tenant_config_versions "
|
||
"(tenant_id, version, config_json, updated_by, created_at) "
|
||
"VALUES (:t, :v, :c, 'test', '2026-01-01 00:00:00')"),
|
||
{"t": tid, "v": 2, "c": "{oops"})
|
||
await conn.commit()
|
||
with pytest.raises(ValueError) as excinfo:
|
||
await DbTenantSource(engine).load_tenant("jiancai")
|
||
msg = str(excinfo.value)
|
||
assert "jiancai" in msg and "版本 2" in msg and "JSON" in msg
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_db_source_non_object_json_raises(tmp_path):
|
||
"""合法 JSON 但非对象(如数组)→ 显性错误带租户名与版本号。"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
await _add_jiancai(engine, config_json="[1,2]")
|
||
with pytest.raises(ValueError) as excinfo:
|
||
await DbTenantSource(engine).load_all()
|
||
msg = str(excinfo.value)
|
||
assert "jiancai" in msg and "版本 1" in msg and "不是 JSON 对象" in msg
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_db_source_missing_prompts_group_raises(tmp_path):
|
||
"""快照缺 wall/prompts 组 → 显性错误带租户名与版本号(不留给
|
||
compile_wall/from_config 出无上下文的错)。"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
snapshot = _jiancai_snapshot()
|
||
del snapshot["prompts"]
|
||
await _add_jiancai(engine, config_json=json.dumps(
|
||
snapshot, ensure_ascii=False))
|
||
with pytest.raises(ValueError) as excinfo:
|
||
await DbTenantSource(engine).load_all()
|
||
msg = str(excinfo.value)
|
||
assert "jiancai" in msg and "prompts" in msg and "版本 1" in msg
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_db_source_filters_disabled_sites(tmp_path):
|
||
"""终审 I-1:site.status != active 的站点不进 LoadedTenant(域名不进
|
||
registry 索引 → 该域名 404;site_by_slug 取不到)。es 停用后只剩
|
||
main;全部停用 → 空 sites(零站点校验只对库表形状)。"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
await _add_jiancai(engine, es_status="disabled")
|
||
lt = await DbTenantSource(engine).load_tenant("jiancai")
|
||
assert [s["slug"] for s in lt.sites] == ["main"] # es 被过滤
|
||
main = lt.sites[0]
|
||
assert main["status"] == "active"
|
||
# TenantConfig 消费侧:Site 数据类带 status,域名索引只含 main
|
||
config = TenantConfig.from_snapshot(
|
||
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
||
assert [s.slug for s in config.sites] == ["main"]
|
||
assert config.sites[0].status == "active"
|
||
|
||
# 全部停用 = 合法运营状态(门面全下线),输出空 sites 不炸
|
||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||
await session.execute(text(
|
||
"UPDATE sites SET status='disabled'"))
|
||
await session.commit()
|
||
lt2 = await DbTenantSource(engine).load_tenant("jiancai")
|
||
assert lt2.sites == []
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
# ---- 文件源(直测;等价性由既有 test_registry 全绿证明)-----------------------
|
||
|
||
|
||
async def test_file_source_loads_same_snapshot_shape():
|
||
src = FileTenantSource(SEED_DIR)
|
||
loaded = await src.load_all()
|
||
by_slug = {lt.slug: lt for lt in loaded}
|
||
assert set(by_slug) == {"jiancai", "auto"}
|
||
jc = by_slug["jiancai"]
|
||
assert jc.snapshot["push"]["prefix"] == "ai-kefu-jc"
|
||
assert "system" in jc.snapshot["prompts"] # 行为增量:四件套并入快照
|
||
assert jc.seed_dir == SEED_DIR / "jiancai"
|
||
es = [s for s in jc.sites if s["slug"] == "es"][0]
|
||
assert es["brand"]["title"] == "HESUPIPE Soporte"
|
||
# 快照 prompts 与 from_dir 读盘逐字一致(等价迁移直证)
|
||
assert (PromptProvider.from_config(jc.snapshot["prompts"])
|
||
== PromptProvider.from_dir(SEED_DIR / "jiancai" / "prompts"))
|
||
|
||
|
||
def test_file_source_read_tenant_unknown_slug_raises(tmp_path):
|
||
"""reload upsert 的语义地基:文件源对未知 slug 报 LookupError
|
||
(registry 映射回 UnknownTenantError,不与坏数据的 ValueError 混)。"""
|
||
src = FileTenantSource(tmp_path) # 空目录:一切 slug 未知
|
||
with pytest.raises(LookupError, match="ghost"):
|
||
src.read_tenant("ghost")
|
||
|
||
|
||
def test_file_source_read_tenant_rereads_disk(tmp_path):
|
||
"""read_tenant 每次现读盘(reload 换配置的数据通路)。"""
|
||
tdir = tmp_path / "t1"
|
||
tdir.mkdir()
|
||
shutil.copyfile(SEED_DIR / "jiancai" / "wall.json", tdir / "wall.json")
|
||
shutil.copytree(SEED_DIR / "jiancai" / "prompts", tdir / "prompts")
|
||
(tdir / "tenant.json").write_text(json.dumps({
|
||
"slug": "t1", "name": "t1", "status": "active",
|
||
"sites": [{"slug": "main", "domains": ["a.example.com"]}],
|
||
"config": {"push": {"prefix": "v1"}}}, ensure_ascii=False), encoding="utf-8")
|
||
|
||
src = FileTenantSource(tmp_path)
|
||
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v1"
|
||
tenant = json.loads((tdir / "tenant.json").read_text(encoding="utf-8"))
|
||
tenant["config"]["push"]["prefix"] = "v2"
|
||
(tdir / "tenant.json").write_text(
|
||
json.dumps(tenant, ensure_ascii=False), encoding="utf-8")
|
||
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v2"
|
||
|
||
|
||
def test_file_source_filters_disabled_sites(tmp_path):
|
||
"""终审 I-1:tenant.json 站点带 status: "disabled" → 不进
|
||
LoadedTenant(文件源同步行为);无 status 键 → 默认 active,既有
|
||
种子零改动兼容。"""
|
||
_write_min_seed(tmp_path, {"t1": {
|
||
"slug": "t1", "name": "t1", "status": "active",
|
||
"sites": [
|
||
{"slug": "main", "domains": ["a.example.com"]}, # 无键=active
|
||
{"slug": "es", "status": "active", "domains": []},
|
||
{"slug": "down", "status": "disabled",
|
||
"domains": ["down.example.com"], "brand": {"title": "x"}},
|
||
],
|
||
"config": {}}})
|
||
lt = FileTenantSource(tmp_path / "seed").read_tenant("t1")
|
||
assert [s["slug"] for s in lt.sites] == ["main", "es"] # down 被滤
|
||
# 消费侧同构:from_snapshot 只见 active 站点
|
||
config = TenantConfig.from_snapshot(
|
||
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
||
assert [s.slug for s in config.sites] == ["main", "es"]
|
||
assert all(s.status == "active" for s in config.sites)
|
||
|
||
|
||
# ---- registry × DB 源(reload / upsert)--------------------------------------
|
||
|
||
|
||
async def test_registry_reload_via_db_source(tmp_path):
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
tid = await _add_jiancai(engine, prefix="v1")
|
||
reg = TenantRegistry(
|
||
DbTenantSource(engine), data_dir=tmp_path / "tenants")
|
||
await reg.load_all()
|
||
old = await reg.resolve(JC_DOMAIN)
|
||
assert old.config.data["push"]["prefix"] == "v1"
|
||
assert old.prompts.system # prompts 经 from_config 接线
|
||
|
||
await _add_version(engine, tid, 2, prefix="v2")
|
||
await reg.registry_reload("jiancai")
|
||
rt = await reg.resolve(JC_DOMAIN)
|
||
assert rt is not old
|
||
assert rt.config.data["push"]["prefix"] == "v2" # MAX(version) 生效
|
||
assert old.engine is None # 旧 engine 已 dispose
|
||
await reg.dispose_all()
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
async def test_registry_reload_upsert_new_tenant_db_source(tmp_path):
|
||
"""source 已知但未加载的 slug(DB 新建租户)→ reload 载入插入不炸;
|
||
source 也不知道的 slug → UnknownTenantError(原语义保留)。"""
|
||
engine = await _make_engine(tmp_path)
|
||
try:
|
||
await _add_jiancai(engine)
|
||
reg = TenantRegistry(
|
||
DbTenantSource(engine), data_dir=tmp_path / "tenants")
|
||
await reg.load_all()
|
||
|
||
await _add_tenant(engine, "demo", "demo.example.com", prefix="dm1")
|
||
await reg.registry_reload("demo") # 未加载的 slug:插入
|
||
rt = await reg.resolve("demo.example.com")
|
||
assert rt.config.slug == "demo"
|
||
assert rt.config.data["push"]["prefix"] == "dm1"
|
||
|
||
with pytest.raises(UnknownTenantError):
|
||
await reg.registry_reload("ghost")
|
||
await reg.dispose_all()
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
|
||
def _write_min_seed(tmp_path, tenants):
|
||
"""最小 seed 目录(拷真实 jiancai wall/prompts,免数据重复维护)。
|
||
幂等:第二次调用往同一 seed 下加新租户目录。"""
|
||
seed = tmp_path / "seed"
|
||
seed.mkdir(exist_ok=True)
|
||
for slug, tenant in tenants.items():
|
||
tdir = seed / slug
|
||
tdir.mkdir(exist_ok=True)
|
||
shutil.copyfile(SEED_DIR / "jiancai" / "wall.json", tdir / "wall.json")
|
||
shutil.copytree(SEED_DIR / "jiancai" / "prompts", tdir / "prompts",
|
||
dirs_exist_ok=True)
|
||
(tdir / "tenant.json").write_text(
|
||
json.dumps(tenant, ensure_ascii=False), encoding="utf-8")
|
||
return seed
|
||
|
||
|
||
async def test_registry_reload_upsert_new_tenant_file_source(tmp_path):
|
||
"""文件源同款 upsert 语义:新租户目录落盘后 reload 插入可 resolve
|
||
(Task 5 创建向导 + File 源部署形态的数据通路)。"""
|
||
_write_min_seed(tmp_path, {"t1": {
|
||
"slug": "t1", "name": "t1", "status": "active",
|
||
"sites": [{"slug": "main", "domains": ["a.example.com"]}],
|
||
"config": {}}})
|
||
reg = TenantRegistry.load_from_dir(
|
||
tmp_path / "seed", data_dir=tmp_path / "data")
|
||
|
||
_write_min_seed(tmp_path, {"t2": {
|
||
"slug": "t2", "name": "t2", "status": "active",
|
||
"sites": [{"slug": "main", "domains": ["b.example.com"]}],
|
||
"config": {}}})
|
||
await reg.registry_reload("t2")
|
||
rt = await reg.resolve("b.example.com")
|
||
assert rt.config.slug == "t2"
|
||
await reg.dispose_all()
|