"""配置 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。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 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, 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"]) # ---- 助手 ----------------------------------------------------------------------- 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 版本;密钥绝不进响应。""" 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") 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 版本。""" 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") 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)}