399 lines
13 KiB
Python
399 lines
13 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 数据看板 API
|
||
# =============================================================================
|
||
# 说明:数据统计接口,为管理后台数据看板提供数据支持
|
||
# 1. GET /api/admin/stats/overview — 获取整体统计概览
|
||
# 2. GET /api/admin/stats/conversations — 会话趋势统计
|
||
# 3. GET /api/admin/stats/agents — 坐席绩效统计
|
||
# 4. GET /api/admin/stats/satisfaction — 满意度统计
|
||
# =============================================================================
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from sqlalchemy import func, select, and_, or_
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database import get_db
|
||
from app.models.agent import Agent
|
||
from app.models.conversation import Conversation
|
||
from app.models.conversation_evaluation import ConversationEvaluation
|
||
from app.models.conversation_annotation import ConversationAnnotation
|
||
from app.models.message import Message
|
||
from app.utils.response import success_response
|
||
|
||
from app.api.agents import get_current_agent
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 创建路由器
|
||
router = APIRouter()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 辅助函数
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def get_date_range(
|
||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||
) -> tuple[datetime, datetime]:
|
||
"""解析日期范围参数。
|
||
|
||
Args:
|
||
start_date: 开始日期
|
||
end_date: 结束日期
|
||
|
||
Returns:
|
||
tuple: (开始时间, 结束时间)
|
||
"""
|
||
if end_date:
|
||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||
else:
|
||
end_dt = datetime.now() + timedelta(days=1)
|
||
|
||
if start_date:
|
||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||
else:
|
||
start_dt = end_dt - timedelta(days=30) # 默认30天
|
||
|
||
return start_dt, end_dt
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/admin/stats/overview — 整体统计概览
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/admin/stats/overview")
|
||
async def get_overview_stats(
|
||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: Agent = Depends(get_current_agent),
|
||
):
|
||
"""获取整体统计概览。
|
||
|
||
包含:总会话数、待处理会话数、已解决会话数、平均响应时间、满意度等。
|
||
|
||
Args:
|
||
start_date: 开始日期
|
||
end_date: 结束日期
|
||
db: 数据库会话
|
||
admin: 当前管理员
|
||
|
||
Returns:
|
||
Dict: 整体统计数据
|
||
"""
|
||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||
|
||
# 总会话数
|
||
stmt_total = select(func.count(Conversation.id)).where(
|
||
and_(
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_total)
|
||
total_conversations = result.scalar() or 0
|
||
|
||
# 待处理会话数(状态为 queued 或 serving)
|
||
stmt_pending = select(func.count(Conversation.id)).where(
|
||
and_(
|
||
Conversation.status.in_(["queued", "serving"]),
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_pending)
|
||
pending_conversations = result.scalar() or 0
|
||
|
||
# 已解决会话数(状态为 resolved)
|
||
stmt_resolved = select(func.count(Conversation.id)).where(
|
||
and_(
|
||
Conversation.status == "resolved",
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_resolved)
|
||
resolved_conversations = result.scalar() or 0
|
||
|
||
# 计算满意度(已评价会话的平均评分)
|
||
stmt_satisfaction = select(
|
||
func.avg(ConversationEvaluation.score),
|
||
func.count(ConversationEvaluation.id),
|
||
).join(
|
||
Conversation,
|
||
ConversationEvaluation.conversation_id == Conversation.id,
|
||
).where(
|
||
and_(
|
||
ConversationEvaluation.created_at >= start_dt,
|
||
ConversationEvaluation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_satisfaction)
|
||
satisfaction_row = result.first()
|
||
avg_satisfaction = float(satisfaction_row[0]) if satisfaction_row[0] else 0.0
|
||
evaluated_count = satisfaction_row[1] or 0
|
||
|
||
# 计算平均响应时间(第一条坐席消息与第一条消息的时间差)
|
||
# 简化计算:resolved会话的平均解决时长
|
||
stmt_duration = select(func.avg(
|
||
func.extract('epoch', Conversation.updated_at) - func.extract('epoch', Conversation.created_at)
|
||
)).where(
|
||
and_(
|
||
Conversation.status == "resolved",
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_duration)
|
||
avg_duration_seconds = result.scalar() or 0
|
||
avg_duration_minutes = avg_duration_seconds / 60 if avg_duration_seconds else 0
|
||
|
||
data = {
|
||
"total_conversations": total_conversations,
|
||
"pending_conversations": pending_conversations,
|
||
"resolved_conversations": resolved_conversations,
|
||
"resolution_rate": round(resolved_conversations / total_conversations * 100, 1) if total_conversations > 0 else 0,
|
||
"avg_satisfaction": round(avg_satisfaction, 2),
|
||
"evaluated_count": evaluated_count,
|
||
"avg_duration_minutes": round(avg_duration_minutes, 1),
|
||
}
|
||
|
||
return success_response(data=data)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/admin/stats/conversations — 会话趋势统计
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/admin/stats/conversations")
|
||
async def get_conversation_stats(
|
||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: Agent = Depends(get_current_agent),
|
||
):
|
||
"""获取会话趋势统计。
|
||
|
||
按天统计每日会话数、解决数。
|
||
|
||
Args:
|
||
start_date: 开始日期
|
||
end_date: 结束日期
|
||
db: 数据库会话
|
||
admin: 当前管理员
|
||
|
||
Returns:
|
||
Dict: 趋势数据列表
|
||
"""
|
||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||
|
||
# 按天统计会话数
|
||
stmt = select(
|
||
func.date(Conversation.created_at).label("date"),
|
||
func.count(Conversation.id).label("total"),
|
||
).where(
|
||
and_(
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
).group_by(
|
||
func.date(Conversation.created_at)
|
||
).order_by(
|
||
func.date(Conversation.created_at)
|
||
)
|
||
|
||
result = await db.execute(stmt)
|
||
rows = result.all()
|
||
|
||
# 转换为日期+统计的格式
|
||
trend_data = []
|
||
for row in rows:
|
||
date_val = row.date
|
||
if isinstance(date_val, datetime):
|
||
date_str = date_val.strftime("%Y-%m-%d")
|
||
else:
|
||
date_str = str(date_val)
|
||
|
||
trend_data.append({
|
||
"date": date_str,
|
||
"total": row.total,
|
||
})
|
||
|
||
return success_response(data={"items": trend_data})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/admin/stats/agents — 坐席绩效统计
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/admin/stats/agents")
|
||
async def get_agent_stats(
|
||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: Agent = Depends(get_current_agent),
|
||
):
|
||
"""获取坐席绩效统计。
|
||
|
||
统计各坐席的处理会话数、解决数、平均响应时间。
|
||
|
||
Args:
|
||
start_date: 开始日期
|
||
end_date: 结束日期
|
||
db: 数据库会话
|
||
admin: 当前管理员
|
||
|
||
Returns:
|
||
Dict: 坐席绩效列表
|
||
"""
|
||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||
|
||
# 统计各坐席的会话数
|
||
stmt = select(
|
||
Conversation.assigned_agent_id,
|
||
func.count(Conversation.id).label("total"),
|
||
func.sum(
|
||
func.case((Conversation.status == "resolved", 1), else_=0)
|
||
).label("resolved"),
|
||
).where(
|
||
and_(
|
||
Conversation.assigned_agent_id.isnot(None),
|
||
Conversation.created_at >= start_dt,
|
||
Conversation.created_at < end_dt,
|
||
)
|
||
).group_by(
|
||
Conversation.assigned_agent_id
|
||
)
|
||
|
||
result = await db.execute(stmt)
|
||
rows = result.all()
|
||
|
||
# 获取坐席信息
|
||
agent_ids = [row[0] for row in rows if row[0]]
|
||
agent_stmt = select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids))
|
||
agent_result = await db.execute(agent_stmt)
|
||
agent_map = {a.id: a.name for a in agent_result.scalars().all()}
|
||
|
||
# 转换为坐席绩效数据
|
||
agent_data = []
|
||
for row in rows:
|
||
if not row[0]:
|
||
continue
|
||
agent_id = row[0]
|
||
agent_data.append({
|
||
"agent_id": agent_id,
|
||
"agent_name": agent_map.get(agent_id, "未知"),
|
||
"total_conversations": row[1],
|
||
"resolved_conversations": row[2] or 0,
|
||
"resolution_rate": round((row[2] or 0) / row[1] * 100, 1) if row[1] > 0 else 0,
|
||
})
|
||
|
||
# 按处理数排序
|
||
agent_data.sort(key=lambda x: x["total_conversations"], reverse=True)
|
||
|
||
return success_response(data={"items": agent_data})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/admin/stats/satisfaction — 满意度统计
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/admin/stats/satisfaction")
|
||
async def get_satisfaction_stats(
|
||
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: Agent = Depends(get_current_agent),
|
||
):
|
||
"""获取满意度统计。
|
||
|
||
统计评分分布、各表情占比。
|
||
|
||
Args:
|
||
start_date: 开始日期
|
||
end_date: 结束日期
|
||
db: 数据库会话
|
||
admin: 当前管理员
|
||
|
||
Returns:
|
||
Dict: 满意度统计数据
|
||
"""
|
||
start_dt, end_dt = await get_date_range(start_date, end_date)
|
||
|
||
# 评分分布统计
|
||
stmt = select(
|
||
ConversationEvaluation.score,
|
||
func.count(ConversationEvaluation.id).label("count"),
|
||
).join(
|
||
Conversation,
|
||
ConversationEvaluation.conversation_id == Conversation.id,
|
||
).where(
|
||
and_(
|
||
ConversationEvaluation.created_at >= start_dt,
|
||
ConversationEvaluation.created_at < end_dt,
|
||
)
|
||
).group_by(
|
||
ConversationEvaluation.score
|
||
)
|
||
|
||
result = await db.execute(stmt)
|
||
rows = result.all()
|
||
|
||
# 评分分布
|
||
score_distribution = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||
for row in rows:
|
||
if row[0] in score_distribution:
|
||
score_distribution[row[0]] = row[1]
|
||
|
||
# 表情分布
|
||
stmt_emoji = select(
|
||
ConversationEvaluation.emoji,
|
||
func.count(ConversationEvaluation.id).label("count"),
|
||
).join(
|
||
Conversation,
|
||
ConversationEvaluation.conversation_id == Conversation.id,
|
||
).where(
|
||
and_(
|
||
ConversationEvaluation.created_at >= start_dt,
|
||
ConversationEvaluation.created_at < end_dt,
|
||
ConversationEvaluation.emoji.isnot(None),
|
||
)
|
||
).group_by(
|
||
ConversationEvaluation.emoji
|
||
)
|
||
|
||
result = await db.execute(stmt_emoji)
|
||
emoji_rows = result.all()
|
||
|
||
emoji_distribution = {}
|
||
for row in emoji_rows:
|
||
if row[0]:
|
||
emoji_distribution[row[0]] = row[1]
|
||
|
||
# 计算平均分
|
||
stmt_avg = select(func.avg(ConversationEvaluation.score)).join(
|
||
Conversation,
|
||
ConversationEvaluation.conversation_id == Conversation.id,
|
||
).where(
|
||
and_(
|
||
ConversationEvaluation.created_at >= start_dt,
|
||
ConversationEvaluation.created_at < end_dt,
|
||
)
|
||
)
|
||
result = await db.execute(stmt_avg)
|
||
avg_score = result.scalar() or 0
|
||
|
||
data = {
|
||
"avg_score": round(float(avg_score), 2),
|
||
"total_evaluated": sum(score_distribution.values()),
|
||
"score_distribution": [
|
||
{"score": k, "count": v} for k, v in sorted(score_distribution.items())
|
||
],
|
||
"emoji_distribution": [
|
||
{"emoji": k, "count": v} for k, v in emoji_distribution.items()
|
||
],
|
||
}
|
||
|
||
return success_response(data=data)
|