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
+58 -17
View File
@@ -7,11 +7,19 @@ lead_push_service / resolve_ai_settings ← ai_service / resolve_contact_flags
ConversationManager.__init__ 内联取值。函数体原样搬入,行为不变;服务层
与测试一律经本模块 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
(app.engine.config,与各服务原状一致),无新循环;不得反向 import 服务
模块——resolver 是纯消费视图,不触服务逻辑。
(app.engine.config,与各服务原状一致)与同层 settings_store,无新循环;
不得反向 import 服务模块——resolver 是纯消费视图,不触服务逻辑。
"""
from app.engine.config import settings
from app.platform import settings_store
# 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用)
_DEFAULT_SENDER_NAME = "HESUPIPE"
@@ -21,13 +29,28 @@ _DEFAULT_SENDER_NAME = "HESUPIPE"
_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:
"""租户 config.email → 发信参数(Task 9 接线; env settings 回退)。
"""租户 config.email → 发信参数(三级回退: 租户槽位 → 平台设置快照
smtp.* → env SMTP_*)。
回退规则(语义钉在 test_config_merge: 显式 None = 清空/未设置):
- smtp 缺省或显式 None → 整段用 env SMTP_*;
- smtp 给了 dict → 段内逐键回退(缺键/空串 → env 对应值, 租户可只改
密码/只换 host);
- smtp 缺省或显式 None → 整段走 平台设置快照 → env;
- smtp 给了 dict → 段内逐键回退(缺键/空串 → 平台设置快照对应键 →
env 对应值, 租户可只改密码/只换 host);
- sender_name 空 → 平台默认(_DEFAULT_SENDER_NAME);
- notify_emails 空列表 → env NOTIFY_EMAIL 单元素。
"""
@@ -35,10 +58,18 @@ def resolve_email_settings(config_data: dict | None) -> dict:
smtp = email.get("smtp") or {}
notify = [e for e in (email.get("notify_emails") or []) if e]
return {
"host": smtp.get("host") or settings.SMTP_HOST,
"port": smtp.get("port") or settings.SMTP_PORT,
"user": smtp.get("user") or settings.SMTP_USER,
"password": smtp.get("password") or settings.SMTP_PASS,
"host": (smtp.get("host")
or settings_store.get("smtp.host")
or settings.SMTP_HOST),
"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,
"notify_emails": notify or (
[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:
"""租户 config.push → 推送参数(Task 9 接线; env settings 回退)。
平台设置页无 push 推荐键(只有 ai.*/smtp.* 两组,Phase 4 Task 5A
接线范围),本组保持租户槽位 → env 两级回退。
空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律):
- 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 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:
"""租户 config.ai → provider 实例化参数(Task 9 接线; env 回退)。
"""租户 config.ai → provider 实例化参数(三级回退: 租户槽位 → 平台
设置快照 ai.* → env)。
api_key/base_url 空槽(None/空串)→ env 智谱凭据(平台默认集团 key,
spec §5.5 "用平台默认"状态下租户侧不存密钥);model 空或等于出厂默认
→ env AI_MODEL。
api_key/base_url 空槽(None/空串)→ 平台设置快照 → env 智谱凭据
(平台默认集团 key,spec §5.5 "用平台默认"状态下租户侧不存密钥);
model 空或等于出厂默认 → 平台设置 ai.model(显式存储,不做出厂哨兵
检查)→ env AI_MODEL。provider 无平台键,租户槽位 → env AI_PROVIDER。
已知边界: provider 出厂 "glm" 同样遮 env AI_PROVIDER——当前单 provider
无实害,多 provider 接入时再改哨兵结构(与 push 数值同款纪律)。
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 {}
model = ai.get("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 {
"provider": ai.get("provider") or settings.AI_PROVIDER,
"api_key": ai.get("api_key") or settings.ZHIPU_API_KEY,
"base_url": ai.get("base_url") or settings.ZHIPU_BASE_URL,
"api_key": (ai.get("api_key")
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,
}