refactor(api): 渠道与存储管理端点通用化,端点层零目标特色

- schemas 新增公共 ManageRequest(target+action+params) 与 StorageAction 词汇表
- endpoint 层收敛为 POST /notification/manage、/storage/manage 两个通用接口,
  不再定义任何渠道/存储特定的名称、参数与响应字段,前端上送参数原样透传
- chain 层 manage_channel/manage_storage 接受字符串标识纯透明转发,
  StorageChain 移除全部特色管理方法
- FileManagerModule 新增 storage_manage 统一入口,模块返回归一化为
  {success, message, data};wechatclawbot channel_manage 同步归一化,
  路由标识兼容枚举名/值/对象
- dashboard 与 agentopsassistant 的用量查询改走 manage_storage(usage)
- 新增 10 项存储契约守护测试,通知侧补充 3 项链透传与字符串路由测试,
  API 守护测试对 ManageRequest 开放映射按设计豁免
This commit is contained in:
jxxghp
2026-08-16 07:18:49 +08:00
parent 5b0c631f80
commit a6dbd799d5
14 changed files with 354 additions and 380 deletions

View File

@@ -13,6 +13,7 @@ from app.application.security.access import verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.api.deps import get_current_active_superuser
from app.schemas.types import StorageAction
from app.application.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.adapters.system.host import SystemUtils
@@ -65,7 +66,8 @@ def _build_storage() -> schemas.Storage:
return schemas.Storage(total_storage=total, used_storage=total - available)
storages = set([d.library_storage for d in dirs if d.library_storage])
for _storage in storages:
_usage = StorageChain().storage_usage(_storage)
_result = StorageChain().manage_storage(storage=_storage, action=StorageAction.USAGE.value)
_usage = _result.get("data") if _result.get("success") else None
if _usage:
total += _usage.total
available += _usage.available

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Any, Dict
from fastapi import Depends
@@ -7,157 +7,32 @@ from app.api.response import ResponseAPIRouter
from app.chain.notification import NotificationChain
from app.db.models import User
from app.api.deps import get_current_active_superuser
from app.schemas.types import MessageChannel, NotificationAction
router = ResponseAPIRouter()
@router.get(
"/wechatclawbot/status",
summary="查询微信 ClawBot 登录状态",
response_model=schemas.Response[schemas.WechatClawBotData],
@router.post(
"/manage",
summary="通知渠道统一管理",
response_model=schemas.Response[Dict[str, Any]],
)
def wechatclawbot_status(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
refresh_remote: bool = True,
auto_generate_qrcode: bool = True,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
def manage_channel(
request: schemas.ManageRequest,
_: User = Depends(get_current_active_superuser),
):
"""查询微信 ClawBot 登录状态和二维码。"""
"""
通知渠道统一管理入口
端点层不定义任何渠道特定的名称与参数,
渠道标识、管理动作与表单参数由前端上送并原样透传给渠道模块
"""
result = NotificationChain().manage_channel(
channel=MessageChannel.WechatClawBot,
action=NotificationAction.STATUS,
source=source,
fallback_source=fallback_source,
refresh_remote=refresh_remote,
auto_generate_qrcode=auto_generate_qrcode,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
channel=request.target,
action=request.action,
**request.params,
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result if result.get("success") else None,
data=result.get("data"),
)
@router.post(
"/wechatclawbot/refresh",
summary="刷新微信 ClawBot 二维码",
response_model=schemas.Response[schemas.WechatClawBotData],
)
def refresh_wechatclawbot_qrcode(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""刷新微信 ClawBot 二维码。"""
result = NotificationChain().manage_channel(
channel=MessageChannel.WechatClawBot,
action=NotificationAction.REFRESH_QRCODE,
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result,
)
@router.post(
"/wechatclawbot/logout",
summary="退出微信 ClawBot 登录",
response_model=schemas.Response[schemas.WechatClawBotData],
)
def logout_wechatclawbot(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""退出微信 ClawBot 登录。"""
result = NotificationChain().manage_channel(
channel=MessageChannel.WechatClawBot,
action=NotificationAction.LOGOUT,
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result,
)
@router.get(
"/wechatclawbot/test",
summary="测试微信 ClawBot 连通性",
response_model=schemas.Response[None],
)
def test_wechatclawbot(
source: Optional[str] = None,
fallback_source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
_: User = Depends(get_current_active_superuser),
):
"""测试微信 ClawBot 当前登录态是否可用。"""
result = NotificationChain().manage_channel(
channel=MessageChannel.WechatClawBot,
action=NotificationAction.TEST_CONNECTION,
source=source,
fallback_source=fallback_source,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
)
return schemas.Response(success=bool(result.get("success")), message=result.get("message"))
@router.post(
"/wechatclawbot/migrate",
summary="迁移微信 ClawBot 登录缓存",
response_model=schemas.Response[None],
)
def migrate_wechatclawbot_cache(
old_source: str,
new_source: str,
cleanup_old: bool = False,
overwrite: bool = False,
_: User = Depends(get_current_active_superuser),
):
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
result = NotificationChain().manage_channel(
channel=MessageChannel.WechatClawBot,
action=NotificationAction.MIGRATE_CACHE,
old_name=old_source,
new_name=new_source,
cleanup_old=cleanup_old,
overwrite=overwrite,
)
return schemas.Response(success=bool(result.get("success")), message=result.get("message"))

View File

@@ -2,7 +2,7 @@ import fnmatch
import math
import re
from pathlib import Path
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional
from fastapi import Depends, HTTPException
from starlette.responses import FileResponse, Response
@@ -13,12 +13,10 @@ from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.runtime.config import settings
from app.application.security.access import verify_token
from app.db.models import User
from app.api.deps import (
get_current_active_manage_user,
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.runtime.progress import ProgressHelper
from app.schemas.types import ProgressKey
@@ -27,75 +25,28 @@ from app.foundation import text as text_tools
router = ResponseAPIRouter()
@router.get(
"/qrcode/{name}",
summary="生成二维码内容",
response_model=schemas.Response[schemas.StorageQrCodeData],
@router.post(
"/manage", summary="网盘存储统一管理", response_model=schemas.Response[Dict[str, Any]]
)
def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data, errmsg = StorageChain().generate_qrcode(name)
if qrcode_data:
return schemas.Response(success=True, data=qrcode_data, message=errmsg)
return schemas.Response(success=False, message=errmsg)
@router.get(
"/auth_url/{name}",
summary="获取 OAuth2 授权 URL",
response_model=schemas.Response[schemas.StorageAuthUrlData],
)
def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取 OAuth2 授权 URL
"""
auth_data, errmsg = StorageChain().generate_auth_url(name)
if auth_data:
return schemas.Response(success=True, data=auth_data)
return schemas.Response(success=False, message=errmsg)
@router.get(
"/check/{name}",
summary="二维码登录确认",
response_model=schemas.Response[schemas.StorageLoginStatusData],
)
def check(
name: str,
ck: Optional[str] = None,
t: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
def manage(
request: schemas.ManageRequest, _: User = Depends(get_current_active_superuser)
) -> Any:
"""
二维码登录确认
"""
if ck or t:
data, errmsg = StorageChain().check_login(name, ck=ck, t=t)
else:
data, errmsg = StorageChain().check_login(name)
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
网盘存储统一管理入口
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response[None])
def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any:
端点层不定义任何存储特定的名称与参数,
存储标识、管理动作与表单参数由前端上送并原样透传给存储模块
"""
保存存储配置
"""
StorageChain().save_config(name, conf)
return schemas.Response(success=True)
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response[None])
def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
"""
重置存储配置
"""
StorageChain().reset_config(name)
return schemas.Response(success=True)
result = StorageChain().manage_storage(
storage=request.target,
action=request.action,
**request.params,
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
)
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
@@ -293,33 +244,3 @@ def rename(
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get(
"/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage
)
def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
"""
查询存储空间
"""
ret = StorageChain().storage_usage(name)
if ret:
return ret
return schemas.StorageUsage()
@router.get(
"/transtype/{name}",
summary="支持的整理方式获取",
response_model=schemas.StorageTransType,
)
async def transtype(
name: str, _: User = Depends(get_current_active_superuser_async)
) -> Any:
"""
查询支持的整理方式
"""
ret = StorageChain().support_transtype(name)
if ret:
return schemas.StorageTransType(transtype=ret)
return schemas.StorageTransType()

View File

@@ -1,28 +1,27 @@
from typing import Any, Dict
from app.chain import ChainBase
from app.schemas.types import MessageChannel, NotificationAction
class NotificationChain(ChainBase):
"""
通知渠道管理链,仅按渠道名透明转发管理动作到模块
不包含任何渠道特定逻辑:动作语义、表单参数解释、客户端实例解析与
临时参数初始化全部封闭在实现 channel_manage 契约的模块内部
不包含任何渠道特定逻辑:渠道标识、动作语义、表单参数解释、客户端实例
解析与临时参数初始化全部封闭在实现 channel_manage 契约的模块内部
"""
def manage_channel(
self,
channel: MessageChannel,
action: NotificationAction,
channel: str,
action: str,
**params: Any,
) -> Dict[str, Any]:
"""
对指定通知渠道执行管理动作
:param channel: 渠道标识,用于模块路由
:param action: 通用管理动作,具体语义由渠道模块解释
:param action: 通用管理动作标识,具体语义由渠道模块解释
:param params: 表单与动作参数,原样透传给模块
:return: 统一结构 {"success": bool, "message": ..., ...}
"""

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, Tuple, List, Dict
from typing import Any, Optional, List, Dict
from app import schemas
from app.chain import ChainBase
@@ -13,35 +13,20 @@ class StorageChain(ChainBase):
存储处理链
"""
def save_config(self, storage: str, conf: dict) -> None:
def manage_storage(self, storage: str, action: str, **params: Any) -> Dict[str, Any]:
"""
保存存储配置
"""
self.run_module("save_config", storage=storage, conf=conf)
对指定网盘存储执行管理动作
def reset_config(self, storage: str) -> None:
"""
重置存储配置
"""
self.run_module("reset_config", storage=storage)
不包含任何存储特定逻辑:存储标识、动作语义与参数解释全部封闭在
实现 storage_manage 契约的模块内部
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
:param storage: 存储类型标识,用于模块路由
:param action: 通用管理动作标识,具体语义由存储实现解释
:param params: 表单与动作参数,原样透传给模块
:return: 统一结构 {"success": bool, "message": ..., "data": ...}
"""
生成二维码
"""
return self.run_module("generate_qrcode", storage=storage)
def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:
"""
生成 OAuth2 授权 URL
"""
return self.run_module("generate_auth_url", storage=storage)
def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:
"""
登录确认
"""
return self.run_module("check_login", storage=storage, **kwargs)
result = self.run_module("storage_manage", storage=storage, action=action, **params)
return result or {"success": False, "message": "该存储类型未启用或不支持此管理动作"}
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
"""
@@ -136,18 +121,6 @@ class StorageChain(ChainBase):
last_snapshot_time=last_snapshot_time, max_depth=max_depth,
previous_snapshot=previous_snapshot)
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
"""
存储使用情况
"""
return self.run_module("storage_usage", storage=storage)
def support_transtype(self, storage: str) -> Optional[dict]:
"""
获取支持的整理方式
"""
return self.run_module("support_transtype", storage=storage)
def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool:
"""
检查是否蓝光目录

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, List, Tuple, Union, Dict, Callable
from typing import Any, Optional, List, Tuple, Union, Dict, Callable
from app.runtime.config import settings
from app.domain.context import MediaInfo, MusicInfo
@@ -13,8 +13,8 @@ from app.runtime.log import logger
from app.modules import _ModuleBase
from app.modules.filemanager.storages import StorageBase
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType, StorageAction
from app.adapters.system.host import SystemUtils
from app.foundation import text as text_tools
@@ -122,17 +122,55 @@ class FileManagerModule(_ModuleBase):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def support_transtype(self, storage: str) -> Optional[dict]:
def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]:
"""
支持的整理方式
网盘存储统一管理入口,按存储标识路由
动作语义与参数解释交给具体存储实现,
统一返回 {"success": bool, "message": ..., "data": ...}
"""
try:
action = StorageAction(action)
except ValueError:
return {"success": False, "message": f"不支持的存储管理动作:{action}"}
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
return {"success": False, "message": f"不支持的存储类型:{storage}"}
if action == StorageAction.SAVE_CONFIG:
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
return {"success": False, "message": f"不支持 {storage} 的配置保存"}
storage_oper.set_config(params.get("conf") or {})
return {"success": True}
if action == StorageAction.RESET_CONFIG:
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
return {"success": False, "message": f"不支持 {storage} 的重置存储配置"}
storage_oper.reset_config()
return {"success": True}
if action == StorageAction.SUPPORT_TRANSTYPE:
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
return {"success": False, "message": f"不支持 {storage} 的整理方式获取"}
transtype = storage_oper.support_transtype()
return {"success": bool(transtype), "data": transtype}
if action == StorageAction.USAGE:
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
return {"success": False, "message": f"不支持 {storage} 的存储使用情况"}
usage = storage_oper.usage()
return {"success": bool(usage), "data": usage}
# 登录类动作:存储实现不支持时返回失败信息
oper_method = action.value
storage_oper = self.__get_storage_oper(storage, oper_method)
if not storage_oper:
logger.error(f"不支持 {storage} 的整理方式获取")
return None
return storage_oper.support_transtype()
return {"success": False, "message": f"{storage} 不支持 {oper_method}"}
result = getattr(storage_oper, oper_method)(**params)
if result is None:
return {"success": False, "message": f"{storage}{oper_method} 执行失败"}
data, errmsg = result
return {"success": bool(data), "message": errmsg, "data": data}
@staticmethod
def recommend_name(meta: MetaBase, mediainfo: MediaInfo,
@@ -157,56 +195,6 @@ class FileManagerModule(_ModuleBase):
)
return path.as_posix() if path else ""
def save_config(self, storage: str, conf: Dict) -> None:
"""
保存存储配置
"""
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的配置保存")
return
storage_oper.set_config(conf)
def reset_config(self, storage: str) -> None:
"""
重置存储配置
"""
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的重置存储配置")
return
storage_oper.reset_config()
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
"""
生成二维码
"""
storage_oper = self.__get_storage_oper(storage, "generate_qrcode")
if not storage_oper:
logger.error(f"不支持 {storage} 的二维码生成")
return None
return storage_oper.generate_qrcode()
def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:
"""
生成 OAuth2 授权 URL
"""
storage_oper = self.__get_storage_oper(storage, "generate_auth_url")
if not storage_oper:
logger.error(f"不支持 {storage} 的 OAuth2 授权")
return {}, f"不支持 {storage} 的 OAuth2 授权"
return storage_oper.generate_auth_url()
def check_login(self, storage: str, **kwargs) -> Optional[Dict[str, str]]:
"""
登录确认
"""
storage_oper = self.__get_storage_oper(storage, "check_login")
if not storage_oper:
logger.error(f"不支持 {storage} 的登录确认")
return None
return storage_oper.check_login(**kwargs)
def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]:
"""
浏览文件
@@ -397,18 +385,6 @@ class FileManagerModule(_ModuleBase):
previous_snapshot=previous_snapshot
)
def storage_usage(self, storage: str) -> Optional[StorageUsage]:
"""
存储使用情况
"""
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的存储使用情况")
return None
return storage_oper.usage()
def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,
target_directory: TransferDirectoryConf = None,
target_storage: Optional[str] = None, target_path: Path = None,

View File

@@ -93,9 +93,12 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
动作语义与表单参数全部由模块自行解释:优先使用已保存配置实例,
无匹配配置时可基于表单参数构造临时实例(未保存配置的扫码预览)。
统一返回 {"success": bool, "message": ..., ...} 结构。
统一返回 {"success": bool, "message": ..., "data": ...} 结构。
"""
if channel != self.get_subtype():
# 路由标识归一化:兼容枚举名、枚举值与原始枚举对象
if isinstance(channel, str) and channel not in (self.get_subtype().name, self.get_subtype().value):
return None
if not isinstance(channel, str) and channel != self.get_subtype():
return None
try:
action = NotificationAction(action)
@@ -116,14 +119,17 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
return {"success": False, "message": errmsg}
if action == NotificationAction.STATUS:
return client.get_status(
data = client.get_status(
refresh_remote=bool(params.get("refresh_remote", True)),
auto_generate_qrcode=bool(params.get("auto_generate_qrcode", True)),
)
return {"success": bool(data.get("success")), "message": data.get("message"), "data": data}
if action == NotificationAction.REFRESH_QRCODE:
return client.refresh_qrcode()
data = client.refresh_qrcode()
return {"success": bool(data.get("success")), "message": data.get("message"), "data": data}
if action == NotificationAction.LOGOUT:
return client.logout()
data = client.logout()
return {"success": bool(data.get("success")), "message": data.get("message"), "data": data}
if action == NotificationAction.TEST_CONNECTION:
state, message = client.test_connection()
return {"success": state, "message": message}

View File

@@ -1,6 +1,6 @@
"""API 端点共享的小型业务数据模型。"""
from typing import Optional, Union
from typing import Any, Dict, Optional, Union
from pydantic import BaseModel, Field, RootModel
from typing_extensions import TypeAliasType
@@ -76,3 +76,15 @@ class TimeData(BaseModel):
"""网络请求耗时。"""
time: int | float = Field(description="耗时毫秒数")
class ManageRequest(BaseModel):
"""通用管理请求:目标标识 + 管理动作 + 透传参数
用于通知渠道、网盘存储等统一管理能力入口,
动作与参数的具体语义由目标模块自行解释
"""
target: str = Field(description="管理目标标识,如渠道名或存储类型")
action: str = Field(description="管理动作标识")
params: Dict[str, Any] = Field(default_factory=dict, description="表单与动作参数,透传给目标模块")

View File

@@ -509,6 +509,28 @@ class NotificationAction(str, Enum):
MIGRATE_CACHE = "migrate_cache"
class StorageAction(str, Enum):
"""
网盘存储通用管理动作
作为存储管理契约的公共词汇表,具体动作的支持范围与参数语义由存储实现自行解释
"""
# 保存存储配置
SAVE_CONFIG = "save_config"
# 重置存储配置
RESET_CONFIG = "reset_config"
# 生成登录二维码
GENERATE_QRCODE = "generate_qrcode"
# 生成 OAuth2 授权 URL
GENERATE_AUTH_URL = "generate_auth_url"
# 登录确认
CHECK_LOGIN = "check_login"
# 查询存储空间用量
USAGE = "usage"
# 查询支持的整理方式
SUPPORT_TRANSTYPE = "support_transtype"
# 下载器类型
class DownloaderType(Enum):
# Qbittorrent

View File

@@ -212,14 +212,23 @@ exceptions and value domains used by both modules and upper layers live in
method names. The directory remains unchanged because discovery and plugin code
depend on this established runtime root.
Channels that need login management or temporary-parameter initialization
follow one generic contract instead of per-channel APIs: modules implement
`channel_manage(channel, action, **params)`, route by the requested
`MessageChannel` (returning `None` for other channels), and interpret actions
from the shared `schemas.types.NotificationAction` vocabulary plus opaque form
parameters themselves. `NotificationChain.manage_channel` forwards transparently
and must stay free of any channel-specific names or logic; new channels adopt
the same contract without touching the chain.
Channels and storages that need login management or temporary-parameter
initialization follow one generic contract instead of per-target APIs: modules
implement `channel_manage(channel, action, **params)` or
`storage_manage(storage, action, **params)`, route by the requested target
identifier (returning `None` for other targets, accepting both enum members
and plain strings), and interpret actions from the shared
`schemas.types.NotificationAction` / `StorageAction` vocabulary plus opaque
form parameters themselves. All results use the unified
`{"success": bool, "message": ..., "data": ...}` shape.
`NotificationChain.manage_channel` and `StorageChain.manage_storage` forward
transparently and must stay free of any channel/storage-specific names or
logic; new channels or storages adopt the same contract without touching the
chains. The endpoint layer exposes this as two generic endpoints
(`POST /api/v1/notification/manage`, `POST /api/v1/storage/manage`) taking the
common `schemas.ManageRequest` body (`target` + `action` + `params`) and must
never define target-specific names, parameters or response fields — the
frontend supplies them and the endpoint passes them through untouched.
### DB / Oper layer

View File

@@ -329,23 +329,23 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| GET | `/api/v1/mediaserver/library` | Library list. Params: `server` (required), `hidden` |
| GET | `/api/v1/mediaserver/clients` | Available media servers |
### Storage / Files (13 endpoints)
### Notification (1 endpoint)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/notification/manage` | Unified notification-channel management. Body: ManageRequest JSON `{target, action, params}`; `target` is the channel name, `action` is one of `status`, `refresh_qrcode`, `logout`, `test_connection`, `migrate_cache`, `params` carries channel-specific form fields passed through to the channel module |
### Storage / Files (7 endpoints)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/storage/manage` | Unified storage management. Body: ManageRequest JSON `{target, action, params}`; `target` is the storage type, `action` is one of `save_config` (config in `params.conf`), `reset_config`, `generate_qrcode`, `generate_auth_url`, `check_login` (`params.ck`/`params.t`), `usage`, `support_transtype` |
| POST | `/api/v1/storage/list` | List directory contents. Params: `sort`. Body: FileItem JSON |
| POST | `/api/v1/storage/mkdir` | Create directory. Params: `name` (required). Body: FileItem |
| POST | `/api/v1/storage/delete` | Delete file or directory. Body: FileItem JSON |
| POST | `/api/v1/storage/download` | Download file. Body: FileItem JSON |
| POST | `/api/v1/storage/image` | Preview image. Body: FileItem JSON |
| POST | `/api/v1/storage/rename` | Rename file/dir. Params: `new_name` (required), `recursive`. Body: FileItem |
| GET | `/api/v1/storage/usage/{name}` | Storage usage info |
| GET | `/api/v1/storage/transtype/{name}` | Supported transfer types |
| GET | `/api/v1/storage/qrcode/{name}` | Generate QR code for auth |
| GET | `/api/v1/storage/auth_url/{name}` | Get OAuth2 auth URL |
| GET | `/api/v1/storage/check/{name}` | Confirm QR login. Params: `ck`, `t` |
| POST | `/api/v1/storage/save/{name}` | Save storage config. Body: JSON object |
| GET | `/api/v1/storage/reset/{name}` | Reset storage config |
### Transfer (7 endpoints)

View File

@@ -633,6 +633,10 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas():
# 分类规则与 CookieCloud 解密载荷按设计接受扩展键。
"CategoryRule",
"CookieDecryptedPayload",
# 通用管理请求的 params 按设计透传模块个性化参数。
"ManageRequest",
# 通用管理响应的 data 为模块自定义结构,按设计不固定字段。
"Response_Dict_str__Any__",
}
allowed_empty_components = {"McpJsonRpcEmptyResult"}
violations = []

View File

@@ -10,6 +10,7 @@ from types import SimpleNamespace
import pytest
from app.chain.notification import NotificationChain
from app.modules.wechatclawbot import WechatClawBotModule
from app.schemas.types import MessageChannel, NotificationAction
@@ -95,7 +96,7 @@ def test_channel_manage_prefers_saved_instance(module, monkeypatch):
source="已保存",
)
assert result["success"] is True
assert result["connected"] is True
assert result["data"]["connected"] is True
def test_channel_manage_migrate_cache_dispatches_without_client(module, monkeypatch):
@@ -118,3 +119,43 @@ def test_channel_manage_migrate_cache_dispatches_without_client(module, monkeypa
new_name="新名",
)
assert result == {"success": True, "message": "迁移成功"}
def test_notification_chain_forwards_target_action_and_params(monkeypatch):
"""链层接受字符串标识与透传参数,按 channel_manage 契约原样转发。"""
captured = {}
def fake_run_module(self, method, **kwargs):
captured.update(method=method, kwargs=kwargs)
return {"success": True, "data": {"connected": True}}
monkeypatch.setattr(NotificationChain, "run_module", fake_run_module)
chain = NotificationChain.__new__(NotificationChain)
result = chain.manage_channel(channel="WechatClawBot", action="status", source="预览")
assert captured["method"] == "channel_manage"
assert captured["kwargs"] == {
"channel": "WechatClawBot",
"action": "status",
"source": "预览",
}
assert result["success"] is True
def test_notification_chain_reports_missing_module(monkeypatch):
"""无模块实现 channel_manage 时返回统一失败结构。"""
monkeypatch.setattr(NotificationChain, "run_module", lambda self, method, **kwargs: None)
chain = NotificationChain.__new__(NotificationChain)
result = chain.manage_channel(channel="unknown", action="status")
assert result["success"] is False
assert result["message"]
def test_channel_manage_accepts_plain_string_identifiers(module, monkeypatch):
"""端点层透传的原始字符串渠道名与动作名可被模块正确路由与解释。"""
saved = SimpleNamespace()
saved.test_connection = lambda: (True, None)
monkeypatch.setattr(module, "get_instance", lambda name=None: saved)
result = module.channel_manage(channel="WechatClawBot", action="test_connection")
assert result == {"success": True, "message": None}

View File

@@ -0,0 +1,134 @@
"""
网盘存储通用管理契约storage_manage守护测试
验证与通知渠道一致的通用模式:
1. 链层 manage_storage 只透明转发存储标识、动作与参数,不做任何存储特定处理
2. 模块按存储标识路由,动作语义与参数解释封闭在模块内
3. 端点层的 ManageRequest 通用请求结构target + action + params
"""
from types import SimpleNamespace
import pytest
from app import schemas
from app.chain.storage import StorageChain
from app.modules.filemanager import FileManagerModule
class _FakeStorageOper:
"""记录管理动作调用情况的假存储实现"""
schema = SimpleNamespace(value="fakestore")
calls = []
def set_config(self, conf):
_FakeStorageOper.calls.append(("set_config", conf))
def reset_config(self):
_FakeStorageOper.calls.append(("reset_config", None))
def usage(self):
return schemas.StorageUsage(total=100, available=40)
def support_transtype(self):
return {"move": True}
def check_login(self, **kwargs):
_FakeStorageOper.calls.append(("check_login", kwargs))
return {"status": True}, None
@pytest.fixture
def module(monkeypatch):
_FakeStorageOper.calls.clear()
module = FileManagerModule()
monkeypatch.setattr(module, "_support_storages", ["fakestore"])
monkeypatch.setattr(module, "_storage_schemas", [_FakeStorageOper])
return module
def test_manage_request_schema():
"""ManageRequest 仅定义目标标识、动作标识与透传参数,无任何特定领域字段。"""
request = schemas.ManageRequest(target="fakestore", action="usage")
assert request.target == "fakestore"
assert request.action == "usage"
assert request.params == {}
def test_storage_chain_forwards_target_action_and_params(monkeypatch):
"""链层按 storage_manage 契约原样透传,不引入存储特定逻辑。"""
captured = {}
def fake_run_module(self, method, **kwargs):
captured.update(method=method, kwargs=kwargs)
return {"success": True, "data": {"total": 100}}
monkeypatch.setattr(StorageChain, "run_module", fake_run_module)
chain = StorageChain.__new__(StorageChain)
result = chain.manage_storage(storage="fakestore", action="usage", extra="value")
assert captured["method"] == "storage_manage"
assert captured["kwargs"] == {"storage": "fakestore", "action": "usage", "extra": "value"}
assert result["success"] is True
def test_storage_chain_reports_missing_module(monkeypatch):
"""无模块实现 storage_manage 时返回统一失败结构。"""
monkeypatch.setattr(StorageChain, "run_module", lambda self, method, **kwargs: None)
chain = StorageChain.__new__(StorageChain)
result = chain.manage_storage(storage="unknown", action="usage")
assert result["success"] is False
assert result["message"]
def test_storage_manage_rejects_unknown_action(module):
"""动作词汇表之外的请求返回统一错误结构。"""
result = module.storage_manage(storage="fakestore", action="not_an_action")
assert result["success"] is False
assert "不支持" in result["message"]
def test_storage_manage_rejects_unknown_storage(module):
"""未注册的存储标识直接返回错误,不进入动作分发。"""
result = module.storage_manage(storage="unknown_store", action="usage")
assert result["success"] is False
assert "不支持的存储类型" in result["message"]
def test_storage_manage_save_config_passes_conf_through(module):
"""save_config 动作将 params.conf 原样交给存储实现持久化。"""
result = module.storage_manage(
storage="fakestore", action="save_config", conf={"token": "abc"}
)
assert result["success"] is True
assert ("set_config", {"token": "abc"}) in _FakeStorageOper.calls
def test_storage_manage_usage_returns_oper_data(module):
"""usage 动作返回存储实现的用量数据。"""
result = module.storage_manage(storage="fakestore", action="usage")
assert result["success"] is True
assert result["data"].total == 100
assert result["data"].available == 40
def test_storage_manage_support_transtype(module):
"""support_transtype 动作返回存储支持的整理方式。"""
result = module.storage_manage(storage="fakestore", action="support_transtype")
assert result["success"] is True
assert result["data"] == {"move": True}
def test_storage_manage_login_action_forwards_params(module):
"""登录类动作透传表单参数并归一化元组返回值。"""
result = module.storage_manage(storage="fakestore", action="check_login", ck="ck1", t="t1")
assert result["success"] is True
assert result["data"] == {"status": True}
assert ("check_login", {"ck": "ck1", "t": "t1"}) in _FakeStorageOper.calls
def test_storage_manage_reports_unsupported_login_action(module):
"""存储实现未提供对应登录方法时返回明确失败信息。"""
result = module.storage_manage(storage="fakestore", action="generate_qrcode")
assert result["success"] is False
assert "不支持" in result["message"]