refactor(channel): 重构并新增多项渠道管理功能

1. 简化message_actions.py中获取适配器的逻辑
2. 新增适配器合法性校验工具方法
3. 新增会话映射过期清理功能
4. 重构渠道状态机与基础适配器实现
5. 统一渠道操作异常处理逻辑
6. 新增凭证状态查询与刷新接口
7. 优化健康检查与自动重连逻辑
8. 新增统计数据缓存与批量查询优化
9. 修复部分数据库操作的异常处理逻辑
This commit is contained in:
Kris 2026-05-14 09:24:50 +08:00
parent 277bf20153
commit 6ca611fead
6 changed files with 337 additions and 110 deletions

View File

@ -9,6 +9,7 @@ from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
@ -39,6 +40,23 @@ class BaseChannelAdapter(ChannelLifecycleProtocol, ChannelGatewayProtocol, ABC):
config_schema: ClassVar[dict[str, dict[str, Any]]] = {}
VALID_TRANSITIONS: ClassVar[dict[str, set[str]]] = {
ChannelStatus.DISCONNECTED.value: {ChannelStatus.CONNECTING.value},
ChannelStatus.CONNECTING.value: {
ChannelStatus.CONNECTED.value,
ChannelStatus.ERROR.value,
ChannelStatus.DISCONNECTED.value,
},
ChannelStatus.CONNECTED.value: {"disconnecting", ChannelStatus.RECONNECTING.value, ChannelStatus.ERROR.value},
"disconnecting": {ChannelStatus.DISCONNECTED.value},
ChannelStatus.RECONNECTING.value: {
ChannelStatus.CONNECTED.value,
ChannelStatus.ERROR.value,
ChannelStatus.DISCONNECTED.value,
},
ChannelStatus.ERROR.value: {ChannelStatus.CONNECTING.value, ChannelStatus.DISCONNECTED.value},
}
_state_store: PluginStateStore | None = None
def __init__(self, config: dict[str, Any] | None = None):
@ -147,7 +165,7 @@ class BaseChannelAdapter(ChannelLifecycleProtocol, ChannelGatewayProtocol, ABC):
async def send(self, response: ChannelResponse) -> DeliveryResult: ...
async def receive(self) -> AsyncIterator[ChannelMessage]:
raise NotImplementedError
return
yield # type: ignore[misc]
@abstractmethod
@ -303,6 +321,17 @@ class BaseChannelAdapter(ChannelLifecycleProtocol, ChannelGatewayProtocol, ABC):
def is_configured(self) -> bool:
return bool(self.config)
def _transition(self, new_status: str) -> None:
current = self.status
valid_targets = self.VALID_TRANSITIONS.get(current)
if valid_targets is None:
raise ValueError(f"Unknown current status: {current}")
if new_status not in valid_targets:
raise ValueError(
f"Illegal state transition: {current} -> {new_status}. "
f"Allowed transitions from {current}: {valid_targets}"
)
@property
def status(self) -> str:
return getattr(self, "_status", "unknown") or "unknown"

View File

