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
+256
View File
@@ -0,0 +1,256 @@
"""配置校验管线(Phase 2 Task 6)——PUT /config/{group} 的分组校验。
validate_group(group, payload, context) -> list[str]:错误清单(中文,带
字段路径),空清单 = 通过;路由层把非空清单拼进 400 detail。
纪律:
- **键 allowlist**:各组未知键显性拒绝(与 wall_schema._KNOWN_FIELDS 同款
防呆——配置编辑器时代拼错键 = 深合并静默忽略,这里前移到保存期报错)。
wall 组例外:未知键交 compile_wall 管(它的报错自带 wall 侧字段路径)。
- **单一事实源**:各组合法键从 DEFAULT_CONFIG 派生(prompts 除外——
DEFAULT 不含 prompts,取 ai_service.PROMPTS_KEYS 四件套)。
- **wall 组不重复造校验**:深拷贝 payload 交 compile_wall,复用全部既有
校验(键 allowlist/占位符白名单/flags/正则编译/必填池)。
- **prompts 组**:四键字符串 + 大小上限(company_facts ≤50KB 软上限,
spec §5.7——每轮整段拼入 system prompt,过大门线性放大 LLM 成本;
其余三件 ≤100KB)。字节口径按 UTF-8 编码长度。
- **push.prefix 跨租户查重**(spec E1,external_key 前缀全局唯一防 CRM
线索空间串号):context.other_prefixes 由路由层从 DB 各租户当前版本
收集(排除自身;空串 = 未设置,不参战——出厂默认本就多租户同空)。
- 数值字段(None = "未设置"哨兵,与 DEFAULT_CONFIG 同款纪律):None 或
范围内数值;bool 不是 int(isinstance 陷阱,显式排除)。
"""
import copy
from dataclasses import dataclass, field
from app.engine.services.ai_service import PROMPTS_KEYS
from app.engine.wall_schema import WallConfigError, compile_wall
from app.platform.defaults import DEFAULT_CONFIG
# 各组合法键 = DEFAULT_CONFIG 同组键(单一事实源);prompts 特例见上。
_ALLOWED_KEYS: dict[str, frozenset] = {
group: frozenset(keys) for group, keys in DEFAULT_CONFIG.items()}
_ALLOWED_KEYS["prompts"] = frozenset(PROMPTS_KEYS)
# 合法组名(排序输出进错误信息,稳定可测)
CONFIG_GROUPS = frozenset(_ALLOWED_KEYS)
# prompts 大小上限(字节,UTF-8 口径):company_facts 50KB 软上限,其余 100KB
PROMPTS_SIZE_CAPS = {
name: 50 * 1024 if name == "company_facts" else 100 * 1024
for name in PROMPTS_KEYS}
_BRAND_STR_KEYS = ("title", "color", "avatar", "welcome", "placeholder",
"whatsapp", "email", "privacy_url")
@dataclass(frozen=True)
class ValidationContext:
"""跨租户校验上下文(路由层按请求现势收集,校验管线不自查 DB)。"""
other_prefixes: frozenset = field(default_factory=frozenset)
def _type_name(value) -> str:
return type(value).__name__
def _want_str(value, path: str, errors: list) -> None:
if not isinstance(value, str):
errors.append(f"{path}: 须为字符串(实际 {_type_name(value)})")
def _want_bool(value, path: str, errors: list) -> None:
if not isinstance(value, bool):
errors.append(f"{path}: 须为布尔(实际 {_type_name(value)})")
def _want_int(value, path: str, errors: list) -> None:
# bool 是 int 的子类——True 会被 isinstance 放过,显式排除
if not isinstance(value, int) or isinstance(value, bool):
errors.append(f"{path}: 须为整数(实际 {_type_name(value)})")
def _want_str_list(value, path: str, errors: list) -> None:
if not isinstance(value, list):
errors.append(f"{path}: 须为字符串列表(实际 {_type_name(value)})")
return
for i, item in enumerate(value):
if not isinstance(item, str):
errors.append(f"{path}[{i}]: 项须为字符串(实际 {_type_name(item)})")
def _check_brand(payload: dict, ctx: ValidationContext, errors: list) -> None:
for key in _BRAND_STR_KEYS:
if key in payload:
_want_str(payload[key], f"brand.{key}", errors)
if "topics" in payload:
_want_str_list(payload["topics"], "brand.topics", errors)
def _check_wall(payload: dict, ctx: ValidationContext, errors: list) -> None:
"""全量交 compile_wall(深拷贝防意外 mutation):键 allowlist/占位符/
flags/正则编译/必填池全套复用,WallConfigError 转清单项。"""
try:
compile_wall(copy.deepcopy(payload))
except WallConfigError as exc:
errors.append(str(exc))
def _check_prompts(payload: dict, ctx: ValidationContext,
errors: list) -> None:
for key in PROMPTS_KEYS:
if key not in payload:
errors.append(f"prompts.{key}: 缺少必填键(四件套须整组提交)")
continue
text = payload[key]
if not isinstance(text, str):
errors.append(f"prompts.{key}: 须为字符串(实际 {_type_name(text)})")
continue
size = len(text.encode("utf-8"))
cap = PROMPTS_SIZE_CAPS[key]
if size > cap:
errors.append(
f"prompts.{key}: 内容 {size} 字节超过上限 {cap} 字节"
f"({cap // 1024}KB,spec §5.7——每轮拼入 system prompt,"
f"请精炼)")
def _check_contact(payload: dict, ctx: ValidationContext,
errors: list) -> None:
for key in ("cc_completion", "email_light_ask"):
if key in payload:
_want_bool(payload[key], f"contact.{key}", errors)
if "template_languages" in payload:
_want_str_list(payload["template_languages"],
"contact.template_languages", errors)
def _check_email(payload: dict, ctx: ValidationContext, errors: list) -> None:
if "sender_name" in payload:
_want_str(payload["sender_name"], "email.sender_name", errors)
if "smtp" in payload and not (payload["smtp"] is None
or isinstance(payload["smtp"], dict)):
errors.append(f"email.smtp: 须为映射或 null(实际 "
f"{_type_name(payload['smtp'])})")
if "notify_emails" in payload:
value = payload["notify_emails"]
if not isinstance(value, list):
errors.append(f"email.notify_emails: 须为列表(实际 "
f"{_type_name(value)})")
return
for i, item in enumerate(value):
if not isinstance(item, str) or "@" not in item:
errors.append(f"email.notify_emails[{i}]: 项须为含 @ 的邮箱"
f"地址(实际 {item!r})")
def _check_push(payload: dict, ctx: ValidationContext, errors: list) -> None:
if "enabled" in payload:
_want_bool(payload["enabled"], "push.enabled", errors)
for key in ("url", "api_key", "prefix"):
if key in payload:
_want_str(payload[key], f"push.{key}", errors)
if "timeout" in payload:
_want_positive_number(payload["timeout"], "push.timeout", errors)
if "retry_backoff_base" in payload:
_want_positive_number(payload["retry_backoff_base"],
"push.retry_backoff_base", errors)
if "max_attempts" in payload:
value = payload["max_attempts"]
if value is not None and (not isinstance(value, int)
or isinstance(value, bool)
or not 1 <= value <= 10):
errors.append(f"push.max_attempts: 须为 1-10 整数或 null(实际 "
f"{value!r})")
# spec E1: external_key 前缀全局唯一(空串 = 未设置,不参战)
prefix = payload.get("prefix")
if isinstance(prefix, str) and prefix and prefix in ctx.other_prefixes:
errors.append(f"push.prefix: {prefix!r} 已被其他租户占用"
f"(external_key 前缀全局唯一)")
def _want_positive_number(value, path: str, errors: list) -> None:
if value is None:
return
if (not isinstance(value, (int, float)) or isinstance(value, bool)
or value <= 0):
errors.append(f"{path}: 须为正数或 null(实际 {value!r})")
def _check_ai(payload: dict, ctx: ValidationContext, errors: list) -> None:
for key in ("provider", "model"):
if key in payload:
_want_str(payload[key], f"ai.{key}", errors)
for key in ("api_key", "base_url"):
if key in payload:
value = payload[key]
if value is not None and not isinstance(value, str):
errors.append(f"ai.{key}: 须为字符串或 null(实际 "
f"{_type_name(value)})")
def _check_engine(payload: dict, ctx: ValidationContext,
errors: list) -> None:
for key in ("rate_limit_per_hour", "ws_conn_limit"):
if key in payload:
value = payload[key]
_want_int(value, f"engine.{key}", errors)
if isinstance(value, int) and not isinstance(value, bool) \
and value < 1:
errors.append(f"engine.{key}: 须为正整数(实际 {value})")
if "context_seed_keys" in payload:
value = payload["context_seed_keys"]
if not isinstance(value, dict):
errors.append(f"engine.context_seed_keys: 须为映射(实际 "
f"{_type_name(value)})")
return
for k, v in value.items():
if not isinstance(v, str):
errors.append(f"engine.context_seed_keys.{k}: 须为字符串"
f"(实际 {_type_name(v)})")
def _check_misc(payload: dict, ctx: ValidationContext, errors: list) -> None:
if "name_to_region_extras" in payload:
value = payload["name_to_region_extras"]
if not isinstance(value, dict):
errors.append(f"misc.name_to_region_extras: 须为映射(实际 "
f"{_type_name(value)})")
return
for k, v in value.items():
if not isinstance(v, str):
errors.append(f"misc.name_to_region_extras.{k}: 须为字符串"
f"(实际 {_type_name(v)})")
_GROUP_CHECKERS = {
"brand": _check_brand,
"wall": _check_wall,
"contact": _check_contact,
"email": _check_email,
"push": _check_push,
"ai": _check_ai,
"engine": _check_engine,
"misc": _check_misc,
"prompts": _check_prompts,
}
def validate_group(group: str, payload, context: ValidationContext) -> list:
"""校验单个配置组的 payload(整组替换语义:只查出现键的类型/形状,
缺省键由消费侧深合并出厂默认补)。返回错误清单(中文,带字段路径),
空清单 = 通过。未知组名/非对象 payload 也走清单(路由统一转 400)。"""
checker = _GROUP_CHECKERS.get(group)
if checker is None:
return [f"未知配置组: {group!r}(合法: "
f"{', '.join(sorted(CONFIG_GROUPS))})"]
if not isinstance(payload, dict):
return [f"{group}: 须为 JSON 对象(实际 {_type_name(payload)})"]
errors: list = []
if group != "wall": # wall 的未知键交 compile_wall(报错自带其字段路径)
unknown = set(payload) - _ALLOWED_KEYS[group]
if unknown:
errors.append(f"{group}: 未知键 {', '.join(sorted(unknown))}"
f"(合法: {', '.join(sorted(_ALLOWED_KEYS[group]))})")
checker(payload, context, errors)
return errors
+29
View File
@@ -26,6 +26,7 @@ PlatformBase.metadata,engine/models.ensure_schema 只跑 Base.metadata——
(Windows 文件锁,同 registry)。
"""
import datetime
import json
import pathlib
from sqlalchemy import (
@@ -38,6 +39,7 @@ from sqlalchemy import (
Text,
UniqueConstraint,
event,
func,
select,
)
from sqlalchemy.exc import IntegrityError
@@ -69,6 +71,10 @@ class Tenant(PlatformBase):
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16), default="active")
# 创建向导选定的出厂档案(Task 6 起):重置出厂按它取对应档案组
# (auto/jiancai 档案 / blank = jiancai 去身份组)。缺省 blank——
# 正常路径一律经创建向导写入,缺省只是手工插行的兜底语义。
profile: Mapped[str] = mapped_column(String(16), default="blank")
created_at: Mapped[datetime.datetime] = mapped_column(default=_utcnow)
@@ -176,6 +182,29 @@ async def ensure_platform_schema(engine: AsyncEngine) -> None:
# ---- 密码哈希:定义在 auth.py(见文件头 re-export)---------------------------
# ---- 配置版本写入(Task 6 抽公共:创建向导与配置 API 的单一版本写路径) --------
async def write_config_version(
session: AsyncSession, tenant_id: int, snapshot: dict, *,
updated_by: str = "", comment: str | None = None) -> int:
"""写新配置版本:MAX(version)+1,整份快照直存(spec §5.2 历史不可变,
当前生效 = MAX)。Task 5 创建向导(版本 1)与 Task 6 配置 API(PUT 分组
编辑/rollback 复制旧版/reset 重置出厂)的公共写入路径——两处各自
MAX+1 会漂移,收敛为单一事实源。调用方负责 commit;返回新版本号。"""
current = (await session.execute(
select(func.max(TenantConfigVersion.version))
.where(TenantConfigVersion.tenant_id == tenant_id))).scalar()
version = (current or 0) + 1
session.add(TenantConfigVersion(
tenant_id=tenant_id,
version=version,
config_json=json.dumps(snapshot, ensure_ascii=False),
updated_by=updated_by,
comment=comment))
return version
# ---- 超管引导 ---------------------------------------------------------------
async def bootstrap_superuser(
+433
View File
@@ -0,0 +1,433 @@
"""配置 API(Phase 2 Task 6):/api/platform/tenants/{slug}/config* 与
/api/platform/settings。
能力面(校验管线见 config_validation.py):
- GET /config:当前生效快照(MAX 版本)+ version。返回**原样快照**——
DEFAULT_CONFIG 不入版本(出厂模板演进不被旧版本锁死,spec E4),消费侧
(registry → TenantConfig.from_snapshot)深合并;
- PUT /config/{group}:校验 → 读当前快照 → **整组替换** → write_config_version
(MAX+1)→ registry_reload 热生效。updated_by 取认证主体(super#id /
tenant#id),comment 走查询参数(可选);
- POST /config/{group}/reset:重置出厂 = 代码库当前模板(spec §5.6,随平台
升级演进)——按 Tenant.profile 取 _profile_snapshot 的对应组,档案缺的组
取 DEFAULT_CONFIG 整组;
- GET /config/versions(列表:版本/时间/作者/备注,不含 config 本体)与
/config/versions/{n}(详情含整份);
- POST /config/rollback {version}:目标版本复制为新版本(历史不可变),
reload 生效;
- POST /wall/test(试金石):body 可带 wall 覆盖(先 compile_wall 校验)或
缺省用当前墙——逐句调 boundary_filter 纯函数给判定,**不落库不 bump 版本**
(reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致:
价格墙 → 零号码墙 → 再索要墙(仅 has_contact);
- GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落
存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认
回退链接入归 Phase 3。
鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户
管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。
版本写入一律走 db.write_config_version(与 Task 5 创建向导同一 MAX+1 路径)。
"""
import copy
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.services import boundary_filter
from app.engine.wall_schema import WallConfigError, compile_wall
from app.platform.auth import UserContext, require_super
from app.platform.config_validation import (
CONFIG_GROUPS,
ValidationContext,
validate_group,
)
from app.platform.db import (
PlatformSetting,
Tenant,
TenantConfigVersion,
write_config_version,
)
from app.platform.defaults import DEFAULT_CONFIG
from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard
from app.platform.tenant_source import _profile_snapshot
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-config"])
settings_router = APIRouter(
prefix="/api/platform/settings", tags=["platform-settings"])
# ---- 助手 -----------------------------------------------------------------------
async def _json_body(request: Request) -> dict:
"""手动解析 JSON 对象请求体:非 JSON/非对象一律 400(带实际类型),
不让 FastAPI 的 422 抢答——配置 API 的错误契约是"400 带字段路径"。"""
try:
body = await request.json()
except ValueError as exc:
raise HTTPException(status_code=400,
detail="请求体必须是合法 JSON 对象") from exc
if not isinstance(body, dict):
raise HTTPException(
status_code=400,
detail=f"请求体必须是 JSON 对象(实际 {type(body).__name__})")
return body
async def _current_version_row(
session: AsyncSession, tenant_id: int) -> TenantConfigVersion | None:
"""当前生效版本行(= MAX(version),db.py 纪律)。"""
return (await session.execute(
select(TenantConfigVersion)
.where(TenantConfigVersion.tenant_id == tenant_id)
.order_by(TenantConfigVersion.version.desc())
.limit(1))).scalar_one_or_none()
async def _load_current(
session: AsyncSession, tenant_id: int, slug: str
) -> tuple[TenantConfigVersion, dict]:
"""当前生效版本行 + 解析后的快照。无版本(防御——创建向导必写版本 1)
→ 404;坏 JSON(库层数据损坏)→ 500 显性报,不静默。"""
row = await _current_version_row(session, tenant_id)
if row is None:
raise HTTPException(
status_code=404, detail=f"租户 {slug!r} 没有任何配置版本")
try:
snapshot = json.loads(row.config_json)
except ValueError as exc:
raise HTTPException(
status_code=500,
detail=f"租户 {slug!r} 版本 {row.version} 的 config_json 损坏"
) from exc
return row, snapshot
async def _other_prefixes(
session: AsyncSession, exclude_tenant_id: int) -> frozenset:
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源,排除自身;
空/坏数据不参战)。MAX(version) 子查询定位每租户当前版。"""
max_versions = (
select(TenantConfigVersion.tenant_id.label("tid"),
func.max(TenantConfigVersion.version).label("mv"))
.group_by(TenantConfigVersion.tenant_id).subquery())
rows = (await session.execute(
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
prefixes = set()
for tenant_id, config_json in rows:
if tenant_id == exclude_tenant_id:
continue
try:
snapshot = json.loads(config_json)
except ValueError:
continue
push = snapshot.get("push") if isinstance(snapshot, dict) else None
prefix = push.get("prefix") if isinstance(push, dict) else None
if isinstance(prefix, str) and prefix:
prefixes.add(prefix)
return frozenset(prefixes)
def _updated_by(user: UserContext) -> str:
return f"{user.role}#{user.user_id}"
# ---- 配置快照 / 分组编辑 ----------------------------------------------------------
@router.get("/{slug}/config")
async def get_config(slug: str, request: Request,
user=Depends(_tenant_guard)):
"""当前生效快照(MAX 版本)+ version。原样快照,非合并视图——缺省键
由消费侧(registry)深合并 DEFAULT_CONFIG。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
row, snapshot = await _load_current(session, tenant.id, slug)
return {"slug": slug, "version": row.version, "config": snapshot}
@router.put("/{slug}/config/{group}")
async def put_config_group(
slug: str, group: str, request: Request, comment: str | None = None,
user=Depends(_tenant_guard)):
"""分组编辑:校验(config_validation 管线)→ 整组替换当前快照该组 →
MAX+1 写新版本 → registry_reload 热生效。快照存用户配置**原样组**
(组内缺省键消费侧深合并出厂默认补)。"""
body = await _json_body(request)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
context = ValidationContext(
other_prefixes=await _other_prefixes(session, tenant.id))
errors = validate_group(group, body, context)
if errors:
raise HTTPException(status_code=400, detail="; ".join(errors))
_, current = await _load_current(session, tenant.id, slug)
new_snapshot = dict(current)
new_snapshot[group] = copy.deepcopy(body)
version = await write_config_version(
session, tenant.id, new_snapshot,
updated_by=_updated_by(user), comment=comment)
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"slug": slug, "group": group, "version": version,
"config": new_snapshot[group]}
@router.post("/{slug}/config/{group}/reset")
async def reset_config_group(
slug: str, group: str, request: Request, user=Depends(_tenant_guard)):
"""重置出厂(spec §5.6 = 代码库当前模板,非创建时的版本 1 快照):
按租户创建 profile 取 _profile_snapshot 对应组;档案快照缺的组
(contact/misc/ai)取 DEFAULT_CONFIG 整组。产新版本 + reload。"""
if group not in CONFIG_GROUPS:
raise HTTPException(
status_code=400,
detail=f"未知配置组: {group!r}(合法: "
f"{', '.join(sorted(CONFIG_GROUPS))})")
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
try:
factory = _profile_snapshot(tenant.profile)
except LookupError as exc:
raise HTTPException(
status_code=400, detail=f"出厂档案缺失: {exc}") from exc
if group in factory:
value = copy.deepcopy(factory[group])
else:
value = copy.deepcopy(DEFAULT_CONFIG[group])
_, current = await _load_current(session, tenant.id, slug)
new_snapshot = dict(current)
new_snapshot[group] = value
version = await write_config_version(
session, tenant.id, new_snapshot,
updated_by=_updated_by(user),
comment=f"重置出厂: {group}")
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"slug": slug, "group": group, "version": version, "config": value}
# ---- 版本历史 / 回滚 ---------------------------------------------------------------
@router.get("/{slug}/config/versions")
async def list_config_versions(
slug: str, request: Request, user=Depends(_tenant_guard)):
"""版本列表(升序):版本/时间/作者/备注——不含 config_json 本体
(列表几十 KB × N 版本会白白撑爆响应)。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
rows = (await session.execute(
select(TenantConfigVersion)
.where(TenantConfigVersion.tenant_id == tenant.id)
.order_by(TenantConfigVersion.version))).scalars().all()
return {"slug": slug, "items": [{
"version": row.version,
"created_at": row.created_at.isoformat(),
"updated_by": row.updated_by,
"comment": row.comment,
} for row in rows]}
@router.get("/{slug}/config/versions/{version}")
async def get_config_version(
slug: str, version: int, request: Request, user=Depends(_tenant_guard)):
"""单版本详情(含整份 config)。不存在 → 404。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
row = (await session.execute(
select(TenantConfigVersion)
.where(TenantConfigVersion.tenant_id == tenant.id,
TenantConfigVersion.version == version)
)).scalar_one_or_none()
if row is None:
raise HTTPException(
status_code=404, detail=f"版本不存在: {version}")
return {
"slug": slug, "version": row.version,
"created_at": row.created_at.isoformat(),
"updated_by": row.updated_by, "comment": row.comment,
"config": json.loads(row.config_json),
}
@router.post("/{slug}/config/rollback")
async def rollback_config(
slug: str, request: Request, user=Depends(_tenant_guard)):
"""回滚:目标版本整份复制为新版本(历史不可变——旧版本行永不改写),
reload 生效。"""
body = await _json_body(request)
target = body.get("version")
if not isinstance(target, int) or isinstance(target, bool) or target < 1:
raise HTTPException(
status_code=400, detail="version: 须为正整数(实际 "
f"{target!r})")
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
row = (await session.execute(
select(TenantConfigVersion)
.where(TenantConfigVersion.tenant_id == tenant.id,
TenantConfigVersion.version == target)
)).scalar_one_or_none()
if row is None:
raise HTTPException(
status_code=404, detail=f"版本不存在: {target}")
snapshot = json.loads(row.config_json)
new_version = await write_config_version(
session, tenant.id, snapshot,
updated_by=_updated_by(user),
comment=f"回滚自版本 {target}")
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"slug": slug, "rollback_to": target, "version": new_version,
"config": snapshot}
# ---- 试金石(wall/test) -----------------------------------------------------------
_WALL_TEST_KEYS = frozenset({"sentences", "wall", "has_contact", "source"})
def _judge_sentence(sentence: str, wall, *, has_contact: bool,
source: str) -> dict:
"""单句判定——优先级与 conversation.py 执行侧逐句门控一致:
价格墙(掐断+替换)→ 零号码墙(替换)→ 再索要墙(仅已留资,静默丢弃)。
替换话术同款取自墙词表:deflection_for(首句形态,emitted 为空的分支)/
no_contact 按 has_contact·source 分支 / 再索要无替换(丢句不掐断)。"""
if boundary_filter.violates_boundary(sentence, wall=wall):
return {"sentence": sentence, "action": "blocked", "reason": "boundary",
"replacement": boundary_filter.deflection_for(
sentence, has_contact=has_contact, wall=wall)}
if boundary_filter.no_contact_value(sentence, wall=wall):
replacement = (
wall.no_contact["already"] if has_contact
else wall.no_contact["app"] if source == "app"
else wall.no_contact["web"])
return {"sentence": sentence, "action": "replaced",
"reason": "no_contact", "replacement": replacement}
if has_contact and boundary_filter.asks_contact(sentence, wall=wall):
return {"sentence": sentence, "action": "blocked", "reason": "reask",
"replacement": None}
return {"sentence": sentence, "action": "pass", "reason": None,
"replacement": None}
@router.post("/{slug}/wall/test")
async def test_wall(slug: str, request: Request, user=Depends(_tenant_guard)):
"""试金石:逐句墙判定预览。body:
- sentences: 非空字符串列表(必填);
- wall: 覆盖墙(先 compile_wall 校验,坏墙 400 带字段路径)——缺省用
当前生效墙(reset/编辑器改墙前的预览入口);
- has_contact: 客户已留联系方式(默认 False——启用再索要墙/换话术池);
- source: "web" | "app"(默认 web——零号码墙替换句分支)。
纯内存判定:不落库、不 bump 版本、不 reload。"""
body = await _json_body(request)
unknown = set(body) - _WALL_TEST_KEYS
if unknown:
raise HTTPException(
status_code=400,
detail=f"未知字段: {', '.join(sorted(unknown))}(合法: "
f"{', '.join(sorted(_WALL_TEST_KEYS))})")
sentences = body.get("sentences")
if not isinstance(sentences, list) or not sentences:
raise HTTPException(status_code=400,
detail="sentences: 须为非空字符串列表")
for i, sentence in enumerate(sentences):
if not isinstance(sentence, str):
raise HTTPException(
status_code=400,
detail=f"sentences[{i}]: 项须为字符串(实际 "
f"{type(sentence).__name__})")
has_contact = body.get("has_contact", False)
if not isinstance(has_contact, bool):
raise HTTPException(
status_code=400,
detail=f"has_contact: 须为布尔(实际 {type(has_contact).__name__})")
source = body.get("source", "web")
if source not in ("web", "app"):
raise HTTPException(
status_code=400, detail=f"source: 只允许 web/app(实际 {source!r})")
engine = request.app.state.platform_engine
wall_override = body.get("wall")
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
if wall_override is not None:
wall_source = "override"
candidate = wall_override
else:
wall_source = "current"
_, snapshot = await _load_current(session, tenant.id, slug)
candidate = snapshot.get("wall")
try:
compiled = compile_wall(copy.deepcopy(candidate))
except WallConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
results = [_judge_sentence(s, compiled, has_contact=has_contact,
source=source) for s in sentences]
return {"slug": slug, "wall_source": wall_source, "results": results}
# ---- 平台设置(仅超管;resolver 回退链接入归 Phase 3) -----------------------------
def _decode_setting(raw: str):
"""Text 列还原类型:json.loads 失败(手工 SQL 塞的裸文本)回退原串。"""
try:
return json.loads(raw)
except ValueError:
return raw
@settings_router.get("")
async def list_settings(request: Request, user=Depends(require_super)):
"""平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)——本阶段只落
存储与接口;消费(resolver 平台默认回退链)归 Phase 3。"""
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
rows = (await session.execute(
select(PlatformSetting).order_by(PlatformSetting.key))
).scalars().all()
return {"items": [{"key": row.key, "value": _decode_setting(row.value)}
for row in rows]}
@settings_router.put("")
async def put_setting(request: Request, user=Depends(require_super)):
"""upsert 单个设置项:key 1-64 字符;value 任意 JSON(json.dumps 入
Text,读回还原)。同 key 再写 = 整值替换。"""
body = await _json_body(request)
key = body.get("key")
if not isinstance(key, str) or not 1 <= len(key) <= 64:
raise HTTPException(
status_code=400, detail="key: 须为 1-64 字符字符串")
if "value" not in body:
raise HTTPException(status_code=400, detail="value: 缺少(必填)")
encoded = json.dumps(body["value"], ensure_ascii=False)
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
row = (await session.execute(
select(PlatformSetting).where(PlatformSetting.key == key)
)).scalar_one_or_none()
if row is None:
session.add(PlatformSetting(key=key, value=encoded))
else:
row.value = encoded
await session.commit()
return {"key": key, "value": body["value"]}
+22 -45
View File
@@ -23,10 +23,12 @@ DEFAULT_CONFIG 满足不了。
M-2a 陷阱:Site.brand_overlay 是 ORM JSON 列——写时**整字典赋值*,不做
按键局部更新(局部改不标记脏,静默丢写)。
Task 6 增量:出厂档案快照助手(_profile_snapshot/_blankify)迁至
tenant_source.py(配置 API 的重置出厂同样消费);版本 1 写入改走
db.write_config_version(与配置 API 的 PUT/rollback/reset 同一版本写入
路径,单一事实源);Tenant 落 profile 列(重置出厂按它取对应档案组)。
"""
import copy
import json
import pathlib
import re
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -42,50 +44,23 @@ from app.platform.auth import (
require_super,
require_tenant,
)
from app.platform.db import AdminUser, Site, SiteDomain, Tenant, TenantConfigVersion
from app.platform.defaults import DEFAULT_CONFIG
from app.platform.db import (
AdminUser,
Site,
SiteDomain,
Tenant,
TenantConfigVersion,
write_config_version,
)
from app.platform.registry import _normalize_host
from app.platform.tenant_source import FileTenantSource
from app.platform.tenant_source import _profile_snapshot
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-tenants"])
# 出厂档案目录:与 app.main.SEED_DIR 同一锚定规则(仓库根/seed_profiles),
# 但不 import app.main——main 模块级挂路由,反向导入制造循环依赖。
SEED_DIR = pathlib.Path(__file__).resolve().parents[3] / "seed_profiles"
PROFILES = ("auto", "jiancai", "blank")
STATUSES = ("active", "disabled")
SLUG_RE = re.compile(r"^[a-z0-9-]+$")
# 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 保留——见模块 docstring)。"""
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
# ---- 校验助手 -------------------------------------------------------------------
@@ -253,7 +228,8 @@ async def create_tenant(
for domain in domains:
await _assert_domain_free(session, domain)
tenant = Tenant(slug=body.slug, name=body.name, status="active")
tenant = Tenant(slug=body.slug, name=body.name, status="active",
profile=body.profile)
session.add(tenant)
await session.flush()
site = Site(tenant_id=tenant.id, slug="main", name="main")
@@ -268,12 +244,13 @@ async def create_tenant(
password_hash=password_hash,
salt=salt,
must_change_password=True))
session.add(TenantConfigVersion(
tenant_id=tenant.id,
version=1,
config_json=json.dumps(snapshot, ensure_ascii=False),
# 版本写入走公共 helper(Task 6:与配置 API 的 PUT/rollback/reset
# 同一条 MAX(version)+1 路径;新租户无历史版本 → 落版本 1)
version = await write_config_version(
session, tenant.id, snapshot,
updated_by=f"super#{user.user_id}",
comment=f"创建向导: profile={body.profile}"))
comment=f"创建向导: profile={body.profile}")
assert version == 1
try:
await session.commit()
except IntegrityError as exc:
+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