feat: normalize adapter types and improve validation in adapters

This commit is contained in:
shiyu
2025-11-20 12:43:41 +08:00
parent 219f3e81b8
commit 3278896d4b
12 changed files with 83 additions and 23 deletions
+4 -8
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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},
+1 -1
View File
@@ -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},
+1 -1
View File
@@ -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 复制"},
+31 -4
View File
@@ -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
+1 -1
View File
@@ -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 名称",
+1 -1
View File
@@ -8,7 +8,7 @@ from telethon.sessions import StringSession
import socks import socks
# 适配器类型标识 # 适配器类型标识
ADAPTER_TYPE = "Telegram" ADAPTER_TYPE = "telegram"
# 适配器配置项定义 # 适配器配置项定义
CONFIG_SCHEMA = [ CONFIG_SCHEMA = [
-1
View File
@@ -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[];
} }
+9
View File
@@ -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',
+9
View File
@@ -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': '自动化任务',
+11 -4
View File
@@ -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;