ForcePilot/backend/server/routers/channels/wizard_router.py
Kris bba1775220 refactor(channel-router): 清理冗余空行并优化代码结构
本次提交包含多类优化:
1.  移除多个路由文件中多余的空导入行,统一代码格式
2.  重构Query参数定义,将长参数拆分为多行提升可读性
3.  新增多个业务端点:
    - 渠道能力画像矩阵查询CAP-03
    - 配对审批计数接口用于待办角标
    - 批量查询对端目录资料接口
    - 向导扫码登录相关端点
    - 会话实时事件SSE推送端点
    - 工作台待办统计接口
4.  完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5.  调整路由导入顺序,修复动态路由路径冲突隐患
6.  更新文档注释与接口清单,修正部分接口描述细节
2026-07-06 20:50:03 +08:00

369 lines
15 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.

"""安装向导域 RouterWIZ-01~WIZ-06
本 router 实现渠道插件安装向导流程的 HTTP 端点,覆盖步骤查询 / 校验 /
应用 / 完成 / OAuth 发起 / OAuth 回调共 6 个操作。所有端点统一采用模板 A
(控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``
经 ``wizard`` 端口调用类型化方法,由 ``ChannelControlService._executeControl``
内部走控制面管道auth → permission → rate_limit → dispatch → audit
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
角色校验由依赖函数完成router 内不做角色判断(规范 §4
路径设计:所有端点路径均为动态 ``/{channel_type}/wizard/*``,子 router
不自行设置 prefix根前缀 ``/channels`` 由 ``channels_router`` 聚合 router
统一追加。
端点清单对应《20-channels-wizard-domain-v1.0.md》§2.1
- GET /{channel_type}/wizard/steps WIZ-01 get_wizard_steps
- POST /{channel_type}/wizard/steps/{step_id}/validate WIZ-02 validate_wizard_step
- POST /{channel_type}/wizard/steps/{step_id}/apply WIZ-03 apply_wizard_step
- POST /{channel_type}/wizard/finalize WIZ-04 finalize_wizard
- POST /{channel_type}/wizard/oauth/initiate WIZ-05 initiate_wizard_oauth
- POST /{channel_type}/wizard/oauth-callback WIZ-06 handle_wizard_oauth_callback
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator
from yuxi.channels.contract.dtos.channel import ChannelType
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
wizard_router = APIRouter(tags=["channels-wizard"])
# ---------------- Request Schemas ----------------
class ValidateWizardRequest(BaseModel):
"""校验向导步骤请求。字段对齐 ``WizardPort.validateWizardStep`` 入参。
``step_id`` 作为路径参数(与 ``applyWizardStep`` 一致,遵循项目 RESTful
资源定位规范:步骤 ID 在路径中标识资源body 仅承载字段值)。
"""
model_config = ConfigDict(frozen=True)
values: dict[str, Any] = Field(..., description="步骤字段值字典")
class ApplyWizardRequest(BaseModel):
"""应用向导步骤请求。字段对齐 ``WizardPort.applyWizardStep`` 入参。"""
model_config = ConfigDict(frozen=True)
values: dict[str, Any] = Field(..., description="步骤字段值字典")
class WizardPatch(BaseModel):
"""向导配置补丁。字段对齐 ``WizardConfigPatch`` 值对象。
每项补丁含 ``step_id``(用于校验步骤存在性)与 ``values``(配置键值对),
在 router 层把好入口关,避免弱类型 ``dict[str, Any]`` 掩盖契约违反。
"""
model_config = ConfigDict(frozen=True)
step_id: str = Field(..., min_length=1, description="向导步骤 ID")
values: dict[str, Any] = Field(..., description="步骤字段值字典")
class FinalizeWizardRequest(BaseModel):
"""完成向导请求。字段对齐 ``WizardPort.finalizeWizard`` 入参。
``patches`` 至少含一项Port 前置条件要求非空),每项结构由
``WizardPatch`` 校验。转换为 ``list[dict[str, Any]]`` 后传入 Port
保持 Port 签名不变。
"""
model_config = ConfigDict(frozen=True)
patches: list[WizardPatch] = Field(..., min_length=1, description="配置补丁列表")
class OAuthCallbackRequest(BaseModel):
"""OAuth 回调请求。字段对齐 ``WizardPort.handleWizardOAuthCallback`` 入参。
``code`` 为 OAuth 授权码(敏感凭证),通过 request body 传递避免
出现在 URL query string 中被日志 / 浏览器历史 / Referer 头泄漏。
``redirect_uri`` 必须为合法 URL 且 scheme 为 http/https生产环境要求
HTTPS由 ``WizardPort.handleWizardOAuthCallback`` @pre 强制),本地
开发环境允许 localhost HTTP 以便联调。
``state`` 为 ``POST /wizard/oauth/initiate`` 端点返回的 CSRF 防护参数,
服务端会与发起时存储的 state 比对,不匹配时拒绝处理。
"""
model_config = ConfigDict(frozen=True)
code: str = Field(..., min_length=1, description="OAuth 授权码")
state: str = Field(..., min_length=1, description="CSRF 防护参数(由 oauth/initiate 端点返回)")
redirect_uri: str = Field(..., description="回调重定向 URI")
@field_validator("redirect_uri")
@classmethod
def _validate_redirect_uri_scheme(cls, v: str) -> str:
parsed = urlparse(v)
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
raise ValueError("redirect_uri must be a valid http(s) URL")
return v
class OAuthInitiateRequest(BaseModel):
"""OAuth 发起请求。字段对齐 ``WizardPort.initiateWizardOAuth`` 入参。
``redirect_uri`` 的 scheme 校验与 ``OAuthCallbackRequest`` 一致,允许
localhost HTTP 用于本地联调。服务端会绑定 ``redirect_uri`` 与生成的
state后续 ``oauth-callback`` 请求中的 ``redirect_uri`` 必须完全一致,
防止篡改。
"""
model_config = ConfigDict(frozen=True)
redirect_uri: str = Field(..., description="回调重定向 URI需与后续 oauth-callback 一致)")
@field_validator("redirect_uri")
@classmethod
def _validate_redirect_uri_scheme(cls, v: str) -> str:
parsed = urlparse(v)
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
raise ValueError("redirect_uri must be a valid http(s) URL")
return v
class QrLoginWaitRequest(BaseModel):
"""扫码登录轮询请求。字段对齐 ``WizardPort.waitWizardQrLogin`` 入参。"""
model_config = ConfigDict(frozen=True)
session_id: str = Field(..., min_length=1, description="扫码会话 ID由 qr/initiate 返回)")
current_qr_data_url: str | None = Field(None, description="当前二维码数据 URL用于刷新场景")
# ---------------- Endpoints ----------------
@wizard_router.get("/{channel_type}/wizard/steps", response_model=dict)
async def get_wizard_steps(
channel_type: ChannelType,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询安装向导步骤列表WIZ-01
对应控制面操作 ``wizard/steps``(由 ``ChannelControlService.getWizardSteps``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
步骤字段从已保存的向导状态预填充默认值,支持从现有配置恢复向导进度。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.getWizardSteps(
channel_type=channel_type,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/steps/{step_id}/validate", response_model=dict)
async def validate_wizard_step(
channel_type: ChannelType,
step_id: str,
payload: ValidateWizardRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""校验向导步骤字段值WIZ-02
对应控制面操作 ``wizard/validate``(由 ``ChannelControlService.validateWizardStep``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
仅校验字段值,不应用配置变更。校验通过时返回携带配置补丁的结果。
路径设计对齐 ``applyWizardStep````step_id`` 作为路径参数标识步骤资源,
body 仅承载 ``values`` 字段值,与 ``apply`` 端点保持 API 表面一致。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.validateWizardStep(
channel_type=channel_type,
step_id=step_id,
values=payload.values,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/steps/{step_id}/apply", response_model=dict)
async def apply_wizard_step(
channel_type: ChannelType,
step_id: str,
payload: ApplyWizardRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""应用向导步骤WIZ-03
对应控制面操作 ``wizard/apply``(由 ``ChannelControlService.applyWizardStep``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
先校验步骤字段值,校验通过后应用步骤并将配置补丁合并到向导状态。
校验失败时不应用配置AC-39
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.applyWizardStep(
channel_type=channel_type,
step_id=step_id,
values=payload.values,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/finalize", response_model=dict)
async def finalize_wizard(
channel_type: ChannelType,
payload: FinalizeWizardRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""完成安装向导WIZ-04
对应控制面操作 ``wizard/finalize``(由 ``ChannelControlService.finalizeWizard``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
聚合请求中的配置补丁与向导状态中的已应用补丁,写入渠道配置并清除向导状态。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.finalizeWizard(
channel_type=channel_type,
patches=[patch.model_dump() for patch in payload.patches],
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/oauth/initiate", response_model=dict)
async def initiate_wizard_oauth(
channel_type: ChannelType,
payload: OAuthInitiateRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""发起向导 OAuth 授权WIZ-05
对应控制面操作 ``wizard/oauth-initiate``(由 ``ChannelControlService.initiateWizardOAuth``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
生成服务端 CSRF state 并存储到向导状态,前端获取 state 后附加到
OAuth 授权 URL 跳转到渠道授权页。
前端必须在调用 ``oauth-callback`` 前先调用本端点获取 state否则
回调会被拒绝(``state`` 不匹配)。``redirect_uri`` 需与后续 ``oauth-callback``
请求中的值完全一致,服务端会绑定校验。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.initiateWizardOAuth(
channel_type=channel_type,
redirect_uri=payload.redirect_uri,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/oauth-callback", response_model=dict)
async def handle_wizard_oauth_callback(
channel_type: ChannelType,
payload: OAuthCallbackRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""处理向导 OAuth 回调WIZ-06
对应控制面操作 ``wizard/oauth-callback``(由 ``ChannelControlService.handleWizardOAuthCallback``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
委托向导适配器交换 OAuth 授权码并产出配置补丁,合并到向导状态。
CSRF 防护:``state`` 必须与 ``initiate_wizard_oauth`` 端点返回的 state
一致,服务端校验通过后一次性消费 state防止重放。未发起授权或
state 不匹配时返回 400。
采用 POST + request body 传递授权码,避免 GET query string 泄漏
敏感凭证OAuth code 可交换 user_access_token。``redirect_uri``
在 router 层校验合法 http(s) scheme 与非空 netloc生产环境强制
HTTPS 由 ``WizardPort.handleWizardOAuthCallback`` @pre 兜底(允许
localhost HTTP 用于本地联调)。响应中的 token 等敏感字段已脱敏。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.handleWizardOAuthCallback(
channel_type=channel_type,
code=payload.code,
state=payload.state,
redirect_uri=payload.redirect_uri,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/qr/initiate", response_model=dict)
async def initiate_wizard_qr_login(
channel_type: ChannelType,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""发起向导扫码登录WIZ-07
对应控制面操作 ``wizard/qr-initiate``(由 ``ChannelControlService.initiateWizardQrLogin``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
在账户尚未创建时复用 ``LoginAdapter`` 获取二维码,返回 session_id 与
qr_data_url 供前端展示并轮询。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.initiateWizardQrLogin(
channel_type=channel_type,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@wizard_router.post("/{channel_type}/wizard/qr/wait", response_model=dict)
async def wait_wizard_qr_login(
channel_type: ChannelType,
payload: QrLoginWaitRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""轮询向导扫码登录结果WIZ-08
对应控制面操作 ``wizard/qr-wait``(由 ``ChannelControlService.waitWizardQrLogin``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
扫码成功后返回 credentials由前端填入当前向导步骤并通过 ``apply`` 提交。
"""
operator = build_operator(current_user, request)
result = await use_cases.wizard.waitWizardQrLogin(
channel_type=channel_type,
session_id=payload.session_id,
current_qr_data_url=payload.current_qr_data_url,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}