diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index c650fbb5b..b233d0de0 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -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 diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index fbcd9bf28..eee00ea5f 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -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")) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 9f53a1b8e..1fc35100c 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -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() diff --git a/app/chain/notification.py b/app/chain/notification.py index 2b0056464..3ce290d18 100644 --- a/app/chain/notification.py +++ b/app/chain/notification.py @@ -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": ..., ...} """ diff --git a/app/chain/storage.py b/app/chain/storage.py index a8138d441..6f9c2333c 100644 --- a/app/chain/storage.py +++ b/app/chain/storage.py @@ -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: """ 检查是否蓝光目录 diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 4d6bd86bc..895d30298 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -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, diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index 20e0f32d0..d6a8f5734 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -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} diff --git a/app/schemas/common.py b/app/schemas/common.py index 592215ac1..ab0f11f56 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -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="表单与动作参数,透传给目标模块") diff --git a/app/schemas/types.py b/app/schemas/types.py index dfa05dd49..943504a4c 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -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 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 31aa20763..7ae7d643c 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -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 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 3a709c48e..94a353ff9 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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) diff --git a/tests/test_api_response.py b/tests/test_api_response.py index 0529cdd6d..b88002782 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -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 = [] diff --git a/tests/test_notification_channel_manage.py b/tests/test_notification_channel_manage.py index e92f8004f..7bfe4c39a 100644 --- a/tests/test_notification_channel_manage.py +++ b/tests/test_notification_channel_manage.py @@ -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} \ No newline at end of file diff --git a/tests/test_storage_manage.py b/tests/test_storage_manage.py new file mode 100644 index 000000000..d804e4deb --- /dev/null +++ b/tests/test_storage_manage.py @@ -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"]