From 89339868db7bc39bf64b95aa6f9d28b2e06bcd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 12 Oct 2026 16:44:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=87=BA=E5=8E=82=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=8E=E5=BF=AB=E7=85=A7=E6=B7=B1=E5=90=88=E5=B9=B6=EF=BC=88?= =?UTF-8?q?E4=20=E9=85=8D=E7=BD=AE=E6=BC=94=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/platform/__init__.py | 2 ++ app/platform/defaults.py | 30 ++++++++++++++++ app/platform/tenant_config.py | 35 ++++++++++++++++++ tests/test_config_merge.py | 68 +++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 app/platform/__init__.py create mode 100644 app/platform/defaults.py create mode 100644 app/platform/tenant_config.py create mode 100644 tests/test_config_merge.py diff --git a/app/platform/__init__.py b/app/platform/__init__.py new file mode 100644 index 0000000..d43e6f7 --- /dev/null +++ b/app/platform/__init__.py @@ -0,0 +1,2 @@ +"""平台层(多租户):出厂默认、配置合并、租户/站点模型、注册表等。 +Phase 1 逐步迁入;本包不得反向依赖 app/engine 之外的假设。""" diff --git a/app/platform/defaults.py b/app/platform/defaults.py new file mode 100644 index 0000000..1de5ce0 --- /dev/null +++ b/app/platform/defaults.py @@ -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 diff --git a/app/platform/tenant_config.py b/app/platform/tenant_config.py new file mode 100644 index 0000000..12a81f4 --- /dev/null +++ b/app/platform/tenant_config.py @@ -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) diff --git a/tests/test_config_merge.py b/tests/test_config_merge.py new file mode 100644 index 0000000..2f508f5 --- /dev/null +++ b/tests/test_config_merge.py @@ -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 # 缺省补齐