756 lines
26 KiB
Python
756 lines
26 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
|
|||
|
|
from yuxi.channels.manager import channel_manager
|
|||
|
|
from yuxi.channels.registry import BUILTIN_ADAPTERS
|
|||
|
|
from yuxi.storage.postgres.models_business import User
|
|||
|
|
from yuxi.storage.postgres.models_channels import 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}
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _check_rate_limit(user_id: str, key: str, max_req: int, window: int) -> None:
|
|||
|
|
rate_key = f"{key}:{user_id}"
|
|||
|
|
allowed = await channel_manager.check_rate_limit(rate_key, max_req, window)
|
|||
|
|
if not allowed:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|||
|
|
detail="请求频率超限,请稍后再试",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@channels.get("/status")
|
|||
|
|
async def list_channels_status(current_user: User = Depends(get_admin_user)):
|
|||
|
|
status_data = await channel_manager.get_channel_status()
|
|||
|
|
return _make_response(data=status_data)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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.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,
|
|||
|
|
current_user: User = Depends(get_admin_user),
|
|||
|
|
):
|
|||
|
|
status_data = await channel_manager.get_channel_status(channel_id)
|
|||
|
|
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", 1, 1)
|
|||
|
|
|
|||
|
|
registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys())
|
|||
|
|
if channel_id not in registered_ids:
|
|||
|
|
return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered")
|
|||
|
|
|
|||
|
|
if channel_id in channel_manager._adapters:
|
|||
|
|
return _make_response(code=409, message=f"Channel '{channel_id}' is already running")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
await channel_manager.start_channel(channel_id)
|
|||
|
|
return _make_response(
|
|||
|
|
data={"channel_id": channel_id, "status": "connecting"},
|
|||
|
|
message="Channel start initiated",
|
|||
|
|
)
|
|||
|
|
except ValueError as e:
|
|||
|
|
return _make_response(code=-1, message=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to start channel {channel_id}: {e}")
|
|||
|
|
return _make_response(code=-1, message=f"Failed to start channel: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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", 1, 1)
|
|||
|
|
|
|||
|
|
if channel_id not in channel_manager._adapters:
|
|||
|
|
return _make_response(code=409, message=f"Channel '{channel_id}' is not running")
|
|||
|
|
|
|||
|
|
await channel_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", 1, 1)
|
|||
|
|
|
|||
|
|
registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys())
|
|||
|
|
if channel_id not in registered_ids:
|
|||
|
|
return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
await channel_manager.restart_channel(channel_id)
|
|||
|
|
return _make_response(
|
|||
|
|
data={"channel_id": channel_id, "status": "connecting"},
|
|||
|
|
message="Channel restart initiated",
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to restart channel {channel_id}: {e}")
|
|||
|
|
return _make_response(code=-1, message=f"Failed to restart channel: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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),
|
|||
|
|
):
|
|||
|
|
registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys())
|
|||
|
|
if channel_id not in registered_ids:
|
|||
|
|
return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = await channel_manager.update_channel_config(channel_id, config_updates)
|
|||
|
|
return _make_response(
|
|||
|
|
data=result,
|
|||
|
|
message="Configuration updated. Restart channel to apply changes.",
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
return _make_response(code=-1, message=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@channels.post("/{channel_id}/test")
|
|||
|
|
async def test_channel(
|
|||
|
|
channel_id: str,
|
|||
|
|
current_user: User = Depends(get_admin_user),
|
|||
|
|
):
|
|||
|
|
registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys())
|
|||
|
|
if channel_id not in registered_ids:
|
|||
|
|
return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered")
|
|||
|
|
|
|||
|
|
result = await channel_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 func, select
|
|||
|
|
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelMsgRecord
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys())
|
|||
|
|
if channel_id not in registered_ids:
|
|||
|
|
return _make_response(code=-1, message=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(
|
|||
|
|
func.cast(
|
|||
|
|
(ChannelMsgRecord.status == "success").cast(func.Integer),
|
|||
|
|
func.Integer,
|
|||
|
|
)
|
|||
|
|
).label("success_count"),
|
|||
|
|
func.sum(
|
|||
|
|
func.cast(
|
|||
|
|
(ChannelMsgRecord.status == "error").cast(func.Integer),
|
|||
|
|
func.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(
|
|||
|
|
func.cast(
|
|||
|
|
(ChannelMsgRecord.status == "success").cast(func.Integer),
|
|||
|
|
func.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),
|
|||
|
|
current_user: User = Depends(get_admin_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from sqlalchemy import func, select, desc
|
|||
|
|
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelMsgRecord
|
|||
|
|
|
|||
|
|
conditions = [ChannelMsgRecord.channel_id == channel_id]
|
|||
|
|
|
|||
|
|
if status:
|
|||
|
|
conditions.append(ChannelMsgRecord.status == status)
|
|||
|
|
if content_type:
|
|||
|
|
conditions.append(ChannelMsgRecord.content_type == content_type)
|
|||
|
|
if date_from:
|
|||
|
|
try:
|
|||
|
|
dt_from = datetime.strptime(date_from, "%Y-%m-%d")
|
|||
|
|
conditions.append(ChannelMsgRecord.created_at >= dt_from)
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
if date_to:
|
|||
|
|
try:
|
|||
|
|
dt_to = datetime.strptime(date_to, "%Y-%m-%d")
|
|||
|
|
conditions.append(ChannelMsgRecord.created_at < dt_to)
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
if search:
|
|||
|
|
conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{search}%"))
|
|||
|
|
|
|||
|
|
count_query = select(func.count()).where(*conditions)
|
|||
|
|
total_result = await db.execute(count_query)
|
|||
|
|
total = total_result.scalar() or 0
|
|||
|
|
|
|||
|
|
query = (
|
|||
|
|
select(ChannelMsgRecord)
|
|||
|
|
.where(*conditions)
|
|||
|
|
.order_by(desc(ChannelMsgRecord.created_at))
|
|||
|
|
.offset((page - 1) * page_size)
|
|||
|
|
.limit(page_size)
|
|||
|
|
)
|
|||
|
|
result = await db.execute(query)
|
|||
|
|
rows = result.scalars().all()
|
|||
|
|
messages = [
|
|||
|
|
{
|
|||
|
|
"id": r.id,
|
|||
|
|
"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,
|
|||
|
|
"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),
|
|||
|
|
):
|
|||
|
|
import csv
|
|||
|
|
from datetime import datetime
|
|||
|
|
from io import StringIO
|
|||
|
|
|
|||
|
|
from fastapi.responses import StreamingResponse
|
|||
|
|
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelMsgRecord
|
|||
|
|
|
|||
|
|
conditions = [ChannelMsgRecord.channel_id == channel_id]
|
|||
|
|
if status:
|
|||
|
|
conditions.append(ChannelMsgRecord.status == status)
|
|||
|
|
if content_type:
|
|||
|
|
conditions.append(ChannelMsgRecord.content_type == content_type)
|
|||
|
|
if date_from:
|
|||
|
|
try:
|
|||
|
|
dt_from = datetime.strptime(date_from, "%Y-%m-%d")
|
|||
|
|
conditions.append(ChannelMsgRecord.created_at >= dt_from)
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
if date_to:
|
|||
|
|
try:
|
|||
|
|
dt_to = datetime.strptime(date_to, "%Y-%m-%d")
|
|||
|
|
conditions.append(ChannelMsgRecord.created_at < dt_to)
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
if search:
|
|||
|
|
conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{search}%"))
|
|||
|
|
|
|||
|
|
query = select(ChannelMsgRecord).where(*conditions).order_by(ChannelMsgRecord.created_at.desc()).limit(10000)
|
|||
|
|
result = await db.execute(query)
|
|||
|
|
rows = result.scalars().all()
|
|||
|
|
|
|||
|
|
output = StringIO()
|
|||
|
|
writer = csv.writer(output)
|
|||
|
|
writer.writerow([
|
|||
|
|
"id", "created_at", "sender_user_id", "content_preview", "content_type",
|
|||
|
|
"status", "response_time_ms", "reply_content_preview", "error_message",
|
|||
|
|
"chat_id", "chat_type"
|
|||
|
|
])
|
|||
|
|
for r in rows:
|
|||
|
|
writer.writerow([
|
|||
|
|
r.id,
|
|||
|
|
r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else "",
|
|||
|
|
r.sender_user_id,
|
|||
|
|
r.content_preview,
|
|||
|
|
r.content_type,
|
|||
|
|
r.status,
|
|||
|
|
r.response_time_ms or "",
|
|||
|
|
r.reply_content_preview or "",
|
|||
|
|
r.error_message or "",
|
|||
|
|
r.chat_id,
|
|||
|
|
r.chat_type,
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
output.seek(0)
|
|||
|
|
return StreamingResponse(
|
|||
|
|
iter([output.getvalue()]),
|
|||
|
|
media_type="text/csv; charset=utf-8-sig",
|
|||
|
|
headers={"Content-Disposition": f"attachment; filename=messages_{channel_id}.csv"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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),
|
|||
|
|
):
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelPolicyConfig
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id)
|
|||
|
|
)
|
|||
|
|
policy = result.scalar_one_or_none()
|
|||
|
|
if not policy:
|
|||
|
|
return _make_response(
|
|||
|
|
data={
|
|||
|
|
"channel_id": channel_id,
|
|||
|
|
"group_chat_mode": "all",
|
|||
|
|
"whitelist_ids": [],
|
|||
|
|
"welcome_message": "",
|
|||
|
|
"work_hours_start": "09:00",
|
|||
|
|
"work_hours_end": "18:00",
|
|||
|
|
"timezone_offset": 8,
|
|||
|
|
"off_hours_reply": "",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return _make_response(data=policy.to_dict())
|
|||
|
|
|
|||
|
|
|
|||
|
|
@channels.put("/{channel_id}/policy")
|
|||
|
|
async def update_channel_policy(
|
|||
|
|
channel_id: str,
|
|||
|
|
policy_updates: dict[str, Any],
|
|||
|
|
current_user: User = Depends(get_admin_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelPolicyConfig
|
|||
|
|
from yuxi.utils.datetime_utils import utc_now_naive
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id)
|
|||
|
|
)
|
|||
|
|
policy = result.scalar_one_or_none()
|
|||
|
|
|
|||
|
|
if policy:
|
|||
|
|
for key in [
|
|||
|
|
"group_chat_mode", "whitelist_ids", "welcome_message",
|
|||
|
|
"work_hours_start", "work_hours_end", "timezone_offset", "off_hours_reply",
|
|||
|
|
]:
|
|||
|
|
if key in policy_updates:
|
|||
|
|
setattr(policy, key, policy_updates[key])
|
|||
|
|
policy.updated_at = utc_now_naive()
|
|||
|
|
else:
|
|||
|
|
policy = ChannelPolicyConfig(
|
|||
|
|
channel_id=channel_id,
|
|||
|
|
**{k: v for k, v in policy_updates.items() if k in [
|
|||
|
|
"group_chat_mode", "whitelist_ids", "welcome_message",
|
|||
|
|
"work_hours_start", "work_hours_end", "timezone_offset", "off_hours_reply",
|
|||
|
|
]}
|
|||
|
|
)
|
|||
|
|
db.add(policy)
|
|||
|
|
|
|||
|
|
await db.commit()
|
|||
|
|
await db.refresh(policy)
|
|||
|
|
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),
|
|||
|
|
):
|
|||
|
|
from yuxi.storage.postgres.models_channels import ChannelRoutingRule
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(ChannelRoutingRule)
|
|||
|
|
.where(ChannelRoutingRule.channel_id == channel_id)
|
|||
|
|
.order_by(ChannelRoutingRule.priority.desc())
|
|||
|
|
)
|
|||
|
|
rules = result.scalars().all()
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
await db.execute(
|
|||
|
|
select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id)
|
|||
|
|
)
|
|||
|
|
existing = (await db.execute(
|
|||
|
|
select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id)
|
|||
|
|
)).scalars().all()
|
|||
|
|
for rule in existing:
|
|||
|
|
await db.delete(rule)
|
|||
|
|
|
|||
|
|
for i, item in enumerate(routing_updates):
|
|||
|
|
rule = ChannelRoutingRule(
|
|||
|
|
channel_id=channel_id,
|
|||
|
|
command=item.get("command", ""),
|
|||
|
|
agent_config_id=item.get("agent_config_id", 0),
|
|||
|
|
priority=item.get("priority", len(routing_updates) - i),
|
|||
|
|
)
|
|||
|
|
db.add(rule)
|
|||
|
|
|
|||
|
|
await db.commit()
|
|||
|
|
return _make_response(data={"channel_id": channel_id, "routes": routing_updates}, message="路由规则已保存")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@channels.get("/{channel_id}/config-schema")
|
|||
|
|
async def get_channel_config_schema(
|
|||
|
|
channel_id: str,
|
|||
|
|
current_user: User = Depends(get_admin_user),
|
|||
|
|
):
|
|||
|
|
from yuxi.channels.registry import BUILTIN_ADAPTERS
|
|||
|
|
|
|||
|
|
all_adapters = {**BUILTIN_ADAPTERS}
|
|||
|
|
for cid, adapter_cls in all_adapters.items():
|
|||
|
|
if cid == channel_id:
|
|||
|
|
try:
|
|||
|
|
default_config = adapter_cls.default_config() if hasattr(adapter_cls, "default_config") else {}
|
|||
|
|
except Exception:
|
|||
|
|
default_config = {}
|
|||
|
|
|
|||
|
|
fields = []
|
|||
|
|
for key, value in default_config.items():
|
|||
|
|
field_type = "text"
|
|||
|
|
if "token" in key or "secret" in key or "key" in key:
|
|||
|
|
field_type = "password"
|
|||
|
|
elif isinstance(value, bool):
|
|||
|
|
field_type = "switch"
|
|||
|
|
elif isinstance(value, (int, float)):
|
|||
|
|
field_type = "number"
|
|||
|
|
is_credential = any(k in key for k in ("token", "secret", "key", "app_id"))
|
|||
|
|
fields.append({
|
|||
|
|
"key": key,
|
|||
|
|
"label": key.replace("_", " ").title(),
|
|||
|
|
"type": field_type,
|
|||
|
|
"default": value,
|
|||
|
|
"required": "token" in key or "secret" in key,
|
|||
|
|
"group": "credentials" if is_credential else "general",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
fields.append({
|
|||
|
|
"key": "agent_config_id",
|
|||
|
|
"label": "默认 Agent",
|
|||
|
|
"type": "agent_select",
|
|||
|
|
"required": False,
|
|||
|
|
"group": "routing",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return _make_response(data={
|
|||
|
|
"channel_type": channel_id,
|
|||
|
|
"fields": fields,
|
|||
|
|
"groups": [
|
|||
|
|
{"key": "credentials", "label": "认证凭据"},
|
|||
|
|
{"key": "general", "label": "通用设置"},
|
|||
|
|
{"key": "routing", "label": "路由配置"},
|
|||
|
|
],
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return _make_response(data={"channel_type": channel_id, "fields": [], "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,
|
|||
|
|
}
|
|||
|
|
)
|