1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
376 lines
15 KiB
Python
376 lines
15 KiB
Python
"""配置管理域 Router。
|
||
|
||
操作码:CFG-02 / CFG-READ-01 / CFG-READ-MANY / CFG-UPDATE-01 /
|
||
CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE。
|
||
|
||
本 router 实现渠道配置管理域的全部 HTTP 端点,覆盖 schema 查询、配置查询、
|
||
批量查询、更新、回滚、历史、导出、导入与批量更新共 9 个操作。所有端点统一采用模板 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.<method>``
|
||
→ ``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/values`` 先于动态路径 ``/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/values CFG-READ-MANY get_config_values
|
||
- 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 (
|
||
BATCH_UPDATE_LIMIT,
|
||
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(..., min_length=1, 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-``BATCH_UPDATE_LIMIT``(契约层单一真相源)。
|
||
``stop_on_error=false`` 时逐条独立事务(模式 D 部分成功);
|
||
``stop_on_error=true`` 时首条失败即终止。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
updates: list[ConfigUpdateItemRequest] = Field(
|
||
..., min_length=1, max_length=BATCH_UPDATE_LIMIT, 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)
|
||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||
if result.status == "partial":
|
||
response["partial"] = True
|
||
return response
|
||
|
||
|
||
@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)
|
||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||
if result.status == "partial":
|
||
response["partial"] = True
|
||
return response
|
||
|
||
|
||
@config_router.get("/config/values", response_model=dict)
|
||
async def get_config_values(
|
||
request: Request,
|
||
keys: list[str] = Query(..., min_length=1, max_length=BATCH_UPDATE_LIMIT, description="配置键列表"),
|
||
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-MANY)。对应控制面操作 config/get_many。
|
||
|
||
一次性查询多个配置键的当前值,返回顺序与请求 keys 一致,供管理后台
|
||
替代多次单 key 请求。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.config_management.getConfigValues(
|
||
keys=keys,
|
||
operator=operator,
|
||
scope=scope,
|
||
target=target,
|
||
)
|
||
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)}
|