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
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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(
|
2026-09-10 15:55:54 +08:00
|
|
|
|
self, session_id: str, context: RequestContext, limit: int, before: int | None = None
|
2026-09-09 21:55:37 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 15:21:36 +08:00
|
|
|
|
"""消息列表投影;`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`**。
|
|
|
|
|
|
"""
|
2026-09-10 15:55:54 +08:00
|
|
|
|
rows = await self.repository.messages(
|
2026-09-11 15:21:36 +08:00
|
|
|
|
session_id, int(context.user_id), limit + 1, before=before
|
2026-09-10 15:55:54 +08:00
|
|
|
|
)
|
2026-09-11 15:21:36 +08:00
|
|
|
|
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:
|
2026-09-10 15:55:54 +08:00
|
|
|
|
raise GenericResourceNotFoundError("消息不存在")
|
2026-09-09 21:55:37 +08:00
|
|
|
|
if await self.repository.feedback(message_id, int(context.user_id)) is not None:
|
2026-09-10 15:55:54 +08:00
|
|
|
|
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})
|
2026-09-11 15:21:36 +08:00
|
|
|
|
# 只返回业务数据;§3.3 的信封由 Controller 套(此前这里自带 {"data": ...},
|
|
|
|
|
|
# 于是成功响应没有 meta.trace_id)。
|
|
|
|
|
|
return {"feedback_no": feedback.feedback_no, "status": feedback.status}
|
2026-09-09 21:55:37 +08:00
|
|
|
|
|
2026-09-10 15:55:54 +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),
|
|
|
|
|
|
))
|