diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index afbd4aa..266f3e3 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -10,6 +10,7 @@ from app.core.errors import RunLeaseLostError from app.model.audit import InteractionAudit from app.model.conversation import ConversationMessage from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency +from app.model.risk import RiskUser class AgentPersistenceService: @@ -55,10 +56,15 @@ class AgentPersistenceService: # 转人工只接受已完成治理决策的结果;工单与回复绑定,便于管理员回看上下文。 handover_ticket: HandoverTicket | None = None if result.result.transfer_required: + # 访客令牌的 subject 只是匿名会话标识,不对应 sys_user;工单归属字段 + # 可为空,避免把匿名 ID 当成正式用户外键,同时保留 session_id 供后台追踪。 + ticket_customer_id = await self.session.scalar( + select(RiskUser.id).where(RiskUser.id == run.user_id) + ) handover_ticket = HandoverTicket( ticket_no=f"ticket-{uuid4().hex[:24]}", session_id=run.session_id, - customer_id=run.user_id, + customer_id=ticket_customer_id, source_agent=run.agent_type, source_message_id=message.id, intent=(result.result.intent.intent if result.result.intent else None), @@ -84,7 +90,8 @@ class AgentPersistenceService: run.updated_at = now run.locked_until = None self.session.add(InteractionAudit( - actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id, + actor_type="agent", actor_id=run.user_id, + target_customer_id=handover_ticket.customer_id if handover_ticket else run.user_id, session_id=run.session_id, portal="agent", action_type="agent.run_completed", detail={"run_id": run_id, "result_message_id": message.id}, created_at=now, )) diff --git a/tests/unit/service/test_agent_persistence_handover.py b/tests/unit/service/test_agent_persistence_handover.py index 544fa45..7a155f1 100644 --- a/tests/unit/service/test_agent_persistence_handover.py +++ b/tests/unit/service/test_agent_persistence_handover.py @@ -26,12 +26,14 @@ class FakeSession: self.run = run self.added: list[Any] = [] self.executed: list[Any] = [] + self._scalar_calls = 0 def begin(self) -> FakeTransaction: return FakeTransaction() - async def scalar(self, statement: object) -> AgentRun: - return self.run + async def scalar(self, statement: object) -> object: + self._scalar_calls += 1 + return self.run if self._scalar_calls == 1 else self.run.user_id def add(self, item: Any) -> None: self.added.append(item) @@ -105,6 +107,33 @@ async def test_transfer_required_result_creates_pending_ticket_and_outbox_event( assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no +class VisitorSession(FakeSession): + """模拟访客运行:运行记录存在,但不存在对应的正式 sys_user。""" + + def __init__(self, run: AgentRun) -> None: + super().__init__(run) + + async def scalar(self, statement: object) -> object: + self._scalar_calls += 1 + if self._scalar_calls == 1: + return self.run + return None + + +@pytest.mark.asyncio +async def test_visitor_transfer_ticket_does_not_use_anonymous_id_as_customer_fk() -> None: + """访客转人工写匿名工单,customer_id 必须为空而不是随机访客 ID。""" + session = VisitorSession(queued_run()) + + await AgentPersistenceService(session).complete_run( + "run-transfer-1", result(transfer_required=True), memory_extraction_requested=False + ) + + tickets = added_of(session.added, HandoverTicket) + assert len(tickets) == 1 + assert tickets[0].customer_id is None + + @pytest.mark.asyncio async def test_normal_result_does_not_create_handover_ticket_or_event() -> None: """非转人工回答不得污染管理员待处理队列。"""