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

583 lines
23 KiB
Python
Raw Normal View History

"""账户资源域 RouterAL-01~AL-04 + ACC-01 runtime-status + P0缺口
router 实现渠道账户生命周期的全部 HTTP 端点覆盖创建 / 查询 / 更新 /
删除 / 启用 / 禁用 / 运行时状态查询 / 凭据轮换 / 连通性测试共 10 个操作所有端点统一采用模板 A
控制面端口路由通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases``
``account_management`` 端口调用类型化方法 ``ChannelControlService._executeControl``
内部走控制面管道auth permission rate_limit dispatch audit
鉴权策略全部端点使用 ``get_admin_user`` 依赖要求管理员或超级管理员角色
角色校验由依赖函数完成router 内不做角色判断规范 §4
路径设计静态跨渠道查询 ``GET /accounts`` ``POST /accounts/batch/*`` 先于
动态路径 ``/{channel_type}/...`` 声明规范 §6.5避免静态路径被动态参数
捕获 router 不自行设置 prefix根前缀 ``/channels`` ``channels_router``
聚合 router 统一追加
端点清单对应01-账户资源域设计方案§2.1 + P0/P1缺口补全
- GET /accounts ACC-AL-01 list_accounts
- POST /accounts/batch/enable ACC-BATCH-STATE batch_enable_accounts
- POST /accounts/batch/disable ACC-BATCH-STATE batch_disable_accounts
- POST /{channel_type}/accounts ACC-AL-02 create_account
- GET /{channel_type}/accounts/{account_id} ACC-AL-01 get_account
- PUT /{channel_type}/accounts/{account_id} ACC-AL-03 update_account
- DELETE /{channel_type}/accounts/{account_id} ACC-AL-04 delete_account
- POST /{channel_type}/accounts/{account_id}/enable ACC-STATE-01 enable_account
- POST /{channel_type}/accounts/{account_id}/disable ACC-STATE-02 disable_account
- POST /{channel_type}/accounts/{account_id}/recover ACC-STATE-03 recover_account
- GET /{channel_type}/accounts/{account_id}/runtime-status ACC-01 get_account_runtime_status
- POST /{channel_type}/accounts/{account_id}/rotate-credentials ACC-ROTATE-01 rotate_credentials
- POST /{channel_type}/accounts/{account_id}/test-connection ACC-TEST-01 test_connection
- GET /{channel_type}/accounts/{account_id}/export ACC-EXPORT export_account
- POST /{channel_type}/accounts/{account_id}/clone ACC-CLONE clone_account
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, model_validator
from yuxi.channels.contract.dtos.channel import (
BatchStateChangeCmd,
ChannelType,
CloneAccountCmd,
)
from yuxi.channels.contract.errors import ValidationError
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
account_router = APIRouter(tags=["channels-account"])
# ---------------- Request Schemas ----------------
class CreateAccountRequest(BaseModel):
"""创建账户请求。字段对齐 ``AccountManagementPort.createAccount`` 入参。"""
model_config = ConfigDict(frozen=True)
display_name: str = Field(
...,
min_length=1,
max_length=128,
description="账户显示名称",
)
raw_config: dict[str, Any] = Field(
...,
description="渠道原始配置(由插件校验)",
)
class UpdateAccountRequest(BaseModel):
"""更新账户请求。字段对齐 ``AccountManagementPort.updateAccount`` 入参。
``display_name`` ``raw_config`` 均可选但至少一个非空
``_require_at_least_one`` 校验器保证否则抛 ``ValidationError``
"""
model_config = ConfigDict(frozen=True)
display_name: str | None = Field(
default=None,
min_length=1,
max_length=128,
description="账户显示名称",
)
raw_config: dict[str, Any] | None = Field(
default=None,
description="渠道原始配置",
)
@model_validator(mode="after")
def _require_at_least_one(self) -> UpdateAccountRequest:
"""校验至少一个字段非空。
两个字段均为 ``None`` 时抛 ``ValidationError``规范 §6.3 跨字段校验
由于 ``ValidationError`` 继承 ``ValueError``Pydantic v2 会将其包装为
``RequestValidationError``由全局处理器统一映射为 422 + 统一格式响应
"""
if self.display_name is None and self.raw_config is None:
raise ValidationError(
"display_name",
"at least one of display_name or raw_config must be provided",
)
return self
class BatchStateChangeRequest(BaseModel):
"""批量启停账户请求体ACC-BATCH-STATE
``account_ids`` ``filter`` 二选一二者不可同时为空亦不可同时
提供schema 层强制互斥避免歧义``action`` 由端点路径决定
``enable`` / ``disable``不在 body
"""
model_config = ConfigDict(frozen=True)
account_ids: list[str] | None = Field(
default=None,
description="显式账户 ID 列表(与 filter 二选一)",
)
filter: dict[str, Any] | None = Field(
default=None,
description="筛选条件(含 channel_type / status",
)
reason: str | None = Field(default=None, max_length=512, description="操作原因(审计用)")
@model_validator(mode="after")
def _require_exclusive_one_of(self) -> BatchStateChangeRequest:
"""校验 ``account_ids`` 与 ``filter`` 互斥且至少一个非空。
二者均为空时抛 ``ValidationError``缺目标二者均非空时抛
``ValidationError``互斥违反避免 ``filter`` 字段被静默忽略
"""
has_ids = bool(self.account_ids)
has_filter = bool(self.filter)
if not has_ids and not has_filter:
raise ValidationError(
"account_ids",
"either account_ids or filter must be provided",
)
if has_ids and has_filter:
raise ValidationError(
"account_ids",
"account_ids and filter are mutually exclusive",
)
return self
class CloneAccountRequest(BaseModel):
"""克隆账户请求体ACC-CLONE
``channel_type`` 与源 ``account_id`` 由路径参数提供不在 body
新账户默认为 DISABLED 状态需手动 enable
"""
model_config = ConfigDict(frozen=True)
new_display_name: str = Field(..., min_length=1, max_length=128, description="新账户显示名")
new_raw_config_overrides: dict[str, Any] | None = Field(
default=None,
description="配置覆盖项(可选,按字段覆盖源账户配置)",
)
include_credentials: bool = Field(default=False, description="是否克隆凭据(默认 False")
# ---------------- Endpoints ----------------
@account_router.get("/accounts", response_model=dict)
async def list_accounts(
request: Request,
channel_type: ChannelType | None = Query(
default=None,
description="按渠道类型过滤",
),
limit: int = Query(default=1000, ge=1, le=1000, description="分页大小"),
offset: int = Query(default=0, ge=0, description="分页偏移"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""列出已配置的机器人账号AL-01
支持按渠道类型过滤与分页对应控制面操作 ``account/list``
``ChannelControlService.listAccounts`` 内部构造 ``ControlCmd``
委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.listAccounts(
channel_type=channel_type,
operator=operator,
limit=limit,
offset=offset,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/accounts/batch/enable", response_model=dict)
async def batch_enable_accounts(
payload: BatchStateChangeRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启用渠道账户ACC-BATCH-STATEaction=enable
支持 ``account_ids`` 显式列表或 ``filter`` 筛选条件两种模式二者
互斥 schema 校验逐条独立事务复用单条 ``enableAccount`` 逻辑
模式 D单条失败不回滚已成功条目幂等 active 直接计入成功
整体返回 ``success``部分失败信息在 ``data.failed`` 列表中客户端
需检查该列表对应控制面操作 ``account/batch_enable``
静态路径先于 ``/{channel_type}/accounts`` 声明避免被动态参数捕获
"""
operator = build_operator(current_user, request)
cmd = BatchStateChangeCmd(
action="enable",
operator=operator,
account_ids=tuple(payload.account_ids) if payload.account_ids else (),
filter=payload.filter,
reason=payload.reason,
)
result = await use_cases.account_management.batchEnableAccounts(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/accounts/batch/disable", response_model=dict)
async def batch_disable_accounts(
payload: BatchStateChangeRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量禁用渠道账户ACC-BATCH-STATEaction=disable
支持 ``account_ids`` 显式列表或 ``filter`` 筛选条件两种模式二者
互斥 schema 校验逐条独立事务复用单条 ``disableAccount`` 逻辑
模式 D单条失败不回滚已成功条目幂等 disabled 直接计入成功
整体返回 ``success``部分失败信息在 ``data.failed`` 列表中客户端
需检查该列表对应控制面操作 ``account/batch_disable``
静态路径先于 ``/{channel_type}/accounts`` 声明避免被动态参数捕获
"""
operator = build_operator(current_user, request)
cmd = BatchStateChangeCmd(
action="disable",
operator=operator,
account_ids=tuple(payload.account_ids) if payload.account_ids else (),
filter=payload.filter,
reason=payload.reason,
)
result = await use_cases.account_management.batchDisableAccounts(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/{channel_type}/accounts", response_model=dict)
async def create_account(
channel_type: ChannelType,
payload: CreateAccountRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建机器人账号AL-02
对应控制面操作 ``account/create`` ``ChannelControlService.createAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
``raw_config`` 由对应渠道插件的 ``LifecycleAdapter`` 校验
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.createAccount(
channel_type=channel_type,
raw_config=payload.raw_config,
display_name=payload.display_name,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.get("/{channel_type}/accounts/{account_id}", response_model=dict)
async def get_account(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查看账户详情AL-01。配置字段已脱敏。
对应控制面操作 ``account/get`` ``ChannelControlService.getAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.getAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.put("/{channel_type}/accounts/{account_id}", response_model=dict)
async def update_account(
channel_type: ChannelType,
account_id: str,
payload: UpdateAccountRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新账户配置AL-03
对应控制面操作 ``account/update`` ``ChannelControlService.updateAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
至少传入 ``display_name`` ``raw_config`` 之一 schema 校验
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.updateAccount(
channel_type=channel_type,
account_id=account_id,
raw_config=payload.raw_config,
display_name=payload.display_name,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.delete("/{channel_type}/accounts/{account_id}", response_model=dict)
async def delete_account(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""下线并删除账户AL-04。删除前触发 beforeAccountDelete 回调。
对应控制面操作 ``account/delete`` ``ChannelControlService.deleteAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
删除成功后 ``result.data`` 可能为 ``None``统一返回 ``null``
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.deleteAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/{channel_type}/accounts/{account_id}/enable", response_model=dict)
async def enable_account(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""启用账户ACC-STATE-01。幂等已 active 直接返回成功。
对应控制面操作 ``account/enable`` ``ChannelControlService.enableAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.enableAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/{channel_type}/accounts/{account_id}/disable", response_model=dict)
async def disable_account(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""禁用账户ACC-STATE-02。幂等已 disabled 直接返回成功。
对应控制面操作 ``account/disable`` ``ChannelControlService.disableAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.disableAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post("/{channel_type}/accounts/{account_id}/recover", response_model=dict)
async def recover_account(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""恢复降级账户ACC-STATE-03。幂等已 active 直接返回成功。
对应控制面操作 ``account/recover`` ``ChannelControlService.recoverAccount``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
业务用途DEGRADED 状态的账户无法通过 ``enable`` 恢复聚合根
``enable()`` DEGRADED 状态抛 RuleViolationError必须通过本端点恢复
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.recoverAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.get(
"/{channel_type}/accounts/{account_id}/runtime-status",
response_model=dict,
)
async def get_account_runtime_status(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询账户运行时状态AC-32
返回账户状态机熔断器状态登录态降级标记对应控制面操作
``account/runtime_status`` ``ChannelControlService.getAccountRuntimeStatus``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.getAccountRuntimeStatus(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post(
"/{channel_type}/accounts/{account_id}/rotate-credentials",
response_model=dict,
)
async def rotate_credentials(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""轮换渠道账户凭据ACC-ROTATE-01
触发渠道账户凭据轮换流程用于合规性凭据更新场景
对应控制面操作 ``account/rotate_credentials``
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.rotateCredentials(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post(
"/{channel_type}/accounts/{account_id}/test-connection",
response_model=dict,
)
async def test_connection(
channel_type: ChannelType,
account_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""测试渠道账户连通性ACC-TEST-01
验证账户凭据有效性与API连通性返回连通性检查结果
对应控制面操作 ``account/test_connection``
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.testConnection(
channel_type=channel_type,
account_id=account_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.get(
"/{channel_type}/accounts/{account_id}/export",
response_model=dict,
)
async def export_account(
channel_type: ChannelType,
account_id: str,
request: Request,
include_secrets: bool = Query(
default=False,
description="是否包含敏感凭据True 时需超级管理员权限)",
),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""导出账户配置ACC-EXPORT
查询账户配置并按 ``include_secrets`` 决定是否脱敏``include_secrets=False``
时经 MaskingPort 遮蔽敏感字段``include_secrets=True`` 时需超级管理员
权限返回含凭据的原始配置对应控制面操作 ``account/export``
"""
operator = build_operator(current_user, request)
result = await use_cases.account_management.exportAccount(
channel_type=channel_type,
account_id=account_id,
operator=operator,
include_secrets=include_secrets,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@account_router.post(
"/{channel_type}/accounts/{account_id}/clone",
response_model=dict,
)
async def clone_account(
channel_type: ChannelType,
account_id: str,
payload: CloneAccountRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""克隆账户ACC-CLONE
基于源账户配置创建新账户查询源账户 调用 ``ChannelAccount.clone()``
聚合根方法 持久化 触发 ``afterAccountConfigWritten`` 回调
best-effort新账户默认为 DISABLED 状态需手动 enable对应
控制面操作 ``account/clone``
协议翻译路径参数 ``channel_type`` / ``account_id``源账户 body
字段组合为 ``CloneAccountCmd``
"""
operator = build_operator(current_user, request)
cmd = CloneAccountCmd(
channel_type=channel_type,
account_id=account_id,
new_display_name=payload.new_display_name,
operator=operator,
new_raw_config_overrides=payload.new_raw_config_overrides,
include_credentials=payload.include_credentials,
)
result = await use_cases.account_management.cloneAccount(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}