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:
浅念
2026-10-12 16:44:49 +08:00
parent 603c03eb4a
commit 89339868db
4 changed files with 135 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""平台层(多租户):出厂默认、配置合并、租户/站点模型、注册表等。
Phase 1 逐步迁入;本包不得反向依赖 app/engine 之外的假设。"""
+30
View File
@@ -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
+35
View File
@@ -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)