ForcePilot/backend/server/routers/channels/session_router.py
Kris e5e9f45411 refactor(channel-routers): 批量优化各渠道路由代码与契约对齐
1. config_router: 为expected_version添加ge=1校验
2. directory_router: 补充scope校验逻辑与注释
3. login_router: 拆分强制下线权限,添加参数校验与注释更新
4. reports_router: 统一时间参数处理,修复分页限制使用契约常量
5. dashboard_router: 更新文档与响应格式,修正参数传递逻辑
6. health_router: 缩减健康检查响应字段,修复响应结构与参数校验
7. plugin_router: 新增插件目录端点,补充枚举校验与注释
8. pairing_router: 新增时间过滤参数,补充参数校验
9. __init__.py: 修复异常映射,更新trace_id获取逻辑与工具类
10. doctor_router: 重构单项检查端点,修正注释与校验逻辑
11. account_router: 新增恢复降级账户端点,补充批量操作校验
12. webhook_router: 优化webhook处理逻辑,修复流式读取与响应逻辑
13. content_review_router: 补充批量审核端点,完善参数校验与注释
14. analytics_router: 修正管道阶段描述,统一参数传递
15. wizard_router: 新增OAuth相关端点,重构路由路径与校验逻辑
2026-07-04 00:16:00 +08:00

460 lines
19 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。
提供渠道会话管理的 11 个 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/{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
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.contract.dtos.channel import ChannelType, SessionStatus
from yuxi.channels.contract.dtos.conversation import MergeConversationCmd
from yuxi.channels.contract.dtos.session import (
BatchCloseSessionsCmd,
CloseSessionCmd,
OwnerTransferCmd,
)
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
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/{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)}