5813fc91c7
- 后端 GET /api/platform/settings/widget-domain:读平台设置键 shared_widget_domain(settings_store 内存快照,PUT settings 后热刷新 即时可见);platform_user 两种角色可读(超管配置/租户管理员生成嵌入 代码——与 settings CRUD 的 require_super 不同,非密钥无敏感面); 空串/非字符串视同未配置 → null - config/center 嵌入卡双形态(spec 2026-10-19 §5):域名形态 + 共享域名形态(/t/<slug>/static/widget.js,路径前缀即租户标识); 可用性规则(两料齐双形态/无域名仅共享/共享未配仅域名/都无引导文案) 与生效态回退;data-site 两形态通用 - normalizeSharedDomain 主机名字符集校验(拒引号/尖括号/空格——防 配置侧把恶意字符注入生成的 script src 串)+ buildEmbedCode 纯函数, vitest 6 例钉位 - 平台设置页新增「共享域名」组(shared_widget_domain 推荐键,表驱动 渲染自动生效);settings API 模块加 getWidgetDomain Co-Authored-By: Claude Code <noreply@anthropic.com>
761 lines
34 KiB
Python
761 lines
34 KiB
Python
"""配置 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);
|
|
- POST /email/test 与 /push/test(凭据测试,Phase 3 Task 7c):分别用
|
|
resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 /
|
|
探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量),
|
|
两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检);
|
|
- GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。value 以
|
|
JSON Text 直存(读回还原类型);PUT 提交后 settings_store.refresh 热刷新
|
|
进程内存快照——resolver 的平台默认回退链(Phase 4 Task 5A 接线,键名
|
|
ai.*/smtp.* 对齐 admin 平台设置页)即时生效,无需重启。
|
|
|
|
鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户
|
|
管理员可自助改配置);settings = require_super(例外:GET /settings/widget-domain
|
|
= platform_user 两种角色可读——嵌入代码生成器的原料,spec 2026-10-19 §5)。
|
|
409 门经 platform_user 连锁。
|
|
|
|
密钥掩码哨兵(Phase 3 Task 1):push.api_key / ai.api_key /
|
|
email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的响应
|
|
一律掩码为 "__SET__"(原值非空;空串/None 原样——前端能区分"配了"与
|
|
"没配");PUT 收到 "__SET__" 还原为库值再写版本(前端"不改就原样传回"
|
|
的往返契约)。库行/版本历史永远存原值——掩码只作用响应层。
|
|
|
|
版本写入一律走 db.write_config_version(与 Task 5 创建向导同一 MAX+1 路径)。
|
|
"""
|
|
import copy
|
|
import json
|
|
import socket
|
|
import time
|
|
from collections import defaultdict
|
|
|
|
import httpx
|
|
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.services.email_service import send_test_email
|
|
from app.engine.wall_schema import WallConfigError, compile_wall
|
|
from app.platform.auth import UserContext, platform_user, require_super
|
|
from app.platform.config_validation import (
|
|
CONFIG_GROUPS,
|
|
ValidationContext,
|
|
validate_group,
|
|
)
|
|
from app.platform.config_views import (
|
|
resolve_email_settings,
|
|
resolve_push_settings,
|
|
)
|
|
from app.platform.db import (
|
|
PlatformSetting,
|
|
Tenant,
|
|
TenantConfigVersion,
|
|
other_push_prefixes,
|
|
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 import settings_store
|
|
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"])
|
|
|
|
|
|
# ---- 测试端点限流(Phase 4 Task 5B) -------------------------------------------------
|
|
|
|
TEST_WINDOW_SECONDS = 15 * 60
|
|
TEST_MAX_CALLS = 5
|
|
|
|
|
|
class SlidingWindowCounter:
|
|
"""(kind, tenant_slug) 键滑动窗计数: 窗内第 5 次发起后拒绝后续,窗过
|
|
恢复。LoginLimiter 同款风格/单进程内存态,但语义是"计数一切发起的
|
|
调用"——email/push 测试端点每次受理都可能触发出站,不分成败(登录
|
|
限流只计失败,这里没有"成功即清零"的正路)。计数只落在通过全部
|
|
预检(参数/租户/配置可用)真正出站的那次——校验失败的调用不消耗
|
|
预算,但超限后连预检一并拒绝(allow 守卫在端点最前)。"""
|
|
|
|
def __init__(
|
|
self,
|
|
window_seconds: float = TEST_WINDOW_SECONDS,
|
|
max_calls: int = TEST_MAX_CALLS) -> None:
|
|
self.window_seconds = window_seconds
|
|
self.max_calls = max_calls
|
|
self._calls: dict[tuple[str, str], list[float]] = defaultdict(list)
|
|
|
|
def allow(self, key: tuple[str, str]) -> bool:
|
|
"""窗内计数是否仍低于上限(不记录——记录走 record)。"""
|
|
self._prune(key, time.time())
|
|
return len(self._calls.get(key, ())) < self.max_calls
|
|
|
|
def record(self, key: tuple[str, str]) -> None:
|
|
now = time.time()
|
|
self._prune(key, now)
|
|
self._calls[key].append(now)
|
|
|
|
def _prune(self, key: tuple[str, str], now: float) -> None:
|
|
calls = self._calls.get(key)
|
|
if calls is None:
|
|
return
|
|
kept = [t for t in calls if t > now - self.window_seconds]
|
|
if kept:
|
|
self._calls[key] = kept
|
|
else:
|
|
self._calls.pop(key, None)
|
|
|
|
|
|
# 模块级单例: email/push 两测试端点共用(键含 kind,两预算互不挤占)
|
|
_test_limiter = SlidingWindowCounter()
|
|
|
|
|
|
def _reset_test_limiter_for_test() -> None:
|
|
"""单例复位(测试隔离),conftest autouse 前后各跑一次(同 auth 纪律)。"""
|
|
_test_limiter._calls.clear()
|
|
_test_limiter.window_seconds = TEST_WINDOW_SECONDS
|
|
_test_limiter.max_calls = TEST_MAX_CALLS
|
|
|
|
|
|
def _test_rate_guard(kind: str, slug: str) -> None:
|
|
"""测试端点的统一限流守卫: 超限 → 429(先于一切业务逻辑——被限流的
|
|
租户连 400 预检也不触发,更不许出站)。"""
|
|
if not _test_limiter.allow((kind, slug)):
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"测试端点调用过于频繁(每租户 15 分钟 {TEST_MAX_CALLS} 次)"
|
|
"——请稍后再试")
|
|
|
|
|
|
def _record_test_call(kind: str, slug: str) -> None:
|
|
"""通过全部预检、即将出站的那次调用计数。"""
|
|
_test_limiter.record((kind, slug))
|
|
|
|
|
|
# ---- 助手 -----------------------------------------------------------------------
|
|
|
|
|
|
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
|
|
|
|
|
|
def _updated_by(user: UserContext) -> str:
|
|
return f"{user.role}#{user.user_id}"
|
|
|
|
|
|
# ---- 密钥掩码 / __SET__ 哨兵(Phase 3 Task 1) --------------------------------------
|
|
|
|
MASKED = "__SET__"
|
|
|
|
# 租户配置密钥字段清单(组 → 组内路径元组;多元素元组 = 嵌套键)。响应一律
|
|
# 掩码;PUT 收到 MASKED 值还原为当前快照原值——前端"不改就原样传回"的往返
|
|
# 契约。语义:原值非空 → MASKED;空串/None/缺键原样(前端能区分"配了"与
|
|
# "没配")。空串传回仍 = 清空(既有语义不因掩码改变)。
|
|
_SECRET_PATHS: dict[str, tuple[tuple[str, ...], ...]] = {
|
|
"push": (("api_key",),),
|
|
"ai": (("api_key",),),
|
|
"email": (("smtp", "password"),),
|
|
}
|
|
|
|
|
|
def _dig(container, path: tuple[str, ...]):
|
|
"""按路径取嵌套 dict 值;任一层不是 dict → None。"""
|
|
node = container
|
|
for key in path:
|
|
node = node.get(key) if isinstance(node, dict) else None
|
|
return node
|
|
|
|
|
|
def _mask_group(group: str, value):
|
|
"""单组掩码视图(深拷贝):密钥路径上非空字符串 → MASKED,空串/None/
|
|
缺键原样。非 dict 组值原样深拷贝返回。"""
|
|
out = copy.deepcopy(value)
|
|
if not isinstance(out, dict):
|
|
return out
|
|
for path in _SECRET_PATHS.get(group, ()):
|
|
leaf = _dig(out, path)
|
|
if isinstance(leaf, str) and leaf:
|
|
node = _dig(out, path[:-1])
|
|
node[path[-1]] = MASKED
|
|
return out
|
|
|
|
|
|
def _mask_snapshot(snapshot: dict) -> dict:
|
|
"""整份快照的掩码视图(逐组 _mask_group)。"""
|
|
return {group: _mask_group(group, value)
|
|
for group, value in snapshot.items()}
|
|
|
|
|
|
def _restore_sentinels(group: str, body: dict, current: dict) -> dict:
|
|
"""PUT 哨兵还原:body 密钥路径上值恰为 MASKED → 替换为当前快照同路径
|
|
原值(无库存 → None,保持"未设置"——哨兵字面量绝不落库)。返回处理后
|
|
的深拷贝,不改入参;非密钥路径与传新值/空串的路径一律原样。"""
|
|
out = copy.deepcopy(body)
|
|
for path in _SECRET_PATHS.get(group, ()):
|
|
if _dig(out, path) == MASKED:
|
|
node = _dig(out, path[:-1])
|
|
node[path[-1]] = _dig(current.get(group), path)
|
|
return out
|
|
|
|
|
|
# 平台设置密钥判定:key 名(不区分大小写)含以下片段即视为密钥。适用设置
|
|
# 项自身的 key(标量密钥,如 glm_api_key = "sk-...")与 value 的嵌套 dict
|
|
# 递归两层。
|
|
_SETTING_SECRET_MARKERS = ("key", "password", "secret")
|
|
|
|
|
|
def _is_secret_setting_key(name: str) -> bool:
|
|
lowered = name.lower() if isinstance(name, str) else ""
|
|
return any(marker in lowered for marker in _SETTING_SECRET_MARKERS)
|
|
|
|
|
|
def _mask_setting_value(value):
|
|
"""递归掩码:密钥名下的非空字符串值 → MASKED;dict/list 递归,其余
|
|
标量原样。"""
|
|
if isinstance(value, dict):
|
|
return {
|
|
k: (MASKED if _is_secret_setting_key(k)
|
|
and isinstance(v, str) and v else _mask_setting_value(v))
|
|
for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_mask_setting_value(v) for v in value]
|
|
return value
|
|
|
|
|
|
def _mask_setting_item(key: str, value):
|
|
"""单个设置项的掩码视图:设置 key 本身命中密钥名且值为非空字符串 →
|
|
掩码;否则递归掩码 value 内部。"""
|
|
if _is_secret_setting_key(key) and isinstance(value, str) and value:
|
|
return MASKED
|
|
return _mask_setting_value(value)
|
|
|
|
|
|
def _restore_setting_sentinels(value, stored):
|
|
"""value 中密钥位置的 MASKED → stored 同路径值(递归结构与
|
|
_mask_setting_value 对齐;stored 无该路径 → None)。不改入参。"""
|
|
if isinstance(value, dict):
|
|
src = stored if isinstance(stored, dict) else {}
|
|
return {
|
|
k: (copy.deepcopy(src.get(k))
|
|
if _is_secret_setting_key(k) and v == MASKED
|
|
else _restore_setting_sentinels(v, src.get(k)))
|
|
for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
src = stored if isinstance(stored, list) else []
|
|
return [_restore_setting_sentinels(
|
|
v, src[i] if i < len(src) else None)
|
|
for i, v in enumerate(value)]
|
|
return value
|
|
|
|
|
|
# ---- 配置快照 / 分组编辑 ----------------------------------------------------------
|
|
|
|
|
|
@router.get("/{slug}/config")
|
|
async def get_config(slug: str, request: Request,
|
|
user=Depends(_tenant_guard)):
|
|
"""当前生效快照(MAX 版本)+ version。原样快照,非合并视图——缺省键
|
|
由消费侧(registry)深合并 DEFAULT_CONFIG。密钥字段掩码(Phase 3
|
|
Task 1):非空 → MASKED,空串/None 原样。"""
|
|
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": _mask_snapshot(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 热生效。快照存用户配置**原样组**
|
|
(组内缺省键消费侧深合并出厂默认补)。
|
|
|
|
密钥哨兵(Phase 3 Task 1):密钥字段传 MASKED = 保持库值(校验按
|
|
字符串过,还原发生在校验后写入前);传新值 → 覆盖;空串传回 = 清空
|
|
(既有语义不因掩码改变)。响应的密钥字段同样掩码——哨兵还原后的原值
|
|
不经响应回显。"""
|
|
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_push_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)
|
|
body = _restore_sentinels(group, body, current)
|
|
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": _mask_group(group, 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])
|
|
# push 组重置补跨租户查重(终审 I-3):重置回出厂 prefix 可能撞上
|
|
# 现在的占用方(本租户改过前缀后,出厂前缀被别人取走)。
|
|
if group == "push" and isinstance(value, dict):
|
|
prefix = value.get("prefix")
|
|
if (isinstance(prefix, str) and prefix and prefix
|
|
in await other_push_prefixes(session, tenant.id)):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"重置后的 push.prefix {prefix!r} 已被其他租户"
|
|
f"占用——请改用 PUT /config/push 指定其他前缀")
|
|
_, 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": _mask_group(group, 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。config 掩码视图
|
|
(Phase 3 Task 1)——历史行本身永远存原值。"""
|
|
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": _mask_snapshot(json.loads(row.config_json)),
|
|
}
|
|
|
|
|
|
@router.post("/{slug}/config/rollback")
|
|
async def rollback_config(
|
|
slug: str, request: Request, user=Depends(_tenant_guard)):
|
|
"""回滚:目标版本整份复制为新版本(历史不可变——旧版本行永不改写),
|
|
reload 生效。落库原值;响应为掩码视图(Phase 3 Task 1)。"""
|
|
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": _mask_snapshot(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}
|
|
|
|
|
|
# ---- 凭据测试端点(email/push test,Phase 3 Task 7c) -------------------------------
|
|
|
|
_EMAIL_TEST_KEYS = frozenset({"to"})
|
|
|
|
|
|
@router.post("/{slug}/email/test")
|
|
async def test_email(slug: str, request: Request,
|
|
user=Depends(_tenant_guard)):
|
|
"""SMTP 测试邮件: 用当前生效邮件配置(resolve_email_settings,租户组
|
|
优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见
|
|
email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空)
|
|
→ 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump
|
|
版本;密钥绝不进响应。
|
|
限流(Phase 4 Task 5B): 每租户 15 分钟 5 次真实出站,超限 429。"""
|
|
_test_rate_guard("email", slug)
|
|
body = await _json_body(request)
|
|
unknown = set(body) - _EMAIL_TEST_KEYS
|
|
if unknown:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"未知字段: {', '.join(sorted(unknown))}(合法: to)")
|
|
to = body.get("to")
|
|
if not isinstance(to, str) or "@" not in to.strip():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"to: 须为含 @ 的收件人邮箱(实际 {to!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)
|
|
_, snapshot = await _load_current(session, tenant.id, slug)
|
|
cfg = resolve_email_settings(snapshot)
|
|
if not cfg["host"] or not cfg["user"]:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)"
|
|
"——请先在「配置中心 → 凭据」保存邮件 SMTP")
|
|
_record_test_call("email", slug)
|
|
sent, error = await send_test_email(to.strip(), tenant.name, snapshot)
|
|
return {"slug": slug, "sent": sent, "error": error}
|
|
|
|
|
|
def _transport_reason(exc: Exception) -> str:
|
|
"""传输异常 → 不可达原因分类(前端三态展示): dns(域名解析失败)/
|
|
timeout(TimeoutException 族)/connect(拒绝·不可达·TLS 等其余)。
|
|
|
|
dns 判定双通道: 沿 __cause__/__context__ 链找 gaierror(httpx 把解析
|
|
错误包在 ConnectError 里),并对链上异常做文案匹配——Windows 实测
|
|
(冒烟 Task 7c)httpx/anyio 会把 errno 11001 包成裸 ConnectError,
|
|
cause 链不带 gaierror,只能从 "[Errno 11001] getaddrinfo failed"
|
|
文案识别;分类仅供修复提示,错误原文始终随响应返回兜底。"""
|
|
node: BaseException | None = exc
|
|
while node is not None:
|
|
if isinstance(node, socket.gaierror):
|
|
return "dns"
|
|
text = str(node).lower()
|
|
if "getaddrinfo" in text or "name or service not known" in text:
|
|
return "dns"
|
|
node = node.__cause__ or node.__context__
|
|
if isinstance(exc, httpx.TimeoutException):
|
|
return "timeout"
|
|
return "connect"
|
|
|
|
|
|
async def _probe_push(cfg: dict) -> dict:
|
|
"""单次连通性探测(无重试): 与 lead_push_service._do_post 同款连接形态
|
|
(timeout/follow_redirects/X-Api-Key 头),body 恒为空 messages 列表——
|
|
**绝不携带任何线索数据**(UniService 是批量结构,空批量对方案应为
|
|
无操作;契约文档不在库内,这是可用的最保守预检形态)。"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=cfg["timeout"],
|
|
follow_redirects=True) as client:
|
|
resp = await client.post(
|
|
cfg["url"], json={"messages": []},
|
|
headers={"X-Api-Key": cfg["api_key"]})
|
|
except httpx.HTTPError as exc:
|
|
return {"reachable": False, "auth_ok": None, "status": None,
|
|
"reason": _transport_reason(exc),
|
|
"error": str(exc) or repr(exc)}
|
|
auth_ok = (True if resp.status_code < 300
|
|
else False if resp.status_code in (401, 403)
|
|
else None)
|
|
return {"reachable": True, "auth_ok": auth_ok,
|
|
"status": resp.status_code, "reason": None, "error": None}
|
|
|
|
|
|
@router.post("/{slug}/push/test")
|
|
async def test_push(slug: str, request: Request,
|
|
user=Depends(_tenant_guard)):
|
|
"""推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages
|
|
批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达
|
|
(含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx);
|
|
其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。
|
|
限流(Phase 4 Task 5B): 每租户 15 分钟 5 次探测,超限 429。"""
|
|
_test_rate_guard("push", slug)
|
|
engine = request.app.state.platform_engine
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
tenant = await _get_tenant_or_404(session, slug)
|
|
_, snapshot = await _load_current(session, tenant.id, slug)
|
|
cfg = resolve_push_settings(snapshot)
|
|
if not cfg["url"]:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="推送 URL 未配置(租户配置与环境回退后均为空)"
|
|
"——请先在「配置中心 → 凭据」保存推送 URL")
|
|
_record_test_call("push", slug)
|
|
return {"slug": slug, **await _probe_push(cfg)}
|
|
|
|
|
|
# ---- 平台设置(仅超管;Phase 4 Task 5A 起 resolver 回退链已接线) ---------------------
|
|
|
|
|
|
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)。值掩码视图
|
|
(Phase 3 Task 1):key 名含 key/password/secret 的非空字符串值 →
|
|
MASKED(设置 key 本身与嵌套 dict 递归都算);库行永远存原值。"""
|
|
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": _mask_setting_item(
|
|
row.key, _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 再写 = 整值替换。提交后 settings_store.refresh
|
|
热刷新内存快照——resolver 的平台默认回退链即时生效(Phase 4 Task 5A)。
|
|
|
|
密钥哨兵(Phase 3 Task 1):key 命中密钥名且值恰为 MASKED,或嵌套
|
|
dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。
|
|
非密钥位置的 MASKED 字面量 = 普通字符串原样落库。响应掩码。"""
|
|
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: 缺少(必填)")
|
|
|
|
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()
|
|
stored = _decode_setting(row.value) if row is not None else None
|
|
if _is_secret_setting_key(key) and body["value"] == MASKED:
|
|
value = copy.deepcopy(stored)
|
|
else:
|
|
value = _restore_setting_sentinels(body["value"], stored)
|
|
encoded = json.dumps(value, ensure_ascii=False)
|
|
if row is None:
|
|
session.add(PlatformSetting(key=key, value=encoded))
|
|
else:
|
|
row.value = encoded
|
|
await session.commit()
|
|
await settings_store.refresh(engine)
|
|
return {"key": key, "value": _mask_setting_item(key, value)}
|
|
|
|
|
|
@settings_router.get("/widget-domain")
|
|
async def get_widget_domain(user: platform_user):
|
|
"""共享挂件域名读取(spec 2026-10-19 §5)——配置中心嵌入代码生成器
|
|
消费的平台设置键 shared_widget_domain。两种平台角色可读:超管在平台
|
|
设置页配置、租户管理员生成 /t/<slug>/ 形态嵌入代码都要用(与 settings
|
|
CRUD 的 require_super 不同——本值非密钥、无敏感面,公开到登录管理员
|
|
层即可)。读 settings_store 内存快照(lifespan 装载 / PUT settings 后
|
|
refresh 热刷新,Phase 4 Task 5A 同链路);空串/None/非字符串值视同
|
|
未配置 → None(前端生成侧还有域名合法字符校验兜底)。"""
|
|
raw = settings_store.get("shared_widget_domain")
|
|
return {"widget_domain": raw if isinstance(raw, str) and raw else None}
|