@ -19,6 +19,7 @@ from yuxi.channels.infra.config_watcher import ConfigWatcher
from yuxi.channels.models import ChannelStatus
from yuxi.channels.registry import _BUILTIN_ADAPTERS, ChannelRegistry, _load_builtin_adapters
from yuxi.channels.router import MessageRouter
from yuxi.channels.session_mapper import SessionMapper
from yuxi.channels.services.context import GatewayRequestContext
from yuxi.channels.services.doctor import ConfigDoctor, DiagnosisIssue
from yuxi.channels.services.maintenance import MaintenanceRunner
@ -35,6 +36,7 @@ from yuxi.utils.logging_config import logger
HEALTH_CHECK_INTERVAL = 60
HEALTH_CHECK_MAX_AGE = 65
CONFIG_WATCH_INTERVAL = 30.0
STATS_CACHE_TTL = 60.0
_DEFAULT_RATE_LIMIT = {
"enabled": True,
@ -125,6 +127,10 @@ class ChannelManager:
self._prev_statuses: dict[str, str] = {}
self._config_lock: asyncio.Lock = asyncio.Lock()
self._cached_health: dict[str, tuple[float, Any]] = {}
self._stats_cache: dict[str, tuple[float, dict]] = {}
self._batch_stats_cache: tuple[float, dict[str, dict]] = (0, {})
self._start_stop_locks: dict[str, asyncio.Lock] = {}
self._health_failure_counts: dict[str, int] = defaultdict(int)
@property
def phase(self) -> str:
@ -484,6 +490,11 @@ class ChannelManager:
return await self._watcher.reload_now(changed_keys)
async def start_channel(self, channel_id: str, config: dict[str, Any] | None = None) -> None:
lock = self._start_stop_locks.setdefault(channel_id, asyncio.Lock())
async with lock:
return await self._start_channel_impl(channel_id, config)
async def _start_channel_impl(self, channel_id: str, config: dict[str, Any] | None = None) -> None:
if channel_id in self._adapters:
logger.warning(f"Channel {channel_id} already running")
return
@ -493,6 +504,8 @@ class ChannelManager:
raise ValueError(f"No adapter registered for channel {channel_id}")
config = config or self._channels_config.get(channel_id, {})
_validate_channel_config(channel_id, config)
adapter = adapter_cls(config=config)
adapter._state_store = self._state_store
adapter.on_message(self._handle_inbound_message)
@ -532,6 +545,11 @@ class ChannelManager:
logger.info(f"Channel {channel_id} started")
async def stop_channel(self, channel_id: str) -> None:
lock = self._start_stop_locks.setdefault(channel_id, asyncio.Lock())
async with lock:
return await self._stop_channel_impl(channel_id)
async def _stop_channel_impl(self, channel_id: str) -> None:
adapter = self._adapters.get(channel_id)
if not adapter:
return
@ -551,6 +569,7 @@ class ChannelManager:
finally:
self._adapters.pop(channel_id, None)
self._prev_statuses.pop(channel_id, None)
self._health_failure_counts.pop(channel_id, None)
if channel_id in self._circuit_breakers:
self._circuit_breakers[channel_id] = CircuitBreaker(channel_id=channel_id)
@ -733,8 +752,6 @@ class ChannelManager:
"status": "error",
"total_messages": 0,
"today_messages": 0,
"total_count": 0,
"today_count": 0,
"active_connections": 0,
"health": None,
}
@ -749,8 +766,6 @@ class ChannelManager:
"status": info.get("status", "not_found"),
"total_messages": stats.get("total_messages", 0),
"today_messages": stats.get("today_messages", 0),
"total_count": stats.get("total_messages", 0),
"today_count": stats.get("today_messages", 0),
"active_connections": 1 if info.get("status") == ChannelStatus.CONNECTED.value else 0,
"health": info.get("health"),
}
@ -771,38 +786,34 @@ class ChannelManager:
_validate_channel_config(channel_id, config_updates)
adapter = self._adapters.get(channel_id)
old_config: dict[str, Any] | None = dict(adapter.config) if adapter else None
try:
if adapter:
await adapter.reload_config(config_updates)
async with pg_manager.get_async_session_context() as db:
result = await db.execute(select(ChannelConfig).where(ChannelConfig.channel_id == channel_id))
db_config = result.scalar_one_or_none()
if db_config:
existing = db_config.config_json or {}
db_config.config_json = {**existing, **config_updates}
db_config.updated_at = _utc_now()
else:
db_config = ChannelConfig(
channel_id=channel_id,
config_json=config_updates,
enabled=True,
)
db.add(db_config)
await db.commit()
except Exception:
if adapter and old_config is not None:
await adapter.reload_config(old_config)
raise
async with pg_manager.get_async_session_context() as db:
result = await db.execute(select(ChannelConfig).where(ChannelConfig.channel_id == channel_id))
db_config = result.scalar_one_or_none()
if db_config:
existing = db_config.config_json or {}
db_config.config_json = {**existing, **config_updates}
db_config.updated_at = _utc_now()
else:
db_config = ChannelConfig(
channel_id=channel_id,
config_json=config_updates,
enabled=True,
)
db.add(db_config)
await db.commit()
async with self._config_lock:
if channel_id not in self._channels_config:
self._channels_config[channel_id] = {}
self._channels_config[channel_id].update(config_updates)
if adapter:
try:
await adapter.reload_config(config_updates)
except Exception:
logger.exception(f"Failed to reload config for running adapter {channel_id}")
needs_restart = adapter is None
logger.info(
@ -916,6 +927,9 @@ class ChannelManager:
return {"channel_id": channel_id, "test_result": "failure", "error": str(e)}
def is_registered(self, channel_id: str) -> bool:
return channel_id in self._channels_config
def is_enabled(self, channel_id: str) -> bool:
if channel_id not in self._channels_config:
return False
return self._channels_config[channel_id].get("enabled", True)
@ -939,6 +953,16 @@ class ChannelManager:
except Exception:
logger.warning("Failed to cleanup expired state entries", exc_info=True)
try:
async with pg_manager.get_async_session_context() as db:
removed = await SessionMapper._cleanup_expired_mappings(db)
if removed > 0:
logger.info(f"[Cleanup] Removed {removed} expired thread mappings")
except asyncio.CancelledError:
break
except Exception:
logger.warning("Failed to cleanup expired thread mappings", exc_info=True)
async def check_rate_limit(self, key: str, max_req: int, window_seconds: int) -> bool:
lock = self._rate_limit_locks.setdefault(key, asyncio.Lock())
async with lock:
@ -984,6 +1008,7 @@ class ChannelManager:
logger.exception("Error handling inbound message")
async def _health_check_loop(self, channel_id: str) -> None:
HEALTH_FAILURE_THRESHOLD = 3
while channel_id in self._adapters:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
adapter = self._adapters.get(channel_id)
@ -998,6 +1023,7 @@ class ChannelManager:
try:
health = await adapter.health_check()
self._cached_health[channel_id] = (time.monotonic(), health)
self._health_failure_counts[channel_id] = 0
if cb and health.status == "healthy":
try:
await cb.record_success()
@ -1017,6 +1043,18 @@ class ChannelManager:
await cb.record_failure()
except Exception:
pass
self._health_failure_counts[channel_id] += 1
if self._health_failure_counts[channel_id] >= HEALTH_FAILURE_THRESHOLD:
logger.warning(
f"Health check failed {self._health_failure_counts[channel_id]} consecutive times "
f"for {channel_id}, triggering auto-reconnect"
)
self._health_failure_counts[channel_id] = 0
try:
await self.restart_channel(channel_id)
except Exception as reconnect_error:
logger.error(f"Auto-reconnect failed for {channel_id}: {reconnect_error}")
continue
if current_status != prev_status and self._broadcaster:
self._prev_statuses[channel_id] = current_status
@ -1034,11 +1072,16 @@ class ChannelManager:
if not channel_ids:
return {}
now = time.monotonic()
cache_ts, cached = self._batch_stats_cache
if now - cache_ts < STATS_CACHE_TTL and cached:
return {cid: cached.get(cid, self._empty_stats()) for cid in channel_ids}
try:
from yuxi.utils.datetime_utils import utc_now_naive
now = utc_now_naive()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
utc_now = utc_now_naive()
today_start = utc_now.replace(hour=0, minute=0, second=0, microsecond=0)
async with pg_manager.get_async_session_context() as session:
totals_result = await session.execute(
@ -1084,26 +1127,13 @@ class ChannelManager:
"success_rate": round(success / total, 3) if total > 0 else 0,
}
else:
stats_map[cid] = {
"total_messages": 0,
"today_messages": 0,
"success_count": 0,
"error_count": 0,
"success_rate": 0,
}
return stats_map
stats_map[cid] = self._empty_stats()
self._batch_stats_cache = (now, stats_map)
return {cid: stats_map.get(cid, self._empty_stats()) for cid in channel_ids}
except Exception:
logger.warning(f"批量查询渠道统计失败 (channel_ids 数量: {len(channel_ids)}):", exc_info=True)
return {
cid: {
"total_messages": 0,
"today_messages": 0,
"success_count": 0,
"error_count": 0,
"success_rate": 0,
}
for cid in channel_ids
}
return {cid: self._empty_stats() for cid in channel_ids}
async def _get_single_channel_status(self, channel_id: str, stats: dict | None = None) -> dict:
adapter = self._adapters.get(channel_id)
@ -1193,11 +1223,16 @@ class ChannelManager:
}
async def _get_channel_stats(self, channel_id: str) -> dict:
now = time.monotonic()
cached = self._stats_cache.get(channel_id)
if cached and now - cached[0] < STATS_CACHE_TTL:
return cached[1]
try:
from yuxi.utils.datetime_utils import utc_now_naive
now = utc_now_naive()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
utc_now = utc_now_naive()
today_start = utc_now.replace(hour=0, minute=0, second=0, microsecond=0)
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
@ -1224,21 +1259,27 @@ class ChannelManager:
)
today = today_result.scalar() or 0
return {
stats = {
"total_messages": total,
"today_messages": today,
"success_count": int(success),
"error_count": int(error),
"success_rate": round(success / total, 3) if total > 0 else 0,
}
self._stats_cache[channel_id] = (now, stats)
return stats
except Exception:
return {
"total_messages": 0,
"today_messages": 0,
"success_count": 0,
"error_count": 0,
"success_rate": 0,
}
return self._empty_stats()
@staticmethod
def _empty_stats() -> dict:
return {
"total_messages": 0,
"today_messages": 0,
"success_count": 0,
"error_count": 0,
"success_rate": 0,
}
def _adapter_status(self, adapter: BaseChannelAdapter) -> str:
_status = getattr(adapter, "_status", None)
@ -1246,6 +1287,52 @@ class ChannelManager:
return "unknown"
return _status.value if hasattr(_status, "value") else str(_status)
async def _get_credential_summary(self, channel_id: str, adapter: BaseChannelAdapter) -> dict:
try:
secret_manager = getattr(adapter, "_secret_manager", None)
if secret_manager and hasattr(secret_manager, "get_credential_status"):
return await secret_manager.get_credential_status()
except Exception:
logger.warning(f"获取渠道 {channel_id} 凭证状态失败", exc_info=True)
config = self._channels_config.get(channel_id, {})
has_credential = any(
any(pattern in k.lower() for pattern in self.SENSITIVE_KEY_PATTERNS) and v for k, v in config.items()
)
return {
"has_credential": has_credential,
"is_expired": False,
"source": "config" if has_credential else "none",
}
async def get_credential_status(self, channel_id: str) -> dict:
adapter = self._adapters.get(channel_id)
if adapter:
return await self._get_credential_summary(channel_id, adapter)
config = self._channels_config.get(channel_id, {})
has_credential = any(
any(pattern in k.lower() for pattern in self.SENSITIVE_KEY_PATTERNS) and v for k, v in config.items()
)
return {
"has_credential": has_credential,
"is_expired": False,
"source": "config" if has_credential else "none",
}
async def refresh_credential(self, channel_id: str) -> dict:
adapter = self._adapters.get(channel_id)
if adapter and hasattr(adapter, "refresh_credential"):
result = await adapter.refresh_credential()
return result
if adapter:
logger.info(f"渠道 {channel_id} 适配器不支持凭证刷新,尝试重连")
await self.restart_channel(channel_id)
return {"refreshed": True, "method": "restart", "message": "通过重启渠道刷新凭证"}
raise ValueError(f"Channel '{channel_id}' is not running, cannot refresh credential")
async def _ensure_virtual_department(self, db) -> None:
from yuxi.storage.postgres.models_business import Department

View File

@ -309,6 +309,4 @@ class ActionRouter:
for adapter in self._manager._adapters.values():
if hasattr(adapter, "_active_chats") and chat_id in adapter._active_chats:
return adapter
for adapter in self._manager._adapters.values():
return adapter
return None

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import inspect
from yuxi.channels.base import BaseChannelAdapter
_BUILTIN_ADAPTERS: dict[str, type[BaseChannelAdapter]] = {}
@ -217,6 +219,39 @@ class ChannelRegistry:
def is_available(self, channel_id: str) -> bool:
return channel_id in self._adapters or channel_id in _BUILTIN_ADAPTERS
@staticmethod
def _validate_adapter(adapter_cls: type[BaseChannelAdapter]) -> list[str]:
CAPABILITY_METHOD_MAP: dict[str, str] = {
"reactions": "send_reaction",
"edit": "edit_message",
"unsend": "delete_message",
"media": "send_media",
}
caps = getattr(adapter_cls, "capabilities", None)
if caps is None:
return []
issues: list[str] = []
for cap_flag, method_name in CAPABILITY_METHOD_MAP.items():
if not getattr(caps, cap_flag, False):
continue
method = getattr(adapter_cls, method_name, None)
if method is None:
issues.append(f"Capability '{cap_flag}' declared but method '{method_name}' not found")
continue
try:
source = inspect.getsource(method)
except (OSError, TypeError):
continue
if "raise NotImplementedError" in source or "raise NotImplementedError()" in source:
issues.append(
f"Capability '{cap_flag}' declared but method '{method_name}' is not implemented "
f"(raises NotImplementedError)"
)
return issues
def load_builtins(self) -> None:
"""将 _BUILTIN_ADAPTERS 中的所有内置适配器加载到 self._adapters 中"""
_load_builtin_adapters()

View File

@ -1,8 +1,9 @@
from __future__ import annotations
import uuid as uuid_lib
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -16,6 +17,9 @@ from yuxi.utils.logging_config import logger
VIRTUAL_DEPARTMENT_ID = -1
USER_SOURCE_PREFIX = "channel:"
THREAD_MAPPING_CLEANUP_TTL_DAYS = 7
THREAD_MAPPING_CLEANUP_LONG_TTL_DAYS = 30
class SessionMapper:
def __init__(self, db: AsyncSession, department_id: int = VIRTUAL_DEPARTMENT_ID):
@ -139,3 +143,25 @@ class SessionMapper:
)
)
return result.scalar_one_or_none()
@staticmethod
async def cleanup_expired(db: AsyncSession, ttl_days: int = THREAD_MAPPING_CLEANUP_TTL_DAYS) -> int:
cutoff = utc_now_naive() - timedelta(days=ttl_days)
result = await db.execute(delete(ChannelThreadMapping).where(ChannelThreadMapping.last_active_at < cutoff))
await db.commit()
removed = result.rowcount
if removed:
logger.info(f"SessionMapper: cleaned up {removed} expired thread mappings (ttl={ttl_days}d)")
return removed
@staticmethod
async def _cleanup_expired_mappings(db: AsyncSession) -> int:
cutoff = utc_now_naive() - timedelta(days=THREAD_MAPPING_CLEANUP_LONG_TTL_DAYS)
result = await db.execute(delete(ChannelThreadMapping).where(ChannelThreadMapping.last_active_at < cutoff))
await db.commit()
removed = result.rowcount
if removed:
logger.info(
f"SessionMapper: cleaned up {removed} expired mappings (ttl={THREAD_MAPPING_CLEANUP_LONG_TTL_DAYS}d)"
)
return removed

