"""登录流程域 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.`` → ``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)}