refactor(approval): 将ApprovalEngine迁移到独立服务模块

删除原approval通道下的engine.py文件,将ApprovalEngine导入路径调整到channel_approval_service服务模块,统一审批引擎的代码组织方式
This commit is contained in:
Kris 2026-05-22 11:18:23 +08:00
parent f77d2b746d
commit e200457519
2 changed files with 1 additions and 273 deletions

View File

@ -1,5 +1,5 @@
from yuxi.channel.approval.delivery import DeliveryResult, deliver_approval_request
from yuxi.channel.approval.engine import ApprovalEngine
from yuxi.services.channel_approval_service import ApprovalEngine
from yuxi.channel.approval.native_runtime import (
NativeApprovalCard,
NativeApprovalRenderer,

View File

@ -1,272 +0,0 @@
import asyncio
import logging
import time
from yuxi.channel.protocols import (
ApprovalAction,
ApprovalDecision,
ApprovalProtocol,
ApprovalRequest,
ApprovalResult,
)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
logger = logging.getLogger(__name__)
_TERMINAL_DECISIONS = frozenset({
ApprovalDecision.APPROVED,
ApprovalDecision.DENIED,
ApprovalDecision.CANCELLED,
ApprovalDecision.EXPIRED,
})
class ApprovalEngine:
def __init__(self):
self._pending: dict[str, ApprovalRequest] = {}
self._results: dict[str, ApprovalResult] = {}
self._events: dict[str, asyncio.Event] = {}
self._cleanup_task: asyncio.Task | None = None
@property
def pending_count(self) -> int:
return len(self._pending)
@property
def results_count(self) -> int:
return len(self._results)
def get_pending_request(self, request_id: str) -> ApprovalRequest | None:
return self._pending.get(request_id)
def get_result(self, request_id: str) -> ApprovalResult | None:
return self._results.get(request_id)
def _is_terminal(self, request_id: str) -> bool:
existing = self._results.get(request_id)
return existing is not None and existing.decision in _TERMINAL_DECISIONS
def cancel_request(self, request_id: str, reason: str = "已取消") -> bool:
existing = self._results.get(request_id)
if existing is not None:
logger.warning(
"审批请求已达终态,无法取消: req=%s, current=%s",
request_id,
existing.decision.value,
)
return False
if request_id not in self._pending:
return False
del self._pending[request_id]
result = ApprovalResult(
request_id=request_id,
decision=ApprovalDecision.CANCELLED,
reason=reason,
)
self._results[request_id] = result
self._signal(request_id)
logger.info("审批请求已取消: req=%s, reason=%s", request_id, reason)
return True
def register_decision(
self,
request_id: str,
decision: ApprovalDecision,
decided_by: str = "external",
reason: str | None = None,
) -> bool:
existing = self._results.get(request_id)
if existing is not None:
logger.warning(
"审批请求已达终态,无法重新决策: req=%s, current=%s, attempted=%s",
request_id,
existing.decision.value,
decision.value,
)
return False
request = self._pending.get(request_id)
if request is None:
return False
if not request.approver_ids:
logger.warning(
"审批身份校验失败: req=%s, 审批人列表为空",
request_id,
)
return False
if decided_by not in request.approver_ids:
logger.warning(
"审批身份校验失败: req=%s, decided_by=%s 不在审批人列表 %s",
request_id,
decided_by,
request.approver_ids,
)
return False
del self._pending[request_id]
result = ApprovalResult(
request_id=request_id,
decision=decision,
decided_by=decided_by,
reason=reason,
)
self._results[request_id] = result
self._signal(request_id)
logger.info(
"审批决策已注册: req=%s, decision=%s, decided_by=%s, reason=%s",
request_id,
decision.value,
decided_by,
reason,
)
return True
def cleanup_expired(self, max_age_seconds: float = 300) -> int:
removed = 0
now = time.monotonic()
stale = [
rid for rid, req in self._pending.items()
if now - req.created_at > max_age_seconds and rid not in self._results
]
for rid in stale:
del self._pending[rid]
result = ApprovalResult(
request_id=rid,
decision=ApprovalDecision.EXPIRED,
reason="清理过期审批",
)
self._results[rid] = result
self._signal(rid)
removed += 1
if removed > 0:
logger.info("清理过期审批请求: count=%d", removed)
return removed
def cleanup_results(self, max_age_seconds: float = 600) -> int:
removed = 0
now = time.monotonic()
stale = [rid for rid, res in self._results.items() if now - res.decided_at > max_age_seconds]
for rid in stale:
del self._results[rid]
removed += 1
if removed > 0:
logger.info("清理过期审批结果: count=%d", removed)
return removed
def start_cleanup_task(self, interval: float = 60) -> None:
if self._cleanup_task is not None and not self._cleanup_task.done():
return
self._cleanup_task = asyncio.create_task(self._run_cleanup_loop(interval))
def stop_cleanup_task(self) -> None:
if self._cleanup_task is not None and not self._cleanup_task.done():
self._cleanup_task.cancel()
async def _run_cleanup_loop(self, interval: float) -> None:
try:
while True:
await asyncio.sleep(interval)
self.cleanup_expired()
self.cleanup_results()
except asyncio.CancelledError:
pass
def _signal(self, request_id: str) -> None:
event = self._events.pop(request_id, None)
if event is not None:
event.set()
async def request_approval(
self,
channel_type: str,
account_id: str,
config: dict,
action: ApprovalAction,
initiator_peer_id: str,
description: str,
context: dict,
) -> ApprovalRequest | None:
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is None or not isinstance(plugin, ApprovalProtocol):
return None
need = await plugin.check_approval_required(config, action, initiator_peer_id)
if not need:
return None
request = await plugin.create_approval_request(
config,
action,
initiator_peer_id,
description,
context,
)
approvers = plugin.get_approver_ids(config)
if not approvers:
logger.warning(
"审批请求创建失败: channel=%s, 审批人列表为空",
channel_type,
)
return None
request.approver_ids = list(approvers)
sent = await plugin.send_approval_notification(config, request, approvers)
if not sent:
logger.warning(
"审批通知发送失败: req=%s, channel=%s, approvers=%s",
request.id,
channel_type,
approvers,
)
self._pending[request.id] = request
return request
async def wait_for_decision(
self, channel_type: str, config: dict, request_id: str, timeout: float = 300
) -> ApprovalResult:
existing = self._results.get(request_id)
if existing is not None:
return existing
event = self._events.get(request_id)
if event is None:
event = asyncio.Event()
self._events[request_id] = event
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
plugin_result = await self._check_and_update(channel_type, config, request_id)
if plugin_result is not None:
self._pending.pop(request_id, None)
self._results[request_id] = plugin_result
self._events.pop(request_id, None)
return plugin_result
try:
await asyncio.wait_for(event.wait(), timeout=min(remaining, 5))
except asyncio.TimeoutError:
continue
stored = self._results.get(request_id)
if stored is not None:
self._events.pop(request_id, None)
return stored
self._events.pop(request_id, None)
expired = ApprovalResult(
request_id=request_id,
decision=ApprovalDecision.EXPIRED,
reason="审批超时",
)
self._pending.pop(request_id, None)
self._results[request_id] = expired
return expired
async def _check_and_update(self, channel_type: str, config: dict, request_id: str) -> ApprovalResult | None:
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is None or not isinstance(plugin, ApprovalProtocol):
return None
return await plugin.check_approval_status(config, request_id)