ForcePilot/backend/server/routers/channels/session_router.py

529 lines
22 KiB
Python
Raw Normal View History

"""会话资源域 Router。
提供渠道会话管理的 12 HTTP 端点全部由 ``get_admin_user`` 守门仅管理员
可访问Router 不含任何业务逻辑仅做协议翻译 HTTP 请求参数组装为契约层
命令/参数调用 ``use_cases.session_management`` 的类型化方法走控制面管道
再通过 ``raiseOnControlFailure`` 转译失败``serialize_control_data`` 序列化
成功结果模板 AP3 绑定相关端点不走控制面管道直接调用
``use_cases.user_binding`` 用例方法模板 B异常由全局异常处理器统一转译
端点清单
- GET /sessions SES-QUERY-01 列出渠道会话
- POST /sessions/batch-close SES-BATCH-CLOSE-01 批量关闭会话
- GET /sessions/events SES-EVENTS-01 渠道会话实时事件 SSE
- GET /sessions/{session_id} SES-QUERY-02 查询会话详情
- POST /sessions/{session_id}/merge SES-05 合并会话FR-07
- POST /sessions/{session_id}/transfer SES-13 转移会话所有者FR-26
- POST /sessions/{session_id}/close SES-CLOSE-01 关闭会话
- GET /sessions/{session_id}/messages SES-MSG-01 查询会话消息流
- GET /sessions/{session_id}/stats SES-STATS-01 查询会话统计指标
- POST /sessions/{session_id}/bind-user SES-BIND-01 绑定系统用户P3
- DELETE /sessions/{session_id}/bind-user SES-UNBIND-01 解绑系统用户P3
- GET /sessions/{session_id}/identity SES-IDENTITY-01 查询会话身份绑定状态P3
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.application.extension.channel_event_broadcaster import (
ChannelEventBroadcaster,
)
from yuxi.channels.contract.dtos.channel import ChannelType, SessionStatus
from yuxi.channels.contract.dtos.conversation import MergeConversationCmd
from yuxi.channels.contract.dtos.plugin import DomainEvent
from yuxi.channels.contract.dtos.session import (
BatchCloseSessionsCmd,
CloseSessionCmd,
OwnerTransferCmd,
)
from yuxi.channels.infrastructure import DependencyInjectionContainer
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
dataclass_to_dict,
get_channel_di_container,
get_channel_use_cases,
parse_datetime,
raiseOnControlFailure,
serialize_control_data,
)
from server.utils.auth_middleware import get_admin_user
session_router = APIRouter(tags=["channels-session"])
class MergeSessionRequest(BaseModel):
"""合并会话请求体。
路径参数 ``session_id`` 作为目标会话 ID映射为
``MergeConversationCmd.target_conversation_id``不在 body
"""
model_config = ConfigDict(frozen=True)
source_conversation_id: str = Field(..., min_length=1, description="源会话 ID被合并方")
reason: str = Field(default="", max_length=512, description="合并原因(审计用)")
class TransferOwnerRequest(BaseModel):
"""转移所有者请求体。
路径参数 ``session_id`` 作为目标会话 ID映射为
``OwnerTransferCmd.conversation_id``不在 body
"""
model_config = ConfigDict(frozen=True)
new_owner_id: str = Field(..., min_length=1, description="新所有者对端 ID")
class CloseSessionRequest(BaseModel):
"""关闭会话请求体。
路径参数 ``session_id`` 映射为 ``CloseSessionCmd.session_id``不在 body
"""
model_config = ConfigDict(frozen=True)
reason: str | None = Field(default=None, max_length=512, description="关闭原因(审计用)")
class BatchCloseFilter(BaseModel):
"""批量关闭会话筛选条件SES-BATCH-CLOSE-01
``inactive_before`` Router 层经 ``parse_datetime`` 解析为 UTC datetime
后传入 ``BatchCloseSessionsCmd.filter``确保时区一致性与统一错误处理
"""
model_config = ConfigDict(frozen=True)
channel_type: ChannelType | None = Field(default=None, description="按渠道类型过滤")
inactive_before: str | None = Field(
default=None,
description="最后活动时间早于该时刻ISO 8601视为非活跃",
)
status: str | None = Field(
default=None,
description="会话状态过滤active / closed",
)
class BatchCloseSessionsRequest(BaseModel):
"""批量关闭会话请求体SES-BATCH-CLOSE-01
``session_ids`` ``filter`` 二者不可同时为空
``BatchCloseSessionsCmd.__post_init__`` 校验INV-8
"""
model_config = ConfigDict(frozen=True)
session_ids: list[str] | None = Field(
default=None,
description="显式会话 ID 列表(与 filter 二选一)",
)
filter: BatchCloseFilter | None = Field(
default=None,
description="筛选条件(含 channel_type / inactive_before / status",
)
max_count: int = Field(default=100, ge=1, le=1000, description="单次最大关闭数")
reason: str | None = Field(default=None, max_length=512, description="关闭原因(审计用)")
class BindUserRequest(BaseModel):
"""绑定系统用户请求体P3 渐进式绑定)。
路径参数 ``session_id`` 作为目标会话 ID不在 body
``user_uid`` 对应系统用户的 ``User.uid````user_type='user'``
"""
model_config = ConfigDict(frozen=True)
user_uid: str = Field(..., min_length=1, description="系统用户 UID")
remark: str | None = Field(default=None, max_length=512, description="绑定备注(审计用)")
@session_router.get("/sessions", response_model=dict)
async def list_sessions(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤,留空跨渠道查询"),
peer_id: str | None = Query(default=None, description="按对端 ID 模糊匹配"),
created_after: str | None = Query(default=None, description="创建时间下界ISO 8601"),
created_before: str | None = Query(default=None, description="创建时间上界ISO 8601"),
owner_peer_id: str | None = Query(default=None, description="按主会话所有者对端 ID 精确匹配"),
status: SessionStatus | None = Query(default=None, description="会话状态过滤active/closed"),
last_message_after: str | None = Query(default=None, description="最近消息时间下界ISO 8601"),
last_message_before: str | None = Query(default=None, description="最近消息时间上界ISO 8601"),
abnormal: bool = Query(default=False, description="仅返回异常会话(僵尸/账户不可用/无主)"),
limit: int = Query(default=100, ge=1, le=1000, description="分页大小"),
offset: int = Query(default=0, ge=0, description="分页偏移"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""列出渠道会话运维排查用例SES-QUERY-01
供管理员排查"用户消息未到达"等运维问题``channel_type`` 留空时跨渠道查询
``peer_id`` 提供时按对端 ID 模糊匹配``created_after`` / ``created_before``
提供时按会话创建时间范围过滤对应控制面操作 ``session/list``
"""
operator = build_operator(current_user, request)
created_after_dt = parse_datetime("created_after", created_after)
created_before_dt = parse_datetime("created_before", created_before)
last_message_after_dt = parse_datetime("last_message_after", last_message_after)
last_message_before_dt = parse_datetime("last_message_before", last_message_before)
result = await use_cases.session_management.listSessions(
channel_type=channel_type,
limit=limit,
operator=operator,
offset=offset,
peer_id=peer_id,
created_after=created_after_dt,
created_before=created_before_dt,
owner_peer_id=owner_peer_id,
status=status,
last_message_after=last_message_after_dt,
last_message_before=last_message_before_dt,
abnormal=abnormal,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.post("/sessions/batch-close", response_model=dict)
async def batch_close_sessions(
payload: BatchCloseSessionsRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量关闭渠道会话SES-BATCH-CLOSE-01
支持 ``session_ids`` 显式列表与 ``filter`` 筛选条件两种模式二者不可
同时空 ``BatchCloseSessionsCmd.__post_init__`` 校验逐条独立事务
复用单条 ``closeSession`` 逻辑模式 D部分成功语义单条失败不回滚
已成功条目对应控制面操作 ``session/batch_close``
``filter.inactive_before`` 在本层经 ``parse_datetime`` 解析为 UTC
datetime确保时区一致性与统一错误处理project 硬约束
静态路径先于 ``/sessions/{session_id}`` 声明避免被动态参数捕获
"""
operator = build_operator(current_user, request)
# 将 BatchCloseFilter 转换为 dict 并解析 inactive_before 为 UTC datetime
# 透传至 BatchCloseSessionsCmd.filterpersistence_port.findSessionsByFilter
# 接受 datetime 或 ISO 8601 字符串,统一为 datetime 避免歧义)
filter_param: dict[str, Any] | None = None
if payload.filter is not None:
filter_param = {
"channel_type": payload.filter.channel_type,
"inactive_before": parse_datetime("inactive_before", payload.filter.inactive_before),
"status": payload.filter.status,
}
cmd = BatchCloseSessionsCmd(
operator=operator,
session_ids=tuple(payload.session_ids) if payload.session_ids else (),
filter=filter_param,
max_count=payload.max_count,
reason=payload.reason,
)
result = await use_cases.session_management.batchCloseSessions(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.get("/sessions/events")
async def stream_session_events(
request: Request,
di_container: DependencyInjectionContainer = Depends(get_channel_di_container),
current_user: User = Depends(get_admin_user),
) -> StreamingResponse:
"""渠道会话/消息实时事件 SSE 端点BE-14
返回 ``text/event-stream`` 长连接 ``ChannelSessionUpdated`` /
``ChannelMessageReceived`` / ``ChannelMessageSent`` 三类事件以 SSE
形式推送给已认证管理员 15 秒无事件时发送心跳注释客户端断开时
自动取消订阅
本端点静态路径先于 ``/sessions/{session_id}`` 声明避免被动态参数捕获
"""
broadcaster = di_container.resolve(ChannelEventBroadcaster)
async def event_stream() -> AsyncIterator[str]:
"""SSE 事件流异步生成器。"""
queue, subscription_id = await broadcaster.subscribe()
try:
while not await request.is_disconnected():
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
except TimeoutError:
if await request.is_disconnected():
break
yield ":heartbeat\n\n"
continue
if await request.is_disconnected():
break
yield _format_sse_event(event)
finally:
await broadcaster.unsubscribe(subscription_id)
return StreamingResponse(
event_stream(),
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
def _format_sse_event(event: DomainEvent) -> str:
"""将 ``DomainEvent`` 格式化为 SSE 帧。
输出格式
event: {event.event_type}\n
id: {event.event_id}\n
data: {json.dumps(event_dict)}\n\n
其中 ``event_dict`` ``dataclass_to_dict`` 递归序列化为 JSON 兼容结构
"""
event_dict = dataclass_to_dict(event)
return f"event: {event.event_type}\nid: {event.event_id}\ndata: {json.dumps(event_dict, ensure_ascii=False)}\n\n"
@session_router.get("/sessions/{session_id}", response_model=dict)
async def get_session(
session_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""按 session_id 查询渠道会话详情SES-QUERY-02
供管理员审批 mergeSession 前核对源/目标会话信息软删除的会话返回 404
对应控制面操作 ``session/get``
"""
operator = build_operator(current_user, request)
result = await use_cases.session_management.getSession(
session_id=session_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.post("/sessions/{session_id}/merge", response_model=dict)
async def merge_session(
session_id: str,
payload: MergeSessionRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""合并会话FR-07SES-05
将源会话合并至目标会话路径 ``session_id``迁移消息并软删除源会话
``merge_strategy_enabled`` 配置策略门控制falsy 时返回 422
对应控制面操作 ``session/merge``
协议翻译路径参数 ``session_id`` ``MergeConversationCmd.target_conversation_id``
"""
operator = build_operator(current_user, request)
cmd = MergeConversationCmd(
source_conversation_id=payload.source_conversation_id,
target_conversation_id=session_id,
operator=operator,
reason=payload.reason,
)
result = await use_cases.session_management.mergeSession(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.post("/sessions/{session_id}/transfer", response_model=dict)
async def transfer_session_owner(
session_id: str,
payload: TransferOwnerRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""转移会话所有者FR-26SES-13
将会话路径 ``session_id``所有者从当前对端转移至新对端 DB 写入
加入控制面管道事务审计日志由 AuditStage SHARED 事务中统一写入fail-closed
对应控制面操作 ``session/transfer_owner``
协议翻译路径参数 ``session_id`` ``OwnerTransferCmd.conversation_id``
"""
operator = build_operator(current_user, request)
cmd = OwnerTransferCmd(
conversation_id=session_id,
new_owner_id=payload.new_owner_id,
operator=operator,
)
result = await use_cases.session_management.transferSessionOwner(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.post("/sessions/{session_id}/close", response_model=dict)
async def close_session(
session_id: str,
payload: CloseSessionRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""关闭渠道会话SES-CLOSE-01
主动关闭指定会话停止接收新消息已关闭会话不可重新激活
对应控制面操作 ``session/close``
协议翻译路径参数 ``session_id`` ``CloseSessionCmd.session_id``
"""
operator = build_operator(current_user, request)
cmd = CloseSessionCmd(
session_id=session_id,
reason=payload.reason,
operator=operator,
)
result = await use_cases.session_management.closeSession(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.get("/sessions/{session_id}/messages", response_model=dict)
async def list_session_messages(
session_id: str,
request: Request,
limit: int = Query(default=50, ge=1, le=200, description="分页大小"),
offset: int = Query(default=0, ge=0, description="分页偏移"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询会话消息流SES-MSG-01
按时间顺序列出会话内的消息支持分页
对应控制面操作 ``session/list_messages``
"""
operator = build_operator(current_user, request)
result = await use_cases.session_management.listSessionMessages(
session_id=session_id,
limit=limit,
offset=offset,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.get("/sessions/{session_id}/stats", response_model=dict)
async def get_session_stats(
session_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询单会话统计指标SES-STATS-01
``session_id`` 查询会话的统计指标消息计数首响时间平均响应
会话时长等 dispatch handler 组合 ConversationPort 消息查询计算
对应控制面操作 ``session/stats``
协议翻译路径参数 ``session_id`` 直接传入
``getSessionStats(session_id=..., operator=...)``
"""
operator = build_operator(current_user, request)
result = await use_cases.session_management.getSessionStats(
session_id=session_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.post("/sessions/{session_id}/bind-user", response_model=dict)
async def bind_session_user(
session_id: str,
payload: BindUserRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""绑定系统用户到渠道会话P3 渐进式绑定SES-BIND-01
将渠道会话的统一身份绑定到系统用户绑定后身份类型升级为 ``manual``
并触发跨渠道会话合并同一统一身份下的兄弟会话合并到当前会话
不走控制面管道直接调用用例方法异常由全局异常处理器统一转译
协议翻译路径参数 ``session_id`` + body ``user_uid`` / ``remark``
``bindUserToSession(session_id, user_uid, operator, remark=...)``
"""
operator = build_operator(current_user, request)
result = await use_cases.user_binding.bindUserToSession(
session_id=session_id,
user_uid=payload.user_uid,
operator=operator,
remark=payload.remark,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.delete("/sessions/{session_id}/bind-user", response_model=dict)
async def unbind_session_user(
session_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""解绑渠道会话的系统用户P3 渐进式绑定SES-UNBIND-01
解除统一身份与系统用户的绑定身份类型回退为 ``channel_guest``
不删除历史消息解绑仅修改身份关联状态历史消息保留在原会话中
不走控制面管道直接调用用例方法异常由全局异常处理器统一转译
协议翻译路径参数 ``session_id``
``unbindUserFromSession(session_id, operator)``
"""
operator = build_operator(current_user, request)
result = await use_cases.user_binding.unbindUserFromSession(
session_id=session_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@session_router.get("/sessions/{session_id}/identity", response_model=dict)
async def get_session_identity(
session_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询渠道会话身份绑定状态P3 渐进式绑定SES-IDENTITY-01
返回统一身份 ID绑定用户 UID身份类型与绑定状态会话未关联统一
身份时返回 ``is_bound=False`` 的空状态不走控制面管道直接调用
用例方法异常由全局异常处理器统一转译
协议翻译路径参数 ``session_id``
``getSessionIdentity(session_id, operator)``
"""
operator = build_operator(current_user, request)
result = await use_cases.user_binding.getSessionIdentity(
session_id=session_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}