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相关端点,重构路由路径与校验逻辑
460 lines
19 KiB
Python
460 lines
19 KiB
Python
"""会话资源域 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.filter(persistence_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-07,SES-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-26,SES-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)}
|