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