feat: 出厂默认与快照深合并(E4 配置演进)
- app/platform/defaults.py:DEFAULT_CONFIG(spec §5.1 九组,brand/wall 待 Task 4 填充)+ deep_merge(补缺/不动入参/兄弟保留) - app/platform/tenant_config.py:TenantConfig/Site frozen dataclass;site.brand 以租户级 data["brand"] 为合并基线,租户自定义可被站点继承 - tests/test_config_merge.py:merge 三性质 + 站点覆盖/继承/无 sites 共 5 用例 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""平台层(多租户):出厂默认、配置合并、租户/站点模型、注册表等。
|
||||
Phase 1 逐步迁入;本包不得反向依赖 app/engine 之外的假设。"""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""出厂默认配置。结构 = spec §5.1 的 9 个配置组。
|
||||
本阶段 brand/wall 的默认值由 Task 4 从 jc 代码提取填充。"""
|
||||
import copy
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"brand": {
|
||||
"title": "AI Support", "color": "#066fc0", "avatar": "/static/kefu-small.png",
|
||||
"welcome": "", "topics": [], "placeholder": "",
|
||||
"whatsapp": "", "email": "", "privacy_url": "",
|
||||
},
|
||||
"wall": {}, # Task 4 填充
|
||||
"contact": {"cc_completion": True, "email_light_ask": True, "template_languages": ["en", "zh", "es"]},
|
||||
"email": {"smtp": None, "sender_name": "", "notify_emails": []},
|
||||
"push": {"enabled": False, "url": "", "api_key": "", "prefix": "", "timeout": 10,
|
||||
"max_attempts": 3, "retry_backoff_base": 1.0},
|
||||
"ai": {"provider": "glm", "api_key": None, "base_url": None, "model": "glm-4-flash"},
|
||||
"engine": {"context_seed_keys": {}, "rate_limit_per_hour": 30},
|
||||
"misc": {"name_to_region_extras": {}},
|
||||
}
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
"""override 逐层覆盖 base;dict 递归合并,其余类型整体替换。不修改入参。"""
|
||||
out = copy.deepcopy(base)
|
||||
for k, v in (override or {}).items():
|
||||
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||||
out[k] = deep_merge(out[k], v)
|
||||
else:
|
||||
out[k] = copy.deepcopy(v)
|
||||
return out
|
||||
@@ -0,0 +1,35 @@
|
||||
"""租户与站点配置模型。
|
||||
|
||||
- TenantConfig.data = deep_merge(DEFAULT_CONFIG, snapshot):租户快照 ⊕ 出厂默认
|
||||
- Site.brand 的合并基线是**租户级** data["brand"](而非出厂默认),
|
||||
这样租户级 brand 自定义也能被各站点继承。
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.platform.defaults import DEFAULT_CONFIG, deep_merge
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Site:
|
||||
slug: str
|
||||
name: str
|
||||
domains: tuple[str, ...]
|
||||
brand: dict # 站点门面覆盖层 ⊕ 租户默认 brand(已深合并)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantConfig:
|
||||
slug: str
|
||||
name: str
|
||||
status: str # active | disabled
|
||||
data: dict # deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
sites: tuple[Site, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_snapshot(cls, slug, name, snapshot, sites=(), status="active"):
|
||||
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
resolved = tuple(
|
||||
Site(s["slug"], s.get("name", s["slug"]), tuple(s.get("domains", ())),
|
||||
deep_merge(merged["brand"], s.get("brand", {})))
|
||||
for s in sites)
|
||||
return cls(slug=slug, name=name, status=status, data=merged, sites=resolved)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""出厂默认与快照深合并(E4 配置演进)——Task 3 TDD 用例。
|
||||
|
||||
覆盖:
|
||||
- deep_merge 三性质:缺省补齐 / 不动入参 / 兄弟键保留
|
||||
- TenantConfig.from_snapshot 的站点模型:覆盖生效、未覆盖项继承租户级 brand、
|
||||
无 sites 时空元组
|
||||
"""
|
||||
from app.platform.defaults import DEFAULT_CONFIG, deep_merge
|
||||
from app.platform.tenant_config import Site, TenantConfig
|
||||
|
||||
|
||||
# ---------- deep_merge 三性质 ----------
|
||||
|
||||
def test_missing_keys_filled_from_defaults():
|
||||
snapshot = {"brand": {"title": "X"}} # 快照缺一大堆键
|
||||
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
assert merged["brand"]["title"] == "X" # 覆盖生效
|
||||
assert "color" in merged["brand"] # 缺省补齐
|
||||
assert "wall" in merged and "push" in merged # 顶层组补齐
|
||||
|
||||
|
||||
def test_snapshot_not_mutated():
|
||||
snapshot = {"brand": {"title": "X"}}
|
||||
deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
assert snapshot == {"brand": {"title": "X"}}
|
||||
|
||||
|
||||
def test_nested_override_keeps_sibling():
|
||||
snapshot = {"email": {"sender_name": "【T】"}}
|
||||
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
assert merged["email"]["sender_name"] == "【T】"
|
||||
assert "notify_emails" in merged["email"] # 兄弟键保留默认
|
||||
|
||||
|
||||
# ---------- TenantConfig / Site 站点模型 ----------
|
||||
|
||||
def test_site_overrides_and_inherits_tenant_brand():
|
||||
"""站点覆盖项生效;未覆盖项继承租户级 brand(而非出厂默认)。"""
|
||||
tenant = TenantConfig.from_snapshot(
|
||||
slug="t1",
|
||||
name="Tenant 1",
|
||||
snapshot={"brand": {"title": "T Co", "welcome": "hi from tenant"}},
|
||||
sites=[
|
||||
{"slug": "s1", "name": "Site 1", "domains": ["a.com"],
|
||||
"brand": {"color": "#ff0000"}},
|
||||
],
|
||||
)
|
||||
site = tenant.sites[0]
|
||||
assert isinstance(site, Site)
|
||||
assert site.slug == "s1"
|
||||
assert site.name == "Site 1"
|
||||
assert site.domains == ("a.com",)
|
||||
assert site.brand["color"] == "#ff0000" # 站点覆盖生效
|
||||
assert site.brand["title"] == "T Co" # 继承租户级 brand
|
||||
assert site.brand["welcome"] == "hi from tenant"
|
||||
# 站点 brand 是独立副本,不与租户 data 共享可变对象
|
||||
assert site.brand is not tenant.data["brand"]
|
||||
|
||||
|
||||
def test_tenant_without_sites():
|
||||
"""无 sites 时 sites 为空元组,data 正常深合并。"""
|
||||
tenant = TenantConfig.from_snapshot(
|
||||
slug="t2", name="Tenant 2", snapshot={"ai": {"model": "glm-4-plus"}},
|
||||
)
|
||||
assert tenant.sites == ()
|
||||
assert tenant.status == "active"
|
||||
assert tenant.data["ai"]["model"] == "glm-4-plus" # 覆盖生效
|
||||
assert tenant.data["push"]["enabled"] is False # 缺省补齐
|
||||
Reference in New Issue
Block a user