View File

@ -58,6 +58,24 @@ async def _check_rate_limit(user_id: str, key: str, max_req: int, window: int) -
)
async def _handle_channel_operation_exceptions(channel_id: str, operation):
try:
return await operation
except ChannelAuthenticationError as e:
raise HTTPException(status_code=401, detail=f"凭证无效: {e}")
except ChannelNotConnectedError as e:
raise HTTPException(status_code=503, detail=f"渠道不可达: {e}")
except ChannelTimeoutError as e:
raise HTTPException(status_code=504, detail=f"连接超时: {e}")
except ChannelRateLimitError as e:
raise HTTPException(status_code=429, detail=f"上游限流: {e}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to execute channel operation for {channel_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to execute channel operation: {e}")
_POLICY_FIELDS = [
"group_chat_mode",
"whitelist_ids",
@ -468,21 +486,7 @@ async def start_channel(
if manager.is_running(channel_id):
return _make_response(code=409, message=f"Channel '{channel_id}' is already running")
try:
await manager.start_channel(channel_id)
except ChannelAuthenticationError as e:
raise HTTPException(status_code=401, detail=f"凭证无效: {e}")
except ChannelNotConnectedError as e:
raise HTTPException(status_code=503, detail=f"渠道不可达: {e}")
except ChannelTimeoutError as e:
raise HTTPException(status_code=504, detail=f"连接超时: {e}")
except ChannelRateLimitError as e:
raise HTTPException(status_code=429, detail=f"上游限流: {e}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to start channel {channel_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to start channel: {e}")
await _handle_channel_operation_exceptions(channel_id, manager.start_channel(channel_id))
adapter = manager._adapters.get(channel_id)
current_status = manager._adapter_status(adapter) if adapter else "connecting"
@ -539,21 +543,7 @@ async def restart_channel(
if not manager.is_running(channel_id):
raise HTTPException(status_code=409, detail=f"Channel '{channel_id}' is not running")
try:
await manager.restart_channel(channel_id)
except ChannelAuthenticationError as e:
raise HTTPException(status_code=401, detail=f"凭证无效: {e}")
except ChannelNotConnectedError as e:
raise HTTPException(status_code=503, detail=f"渠道不可达: {e}")
except ChannelTimeoutError as e:
raise HTTPException(status_code=504, detail=f"连接超时: {e}")
except ChannelRateLimitError as e:
raise HTTPException(status_code=429, detail=f"上游限流: {e}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to restart channel {channel_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to restart channel: {e}")
await _handle_channel_operation_exceptions(channel_id, manager.restart_channel(channel_id))
adapter = manager._adapters.get(channel_id)
current_status = manager._adapter_status(adapter) if adapter else "connecting"
@ -980,20 +970,31 @@ async def update_channel_policy(
updates_dict = policy_updates.model_dump(exclude_none=True)
result = await db.execute(select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id))
policy = result.scalar_one_or_none()
try:
result = await db.execute(select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id))
policy = result.scalar_one_or_none()
if policy:
for key in _POLICY_FIELDS:
if key in updates_dict:
setattr(policy, key, updates_dict[key])
else:
policy_data = {k: v for k, v in updates_dict.items() if k in _POLICY_FIELDS}
policy = ChannelPolicyConfig(channel_id=channel_id, **policy_data)
db.add(policy)
if policy:
for key in _POLICY_FIELDS:
if key in updates_dict:
setattr(policy, key, updates_dict[key])
else:
policy_data = {k: v for k, v in updates_dict.items() if k in _POLICY_FIELDS}
policy = ChannelPolicyConfig(channel_id=channel_id, **policy_data)
db.add(policy)
await db.commit()
await db.refresh(policy)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="策略配置写入冲突,请重试")
except DBAPIError as e:
logger.error(f"数据库操作失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
except Exception as e:
logger.error(f"更新策略配置失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="更新策略配置失败")
await db.commit()
await db.refresh(policy)
return _make_response(data=policy.to_dict(), message="策略配置已保存")
@ -1009,8 +1010,16 @@ async def get_channel_routing(
from yuxi.storage.postgres.models_channels import ChannelRoutingRule
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
rules = result.scalars().all()
try:
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
rules = result.scalars().all()
except DBAPIError as e:
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
except Exception as e:
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="查询路由规则失败")
return _make_response(data={"channel_id": channel_id, "routes": [r.to_dict() for r in rules]})
@ -1059,10 +1068,27 @@ async def update_channel_routing(
)
db.add(rule)
await db.commit()
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="路由规则写入冲突,请重试")
except DBAPIError as e:
logger.error(f"数据库操作失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
except Exception as e:
logger.error(f"保存路由规则失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="保存路由规则失败")
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
saved_rules = result.scalars().all()
try:
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
saved_rules = result.scalars().all()
except DBAPIError as e:
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
except Exception as e:
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
raise HTTPException(status_code=500, detail="查询路由规则失败")
return _make_response(
data={"channel_id": channel_id, "routes": [r.to_dict() for r in saved_rules]},
message="路由规则已保存",
@ -1320,6 +1346,32 @@ async def delete_channel_state_entry(
)
@channels.get("/{channel_id}/credential-status")
async def get_channel_credential_status(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
credential = await manager.get_credential_status(channel_id)
return _make_response(data={"channel_id": channel_id, "credential": credential})
@channels.post("/{channel_id}/refresh-credential")
async def refresh_channel_credential(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
result = await manager.refresh_credential(channel_id)
return _make_response(data={"channel_id": channel_id, "credential": result})
@channels.delete("/{channel_id}")
async def unregister_channel(
channel_id: str,