WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+398
View File
@@ -0,0 +1,398 @@
# =============================================================================
# 企微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)