feat: 增加远程自动调度处理

This commit is contained in:
2026-05-25 17:48:32 +08:00
parent 89fe45c813
commit 8e891964d5
14 changed files with 1676 additions and 20 deletions
+408
View File
@@ -0,0 +1,408 @@
from __future__ import annotations
import json
import threading
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any
from fastapi import HTTPException
from app import state
from app.config import CACHE_DIR, AutoSchedulerConfig, RemoteDataConfig
from app.services.remote_download import RemoteDataDownloader, RemoteFileInfo
from app.utils.file_dates import extract_file_date, required_week_days
READY_DIR = CACHE_DIR / "auto_scheduler"
READY_FLAG = READY_DIR / "ready.flag"
DISABLED_CHECK_SECONDS = 60
STARTUP_CHECK_SECONDS = 5
FAILURE_RESULTS = {"scan_failed", "trigger_failed", "source_cleanup_failed", "failed", "invalid_flag"}
@dataclass(frozen=True)
class DirectoryReadyStatus:
directory: str
ready: bool
found_days: list[date]
missing_days: list[date]
file_count: int
error: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"ready": self.ready,
"found_days": [item.isoformat() for item in self.found_days],
"missing_days": [item.isoformat() for item in self.missing_days],
"found_count": len(self.found_days),
"required_count": len(self.found_days) + len(self.missing_days),
"file_count": self.file_count,
"error": self.error,
}
class AutoScheduler:
def __init__(self) -> None:
self._stop_event = threading.Event()
self._check_lock = threading.Lock()
self._status_lock = threading.Lock()
self._thread: threading.Thread | None = None
self._running = False
self._last_check_at: datetime | None = None
self._next_check_at: datetime | None = None
self._last_result = "not_started"
self._last_message = "自动调度器尚未检查"
self._directory_status: dict[str, dict[str, Any]] = {}
self._task_running = False
self._task_id: str | None = None
self._failure_count = 0
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._running = True
self._stop_event.clear()
self._set_next_check(datetime.now() + timedelta(seconds=STARTUP_CHECK_SECONDS))
self._thread = threading.Thread(target=self._run_loop, name="auto-scheduler", daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
def get_status(self) -> dict[str, Any]:
config = state.current_config().remote_data.normalized()
scheduler = config.auto_scheduler.normalized()
target_days = required_week_days(scheduler.week_offset)
ready_flag = self._read_ready_flag()
with self._status_lock:
return {
"enabled": scheduler.enabled,
"running": self._running,
"check_interval_hours": scheduler.check_interval_hours,
"expected_directories": scheduler.expected_directories,
"week_offset": scheduler.week_offset,
"auto_delete_source": config.auto_delete_source,
"next_check_at": self._format_dt(self._next_check_at),
"last_check_at": self._format_dt(self._last_check_at),
"last_result": self._last_result,
"last_message": self._last_message,
"failure_count": self._failure_count,
"task_running": self._task_running,
"task_id": self._task_id,
"ready_flag": ready_flag,
"target_week": {
"start": target_days[0].isoformat(),
"end": target_days[-1].isoformat(),
"days": [item.isoformat() for item in target_days],
},
"directory_status": self._directory_status,
}
def check_and_run(self, manual: bool = False) -> dict[str, Any]:
if not self._check_lock.acquire(blocking=False):
return self._finish_check("busy", "自动调度器正在检查中", manual)
try:
return self._check_and_run_locked(manual)
finally:
self._check_lock.release()
def _run_loop(self) -> None:
while not self._stop_event.wait(self._seconds_until_next_check()):
self.check_and_run(manual=False)
def _check_and_run_locked(self, manual: bool) -> dict[str, Any]:
now = datetime.now()
remote_config = state.current_config().remote_data.normalized()
scheduler = remote_config.auto_scheduler.normalized()
self._last_check_at = now
if not scheduler.enabled:
self._set_next_check(now + timedelta(seconds=DISABLED_CHECK_SECONDS))
return self._finish_check("disabled", "自动调度未启用", manual)
self._set_next_check(now + timedelta(hours=scheduler.check_interval_hours))
if not remote_config.enabled:
return self._finish_check("remote_disabled", "远程数据源未启用", manual)
if state.global_task_lock["locked"]:
return self._finish_check("task_running", "已有任务在运行,本轮自动调度跳过", manual)
ready_flag = self._read_ready_flag()
if ready_flag["exists"]:
if ready_flag.get("invalid"):
self._clear_ready_flag()
return self._finish_check("invalid_flag", "就绪标识格式错误,已清除,本轮不触发处理", manual)
target_dates = self._target_dates_from_flag(ready_flag, scheduler)
return self._trigger_processing(target_dates, ready_flag, manual)
target_days = required_week_days(scheduler.week_offset)
directory_status = self._check_remote_ready(remote_config, scheduler, target_days)
ready = bool(directory_status) and all(item.ready for item in directory_status.values())
self._set_directory_status(directory_status)
error_count = sum(1 for item in directory_status.values() if item.error)
if error_count:
return self._finish_check(
"scan_failed",
f"远程目录扫描失败,{error_count}/{len(directory_status)} 个目录无法访问或扫描失败",
manual,
)
if ready:
self._mark_ready(target_days, directory_status)
return self._finish_check(
"marked_ready",
"远程数据已满足目标周 7 天,已写入就绪标识,下次检查将自动处理",
manual,
)
ready_count = sum(1 for item in directory_status.values() if item.ready)
return self._finish_check(
"waiting",
f"远程数据未就绪,{ready_count}/{len(directory_status)} 个目录满足目标周 7 天",
manual,
)
def _check_remote_ready(
self,
remote_config: RemoteDataConfig,
scheduler: AutoSchedulerConfig,
target_days: list[date],
) -> dict[str, DirectoryReadyStatus]:
downloader = RemoteDataDownloader(remote_config)
expected_directories = scheduler.expected_directories
if expected_directories:
return {
directory: self._directory_ready_status(
directory,
self._safe_list_remote_zip_files(downloader, directory),
target_days,
)
for directory in expected_directories
}
files = self._safe_list_remote_zip_files(downloader, None)
if files is None:
return {
".": DirectoryReadyStatus(
directory=".",
ready=False,
found_days=[],
missing_days=target_days,
file_count=0,
error="远程目录扫描失败",
)
}
grouped: dict[str, list[RemoteFileInfo]] = defaultdict(list)
for remote_file in files:
grouped[remote_file.parent or "."].append(remote_file)
if not grouped:
return {
".": DirectoryReadyStatus(
directory=".",
ready=False,
found_days=[],
missing_days=target_days,
file_count=0,
error="远程目录未找到 ZIP 文件",
)
}
return {
directory: self._directory_ready_status(directory, directory_files, target_days)
for directory, directory_files in sorted(grouped.items(), key=lambda item: item[0])
}
def _safe_list_remote_zip_files(
self,
downloader: RemoteDataDownloader,
directory: str | None,
) -> list[RemoteFileInfo] | None:
try:
return downloader.list_remote_zip_files(directory)
except Exception:
return None
def _directory_ready_status(
self,
directory: str,
files: list[RemoteFileInfo] | None,
target_days: list[date],
) -> DirectoryReadyStatus:
if files is None:
return DirectoryReadyStatus(
directory=directory,
ready=False,
found_days=[],
missing_days=target_days,
file_count=0,
error="远程目录不存在或无法访问",
)
required = set(target_days)
found = {
file_date
for remote_file in files
if (file_date := extract_file_date(remote_file.name)) in required
}
missing = [item for item in target_days if item not in found]
return DirectoryReadyStatus(
directory=directory,
ready=not missing,
found_days=sorted(found),
missing_days=missing,
file_count=len(files),
)
def _trigger_processing(
self,
target_dates: list[date],
ready_flag: dict[str, Any],
manual: bool,
) -> dict[str, Any]:
from app.api.routers.remote import start_remote_processing_job
try:
result = start_remote_processing_job(
source="scheduler",
on_finish=self._on_processing_finish,
target_dates=target_dates,
)
except HTTPException as exc:
return self._finish_check("trigger_failed", str(exc.detail), manual)
except Exception as exc:
return self._finish_check("trigger_failed", f"自动调度触发失败: {exc}", manual)
task_id = result.get("task_id")
with self._status_lock:
self._task_running = True
self._task_id = str(task_id) if task_id else None
self._last_result = "triggered"
self._last_message = "已根据就绪标识触发远程下载并处理"
return {
"success": True,
"result": "triggered",
"message": "已根据就绪标识触发远程下载并处理",
"task_id": task_id,
"ready_flag": ready_flag,
"status": self.get_status(),
}
def _mark_ready(
self,
target_days: list[date],
directory_status: dict[str, DirectoryReadyStatus],
) -> None:
READY_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"ready_at": datetime.now().isoformat(timespec="seconds"),
"week_start": target_days[0].isoformat(),
"week_end": target_days[-1].isoformat(),
"target_dates": [item.isoformat() for item in target_days],
"directories": {
name: item.to_dict()
for name, item in directory_status.items()
},
}
READY_FLAG.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _clear_ready_flag(self) -> None:
try:
READY_FLAG.unlink(missing_ok=True)
except OSError:
pass
def _read_ready_flag(self) -> dict[str, Any]:
if not READY_FLAG.exists():
return {"exists": False}
try:
data = json.loads(READY_FLAG.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {"exists": True, **data}
except Exception as exc:
return {"exists": True, "invalid": True, "error": str(exc)}
return {"exists": True, "invalid": True, "error": "ready.flag 格式错误"}
def _target_dates_from_flag(
self,
ready_flag: dict[str, Any],
scheduler: AutoSchedulerConfig,
) -> list[date]:
raw_dates = ready_flag.get("target_dates")
if isinstance(raw_dates, list):
dates: list[date] = []
for raw_date in raw_dates:
try:
dates.append(date.fromisoformat(str(raw_date)))
except ValueError:
continue
if dates:
return sorted(dates)
return required_week_days(scheduler.week_offset)
def _on_processing_finish(self, task_id: str, status: str) -> None:
if status == "completed":
self._clear_ready_flag()
message = "自动调度任务处理成功,已清除就绪标识"
result = "completed"
else:
message = "自动调度任务未完成,就绪标识已保留,下次检查会重试"
result = status or "failed"
with self._status_lock:
self._task_running = False
self._task_id = task_id
self._last_result = result
self._last_message = message
self._failure_count = 0 if result == "completed" else self._failure_count + 1
def _finish_check(self, result: str, message: str, manual: bool) -> dict[str, Any]:
with self._status_lock:
self._last_result = result
self._last_message = message
if result in FAILURE_RESULTS:
self._failure_count += 1
elif result not in {"busy", "task_running"}:
self._failure_count = 0
if result not in {"triggered", "task_running"}:
self._task_running = False
return {
"success": result not in FAILURE_RESULTS,
"manual": manual,
"result": result,
"message": message,
"status": self.get_status(),
}
def _set_directory_status(self, directory_status: dict[str, DirectoryReadyStatus]) -> None:
with self._status_lock:
self._directory_status = {
name: item.to_dict()
for name, item in directory_status.items()
}
def _set_next_check(self, value: datetime) -> None:
with self._status_lock:
self._next_check_at = value
def _seconds_until_next_check(self) -> float:
with self._status_lock:
next_check_at = self._next_check_at
if not next_check_at:
return STARTUP_CHECK_SECONDS
return max((next_check_at - datetime.now()).total_seconds(), 0)
@staticmethod
def _format_dt(value: datetime | None) -> str | None:
return value.isoformat(timespec="seconds") if value else None
+181 -2
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import stat
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date
from ftplib import FTP
from pathlib import Path
from typing import Callable, Iterable
from app.config import RemoteDataConfig
from app.utils.file_dates import extract_file_date, select_recent_items_by_directory
LogFn = Callable[[str], None]
@@ -19,6 +22,15 @@ class RemoteDownloadResult:
remote_files: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class RemoteFileInfo:
path: str
relative_path: str
parent: str
name: str
size: int = 0
class RemoteDownloadError(RuntimeError):
pass
@@ -42,13 +54,71 @@ class RemoteDataDownloader:
finally:
ssh.close()
def download_to(self, destination: Path) -> RemoteDownloadResult:
def download_to(self, destination: Path, target_dates: Iterable[date] | None = None) -> RemoteDownloadResult:
self._validate_config()
destination.mkdir(parents=True, exist_ok=True)
zip_files = self.list_remote_zip_files()
date_filter = set(target_dates or [])
if date_filter:
selected_files = self._select_files_by_dates(zip_files, date_filter)
return self._download_selected_files(destination, selected_files)
if zip_files:
selected_files, summaries = select_recent_items_by_directory(
zip_files,
parent_key=lambda item: item.parent,
name_key=lambda item: item.name,
)
for summary in summaries:
if summary.max_date and summary.start_date and summary.skipped_count:
self._log(
f"远程目录 {summary.directory or '.'}: 仅下载 "
f"{summary.start_date.isoformat()} 至 {summary.max_date.isoformat()} "
f"的 {summary.selected_count}/{summary.total_count} 个 ZIP 文件,"
f"跳过 {summary.skipped_count} 个旧文件"
)
return self._download_selected_files(destination, selected_files)
if self.config.protocol == "ftp":
return self._download_ftp(destination)
return self._download_sftp(destination)
def _select_files_by_dates(self, zip_files: list[RemoteFileInfo], target_dates: set[date]) -> list[RemoteFileInfo]:
selected_files: list[RemoteFileInfo] = []
grouped: dict[str, list[RemoteFileInfo]] = defaultdict(list)
for remote_file in zip_files:
grouped[remote_file.parent].append(remote_file)
for parent, files in sorted(grouped.items(), key=lambda item: item[0]):
selected = [
remote_file
for remote_file in files
if extract_file_date(remote_file.name) in target_dates
]
selected_files.extend(selected)
skipped_count = len(files) - len(selected)
if skipped_count:
first_day = min(target_dates).isoformat()
last_day = max(target_dates).isoformat()
self._log(
f"远程目录 {parent or '.'}: 仅下载调度目标日期 "
f"{first_day} 至 {last_day} 的 {len(selected)}/{len(files)} 个 ZIP 文件,"
f"跳过 {skipped_count} 个非目标日期文件"
)
return selected_files
def list_remote_zip_files(self, directory: str | None = None) -> list[RemoteFileInfo]:
self._validate_config()
remote_dir = (
self._join_remote_path(self.config.remote_dir, directory.strip("/"))
if directory
else self.config.remote_dir
)
if self.config.protocol == "ftp":
return self._list_ftp_zip_files(remote_dir)
return self._list_sftp_zip_files(remote_dir)
def delete_source_files(self, remote_files: Iterable[str] | None = None) -> int:
self._validate_config()
source_files = list(remote_files or [])
@@ -164,6 +234,59 @@ class RemoteDataDownloader:
result.total_bytes += expected_size or local_path.stat().st_size
result.remote_files.append(remote_path)
def _download_selected_files(self, destination: Path, remote_files: list[RemoteFileInfo]) -> RemoteDownloadResult:
result = RemoteDownloadResult()
if not remote_files:
return result
if self.config.protocol == "ftp":
with self._ftp_client() as ftp:
self._log(f"已连接 FTP: {self.config.host}:{self.config.port}")
for remote_file in remote_files:
self._download_ftp_file(
ftp,
remote_file.path,
destination / remote_file.relative_path,
result,
remote_file.size,
)
return result
ssh = self._sftp_ssh_client()
try:
with ssh.open_sftp() as sftp:
self._log(f"已连接 SFTP: {self.config.host}:{self.config.port}")
for remote_file in remote_files:
self._download_sftp_file(
sftp,
remote_file.path,
destination / remote_file.relative_path,
result,
remote_file.size,
)
finally:
ssh.close()
return result
def _list_ftp_zip_files(self, remote_dir: str) -> list[RemoteFileInfo]:
with self._ftp_client() as ftp:
return self._collect_ftp_zip_files(ftp, remote_dir)
def _collect_ftp_zip_files(self, ftp: FTP, remote_dir: str) -> list[RemoteFileInfo]:
files: list[RemoteFileInfo] = []
for name, entry_type, size in self._list_ftp_entries(ftp, remote_dir):
if name in {".", ".."}:
continue
remote_path = self._join_remote_path(remote_dir, name)
if entry_type == "dir" or (entry_type == "unknown" and self._ftp_is_dir(ftp, remote_path)):
files.extend(self._collect_ftp_zip_files(ftp, remote_path))
continue
if name.lower().endswith(".zip"):
files.append(self._remote_file_info(remote_path, size))
return files
def _delete_ftp_source_files(self) -> int:
with self._ftp_client() as ftp:
self._log(f"开始清理 FTP 源文件: {self.config.remote_dir}")
@@ -246,13 +369,50 @@ class RemoteDataDownloader:
)
return
self._download_sftp_file(sftp, remote_path, local_path, result, int(getattr(attrs, "st_size", 0) or 0))
def _download_sftp_file(
self,
sftp,
remote_path: str,
local_path: Path,
result: RemoteDownloadResult,
expected_size: int = 0,
) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
self._log(f"下载: {remote_path}")
sftp.get(remote_path, str(local_path))
result.file_count += 1
result.total_bytes += int(getattr(attrs, "st_size", 0) or local_path.stat().st_size)
result.total_bytes += expected_size or local_path.stat().st_size
result.remote_files.append(remote_path)
def _list_sftp_zip_files(self, remote_dir: str) -> list[RemoteFileInfo]:
ssh = self._sftp_ssh_client()
try:
with ssh.open_sftp() as sftp:
return self._collect_sftp_zip_files(sftp, remote_dir)
finally:
ssh.close()
def _collect_sftp_zip_files(self, sftp, remote_path: str) -> list[RemoteFileInfo]:
attrs = sftp.stat(remote_path)
if not stat.S_ISDIR(attrs.st_mode):
name = Path(remote_path.replace("\\", "/")).name
if name.lower().endswith(".zip"):
return [self._remote_file_info(remote_path, int(getattr(attrs, "st_size", 0) or 0))]
return []
files: list[RemoteFileInfo] = []
for item in sftp.listdir_attr(remote_path):
if item.filename in {".", ".."}:
continue
child_path = self._join_remote_path(remote_path, item.filename)
if stat.S_ISDIR(item.st_mode):
files.extend(self._collect_sftp_zip_files(sftp, child_path))
elif item.filename.lower().endswith(".zip"):
files.append(self._remote_file_info(child_path, int(getattr(item, "st_size", 0) or 0)))
return files
def _delete_sftp_source_files(self) -> int:
ssh = self._sftp_ssh_client()
try:
@@ -301,3 +461,22 @@ class RemoteDataDownloader:
if parent == "/":
return f"/{child}"
return f"{parent}/{child}"
def _remote_file_info(self, remote_path: str, size: int = 0) -> RemoteFileInfo:
normalized_root = self.config.remote_dir.replace("\\", "/").rstrip("/")
normalized_path = remote_path.replace("\\", "/")
if normalized_root and normalized_root != "/" and normalized_path.startswith(f"{normalized_root}/"):
relative_path = normalized_path[len(normalized_root) + 1 :]
else:
relative_path = normalized_path.lstrip("/")
relative = Path(relative_path)
parent = str(relative.parent).replace("\\", "/")
if parent == ".":
parent = ""
return RemoteFileInfo(
path=remote_path,
relative_path=relative_path,
parent=parent,
name=relative.name,
size=size,
)