"""账户资源域 Router(AL-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-STATE,action=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-STATE,action=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)}