ForcePilot/backend/server/routers/channels/wizard_router.py

313 lines
13 KiB
Python
Raw Normal View History

"""安装向导域 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
# ---------------- 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)}