ForcePilot/backend/server/routers/channels_router.py
Kris 6ca611fead refactor(channel): 重构并新增多项渠道管理功能
1. 简化message_actions.py中获取适配器的逻辑
2. 新增适配器合法性校验工具方法
3. 新增会话映射过期清理功能
4. 重构渠道状态机与基础适配器实现
5. 统一渠道操作异常处理逻辑
6. 新增凭证状态查询与刷新接口
7. 优化健康检查与自动重连逻辑
8. 新增统计数据缓存与批量查询优化
9. 修复部分数据库操作的异常处理逻辑
2026-05-14 09:24:50 +08:00

1388 lines
48 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.

from __future__ import annotations
import csv
import codecs
import json
import re
from datetime import datetime
from io import StringIO
from typing import Any, Literal
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import asc, desc, func, select
from sqlalchemy.exc import DBAPIError, IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import JSONResponse
from yuxi.channels.manager import get_channel_manager
from yuxi.channels.services.plugin_state_store import PluginStateStore, PostgresPluginStateStore
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelException,
ChannelNotConnectedError,
ChannelRateLimitError,
ChannelTimeoutError,
)
from yuxi.storage.postgres.models_business import User
from yuxi.storage.postgres.models_channels import (
ChannelMsgRecord,
ChannelPolicyConfig,
ChannelThreadMapping,
ChannelUserMapping,
)
from yuxi.utils.logging_config import logger
from server.utils.auth_middleware import get_admin_user, get_db
channels = APIRouter(prefix="/channels", tags=["channels"])
def _make_response(data: Any = None, code: int = 0, message: str = "ok") -> dict:
return {"code": code, "data": data, "message": message}
class UpdateStateEntryBody(BaseModel):
value: Any = Field(..., description="状态值(必填)")
ttl_seconds: int | None = Field(None, gt=0, description="过期时间(秒),必须为正整数")
async def _check_rate_limit(user_id: str, key: str, max_req: int, window: int) -> None:
rate_key = f"{key}:{user_id}"
allowed = await get_channel_manager().check_rate_limit(rate_key, max_req, window)
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="请求频率超限,请稍后再试",
)
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",
"welcome_message",
"work_hours_start",
"work_hours_end",
"timezone_offset",
"off_hours_reply",
]
_POLICY_DEFAULTS = {
"group_chat_mode": "mention_only",
"whitelist_ids": [],
"welcome_message": "",
"work_hours_start": "09:00",
"work_hours_end": "18:00",
"timezone_offset": 8,
"off_hours_reply": "",
}
class ChannelPolicyUpdate(BaseModel):
group_chat_mode: Literal["mention_only", "all", "whitelist", "blacklist"] | None = None
whitelist_ids: list[str] | None = None
welcome_message: str | None = Field(None, max_length=500)
work_hours_start: str | None = Field(None, pattern=r"^\d{2}:\d{2}$")
work_hours_end: str | None = Field(None, pattern=r"^\d{2}:\d{2}$")
timezone_offset: int | None = Field(None, ge=-12, le=14)
off_hours_reply: str | None = Field(None, max_length=500)
def _get_adapter(channel_id: str):
adapter = get_channel_manager()._adapters.get(channel_id)
if adapter is None:
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found or not running")
return adapter
async def _check_channel_rate_limit(channel_id: str, user_id: str, action: str = "message") -> None:
result = await get_channel_manager().check_per_channel_rate_limit(channel_id, user_id, action)
if not result.allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求频率超限,请在 {result.retry_after_seconds} 秒后重试",
)
VALID_MESSAGE_STATUSES = frozenset({"processing", "success", "error", "timeout"})
VALID_CONTENT_TYPES = frozenset({"text", "image", "file", "voice", "video", "location", "event"})
MIN_SEARCH_LENGTH = 2
MAX_SEARCH_LENGTH = 100
SORTABLE_FIELDS = frozenset({"created_at", "response_time_ms", "id"})
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _build_message_conditions(
channel_id: str,
status_value: str | None = None,
content_type: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
search: str | None = None,
) -> list:
conditions = [ChannelMsgRecord.channel_id == channel_id]
if status_value:
if status_value not in VALID_MESSAGE_STATUSES:
raise HTTPException(
status_code=400,
detail=f"无效的 status 值: '{status_value}',可选: {', '.join(sorted(VALID_MESSAGE_STATUSES))}",
)
conditions.append(ChannelMsgRecord.status == status_value)
if content_type:
if content_type not in VALID_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"无效的 content_type 值: '{content_type}',可选: {', '.join(sorted(VALID_CONTENT_TYPES))}",
)
conditions.append(ChannelMsgRecord.content_type == content_type)
dt_from = None
dt_to = None
if date_from:
try:
dt_from = datetime.strptime(date_from, "%Y-%m-%d")
conditions.append(ChannelMsgRecord.created_at >= dt_from)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"日期格式无效: '{date_from}',正确格式: YYYY-MM-DD",
)
if date_to:
try:
dt_to = datetime.strptime(date_to, "%Y-%m-%d")
conditions.append(ChannelMsgRecord.created_at < dt_to)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"日期格式无效: '{date_to}',正确格式: YYYY-MM-DD",
)
if dt_from is not None and dt_to is not None:
if dt_from >= dt_to:
raise HTTPException(
status_code=400,
detail=f"date_from ({date_from}) 必须早于 date_to ({date_to})",
)
if search:
search = search.strip()
if len(search) < MIN_SEARCH_LENGTH:
raise HTTPException(
status_code=400,
detail=f"搜索关键词至少 {MIN_SEARCH_LENGTH} 个字符",
)
if len(search) > MAX_SEARCH_LENGTH:
raise HTTPException(
status_code=400,
detail=f"搜索关键词最多 {MAX_SEARCH_LENGTH} 个字符",
)
escaped = _escape_like(search)
conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{escaped}%", escape="\\"))
return conditions
_CSV_RISKY_PREFIXES = frozenset(("=", "+", "-", "@"))
_CSV_HEADER = [
"id",
"channel_type",
"message_id",
"chat_id",
"chat_type",
"content_type",
"sender_user_id",
"content_preview",
"reply_to_message_id",
"agent_config_id",
"status",
"error_message",
"response_time_ms",
"reply_message_id",
"reply_content_preview",
"created_at",
"replied_at",
"extra_metadata",
]
def _sanitize_csv_cell(value: str | None) -> str:
if not value:
return ""
if value[0] in _CSV_RISKY_PREFIXES:
return f"'{value}"
return value
class _UTF8SigEncoder:
def __init__(self, generator):
self._gen = generator
self._sent_bom = False
def __iter__(self):
return self
def __next__(self):
if not self._sent_bom:
self._sent_bom = True
return codecs.BOM_UTF8
return next(self._gen)
def _csv_line_generator(rows):
output = StringIO()
writer = csv.writer(output)
writer.writerow(_CSV_HEADER)
yield output.getvalue()
output.truncate(0)
output.seek(0)
for r in rows:
writer.writerow(
[
r.id,
r.channel_type or "",
r.message_id or "",
r.chat_id,
r.chat_type,
r.content_type,
_sanitize_csv_cell(r.sender_user_id),
_sanitize_csv_cell(r.content_preview),
r.reply_to_message_id or "",
r.agent_config_id or "",
r.status,
_sanitize_csv_cell(r.error_message),
r.response_time_ms or "",
r.reply_message_id or "",
_sanitize_csv_cell(r.reply_content_preview),
r.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.created_at else "",
r.replied_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.replied_at else "",
json.dumps(r.extra_metadata, ensure_ascii=False) if r.extra_metadata else "",
]
)
yield output.getvalue()
output.truncate(0)
output.seek(0)
def _get_nostr_adapter(channel_id: str):
adapter = _get_adapter(channel_id)
profile_api = getattr(adapter, "_profile_api", None)
if profile_api is None:
raise HTTPException(status_code=501, detail=f"渠道 '{channel_id}' 不支持 Profile 操作")
return adapter, profile_api
def get_state_store() -> PluginStateStore:
return PostgresPluginStateStore()
@channels.get("/nostr/profile")
async def get_nostr_profile(
channel_id: str = Query(..., description="Nostr 渠道 ID"),
account_id: str = Query("default"),
current_user: User = Depends(get_admin_user),
):
adapter, profile_api = _get_nostr_adapter(channel_id)
profile = await profile_api.get_profile(account_id)
return _make_response(data=profile)
@channels.post("/nostr/profile/publish")
async def publish_nostr_profile(
channel_id: str = Query(..., description="Nostr 渠道 ID"),
profile_data: dict = Body(..., description="Profile 数据"),
account_id: str = Body("default"),
current_user: User = Depends(get_admin_user),
):
adapter, profile_api = _get_nostr_adapter(channel_id)
result = await profile_api.publish_profile(profile_data, account_id)
return _make_response(data=result)
@channels.get("/nostr/profile/import")
async def import_nostr_profile(
pubkey: str = Query(..., description="目标 Nostr 公钥 (hex)"),
channel_id: str = Query(..., description="Nostr 渠道 ID"),
current_user: User = Depends(get_admin_user),
):
adapter, profile_api = _get_nostr_adapter(channel_id)
result = await profile_api.import_profile(pubkey)
return _make_response(data=result)
@channels.post("/nostr/profile/merge")
async def merge_nostr_profile(
channel_id: str = Query(..., description="Nostr 渠道 ID"),
body: dict = Body(...),
current_user: User = Depends(get_admin_user),
):
local_data = body.get("local", {})
imported_pubkey = body.get("imported_pubkey", "")
adapter, profile_api = _get_nostr_adapter(channel_id)
result = await profile_api.merge_profile(local_data, imported_pubkey)
return _make_response(data=result)
@channels.get("/status")
async def list_channels_status(current_user: User = Depends(get_admin_user)):
status_data = await get_channel_manager().get_channel_status()
meta = status_data.get("_meta", {})
code = 0
message = "ok"
if meta.get("failed", 0) > 0:
code = 2001
message = f"部分渠道状态获取失败 ({meta['failed']}/{meta['total_channels']})"
return _make_response(code=code, data=status_data, message=message)
@channels.get("/actions")
async def list_channels_for_action(
action: str | None = Query(None, description="要查询的 MessageAction如 pin/edit/send"),
current_user: User = Depends(get_admin_user),
):
from yuxi.channels.message_actions import ActionRegistry, MessageAction
if action:
try:
msg_action = MessageAction(action)
except ValueError:
return _make_response(
code=-1,
message=f"Unknown action: {action}. Valid actions: {[a.value for a in MessageAction]}",
)
channels = ActionRegistry.list_channels_for_action(msg_action)
return _make_response(data={"action": action, "channels": channels})
all_channels = ActionRegistry.list_all()
result = {}
for cid, caps in all_channels.items():
result[cid] = {
"supported_actions": caps.supported_actions(),
"chat_types": caps.chat_types,
}
return _make_response(data=result)
@channels.post("/register")
async def register_channel(
body: dict[str, Any],
current_user: User = Depends(get_admin_user),
):
channel_type = body.get("channel_type")
if not channel_type:
raise HTTPException(status_code=400, detail="channel_type is required")
config = body.get("config", {})
manager = get_channel_manager()
try:
await manager.register_channel(channel_type, config)
return _make_response(
data={"channel_id": channel_type, "registered": True},
message="渠道注册成功",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@channels.get("/{channel_id}/actions")
async def get_channel_actions(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
from yuxi.channels.message_actions import ActionRegistry
declarations = ActionRegistry.list_all_declarations(channel_id)
if declarations:
actions_data = [
{
"action": decl.action.value,
"status": decl.status.value,
"reason": decl.reason,
"impl": decl.impl,
}
for decl in declarations.values()
]
return _make_response(data={"channel_id": channel_id, "actions": actions_data})
caps = ActionRegistry.get(channel_id)
if caps is None:
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
supported = caps.supported_actions()
return _make_response(
data={
"channel_id": channel_id,
"actions": [{"action": a, "status": "supported", "reason": "", "impl": ""} for a in supported],
}
)
@channels.get("/{channel_id}/status")
async def get_channel_status(
channel_id: str = Path(
...,
min_length=1,
max_length=64,
pattern=r"^[a-zA-Z0-9_-]+$",
description="渠道标识符",
),
include_stats: bool = Query(False),
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_status", 30, 60)
status_data = await get_channel_manager().get_channel_status(channel_id, include_stats=include_stats)
if status_data.get("status") == "not_found":
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
return _make_response(data=status_data)
@channels.post("/{channel_id}/start")
async def start_channel(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_start", 3, 3)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
config = manager._channels_config.get(channel_id, {})
if not config.get("enabled", False):
raise HTTPException(
status_code=400,
detail=f"Channel '{channel_id}' is disabled in configuration",
)
if manager.is_running(channel_id):
return _make_response(code=409, message=f"Channel '{channel_id}' is already running")
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"
response_data = {"channel_id": channel_id, "status": current_status}
if adapter:
try:
snapshot = adapter.snapshot()
response_data.update(
{k: v for k, v in snapshot.items() if k in ("channel_type", "display_name", "capabilities", "health")}
)
except Exception:
logger.warning(f"Failed to get snapshot for {channel_id}")
return _make_response(data=response_data, message="Channel start initiated")
@channels.post("/{channel_id}/stop")
async def stop_channel(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_stop", 3, 3)
manager = get_channel_manager()
if not manager.is_running(channel_id):
return _make_response(code=409, message=f"Channel '{channel_id}' is not running")
await manager.stop_channel(channel_id)
return _make_response(
data={"channel_id": channel_id, "status": "disconnected"},
message="Channel stopped",
)
@channels.post("/{channel_id}/restart")
async def restart_channel(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_restart", 3, 3)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
config = manager._channels_config.get(channel_id, {})
if not config.get("enabled", False):
raise HTTPException(
status_code=400,
detail=f"Channel '{channel_id}' is disabled in configuration",
)
if not manager.is_running(channel_id):
raise HTTPException(status_code=409, detail=f"Channel '{channel_id}' is not running")
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"
return _make_response(
data={"channel_id": channel_id, "status": current_status},
message="Channel restart initiated",
)
@channels.put("/{channel_id}/config")
async def update_channel_config(
channel_id: str,
config_updates: dict[str, Any],
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_config", 5, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
try:
result = await manager.update_channel_config(channel_id, config_updates, user_id=str(current_user.id))
if result["adapter_status"] == "stopped":
message = "配置已保存。启动渠道后生效。"
elif result.get("needs_restart"):
message = "配置已更新。建议重启渠道以确保所有内部状态同步。"
else:
message = "配置已实时生效。"
return _make_response(data=result, message=message)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ChannelException as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update config for channel {channel_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update channel config: {e}")
@channels.post("/{channel_id}/test")
async def test_channel(
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.test_channel(channel_id)
return _make_response(data=result)
@channels.get("/{channel_id}/stats")
async def get_channel_stats(
channel_id: str,
days: int = Query(7, ge=1, le=90),
period: str | None = Query(None, description="时间范围: 1d / 7d / 30d"),
granularity: str = Query("day"),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from datetime import timedelta
from sqlalchemy import Integer
from yuxi.utils.datetime_utils import utc_now_naive
if period:
period_map = {"1d": 1, "7d": 7, "30d": 30}
days = period_map.get(period, 7)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
now = utc_now_naive()
since = now - timedelta(days=days)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
stats_base = select(
func.count().label("total"),
func.sum(
(ChannelMsgRecord.status == "success").cast(Integer),
).label("success_count"),
func.sum(
(ChannelMsgRecord.status == "error").cast(Integer),
).label("error_count"),
func.avg(ChannelMsgRecord.response_time_ms).label("avg_response_ms"),
func.percentile_cont(0.5).within_group(ChannelMsgRecord.response_time_ms).label("p50_ms"),
func.percentile_cont(0.95).within_group(ChannelMsgRecord.response_time_ms).label("p95_ms"),
func.percentile_cont(0.99).within_group(ChannelMsgRecord.response_time_ms).label("p99_ms"),
).where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= since,
)
result = await db.execute(stats_base)
row = result.one()
total = row.total or 0
success = row.success_count or 0
error = row.error_count or 0
today_result = await db.execute(
select(func.count()).where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= today_start,
)
)
today_count = today_result.scalar() or 0
active_users_result = await db.execute(
select(func.count(func.distinct(ChannelMsgRecord.sender_user_id))).where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= since,
)
)
active_users = active_users_result.scalar() or 0
summary = {
"today_count": today_count,
"total_count": total,
"active_users": active_users,
"success_count": int(success),
"error_count": int(error),
"success_rate": round(success / total, 3) if total > 0 else 0,
"avg_latency": round(row.avg_response_ms, 1) if row.avg_response_ms else 0,
"p50_latency": round(row.p50_ms, 1) if row.p50_ms else 0,
"p95_latency": round(row.p95_ms, 1) if row.p95_ms else 0,
"p99_latency": round(row.p99_ms, 1) if row.p99_ms else 0,
}
daily_trend: list[dict] = []
daily_success_rate: list[dict] = []
if granularity == "day":
daily_query = (
select(
func.date(ChannelMsgRecord.created_at).label("date"),
func.count().label("count"),
func.avg(ChannelMsgRecord.response_time_ms).label("avg_ms"),
)
.where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= since,
)
.group_by(func.date(ChannelMsgRecord.created_at))
.order_by(func.date(ChannelMsgRecord.created_at))
)
daily_result = await db.execute(daily_query)
daily_trend = [
{"date": str(r.date), "count": r.count, "avg_ms": round(r.avg_ms, 1) if r.avg_ms else 0}
for r in daily_result.all()
]
success_rate_query = (
select(
func.date(ChannelMsgRecord.created_at).label("date"),
func.count().label("total"),
func.sum(
(ChannelMsgRecord.status == "success").cast(Integer),
).label("success"),
)
.where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= since,
)
.group_by(func.date(ChannelMsgRecord.created_at))
.order_by(func.date(ChannelMsgRecord.created_at))
)
success_rate_result = await db.execute(success_rate_query)
daily_success_rate = [
{
"date": str(r.date),
"success_rate": round(r.success / r.total * 100, 1) if r.total > 0 else 0,
"total": r.total,
}
for r in success_rate_result.all()
]
type_dist_result = await db.execute(
select(ChannelMsgRecord.chat_type, func.count().label("cnt"))
.where(ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since)
.group_by(ChannelMsgRecord.chat_type)
)
chat_type_distribution = {r.chat_type: r.cnt for r in type_dist_result.all()}
msg_type_result = await db.execute(
select(ChannelMsgRecord.content_type, func.count().label("cnt"))
.where(ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since)
.group_by(ChannelMsgRecord.content_type)
)
message_type_distribution = {r.content_type: r.cnt for r in msg_type_result.all()}
return _make_response(
data={
"channel_id": channel_id,
"period": {
"from": since.strftime("%Y-%m-%dT%H:%M:%SZ"),
"to": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"summary": summary,
"daily_trend": daily_trend,
"daily_success_rate": daily_success_rate,
"chat_type_distribution": chat_type_distribution,
"message_type_distribution": message_type_distribution,
}
)
@channels.get("/{channel_id}/users/{user_id}")
async def get_channel_user_mapping(
channel_id: str,
user_id: str,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ChannelUserMapping).where(
ChannelUserMapping.channel_id == channel_id,
ChannelUserMapping.channel_user_id == user_id,
)
)
mapping = result.scalar_one_or_none()
if not mapping:
raise HTTPException(status_code=404, detail="User mapping not found")
thread_result = await db.execute(
select(ChannelThreadMapping)
.where(
ChannelThreadMapping.channel_id == channel_id,
ChannelThreadMapping.internal_user_id == mapping.internal_user_id,
)
.order_by(ChannelThreadMapping.last_active_at.desc())
.limit(10)
)
recent_chats = [
{
"chat_id": t.channel_chat_id,
"thread_id": t.thread_id,
"last_active": t.last_active_at.strftime("%Y-%m-%dT%H:%M:%SZ") if t.last_active_at else None,
}
for t in thread_result.all()
]
return _make_response(
data={
"channel_id": channel_id,
"channel_user_id": user_id,
"internal_user_id": mapping.internal_user_id,
"created_at": mapping.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.created_at else None,
"recent_chats": recent_chats,
}
)
@channels.get("/{channel_id}/messages")
async def get_channel_messages(
channel_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(None),
content_type: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
search: str | None = Query(None),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(
status_code=404,
detail=f"渠道 '{channel_id}' 未注册",
)
await _check_channel_rate_limit(
channel_id=channel_id,
user_id=str(current_user.id),
)
conditions = _build_message_conditions(
channel_id=channel_id,
status_value=status,
content_type=content_type,
date_from=date_from,
date_to=date_to,
search=search,
)
count_query = select(func.count()).select_from(ChannelMsgRecord).where(*conditions)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
sort_column = getattr(ChannelMsgRecord, sort_by, None)
if sort_column is None or sort_by not in SORTABLE_FIELDS:
raise HTTPException(
status_code=400,
detail=f"不支持的排序字段: '{sort_by}',可选: {', '.join(sorted(SORTABLE_FIELDS))}",
)
order_func = desc if sort_order == "desc" else asc
query = (
select(ChannelMsgRecord)
.where(*conditions)
.order_by(order_func(sort_column))
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(query)
rows = result.scalars().all()
messages = [
{
"id": r.id,
"message_id": r.message_id,
"channel_type": r.channel_type,
"created_at": r.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.created_at else None,
"sender_user_id": r.sender_user_id,
"content_preview": r.content_preview,
"content_type": r.content_type,
"status": r.status,
"response_time_ms": r.response_time_ms,
"reply_content_preview": r.reply_content_preview,
"reply_message_id": r.reply_message_id,
"replied_at": r.replied_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.replied_at else None,
"error_message": r.error_message,
"chat_id": r.chat_id,
"chat_type": r.chat_type,
}
for r in rows
]
return _make_response(data={"messages": messages, "total": total})
@channels.get("/{channel_id}/messages/export")
async def export_channel_messages(
channel_id: str,
status: str | None = Query(None),
content_type: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
search: str | None = Query(None),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
await _check_rate_limit(str(current_user.id), "channel_messages_export", 5, 60)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(
status_code=404,
detail=f"渠道 '{channel_id}' 未注册",
)
conditions = _build_message_conditions(
channel_id=channel_id,
status_value=status,
content_type=content_type,
date_from=date_from,
date_to=date_to,
search=search,
)
try:
count_query = select(func.count()).select_from(ChannelMsgRecord).where(*conditions)
total = (await db.execute(count_query)).scalar() or 0
query = select(ChannelMsgRecord).where(*conditions).order_by(ChannelMsgRecord.created_at.desc()).limit(10000)
result = await db.execute(query)
rows = result.scalars().all()
headers = {
"Content-Disposition": f"attachment; filename=messages_{channel_id}.csv",
"X-Total-Count": str(total),
}
if total > 10000:
headers["X-Truncated"] = "true"
return StreamingResponse(
_UTF8SigEncoder(_csv_line_generator(rows)),
media_type="text/csv; charset=utf-8",
headers=headers,
)
except Exception:
logger.exception(f"Failed to export messages for channel {channel_id}")
raise HTTPException(status_code=500, detail="导出消息失败,请稍后重试")
@channels.get("/{channel_id}/policy")
async def get_channel_policy(
channel_id: str,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
try:
result = await db.execute(select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id))
policy = result.scalar_one_or_none()
except Exception:
logger.error(f"Failed to query policy for channel {channel_id}", exc_info=True)
raise HTTPException(status_code=500, detail="查询策略配置失败")
if not policy:
return _make_response(data={"channel_id": channel_id, **_POLICY_DEFAULTS})
return _make_response(data=policy.to_dict())
@channels.put("/{channel_id}/policy")
async def update_channel_policy(
channel_id: str,
policy_updates: ChannelPolicyUpdate,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
await _check_rate_limit(str(current_user.id), "channel_policy_update", 5, 5)
updates_dict = policy_updates.model_dump(exclude_none=True)
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)
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="更新策略配置失败")
return _make_response(data=policy.to_dict(), message="策略配置已保存")
@channels.get("/{channel_id}/routing")
async def get_channel_routing(
channel_id: str,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")
from yuxi.storage.postgres.models_channels import ChannelRoutingRule
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]})
@channels.put("/{channel_id}/routing")
async def update_channel_routing(
channel_id: str,
routing_updates: list[dict[str, Any]],
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from yuxi.storage.postgres.models_channels import ChannelRoutingRule
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")
if not routing_updates:
raise HTTPException(status_code=422, detail="不允许清空全部路由规则")
from yuxi.repositories.agent_config_repository import AgentConfigRepository
agent_repo = AgentConfigRepository(db)
for item in routing_updates:
aid = str(item.get("agent_config_id", "0"))
try:
aid_int = int(aid)
except (ValueError, TypeError):
raise HTTPException(status_code=422, detail=f"Agent 配置 ID '{aid}' 无效")
config = await agent_repo.get_by_id(aid_int)
if config is None:
raise HTTPException(status_code=422, detail=f"Agent 配置 '{aid}' 不存在")
existing = (
(await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id)))
.scalars()
.all()
)
for rule in existing:
await db.delete(rule)
for item in routing_updates:
rule = ChannelRoutingRule(
channel_id=channel_id,
command=item.get("command", ""),
agent_config_id=str(item.get("agent_config_id", "0")),
)
db.add(rule)
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="保存路由规则失败")
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="路由规则已保存",
)
ALL_CONFIG_GROUPS = {
"credentials": {"key": "credentials", "label": "认证凭据"},
"general": {"key": "general", "label": "通用设置"},
"routing": {"key": "routing", "label": "路由配置"},
"advanced": {"key": "advanced", "label": "高级设置"},
}
@channels.get("/{channel_id}/config-schema")
async def get_channel_config_schema(
channel_id: str,
current_user: User = Depends(get_admin_user),
):
if not re.match(r"^[a-z0-9][a-z0-9_-]{0,30}$", channel_id):
raise HTTPException(status_code=400, detail=f"Invalid channel_id: '{channel_id}'")
await _check_rate_limit(str(current_user.id), "config_schema", 10, 5)
manager = get_channel_manager()
adapter_cls = manager._registry.get(channel_id)
if adapter_cls is None:
return _make_response(data={"channel_type": channel_id, "fields": [], "groups": []})
schema = adapter_cls.get_config_schema()
if not schema:
logger.info(f"渠道 {channel_id} 未定义 config_schema返回仅含 agent_config_id 的基本 Schema")
fields = []
used_groups: set[str] = set()
for key, schema_def in schema.items():
field_type = schema_def.get("type", "text")
group = schema_def.get("group", "general")
used_groups.add(group)
field_entry: dict[str, Any] = {
"key": key,
"label": schema_def.get("label") or key.replace("_", " ").title(),
"type": field_type,
"required": schema_def.get("required", False),
"group": group,
}
if "default" in schema_def:
field_entry["default"] = schema_def["default"]
if "enum" in schema_def:
field_entry["enum"] = schema_def["enum"]
if schema_def.get("secret"):
field_entry["secret"] = True
fields.append(field_entry)
fields.append(
{
"key": "agent_config_id",
"label": "默认 Agent",
"type": "agent_select",
"required": False,
"group": "routing",
}
)
used_groups.add("routing")
groups = [ALL_CONFIG_GROUPS[g] for g in sorted(used_groups) if g in ALL_CONFIG_GROUPS]
return _make_response(
data={
"channel_type": channel_id,
"fields": fields,
"groups": groups,
}
)
@channels.get("/{channel_id}/chats/{chat_id}")
async def get_channel_chat_mapping(
channel_id: str,
chat_id: str,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ChannelThreadMapping)
.where(
ChannelThreadMapping.channel_id == channel_id,
ChannelThreadMapping.channel_chat_id == chat_id,
)
.order_by(ChannelThreadMapping.last_active_at.desc())
.limit(1)
)
mapping = result.scalar_one_or_none()
if not mapping:
raise HTTPException(status_code=404, detail="Chat mapping not found")
return _make_response(
data={
"channel_id": channel_id,
"channel_chat_id": chat_id,
"thread_id": mapping.thread_id,
"agent_id": mapping.agent_id,
"created_at": mapping.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.created_at else None,
"last_active_at": mapping.last_active_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.last_active_at else None,
}
)
@channels.post("/state/cleanup")
async def cleanup_expired_state(
store: PluginStateStore = Depends(get_state_store),
current_user: User = Depends(get_admin_user),
):
count = await store.cleanup_expired()
return _make_response(data={"cleaned": count}, message=f"清理了 {count} 条过期状态")
@channels.get("/{channel_id}/state")
async def list_channel_state(
channel_id: str,
store: PluginStateStore = Depends(get_state_store),
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "state_read", 30, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
all_state = await store.get_all(channel_id)
safe_state = {}
for ns, entries in all_state.items():
if ns == "credentials":
safe_state[ns] = {k: {"redacted": True, "has_credential": True} for k in entries}
else:
safe_state[ns] = entries
return _make_response(data={"channel_id": channel_id, "state": safe_state})
@channels.get("/{channel_id}/state/{namespace}/{key}")
async def get_channel_state_entry(
channel_id: str,
namespace: str = Path(..., min_length=1, max_length=64),
key: str = Path(..., min_length=1, max_length=128),
store: PluginStateStore = Depends(get_state_store),
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "state_read", 30, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
value = await store.get(channel_id, key, namespace)
if value is None:
raise HTTPException(status_code=404, detail="State entry not found")
if namespace == "credentials":
return _make_response(
data={
"channel_id": channel_id,
"namespace": namespace,
"key": key,
"redacted": True,
"has_credential": True,
}
)
return _make_response(data={"channel_id": channel_id, "namespace": namespace, "key": key, "value": value})
@channels.put("/{channel_id}/state/{namespace}/{key}")
async def update_channel_state_entry(
body: UpdateStateEntryBody,
channel_id: str = Path(..., min_length=1, max_length=32),
namespace: str = Path(..., min_length=1, max_length=64),
key: str = Path(..., min_length=1, max_length=128),
store: PluginStateStore = Depends(get_state_store),
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "state_write", 10, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
logger.info(
"渠道状态写入: user=%s, channel=%s, namespace=%s, key=%s, has_ttl=%s",
current_user.id,
channel_id,
namespace,
key,
body.ttl_seconds is not None,
)
try:
is_new = await store.set(
channel_id,
key,
body.value,
namespace,
ttl_seconds=body.ttl_seconds,
)
except IntegrityError:
raise HTTPException(status_code=409, detail="状态条目写入冲突,请重试")
except DBAPIError as e:
logger.error(
"更新渠道状态失败 (channel=%s, ns=%s, key=%s): %s",
channel_id,
namespace,
key,
e,
exc_info=True,
)
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
except Exception as e:
logger.error("更新渠道状态失败 (channel=%s): %s", channel_id, e, exc_info=True)
raise HTTPException(status_code=500, detail="更新渠道状态失败")
status_code = 201 if is_new else 200
message = "状态已创建" if is_new else "状态已更新"
return JSONResponse(
content=_make_response(
data={"channel_id": channel_id, "namespace": namespace, "key": key},
message=message,
),
status_code=status_code,
)
@channels.delete("/{channel_id}/state/{namespace}/{key}")
async def delete_channel_state_entry(
channel_id: str,
namespace: str = Path(..., min_length=1, max_length=64),
key: str = Path(..., min_length=1, max_length=128),
store: PluginStateStore = Depends(get_state_store),
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "state_write", 10, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
await store.delete(channel_id, key, namespace)
return _make_response(
data={"channel_id": channel_id, "namespace": namespace, "key": key},
message="状态已删除",
)
@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,
current_user: User = Depends(get_admin_user),
):
await _check_rate_limit(str(current_user.id), "channel_unregister", 5, 10)
manager = get_channel_manager()
if not manager.is_registered(channel_id):
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
await manager.unregister_channel(channel_id)
return _make_response(data={"channel_id": channel_id, "unregistered": True}, message="渠道已注销")