fix(platform): 配置告警覆盖全部受 release 约束的表,并提供生效快照能力

**问题**:上一轮加的"配置丢失告警"只比对 platform_config_item,而受 config_release
整版本替换影响的表有**三张**(按 information_schema 核对):platform_config_item /
prompt_template_version / model_routing_rule。这个盲区造成过真实后果 —— 客服闲聊提示词
挂在 release 174,active 变成 181 后 load_active_prompt 读不到,而 Agent 侧有逐字段
兜底、回落到代码默认值,于是功能看着正常、没人发现、**一行告警都没有**。

**改动**(均在 app/service/config_release_service.py):

1. 新增 RELEASE_SCOPED_TABLES:三张表 + 各自的**逻辑键**。逻辑键不含 release_id、
   不含自增 id、也**不含 version** —— 同名提示词在不同版本里可以用不同 version,
   那仍是同一份配置。清单是穷举的,并注明漏掉任何一张的后果都是静默失效。

2. 新增 effective_snapshot():读当前生效版本在**全部三张表**里的内容,每行已剥掉
   id / 
elease_id(见 NOT_PORTABLE_COLUMNS),可直接作为新版本的写入载荷。
   发布脚本应先取它、再追加本次变更,这样"漏继承"就从"每次靠人记得"变成结构上不容易漏。

3. _warn_dropped_items 改为逐张表比对,告警里带上表名。

model_routing_rule 没有 ORM 映射,用原生 SQL 处理;它当前 0 行,但纳进来才不会将来
配了又漏。

测试:新增一条专门锁住"提示词被丢掉时也要点名"(那正是这次的盲区),并把 fake session
改成按表 + release 返回行 —— 第一版 fake 不分表,查提示词表时会拿到配置项的行、
报 KeyError,虽然真实代码是按表查的,但 fake 不真实就盖不住问题。

