mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-08 09:06:50 +08:00
feat: normalize adapter types and improve validation in adapters
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
from models import StorageAdapter
|
from models import StorageAdapter
|
||||||
from schemas import AdapterCreate, AdapterOut
|
from schemas import AdapterCreate, AdapterOut
|
||||||
from services.auth import get_current_active_user, User
|
from services.auth import get_current_active_user, User
|
||||||
from services.adapters.registry import runtime_registry, get_config_schemas
|
from services.adapters.registry import runtime_registry, get_config_schemas, normalize_adapter_type
|
||||||
from api.response import success
|
from api.response import success
|
||||||
from services.logging import LogService
|
from services.logging import LogService
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ router = APIRouter(prefix="/api/adapters", tags=["adapters"])
|
|||||||
|
|
||||||
def validate_and_normalize_config(adapter_type: str, cfg):
|
def validate_and_normalize_config(adapter_type: str, cfg):
|
||||||
schemas = get_config_schemas()
|
schemas = get_config_schemas()
|
||||||
|
adapter_type = normalize_adapter_type(adapter_type)
|
||||||
|
if not adapter_type:
|
||||||
|
raise HTTPException(400, detail="不支持的适配器类型")
|
||||||
if not isinstance(cfg, dict):
|
if not isinstance(cfg, dict):
|
||||||
raise HTTPException(400, detail="config 必须是对象")
|
raise HTTPException(400, detail="config 必须是对象")
|
||||||
schema = schemas.get(adapter_type)
|
schema = schemas.get(adapter_type)
|
||||||
@@ -77,17 +80,10 @@ async def list_adapters(
|
|||||||
async def available_adapter_types(
|
async def available_adapter_types(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
name_map = {
|
|
||||||
"local": "本地文件系统",
|
|
||||||
"webdav": "WebDAV",
|
|
||||||
"GoogleDrive": "Google Drive",
|
|
||||||
"OneDrive": "OneDrive",
|
|
||||||
}
|
|
||||||
data = []
|
data = []
|
||||||
for t, fields in get_config_schemas().items():
|
for t, fields in get_config_schemas().items():
|
||||||
data.append({
|
data.append({
|
||||||
"type": t,
|
"type": t,
|
||||||
"name": name_map.get(t, t),
|
|
||||||
"config_schema": fields,
|
"config_schema": fields,
|
||||||
})
|
})
|
||||||
return success(data)
|
return success(data)
|
||||||
|
|||||||
+14
-1
@@ -1,15 +1,28 @@
|
|||||||
|
import re
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class AdapterBase(BaseModel):
|
class AdapterBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: str = Field(pattern=r"^[a-zA-Z0-9_]+$")
|
type: str = Field(pattern=r"^[a-z0-9_]+$")
|
||||||
config: Dict = Field(default_factory=dict)
|
config: Dict = Field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
path: str = None
|
path: str = None
|
||||||
sub_path: Optional[str] = None
|
sub_path: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("type", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_type(cls, v: str):
|
||||||
|
if not isinstance(v, str):
|
||||||
|
raise ValueError("type required")
|
||||||
|
normalized = v.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("type required")
|
||||||
|
if not re.fullmatch(r"[a-z0-9_]+", normalized):
|
||||||
|
raise ValueError("type must be lowercase alphanumeric or underscore")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
class AdapterCreate(AdapterBase):
|
class AdapterCreate(AdapterBase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ class GoogleDriveAdapter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
ADAPTER_TYPE = "GoogleDrive"
|
ADAPTER_TYPE = "googledrive"
|
||||||
|
|
||||||
CONFIG_SCHEMA = [
|
CONFIG_SCHEMA = [
|
||||||
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ class OneDriveAdapter:
|
|||||||
return self._format_item(resp.json())
|
return self._format_item(resp.json())
|
||||||
|
|
||||||
|
|
||||||
ADAPTER_TYPE = "OneDrive"
|
ADAPTER_TYPE = "onedrive"
|
||||||
|
|
||||||
CONFIG_SCHEMA = [
|
CONFIG_SCHEMA = [
|
||||||
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ class QuarkAdapter:
|
|||||||
return it["fid"]
|
return it["fid"]
|
||||||
|
|
||||||
|
|
||||||
ADAPTER_TYPE = "Quark"
|
ADAPTER_TYPE = "quark"
|
||||||
|
|
||||||
CONFIG_SCHEMA = [
|
CONFIG_SCHEMA = [
|
||||||
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
|
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ TYPE_MAP: Dict[str, AdapterFactory] = {}
|
|||||||
CONFIG_SCHEMAS: Dict[str, list] = {}
|
CONFIG_SCHEMAS: Dict[str, list] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_adapter_type(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
def discover_adapters():
|
def discover_adapters():
|
||||||
"""扫描 services.adapters 包, 自动注册适配器类型、工厂与配置 schema。"""
|
"""扫描 services.adapters 包, 自动注册适配器类型、工厂与配置 schema。"""
|
||||||
from .. import adapters as adapters_pkg
|
from .. import adapters as adapters_pkg
|
||||||
@@ -25,7 +32,7 @@ def discover_adapters():
|
|||||||
module = import_module(full_name)
|
module = import_module(full_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
adapter_type = getattr(module, "ADAPTER_TYPE", None)
|
adapter_type = normalize_adapter_type(getattr(module, "ADAPTER_TYPE", None))
|
||||||
schema = getattr(module, "CONFIG_SCHEMA", None)
|
schema = getattr(module, "CONFIG_SCHEMA", None)
|
||||||
factory = getattr(module, "ADAPTER_FACTORY", None)
|
factory = getattr(module, "ADAPTER_FACTORY", None)
|
||||||
|
|
||||||
@@ -64,7 +71,16 @@ class RuntimeRegistry:
|
|||||||
self._instances.clear()
|
self._instances.clear()
|
||||||
adapters = await StorageAdapter.filter(enabled=True)
|
adapters = await StorageAdapter.filter(enabled=True)
|
||||||
for rec in adapters:
|
for rec in adapters:
|
||||||
factory = TYPE_MAP.get(rec.type)
|
normalized_type = normalize_adapter_type(rec.type)
|
||||||
|
if not normalized_type:
|
||||||
|
continue
|
||||||
|
if normalized_type != rec.type:
|
||||||
|
rec.type = normalized_type
|
||||||
|
try:
|
||||||
|
await rec.save(update_fields=["type"])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
factory = TYPE_MAP.get(normalized_type)
|
||||||
if not factory:
|
if not factory:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -89,10 +105,21 @@ class RuntimeRegistry:
|
|||||||
self.remove(rec.id)
|
self.remove(rec.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
factory = TYPE_MAP.get(rec.type)
|
normalized_type = normalize_adapter_type(rec.type)
|
||||||
|
if not normalized_type:
|
||||||
|
self.remove(rec.id)
|
||||||
|
return
|
||||||
|
if normalized_type != rec.type:
|
||||||
|
rec.type = normalized_type
|
||||||
|
try:
|
||||||
|
await rec.save(update_fields=["type"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
factory = TYPE_MAP.get(normalized_type)
|
||||||
if not factory:
|
if not factory:
|
||||||
discover_adapters()
|
discover_adapters()
|
||||||
factory = TYPE_MAP.get(rec.type)
|
factory = TYPE_MAP.get(normalized_type)
|
||||||
if not factory:
|
if not factory:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ class S3Adapter:
|
|||||||
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
ADAPTER_TYPE = "S3"
|
ADAPTER_TYPE = "s3"
|
||||||
|
|
||||||
CONFIG_SCHEMA = [
|
CONFIG_SCHEMA = [
|
||||||
{"key": "bucket_name", "label": "Bucket 名称",
|
{"key": "bucket_name", "label": "Bucket 名称",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from telethon.sessions import StringSession
|
|||||||
import socks
|
import socks
|
||||||
|
|
||||||
# 适配器类型标识
|
# 适配器类型标识
|
||||||
ADAPTER_TYPE = "Telegram"
|
ADAPTER_TYPE = "telegram"
|
||||||
|
|
||||||
# 适配器配置项定义
|
# 适配器配置项定义
|
||||||
CONFIG_SCHEMA = [
|
CONFIG_SCHEMA = [
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export interface AdapterTypeField {
|
|||||||
|
|
||||||
export interface AdapterTypeMeta {
|
export interface AdapterTypeMeta {
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
|
||||||
config_schema: AdapterTypeField[];
|
config_schema: AdapterTypeField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,6 +525,15 @@ export const en = {
|
|||||||
'Select adapter type': 'Select adapter type',
|
'Select adapter type': 'Select adapter type',
|
||||||
'/ or /drive': '/ or /drive',
|
'/ or /drive': '/ or /drive',
|
||||||
'Adapter Config': 'Adapter Config',
|
'Adapter Config': 'Adapter Config',
|
||||||
|
'adapter.type.local': 'Local Filesystem',
|
||||||
|
'adapter.type.webdav': 'WebDAV',
|
||||||
|
'adapter.type.googledrive': 'Google Drive',
|
||||||
|
'adapter.type.onedrive': 'OneDrive',
|
||||||
|
'adapter.type.s3': 'Amazon S3',
|
||||||
|
'adapter.type.ftp': 'FTP',
|
||||||
|
'adapter.type.sftp': 'SFTP',
|
||||||
|
'adapter.type.telegram': 'Telegram',
|
||||||
|
'adapter.type.quark': 'Quark Drive',
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
'Automation Tasks': 'Automation Tasks',
|
'Automation Tasks': 'Automation Tasks',
|
||||||
|
|||||||
@@ -528,6 +528,15 @@ export const zh = {
|
|||||||
'Select adapter type': '选择适配器类型',
|
'Select adapter type': '选择适配器类型',
|
||||||
'/ or /drive': '/或/drive',
|
'/ or /drive': '/或/drive',
|
||||||
'Adapter Config': '适配器配置',
|
'Adapter Config': '适配器配置',
|
||||||
|
'adapter.type.local': '本地文件系统',
|
||||||
|
'adapter.type.webdav': 'WebDAV',
|
||||||
|
'adapter.type.googledrive': 'Google Drive',
|
||||||
|
'adapter.type.onedrive': 'OneDrive',
|
||||||
|
'adapter.type.s3': 'Amazon S3',
|
||||||
|
'adapter.type.ftp': 'FTP',
|
||||||
|
'adapter.type.sftp': 'SFTP',
|
||||||
|
'adapter.type.telegram': 'Telegram',
|
||||||
|
'adapter.type.quark': '夸克网盘',
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
'Automation Tasks': '自动化任务',
|
'Automation Tasks': '自动化任务',
|
||||||
|
|||||||
@@ -130,9 +130,16 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTypeLabel = useCallback((type?: string) => {
|
||||||
|
if (!type) return '-';
|
||||||
|
const key = `adapter.type.${type}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? type : label;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: t('Name'), dataIndex: 'name' },
|
{ title: t('Name'), dataIndex: 'name' },
|
||||||
{ title: t('Type'), dataIndex: 'type', width: 100 },
|
{ title: t('Type'), dataIndex: 'type', width: 140, render: (value: string) => renderTypeLabel(value) },
|
||||||
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||||
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
@@ -233,9 +240,9 @@ const AdaptersPage = memo(function AdaptersPage() {
|
|||||||
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('Select adapter type')}
|
placeholder={t('Select adapter type')}
|
||||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
|
||||||
onChange={() => {
|
onChange={(value) => {
|
||||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
const t = availableTypes.find(v => v.type === value);
|
||||||
const cfgDefaults: Record<string, any> = {};
|
const cfgDefaults: Record<string, any> = {};
|
||||||
t?.config_schema.forEach(f => {
|
t?.config_schema.forEach(f => {
|
||||||
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
||||||
|
|||||||
Reference in New Issue
Block a user