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:
@@ -20,6 +20,7 @@ from app.platform.db import (
|
||||
)
|
||||
from app.platform.host_middleware import TenantHostMiddleware
|
||||
from app.platform.registry import TenantRegistry
|
||||
from app.platform import settings_store
|
||||
from app.platform.tenant_source import DbTenantSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,6 +143,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
try:
|
||||
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 仍是出厂默认时启动日志
|
||||
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
|
||||
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 /
|
||||
探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量),
|
||||
两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检);
|
||||
- GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落
|
||||
存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认
|
||||
回退链接线归 Phase 4(当前仅存储,admin 平台设置页页头有注记)。
|
||||
- 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。409 门经 platform_user 连锁。
|
||||
@@ -69,6 +70,7 @@ from app.platform.db import (
|
||||
)
|
||||
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"])
|
||||
@@ -602,7 +604,7 @@ async def test_push(slug: str, request: Request,
|
||||
return {"slug": slug, **await _probe_push(cfg)}
|
||||
|
||||
|
||||
# ---- 平台设置(仅超管;resolver 回退链接线归 Phase 4——当前仅存储) ------------------
|
||||
# ---- 平台设置(仅超管;Phase 4 Task 5A 起 resolver 回退链已接线) ---------------------
|
||||
|
||||
|
||||
def _decode_setting(raw: str):
|
||||
@@ -632,7 +634,8 @@ async def list_settings(request: Request, user=Depends(require_super)):
|
||||
@settings_router.put("")
|
||||
async def put_setting(request: Request, user=Depends(require_super)):
|
||||
"""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,或嵌套
|
||||
dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。
|
||||
@@ -661,4 +664,5 @@ async def put_setting(request: Request, user=Depends(require_super)):
|
||||
else:
|
||||
row.value = encoded
|
||||
await session.commit()
|
||||
await settings_store.refresh(engine)
|
||||
return {"key": key, "value": _mask_setting_item(key, value)}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user