ruff / mypy(136 文件) / 612 unit+contract 全绿。
This commit is contained in:
2026-09-11 13:25:00 +08:00
parent 4e2e42c896
commit e017bcc9bb
2 changed files with 172 additions and 56 deletions
+95 -29
View File
@@ -1,8 +1,9 @@
import logging
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.model.audit import InteractionAudit
@@ -11,6 +12,25 @@ from app.model.platform import DomainEventOutbox
logger = logging.getLogger(__name__)
# 受 `config_release` **整版本替换**影响的表,以及各自的**逻辑键**。
#
# 逻辑键用来判断"同一份配置"在新版本里还在不在:它不含 `release_id`、不含自增 id,
# 也**不含 version** —— 同名提示词在不同版本里可以用不同 version,那仍是同一份配置。
#
# 这张清单是**穷举**的:`information_schema` 里带 `release_id` 列的表只有这三张。
# 漏掉任何一张的后果都是**静默失效**,而且不会报错:客服闲聊提示词就这么失效过一次
# —— 它挂在 release 174,active 变成 181 后 `load_active_prompt` 读不到,而 Agent 侧
# 有逐字段兜底、回落到代码默认值,于是功能看着正常、没有任何人发现、也没有任何告警。
RELEASE_SCOPED_TABLES: tuple[tuple[str, tuple[str, ...]], ...] = (
("platform_config_item", ("namespace", "config_key")),
("prompt_template_version", ("prompt_code", "task_type", "agent_type")),
("model_routing_rule", ("rule_code",)),
)
# 搬运配置项时要剥掉的列:`id` 与 `release_id` 由新版本自己生成,
# 带过去要么主键冲突、要么把内容挂到旧版本上。
NOT_PORTABLE_COLUMNS = frozenset({"id", "release_id"})
class ConfigReleaseError(ValueError):
pass
@@ -48,43 +68,89 @@ class ConfigReleaseService:
await self.session.flush()
return release
async def active_release_id(self) -> int | None:
"""当前生效版本的 id;没有任何生效版本时返回 None。"""
found = await self.session.scalar(
select(ConfigRelease.id).where(ConfigRelease.status == "active")
)
return int(found) if found is not None else None
async def effective_snapshot(
self, release_id: int | None = None
) -> dict[str, list[dict[str, Any]]]:
"""读某个发布版本在**全部受管表**里的内容;默认读当前生效版本。
发布脚本应当**先取这份快照**,把它原样搬到新版本、再追加本次变更 —— 因为
`config_release` 是整版本替换,不搬就等于删(详见 RELEASE_SCOPED_TABLES 的说明)。
每行已剥掉 `id` 与 `release_id`(见 NOT_PORTABLE_COLUMNS),可直接作为写入载荷。
"""
if release_id is None:
release_id = await self.active_release_id()
snapshot: dict[str, list[dict[str, Any]]] = {}
for table, _keys in RELEASE_SCOPED_TABLES:
if release_id is None:
snapshot[table] = []
continue
rows = (await self.session.execute(
text(f"SELECT * FROM {table} WHERE release_id = :rid"), {"rid": release_id}
)).mappings().all()
snapshot[table] = [
{
name: value
for name, value in dict(row).items()
if name not in NOT_PORTABLE_COLUMNS
}
for row in rows
]
return snapshot
async def _table_keys(
self, table: str, keys: tuple[str, ...], release_ids: list[int]
) -> dict[int, set[tuple[Any, ...]]]:
"""按 release 分组取出某张表的逻辑键集合。"""
columns = ", ".join(keys)
grouped: dict[int, set[tuple[Any, ...]]] = {}
for release_id in release_ids:
rows = (await self.session.execute(
text(f"SELECT {columns} FROM {table} WHERE release_id = :rid"),
{"rid": release_id},
)).mappings().all()
grouped[release_id] = {tuple(row[key] for key in keys) for row in rows}
return grouped
async def _warn_dropped_items(
self, release: ConfigRelease, previous_active: list[ConfigRelease]
) -> None:
"""列出"旧版本有、新版本没有"的配置项——它们在激活后会静默失效。
"""点名"旧版本有、新版本没有"的配置 —— 它们在激活后会静默失效。
`config_release` 是**整版本替换**语义:激活新版本后,旧版本的所有配置项都不再生效。
所以只要新版本漏了某一项,它就是**无声消失**的——工具白名单随之变成空集、相关
Agent 的工具被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了",
没人会想到是发布配置少了一条。
**逐张覆盖全部受管表**。原先只比对 `platform_config_item`,于是客服闲聊提示词在
`prompt_template_version` 里被静默丢掉时,连一行告警都没有:它挂在 release 174,
active 变成 181 后读不到,而 Agent 侧有兜底、回落到代码默认值,功能看着正常,
于是没有人发现(详见 RELEASE_SCOPED_TABLES 的说明)。
本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑),
这说明风险是真实且反复出现的。这里不阻断激活(有时确实是要撤下某项配置),
但把它显式写进日志,让运维事后能查到"是谁把它弄没的"。
不阻断激活 —— 有时确实是要主动撤下某项配置;这里要的是"事后能查到是谁弄没的"。
"""
if not previous_active:
return
rows = (await self.session.execute(
select(
PlatformConfigItem.namespace,
PlatformConfigItem.config_key,
PlatformConfigItem.release_id,
).where(PlatformConfigItem.release_id.in_(
[previous.id for previous in previous_active] + [release.id]
))
)).all()
new_keys = {(namespace, key) for namespace, key, rid in rows if rid == release.id}
for previous in previous_active:
old_keys = {(namespace, key) for namespace, key, rid in rows if rid == previous.id}
dropped = sorted(old_keys - new_keys)
if dropped:
logger.warning(
"配置发布 %s 取代 %s:以下 %d 条配置项在新版本中不存在,激活后即失效 → %s",
release.release_no,
previous.release_no,
len(dropped),
[f"{namespace}/{key}" for namespace, key in dropped],
release_ids = [previous.id for previous in previous_active] + [release.id]
for table, keys in RELEASE_SCOPED_TABLES:
grouped = await self._table_keys(table, keys, release_ids)
new_keys = grouped.get(release.id, set())
for previous in previous_active:
dropped = sorted(
(key for key in grouped.get(previous.id, set()) if key not in new_keys),
key=str,
)
if dropped:
logger.warning(
"配置发布 %s 取代 %s:%s 里有 %d 条配置在新版本中不存在,"
"激活后即失效 → %s",
release.release_no,
previous.release_no,
table,
len(dropped),
[dict(zip(keys, key, strict=True)) for key in dropped],
)
async def activate(self, release_id: int, actor_id: int) -> ConfigRelease:
release = await self._get(release_id)
@@ -1,12 +1,15 @@
"""配置发布激活时的"配置项被静默丢掉"警告。
"""配置发布激活时的"配置被静默丢掉"告警。
**背景**:`config_release` 是**整版本替换**语义 —— 激活新版本后,旧版本的配置项全部不再
生效。所以新版本只要漏了某项,它就是**无声消失**的:`agent_tools` 里的工具白名单一少,
相关 Agent 的工具就被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了",
没人会想到是发布配置少了一条。
**背景**:`config_release` 是**整版本替换**语义 —— 激活新版本后,旧版本在
`platform_config_item` / `prompt_template_version` / `model_routing_rule` 三张表里的内容
**全部失效**。新版本只要漏了某项,它就是**无声消失**的,而且不会报错。
本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑),
说明风险真实且反复出现。这里锁住:只要发生丢项,就必须在日志里点名。
这不是假想:客服闲聊提示词就这么失效过一次 —— 它挂在 release 174,active 变成 181 后
`load_active_prompt` 读不到,而 Agent 侧有逐字段兜底、回落到代码默认值,于是功能看着正常,
**没有任何人发现,也没有任何告警**。第一版告警只比对 `platform_config_item`,
正是那个盲区让这件事发生。
所以这里逐张表锁住:只要发生丢项,就必须在日志里点名,并且说清是哪张表。
"""
import logging
@@ -14,25 +17,41 @@ from typing import Any, cast
import pytest
from app.service.config_release_service import ConfigReleaseService
from app.service.config_release_service import RELEASE_SCOPED_TABLES, ConfigReleaseService
_KNOWN_TABLES = tuple(table for table, _keys in RELEASE_SCOPED_TABLES)
class _FakeResult:
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def all(self) -> list[tuple[Any, ...]]:
def mappings(self) -> "_FakeResult":
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
class _FakeSession:
"""只提供 `execute(...).all()`,因为被测方法只用到这一条路径。"""
"""按「表 + release_id」返回预设行。
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
被测代码逐表、逐 release 查询(`SELECT <keys> FROM <table> WHERE release_id = :rid`),
所以这里从 SQL 文本里认出表名,再按 `params["rid"]` 取行。不这么做的话,
查提示词表时会拿到平台配置项的行,报 `KeyError: 'prompt_code'`
—— 第一版 fake 就踩了这个(真实代码是按表查的,问题只在 fake)。
"""
def __init__(self, rows: dict[tuple[str, int], list[dict[str, Any]]]) -> None:
self._rows = rows
async def execute(self, statement: Any) -> _FakeResult:
return _FakeResult(self._rows)
async def execute(self, statement: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(statement)
table = next((name for name in _KNOWN_TABLES if name in sql), "")
release_id = (params or {}).get("rid")
if not table or release_id is None:
return _FakeResult([])
return _FakeResult(list(self._rows.get((table, int(release_id)), [])))
class _Release:
@@ -41,36 +60,67 @@ class _Release:
self.release_no = release_no
def _service(rows: list[tuple[Any, ...]]) -> ConfigReleaseService:
def _service(rows: dict[tuple[str, int], list[dict[str, Any]]]) -> ConfigReleaseService:
return ConfigReleaseService(cast(Any, _FakeSession(rows)))
def _item(key: str, release_id: int) -> tuple[tuple[str, int], list[dict[str, Any]]]:
return (
("platform_config_item", release_id),
[{"namespace": "agent_tools", "config_key": key}],
)
def _prompt(code: str, release_id: int) -> tuple[tuple[str, int], list[dict[str, Any]]]:
return (
("prompt_template_version", release_id),
[{"prompt_code": code, "task_type": "chat", "agent_type": "customer_service"}],
)
@pytest.mark.asyncio
async def test_warns_when_new_release_drops_config_items(
caplog: pytest.LogCaptureFixture,
) -> None:
"""新版本少了一条配置项时必须点名警告——它会静默失效。"""
service = _service([
("agent_tools", "customer_service:faq", 1), # 旧版本有
("agent_tools", "risk:risk_overview", 2), # 新版本新增
])
"""新版本少了一条平台配置项时必须点名——它会静默失效。"""
service = _service(dict([_item("customer_service:faq", 1), _item("risk:risk_overview", 2)]))
with caplog.at_level(logging.WARNING):
await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")])
assert "agent_tools" in caplog.text
assert "customer_service:faq" in caplog.text
assert "agent_tools/customer_service:faq" in caplog.text
@pytest.mark.asyncio
async def test_warns_when_new_release_drops_a_prompt_template(
caplog: pytest.LogCaptureFixture,
) -> None:
"""提示词模板被丢掉时同样要点名,并说清是哪张表。
这条是本次修复的核心:第一版告警只比对 `platform_config_item`,于是客服闲聊提示词
在 `prompt_template_version` 里被静默丢掉时**一行告警都没有**,直到有人手工查库才发现。
"""
service = _service(dict([_prompt("customer_service_chitchat", 1)]))
with caplog.at_level(logging.WARNING):
await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")])
assert "prompt_template_version" in caplog.text
assert "customer_service_chitchat" in caplog.text
@pytest.mark.asyncio
async def test_no_warning_when_everything_is_carried_over(
caplog: pytest.LogCaptureFixture,
) -> None:
"""配置项被完整继承时不该有噪音——否则运维会习惯性忽略这条日志。"""
service = _service([
("agent_tools", "customer_service:faq", 1),
("agent_tools", "customer_service:faq", 2),
])
"""配置被完整继承时不该有噪音——否则运维会习惯性忽略这条日志。"""
service = _service(dict([
_item("customer_service:faq", 1),
_item("customer_service:faq", 2),
_prompt("customer_service_chitchat", 1),
_prompt("customer_service_chitchat", 2),
]))
with caplog.at_level(logging.WARNING):
await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")])
@@ -83,7 +133,7 @@ async def test_first_release_warns_about_nothing(
caplog: pytest.LogCaptureFixture,
) -> None:
"""平台首个版本没有"前一个版本",不该报丢项。"""
service = _service([("agent_tools", "customer_service:faq", 1)])
service = _service(dict([_item("customer_service:faq", 1)]))
with caplog.at_level(logging.WARNING):
await service._warn_dropped_items(_Release(1, "first"), [])