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), ))