feat: resolver回退链接入platform_settings(三级回退/快照热刷新)

- app/platform/settings_store.py 新模块: lifespan 启动把 platform_settings
  全表快照进进程内存(load_snapshot/get/refresh,workers=1 单进程纪律);
  PUT /api/platform/settings 提交后 refresh 热刷新,回退链即时生效
- config_views email/ai 两组改三级回退: 租户槽位 → 平台设置快照
  (键名对齐平台设置页推荐键 ai.*/smtp.*)→ env settings(env 键名
  不变做最终兜底);push 无平台推荐键保持两级;smtp.port 自由 KV 容错
  归一(数字串转 int,脏值视同未设置落 env)
- conftest autouse 复位快照(resolver 直调测试隔离);新增
  test_platform_settings_fallback 六测(启动装载/三级链/端口容错/
  push 不接线钉死/PUT 即时生效)
- 平台设置页"仅存储"注记移除(CLAUDE.md 与前端页头同步改为已接线)
- 回归: 后端 627 passed(621+6)/ 前端 vitest 156 / build 零错误

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 10:10:52 +08:00
parent 7808f8a299
commit d734ed6d2a
9 changed files with 341 additions and 37 deletions
+2 -2
View File
@@ -123,7 +123,7 @@
- 会话与线索:列表(筛选 / 分页 / 租户切换)/ 详情消息流 / 推送日志 / 删除。 - 会话与线索:列表(筛选 / 分页 / 租户切换)/ 详情消息流 / 推送日志 / 删除。
- 配置中心:品牌(表单 / 本地简化预览 / 嵌入代码 / 头像上传)、提示词四件套与结构化词表、试金石面板、凭据(邮件 / 推送 / AI 表单 + SMTP 测试 + 推送探测)、contact/engine/misc 三组通用 JSON 编辑(终审 I-1 补齐 spec §7.3 全组覆盖)、版本历史(两版对比 / 回滚 / 组级重置出厂)。 - 配置中心:品牌(表单 / 本地简化预览 / 嵌入代码 / 头像上传)、提示词四件套与结构化词表、试金石面板、凭据(邮件 / 推送 / AI 表单 + SMTP 测试 + 推送探测)、contact/engine/misc 三组通用 JSON 编辑(终审 I-1 补齐 spec §7.3 全组覆盖)、版本历史(两版对比 / 回滚 / 组级重置出厂)。
- 统计分析:卡片 / 转化漏斗 / 按日趋势 / 意向·来源·站点·国家分布(ECharts)/ 推送健康表;时间与租户筛选(口径同 stats.py docstring 口径表)。 - 统计分析:卡片 / 转化漏斗 / 按日趋势 / 意向·来源·站点·国家分布(ECharts)/ 推送健康表;时间与租户筛选(口径同 stats.py docstring 口径表)。
- 平台设置(仅超管):自由 KV 表的推荐键表单(ai.* / smtp.*,键名是前端约定)+ 存储总览表格;保存 = 逐键 PUT(只提交脏键)。**当前仅存储与展示——resolver 的平台默认回退链未接线(页头注记),引擎不读取这些值**;密钥键掩码哨兵交互与凭据页同款(清除 / 恢复保持)。 - 平台设置(仅超管):自由 KV 表的推荐键表单(ai.* / smtp.*,键名是前端约定)+ 存储总览表格;保存 = 逐键 PUT(只提交脏键)。**Phase 4 Task 5A 起推荐键已接线 resolver 平台默认回退链(租户槽位 → 平台设置快照 → env),保存后即时生效**;密钥键掩码哨兵交互与凭据页同款(清除 / 恢复保持)。
**前端实现约定**(后续维护须知): **前端实现约定**(后续维护须知):
@@ -146,7 +146,7 @@
2. 推送探测 env 回退真址:租户 push URL 空槽时 `resolve_push_settings` 回退 env `PUSH_API_URL`(默认即真 UniService 生产址)——后台点「探测」会对真实服务发空批量 POST(`{"messages": []}`,按契约文档应为无操作,但属真实出站)。收口 = 部署设 stub 域或探测端点拒绝 env 回退 URL。 2. 推送探测 env 回退真址:租户 push URL 空槽时 `resolve_push_settings` 回退 env `PUSH_API_URL`(默认即真 UniService 生产址)——后台点「探测」会对真实服务发空批量 POST(`{"messages": []}`,按契约文档应为无操作,但属真实出站)。收口 = 部署设 stub 域或探测端点拒绝 env 回退 URL。
3. 生产反代需规划 admin 域的头像/品牌公开端点:`/api/public/*` 按租户 Host 解析,admin 域直挂必 404——开发期靠 Vite Host 改写代理,生产要么同源反代改 Host(单租户形态)要么前端改用租户域名绝对 URL。 3. 生产反代需规划 admin 域的头像/品牌公开端点:`/api/public/*` 按租户 Host 解析,admin 域直挂必 404——开发期靠 Vite Host 改写代理,生产要么同源反代改 Host(单租户形态)要么前端改用租户域名绝对 URL。
4. `VITE_TENANT_PREVIEW_HOST` 单一预览域名:开发代理只指向一个租户 Host,切换租户时头像等公开资源预览不准(.env.development 已注记)。 4. `VITE_TENANT_PREVIEW_HOST` 单一预览域名:开发代理只指向一个租户 Host,切换租户时头像等公开资源预览不准(.env.development 已注记)。
5. **resolver 平台默认回退链接入 platform_settings**(终审 I-3 记档):平台设置当前仅存储与展示(admin 页头有注记),引擎不读取——回退链接线是 Phase 4 的功能项。 5. ~~resolver 平台默认回退链接入 platform_settings~~(Phase 4 Task 5A 完成:`app/platform/settings_store.py` 内存快照 + lifespan 装载 + PUT settings 后 refresh 热刷新;config_views 的 email/ai 两组改三级回退(租户槽位 → 平台设置快照 → env),push 无平台推荐键保持两级;平台设置页"仅存储"注记移除)。
6. **email/test 与 push/test 速率限制**(终审 I-3 记档):两测试端点无限流,复用登录限流器模式收口。 6. **email/test 与 push/test 速率限制**(终审 I-3 记档):两测试端点无限流,复用登录限流器模式收口。
7. **锁屏功能已摘除入口**(终审 M-1):用户菜单项删除 + 全局组件 enabled:false 不再挂载(AES 存 localStorage + 公开模板默认密钥,防御价值为零);代码待 Phase 4 决定删否。 7. **锁屏功能已摘除入口**(终审 M-1):用户菜单项删除 + 全局组件 enabled:false 不再挂载(AES 存 localStorage + 公开模板默认密钥,防御价值为零);代码待 Phase 4 决定删否。
8. **租户切换器 composable 收拢**(终审 M-3 不做记档):仪表盘/会话/配置三页各自内联实现同一模式,收拢归后续。 8. **租户切换器 composable 收拢**(终审 M-3 不做记档):仪表盘/会话/配置三页各自内联实现同一模式,收拢归后续。
+2 -10
View File
@@ -10,19 +10,11 @@
'__SET__' → 输入框留空 + 占位符「已设置,输入新值覆盖」;不动不提交; '__SET__' → 输入框留空 + 占位符「已设置,输入新值覆盖」;不动不提交;
「清除」按钮 = 显式清空意图(提交空串),可「恢复保持」撤销。 「清除」按钮 = 显式清空意图(提交空串),可「恢复保持」撤销。
页头注记(CLAUDE.md Phase 3 取舍):resolver 的平台默认回退链尚未接入 回退链已接线(Phase 4 Task 5A):ai.*/smtp.* 推荐键经后端内存快照接入
platform_settings——本页现阶段仅存储与展示,引擎不读取这些值。 resolver 的平台默认回退链(租户槽位 → 平台设置 → env),保存后即时生效。
--> -->
<template> <template>
<div class="settings-page"> <div class="settings-page">
<ElAlert
type="info"
:closable="false"
show-icon
title="平台默认值将在后续版本接入引擎回退链,当前本页仅存储与展示——引擎暂不读取这些值。"
class="page-note"
/>
<ElCard shadow="never"> <ElCard shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
+5 -3
View File
@@ -8,7 +8,9 @@
* - 后端 app/platform/routes/config_routes.py 的 settings_router: * - 后端 app/platform/routes/config_routes.py 的 settings_router:
* PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON), * PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON),
* 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*) * 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*)
* 是前端约定,不是后端 schema; * 是前端约定,不是后端 schema——但推荐键已接线后端 resolver 的平台
* 默认回退链(Phase 4 Task 5A:租户槽位 → 平台设置 → env,保存后
* 即时生效);
* - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含 * - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含
* key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中, * key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中,
* 与后端行为一致是契约,不"修正"); * 与后端行为一致是契约,不"修正");
@@ -54,7 +56,7 @@ export interface SettingFieldMeta {
kind?: 'int' kind?: 'int'
} }
/** 集团 AI 凭据(租户 ai 组空槽的回退源——回退链接线归后续版本) */ /** 集团 AI 凭据(租户 ai 组空槽的回退源——Phase 4 Task 5A 已接线后端 resolver) */
export const AI_SETTING_FIELDS: SettingFieldMeta[] = [ export const AI_SETTING_FIELDS: SettingFieldMeta[] = [
{ {
key: 'ai.api_key', key: 'ai.api_key',
@@ -74,7 +76,7 @@ export const AI_SETTING_FIELDS: SettingFieldMeta[] = [
} }
] ]
/** 默认 SMTP(租户 email 组空槽的回退源——回退链接线归后续版本) */ /** 默认 SMTP(租户 email 组空槽的回退源——Phase 4 Task 5A 已接线后端 resolver) */
export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [ export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [
{ {
key: 'smtp.host', key: 'smtp.host',
+5
View File
@@ -20,6 +20,7 @@ from app.platform.db import (
) )
from app.platform.host_middleware import TenantHostMiddleware from app.platform.host_middleware import TenantHostMiddleware
from app.platform.registry import TenantRegistry from app.platform.registry import TenantRegistry
from app.platform import settings_store
from app.platform.tenant_source import DbTenantSource from app.platform.tenant_source import DbTenantSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,6 +143,10 @@ async def lifespan(app: FastAPI):
try: try:
await ensure_platform_schema(platform_engine) await ensure_platform_schema(platform_engine)
# 平台设置快照(Phase 4 Task 5A):schema 就绪后全表读入内存——
# resolver 平台默认回退链(config_views)的数据源。PUT settings
# 后经 settings_store.refresh 热刷新,无需重启。
await settings_store.load_snapshot(platform_engine)
# 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志 # 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份 # 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。 # "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
+58 -17
View File
@@ -7,11 +7,19 @@ lead_push_service / resolve_ai_settings ← ai_service / resolve_contact_flags
ConversationManager.__init__ 内联取值。函数体原样搬入,行为不变;服务层 ConversationManager.__init__ 内联取值。函数体原样搬入,行为不变;服务层
与测试一律经本模块 import(单一定义点纪律,旧定义已删)。 与测试一律经本模块 import(单一定义点纪律,旧定义已删)。
Phase 4 Task 5A 起 email/ai 两组的回退链为三级: 租户槽位 → 平台设置快照
(settings_store,键名对齐 admin 平台设置页: smtp.host/smtp.port/smtp.user/
smtp.password 与 ai.api_key/ai.base_url/ai.model)→ env settings(env 键名
不变做最终兜底)。push 无平台推荐键(平台设置页只有 ai.*/smtp.* 两组),
保持租户槽位 → env 两级;contact_flags/context_seed_map/name_to_region_extras
无 env 兜底,不涉回退链。
依赖方向: 本模块在 platform 层,只 import engine 的 env settings 依赖方向: 本模块在 platform 层,只 import engine 的 env settings
(app.engine.config,与各服务原状一致),无新循环;不得反向 import 服务 (app.engine.config,与各服务原状一致)与同层 settings_store,无新循环;
模块——resolver 是纯消费视图,不触服务逻辑。 不得反向 import 服务模块——resolver 是纯消费视图,不触服务逻辑。
""" """
from app.engine.config import settings from app.engine.config import settings
from app.platform import settings_store
# 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用) # 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用)
_DEFAULT_SENDER_NAME = "HESUPIPE" _DEFAULT_SENDER_NAME = "HESUPIPE"
@@ -21,13 +29,28 @@ _DEFAULT_SENDER_NAME = "HESUPIPE"
_FACTORY_DEFAULT_MODEL = "glm-4-flash" _FACTORY_DEFAULT_MODEL = "glm-4-flash"
def _platform_smtp_port():
"""平台设置快照的 smtp.port 取值(容错归一): int 原样;数字串转 int
(自由 KV 手工 API 可存字符串);其余(空串/None/非法)视同未设置 → None,
由 ``or`` 链条落到 env SMTP_PORT。"""
raw = settings_store.get("smtp.port")
if isinstance(raw, bool): # bool 是 int 子类,先排除
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str) and raw.strip().lstrip("+").isdigit():
return int(raw.strip())
return None
def resolve_email_settings(config_data: dict | None) -> dict: def resolve_email_settings(config_data: dict | None) -> dict:
"""租户 config.email → 发信参数(Task 9 接线; env settings 回退)。 """租户 config.email → 发信参数(三级回退: 租户槽位 → 平台设置快照
smtp.* → env SMTP_*)。
回退规则(语义钉在 test_config_merge: 显式 None = 清空/未设置): 回退规则(语义钉在 test_config_merge: 显式 None = 清空/未设置):
- smtp 缺省或显式 None → 整段用 env SMTP_*; - smtp 缺省或显式 None → 整段走 平台设置快照 → env;
- smtp 给了 dict → 段内逐键回退(缺键/空串 → env 对应值, 租户可只改 - smtp 给了 dict → 段内逐键回退(缺键/空串 → 平台设置快照对应键 →
密码/只换 host); env 对应值, 租户可只改密码/只换 host);
- sender_name 空 → 平台默认(_DEFAULT_SENDER_NAME); - sender_name 空 → 平台默认(_DEFAULT_SENDER_NAME);
- notify_emails 空列表 → env NOTIFY_EMAIL 单元素。 - notify_emails 空列表 → env NOTIFY_EMAIL 单元素。
""" """
@@ -35,10 +58,18 @@ def resolve_email_settings(config_data: dict | None) -> dict:
smtp = email.get("smtp") or {} smtp = email.get("smtp") or {}
notify = [e for e in (email.get("notify_emails") or []) if e] notify = [e for e in (email.get("notify_emails") or []) if e]
return { return {
"host": smtp.get("host") or settings.SMTP_HOST, "host": (smtp.get("host")
"port": smtp.get("port") or settings.SMTP_PORT, or settings_store.get("smtp.host")
"user": smtp.get("user") or settings.SMTP_USER, or settings.SMTP_HOST),
"password": smtp.get("password") or settings.SMTP_PASS, "port": (smtp.get("port")
or _platform_smtp_port()
or settings.SMTP_PORT),
"user": (smtp.get("user")
or settings_store.get("smtp.user")
or settings.SMTP_USER),
"password": (smtp.get("password")
or settings_store.get("smtp.password")
or settings.SMTP_PASS),
"sender_name": email.get("sender_name") or _DEFAULT_SENDER_NAME, "sender_name": email.get("sender_name") or _DEFAULT_SENDER_NAME,
"notify_emails": notify or ( "notify_emails": notify or (
[settings.NOTIFY_EMAIL] if settings.NOTIFY_EMAIL else []), [settings.NOTIFY_EMAIL] if settings.NOTIFY_EMAIL else []),
@@ -48,6 +79,9 @@ def resolve_email_settings(config_data: dict | None) -> dict:
def resolve_push_settings(config_data: dict | None) -> dict: def resolve_push_settings(config_data: dict | None) -> dict:
"""租户 config.push → 推送参数(Task 9 接线; env settings 回退)。 """租户 config.push → 推送参数(Task 9 接线; env settings 回退)。
平台设置页无 push 推荐键(只有 ai.*/smtp.* 两组,Phase 4 Task 5A
接线范围),本组保持租户槽位 → env 两级回退。
空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律): 空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律):
- 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 env - 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 env
PUSH_*——部署级 .env 开关是平台默认集的一部分,租户未配置时继承, PUSH_*——部署级 .env 开关是平台默认集的一部分,租户未配置时继承,
@@ -78,11 +112,13 @@ def resolve_push_settings(config_data: dict | None) -> dict:
def resolve_ai_settings(config_data: dict | None) -> dict: def resolve_ai_settings(config_data: dict | None) -> dict:
"""租户 config.ai → provider 实例化参数(Task 9 接线; env 回退)。 """租户 config.ai → provider 实例化参数(三级回退: 租户槽位 → 平台
设置快照 ai.* → env)。
api_key/base_url 空槽(None/空串)→ env 智谱凭据(平台默认集团 key, api_key/base_url 空槽(None/空串)→ 平台设置快照 → env 智谱凭据
spec §5.5 "用平台默认"状态下租户侧不存密钥);model 空或等于出厂默认 (平台默认集团 key,spec §5.5 "用平台默认"状态下租户侧不存密钥);
→ env AI_MODEL。 model 空或等于出厂默认 → 平台设置 ai.model(显式存储,不做出厂哨兵
检查)→ env AI_MODEL。provider 无平台键,租户槽位 → env AI_PROVIDER。
已知边界: provider 出厂 "glm" 同样遮 env AI_PROVIDER——当前单 provider 已知边界: provider 出厂 "glm" 同样遮 env AI_PROVIDER——当前单 provider
无实害,多 provider 接入时再改哨兵结构(与 push 数值同款纪律)。 无实害,多 provider 接入时再改哨兵结构(与 push 数值同款纪律)。
config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。 config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。
@@ -90,11 +126,16 @@ def resolve_ai_settings(config_data: dict | None) -> dict:
ai = ((config_data or {}).get("ai")) or {} ai = ((config_data or {}).get("ai")) or {}
model = ai.get("model") model = ai.get("model")
if not model or model == _FACTORY_DEFAULT_MODEL: if not model or model == _FACTORY_DEFAULT_MODEL:
model = settings.AI_MODEL or _FACTORY_DEFAULT_MODEL model = (settings_store.get("ai.model")
or settings.AI_MODEL or _FACTORY_DEFAULT_MODEL)
return { return {
"provider": ai.get("provider") or settings.AI_PROVIDER, "provider": ai.get("provider") or settings.AI_PROVIDER,
"api_key": ai.get("api_key") or settings.ZHIPU_API_KEY, "api_key": (ai.get("api_key")
"base_url": ai.get("base_url") or settings.ZHIPU_BASE_URL, or settings_store.get("ai.api_key")
or settings.ZHIPU_API_KEY),
"base_url": (ai.get("base_url")
or settings_store.get("ai.base_url")
or settings.ZHIPU_BASE_URL),
"model": model, "model": model,
} }
+9 -5
View File
@@ -23,9 +23,10 @@
resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 / resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 /
探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量), 探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量),
两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检); 两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检);
- GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落 - GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。value 以
存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认 JSON Text 直存(读回还原类型);PUT 提交后 settings_store.refresh 热刷新
回退链接线归 Phase 4(当前仅存储,admin 平台设置页页头有注记)。 进程内存快照——resolver 的平台默认回退链(Phase 4 Task 5A 接线,键名
ai.*/smtp.* 对齐 admin 平台设置页)即时生效,无需重启。
鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户 鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户
管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。 管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。
@@ -69,6 +70,7 @@ from app.platform.db import (
) )
from app.platform.defaults import DEFAULT_CONFIG from app.platform.defaults import DEFAULT_CONFIG
from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard 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 from app.platform.tenant_source import _profile_snapshot
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-config"]) router = APIRouter(prefix="/api/platform/tenants", tags=["platform-config"])
@@ -602,7 +604,7 @@ async def test_push(slug: str, request: Request,
return {"slug": slug, **await _probe_push(cfg)} return {"slug": slug, **await _probe_push(cfg)}
# ---- 平台设置(仅超管;resolver 回退链接线归 Phase 4——当前仅存储) ------------------ # ---- 平台设置(仅超管;Phase 4 Task 5A 起 resolver 回退链已接线) ---------------------
def _decode_setting(raw: str): def _decode_setting(raw: str):
@@ -632,7 +634,8 @@ async def list_settings(request: Request, user=Depends(require_super)):
@settings_router.put("") @settings_router.put("")
async def put_setting(request: Request, user=Depends(require_super)): async def put_setting(request: Request, user=Depends(require_super)):
"""upsert 单个设置项:key 1-64 字符;value 任意 JSON(json.dumps 入 """upsert 单个设置项:key 1-64 字符;value 任意 JSON(json.dumps 入
Text,读回还原)。同 key 再写 = 整值替换。 Text,读回还原)。同 key 再写 = 整值替换。提交后 settings_store.refresh
热刷新内存快照——resolver 的平台默认回退链即时生效(Phase 4 Task 5A)。
密钥哨兵(Phase 3 Task 1):key 命中密钥名且值恰为 MASKED,或嵌套 密钥哨兵(Phase 3 Task 1):key 命中密钥名且值恰为 MASKED,或嵌套
dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。 dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。
@@ -661,4 +664,5 @@ async def put_setting(request: Request, user=Depends(require_super)):
else: else:
row.value = encoded row.value = encoded
await session.commit() await session.commit()
await settings_store.refresh(engine)
return {"key": key, "value": _mask_setting_item(key, value)} return {"key": key, "value": _mask_setting_item(key, value)}
+63
View File
@@ -0,0 +1,63 @@
"""平台设置快照(Phase 4 Task 5A)——resolver 平台默认回退链的数据源。
PlatformSetting 是自由 KV 表(config_routes.settings_router 的 CRUD);
lifespan 启动时全量快照进进程内存,PUT /api/platform/settings 提交后
refresh 热刷新——平台默认回退链即时生效,无需重启进程。
- 快照键名约定对齐 admin 平台设置页的推荐键(ai.api_key / ai.base_url /
ai.model / smtp.host / smtp.port / smtp.user / smtp.password),但快照
本身不限定键集合——自由 KV 未识别的键照存照读(消费侧只取自己认识的);
- 值语义: value 为 JSON Text 直存,读回还原类型(json.loads 失败的手工
裸文本回退原串——与 config_routes._decode_setting 同款容错);空串/
None 视同未设置,消费侧(config_views)的 ``or`` 空槽链条天然跳过;
- 单进程纪律: 内存快照与 token 缓存/登录限流同约定,uvicorn 必须
--workers 1——多 worker 下各进程快照各自为政,PUT 后互相不可见;
- 密钥值只在本模块与库之间流转,不进日志、不进异常信息。
"""
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.platform.db import PlatformSetting
# 进程内存快照(模块级单例,同 auth.token_store 模式)。启动为空 dict——
# 未 load 过(测试直调 resolver)时 get 一律 None,resolver 直接落到 env 兜底,
# 行为与接线前一致。
_snapshot: dict = {}
def _decode(raw: str):
"""Text 列还原类型:json.loads 失败(手工 SQL 塞的裸文本)回退原串。"""
try:
return json.loads(raw)
except ValueError:
return raw
async def load_snapshot(engine: AsyncEngine) -> dict:
"""全表读入内存快照(lifespan 启动装配)。整表替换而非逐键合并——
库里删掉的键(未来 DELETE 入口)不会在快照里残留。"""
async with AsyncSession(engine, expire_on_commit=False) as session:
rows = (await session.execute(
select(PlatformSetting))).scalars().all()
_snapshot.clear()
_snapshot.update({row.key: _decode(row.value) for row in rows})
return dict(_snapshot)
def get(key: str):
"""快照取值:缺键/未加载/空值由调用方按"未设置"处理(返回原样,
None/空串/0 的取舍归消费侧 ``or`` 链条)。"""
return _snapshot.get(key)
async def refresh(engine: AsyncEngine) -> None:
"""重读全表(PUT settings 提交后调用,即时生效)。"""
await load_snapshot(engine)
def _reset_for_test() -> None:
"""单例复位(测试隔离):快照清空。conftest autouse 前后各跑一次,
防 resolver 直调测试之间串快照(同 auth._reset_for_test 纪律)。"""
_snapshot.clear()
+11
View File
@@ -86,6 +86,17 @@ def _reset_platform_auth():
_reset_for_test() _reset_for_test()
@pytest.fixture(autouse=True)
def _reset_platform_settings_snapshot():
"""Phase 4 Task 5A: 平台设置内存快照前后复位——resolver 直调测试
之间不串平台默认值(不隔离时一脚 PUT 进快照的 ai.api_key 会顶掉后脚
的 env 回退断言)。"""
from app.platform.settings_store import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
# ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) -------------------------- # ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) --------------------------
# 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration / # 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration /
# test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量, # test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量,
+186
View File
@@ -0,0 +1,186 @@
"""Phase 4 Task 5A: resolver 平台默认回退链接入 platform_settings。
回退链(email/ai 两组,键名对齐 admin 平台设置页推荐键):
租户槽位 → 平台设置快照(settings_store 内存 dict)→ env settings。
- 快照数据源: lifespan 启动 load_snapshot 全表读入;PUT /api/platform/
settings 提交后 refresh 热刷新(即时生效,无需重启);
- push 组不在接线范围(平台设置页无 push 推荐键)——保持租户槽位 → env
两级,快照里即使有 push.* 键也不被消费(钉死防误接线);
- 空快照(未加载/未设置)= 接线前行为,env 兜底原样(既有 wiring 测试
的隐含前提,conftest autouse 复位快照隔离本文件外的直调)。
"""
from sqlalchemy import select
from app.engine.config import settings
from app.platform import settings_store
from app.platform.config_views import (
resolve_ai_settings,
resolve_email_settings,
resolve_push_settings,
)
from app.platform.db import (
PlatformSetting,
ensure_platform_schema,
make_platform_engine,
)
from tests.test_tenant_admin_api import _auth
SETTINGS = "/api/platform/settings"
def _load_snapshot_values(mapping: dict) -> None:
"""测试直调注入: 快照整表替换(等价 load_snapshot 对同批行的产物,
免去逐测试建临时库——decode 路径由下方 async 测试单独钉)。"""
settings_store._snapshot.clear()
settings_store._snapshot.update(mapping)
async def test_load_snapshot_reads_and_decodes_all_rows(tmp_path):
"""启动装配路径(lifespan 调的同一函数): 全表读入 + JSON 还原类型;
手工裸文本回退原串(_decode_setting 同款容错);重复 load 整表替换,
库里消失的键不残留。"""
engine = make_platform_engine(tmp_path / "platform.db")
try:
await ensure_platform_schema(engine)
from sqlalchemy.ext.asyncio import AsyncSession
async with AsyncSession(engine) as session:
session.add_all([
PlatformSetting(key="ai.model", value='"glm-4-air"'),
PlatformSetting(key="smtp.port", value="587"),
PlatformSetting(key="plain_note", value="裸文本"),
])
await session.commit()
snap = await settings_store.load_snapshot(engine)
assert snap == {"ai.model": "glm-4-air", "smtp.port": 587,
"plain_note": "裸文本"}
assert settings_store.get("ai.model") == "glm-4-air"
assert settings_store.get("smtp.port") == 587
assert settings_store.get("nope") is None
# 整表替换: 删一行后重 load,旧键不残留
async with AsyncSession(engine) as session:
row = (await session.execute(
select(PlatformSetting).where(
PlatformSetting.key == "plain_note"))).scalar_one()
await session.delete(row)
await session.commit()
await settings_store.refresh(engine)
assert settings_store.get("plain_note") is None
assert settings_store.get("ai.model") == "glm-4-air"
finally:
await engine.dispose()
def test_ai_three_level_fallback_chain(monkeypatch):
"""ai 组三级: 租户槽位 > 平台设置快照 > env;model 出厂哨兵同样落
平台 ai.model;快照缺键 → env。"""
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
monkeypatch.setattr(settings, "ZHIPU_BASE_URL", "env-url")
monkeypatch.setattr(settings, "AI_MODEL", "glm-env")
_load_snapshot_values({"ai.api_key": "plat-key", "ai.model": "glm-plat"})
# 1) 租户槽位全胜
assert resolve_ai_settings({"ai": {
"api_key": "t-key", "base_url": "t-url", "model": "glm-t",
}}) == {"provider": "glm", "api_key": "t-key", "base_url": "t-url",
"model": "glm-t"}
# 2) 租户空槽/出厂哨兵 → 平台设置(ai.base_url 快照缺键 → env)
r = resolve_ai_settings({})
assert r == {"provider": "glm", "api_key": "plat-key",
"base_url": "env-url", "model": "glm-plat"}
# model 出厂默认值同样视同未设置 → 平台 ai.model
assert resolve_ai_settings(
{"ai": {"model": "glm-4-flash"}})["model"] == "glm-plat"
# 3) 快照缺键 → env(api_key 平台清空后回落 env)
_load_snapshot_values({})
r = resolve_ai_settings({})
assert r == {"provider": "glm", "api_key": "env-key",
"base_url": "env-url", "model": "glm-env"}
def test_email_three_level_fallback_chain(monkeypatch):
"""smtp 组三级: 租户槽位 > 平台设置 smtp.* > env SMTP_*;段内逐键
回退保留(租户可只改 host);sender_name/notify_emails 不进平台设置
(无推荐键,沿既有规则)。"""
for k, v in (("SMTP_HOST", "env-host"), ("SMTP_PORT", 465),
("SMTP_USER", "env@x.com"), ("SMTP_PASS", "env-pass")):
monkeypatch.setattr(settings, k, v)
_load_snapshot_values({
"smtp.host": "plat.smtp", "smtp.port": 587,
"smtp.user": "plat@x.com", "smtp.password": "plat-pass"})
# 1) 租户段全量覆盖
r = resolve_email_settings({"email": {"smtp": {
"host": "t.smtp", "port": 25, "user": "t@x.com",
"password": "t-pass"}}})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"t.smtp", 25, "t@x.com", "t-pass")
# 2) 空槽 → 平台设置四键齐取
r = resolve_email_settings({})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"plat.smtp", 587, "plat@x.com", "plat-pass")
# 3) 段内逐键: 租户只改 host,其余走平台
r = resolve_email_settings({"email": {"smtp": {"host": "t.smtp"}}})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"t.smtp", 587, "plat@x.com", "plat-pass")
# 4) 快照缺键 → env
_load_snapshot_values({})
r = resolve_email_settings({})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"env-host", 465, "env@x.com", "env-pass")
def test_platform_smtp_port_coercion(monkeypatch):
"""smtp.port 自由 KV 容错: 数字串转 int;非法串/布尔/空 → 视同未设置
落 env(端口是数值语义,不让手工 API 的脏值顶掉可用默认)。"""
monkeypatch.setattr(settings, "SMTP_PORT", 465)
for raw, expected in (("587", 587), (" 465 ", 465), (993, 993)):
_load_snapshot_values({"smtp.port": raw})
assert resolve_email_settings({})["port"] == expected, raw
for raw in ("abc", True, "", None, [465]):
_load_snapshot_values({"smtp.port": raw})
assert resolve_email_settings({})["port"] == 465, raw
def test_push_stays_two_level(monkeypatch):
"""push 不在接线范围: 快照即使存了 push.* 键也不被消费——env 兜底
原样(平台设置页无 push 推荐键,接线时再改本测试)。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "env-url")
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
_load_snapshot_values({"push.url": "plat-url", "push.api_key": "plat-key"})
r = resolve_push_settings({})
assert r["url"] == "env-url" and r["api_key"] == "env-key"
def test_put_settings_refreshes_snapshot_immediately(
super_client, monkeypatch):
"""PUT /api/platform/settings → settings_store.refresh 即时生效: 提交
后 resolver 立刻取到新值;同 key 再写,快照跟随更新(不残留旧值)。
env patch 成异值证明读的是平台层而非 env 兜底。"""
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
monkeypatch.setattr(settings, "AI_MODEL", "glm-env")
def _put(key, value):
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": key, "value": value})
assert r.status_code == 200, r.text
_put("ai.api_key", "sk-plat-1")
_put("ai.model", "glm-4-air")
r = resolve_ai_settings({})
assert r["api_key"] == "sk-plat-1" and r["model"] == "glm-4-air"
# 同 key 再写 → 快照跟随(refresh 不是只在新增时生效)
_put("ai.model", "glm-4-plus")
assert resolve_ai_settings({})["model"] == "glm-4-plus"
# 密钥值不因响应掩码而脏: 哨兵掩码只作用响应层,快照存原值
_put("smtp.password", "plat-pass")
assert resolve_email_settings({})["password"] == "plat-pass"