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

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from models import StorageAdapter
from schemas import AdapterCreate, AdapterOut
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 services.logging import LogService
@@ -14,6 +14,9 @@ router = APIRouter(prefix="/api/adapters", tags=["adapters"])
def validate_and_normalize_config(adapter_type: str, cfg):
schemas = get_config_schemas()
adapter_type = normalize_adapter_type(adapter_type)
if not adapter_type:
raise HTTPException(400, detail="不支持的适配器类型")
if not isinstance(cfg, dict):
raise HTTPException(400, detail="config 必须是对象")
schema = schemas.get(adapter_type)
@@ -77,17 +80,10 @@ async def list_adapters(
async def available_adapter_types(
current_user: Annotated[User, Depends(get_current_active_user)]
):
name_map = {
"local": "本地文件系统",
"webdav": "WebDAV",
"GoogleDrive": "Google Drive",
"OneDrive": "OneDrive",
}
data = []
for t, fields in get_config_schemas().items():
data.append({
"type": t,
"name": name_map.get(t, t),
"config_schema": fields,
})
return success(data)

View File

@@ -1,15 +1,28 @@
import re
from typing import Dict, Optional
from pydantic import BaseModel, Field, field_validator
class AdapterBase(BaseModel):
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)
enabled: bool = True
path: 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):
@staticmethod

View File

@@ -543,7 +543,7 @@ class GoogleDriveAdapter:
return None
ADAPTER_TYPE = "GoogleDrive"
ADAPTER_TYPE = "googledrive"
CONFIG_SCHEMA = [
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},

View File

@@ -445,7 +445,7 @@ class OneDriveAdapter:
return self._format_item(resp.json())
ADAPTER_TYPE = "OneDrive"
ADAPTER_TYPE = "onedrive"
CONFIG_SCHEMA = [
{"key": "client_id", "label": "Client ID", "type": "string", "required": True},

View File

@@ -718,7 +718,7 @@ class QuarkAdapter:
return it["fid"]
ADAPTER_TYPE = "Quark"
ADAPTER_TYPE = "quark"
CONFIG_SCHEMA = [
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},

View File

@@ -12,6 +12,13 @@ TYPE_MAP: Dict[str, AdapterFactory] = {}
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():
"""扫描 services.adapters 包, 自动注册适配器类型、工厂与配置 schema。"""
from .. import adapters as adapters_pkg
@@ -25,7 +32,7 @@ def discover_adapters():
module = import_module(full_name)
except Exception:
continue
adapter_type = getattr(module, "ADAPTER_TYPE", None)
adapter_type = normalize_adapter_type(getattr(module, "ADAPTER_TYPE", None))
schema = getattr(module, "CONFIG_SCHEMA", None)
factory = getattr(module, "ADAPTER_FACTORY", None)
@@ -64,7 +71,16 @@ class RuntimeRegistry:
self._instances.clear()
adapters = await StorageAdapter.filter(enabled=True)
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:
continue
try:
@@ -89,10 +105,21 @@ class RuntimeRegistry:
self.remove(rec.id)
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:
discover_adapters()
factory = TYPE_MAP.get(rec.type)
factory = TYPE_MAP.get(normalized_type)
if not factory:
return

View File

@@ -359,7 +359,7 @@ class S3Adapter:
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
ADAPTER_TYPE = "S3"
ADAPTER_TYPE = "s3"
CONFIG_SCHEMA = [
{"key": "bucket_name", "label": "Bucket 名称",

View File

@@ -8,7 +8,7 @@ from telethon.sessions import StringSession
import socks
# 适配器类型标识
ADAPTER_TYPE = "Telegram"
ADAPTER_TYPE = "telegram"
# 适配器配置项定义
CONFIG_SCHEMA = [

View File

@@ -21,7 +21,6 @@ export interface AdapterTypeField {
export interface AdapterTypeMeta {
type: string;
name: string;
config_schema: AdapterTypeField[];
}

View File

@@ -525,6 +525,15 @@ export const en = {
'Select adapter type': 'Select adapter type',
'/ or /drive': '/ or /drive',
'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
'Automation Tasks': 'Automation Tasks',

View File

@@ -528,6 +528,15 @@ export const zh = {
'Select adapter type': '选择适配器类型',
'/ or /drive': '/或/drive',
'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
'Automation Tasks': '自动化任务',

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 = [
{ 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('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 }]}>
<Select
placeholder={t('Select adapter type')}
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
onChange={() => {
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
onChange={(value) => {
const t = availableTypes.find(v => v.type === value);
const cfgDefaults: Record<string, any> = {};
t?.config_schema.forEach(f => {
if (f.default !== undefined) cfgDefaults[f.key] = f.default;