本次提交包含多类优化:
1. 移除多个路由文件中多余的空导入行,统一代码格式
2. 重构Query参数定义,将长参数拆分为多行提升可读性
3. 新增多个业务端点:
- 渠道能力画像矩阵查询CAP-03
- 配对审批计数接口用于待办角标
- 批量查询对端目录资料接口
- 向导扫码登录相关端点
- 会话实时事件SSE推送端点
- 工作台待办统计接口
4. 完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5. 调整路由导入顺序,修复动态路由路径冲突隐患
6. 更新文档注释与接口清单,修正部分接口描述细节
327 lines
14 KiB
Python
327 lines
14 KiB
Python
"""登录流程域 Router(LGN-QR-01 / LGN-QR-02 / LGN-01 / LGN-02 / LGN-OUT-01 / LGN-STATUS / LGN-FORCE-LOGOUT)。
|
||
|
||
本 router 实现渠道账户登录流程域的全部 HTTP 端点,覆盖扫码启动、扫码轮询、
|
||
取消扫码、刷新二维码、登出、登录态查询、强制下线共 7 个操作。所有端点统一
|
||
采用模板 A(控制面端口路由),通过 ``get_channel_use_cases`` 装配
|
||
``ChannelUseCases``,经 ``login`` 端口调用类型化方法,由
|
||
``ChannelControlService`` 内部走控制面管道
|
||
(auth → permission → rate_limit → dispatch → audit)。
|
||
|
||
鉴权策略:除 ``LGN-FORCE-LOGOUT`` 外的全部端点使用 ``get_admin_user`` 依赖,
|
||
要求管理员或超级管理员角色;``LGN-FORCE-LOGOUT`` 涉及强制下线与可选凭据吊销,
|
||
属高风险操作,使用 ``get_superadmin_user`` 依赖,仅允许超级管理员(契约
|
||
§LGN-FORCE-LOGOUT @pre)。角色校验由依赖函数完成,控制面管道
|
||
``PermissionStage`` 在 ``OPERATION_PERMISSIONS_EXACT`` 中同步注册
|
||
``login/force_logout`` 为 superadmin 要求,形成双重保险(规范 §4)。
|
||
|
||
模板选型:模板 A(控制面端口路由)——端点函数体仅做 ``operator = build_operator``
|
||
→ ``use_cases.login.<method>`` → ``raiseOnControlFailure`` →
|
||
``return {"success": True, "data": serialize_control_data(result.data)}``,
|
||
不包含 try/except 吞异常、直接 DB 访问、ORM 模型返回或 ``executeControl``
|
||
直接调用。
|
||
|
||
路径设计:所有端点共享 ``/{channel_type}/accounts/{account_id}/login/`` 前缀,
|
||
其下 ``qr/start``、``qr/wait``、``qr/cancel``、``qr/refresh``、``logout``、
|
||
``status``、``force-logout`` 均为静态路径段,无静态/动态路径冲突;子 router
|
||
不自行设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 router
|
||
统一追加。
|
||
|
||
端点清单(对应《登录流程域设计方案》§2.1):
|
||
- POST /{channel_type}/accounts/{account_id}/login/qr/start LGN-QR-01 start_qr_login
|
||
- GET /{channel_type}/accounts/{account_id}/login/qr/wait LGN-QR-02 wait_qr_login
|
||
- POST /{channel_type}/accounts/{account_id}/login/qr/cancel LGN-01 cancel_qr_login
|
||
- POST /{channel_type}/accounts/{account_id}/login/qr/refresh LGN-02 refresh_qr_login
|
||
- POST /{channel_type}/accounts/{account_id}/login/logout LGN-OUT-01 logout
|
||
- GET /{channel_type}/accounts/{account_id}/login/status LGN-STATUS get_login_status
|
||
- POST /{channel_type}/accounts/{account_id}/login/force-logout LGN-FORCE-LOGOUT force_logout
|
||
"""
|
||
|
||
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
|
||
from yuxi.channels.contract.dtos.login import ForceLogoutCmd
|
||
from yuxi.storage.postgres.models_business import User
|
||
|
||
from server.routers.channels import (
|
||
build_operator,
|
||
get_channel_use_cases,
|
||
raiseOnControlFailure,
|
||
serialize_control_data,
|
||
)
|
||
from server.utils.auth_middleware import get_admin_user, get_superadmin_user
|
||
|
||
login_router = APIRouter(tags=["channels-login"])
|
||
|
||
|
||
class ForceLogoutRequest(BaseModel):
|
||
"""强制下线请求体(LGN-FORCE-LOGOUT)。"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
reason: str = Field(
|
||
...,
|
||
min_length=1,
|
||
max_length=500,
|
||
description="强制下线原因(安全审计用,必填,1~500 字符)",
|
||
)
|
||
revoke_credentials: bool = Field(
|
||
default=False,
|
||
description="是否同时吊销凭据(默认 False)",
|
||
)
|
||
|
||
|
||
@login_router.post(
|
||
"/{channel_type}/accounts/{account_id}/login/qr/start",
|
||
response_model=dict,
|
||
)
|
||
async def start_qr_login(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
force: bool = Query(
|
||
default=False,
|
||
description="是否强制启动新会话(取消既有活跃会话)",
|
||
),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""启动扫码登录(LGN-QR-01)。
|
||
|
||
对应控制面操作 ``login/qr_start``(由 ``ChannelControlService.startQrLogin``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 →
|
||
``use_cases.login.startQrLogin`` 调用端口方法 → 控制面管道调度插件
|
||
``LoginAdapter.loginWithQrStart`` 生成会话与二维码 → ``ControlResult`` 经
|
||
``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化
|
||
会话与二维码数据返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.startQrLogin(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
force=force,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.get(
|
||
"/{channel_type}/accounts/{account_id}/login/qr/wait",
|
||
response_model=dict,
|
||
)
|
||
async def wait_qr_login(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
session_id: str = Query(..., description="扫码会话 ID"),
|
||
current_qr_data_url: str | None = Query(
|
||
default=None,
|
||
description="客户端当前持有的二维码 URL(用于变更检测)",
|
||
),
|
||
timeout_ms: int | None = Query(
|
||
default=None,
|
||
ge=1000,
|
||
le=60000,
|
||
description="长轮询超时(毫秒)",
|
||
),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""轮询扫码登录状态(LGN-QR-02)。
|
||
|
||
对应控制面操作 ``login/qr_wait``(由 ``ChannelControlService.waitQrLogin``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
采用 long-polling 模式:服务端在 ``timeout_ms`` 窗口内阻塞等待状态变化
|
||
(已扫码 / 已确认 / 已过期 / 二维码刷新),状态变化或超时立即返回,由
|
||
客户端再次发起轮询。``current_qr_data_url`` 用于变更检测,仅当二维码
|
||
已刷新时返回新的 URL。编排链路:HTTP 入参 → ``build_operator`` →
|
||
``use_cases.login.waitQrLogin`` → 控制面管道调度插件
|
||
``LoginAdapter.loginWithQrWait`` → ``ControlResult`` 经 ``raiseOnControlFailure``
|
||
转译失败 → ``serialize_control_data`` 序列化会话状态返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.waitQrLogin(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
session_id=session_id,
|
||
current_qr_data_url=current_qr_data_url,
|
||
timeout_ms=timeout_ms,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.post(
|
||
"/{channel_type}/accounts/{account_id}/login/qr/cancel",
|
||
response_model=dict,
|
||
)
|
||
async def cancel_qr_login(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
session_id: str = Query(..., description="待取消的扫码会话 ID"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""取消扫码登录(LGN-01)。
|
||
|
||
对应控制面操作 ``login/qr_cancel``(由 ``ChannelControlService.cancelQrLogin``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 →
|
||
``use_cases.login.cancelQrLogin`` 调用端口方法 → 控制面管道调度
|
||
``QrLoginService.cancelQrLogin`` 推进本地状态机至 CANCELLED 并清理
|
||
内存会话映射(不调用插件 LoginAdapter)→ ``ControlResult`` 经
|
||
``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化
|
||
会话终结结果返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.cancelQrLogin(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
session_id=session_id,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.post(
|
||
"/{channel_type}/accounts/{account_id}/login/qr/refresh",
|
||
response_model=dict,
|
||
)
|
||
async def refresh_qr_login(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
session_id: str = Query(..., description="待刷新的扫码会话 ID"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""刷新扫码二维码(LGN-02)。
|
||
|
||
对应控制面操作 ``login/qr_refresh``(由 ``ChannelControlService.refreshQrLogin``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 →
|
||
``use_cases.login.refreshQrLogin`` 调用端口方法 → 控制面管道调度
|
||
``QrLoginService.refreshQrLogin`` 复用插件 ``LoginAdapter.loginWithQrStart``
|
||
重新生成二维码并刷新过期时间 → ``ControlResult`` 经
|
||
``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化
|
||
新二维码数据返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.refreshQrLogin(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
session_id=session_id,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.post(
|
||
"/{channel_type}/accounts/{account_id}/login/logout",
|
||
response_model=dict,
|
||
)
|
||
async def logout(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""登出渠道账户(LGN-OUT-01)。
|
||
|
||
对应控制面操作 ``login/logout``(由 ``ChannelControlService.logout``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 →
|
||
``use_cases.login.logout`` 调用端口方法 → 控制面管道调度插件
|
||
``LoginAdapter.logout`` 清理渠道侧会话与凭据 → ``ControlResult`` 经
|
||
``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化
|
||
登出结果返回。幂等性:未登录或已登出的账户再次调用幂等返回成功。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.logout(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.get(
|
||
"/{channel_type}/accounts/{account_id}/login/status",
|
||
response_model=dict,
|
||
)
|
||
async def get_login_status(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询账户登录态(LGN-STATUS)。
|
||
|
||
对应控制面操作 ``login/status``(由 ``ChannelControlService.getLoginStatus``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
供管理员排查"机器人无响应"时确认账户扫码会话状态,仅返回内存态扫码
|
||
会话信息(不查询渠道侧 API,轻量查询)。编排链路:HTTP 入参 →
|
||
``build_operator`` 构造操作人 → ``use_cases.login.getLoginStatus`` 调用
|
||
端口方法 → 控制面管道调度 ``QrLoginService`` 查询内存态扫码会话 →
|
||
``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 →
|
||
``serialize_control_data`` 序列化登录态结果返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.login.getLoginStatus(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@login_router.post(
|
||
"/{channel_type}/accounts/{account_id}/login/force-logout",
|
||
response_model=dict,
|
||
)
|
||
async def force_logout(
|
||
channel_type: ChannelType,
|
||
account_id: str,
|
||
request: Request,
|
||
body: ForceLogoutRequest,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_superadmin_user),
|
||
) -> dict[str, Any]:
|
||
"""强制下线渠道账户(LGN-FORCE-LOGOUT)。
|
||
|
||
对应控制面操作 ``login/force_logout``(由 ``ChannelControlService.forceLogout``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
超级管理员发起的强制下线,可选吊销凭据。编排链路:HTTP 入参 →
|
||
``build_operator`` 构造操作人 → 构造 ``ForceLogoutCmd`` →
|
||
``use_cases.login.forceLogout`` 调用端口方法 → 控制面管道校验
|
||
superadmin 权限与账户存在性 / 登录态 → 调度
|
||
``QrLoginService.forceLogout``(内部检查适配器是否实现 ``forceLogout``
|
||
+ 调用适配器)→ 发布 ``ChannelAccountOffline`` 事件 →
|
||
``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 →
|
||
``serialize_control_data`` 序列化强制下线结果返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = ForceLogoutCmd(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
operator=operator,
|
||
reason=body.reason,
|
||
revoke_credentials=body.revoke_credentials,
|
||
)
|
||
result = await use_cases.login.forceLogout(cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|