Files
ai-kefu-admin/app/platform/routes/config_routes.py
T
浅念 5813fc91c7 feat(admin): 嵌入代码双形态 + shared_widget_domain 平台键
- 后端 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>
2026-10-19 19:24:56 +08:00

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}