1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
92 lines
4.4 KiB
Python
92 lines
4.4 KiB
Python
from datetime import UTC, datetime
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import (
|
||
FeedbackAlreadyExistsError,
|
||
GenericResourceNotFoundError,
|
||
)
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.conversation import ConversationFeedback
|
||
from app.repository.conversation_repository import ConversationRepository
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
|
||
class ConversationService:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self.session = session
|
||
self.repository = ConversationRepository(session)
|
||
|
||
async def messages(
|
||
self, session_id: str, context: RequestContext, limit: int, before: int | None = None
|
||
) -> dict[str, object]:
|
||
"""消息列表投影;`before` 是文档 §3.8 的游标(记录 ID 边界),已由入口校验。
|
||
|
||
取 `limit + 1` 行来判断"还有没有更旧的":只看"取满没取满"会把恰好等于 limit 的
|
||
最后一页说成还有下一页。`next_cursor` 就是本页最后一条的 `message_id` ——
|
||
游标语义是"取更旧的一页",所以它天然可续(集成测试正是这么翻页的)。
|
||
|
||
返回的是内部结构 `{items, next_cursor, has_more}`,由 Controller 用
|
||
`list_envelope` 拆成 `docs/05` §3.3 要求的 `{data, meta}`;此前这里直接返回
|
||
`{"data": [...]}`,成功响应因此**完全没有 `meta.trace_id`**。
|
||
"""
|
||
rows = await self.repository.messages(
|
||
session_id, int(context.user_id), limit + 1, before=before
|
||
)
|
||
has_more = len(rows) > limit
|
||
page_rows = rows[:limit]
|
||
return {
|
||
"items": [
|
||
{
|
||
"message_id": str(row.id),
|
||
"role": row.role,
|
||
"content": row.content,
|
||
"created_at": row.created_at.isoformat() + "Z",
|
||
}
|
||
for row in page_rows
|
||
],
|
||
"next_cursor": str(page_rows[-1].id) if has_more and page_rows else None,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
async def feedback(
|
||
self, message_id: int, context: RequestContext, rating: int,
|
||
feedback_type: str | None, feedback_content: str | None,
|
||
) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "conversation:feedback")
|
||
async with self.session.begin():
|
||
message = await self.repository.message(message_id, int(context.user_id))
|
||
if message is None:
|
||
raise GenericResourceNotFoundError("消息不存在")
|
||
if await self.repository.feedback(message_id, int(context.user_id)) is not None:
|
||
raise FeedbackAlreadyExistsError("消息已反馈")
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
feedback = ConversationFeedback(
|
||
feedback_no=f"fb-{uuid4().hex[:24]}", session_id=message.session_id,
|
||
message_id=message_id, customer_id=int(context.user_id), rating=rating,
|
||
feedback_type=feedback_type, feedback_content=feedback_content, status="open",
|
||
created_at=now, updated_at=now,
|
||
)
|
||
self.session.add(feedback)
|
||
self._audit(context, message.session_id, "conversation.feedback_created",
|
||
{"feedback_no": feedback.feedback_no})
|
||
# 只返回业务数据;§3.3 的信封由 Controller 套(此前这里自带 {"data": ...},
|
||
# 于是成功响应没有 meta.trace_id)。
|
||
return {"feedback_no": feedback.feedback_no, "status": feedback.status}
|
||
|
||
# 转人工申请(POST /api/v1/conversations/{id}/handover-requests)的唯一实现
|
||
# 在 PublicPlatformService.write("handover", ...):它同一事务写工单 + Outbox
|
||
# 事件 + 审计。此处曾经的第二实现只写审计,会让转人工异步链路断掉,已删除。
|
||
|
||
def _audit(
|
||
self, context: RequestContext, session_id: str, action: str, detail: dict[str, object]
|
||
) -> None:
|
||
self.session.add(InteractionAudit(
|
||
actor_type="user", actor_id=int(context.user_id), session_id=session_id,
|
||
portal=context.portal, action_type=action,
|
||
detail={**detail, "trace_id": context.trace_id},
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
))
|