1. 简化message_actions.py中获取适配器的逻辑 2. 新增适配器合法性校验工具方法 3. 新增会话映射过期清理功能 4. 重构渠道状态机与基础适配器实现 5. 统一渠道操作异常处理逻辑 6. 新增凭证状态查询与刷新接口 7. 优化健康检查与自动重连逻辑 8. 新增统计数据缓存与批量查询优化 9. 修复部分数据库操作的异常处理逻辑
1388 lines
48 KiB
Python
1388 lines
48 KiB
Python
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="渠道已注销")
|