Files
group_fqcd_jr/app/service/conversation_service.py
T

92 lines
4.4 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
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,
)
2026-09-09 21:55:37 +08:00
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
2026-09-09 21:55:37 +08:00
) -> 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,
}
2026-09-09 21:55:37 +08:00
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("消息不存在")
2026-09-09 21:55:37 +08:00
if await self.repository.feedback(message_id, int(context.user_id)) is not None:
raise FeedbackAlreadyExistsError("消息已反馈")
2026-09-09 21:55:37 +08:00
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}
2026-09-09 21:55:37 +08:00
# 转人工申请(POST /api/v1/conversations/{id}/handover-requests)的唯一实现
# 在 PublicPlatformService.write("handover", ...):它同一事务写工单 + Outbox
# 事件 + 审计。此处曾经的第二实现只写审计,会让转人工异步链路断掉,已删除。
2026-09-09 21:55:37 +08:00
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),
))