ForcePilot/backend/server/routers/channels/session_router.py
Kris bba1775220 refactor(channel-router): 清理冗余空行并优化代码结构
本次提交包含多类优化:
1.  移除多个路由文件中多余的空导入行,统一代码格式
2.  重构Query参数定义,将长参数拆分为多行提升可读性
3.  新增多个业务端点:
    - 渠道能力画像矩阵查询CAP-03
    - 配对审批计数接口用于待办角标
    - 批量查询对端目录资料接口
    - 向导扫码登录相关端点
    - 会话实时事件SSE推送端点
    - 工作台待办统计接口
4.  完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5.  调整路由导入顺序,修复动态路由路径冲突隐患
6.  更新文档注释与接口清单,修正部分接口描述细节
2026-07-06 20:50:03 +08:00

529 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""会话资源域 Router。
提供渠道会话管理的 12 个 HTTP 端点,全部由 ``get_admin_user`` 守门,仅管理员
可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装为契约层
命令/参数,调用 ``use_cases.session_management`` 的类型化方法走控制面管道,
再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data`` 序列化
成功结果(模板 A。P3 绑定相关端点不走控制面管道,直接调用
``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)}