184 lines
7.6 KiB
Python
184 lines
7.6 KiB
Python
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
|
||
|
||
|
||
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:
|
||
# 只保留状态机约束:必须处于待审核状态才能审核通过;不再要求审核人不同于创建人
|
||
# (单管理员部署下自审是唯一可行路径)。
|
||
#
|
||
# 自审与多人复核一样**如实写入 `reviewer_id`**:该列的含义就是"审核人",留空会让
|
||
# `admin_service` 的激活分支(要求 `reviewer_id is not None`)把已审核版本判为
|
||
# "未审核"而拒绝激活。
|
||
#
|
||
# 自审要落库,前提是数据库侧不再有 `chk_config_release_separation`
|
||
# (`reviewer_id IS NULL OR reviewer_id <> created_by`)。该约束由本仓库的
|
||
# `alembic/versions/20260911_drop_review_separation.py` 撤下;若约束仍在,写
|
||
# `reviewer_id = created_by` 会抛
|
||
# `Check constraint 'chk_config_release_separation' is violated`,表现为审核接口
|
||
# 500 而不是业务异常。
|
||
release = await self._get(release_id)
|
||
if release.status != "pending_review":
|
||
raise ConfigReleaseError("release is not pending review")
|
||
now = self._now()
|
||
release.status = "approved"
|
||
release.reviewer_id = reviewer_id
|
||
release.reviewed_at = now
|
||
release.updated_at = now
|
||
self._audit("config.approve", reviewer_id, release)
|
||
await self.session.flush()
|
||
return release
|
||
|
||
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]
|
||
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)
|