config_release 是**整版本替换**语义:激活新版本后,旧版本的配置项全部不再生效。 所以新版本只要漏了某项,它就是**无声消失**的——agent_tools 里的工具白名单一少, 相关 Agent 的工具就被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了", 没人会想到是发布配置少了一条。 本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑), 说明这个风险真实且反复出现。 改为在 activate 时先比对"被取代版本的配置项"与"新版本的配置项",把将被丢掉的逐条 写进 warning 日志。**不阻断激活**——有时确实是要主动撤下某项配置,拒绝会让正常运维 做不了事;这里要的是"事后能查到是谁把它弄没的"。 新增 tests/unit/service/test_config_release_dropped_items.py(3 条):丢项时点名、 完整继承时无噪音(否则运维会习惯性忽略这条日志)、首个版本不报丢项。 ruff / mypy(135 文件) / 610 unit+contract 全绿。
219 lines
9.5 KiB
Python
219 lines
9.5 KiB
Python
import logging
|
|
from datetime import UTC, datetime
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import select
|
|
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__)
|
|
|
|
|
|
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 _warn_dropped_items(
|
|
self, release: ConfigRelease, previous_active: list[ConfigRelease]
|
|
) -> None:
|
|
"""列出"旧版本有、新版本没有"的配置项——它们在激活后会静默失效。
|
|
|
|
`config_release` 是**整版本替换**语义:激活新版本后,旧版本的所有配置项都不再生效。
|
|
所以只要新版本漏了某一项,它就是**无声消失**的——工具白名单随之变成空集、相关
|
|
Agent 的工具被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了",
|
|
没人会想到是发布配置少了一条。
|
|
|
|
本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑),
|
|
这说明风险是真实且反复出现的。这里不阻断激活(有时确实是要撤下某项配置),
|
|
但把它显式写进日志,让运维事后能查到"是谁把它弄没的"。
|
|
"""
|
|
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],
|
|
)
|
|
|
|
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)
|