feat: 配置API(分组校验管线/整份版本/回滚/重置出厂/试金石/平台设置)

- config_validation.validate_group: 九组校验管线(类型/键 allowlist/数值
  范围/prompts 大小上限 50KB·100KB/wall 全量交 compile_wall/push.prefix
  跨租户查重 spec E1),错误清单中文带字段路径
- config_routes: GET/PUT config(整组替换原样入版本,MAX+1,热 reload)/
  reset(按 Tenant.profile 取出厂档案组)/versions 列表·详情/rollback
  (旧版复制为新版本,历史不可变)/wall/test 试金石(逐句判定,不落库
  不 bump)/settings 平台设置 CRUD(仅超管,resolver 接线归 Phase 3)
- db.write_config_version: 版本写入公共路径(创建向导与配置 API 单一
  事实源);Tenant 落 profile 列(重置出厂按它取对应档案)
- _profile_snapshot/_blankify 迁 tenant_source(两路由模块共用);
  db_source_app/super_client 夹具收拢 conftest(两测试文件共用)
- 测试: 519 → 537 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 10:07:40 +08:00
parent ec2e17d5e3
commit 7502176190
10 changed files with 1412 additions and 89 deletions
+44
View File
@@ -23,6 +23,7 @@ registry 只认本模块的 TenantSource 协议(async load_all / load_tenant),
- 文件源数据坏:坏 JSON/slug 与目录不一致/零站点 → ValueError 带路径,
wall/prompts 文件缺失 → FileNotFoundError 带路径(read_text 原样)。
"""
import copy
import json
import pathlib
from dataclasses import dataclass
@@ -33,6 +34,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.engine.services.ai_service import PROMPTS_KEYS
from app.platform.db import Site, SiteDomain, Tenant, TenantConfigVersion
from app.platform.defaults import DEFAULT_CONFIG
@dataclass(frozen=True)
@@ -224,3 +226,45 @@ class DbTenantSource:
sites=sites,
snapshot=snapshot,
seed_dir=None)
# ---- 出厂档案快照(Task 5 创建向导/Task 6 重置出厂的公共路径) ----------------------
# 自 tenant_routes 迁入(Task 6 微审注记:配置 API 的 reset 也要用,挪公共位置
# 供两个路由模块消费——此处已是文件源的地盘,零新增依赖)。
# 出厂档案目录:与 app.main.SEED_DIR 同一锚定规则(仓库根/seed_profiles),
# 但不 import app.main——main 模块级挂路由,反向导入制造循环依赖。
SEED_DIR = pathlib.Path(__file__).resolve().parents[2] / "seed_profiles"
# blank 身份组清理清单(brand 的这几个键置出厂默认;其余 brand 字段保留)
_IDENTITY_BRAND_KEYS = ("title", "whatsapp", "email", "privacy_url")
def _blankify(snapshot: dict) -> dict:
"""出厂档案快照 → blank 形态:身份组置出厂默认,其余(含 wall/prompts)
原样保留。深拷贝后改——纯函数纪律,绝不动入参。"""
out = copy.deepcopy(snapshot)
brand = dict(out.get("brand") or {})
for key in _IDENTITY_BRAND_KEYS:
brand[key] = DEFAULT_CONFIG["brand"][key]
out["brand"] = brand
push = dict(out.get("push") or {})
push["prefix"] = DEFAULT_CONFIG["push"]["prefix"]
out["push"] = push
engine = dict(out.get("engine") or {})
engine["context_seed_keys"] = dict(
DEFAULT_CONFIG["engine"]["context_seed_keys"])
out["engine"] = engine
return out
def _profile_snapshot(profile: str) -> dict:
"""profile → 首版快照:auto/jiancai 读对应出厂档案原样;blank 读 jiancai
档案再去身份组(wall/prompts 保留——见 Task 5 模块 docstring)。
重置出厂(spec §5.6"代码库当前模板,随平台升级演进")与创建向导共用
本函数:每次调用现读 seed 目录,不缓存。未知 profile → LookupError
(read_tenant 的文件源契约,调用方映射 400)。"""
base_slug = "jiancai" if profile == "blank" else profile
snapshot = FileTenantSource(SEED_DIR).read_tenant(base_slug).snapshot
return _blankify(snapshot) if profile == "blank" else snapshot