重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件
主要变更: - 后端代码从根目录迁移到 app/ 目录 - 前端代码从 frontend/ 重命名为 WebUI/ - 更新所有导入路径以适配新结构 - 提取公共 API 响应函数到 app/api/common.py - 精简验证器服务代码 - 更新启动脚本和文档 测试: - 新增完整测试套件 (tests/) - 单元测试: 模型、仓库层 - 集成测试: 覆盖所有 22+ API 端点 - E2E 测试: 4个完整工作流场景 - 添加 pytest 配置和测试运行脚本
This commit is contained in:
11
app/repositories/__init__.py
Normal file
11
app/repositories/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""数据访问层包"""
|
||||
from .proxy_repo import ProxyRepository
|
||||
from .settings_repo import SettingsRepository, PluginSettingsRepository
|
||||
from .task_repo import ValidationTaskRepository
|
||||
|
||||
__all__ = [
|
||||
"ProxyRepository",
|
||||
"SettingsRepository",
|
||||
"PluginSettingsRepository",
|
||||
"ValidationTaskRepository",
|
||||
]
|
||||
277
app/repositories/proxy_repo.py
Normal file
277
app/repositories/proxy_repo.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""代理数据访问层 - 所有 SQL 操作收敛于此"""
|
||||
import aiosqlite
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from app.models.domain import Proxy
|
||||
from app.core.log import logger
|
||||
|
||||
|
||||
VALID_PROTOCOLS = ("http", "https", "socks4", "socks5")
|
||||
|
||||
|
||||
def _to_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_proxy(row: Tuple) -> Proxy:
|
||||
return Proxy(
|
||||
ip=row[0],
|
||||
port=row[1],
|
||||
protocol=row[2],
|
||||
score=row[3],
|
||||
response_time_ms=row[4],
|
||||
last_check=_to_datetime(row[5]),
|
||||
created_at=_to_datetime(row[6]),
|
||||
)
|
||||
|
||||
|
||||
class ProxyRepository:
|
||||
"""代理 Repository"""
|
||||
|
||||
@staticmethod
|
||||
async def insert_or_update(
|
||||
db: aiosqlite.Connection,
|
||||
ip: str,
|
||||
port: int,
|
||||
protocol: str = "http",
|
||||
score: int = 10,
|
||||
) -> bool:
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
protocol = "http"
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO proxies (ip, port, protocol, score, last_check, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(ip, port) DO UPDATE SET
|
||||
protocol = excluded.protocol,
|
||||
score = excluded.score,
|
||||
last_check = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(ip, port, protocol, score),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"insert_or_update proxy failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def update_score(
|
||||
db: aiosqlite.Connection,
|
||||
ip: str,
|
||||
port: int,
|
||||
delta: int,
|
||||
min_score: int = 0,
|
||||
max_score: int = 100,
|
||||
) -> bool:
|
||||
try:
|
||||
async with db.execute(
|
||||
"SELECT score FROM proxies WHERE ip = ? AND port = ?", (ip, port)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
current_score = row[0]
|
||||
new_score = max(min_score, min(max_score, current_score + delta))
|
||||
await db.execute(
|
||||
"UPDATE proxies SET score = ?, last_check = CURRENT_TIMESTAMP WHERE ip = ? AND port = ?",
|
||||
(new_score, ip, port),
|
||||
)
|
||||
if new_score <= 0:
|
||||
await db.execute("DELETE FROM proxies WHERE score <= 0")
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"update_score failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def update_response_time(
|
||||
db: aiosqlite.Connection,
|
||||
ip: str,
|
||||
port: int,
|
||||
response_time_ms: float,
|
||||
) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
"UPDATE proxies SET response_time_ms = ? WHERE ip = ? AND port = ?",
|
||||
(response_time_ms, ip, port),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"update_response_time failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: aiosqlite.Connection, ip: str, port: int) -> None:
|
||||
await db.execute("DELETE FROM proxies WHERE ip = ? AND port = ?", (ip, port))
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def batch_delete(db: aiosqlite.Connection, proxies: List[Tuple[str, int]]) -> int:
|
||||
if not proxies:
|
||||
return 0
|
||||
await db.executemany("DELETE FROM proxies WHERE ip = ? AND port = ?", proxies)
|
||||
await db.commit()
|
||||
return len(proxies)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_ip_port(
|
||||
db: aiosqlite.Connection, ip: str, port: int
|
||||
) -> Optional[Proxy]:
|
||||
async with db.execute(
|
||||
"SELECT ip, port, protocol, score, response_time_ms, last_check, created_at FROM proxies WHERE ip = ? AND port = ?",
|
||||
(ip, port),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return _row_to_proxy(row)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_random(db: aiosqlite.Connection) -> Optional[Proxy]:
|
||||
async with db.execute(
|
||||
"SELECT ip, port, protocol, score, response_time_ms, last_check, created_at FROM proxies WHERE score > 0 ORDER BY RANDOM() LIMIT 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return _row_to_proxy(row)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def list_all(
|
||||
db: aiosqlite.Connection,
|
||||
protocol: Optional[str] = None,
|
||||
limit: int = 100000,
|
||||
) -> List[Proxy]:
|
||||
query = "SELECT ip, port, protocol, score, response_time_ms, last_check, created_at FROM proxies"
|
||||
params: List = []
|
||||
if protocol:
|
||||
query += " WHERE protocol = ?"
|
||||
params.append(protocol.lower())
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
async with db.execute(query, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_proxy(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def list_paginated(
|
||||
db: aiosqlite.Connection,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
protocol: Optional[str] = None,
|
||||
min_score: int = 0,
|
||||
max_score: Optional[int] = None,
|
||||
sort_by: str = "last_check",
|
||||
sort_order: str = "DESC",
|
||||
) -> Tuple[List[Proxy], int]:
|
||||
conditions = ["score >= ?"]
|
||||
params: List = [min_score]
|
||||
|
||||
if protocol:
|
||||
conditions.append("protocol = ?")
|
||||
params.append(protocol)
|
||||
if max_score is not None:
|
||||
conditions.append("score <= ?")
|
||||
params.append(max_score)
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
order_clause = f"{sort_by} {sort_order}"
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
count_query = f"SELECT COUNT(*) FROM proxies WHERE {where_clause}"
|
||||
async with db.execute(count_query, list(params)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
total = row[0] if row else 0
|
||||
|
||||
data_query = f"""
|
||||
SELECT ip, port, protocol, score, response_time_ms, last_check, created_at
|
||||
FROM proxies
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params.extend([page_size, offset])
|
||||
async with db.execute(data_query, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
proxies = [_row_to_proxy(row) for row in rows]
|
||||
return proxies, total
|
||||
|
||||
@staticmethod
|
||||
async def get_stats(db: aiosqlite.Connection) -> dict:
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN score > 0 THEN 1 END) as available,
|
||||
AVG(score) as avg_score,
|
||||
COUNT(CASE WHEN protocol = 'http' THEN 1 END) as http_count,
|
||||
COUNT(CASE WHEN protocol = 'https' THEN 1 END) as https_count,
|
||||
COUNT(CASE WHEN protocol = 'socks4' THEN 1 END) as socks4_count,
|
||||
COUNT(CASE WHEN protocol = 'socks5' THEN 1 END) as socks5_count
|
||||
FROM proxies
|
||||
"""
|
||||
async with db.execute(query) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
"total": row[0] or 0,
|
||||
"available": row[1] or 0,
|
||||
"avg_score": round(row[2], 2) if row[2] else 0,
|
||||
"http_count": row[3] or 0,
|
||||
"https_count": row[4] or 0,
|
||||
"socks4_count": row[5] or 0,
|
||||
"socks5_count": row[6] or 0,
|
||||
}
|
||||
return {
|
||||
"total": 0,
|
||||
"available": 0,
|
||||
"avg_score": 0,
|
||||
"http_count": 0,
|
||||
"https_count": 0,
|
||||
"socks4_count": 0,
|
||||
"socks5_count": 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_today_new_count(db: aiosqlite.Connection) -> int:
|
||||
try:
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM proxies WHERE DATE(last_check) = DATE('now', 'localtime')"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception as e:
|
||||
logger.error(f"get_today_new_count failed: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
async def clean_invalid(db: aiosqlite.Connection) -> int:
|
||||
await db.execute("DELETE FROM proxies WHERE score <= 0")
|
||||
await db.commit()
|
||||
return db.total_changes
|
||||
|
||||
@staticmethod
|
||||
async def clean_expired(db: aiosqlite.Connection, days: int) -> int:
|
||||
try:
|
||||
await db.execute(
|
||||
"DELETE FROM proxies WHERE last_check < datetime('now', '-{} days')".format(days)
|
||||
)
|
||||
await db.commit()
|
||||
return db.total_changes
|
||||
except Exception as e:
|
||||
logger.error(f"clean_expired failed: {e}")
|
||||
return 0
|
||||
140
app/repositories/settings_repo.py
Normal file
140
app/repositories/settings_repo.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""设置数据访问层"""
|
||||
import json
|
||||
import aiosqlite
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.log import logger
|
||||
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"crawl_timeout": 30,
|
||||
"validation_timeout": 10,
|
||||
"max_retries": 3,
|
||||
"default_concurrency": 50,
|
||||
"min_proxy_score": 0,
|
||||
"proxy_expiry_days": 7,
|
||||
"auto_validate": True,
|
||||
"validate_interval_minutes": 30,
|
||||
}
|
||||
|
||||
|
||||
class SettingsRepository:
|
||||
"""系统设置 Repository"""
|
||||
|
||||
@staticmethod
|
||||
async def get_all(db: aiosqlite.Connection) -> Dict[str, Any]:
|
||||
settings = DEFAULT_SETTINGS.copy()
|
||||
try:
|
||||
async with db.execute("SELECT key, value FROM settings") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
for key, value in rows:
|
||||
# 类型转换
|
||||
default = DEFAULT_SETTINGS.get(key)
|
||||
if isinstance(default, bool):
|
||||
settings[key] = value.lower() == "true"
|
||||
elif isinstance(default, int):
|
||||
settings[key] = int(value)
|
||||
else:
|
||||
settings[key] = value
|
||||
except Exception as e:
|
||||
logger.error(f"get_all settings failed: {e}")
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def save(db: aiosqlite.Connection, settings: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
for key, value in settings.items():
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, str(value)),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"save settings failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class PluginSettingsRepository:
|
||||
"""插件设置 Repository"""
|
||||
|
||||
@staticmethod
|
||||
async def get_enabled(db: aiosqlite.Connection, plugin_id: str) -> Optional[bool]:
|
||||
async with db.execute(
|
||||
"SELECT enabled FROM plugin_settings WHERE plugin_id = ?", (plugin_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return bool(row[0])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def set_enabled(db: aiosqlite.Connection, plugin_id: str, enabled: bool) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO plugin_settings (plugin_id, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(plugin_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(plugin_id, int(enabled)),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"set_enabled failed for {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def get_config(db: aiosqlite.Connection, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
async with db.execute(
|
||||
"SELECT config_json FROM plugin_settings WHERE plugin_id = ?", (plugin_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def set_config(db: aiosqlite.Connection, plugin_id: str, config: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO plugin_settings (plugin_id, config_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(plugin_id) DO UPDATE SET
|
||||
config_json = excluded.config_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(plugin_id, json.dumps(config, ensure_ascii=False)),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"set_config failed for {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def list_all(db: aiosqlite.Connection) -> Dict[str, Dict[str, Any]]:
|
||||
result = {}
|
||||
async with db.execute("SELECT plugin_id, enabled, config_json FROM plugin_settings") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
for plugin_id, enabled, config_json in rows:
|
||||
config = {}
|
||||
if config_json:
|
||||
try:
|
||||
config = json.loads(config_json)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
result[plugin_id] = {"enabled": bool(enabled), "config": config}
|
||||
return result
|
||||
135
app/repositories/task_repo.py
Normal file
135
app/repositories/task_repo.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""验证任务队列持久化层"""
|
||||
import aiosqlite
|
||||
from typing import List, Optional
|
||||
from app.models.domain import ProxyRaw
|
||||
from app.core.log import logger
|
||||
|
||||
|
||||
class ValidationTaskRepository:
|
||||
"""验证任务 Repository —— 支持队列持久化"""
|
||||
|
||||
@staticmethod
|
||||
async def insert_batch(db: aiosqlite.Connection, proxies: List[ProxyRaw]) -> int:
|
||||
if not proxies:
|
||||
return 0
|
||||
try:
|
||||
rows = [(p.ip, p.port, p.protocol) for p in proxies]
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO validation_tasks (ip, port, protocol, status, created_at)
|
||||
VALUES (?, ?, ?, 'pending', CURRENT_TIMESTAMP)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
await db.commit()
|
||||
return len(rows)
|
||||
except Exception as e:
|
||||
logger.error(f"insert_batch validation tasks failed: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
async def acquire_pending(db: aiosqlite.Connection) -> Optional[dict]:
|
||||
"""原子性地获取一个 pending 任务并将其标记为 processing"""
|
||||
try:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT id, ip, port, protocol FROM validation_tasks
|
||||
WHERE status = 'pending'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
task_id = row[0]
|
||||
await db.execute(
|
||||
"UPDATE validation_tasks SET status = 'processing', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
await db.commit()
|
||||
return {"id": task_id, "ip": row[1], "port": row[2], "protocol": row[3]}
|
||||
except Exception as e:
|
||||
logger.error(f"acquire_pending failed: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def complete_task(
|
||||
db: aiosqlite.Connection,
|
||||
task_id: int,
|
||||
is_valid: bool,
|
||||
response_time_ms: Optional[float] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE validation_tasks
|
||||
SET status = 'completed',
|
||||
result = ?,
|
||||
response_time_ms = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
("valid" if is_valid else "invalid", response_time_ms, task_id),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"complete_task failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def fail_task(db: aiosqlite.Connection, task_id: int) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE validation_tasks
|
||||
SET status = 'failed',
|
||||
result = 'invalid',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"fail_task failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def get_pending_count(db: aiosqlite.Connection) -> int:
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM validation_tasks WHERE status = 'pending'"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def reset_processing(db: aiosqlite.Connection) -> int:
|
||||
"""将异常中断的 processing 任务重置为 pending,用于启动恢复"""
|
||||
try:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE validation_tasks
|
||||
SET status = 'pending', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'processing'
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
return db.total_changes
|
||||
except Exception as e:
|
||||
logger.error(f"reset_processing failed: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_old(db: aiosqlite.Connection, days: int = 7) -> int:
|
||||
try:
|
||||
await db.execute(
|
||||
"DELETE FROM validation_tasks WHERE updated_at < datetime('now', '-{} days')".format(days)
|
||||
)
|
||||
await db.commit()
|
||||
return db.total_changes
|
||||
except Exception as e:
|
||||
logger.error(f"cleanup_old tasks failed: {e}")
|
||||
return 0
|
||||
Reference in New Issue
Block a user