106 lines
4.6 KiB
Python
106 lines
4.6 KiB
Python
"""管理员查看客服转人工队列的只读服务。
|
|||
|
|
|
||
|
|
本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或
|
||
|
|
联系方式,也不提供接单、分配、解决或关闭工单的能力。
|
||
|
|
"""
|
||
|
|
|
||
|
|
from datetime import date, datetime
|
||
|
|
from decimal import Decimal
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from app.core.contracts import RequestContext
|
||
|
|
from app.core.conversation_privacy import sanitize_customer_service_message
|
||
|
|
from app.core.cursor import parse_cursor
|
||
|
|
from app.core.errors import GenericResourceNotFoundError
|
||
|
|
from app.infrastructure.db import SessionFactory
|
||
|
|
from app.model.platform import HandoverTicket
|
||
|
|
from app.service.authorization_service import AuthorizationService
|
||
|
|
|
||
|
|
|
||
|
|
class CustomerServiceHandoverAdminService:
|
||
|
|
"""面向管理员的待处理客服转人工工单只读边界。"""
|
||
|
|
|
||
|
|
permission = "handover:read"
|
||
|
|
|
||
|
|
async def list_tickets(
|
||
|
|
self, context: RequestContext, *, limit: int = 20, cursor: str | None = None
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""按工单 ID 倒序返回一页已脱敏的转人工队列。"""
|
||
|
|
await AuthorizationService.require(context, self.permission, admin=True)
|
||
|
|
before = parse_cursor(cursor)
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
statement = select(HandoverTicket).order_by(HandoverTicket.id.desc()).limit(limit)
|
||
|
|
if before is not None:
|
||
|
|
statement = statement.where(HandoverTicket.id < before)
|
||
|
|
tickets = list(await session.scalars(statement))
|
||
|
|
return {
|
||
|
|
"data": [self._list_item(ticket) for ticket in tickets],
|
||
|
|
"meta": {"trace_id": context.trace_id},
|
||
|
|
}
|
||
|
|
|
||
|
|
async def get_ticket(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||
|
|
"""返回一个工单的脱敏摘要,不回读或拼接原始会话。"""
|
||
|
|
await AuthorizationService.require(context, self.permission, admin=True)
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
ticket = await session.scalar(
|
||
|
|
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
|
||
|
|
)
|
||
|
|
if ticket is None:
|
||
|
|
raise GenericResourceNotFoundError("转人工工单不存在")
|
||
|
|
return {"data": self._detail_item(ticket), "meta": {"trace_id": context.trace_id}}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def _list_item(cls, ticket: HandoverTicket) -> dict[str, Any]:
|
||
|
|
"""列表只提供队列识别、路由与状态字段,避免正文在列表页批量扩散。"""
|
||
|
|
return {
|
||
|
|
"ticket_id": str(ticket.id),
|
||
|
|
"ticket_no": ticket.ticket_no,
|
||
|
|
"session_id": ticket.session_id,
|
||
|
|
"source_agent": ticket.source_agent,
|
||
|
|
"priority": ticket.priority,
|
||
|
|
"reason_code": ticket.reason_code,
|
||
|
|
"status": ticket.status,
|
||
|
|
"created_at": cls._public_value(ticket.created_at),
|
||
|
|
"updated_at": cls._public_value(ticket.updated_at),
|
||
|
|
}
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def _detail_item(cls, ticket: HandoverTicket) -> dict[str, Any]:
|
||
|
|
"""详情只追加已脱敏摘要与受控来源,仍不返回客户标识或原始消息。"""
|
||
|
|
return {
|
||
|
|
**cls._list_item(ticket),
|
||
|
|
"intent": ticket.intent,
|
||
|
|
"confidence": cls._public_value(ticket.confidence),
|
||
|
|
"reason_detail": cls._safe_text(ticket.reason_detail),
|
||
|
|
"conversation_summary": cls._safe_text(ticket.conversation_summary),
|
||
|
|
"source_references": cls._safe_source_references(ticket.source_references),
|
||
|
|
}
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _safe_text(value: str | None) -> str | None:
|
||
|
|
"""兼容历史工单:读取时再次隐藏旧记录中可能存在的敏感凭据。"""
|
||
|
|
return sanitize_customer_service_message(value) if value is not None else None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _safe_source_references(value: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||
|
|
"""来源只透出检索引用协议字段,拒绝未来扩展字段意外进入管理面。"""
|
||
|
|
allowed = {"source_type", "source_id", "title", "score"}
|
||
|
|
return [
|
||
|
|
{key: item[key] for key in allowed if key in item}
|
||
|
|
for item in (value or [])
|
||
|
|
if isinstance(item, dict)
|
||
|
|
]
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _public_value(value: Any) -> Any:
|
||
|
|
"""统一序列化 ORM 的日期、数值和内部整数主键。"""
|
||
|
|
if isinstance(value, datetime):
|
||
|
|
return value.isoformat() + ("Z" if value.tzinfo is None else "")
|
||
|
|
if isinstance(value, (date, Decimal)):
|
||
|
|
return str(value)
|
||
|
|
if isinstance(value, int):
|
||
|
|
return str(value)
|
||
|
|
return value
|