Files
group_fqcd_jr/app/service/customer_service_handover_admin_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

125 lines
5.8 KiB
Python

"""管理员查看客服转人工队列的只读服务。
本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或
联系方式,也不提供接单、分配、解决或关闭工单的能力 ——
**工单的处置(分配/接单/解决/关闭/取消)在
`app/service/customer_service_handover_action_service.py`**,那是另一条有写权限与
状态机校验的边界,两者刻意分开:读侧可以被更宽的人群使用,写侧必须 `handover:write`。
"""
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,
status: 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)
if status:
# 状态取值由接口层用 `HandoverStatus` 收口(与数据库 CHECK 逐字对齐),
# 这里不再重复枚举,避免两处口径漂移。
statement = statement.where(HandoverTicket.status == status)
tickets = list(await session.scalars(statement))
return {
"data": [self._list_item(ticket) for ticket in tickets],
"meta": {"trace_id": context.trace_id, "status": status},
}
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,
# 受理人(坐席)与流转时间是队列路由信息,不是客户数据,列表页需要它。
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"accepted_at": cls._public_value(ticket.accepted_at),
"resolved_at": cls._public_value(ticket.resolved_at),
"closed_at": cls._public_value(ticket.closed_at),
"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),
"resolution": cls._safe_text(ticket.resolution),
"assigned_at": cls._public_value(ticket.assigned_at),
"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