ForcePilot/backend/server/routers/channel_admin_router.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

1065 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""多渠道网关管理端 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()}