Files
group_fqcd_jr/app/service/public_platform_service.py
T

242 lines
11 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import DomainEvent, RequestContext
from app.core.errors import ConflictAgentError, ResourceNotFoundError
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationFeedback
from app.model.platform import AgentRun, HandoverTicket, RequestIdempotency
from app.model.session import ConversationSession
from app.repository.conversation_repository import ConversationRepository
from app.repository.outbox_repository import OutboxRepository
from app.repository.platform_repository import PlatformRepository
from app.repository.session_repository import SessionRepository
from app.service.admin_service import public
from app.service.agent.bootstrap import get_agent_factory
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
class PublicPlatformService:
async def session(self, session_id: str, context: RequestContext) -> dict[str, Any]:
async with SessionFactory() as session:
row = await SessionRepository(session).owned(session_id, int(context.user_id))
return {"data": self._session_view(row), "meta": {"trace_id": context.trace_id}}
async def handover(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
async with SessionFactory() as session:
row = await session.scalar(
select(HandoverTicket).where(
HandoverTicket.ticket_no == ticket_no,
HandoverTicket.customer_id == int(context.user_id),
)
)
if row is None:
raise ResourceNotFoundError("转人工请求不存在")
return {
"data": {
"handover_id": row.ticket_no,
"status": row.status,
"created_at": public(row.created_at),
},
"meta": {"trace_id": context.trace_id},
}
async def write(
self,
operation: str,
target: str,
context: RequestContext,
key: str | None,
payload: dict[str, Any],
) -> dict[str, Any]:
permission = {
"create": "conversation:create",
"close": "conversation:close",
"cancel": "agent:cancel",
"feedback": "conversation:feedback",
"handover": "handover:create",
}[operation]
await AuthorizationService.require(context, permission)
async def action(session: AsyncSession) -> dict[str, Any]:
now = datetime.now(UTC).replace(tzinfo=None)
user_id = int(context.user_id)
session_id: str | None = None
if operation == "create":
get_agent_factory().authorize(payload["agent_type"], context)
row = ConversationSession(
session_id=str(uuid4()),
user_id=user_id,
agent_type=payload["agent_type"],
portal=context.portal,
)
session.add(row)
await session.flush()
session_id = row.session_id
data = self._session_view(row)
elif operation == "cancel":
data, session_id = await self._cancel(session, target, user_id, now)
elif operation == "feedback":
message = await ConversationRepository(session).message(int(target), user_id)
if message is None:
raise ResourceNotFoundError("消息不存在")
if await ConversationRepository(session).feedback(int(target), user_id):
raise ConflictAgentError("消息已经反馈")
feedback = ConversationFeedback(
feedback_no=f"fb-{uuid4().hex[:24]}",
session_id=message.session_id,
message_id=message.id,
customer_id=user_id,
created_at=now,
updated_at=now,
**payload,
)
session.add(feedback)
await session.flush()
session_id = message.session_id
data = {"feedback_no": feedback.feedback_no, "status": feedback.status}
else:
row = await SessionRepository(session).owned(target, user_id, lock=True)
session_id = row.session_id
if operation == "close":
if row.status not in {"active", "ended"}:
raise ConflictAgentError("当前会话状态不能关闭")
if row.status == "active":
row.status, row.ended_at = "ended", now
data = self._session_view(row)
else:
if row.status != "active":
raise ConflictAgentError("会话不在可转人工状态")
messages = await ConversationRepository(session).messages(target, user_id, 1)
ticket = HandoverTicket(
ticket_no=f"ticket-{uuid4().hex[:24]}",
session_id=target,
customer_id=user_id,
source_agent=row.agent_type or "customer_service",
source_message_id=messages[0].id if messages else None,
reason_code=payload["reason_code"],
reason_detail=payload.get("reason_detail"),
status="pending",
created_at=now,
updated_at=now,
)
session.add(ticket)
await session.flush()
await OutboxRepository(session).append(
DomainEvent(
event_id=str(uuid4()),
event_type="conversation.transfer_requested",
aggregate_type="conversation",
aggregate_id=target,
trace_id=context.trace_id,
payload={"ticket_no": ticket.ticket_no},
occurred_at=now,
)
)
data = {
"handover_id": ticket.ticket_no,
"status": ticket.status,
"session_id": target,
"created_at": public(now),
}
session.add(
InteractionAudit(
actor_type="user",
actor_id=user_id,
session_id=session_id,
portal=context.portal,
action_type=f"platform.{operation}",
detail={
"trace_id": context.trace_id,
"target": target,
},
created_at=now,
)
)
return {"data": data, "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context, f"public:{operation}:{target}", key, payload, action
)
async def _cancel(
self, session: AsyncSession, run_id: str, user_id: int, now: datetime
) -> tuple[dict[str, Any], str]:
run = await session.scalar(
select(AgentRun)
.where(AgentRun.run_id == run_id, AgentRun.user_id == user_id)
.with_for_update()
)
if run is None:
raise ResourceNotFoundError("运行不存在")
if run.status in {"succeeded", "failed"}:
raise ConflictAgentError("RUN_NOT_CANCELLABLE")
if run.status not in {"cancel_requested", "cancelled"}:
run.status, run.cancel_requested_at = "cancel_requested", now
await session.execute(
update(RequestIdempotency)
.where(RequestIdempotency.id == run.idempotency_id)
.values(status="failed", error_code="RUN_CANCELLED", updated_at=now)
)
return {
"run_id": run_id,
"status": run.status,
"cancel_requested_at": public(run.cancel_requested_at),
}, run.session_id
@staticmethod
def _session_view(row: ConversationSession) -> dict[str, Any]:
return {
"session_id": row.session_id,
"agent_type": row.agent_type,
"portal": row.portal,
"status": row.status,
"clarification_round": row.clarification_round,
"message_count": row.message_count,
"last_active_at": public(row.last_active_at),
"started_at": public(row.started_at),
"ended_at": public(row.ended_at),
"created_at": public(row.created_at),
}
async def memory(self, customer_id: int, context: RequestContext) -> dict[str, Any]:
own = str(customer_id) == context.user_id
permission = "memory:read:self" if own else "memory:read:customer"
await AuthorizationService.require(context, permission)
scope = context.permission_scopes.get(permission, "self")
if (
not own
and scope != "all"
and (scope != "own_customers" or str(customer_id) not in context.customer_ids)
):
raise ResourceNotFoundError("客户不可访问")
async with SessionFactory() as session, session.begin():
rows = await PlatformRepository(session).rows(
"profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1
)
session.add(
InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
target_customer_id=customer_id,
portal=context.portal,
action_type="memory.profile_read",
detail={"trace_id": context.trace_id},
created_at=datetime.now(UTC).replace(tzinfo=None),
)
)
# Safe minimum projection; arbitrary snapshot JSON needs field-policy expansion.
data: dict[str, Any] = {
"customer_id": str(customer_id),
"version": str(rows[0]["version"]) if rows else None,
"generated_at": public(rows[0].get("generated_at")) if rows else None,
"profile": {},
}
return {"data": data, "meta": {"trace_id": context.trace_id}}