Files
group_fqcd_jr/app/service/config_release_service.py
T
lzf_0626 e017bcc9bb 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 全绿。
2026-09-11 13:25:00 +08:00

285 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.model.audit import InteractionAudit
from app.model.configuration import ConfigRelease, PlatformConfigItem
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
class ConfigReleaseService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def submit_for_review(self, release_id: int, actor_id: int) -> ConfigRelease:
release = await self._get(release_id)
if release.created_by != actor_id or release.status != "draft":
raise ConfigReleaseError("only draft owner can submit")
release.status = "pending_review"
release.updated_at = self._now()
self._audit("config.submit_review", actor_id, release)
await self.session.flush()
return release
async def approve(self, release_id: int, reviewer_id: int) -> ConfigRelease:
# 只保留状态机约束:必须处于待审核状态才能审核通过;不再要求审核人不同于创建人
# (单管理员部署下自审是唯一可行路径)。数据库侧原本还有
# `chk_config_release_separation` 强制双人复核,已于迁移
# `20260910_drop_review_separation` 撤下,因此自审现在与多人复核一样**如实写入
# `reviewer_id`**:该列的含义就是"审核人",留空会让"谁审的"只能靠审计表追溯,
# 一旦有人只读业务表就会误判这条版本尚未复核。
release = await self._get(release_id)
if release.status != "pending_review":
raise ConfigReleaseError("release is not pending review")
release.status = "approved"
release.reviewer_id = reviewer_id
release.reviewed_at = self._now()
release.updated_at = release.reviewed_at
self._audit("config.approve", reviewer_id, release)
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:
"""点名"旧版本有、新版本没有"的配置 —— 它们在激活后会静默失效。
**逐张覆盖全部受管表**。原先只比对 `platform_config_item`,于是客服闲聊提示词在
`prompt_template_version` 里被静默丢掉时,连一行告警都没有:它挂在 release 174,
active 变成 181 后读不到,而 Agent 侧有兜底、回落到代码默认值,功能看着正常,
于是没有人发现(详见 RELEASE_SCOPED_TABLES 的说明)。
不阻断激活 —— 有时确实是要主动撤下某项配置;这里要的是"事后能查到是谁弄没的"。
"""
if not previous_active:
return
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)
if release.status != "approved":
raise ConfigReleaseError("only approved release can activate")
active = await self.session.scalars(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
now = self._now()
previous_active = [previous for previous in active if previous.id != release.id]
# 把"本次激活会让哪些配置项失效"显式记进日志,见 _warn_dropped_items 的说明。
await self._warn_dropped_items(release, previous_active)
for previous in previous_active:
previous.status = "superseded"
previous.updated_at = now
release.status = "active"
release.activated_at = now
release.updated_at = now
# 记录"本版本取代了谁"。`supersedes_release_id` 的列含义就是被取代的版本,但此前
# 从未写入,导致版本链断裂:清理或回滚旧数据时无法知道"谁顶掉了谁",一旦某次实验
# 创建的临时版本被删掉,平台就会静默停在"零个 active 版本"的状态(工具白名单随之
# 变成空集,所有 Agent 工具按 fail-closed 被拒)。唯一键 `uk_config_release_active_one`
# 保证同时最多一个 active,所以这里最多记录一条。
if len(previous_active) == 1:
release.supersedes_release_id = previous_active[0].id
self._audit("config.activate", actor_id, release)
self.session.add(
DomainEventOutbox(
id=0,
event_id=str(uuid4()),
event_type="config.cache_invalidate_requested",
aggregate_type="config_release",
aggregate_id=str(release.id),
trace_id=release.release_no,
payload={"release_id": release.id, "release_no": release.release_no},
status="pending",
retry_count=0,
occurred_at=now,
created_at=now,
updated_at=now,
)
)
await self.session.flush()
return release
async def rollback(self, release_id: int, actor_id: int) -> ConfigRelease:
target = await self._get(release_id)
if target.status not in {"active", "superseded"}:
raise ConfigReleaseError("release cannot be rolled back")
now = self._now()
current = await self.session.scalars(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
for release in current:
release.status = "superseded"
release.updated_at = now
await self.session.flush()
rollback = ConfigRelease(
release_no=f"rollback-{uuid4().hex[:16]}",
title=f"Rollback {target.release_no}",
change_summary=f"Rollback to {target.release_no}",
status="active",
created_by=actor_id,
reviewer_id=None,
reviewed_at=None,
activated_at=now,
created_at=now,
updated_at=now,
rollback_of_release_id=target.id,
)
self.session.add(rollback)
await self.session.flush()
self.session.add(
DomainEventOutbox(
event_id=str(uuid4()),
event_type="config.cache_invalidate_requested",
aggregate_type="config_release",
aggregate_id=str(target.id),
trace_id=target.release_no,
payload={"release_id": target.id, "release_no": target.release_no},
status="pending",
retry_count=0,
occurred_at=now,
created_at=now,
updated_at=now,
)
)
self._audit("config.rollback", actor_id, rollback)
await self.session.flush()
return rollback
async def get_value(
self, release_id: int, namespace: str, config_key: str
) -> dict[str, object] | None:
item = await self.session.scalar(
select(PlatformConfigItem).where(
PlatformConfigItem.release_id == release_id,
PlatformConfigItem.namespace == namespace,
PlatformConfigItem.config_key == config_key,
)
)
return item.value_json if item else None
@staticmethod
def assert_if_match(current_version: str, if_match: str | None) -> None:
if if_match is None or if_match.strip('"') != current_version:
raise ConfigReleaseError("CONFIG_VERSION_CONFLICT")
async def _get(self, release_id: int) -> ConfigRelease:
release = await self.session.get(ConfigRelease, release_id)
if release is None:
raise ConfigReleaseError("release not found")
return release
def _audit(self, action: str, actor_id: int, release: ConfigRelease) -> None:
self.session.add(
InteractionAudit(
actor_type="user",
actor_id=actor_id,
target_customer_id=None,
session_id=None,
portal="admin",
action_type=action,
detail={"release_id": release.id, "release_no": release.release_no},
created_at=self._now(),
)
)
@staticmethod
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)