mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-19 14:04:17 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": ..., ...}
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
检查是否蓝光目录
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="表单与动作参数,透传给目标模块")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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}
|
||||
134
tests/test_storage_manage.py
Normal file
134
tests/test_storage_manage.py
Normal 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"]
|
||||
Reference in New Issue
Block a user