1065 lines
37 KiB
Python
1065 lines
37 KiB
Python
"""多渠道网关管理端 HTTP 路由。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from datetime import timedelta
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
from yuxi.channel import metrics as channel_metrics
|
||
from yuxi.channel.config import ChannelConfigAlreadyExistsError, ChannelConfigManager
|
||
from yuxi.channel.constants import (
|
||
DeliveryStatus,
|
||
channel_delivered_key,
|
||
channel_processing_key,
|
||
channel_publishing_key,
|
||
)
|
||
from yuxi.channel.outbound.publisher import publish_channel_message_event
|
||
from yuxi.channel.plugins.protocol import ChannelCapability, ChannelMeta
|
||
from yuxi.channel.plugins.registry import get_registry
|
||
from yuxi.channel.security.pairing import PairingManager
|
||
from yuxi.channel.security.qr_binding import PairingQRGenerator
|
||
from yuxi.repositories.channel_binding_repository import ChannelBindingRepository
|
||
from yuxi.repositories.channel_identity_repository import ChannelIdentityRepository
|
||
from yuxi.repositories.channel_session_repository import ChannelSessionRepository
|
||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||
from yuxi.repositories.message_repository import MessageRepository
|
||
from yuxi.services import channel_admin_service
|
||
from yuxi.services.run_queue_service import get_redis_client
|
||
from yuxi.storage.postgres.manager import pg_manager
|
||
from yuxi.storage.postgres.model_channel import ChannelPairingRecord
|
||
from yuxi.storage.postgres.models_business import User
|
||
from yuxi.channel.lifecycle.manager import ChannelLifecycleManager
|
||
from yuxi.channel.transport.qr_login import QRLoginSession
|
||
from yuxi.channel.transport.qr_login_transport import BaseQRLoginTransport
|
||
from yuxi.utils import logger
|
||
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
|
||
|
||
from server.utils.auth_middleware import get_admin_user, get_db
|
||
|
||
channel_admin = APIRouter(prefix="/system/channels", tags=["system-channels"])
|
||
|
||
|
||
class ChannelConfigCreate(BaseModel):
|
||
account_id: str = Field(..., max_length=128, min_length=1)
|
||
config_json: dict[str, Any] = Field(default_factory=dict)
|
||
name: str | None = Field(None, max_length=100, min_length=1)
|
||
|
||
|
||
class ChannelConfigUpdate(BaseModel):
|
||
config_json: dict[str, Any] | None = None
|
||
name: str | None = Field(None, max_length=100, min_length=1)
|
||
|
||
|
||
class ChannelBindingCreate(BaseModel):
|
||
binding_rule_hash: str = Field(..., max_length=64, min_length=1)
|
||
agent_id: str = Field(..., max_length=80, min_length=1)
|
||
session_key_pattern: str | None = Field(None, max_length=512)
|
||
match: dict[str, Any] | None = None
|
||
session_override: dict[str, Any] | None = None
|
||
|
||
|
||
class PairingGenerateRequest(BaseModel):
|
||
account_id: str = Field(..., max_length=128, min_length=1)
|
||
peer_id: str = Field(..., max_length=255, min_length=1)
|
||
|
||
|
||
class PairingVerifyRequest(BaseModel):
|
||
channel_type: str = Field(..., max_length=32, min_length=1)
|
||
account_id: str = Field(..., max_length=128, min_length=1)
|
||
peer_id: str = Field(..., max_length=255, min_length=1)
|
||
code: str = Field(..., max_length=10, min_length=1)
|
||
|
||
|
||
class PairingQRGenerateRequest(BaseModel):
|
||
peer_id: str = Field(..., max_length=255, min_length=1)
|
||
platform_user_id: str = Field(..., max_length=255, min_length=1)
|
||
mode: str = Field("qr", pattern="^(code|qr|both)$")
|
||
qr_content_mode: str = Field("plain", pattern="^(plain|link)$")
|
||
|
||
|
||
class PairingQRGenerateResponse(BaseModel):
|
||
pairing_code: str
|
||
qr_content: str
|
||
qr_image_url: str
|
||
expires_at: str
|
||
|
||
|
||
class PairingStatusQuery(BaseModel):
|
||
channel_sender_id: str | None = Field(None, max_length=255)
|
||
platform_user_id: str | None = Field(None, max_length=255)
|
||
|
||
|
||
class PairingStatusResponse(BaseModel):
|
||
linked: bool
|
||
platform_user_id: str | None = None
|
||
paired_by: str | None = None
|
||
paired_at: str | None = None
|
||
|
||
|
||
class PairingUnlinkRequest(BaseModel):
|
||
channel_sender_id: str | None = Field(None, max_length=255)
|
||
platform_user_id: str | None = Field(None, max_length=255)
|
||
|
||
|
||
class QRLoginResponse(BaseModel):
|
||
ticket: str
|
||
qr_content: str
|
||
qr_image_url: str | None = None
|
||
qr_image_base64: str | None = None
|
||
state: str
|
||
expires_at: str | None = None
|
||
|
||
|
||
class LoginStatusResponse(BaseModel):
|
||
state: str
|
||
transport_state: str
|
||
expires_at: str | None = None
|
||
scanned_at: str | None = None
|
||
logged_in_at: str | None = None
|
||
error_message: str | None = None
|
||
|
||
|
||
def _meta_to_dict(meta: ChannelMeta) -> dict[str, Any]:
|
||
"""将渠道元信息转换为对外约定的字典,避免暴露内部字段。"""
|
||
return {
|
||
"channel_type": meta.channel_type,
|
||
"display_name": meta.display_name,
|
||
"description": meta.description,
|
||
"icon": meta.icon,
|
||
"aliases": meta.aliases,
|
||
"capabilities": [cap.name for cap in ChannelCapability if cap in meta.capabilities],
|
||
"transport_type": meta.transport_type.value,
|
||
"delivery_mode": meta.delivery_mode.value,
|
||
"config_schema": meta.config_schema,
|
||
"ui_hints": meta.ui_hints,
|
||
"sort_weight": meta.sort_weight,
|
||
}
|
||
|
||
|
||
def _get_gateway(request: Request):
|
||
gateway = getattr(request.app.state, "channel_gateway", None)
|
||
if gateway is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="Channel gateway not initialized",
|
||
)
|
||
return gateway
|
||
|
||
|
||
def _get_lifecycle_manager(request: Request) -> ChannelLifecycleManager:
|
||
manager = getattr(request.app.state, "channel_lifecycle_manager", None)
|
||
if manager is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="Channel lifecycle manager not initialized",
|
||
)
|
||
return manager
|
||
|
||
|
||
def _get_qr_transport(
|
||
request: Request,
|
||
channel_type: str,
|
||
account_id: str,
|
||
) -> BaseQRLoginTransport:
|
||
"""从生命周期管理器获取指定账户的 QR 登录 transport。
|
||
|
||
若账户未启用或不是 QR 登录 transport,则抛出 HTTP 异常。
|
||
"""
|
||
manager = _get_lifecycle_manager(request)
|
||
transport = manager.get_transport(channel_type, account_id)
|
||
if transport is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Channel instance not found or not enabled",
|
||
)
|
||
if not isinstance(transport, BaseQRLoginTransport):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=f"Channel {channel_type} does not support QR login",
|
||
)
|
||
return transport
|
||
|
||
|
||
@channel_admin.get("")
|
||
async def list_channels(current_user: User = Depends(get_admin_user)):
|
||
"""列出所有已注册渠道插件及账户。"""
|
||
registry = get_registry()
|
||
config_manager = ChannelConfigManager()
|
||
plugins = registry.list_plugins()
|
||
configs = await config_manager.list_all_accounts(limit=1000)
|
||
return {
|
||
"plugins": [_meta_to_dict(meta) for meta in plugins],
|
||
"configs": configs,
|
||
}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/schema")
|
||
async def get_channel_schema(channel_type: str, current_user: User = Depends(get_admin_user)):
|
||
"""获取渠道配置 JSON Schema。"""
|
||
registry = get_registry()
|
||
plugin = registry.get_plugin(channel_type)
|
||
if plugin is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Channel type not found")
|
||
return {"schema": plugin.get_meta().config_schema}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config")
|
||
async def create_channel_config(
|
||
channel_type: str,
|
||
payload: ChannelConfigCreate,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""创建渠道账户配置。"""
|
||
config_manager = ChannelConfigManager()
|
||
try:
|
||
config = await config_manager.create_config(
|
||
channel_type=channel_type,
|
||
account_id=payload.account_id,
|
||
config_json=payload.config_json,
|
||
name=payload.name,
|
||
created_by=str(current_user.uid),
|
||
)
|
||
except ChannelConfigAlreadyExistsError as exc:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||
return {"config": config}
|
||
|
||
|
||
@channel_admin.put("/{channel_type}/config/{account_id}")
|
||
async def update_channel_config(
|
||
channel_type: str,
|
||
account_id: str,
|
||
payload: ChannelConfigUpdate,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""更新渠道账户配置。"""
|
||
config_manager = ChannelConfigManager()
|
||
try:
|
||
config = await config_manager.update_config(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
config_json=payload.config_json,
|
||
name=payload.name,
|
||
updated_by=str(current_user.uid),
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
return {"config": config}
|
||
|
||
|
||
@channel_admin.delete("/{channel_type}/config/{account_id}")
|
||
async def delete_channel_config(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""删除渠道账户配置;如正在运行则先停止,并级联清理绑定与会话。"""
|
||
config_manager = ChannelConfigManager()
|
||
try:
|
||
await config_manager.get_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
|
||
gateway = _get_gateway(request)
|
||
await gateway.stop_channel(channel_type, account_id)
|
||
|
||
binding_repo = ChannelBindingRepository(db)
|
||
session_repo = ChannelSessionRepository(db)
|
||
|
||
await binding_repo.delete_by_channel(channel_type, account_id, auto_commit=False)
|
||
await session_repo.delete_by_channel(channel_type, account_id, auto_commit=False)
|
||
deleted = await config_manager.delete_config(channel_type, account_id, session=db)
|
||
if not deleted:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||
|
||
await db.commit()
|
||
await config_manager.notify_config_changed(channel_type, account_id, "deleted")
|
||
return {"success": True}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/enable")
|
||
async def enable_channel(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""启用渠道账户;启停由 Gateway 的配置变更事件驱动。"""
|
||
gateway = _get_gateway(request)
|
||
lifecycle_manager = _get_lifecycle_manager(request)
|
||
try:
|
||
result = await channel_admin_service.enable_channel(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
updated_by=str(current_user.uid),
|
||
gateway=gateway,
|
||
lifecycle_manager=lifecycle_manager,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
except channel_admin_service.ChannelHealthCheckError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=str(exc),
|
||
) from exc
|
||
return result
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/disable")
|
||
async def disable_channel(
|
||
channel_type: str,
|
||
account_id: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""禁用渠道账户;停止生命周期由 Gateway 的配置变更事件驱动。"""
|
||
config_manager = ChannelConfigManager()
|
||
|
||
try:
|
||
await config_manager.update_config(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
enabled=False,
|
||
updated_by=str(current_user.uid),
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
except Exception as exc:
|
||
logger.exception(
|
||
"Failed to disable channel %s/%s",
|
||
channel_type,
|
||
account_id,
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="Failed to disable channel",
|
||
) from exc
|
||
|
||
return {"enabled": False}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/health")
|
||
async def channel_health(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""查询渠道账户健康状态。"""
|
||
gateway = _get_gateway(request)
|
||
health = await gateway.health_check(channel_type, account_id)
|
||
return {"health": health.to_dict()}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/login/qr", response_model=QRLoginResponse)
|
||
async def refresh_channel_qr(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
gateway = _get_gateway(request)
|
||
plugin = gateway.registry.get_plugin(channel_type)
|
||
if plugin is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown channel type")
|
||
try:
|
||
config = await gateway.config_manager.get_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
if not plugin.supports_qr_login(config):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Channel does not support QR login")
|
||
qr_transport = _get_qr_transport(request, channel_type, account_id)
|
||
try:
|
||
session: QRLoginSession = await qr_transport.refresh_qr()
|
||
except Exception as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Failed to generate QR code: {exc}"
|
||
) from exc
|
||
return QRLoginResponse(
|
||
ticket=session.ticket,
|
||
qr_content=session.qr_content,
|
||
qr_image_url=session.qr_image_url,
|
||
qr_image_base64=session.qr_image_base64,
|
||
state=session.state.value,
|
||
expires_at=format_utc_datetime(session.expires_at),
|
||
)
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/login/status", response_model=LoginStatusResponse)
|
||
async def get_channel_login_status(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
qr_transport = _get_qr_transport(request, channel_type, account_id)
|
||
session = qr_transport.current_session
|
||
return LoginStatusResponse(
|
||
state=qr_transport.login_state.value,
|
||
transport_state=qr_transport.state.value,
|
||
expires_at=format_utc_datetime(session.expires_at) if session else None,
|
||
scanned_at=format_utc_datetime(session.scanned_at) if session else None,
|
||
logged_in_at=format_utc_datetime(session.logged_in_at) if session else None,
|
||
error_message=session.error_message if session else None,
|
||
)
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/login/poll", response_model=QRLoginResponse)
|
||
async def poll_channel_login(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
gateway = _get_gateway(request)
|
||
plugin = gateway.registry.get_plugin(channel_type)
|
||
if plugin is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown channel type")
|
||
try:
|
||
config = await gateway.config_manager.get_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||
if not plugin.supports_qr_login(config):
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Channel does not support QR login")
|
||
qr_transport = _get_qr_transport(request, channel_type, account_id)
|
||
try:
|
||
session: QRLoginSession = await qr_transport.poll_login_status()
|
||
except Exception as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Failed to poll login status: {exc}"
|
||
) from exc
|
||
return QRLoginResponse(
|
||
ticket=session.ticket,
|
||
qr_content=session.qr_content,
|
||
qr_image_url=session.qr_image_url,
|
||
qr_image_base64=session.qr_image_base64,
|
||
state=session.state.value,
|
||
expires_at=format_utc_datetime(session.expires_at),
|
||
)
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/login/logout")
|
||
async def logout_channel_login(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
qr_transport = _get_qr_transport(request, channel_type, account_id)
|
||
try:
|
||
await qr_transport.logout()
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Failed to logout: {exc}") from exc
|
||
return {"success": True}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/sessions")
|
||
async def list_channel_sessions(
|
||
channel_type: str,
|
||
account_id: str,
|
||
limit: int = Query(100, le=500, ge=1),
|
||
offset: int = Query(0, ge=0),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""列出渠道会话映射。"""
|
||
repo = ChannelSessionRepository(db)
|
||
sessions = await repo.list_by_channel(channel_type, account_id, limit=limit, offset=offset)
|
||
return {"sessions": [session.to_dict() for session in sessions]}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/bindings")
|
||
async def list_channel_bindings(
|
||
channel_type: str,
|
||
account_id: str,
|
||
limit: int = Query(100, le=500, ge=1),
|
||
offset: int = Query(0, ge=0),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""列出渠道运行时绑定记录。"""
|
||
repo = ChannelBindingRepository(db)
|
||
bindings = await repo.list_by_channel(channel_type, account_id, limit=limit, offset=offset)
|
||
return {"bindings": [binding.to_dict() for binding in bindings]}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/bindings")
|
||
async def create_channel_binding(
|
||
channel_type: str,
|
||
account_id: str,
|
||
payload: ChannelBindingCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""创建渠道运行时绑定记录。"""
|
||
repo = ChannelBindingRepository(db)
|
||
binding = await repo.create(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
binding_rule_hash=payload.binding_rule_hash,
|
||
agent_id=payload.agent_id,
|
||
session_key_pattern=payload.session_key_pattern or "",
|
||
match=payload.match,
|
||
session_override=payload.session_override,
|
||
created_by=str(current_user.uid),
|
||
auto_commit=False,
|
||
)
|
||
await db.commit()
|
||
await db.refresh(binding)
|
||
return {"binding": binding.to_dict()}
|
||
|
||
|
||
@channel_admin.delete("/{channel_type}/config/{account_id}/bindings/{binding_id}")
|
||
async def delete_channel_binding(
|
||
channel_type: str,
|
||
account_id: str,
|
||
binding_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""删除渠道运行时绑定记录。"""
|
||
repo = ChannelBindingRepository(db)
|
||
binding = await repo.get_by_id(binding_id)
|
||
if binding is None or binding.channel_type != channel_type or binding.account_id != account_id:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Binding not found")
|
||
await repo.delete(binding_id, auto_commit=False)
|
||
await db.commit()
|
||
return {"success": True}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}")
|
||
async def get_channel(channel_type: str, current_user: User = Depends(get_admin_user)):
|
||
"""获取渠道插件元信息。"""
|
||
registry = get_registry()
|
||
plugin = registry.get_plugin(channel_type)
|
||
if plugin is None:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Channel type not found")
|
||
return {"meta": _meta_to_dict(plugin.get_meta())}
|
||
|
||
|
||
@channel_admin.get("/metrics")
|
||
async def get_channel_metrics(current_user: User = Depends(get_admin_user)):
|
||
"""获取多渠道网关运行时指标。
|
||
|
||
注意:返回的指标为当前进程内存中的快照,多进程/多实例部署时
|
||
不同实例间的数据不共享,请勿用于跨实例聚合统计。
|
||
"""
|
||
return {"metrics": channel_metrics.dump()}
|
||
|
||
|
||
_PAIRING_VERIFY_WINDOW_SECONDS = 60
|
||
_PAIRING_VERIFY_MAX_ATTEMPTS = 5
|
||
|
||
|
||
def _pairing_verify_rate_key(channel_type: str, account_id: str, peer_id: str) -> str:
|
||
raw = f"{channel_type}:{account_id}:{peer_id}"
|
||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||
return f"pairing:verify:{digest}"
|
||
|
||
|
||
async def _incr_pairing_verify_attempt(redis, channel_type: str, account_id: str, peer_id: str) -> int:
|
||
key = _pairing_verify_rate_key(channel_type, account_id, peer_id)
|
||
count = await redis.incr(key)
|
||
if count == 1:
|
||
await redis.expire(key, _PAIRING_VERIFY_WINDOW_SECONDS)
|
||
return count
|
||
|
||
|
||
async def _clear_pairing_verify_attempts(redis, channel_type: str, account_id: str, peer_id: str) -> None:
|
||
key = _pairing_verify_rate_key(channel_type, account_id, peer_id)
|
||
await redis.delete(key)
|
||
|
||
|
||
@channel_admin.post("/-/pairing/verify")
|
||
async def verify_pairing_code(
|
||
payload: PairingVerifyRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""验证 DM 配对码。"""
|
||
try:
|
||
redis = await get_redis_client()
|
||
attempt_count = await _incr_pairing_verify_attempt(
|
||
redis,
|
||
payload.channel_type,
|
||
payload.account_id,
|
||
payload.peer_id,
|
||
)
|
||
except Exception as redis_exc:
|
||
logger.error("Redis unavailable for pairing rate limit: %s", redis_exc)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="Rate limit service unavailable",
|
||
) from redis_exc
|
||
|
||
if attempt_count > _PAIRING_VERIFY_MAX_ATTEMPTS:
|
||
logger.bind(
|
||
event="pairing_verify_rate_limited",
|
||
user_id=str(current_user.uid),
|
||
channel_type=payload.channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
attempt_count=attempt_count,
|
||
).warning("Pairing code verification rate limited")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
detail="Too many pairing code verification attempts",
|
||
)
|
||
|
||
manager = PairingManager()
|
||
ok = await manager.verify_pairing_code(
|
||
channel_type=payload.channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
code=payload.code,
|
||
)
|
||
if ok:
|
||
try:
|
||
await _clear_pairing_verify_attempts(
|
||
redis,
|
||
payload.channel_type,
|
||
payload.account_id,
|
||
payload.peer_id,
|
||
)
|
||
except Exception as clear_exc:
|
||
logger.error(
|
||
"Failed to clear pairing verify attempts for %s/%s/%s: %s",
|
||
payload.channel_type,
|
||
payload.account_id,
|
||
payload.peer_id,
|
||
clear_exc,
|
||
)
|
||
logger.bind(
|
||
event="pairing_verify_succeeded",
|
||
user_id=str(current_user.uid),
|
||
channel_type=payload.channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
).info("Pairing code verified successfully")
|
||
return {"valid": True}
|
||
|
||
logger.bind(
|
||
event="pairing_verify_failed",
|
||
user_id=str(current_user.uid),
|
||
channel_type=payload.channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
).warning("Pairing code verification failed")
|
||
return {"valid": False}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/pairing")
|
||
async def generate_pairing_code(
|
||
channel_type: str,
|
||
payload: PairingGenerateRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""生成 DM 配对码。"""
|
||
manager = PairingManager()
|
||
code = await manager.generate_pairing_code(
|
||
channel_type=channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
)
|
||
expires_at = utc_now_naive() + timedelta(minutes=PairingManager.CODE_TTL_MINUTES)
|
||
logger.bind(
|
||
event="pairing_code_generated",
|
||
user_id=str(current_user.uid),
|
||
channel_type=channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
).info("Pairing code generated")
|
||
return {"code": code, "expires_at": expires_at.isoformat()}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/pairing/qr")
|
||
async def generate_pairing_qr(
|
||
channel_type: str,
|
||
account_id: str,
|
||
payload: PairingQRGenerateRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""主动生成配对二维码,返回配对码、二维码内容与图片地址。"""
|
||
manager = PairingManager()
|
||
|
||
config_manager = ChannelConfigManager()
|
||
try:
|
||
config = await config_manager.get_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
) from exc
|
||
|
||
pairing_cfg = config.get("pairing", {})
|
||
qr_content_mode = payload.qr_content_mode or pairing_cfg.get("qr_content_mode", "plain")
|
||
base_url = pairing_cfg.get("qr_link_base_url")
|
||
|
||
qr_generator = PairingQRGenerator(base_url=base_url)
|
||
code = manager.generate_code_string()
|
||
qr_content = qr_generator.generate_content(
|
||
code,
|
||
channel_type,
|
||
account_id,
|
||
payload.peer_id,
|
||
mode=qr_content_mode,
|
||
)
|
||
|
||
code = await manager.generate_pairing_code(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
peer_id=payload.peer_id,
|
||
platform_user_id=payload.platform_user_id,
|
||
qr_content=qr_content,
|
||
pairing_mode=payload.mode,
|
||
code=code,
|
||
)
|
||
|
||
expires_at = utc_now_naive() + timedelta(minutes=PairingManager.CODE_TTL_MINUTES)
|
||
qr_image_url = f"/api/system/channels/{channel_type}/config/{account_id}/pairing/qr-image?code={code}"
|
||
|
||
logger.bind(
|
||
event="pairing_qr_generated",
|
||
user_id=str(current_user.uid),
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
peer_id=payload.peer_id,
|
||
platform_user_id=payload.platform_user_id,
|
||
).info("Pairing QR generated")
|
||
|
||
return {
|
||
"pairing_code": code,
|
||
"qr_content": qr_content,
|
||
"qr_image_url": qr_image_url,
|
||
"expires_at": expires_at.isoformat(),
|
||
}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/pairing/qr-image")
|
||
async def get_pairing_qr_image(
|
||
channel_type: str,
|
||
account_id: str,
|
||
code: str = Query(..., min_length=1, max_length=10),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""根据配对码返回 PNG 格式二维码图片。"""
|
||
async with pg_manager.get_async_session_context() as session:
|
||
result = await session.execute(
|
||
select(ChannelPairingRecord)
|
||
.where(
|
||
ChannelPairingRecord.channel_type == channel_type,
|
||
ChannelPairingRecord.account_id == account_id,
|
||
ChannelPairingRecord.pairing_code == code,
|
||
ChannelPairingRecord.status == "pending",
|
||
)
|
||
.order_by(ChannelPairingRecord.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
if record is None or not record.qr_content:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Pairing code not found or expired",
|
||
)
|
||
qr_content = record.qr_content
|
||
|
||
generator = PairingQRGenerator()
|
||
png_bytes = generator.generate_image(qr_content)
|
||
return Response(content=png_bytes, media_type="image/png")
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/pairing/status")
|
||
async def get_pairing_status(
|
||
channel_type: str,
|
||
account_id: str,
|
||
channel_sender_id: str | None = Query(None, max_length=255),
|
||
platform_user_id: str | None = Query(None, max_length=255),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""查询指定渠道发送者或平台用户的配对状态。"""
|
||
if not channel_sender_id and not platform_user_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Either channel_sender_id or platform_user_id is required",
|
||
)
|
||
|
||
repo = ChannelIdentityRepository(db)
|
||
link = None
|
||
if channel_sender_id:
|
||
link = await repo.get_by_sender(channel_type, account_id, channel_sender_id)
|
||
elif platform_user_id:
|
||
links = await repo.get_by_platform_user(platform_user_id, limit=1)
|
||
link = links[0] if links else None
|
||
|
||
if link is None:
|
||
return {"linked": False}
|
||
|
||
return {
|
||
"linked": True,
|
||
"platform_user_id": link.platform_user_id,
|
||
"paired_by": link.paired_by,
|
||
"paired_at": link.created_at.isoformat() if link.created_at else None,
|
||
}
|
||
|
||
|
||
@channel_admin.delete("/{channel_type}/config/{account_id}/pairing")
|
||
async def unlink_pairing(
|
||
channel_type: str,
|
||
account_id: str,
|
||
payload: PairingUnlinkRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""撤销指定渠道发送者或平台用户的配对绑定。"""
|
||
if not payload.channel_sender_id and not payload.platform_user_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Either channel_sender_id or platform_user_id is required",
|
||
)
|
||
|
||
repo = ChannelIdentityRepository(db)
|
||
deleted = False
|
||
|
||
if payload.channel_sender_id:
|
||
deleted = await repo.delete_by_sender(channel_type, account_id, payload.channel_sender_id, auto_commit=False)
|
||
elif payload.platform_user_id:
|
||
count = await repo.delete_by_platform_user(payload.platform_user_id, auto_commit=False)
|
||
deleted = count > 0
|
||
|
||
if not deleted:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Binding not found",
|
||
)
|
||
|
||
await db.commit()
|
||
logger.bind(
|
||
event="pairing_unlinked",
|
||
user_id=str(current_user.uid),
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
channel_sender_id=payload.channel_sender_id,
|
||
platform_user_id=payload.platform_user_id,
|
||
).info("Pairing unlinked")
|
||
return {"success": True}
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}")
|
||
async def get_channel_config(
|
||
channel_type: str,
|
||
account_id: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""获取单个渠道账户配置。"""
|
||
config_manager = ChannelConfigManager()
|
||
try:
|
||
config_dict = await config_manager.get_admin_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
) from exc
|
||
return {"config": config_dict}
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/test")
|
||
async def test_channel_config(
|
||
channel_type: str,
|
||
account_id: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""测试渠道账户配置连通性;不修改 enabled 状态。"""
|
||
try:
|
||
return await channel_admin_service.test_channel_config(channel_type, account_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
) from exc
|
||
|
||
|
||
@channel_admin.post("/{channel_type}/config/{account_id}/restart")
|
||
async def restart_channel(
|
||
channel_type: str,
|
||
account_id: str,
|
||
request: Request,
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""重启渠道账户。"""
|
||
gateway = _get_gateway(request)
|
||
try:
|
||
result = await channel_admin_service.restart_channel(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
gateway=gateway,
|
||
)
|
||
except ValueError as exc:
|
||
if "not enabled" in str(exc):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=str(exc),
|
||
) from exc
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
) from exc
|
||
except channel_admin_service.ChannelHealthCheckError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=str(exc),
|
||
) from exc
|
||
|
||
logger.bind(
|
||
event="channel_restarted",
|
||
user_id=str(current_user.uid),
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
).info("Channel restarted")
|
||
return result
|
||
|
||
|
||
@channel_admin.get("/{channel_type}/config/{account_id}/sessions/{session_id}")
|
||
async def get_channel_session(
|
||
channel_type: str,
|
||
account_id: str,
|
||
session_id: str,
|
||
limit: int = Query(20, le=100, ge=1),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""获取渠道会话详情及最近消息。"""
|
||
session = await ChannelSessionRepository(db).get_by_id(session_id)
|
||
if session is None or session.channel_type != channel_type or session.account_id != account_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Session not found",
|
||
)
|
||
|
||
recent_messages = await ConversationRepository(db).get_recent_messages(
|
||
session.conversation_id,
|
||
limit=limit,
|
||
)
|
||
return {
|
||
"session": session.to_dict(),
|
||
"conversation_id": session.conversation_id,
|
||
"recent_messages": [message.to_dict() for message in recent_messages],
|
||
}
|
||
|
||
|
||
@channel_admin.post("/messages/{message_id}/retry")
|
||
async def retry_channel_message(
|
||
message_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""重试出站消息投递。"""
|
||
message = await MessageRepository(db).get(message_id)
|
||
if message is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Message not found",
|
||
)
|
||
|
||
if message.delivery_status not in {
|
||
DeliveryStatus.FAILED,
|
||
DeliveryStatus.PARTIAL_FAILED,
|
||
DeliveryStatus.DEAD_LETTER,
|
||
}:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Message delivery_status is not retryable",
|
||
)
|
||
|
||
conversation = await ConversationRepository(db).get_conversation_by_id(message.conversation_id)
|
||
if conversation is None or not conversation.channel_type or conversation.channel_type == "web":
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Conversation is not a channel conversation",
|
||
)
|
||
|
||
session_key = conversation.channel_session_id
|
||
if not session_key:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Conversation has no channel session",
|
||
)
|
||
|
||
redis = await get_redis_client()
|
||
await redis.delete(channel_delivered_key(message_id))
|
||
await redis.delete(channel_publishing_key(message_id))
|
||
await redis.delete(channel_processing_key(message_id))
|
||
|
||
channel_metadata = dict(message.channel_metadata or {})
|
||
channel_metadata["compensate_attempts"] = 0
|
||
channel_metadata["attempts"] = 0
|
||
message.channel_metadata = channel_metadata
|
||
flag_modified(message, "channel_metadata")
|
||
|
||
message.delivery_status = DeliveryStatus.PENDING
|
||
await db.commit()
|
||
|
||
await publish_channel_message_event(message, conversation, session_key)
|
||
|
||
logger.bind(
|
||
event="channel_message_retried",
|
||
user_id=str(current_user.uid),
|
||
message_id=message_id,
|
||
channel_type=conversation.channel_type,
|
||
account_id=conversation.channel_metadata.get("account_id") if conversation.channel_metadata else None,
|
||
).info("Channel message retry requested")
|
||
return {"retried": True, "new_status": "pending"}
|
||
|
||
|
||
@channel_admin.put("/{channel_type}/config/{account_id}/bindings/{binding_id}")
|
||
async def update_channel_binding(
|
||
channel_type: str,
|
||
account_id: str,
|
||
binding_id: str,
|
||
payload: ChannelBindingCreate,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_admin_user),
|
||
):
|
||
"""更新渠道运行时绑定记录。"""
|
||
repo = ChannelBindingRepository(db)
|
||
binding = await repo.get_by_id(binding_id)
|
||
if binding is None or binding.channel_type != channel_type or binding.account_id != account_id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="Binding not found",
|
||
)
|
||
|
||
updated = await repo.update(
|
||
binding_id,
|
||
binding_rule_hash=payload.binding_rule_hash,
|
||
agent_id=payload.agent_id,
|
||
session_key_pattern=payload.session_key_pattern or "",
|
||
match=payload.match,
|
||
session_override=payload.session_override,
|
||
)
|
||
if updated is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail="Binding update failed, possibly due to duplicate rule",
|
||
)
|
||
|
||
gateway = _get_gateway(request)
|
||
await gateway.binding_router.invalidate_account_cache(channel_type, account_id)
|
||
|
||
logger.bind(
|
||
event="channel_binding_updated",
|
||
user_id=str(current_user.uid),
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
binding_id=binding_id,
|
||
).info("Channel binding updated")
|
||
return {"binding": updated.to_dict()}
|