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:
@@ -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"]}
|
||||
Reference in New Issue
Block a user