refactor(approval): 将ApprovalEngine迁移到独立服务模块
删除原approval通道下的engine.py文件,将ApprovalEngine导入路径调整到channel_approval_service服务模块,统一审批引擎的代码组织方式
This commit is contained in:
parent
f77d2b746d
commit
e200457519
@ -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,
|
||||
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user