Files
wecom_it_smart_desk/backend/app/api/evaluations.py

350 lines
12 KiB
Python

# =============================================================================
# 企微IT智能服务台 — 满意度评价 API
# =============================================================================
# 说明:满意度评价相关接口
# 1. POST /api/conversation/{conversation_id}/evaluate - 提交评价
# 2. GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
# 3. GET /api/evaluations/stats - 获取评价统计(管理后台)
# 4. POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请(坐席端触发)
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis
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.schemas.evaluation import (
EvaluationInviteRequest,
EvaluationStatsItem,
EvaluationStatsResponse,
EvaluationSubmitRequest,
EvaluationResponse,
)
from app.services.wecom_service import WecomService
from app.utils.response import AppException, success_response
# H5认证依赖(从 h5.py 导入)
from app.api.h5 import _get_current_employee
from app.models.employee import Employee
# 坐席认证依赖(从 agents.py 导入)
from app.api.agents import get_current_agent
# RBAC 权限装饰器
from app.dependencies import UserInfo, get_current_user, get_redis, require_permission
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 表情标签映射
# --------------------------------------------------------------------------
EMOJI_LABELS = {
"satisfied": "满意",
"neutral": "一般",
"dissatisfied": "不满意",
}
# --------------------------------------------------------------------------
# POST /api/conversation/{conversation_id}/evaluate - 提交评价
# --------------------------------------------------------------------------
@router.post("/conversation/{conversation_id}/evaluate")
async def submit_evaluation(
conversation_id: str,
body: EvaluationSubmitRequest,
db: AsyncSession = Depends(get_db),
employee_id: str = Depends(_get_current_employee),
):
"""提交满意度评价。
员工对已结束的会话进行满意度评价。
评价要素:星级(1-5)、表情(satisfied/neutral/dissatisfied)、文字反馈(可选)。
Args:
conversation_id: 会话ID
body: 评价请求体
db: 数据库会话
employee_id: 当前员工ID(认证依赖注入)
Returns:
Dict: 统一响应格式,包含评价记录
"""
# 1. 获取员工姓名
emp_stmt = select(Employee).where(Employee.employee_id == employee_id)
emp_result = await db.execute(emp_stmt)
employee = emp_result.scalars().first()
employee_name = employee.name if employee else ""
# 2. 验证会话存在且已结单
stmt = select(Conversation).where(Conversation.id == conversation_id)
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(3003, "会话不存在")
if conversation.status != "resolved":
raise AppException(3040, "只能评价已结单的会话")
# 3. 检查是否已评价(防止重复评价)
existing_stmt = select(ConversationEvaluation).where(
ConversationEvaluation.conversation_id == conversation_id,
ConversationEvaluation.employee_id == employee_id,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(3041, "您已对该会话提交过评价")
# 4. 创建评价记录
evaluation = ConversationEvaluation(
id=None, # UUID自动生成
conversation_id=conversation_id,
employee_id=employee_id,
employee_name=employee_name,
star_rating=body.star_rating,
emoji=body.emoji,
feedback_text=body.feedback_text,
)
db.add(evaluation)
await db.commit()
await db.refresh(evaluation)
logger.info(
f"员工 {employee_name} 提交评价: "
f"会话={conversation_id}, 星级={body.star_rating}, 表情={body.emoji}"
)
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# GET /api/conversation/{conversation_id}/evaluation - 获取会话评价
# --------------------------------------------------------------------------
@router.get("/conversation/{conversation_id}/evaluation")
async def get_evaluation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取会话的评价记录。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含评价记录(如果已评价)
"""
stmt = select(ConversationEvaluation).where(
ConversationEvaluation.conversation_id == conversation_id
)
result = await db.execute(stmt)
evaluation = result.scalars().first()
if not evaluation:
return success_response(data=None)
response_data = EvaluationResponse.model_validate(evaluation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# GET /api/evaluations/stats - 获取评价统计
# --------------------------------------------------------------------------
@router.get("/evaluations/stats")
@require_permission("evaluation", "read", "all")
async def get_evaluation_stats(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: UserInfo = Depends(get_current_user),
):
"""获取满意度评价统计数据。
供管理后台查看评价统计信息,包括:
- 总评价数
- 平均星级
- 星级分布
- 表情分布
- 最近评价记录
Args:
page: 页码
page_size: 每页数量
db: 数据库会话
Returns:
Dict: 统一响应格式,包含统计数据
"""
# 1. 获取总评价数
total_stmt = select(func.count(ConversationEvaluation.id))
total_result = await db.execute(total_stmt)
total_count = total_result.scalar() or 0
# 2. 获取平均星级
avg_stmt = select(func.avg(ConversationEvaluation.star_rating))
avg_result = await db.execute(avg_stmt)
avg_star_rating = float(avg_result.scalar() or 0)
# 3. 星级分布统计
star_dist_stmt = select(
ConversationEvaluation.star_rating,
func.count(ConversationEvaluation.id).label("count"),
).group_by(ConversationEvaluation.star_rating)
star_dist_result = await db.execute(star_dist_stmt)
star_rows = star_dist_result.all()
star_distribution = []
for star in range(1, 6):
count = next((row.count for row in star_rows if row.star_rating == star), 0)
percentage = (count / total_count * 100) if total_count > 0 else 0
star_distribution.append(
EvaluationStatsItem(
label=f"{star}",
count=count,
percentage=round(percentage, 1),
)
)
# 4. 表情分布统计
emoji_dist_stmt = select(
ConversationEvaluation.emoji,
func.count(ConversationEvaluation.id).label("count"),
).group_by(ConversationEvaluation.emoji)
emoji_dist_result = await db.execute(emoji_dist_stmt)
emoji_rows = emoji_dist_result.all()
emoji_distribution = []
for emoji_key in ["satisfied", "neutral", "dissatisfied"]:
count = next((row.count for row in emoji_rows if row.emoji == emoji_key), 0)
percentage = (count / total_count * 100) if total_count > 0 else 0
emoji_distribution.append(
EvaluationStatsItem(
label=EMOJI_LABELS.get(emoji_key, emoji_key),
count=count,
percentage=round(percentage, 1),
)
)
# 5. 最近评价记录
recent_stmt = (
select(ConversationEvaluation)
.order_by(ConversationEvaluation.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
recent_result = await db.execute(recent_stmt)
recent_evaluations = recent_result.scalars().all()
recent_list = [
EvaluationResponse.model_validate(e).model_dump()
for e in recent_evaluations
]
response_data = EvaluationStatsResponse(
total_count=total_count,
avg_star_rating=round(avg_star_rating, 2),
star_distribution=star_distribution,
emoji_distribution=emoji_distribution,
recent_evaluations=recent_list,
).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/send-evaluation-invite - 发送评价邀请
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/send-evaluation-invite")
@require_permission("conversation", "update", "own")
async def send_evaluation_invite(
conversation_id: str,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
current_agent: Agent = Depends(get_current_agent),
):
"""发送评价邀请(坐席结单后触发)。
坐席点击"结单"后,系统自动向员工推送评价邀请消息。
员工点击消息中的链接可进入H5页面提交评价。
Args:
conversation_id: 会话ID
db: 数据库会话
redis: Redis连接
current_agent: 当前坐席
Returns:
Dict: 统一响应格式
"""
# 1. 验证会话存在
stmt = select(Conversation).where(Conversation.id == conversation_id)
result = await db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(3003, "会话不存在")
# 2. 验证会话已结单
if conversation.status != "resolved":
raise AppException(3042, "只能对已结单的会话发送评价邀请")
# 3. 检查是否已评价
eval_stmt = select(ConversationEvaluation).where(
ConversationEvaluation.conversation_id == conversation_id
)
eval_result = await db.execute(eval_stmt)
existing_eval = eval_result.scalars().first()
if existing_eval:
raise AppException(3043, "该会话已收到评价,无需再次邀请")
# 4. 通过企微发送评价邀请消息
try:
wecom_service = WecomService(redis)
# 构建评价邀请消息内容
agent_name = current_agent.name if current_agent else "IT服务台"
content = (
f"您好!您与 {agent_name} 的会话已结束。\n\n"
f"请对本次服务进行评价,帮助我们改进服务质量。\n\n"
f"点击下方链接进行评价 >>"
)
# TODO: 后续接入企微应用消息推送
# message_data = {
# "touser": conversation.employee_id,
# "msgtype": "text",
# "agentid": settings.WECOM_AGENT_ID,
# "text": {"content": content},
# }
# await wecom_service.send_message(message_data)
logger.info(
f"发送评价邀请: 会话={conversation_id}, "
f"员工={conversation.employee_id}, 坐席={agent_name}"
)
# 关闭企微服务连接
await wecom_service.close()
except Exception as e:
logger.warning(f"发送评价邀请失败: {e}")
# 失败不影响结单流程,只记录日志
return success_response(data={"message": "评价邀请已发送"})