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