2026-07-02 03:29:06 +08:00
|
|
|
|
"""登录流程域 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)。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
鉴权策略:除 ``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)。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
模板选型:模板 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,
|
|
|
|
|
|
)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
from server.utils.auth_middleware import get_admin_user, get_superadmin_user
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
login_router = APIRouter(tags=["channels-login"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ForceLogoutRequest(BaseModel):
|
|
|
|
|
|
"""强制下线请求体(LGN-FORCE-LOGOUT)。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
reason: str = Field(
|
|
|
|
|
|
...,
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
max_length=500,
|
|
|
|
|
|
description="强制下线原因(安全审计用,必填,1~500 字符)",
|
|
|
|
|
|
)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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),
|
2026-07-04 00:16:00 +08:00
|
|
|
|
current_user: User = Depends(get_superadmin_user),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""强制下线渠道账户(LGN-FORCE-LOGOUT)。
|
|
|
|
|
|
|
|
|
|
|
|
对应控制面操作 ``login/force_logout``(由 ``ChannelControlService.forceLogout``
|
|
|
|
|
|
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
2026-07-04 00:16:00 +08:00
|
|
|
|
超级管理员发起的强制下线,可选吊销凭据。编排链路:HTTP 入参 →
|
2026-07-02 03:29:06 +08:00
|
|
|
|
``build_operator`` 构造操作人 → 构造 ``ForceLogoutCmd`` →
|
2026-07-04 00:16:00 +08:00
|
|
|
|
``use_cases.login.forceLogout`` 调用端口方法 → 控制面管道校验
|
|
|
|
|
|
superadmin 权限与账户存在性 / 登录态 → 调度
|
|
|
|
|
|
``QrLoginService.forceLogout``(内部检查适配器是否实现 ``forceLogout``
|
|
|
|
|
|
+ 调用适配器)→ 发布 ``ChannelAccountOffline`` 事件 →
|
|
|
|
|
|
``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 →
|
2026-07-02 03:29:06 +08:00
|
|
|
|
``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)}
|