"""配置管理域 Router(CFG-02 / CFG-READ-01 / CFG-UPDATE-01 / CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)。 本 router 实现渠道配置管理域的全部 HTTP 端点,覆盖 schema 查询、配置查询、 更新、回滚、历史、导出、导入与批量更新共 8 个操作。所有端点统一采用模板 A (控制面端口路由),通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``, 经 ``config_management`` 端口调用类型化方法,由 ``ChannelControlService`` 内部走控制面管道(auth → permission → rate_limit → dispatch → audit)。 鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。 角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。 模板选型:模板 A(控制面端口路由)——端点函数体仅做 ``operator = build_operator`` → ``use_cases.config_management.`` → ``raiseOnControlFailure`` → ``return {"success": True, "data": serialize_control_data(result.data)}``, 不包含 try/except 吞异常、直接 Redis 访问、ORM 模型返回或 ``_executeControl`` 直接调用。模板选型决策依据详见设计方案 §2.5。 路径设计:静态路径 ``/config/schema`` / ``/config/export`` / ``/config/import`` / ``/config/batch`` 先于动态路径 ``/config/{key}`` 声明,避免被动态路径捕获 (规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为 ``/channels/config/*``(不含 ``{channel_type}`` 段,配置管理为跨渠道 全局能力)。 tags 命名:``config_router = APIRouter(tags=["channels-config"])``,与现有 ``channels-login``/``channels-account`` 等子 router 命名规范一致。 端点清单(对应《配置管理域设计方案》§2.1): - GET /config/schema CFG-02 get_config_schema - GET /config/export CFG-EXPORT export_config - POST /config/import CFG-IMPORT import_config - PUT /config/batch CFG-BATCH-UPDATE batch_update_config - GET /config/{key} CFG-READ-01 get_config - PUT /config/{key} CFG-UPDATE-01 update_config - POST /config/{key}/rollback CFG-ROLL-01 rollback_config - GET /config/{key}/history CFG-HIST-01 get_config_history """ 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.config import ( BatchUpdateConfigCmd, ConfigExportCmd, ConfigScope, ConfigUpdateItem, ImportConfigCmd, RollbackConfigCmd, UpdateConfigCmd, ) 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 config_router = APIRouter(tags=["channels-config"]) class UpdateConfigRequest(BaseModel): """更新配置请求体(CFG-UPDATE-01)。 ``value`` 为 ``Any`` 类型,类型校验由 ``ConfigManager._validateValueType`` 基于 ``CONFIG_SCHEMA`` 声明的 ``type`` 字段执行(``str`` / ``int`` / ``bool`` / ``json``)。 """ model_config = ConfigDict(frozen=True) value: Any = Field(..., description="配置值(类型需与 schema 声明一致)") scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") expected_version: int | None = Field(default=None, ge=1, description="乐观锁版本号") class RollbackConfigRequest(BaseModel): """回滚配置请求体(CFG-ROLL-01)。""" model_config = ConfigDict(frozen=True) target_version: int = Field(..., ge=1, description="回滚目标版本号") scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") class ImportConfigRequest(BaseModel): """导入配置请求体(CFG-IMPORT)。 ``config_data`` 的 key 必须在 ``CONFIG_SCHEMA`` 中声明,否则 dispatch handler 记入 failed。``dry_run=true`` 时仅校验不写入。 """ model_config = ConfigDict(frozen=True) config_data: dict[str, Any] = Field(..., description="待导入的配置字典") scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") overwrite: bool = Field(default=False, description="是否覆盖已存在 key") dry_run: bool = Field(default=False, description="是否仅预校验不写入") class ConfigUpdateItemRequest(BaseModel): """单条配置更新项请求体(CFG-BATCH-UPDATE 子项)。""" model_config = ConfigDict(frozen=True) key: str = Field(..., description="配置键") value: Any = Field(..., description="配置值") expected_version: int | None = Field(default=None, ge=1, description="乐观锁版本号") class BatchUpdateConfigRequest(BaseModel): """批量更新配置请求体(CFG-BATCH-UPDATE)。 ``updates`` 长度限制 1-50(dispatch handler 校验)。``stop_on_error=false`` 时逐条独立事务(模式 D 部分成功);``stop_on_error=true`` 时首条失败即终止。 """ model_config = ConfigDict(frozen=True) updates: list[ConfigUpdateItemRequest] = Field(..., min_length=1, max_length=50, description="配置更新项列表") scope: ConfigScope = Field(default=ConfigScope.GLOBAL, description="配置作用域") target: str | None = Field(default=None, description="作用域目标") stop_on_error: bool = Field(default=False, description="首条失败即终止") @config_router.get("/config/schema", response_model=dict) async def get_config_schema( request: Request, scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询配置 schema 元数据(CFG-02)。对应控制面操作 config/schema。 返回 ``CONFIG_SCHEMA`` 的字段元数据(键、类型、是否必填、默认值、是否可 热更新、约束),供管理后台动态表单渲染。所有作用域共享同一 schema, ``scope`` 参数仅为审计上下文保留。 """ operator = build_operator(current_user, request) result = await use_cases.config_management.getConfigSchema( operator=operator, scope=scope, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.get("/config/export", response_model=dict) async def export_config( request: Request, scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"), target: str | None = Query(default=None, description="作用域目标(channel/account 时必填)"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """导出配置(CFG-EXPORT)。对应控制面操作 config/export。 遍历指定 ``scope`` / ``target`` 的所有配置 key 序列化返回 ``ConfigExportResult``,含 scope / config_data / version / exported_at / target。需超级管理员权限。 """ operator = build_operator(current_user, request) cmd = ConfigExportCmd( operator=operator, scope=scope, target=target, ) result = await use_cases.config_management.exportConfig(cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.post("/config/import", response_model=dict) async def import_config( payload: ImportConfigRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """导入配置(CFG-IMPORT)。对应控制面操作 config/import。 逐条校验 schema → ``dry_run`` 时仅统计不写入 → ``overwrite=false`` 时 已存在 key 记入 skipped → 逐条写入。返回 ``ImportConfigResult``,含 imported_count / skipped_count / failed_count / failed_keys / imported_at。 需超级管理员权限。 """ operator = build_operator(current_user, request) cmd = ImportConfigCmd( operator=operator, config_data=payload.config_data, scope=payload.scope, target=payload.target, overwrite=payload.overwrite, dry_run=payload.dry_run, ) result = await use_cases.config_management.importConfig(cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.put("/config/batch", response_model=dict) async def batch_update_config( payload: BatchUpdateConfigRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量更新配置(CFG-BATCH-UPDATE)。对应控制面操作 config/batch_update。 ``stop_on_error=false`` 时逐条独立事务(模式 D 部分成功); ``stop_on_error=true`` 时首条失败即终止。每条复用 ``config/update`` 逻辑 (版本校验、hot-reload 检查)。返回 ``BatchUpdateConfigResult``,含 total / succeeded(key + new_version) / failed 列表。 """ operator = build_operator(current_user, request) cmd = BatchUpdateConfigCmd( operator=operator, updates=tuple( ConfigUpdateItem( key=item.key, value=item.value, expected_version=item.expected_version, ) for item in payload.updates ), scope=payload.scope, target=payload.target, stop_on_error=payload.stop_on_error, ) result = await use_cases.config_management.batchUpdateConfig(cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.get("/config/{key}", response_model=dict) async def get_config( key: str, request: Request, scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"), target: str | None = Query(default=None, description="作用域目标(channel/account 时必填)"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询配置值(CFG-READ-01)。对应控制面操作 config/get。""" operator = build_operator(current_user, request) result = await use_cases.config_management.getConfig( key=key, operator=operator, scope=scope, target=target, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.put("/config/{key}", response_model=dict) async def update_config( key: str, payload: UpdateConfigRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """更新配置值(CFG-UPDATE-01)。对应控制面操作 config/update。 不可热更新的配置项抛 ``ConfigRestartRequiredError``;值类型校验失败抛 ``ConfigValidationError``;版本冲突抛 ``ConfigVersionConflictError``。 """ operator = build_operator(current_user, request) cmd = UpdateConfigCmd( key=key, value=payload.value, operator=operator, scope=payload.scope, target=payload.target, expected_version=payload.expected_version, ) result = await use_cases.config_management.updateConfig(cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.post("/config/{key}/rollback", response_model=dict) async def rollback_config( key: str, payload: RollbackConfigRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """回滚配置到指定版本(CFG-ROLL-01)。对应控制面操作 config/rollback。 目标版本不存在抛 ``NotFoundError``;版本冲突抛 ``ConfigVersionConflictError``;回滚失败抛 ``ConfigRollbackError``。 """ operator = build_operator(current_user, request) cmd = RollbackConfigCmd( key=key, target_version=payload.target_version, operator=operator, scope=payload.scope, target=payload.target, ) result = await use_cases.config_management.rollbackConfig(cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @config_router.get("/config/{key}/history", response_model=dict) async def get_config_history( key: str, request: Request, scope: ConfigScope = Query(default=ConfigScope.GLOBAL, description="配置作用域"), target: str | None = Query(default=None, description="作用域目标"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询配置版本历史(CFG-HIST-01)。对应控制面操作 config/history。""" operator = build_operator(current_user, request) result = await use_cases.config_management.getConfigHistory( key=key, operator=operator, scope=scope, target=target, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)}