1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""安装向导域 Router(WIZ-01~WIZ-08)。
|
||
|
||
本 router 实现渠道插件安装向导流程的 HTTP 端点,覆盖步骤查询 / 校验 /
|
||
应用 / 完成 / OAuth 发起 / OAuth 回调 / 扫码登录发起 / 扫码登录轮询共 8 个
|
||
操作。所有端点统一采用模板 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
|
||
- POST /{channel_type}/wizard/qr/initiate WIZ-07 initiate_wizard_qr_login
|
||
- POST /{channel_type}/wizard/qr/wait WIZ-08 wait_wizard_qr_login
|
||
"""
|
||
|
||
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)}
|