Files
group_fqcd_jr/app/service/customer_service_handover_admin_service.py
T

106 lines
4.6 KiB
Python
Raw Normal View History

"""管理员查看客服转人工队列的只读服务。
本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或
联系方式,也不提供接单、分配、解决或关闭工单的能力。
"""
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