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
+23
View File
@@ -6,6 +6,7 @@ CapacityReport 用于导入每周容量报表数据,按 `Configure.json` 的
- Excel/CSV/ZIP 数据导入与自动解压、转换、入库。
- FTP/SFTP 远程数据源配置、连接测试、远程下载并处理。
- 远程自动调度:按远程 ZIP 文件名日期检查目标自然周 7 天数据,就绪后自动下载并处理。
- MySQL 数据表查看、清空、删除、CSV/XLSX 导出。
- SQL 脚本在线查看、保存和执行。
- 处理历史、日志查看、历史原始数据打包下载。
@@ -219,6 +220,26 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
- `SheetFilter`:Excel Sheet 过滤规则。
- `ExtractField`:字段映射配置。
`RemoteData.auto_scheduler` 用于远程自动调度:
```json
{
"enabled": false,
"check_interval_hours": 1,
"expected_directories": ["4G/FDD", "4G/900", "5G/2.6", "5G/700"],
"week_offset": 0
}
```
- `enabled`:是否启用自动调度。
- `check_interval_hours`:检查间隔,最小 1 小时。
- `expected_directories`:相对 `remote_dir` 的预期数据目录;为空时按远程 ZIP 实际所在目录检测。
- `week_offset`:`0` 表示上周自然周,`-1` 表示上上周。
自动调度开启后,系统会强制开启 `RemoteData.enabled` 和 `auto_delete_source`。调度器每轮先检查 `cache/auto_scheduler/ready.flag`;如果标识存在则直接触发远程下载并处理。没有标识时,会按 ZIP 文件名中的第一个时间戳判断每个目录是否覆盖目标自然周 7 天;全部就绪后写入标识,下一个检查周期再启动处理。处理成功并完成远程源文件清理后会删除标识;处理失败或源文件清理失败会保留标识,后续自动重试。
无论手动上传还是远程下载,处理流程都会按文件名日期对每个目录只保留最近 7 天文件。文件名支持 `XXX_YYYYMMDDHHMM_YYYYMMDDHHMM` 和 `XXX_YYYYMMDDHHMM` 两类格式,数据日期始终取第一个时间戳。
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
API Token 保存在本地 `api_tokens.json`,包含 HMAC 哈希、显示用前后缀和完整 Token,登录后可在列表中重复复制。该文件属于运行时数据,不应提交到版本库。
@@ -254,6 +275,8 @@ API Token 与登录态一样可访问业务 API,包括文件上传、远程下
- `POST /api/upload`:上传文件
- `POST /api/remote/test`:测试 FTP/SFTP 连接
- `POST /api/remote/start`:远程下载并处理
- `GET /api/remote/scheduler/status`:查询远程自动调度状态
- `POST /api/remote/scheduler/trigger`:手动触发一次自动调度检查
- `POST /api/process/start`:启动本地处理
- `POST /api/process/status`:查询处理状态
- `GET /api/license/status`:查询授权状态
+42 -6
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from datetime import date, datetime
from pathlib import Path
from threading import Thread
from typing import Any
from typing import Any, Callable, Iterable
from fastapi import APIRouter, Body, HTTPException
@@ -27,6 +27,29 @@ async def test_remote_connection(config: dict[str, Any] | None = Body(None)):
@router.post("/api/remote/start")
async def start_remote_processing():
return start_remote_processing_job(source="manual")
@router.get("/api/remote/scheduler/status")
def get_scheduler_status():
if state.auto_scheduler is None:
return {"enabled": False, "running": False, "message": "自动调度器未启动"}
return state.auto_scheduler.get_status()
@router.post("/api/remote/scheduler/trigger")
def trigger_scheduler_check():
if state.auto_scheduler is None:
raise HTTPException(status_code=503, detail="自动调度器未启动")
return state.auto_scheduler.check_and_run(manual=True)
def start_remote_processing_job(
*,
source: str = "manual",
on_finish: Callable[[str, str], None] | None = None,
target_dates: Iterable[date] | None = None,
) -> dict[str, Any]:
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
@@ -70,14 +93,14 @@ async def start_remote_processing():
thread = Thread(
target=_run_remote_processing,
args=(task_id, work_dir, app_config, remote_config, logger),
args=(task_id, work_dir, app_config, remote_config, logger, source, on_finish, target_dates),
daemon=True,
)
thread.start()
return {
"success": True,
"message": "远程下载处理任务已启动",
"message": "自动调度远程下载处理任务已启动" if source == "scheduler" else "远程下载处理任务已启动",
"task_id": task_id,
"stage": "downloading",
}
@@ -99,14 +122,18 @@ def _run_remote_processing(
app_config: AppConfig,
remote_config: RemoteDataConfig,
logger: ProcessLogger,
source: str = "manual",
on_finish: Callable[[str, str], None] | None = None,
target_dates: Iterable[date] | None = None,
) -> None:
final_status = "failed"
try:
logger.info(
f"开始远程下载,协议: {remote_config.protocol.upper()},"
f"服务器: {remote_config.host}:{remote_config.port},目录: {remote_config.remote_dir}"
)
downloader = RemoteDataDownloader(remote_config, logger.info)
download_result = downloader.download_to(work_dir)
download_result = downloader.download_to(work_dir, target_dates=target_dates)
logger.success(
f"远程下载完成,共 {download_result.file_count} 个文件,"
f"{_format_bytes(download_result.total_bytes)}"
@@ -127,8 +154,12 @@ def _run_remote_processing(
try:
deleted_count = downloader.delete_source_files(download_result.remote_files)
logger.success(f"远程源文件清理完成,共删除 {deleted_count} 个文件,目录已保留")
final_status = status
except Exception as exc:
logger.warning(f"远程源文件清理失败,数据处理结果已保留: {exc}")
final_status = "source_cleanup_failed"
else:
final_status = status
state.history_manager.update(
task_id,
@@ -145,7 +176,10 @@ def _run_remote_processing(
}
except Exception as exc:
error_detail = exc.to_detail() if isinstance(exc, LicenseError) else None
logger.error(f"远程自动化任务失败: {exc}")
if source == "scheduler":
logger.error(f"自动调度任务失败: {exc}")
else:
logger.error(f"远程自动化任务失败: {exc}")
state.history_manager.update(task_id, status="failed", error=str(exc))
state.processing_tasks[task_id] = {
"logs": state.history_manager.get_logs(task_id),
@@ -160,6 +194,8 @@ def _run_remote_processing(
except Exception as exc:
print(f"自动清理处理历史失败: {exc}")
state.reset_task_lock()
if on_finish:
on_finish(task_id, final_status)
def _format_bytes(size: int) -> str:
+60 -2
View File
@@ -34,6 +34,59 @@ class MySQLConfig:
dbname: str = "CapacityReport"
@dataclass
class AutoSchedulerConfig:
enabled: bool = False
check_interval_hours: int = 1
expected_directories: List[str] = field(default_factory=list)
week_offset: int = 0
def normalized(self) -> "AutoSchedulerConfig":
try:
check_interval_hours = int(self.check_interval_hours)
except (TypeError, ValueError):
check_interval_hours = 1
try:
week_offset = int(self.week_offset)
except (TypeError, ValueError):
week_offset = 0
directories = []
seen = set()
for directory in self.expected_directories or []:
normalized = str(directory).replace("\\", "/").strip().strip("/")
if normalized and normalized not in seen:
directories.append(normalized)
seen.add(normalized)
return AutoSchedulerConfig(
enabled=bool(self.enabled),
check_interval_hours=max(check_interval_hours, 1),
expected_directories=directories,
week_offset=week_offset,
)
def to_dict(self) -> Dict[str, Any]:
normalized = self.normalized()
return {
"enabled": normalized.enabled,
"check_interval_hours": normalized.check_interval_hours,
"expected_directories": normalized.expected_directories,
"week_offset": normalized.week_offset,
}
@classmethod
def from_dict(cls, data: Dict[str, Any] | None) -> "AutoSchedulerConfig":
data = data or {}
directories = data.get("expected_directories", [])
return cls(
enabled=bool(data.get("enabled", False)),
check_interval_hours=data.get("check_interval_hours", 1),
expected_directories=directories if isinstance(directories, list) else [],
week_offset=data.get("week_offset", 0),
).normalized()
@dataclass
class RemoteDataConfig:
enabled: bool = False
@@ -46,6 +99,7 @@ class RemoteDataConfig:
passive: bool = True
timeout: int = 30
auto_delete_source: bool = False
auto_scheduler: AutoSchedulerConfig = field(default_factory=AutoSchedulerConfig)
def normalized(self) -> "RemoteDataConfig":
protocol = self.protocol.lower().strip()
@@ -53,8 +107,9 @@ class RemoteDataConfig:
protocol = "sftp"
port = self.port or (22 if protocol == "sftp" else 21)
scheduler = self.auto_scheduler.normalized()
return RemoteDataConfig(
enabled=bool(self.enabled),
enabled=bool(self.enabled) or scheduler.enabled,
protocol=protocol,
host=self.host.strip(),
port=port,
@@ -63,7 +118,8 @@ class RemoteDataConfig:
remote_dir=(self.remote_dir or "/").strip() or "/",
passive=bool(self.passive),
timeout=max(int(self.timeout or 30), 1),
auto_delete_source=bool(self.auto_delete_source),
auto_delete_source=bool(self.auto_delete_source) or scheduler.enabled,
auto_scheduler=scheduler,
)
def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
@@ -77,6 +133,7 @@ class RemoteDataConfig:
"passive": self.passive,
"timeout": self.timeout,
"auto_delete_source": self.auto_delete_source,
"auto_scheduler": self.auto_scheduler.normalized().to_dict(),
}
if include_password:
data["passwd"] = self.passwd
@@ -106,6 +163,7 @@ class RemoteDataConfig:
passive=bool(data.get("passive", True)),
timeout=timeout,
auto_delete_source=bool(data.get("auto_delete_source", False)),
auto_scheduler=AutoSchedulerConfig.from_dict(data.get("auto_scheduler")),
).normalized()
+28
View File
@@ -1,4 +1,5 @@
import argparse
from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
@@ -25,6 +26,7 @@ from app.api.routers import (
from app.auth import extract_access_token, resolve_access_context, resolve_login_context
from app.config import BASE_DIR
from app.services.api_tokens import touch_token_usage
from app.services.auto_scheduler import AutoScheduler
APP_VERSION = "3.0.0"
@@ -139,6 +141,14 @@ OPENAPI_OPERATION_DOCS = {
"summary": "远程下载并处理",
"description": "从已配置的 FTP/SFTP 目录递归下载源数据,然后自动执行完整处理流程。",
},
("get", "/api/remote/scheduler/status"): {
"summary": "查询远程自动调度状态",
"description": "返回自动调度启用状态、目标周、就绪标识、下次检查时间和各远程目录的日期覆盖情况。",
},
("post", "/api/remote/scheduler/trigger"): {
"summary": "手动触发自动调度检查",
"description": "立即执行一次远程目录就绪检查;如果已存在就绪标识,会直接触发远程下载并处理。",
},
("post", "/api/history"): {
"summary": "查询处理历史",
"description": "按最近时间返回处理历史记录。",
@@ -249,6 +259,12 @@ OPENAPI_OPERATION_DOCS = {
"passive": True,
"timeout": 30,
"auto_delete_source": False,
"auto_scheduler": {
"enabled": False,
"check_interval_hours": 1,
"expected_directories": ["4G/FDD", "4G/900", "5G/2.6", "5G/700"],
"week_offset": 0,
},
},
},
("post", "/api/config/history-retention"): {
@@ -312,6 +328,17 @@ OPENAPI_OPERATION_DOCS = {
}
@asynccontextmanager
async def app_lifespan(app: FastAPI):
state.auto_scheduler = AutoScheduler()
state.auto_scheduler.start()
try:
yield
finally:
if state.auto_scheduler is not None:
state.auto_scheduler.stop()
def create_app() -> FastAPI:
app = FastAPI(
title="CapacityReport",
@@ -320,6 +347,7 @@ def create_app() -> FastAPI:
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=app_lifespan,
)
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]
+41 -3
View File
@@ -18,6 +18,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from app.config import AppConfig, SQL_SCRIPT
from app.database import DatabaseManager
from app.utils.file_dates import DirectoryDateSelection, select_recent_items_by_directory
class ProcessLogger:
@@ -246,7 +247,7 @@ class DataProcessor:
def _unzip_files(self):
"""解压所有 ZIP 文件(支持中文文件名)"""
self.logger.info("正在解压 ZIP 文件...")
zip_files = list(self.work_dir.rglob("*.zip"))
zip_files = self._filter_recent_files(list(self.work_dir.rglob("*.zip")), "ZIP")
zip_count = 0
for zip_file in zip_files:
@@ -338,6 +339,43 @@ class DataProcessor:
for ext in extensions:
for file in directory.rglob(f"*{ext}"):
yield file
def _filter_recent_files(self, files: list[Path], label: str, root: Path | None = None) -> list[Path]:
if not files:
return files
base = (root or self.work_dir).resolve()
def parent_key(file_path: Path) -> str:
try:
parent = file_path.parent.resolve().relative_to(base)
except ValueError:
parent = file_path.parent
parent_text = str(parent).replace("\\", "/")
return "" if parent_text == "." else parent_text
selected, summaries = select_recent_items_by_directory(
files,
parent_key=parent_key,
name_key=lambda file_path: file_path.name,
)
self._log_recent_file_selection(label, summaries)
return sorted(selected)
def _log_recent_file_selection(self, label: str, summaries: list[DirectoryDateSelection]) -> None:
skipped_total = sum(summary.skipped_count for summary in summaries)
if not skipped_total:
return
for summary in summaries:
if not summary.skipped_count or not summary.start_date or not summary.max_date:
continue
self.logger.info(
f"{label}目录 {summary.directory or '.'}: 仅处理 "
f"{summary.start_date.isoformat()} 至 {summary.max_date.isoformat()} "
f"的 {summary.selected_count}/{summary.total_count} 个文件,"
f"跳过 {summary.skipped_count} 个旧文件"
)
def _process_single_excel(self, excel_file: Path, sheet_filter: set) -> int:
"""处理单个 Excel 文件(用于并行)"""
@@ -368,7 +406,7 @@ class DataProcessor:
def _process_excel_files_parallel(self):
"""并行处理 Excel 文件"""
self.logger.info("正在并行处理 Excel 文件...")
excel_files = list(self._scan_files(self.work_dir, ['.xlsx', '.xls']))
excel_files = self._filter_recent_files(list(self._scan_files(self.work_dir, ['.xlsx', '.xls'])), "Excel")
self.logger.info(f"找到 {len(excel_files)} 个 Excel 文件")
if not excel_files:
@@ -794,7 +832,7 @@ class DataProcessor:
self.db.drop_table(table_name)
# 处理该目录下的所有 CSV
csv_files = list(self._scan_files(subdir, ['.csv']))
csv_files = self._filter_recent_files(list(self._scan_files(subdir, ['.csv'])), "CSV", root=subdir)
self.logger.info(f"找到 {len(csv_files)} 个 CSV 文件")
total_rows = 0
+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,
)
+1
View File
@@ -8,6 +8,7 @@ config = AppConfig.load()
history_manager = HistoryManager()
processing_tasks: dict[str, dict[str, Any]] = {}
upload_sessions: dict[str, dict[str, Any]] = {}
auto_scheduler: Any | None = None
global_task_lock: dict[str, Any] = {
"locked": False,
+108
View File
@@ -0,0 +1,108 @@
"""Filename date parsing and per-directory date filtering."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Callable, Iterable, TypeVar
FILENAME_DATE_RE = re.compile(r"(?<!\d)(20\d{10}(?:\d{2})?)(?!\d)")
T = TypeVar("T")
@dataclass(frozen=True)
class DirectoryDateSelection:
directory: str
total_count: int
selected_count: int
skipped_count: int
undated_count: int
max_date: date | None = None
start_date: date | None = None
def extract_file_date(filename: str | Path) -> date | None:
"""Extract the first timestamp date from a filename."""
match = FILENAME_DATE_RE.search(Path(str(filename)).name)
if not match:
return None
return parse_file_timestamp(match.group(1))
def parse_file_timestamp(value: str) -> date | None:
fmt = "%Y%m%d%H%M%S" if len(value) == 14 else "%Y%m%d%H%M"
try:
return datetime.strptime(value, fmt).date()
except ValueError:
return None
def required_week_days(week_offset: int = 0, today: date | None = None) -> list[date]:
"""Return Monday-Sunday dates for the target natural week.
week_offset=0 means last week, -1 means the week before last.
"""
base_day = today or date.today()
this_monday = base_day - timedelta(days=base_day.weekday())
target_monday = this_monday - timedelta(days=7 * (1 - week_offset))
return [target_monday + timedelta(days=offset) for offset in range(7)]
def select_recent_items_by_directory(
items: Iterable[T],
*,
parent_key: Callable[[T], str],
name_key: Callable[[T], str],
days: int = 7,
) -> tuple[list[T], list[DirectoryDateSelection]]:
"""Select the latest N natural days of dated files in each directory.
If a directory has no dated files at all, all files in that directory are
kept to preserve compatibility with legacy/manual data.
"""
safe_days = max(int(days), 1)
groups: dict[str, list[T]] = {}
for item in items:
groups.setdefault(parent_key(item), []).append(item)
selected: list[T] = []
summaries: list[DirectoryDateSelection] = []
for directory, group_items in sorted(groups.items(), key=lambda pair: pair[0]):
dated_items = [(item, extract_file_date(name_key(item))) for item in group_items]
valid_dates = [file_date for _, file_date in dated_items if file_date]
if not valid_dates:
selected.extend(group_items)
summaries.append(
DirectoryDateSelection(
directory=directory,
total_count=len(group_items),
selected_count=len(group_items),
skipped_count=0,
undated_count=len(group_items),
)
)
continue
max_date = max(valid_dates)
start_date = max_date - timedelta(days=safe_days - 1)
group_selected = [
item
for item, file_date in dated_items
if file_date is not None and start_date <= file_date <= max_date
]
selected.extend(group_selected)
summaries.append(
DirectoryDateSelection(
directory=directory,
total_count=len(group_items),
selected_count=len(group_selected),
skipped_count=len(group_items) - len(group_selected),
undated_count=sum(1 for _, file_date in dated_items if file_date is None),
max_date=max_date,
start_date=start_date,
)
)
return selected, summaries
+366
View File
@@ -0,0 +1,366 @@
# 远程数据自动调度功能设计方案
## 一、需求概述
### 1.1 背景
当前系统需要每周一手动点击"远程下载并处理"来跑上周数据(周一到周日,7天数据)。由于服务器时间不准确,无法使用定时任务在固定时间运行。需要一个基于**数据就绪状态**的自动调度机制。
### 1.2 核心需求
1. **文件时间过滤**:处理时只处理最近7天的文件
2. **自动就绪检测**:每小时检查远程目录,判断上周7天数据是否齐全
3. **自动触发处理**:数据就绪后自动下载并处理
4. **状态标识管理**:使用标识文件避免重复触发
---
## 二、文件时间解析规则
### 2.1 文件名格式
| 格式 | 示例 | 数据日期 |
|------|------|----------|
| `XXX_YYYYMMDDHHMM_YYYYMMDDHHMM` | `CapacityReportData2.6_202605110000_202605120000.zip` | 第一个时间戳 `202605110000` → 2026-05-11 |
| `XXX_YYYYMMDDHHMM` | `CapacityReportData2.6_202605110000.zip` | 时间戳 `202605110000` → 2026-05-11 |
### 2.2 解析逻辑
复用项目已有的正则 `_ZIP_DATE_RE = re.compile(r"(?<!\d)(20\d{10}(?:\d{2})?)(?!\d)")`,取第一个匹配项作为数据日期。
```python
def extract_file_date(filename: str) -> date | None:
"""从文件名提取数据日期(取第一个匹配的时间戳)"""
match = _ZIP_DATE_RE.search(filename)
if not match:
return None
timestamp = match.group(1) # 12位: YYYYMMDDHHMM 或 14位: YYYYMMDDHHMMSS
fmt = "%Y%m%d%H%M%S" if len(timestamp) == 14 else "%Y%m%d%H%M"
try:
return datetime.strptime(timestamp, fmt).date()
except ValueError:
return None
```
---
## 三、自动调度机制
### 3.1 工作流程
```
每小时定时器触发
│
▼
┌─────────────────────┐
│ 检查就绪标识文件 │
│ (ready.flag) │
└─────────────────────┘
│
├── 标识存在 ──► 跳过检测,直接触发处理
│
▼
┌─────────────────────┐
│ 连接 FTP/SFTP │
│ 遍历远程目录 │
└─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 对每个 expected_directory: │
│ 1. 列出所有 ZIP 文件名 │
│ 2. 从文件名提取数据日期 │
│ 3. 检查是否覆盖上周7天(周一~周日)│
└─────────────────────────────────────┘
│
├── 未就绪 ──► 记录日志,等待下次检查
│
▼
┌─────────────────────┐
│ 写入就绪标识文件 │
│ (ready.flag) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 触发远程下载处理 │
│ (复用现有流程) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 处理成功后 │
│ 1. 删除远程源文件 │
│ 2. 删除就绪标识 │
└─────────────────────┘
│
▼
继续每小时检查
```
### 3.2 目标日期范围(严格按自然周)
**永远是上周一到上周日**(自然周,7天)。无论今天是周几,程序都会自动计算正确的上周一和上周日。
```python
def get_target_week_range(week_offset: int = 0) -> tuple[date, date]:
"""获取目标周的周一到周日。
week_offset=0 表示上周,-1 表示上上周
"""
today = date.today()
this_monday = today - timedelta(days=today.weekday())
target_monday = this_monday - timedelta(days=7 * (1 - week_offset))
target_sunday = target_monday + timedelta(days=6)
return target_monday, target_sunday
```
示例(假设 week_offset=0):
| 今天 | 本周一 | 上周一 | 上周日 | 检查范围 |
|------|--------|--------|--------|----------|
| 周一 5/19 | 5/19 | 5/12 | 5/18 | 5/12~5/18 |
| 周三 5/21 | 5/19 | 5/12 | 5/18 | 5/12~5/18 |
| 周日 5/25 | 5/19 | 5/12 | 5/18 | 5/12~5/18 |
**不会**把非自然周的7天范围(如上周五到本周四)当作目标。`week_offset` 参数仅用于补跑场景:如果上周处理失败,可设为 `-1` 检查上上周的周一到周日。
### 3.3 就绪判断
一个目录的"就绪"条件:该目录下所有 ZIP 文件提取出的数据日期集合,**完全覆盖**目标周(上周一~上周日)的全部 7 天。
**关键**:
1. 不依赖系统日期,而是从文件名中提取日期
2. 文件名只取**第一个**时间戳作为数据日期
3. 例如文件 `CapacityReportData2.6_202605120000_202605130000.zip` → 数据日期为 2026-05-12
4. 如果某个日期没有对应的 ZIP 文件,该目录判定为未就绪
### 3.4 标识文件
- **位置**:`cache/auto_scheduler/ready.flag`
- **格式**:JSON
```json
{
"ready_at": "2026-05-19T10:00:00",
"week_start": "2026-05-12",
"week_end": "2026-05-18",
"directories": {
"4G/FDD": {"found_days": 7, "required_days": 7},
"4G/900": {"found_days": 7, "required_days": 7},
"5G/2.6": {"found_days": 7, "required_days": 7},
"5G/700": {"found_days": 7, "required_days": 7}
}
}
```
---
## 四、配置项设计
### 4.1 新增配置(`Configure.json` → `RemoteData` 节点下)
```json
{
"RemoteData": {
"enabled": true,
"protocol": "sftp",
"host": "127.0.0.1",
"port": 2022,
"user": "nixevol",
"remote_dir": "/CapacityReportData",
"passive": true,
"timeout": 30,
"auto_delete_source": true,
"passwd": "242520",
"auto_scheduler": {
"enabled": false,
"check_interval_hours": 1,
"expected_directories": [
"4G/FDD",
"4G/900",
"5G/2.6",
"5G/700"
],
"week_offset": 0
}
}
}
```
### 4.2 字段说明
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | bool | `false` | 是否启用自动调度 |
| `check_interval_hours` | int | `1` | 检查间隔(小时),最小1 |
| `expected_directories` | list[str] | `[]` | 需要检测就绪的子目录路径列表(相对于 `remote_dir`)。为空时按实际 ZIP 所在目录逐个检测 |
| `week_offset` | int | `0` | 周偏移,`0` = 检查上周,`-1` = 检查上上周 |
### 4.3 约束规则
1. **自动调度开启时,`auto_delete_source` 强制为 `true`**(前端灰显、后端校验)
2. `expected_directories` 为空时按远程 ZIP 实际所在目录逐个检测
3. `check_interval_hours` 最小值为 1
---
## 五、代码改动计划
### 5.1 后端
#### `app/config.py`
- `RemoteDataConfig` 新增 4 个字段 + `AutoSchedulerConfig` 子配置类
- 更新 `from_dict()`、`to_dict()`、`to_file_dict()` 序列化逻辑
- 新增约束校验:`auto_scheduler.enabled = true` 时强制 `auto_delete_source = true`
#### `app/services/remote_download.py`
- 新增 `list_remote_zip_files(directory: str | None) -> list[RemoteFileInfo]`:递归列出指定远程目录下所有 ZIP 文件路径、相对路径、所在目录和大小(只扫描,不下载)
- 远程下载会先按每个实际目录筛选最近 7 天 ZIP;自动调度触发时按 ready flag 中的目标日期精确下载,避免数据就绪后远程目录又出现新文件时误下载非目标周数据。
#### `app/services/auto_scheduler.py`(**新建**)
- `AutoScheduler` 类
- `start()` / `stop()`:启动/停止后台定时线程
- `check_and_run()`:检查 → 就绪 → 触发处理
- `_is_ready()`:检查所有目录就绪状态
- `_mark_ready()` / `_clear_ready()`:标识文件管理
- `_trigger_processing()`:复用 `remote.py` 中的 `_run_remote_processing` 逻辑
- `get_status()` → dict:返回调度器当前状态
#### `app/main.py`
- 应用启动时初始化 `AutoScheduler` 并 start
- 应用关闭时 stop
#### `app/api/routers/remote.py`
- 新增 `GET /api/remote/scheduler/status`:查询调度状态
- 新增 `POST /api/remote/scheduler/trigger`:手动触发一次检测
- 修改 `POST /api/remote/start`:自动调度期间禁止手动触发(避免冲突)
### 5.2 前端
#### `frontend/src/components/SettingsPanel.vue`
在"远程数据源"配置区域新增"自动调度"折叠卡片:
- 启用开关
- 检查间隔(小时)输入框
- 预期目录列表(支持增删)
- 周偏移选择(默认上周)
- 状态面板:下次检查时间、上次检查结果、就绪状态、各目录检测结果
---
## 六、日志与监控
### 6.1 日志示例
```
[10:00:00] 自动调度:开始检查远程目录就绪状态
[10:00:01] 自动调度:连接 SFTP 127.0.0.1:2022
[10:00:02] 自动调度:检查 4G/FDD → 找到 5/7 天文件(缺少 2026-05-13, 2026-05-14)
[10:00:03] 自动调度:检查 4G/900 → 找到 7/7 天文件 ✓
[10:00:04] 自动调度:检查 5G/2.6 → 找到 7/7 天文件 ✓
[10:00:05] 自动调度:检查 5G/700 → 找到 6/7 天文件(缺少 2026-05-18)
[10:00:06] 自动调度:数据未就绪(2/4 目录满足),等待下次检查
...
[11:00:00] 自动调度:开始检查远程目录就绪状态
[11:00:05] 自动调度:所有目录数据就绪 (4/4),写入标识文件
[11:00:06] 自动调度:触发远程下载处理任务
[11:05:00] 自动调度:任务处理成功
[11:05:01] 自动调度:远程源文件已清理
[11:05:02] 自动调度:已清除就绪标识,继续监控
```
### 6.2 状态接口响应
```json
GET /api/remote/scheduler/status
{
"enabled": true,
"running": true,
"next_check_at": "2026-05-19T12:00:00",
"last_check_at": "2026-05-19T11:00:00",
"last_result": "triggered",
"failure_count": 0,
"task_running": false,
"ready_flag": {
"exists": false
},
"target_week": {
"start": "2026-05-12",
"end": "2026-05-18"
},
"directory_status": {
"4G/FDD": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true},
"4G/900": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true},
"5G/2.6": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true},
"5G/700": {"found_days": ["2026-05-12"], "found_count": 7, "required_count": 7, "ready": true}
}
}
```
---
## 七、异常处理
| 场景 | 处理方式 |
|------|----------|
| SFTP/FTP 连接失败 | 记录错误日志,等下次周期重试 |
| 连续失败 ≥3 次 | 在前端调度状态面板显示红色失败状态 |
| 处理失败 | **保留就绪标识**,下次检查时直接触发处理(不重新检测) |
| 远程目录不存在 | 跳过该目录,记录警告,其他目录正常检测 |
| 目录内无 ZIP 文件 | 该目录判定为未就绪 |
| 手动触发远程处理 | 自动调度运行中禁止手动触发,反之亦然(互斥锁复用 `global_task_lock`) |
---
## 八、测试要点
1. **文件名解析**
- `XXX_202605110000_202605120000.zip` → 2026-05-11 ✓
- `XXX_202605110000.zip` → 2026-05-11 ✓
- `no_date_here.zip` → None(跳过)
- `XXX_20260511000012345.zip` → 14位匹配 → 2026-05-11 ✓
2. **就绪检测**
- 所有目录都覆盖7天 → 就绪
- 某目录缺少1天 → 未就绪
- `expected_directories` 为空 → 只检测根目录
- 子目录不存在 → 跳过并记录警告
3. **调度流程**
- 检测 → 写标识 → 触发处理 → 成功 → 删标识 → 循环
- 处理失败 → 保留标识 → 下次直接触发
4. **配置联动**
- 开启自动调度 → `auto_delete_source` 自动变为 true
- 修改配置 → 调度器热重载
5. **互斥**
- 自动调度运行中 → 手动触发被拒绝
- 手动任务运行中 → 自动调度跳过本轮
---
## 九、实施步骤与工时估算
| 步骤 | 内容 | 预估工时 |
|------|------|----------|
| 1 | `app/config.py`:扩展 `RemoteDataConfig`,新增 `AutoSchedulerConfig` | 20min |
| 2 | `app/services/remote_download.py`:新增 `list_remote_zip_names()` | 30min |
| 3 | `app/services/auto_scheduler.py`:新建调度器核心逻辑 | 2h |
| 4 | `app/api/routers/remote.py`:新增状态查询和手动触发接口 | 30min |
| 5 | `app/main.py`:集成调度器启动/停止 | 10min |
| 6 | `frontend/src/components/SettingsPanel.vue`:自动调度配置 UI | 1.5h |
| 7 | `frontend/src/api/client.ts`:新增 API 调用方法 | 10min |
| 8 | 联调测试 | 1h |
| **合计** | | **约6h** |
---
## 十、注意事项
1. **不改动现有处理流程**:自动调度只是在现有"远程下载并处理"之上加了一层检测和触发机制
2. **标识文件是运行时数据**:`cache/auto_scheduler/ready.flag` 不应提交到版本库
3. **`week_offset` 用途**:如果某周处理失败需要下周补跑,可将 `week_offset` 设为 `-1` 来检查上上周的数据
4. **配置热重载**:修改自动调度配置后,调度器应在下一次检查周期使用新配置(不需要重启服务)
5. **前端 `auto_delete_source` 联动**:当自动调度开启时,前端应将"处理成功后删除源文件"开关灰显为强制开启状态
+11
View File
@@ -1,5 +1,16 @@
# 项目上下文记录
## 2026-05-25:新增远程自动调度和每目录 7 天处理窗口
- 新增 `app/utils/file_dates.py`,统一解析文件名中的第一个 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳,并提供按目录筛选最近 7 个自然日文件的工具;本地手动上传和远程下载后的 ZIP、Excel、CSV 处理都会按所在目录只保留最近 7 天文件,未携带日期且同目录没有任何可识别日期时保留兼容。
- `app/services/remote_download.py` 增加远程 ZIP 清单扫描和筛选下载:普通远程处理按每个远程目录下载最近 7 天 ZIP;自动调度触发时按 ready flag 中记录的目标日期精确下载,若没有匹配 ZIP 不会回退全量下载,避免误处理新旧混杂数据。
- `app/config.py` 增加 `RemoteData.auto_scheduler` 配置,包含 `enabled`、`check_interval_hours`、`expected_directories` 和 `week_offset`。自动调度开启时后端会强制 `RemoteData.enabled=True` 和 `auto_delete_source=True`,前端也同步灰显并强制打开相关开关。
- 新增 `app/services/auto_scheduler.py` 后台线程:应用启动后按配置间隔检查 FTP/SFTP 目录,使用本机当前日期计算目标自然周(`week_offset=0` 为上周,`-1` 为上上周),但文件覆盖情况完全以 ZIP 文件名日期为准;全部目录覆盖 7 天后写入 `cache/auto_scheduler/ready.flag`,下一轮检测再触发远程下载并处理。
- 调度成功且远程源文件清理成功后会删除 ready flag;处理失败、触发失败或源文件清理失败会保留 ready flag 供下轮重试。扫描失败会记录 `scan_failed`,连续失败次数通过状态接口返回,前端会显示红色失败状态。
- `app/api/routers/remote.py` 新增 `/api/remote/scheduler/status` 和 `/api/remote/scheduler/trigger`,并将远程处理启动逻辑抽成 `start_remote_processing_job()` 供手动按钮和调度器复用。
- `frontend/src/components/SettingsPanel.vue` 在远程数据源配置中加入自动调度区域:启用开关、检查间隔、目标周期、预期目录维护、调度状态、刷新状态和立即检查。配置下载/上传会随 `RemoteData` 一起携带自动调度配置。
- 已验证:文件名日期解析、目标周计算、每目录 7 天筛选、调度开启强制删除源文件配置、调度日期精确筛选逻辑均通过临时 Python 片段;`.venv\Scripts\python.exe -m compileall app` 与 `npm run build` 通过,前端构建仅保留既有 Vite 大 chunk 警告。
## 2026-05-25:修复 API Token 指定日期输入不可见
- `frontend/src/components/ApiTokenManager.vue` 中创建/编辑 Token 的到期日期控件从 `n-date-picker` 改为原生 `input[type=date]`,避免日期选择组件在弹窗内出现占位但输入框不可见的问题。
+255 -7
View File
@@ -84,12 +84,15 @@
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="启用远程自动化">
<n-switch v-model:value="remoteForm.enabled" />
<n-switch v-model:value="remoteForm.enabled" :disabled="remoteForm.auto_scheduler.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="处理成功后删除源文件">
<n-switch v-model:value="remoteForm.auto_delete_source" />
<n-switch
v-model:value="remoteForm.auto_delete_source"
:disabled="remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
@@ -142,6 +145,111 @@
<n-input v-model:value="remoteForm.remote_dir" placeholder="/CapacityReportData" />
</n-form-item>
<p class="form-hint">自动化执行会递归下载该目录下的全部文件和文件夹到本地缓存,再按现有处理流程入库和执行脚本;自动删除源文件只会在处理成功后删除远程文件,保留目录结构。</p>
<n-divider class="settings-compact-divider">自动调度</n-divider>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="启用自动调度">
<n-switch v-model:value="remoteForm.auto_scheduler.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="检查间隔(小时)">
<n-input-number
v-model:value="remoteForm.auto_scheduler.check_interval_hours"
class="full-width"
:min="1"
:precision="0"
:disabled="!remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="目标周期">
<n-select
v-model:value="remoteForm.auto_scheduler.week_offset"
:options="schedulerWeekOptions"
:disabled="!remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="预期目录">
<div class="scheduler-dir-editor">
<n-input-group>
<n-input
v-model:value="newSchedulerDirectory"
placeholder="例如 4G/FDD"
:disabled="!remoteForm.auto_scheduler.enabled"
@keydown.enter.prevent="addSchedulerDirectory"
/>
<n-button :disabled="!remoteForm.auto_scheduler.enabled" @click="addSchedulerDirectory">
添加
</n-button>
</n-input-group>
<div class="scheduler-dir-tags">
<n-tag
v-for="(directory, index) in remoteForm.auto_scheduler.expected_directories"
:key="`${directory}-${index}`"
closable
:disabled="!remoteForm.auto_scheduler.enabled"
@close="removeSchedulerDirectory(index)"
>
{{ directory }}
</n-tag>
<n-tag v-if="remoteForm.auto_scheduler.expected_directories.length === 0" type="default">
按实际 ZIP 目录检测
</n-tag>
</div>
</div>
</n-form-item>
<p class="form-hint">自动调度会按文件名日期检查目标自然周 7 天;开启后会强制启用远程自动化和处理成功后删除源文件。</p>
<div class="scheduler-status-panel">
<div class="scheduler-status-header">
<span>调度状态</span>
<n-tag size="small" :type="schedulerStatusTagType">
{{ schedulerStatusLabel }}
</n-tag>
</div>
<div class="scheduler-status-grid">
<div>
<span>目标周</span>
<strong>{{ schedulerTargetWeekText }}</strong>
</div>
<div>
<span>下次检查</span>
<strong>{{ schedulerStatus?.next_check_at || '-' }}</strong>
</div>
<div>
<span>就绪标识</span>
<strong>{{ schedulerStatus?.ready_flag?.exists ? '已存在' : '无' }}</strong>
</div>
</div>
<p class="form-hint">{{ schedulerStatus?.last_message || '自动调度状态尚未加载' }}</p>
<div v-if="schedulerDirectoryRows.length > 0" class="scheduler-directory-list">
<div v-for="row in schedulerDirectoryRows" :key="row.name" class="scheduler-directory-row">
<span class="scheduler-directory-name">{{ row.name }}</span>
<n-tag size="small" :type="row.ready ? 'success' : 'warning'">
{{ row.ready ? '就绪' : `缺 ${row.missing_days.length} 天` }}
</n-tag>
</div>
</div>
<n-space justify="end" size="small">
<n-button size="small" :loading="loadingSchedulerStatus" @click="loadSchedulerStatus">
刷新状态
</n-button>
<n-button
size="small"
type="primary"
:loading="triggeringScheduler"
:disabled="!remoteForm.auto_scheduler.enabled"
@click="triggerSchedulerCheck"
>
立即检查
</n-button>
</n-space>
</div>
</n-form>
<template #footer>
@@ -310,12 +418,19 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5';
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, HistoryRetentionConfig, RemoteDataConfig } from '../types';
import type {
ApiMessage,
AppConfig,
HistoryRetentionConfig,
RemoteAutoSchedulerConfig,
RemoteDataConfig,
RemoteSchedulerStatus
} from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
import ApiTokenManager from './ApiTokenManager.vue';
@@ -335,6 +450,8 @@ const configUpdateText = computed(() => `更新时间:${configUpdate.value ||
const loading = ref(false);
const testingDb = ref(false);
const testingRemote = ref(false);
const loadingSchedulerStatus = ref(false);
const triggeringScheduler = ref(false);
const savingMysql = ref(false);
const savingRemote = ref(false);
const savingHistoryRetention = ref(false);
@@ -345,6 +462,8 @@ const sheetFilters = ref<string[]>([]);
const newSheetFilter = ref('');
const extractFields = ref<ExtractFieldConfig[]>([]);
const fieldSearch = ref('');
const newSchedulerDirectory = ref('');
const schedulerStatus = ref<RemoteSchedulerStatus | null>(null);
const newExtractValues = reactive<Record<number, string>>({});
const mysqlForm = reactive({
@@ -365,7 +484,13 @@ const remoteForm = reactive<RemoteDataConfig>({
remote_dir: '/',
passive: true,
timeout: 30,
auto_delete_source: false
auto_delete_source: false,
auto_scheduler: {
enabled: false,
check_interval_hours: 1,
expected_directories: [],
week_offset: 0
}
});
const historyRetentionForm = reactive<HistoryRetentionConfig>({
@@ -389,6 +514,10 @@ const remoteProtocolOptions: SelectOption[] = [
{ label: 'SFTP', value: 'sftp' },
{ label: 'FTP', value: 'ftp' }
];
const schedulerWeekOptions: SelectOption[] = [
{ label: '上周', value: 0 },
{ label: '上上周', value: -1 }
];
const visibleFieldMappings = computed(() => {
const keyword = fieldSearch.value.trim().toLowerCase();
@@ -401,6 +530,49 @@ const visibleFieldMappings = computed(() => {
return fieldName.includes(keyword) || extractText.includes(keyword);
});
});
const schedulerStatusLabel = computed(() => {
if (!schedulerStatus.value) return '未加载';
if (!schedulerStatus.value.enabled) return '未启用';
if (schedulerStatus.value.task_running) return '处理中';
if ((schedulerStatus.value.failure_count || 0) >= 3) return '连续失败';
if (schedulerStatus.value.ready_flag?.exists) return '已就绪';
if (schedulerStatus.value.last_result === 'scan_failed') return '检查失败';
if (schedulerStatus.value.last_result === 'trigger_failed') return '触发失败';
if (schedulerStatus.value.last_result === 'waiting') return '等待数据';
if (schedulerStatus.value.last_result === 'marked_ready') return '已标记';
if (schedulerStatus.value.last_result === 'completed') return '已完成';
return schedulerStatus.value.running ? '运行中' : '已停止';
});
const schedulerStatusTagType = computed(() => {
if (!schedulerStatus.value?.enabled) return 'default';
if ((schedulerStatus.value.failure_count || 0) >= 3) return 'error';
if (schedulerStatus.value.task_running || schedulerStatus.value.ready_flag?.exists) return 'success';
if (['scan_failed', 'trigger_failed'].includes(schedulerStatus.value.last_result || '')) return 'error';
if (schedulerStatus.value.last_result === 'waiting') return 'warning';
return 'info';
});
const schedulerTargetWeekText = computed(() => {
const week = schedulerStatus.value?.target_week;
if (!week) return '-';
return `${week.start} 至 ${week.end}`;
});
const schedulerDirectoryRows = computed(() => {
const directoryStatus = schedulerStatus.value?.directory_status || {};
return Object.entries(directoryStatus).map(([name, status]) => ({
name,
...status
}));
});
watch(
() => remoteForm.auto_scheduler.enabled,
enabled => {
if (enabled) {
remoteForm.enabled = true;
remoteForm.auto_delete_source = true;
}
}
);
onMounted(() => {
setPageHeader({
@@ -411,6 +583,7 @@ onMounted(() => {
]
});
void loadConfig();
void loadSchedulerStatus();
});
onBeforeUnmount(() => {
@@ -432,6 +605,7 @@ async function loadConfig() {
sheetFilters.value = [...config.sheet_filter];
extractFields.value = normalizeExtractFields(config.extract_fields);
resetExtractInputs();
enforceRemoteSchedulerRules();
} catch (error) {
message.error(error instanceof Error ? error.message : '加载配置失败');
} finally {
@@ -470,6 +644,8 @@ async function saveMysql() {
}
function getRemotePayload(): RemoteDataConfig {
enforceRemoteSchedulerRules();
const autoScheduler = normalizeAutoSchedulerConfig(remoteForm.auto_scheduler);
return {
...remoteForm,
protocol: remoteForm.protocol,
@@ -480,7 +656,8 @@ function getRemotePayload(): RemoteDataConfig {
remote_dir: remoteForm.remote_dir.trim() || '/',
passive: remoteForm.passive,
timeout: remoteForm.timeout || 30,
auto_delete_source: remoteForm.auto_delete_source
auto_delete_source: remoteForm.auto_delete_source,
auto_scheduler: autoScheduler
};
}
@@ -500,6 +677,7 @@ async function saveRemote() {
const result = await apiPost<ApiMessage>('/api/config/remote', getRemotePayload());
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '远程数据源配置已保存');
await loadSchedulerStatus();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存远程数据源配置失败');
} finally {
@@ -507,6 +685,51 @@ async function saveRemote() {
}
}
function addSchedulerDirectory() {
const value = normalizeDirectoryText(newSchedulerDirectory.value);
if (!value) return;
if (remoteForm.auto_scheduler.expected_directories.includes(value)) {
message.warning('该目录已存在');
return;
}
remoteForm.auto_scheduler.expected_directories.push(value);
newSchedulerDirectory.value = '';
}
function removeSchedulerDirectory(index: number) {
remoteForm.auto_scheduler.expected_directories.splice(index, 1);
}
async function loadSchedulerStatus() {
loadingSchedulerStatus.value = true;
try {
schedulerStatus.value = await apiGet<RemoteSchedulerStatus>('/api/remote/scheduler/status');
} catch (error) {
message.error(error instanceof Error ? error.message : '加载自动调度状态失败');
} finally {
loadingSchedulerStatus.value = false;
}
}
async function triggerSchedulerCheck() {
triggeringScheduler.value = true;
try {
const result = await apiPost<{ success: boolean; message?: string; status?: RemoteSchedulerStatus }>(
'/api/remote/scheduler/trigger'
);
if (result.status) {
schedulerStatus.value = result.status;
} else {
await loadSchedulerStatus();
}
message[result.success ? 'success' : 'warning'](result.message || '自动调度检查已执行');
} catch (error) {
message.error(error instanceof Error ? error.message : '自动调度检查失败');
} finally {
triggeringScheduler.value = false;
}
}
async function testRemote() {
if (!validateRemoteForm()) return;
@@ -694,6 +917,7 @@ async function uploadConfigFile(event: Event) {
function normalizeRemoteConfig(config: RemoteDataConfig | undefined): RemoteDataConfig {
const protocol = config?.protocol === 'ftp' ? 'ftp' : 'sftp';
const autoScheduler = normalizeAutoSchedulerConfig(config?.auto_scheduler);
return {
enabled: Boolean(config?.enabled),
protocol,
@@ -704,10 +928,34 @@ function normalizeRemoteConfig(config: RemoteDataConfig | undefined): RemoteData
remote_dir: config?.remote_dir || '/',
passive: config?.passive ?? true,
timeout: config?.timeout || 30,
auto_delete_source: Boolean(config?.auto_delete_source)
auto_delete_source: Boolean(config?.auto_delete_source) || autoScheduler.enabled,
auto_scheduler: autoScheduler
};
}
function normalizeAutoSchedulerConfig(config: RemoteAutoSchedulerConfig | undefined): RemoteAutoSchedulerConfig {
return {
enabled: Boolean(config?.enabled),
check_interval_hours: Math.max(Number(config?.check_interval_hours ?? 1) || 1, 1),
expected_directories: uniqueStrings(config?.expected_directories || []).map(normalizeDirectoryText).filter(Boolean),
week_offset: Number.isFinite(Number(config?.week_offset)) ? Number(config?.week_offset) : 0
};
}
function enforceRemoteSchedulerRules() {
if (!remoteForm.auto_scheduler.enabled) return;
remoteForm.enabled = true;
remoteForm.auto_delete_source = true;
remoteForm.auto_scheduler.check_interval_hours = Math.max(Number(remoteForm.auto_scheduler.check_interval_hours) || 1, 1);
remoteForm.auto_scheduler.expected_directories = uniqueStrings(remoteForm.auto_scheduler.expected_directories)
.map(normalizeDirectoryText)
.filter(Boolean);
}
function normalizeDirectoryText(value: string): string {
return value.replace(/\\/g, '/').trim().replace(/^\/+|\/+$/g, '');
}
function normalizeHistoryRetentionConfig(config: HistoryRetentionConfig | undefined): HistoryRetentionConfig {
return {
enabled: Boolean(config?.enabled),
+103
View File
@@ -1346,6 +1346,104 @@ select {
line-height: 20px;
}
.settings-compact-divider {
margin: 12px 0;
}
.scheduler-dir-editor {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.scheduler-dir-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.scheduler-status-panel {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding: 10px 12px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
}
.scheduler-status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.scheduler-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.scheduler-status-grid > div {
min-width: 0;
}
.scheduler-status-grid span {
display: block;
margin-bottom: 2px;
color: var(--td-text-color-placeholder);
font-size: 11px;
}
.scheduler-status-grid strong {
display: block;
overflow: hidden;
color: var(--td-text-color-primary);
font-family: var(--td-font-family-mono);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.scheduler-directory-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
max-height: 96px;
overflow-y: auto;
}
.scheduler-directory-row {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
gap: 8px;
padding: 5px 8px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-small);
}
.scheduler-directory-name {
min-width: 0;
overflow: hidden;
color: var(--td-text-color-primary);
font-family: var(--td-font-family-mono);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.field-mapping-card {
display: flex !important;
height: 100%;
@@ -1761,4 +1859,9 @@ select {
width: 100%;
flex: none;
}
.scheduler-status-grid,
.scheduler-directory-list {
grid-template-columns: 1fr;
}
}
+49
View File
@@ -94,6 +94,55 @@ export interface RemoteDataConfig {
passive: boolean;
timeout: number;
auto_delete_source: boolean;
auto_scheduler: RemoteAutoSchedulerConfig;
}
export interface RemoteAutoSchedulerConfig {
enabled: boolean;
check_interval_hours: number;
expected_directories: string[];
week_offset: number;
}
export interface RemoteSchedulerStatus {
enabled: boolean;
running: boolean;
check_interval_hours?: number;
expected_directories?: string[];
week_offset?: number;
auto_delete_source?: boolean;
next_check_at?: string | null;
last_check_at?: string | null;
last_result?: string;
last_message?: string;
failure_count?: number;
task_running?: boolean;
task_id?: string | null;
ready_flag?: {
exists: boolean;
ready_at?: string;
week_start?: string;
week_end?: string;
[key: string]: unknown;
};
target_week?: {
start: string;
end: string;
days?: string[];
};
directory_status?: Record<
string,
{
ready: boolean;
found_days: string[];
missing_days: string[];
found_count: number;
required_count: number;
file_count: number;
error?: string | null;
}
>;
message?: string;
}
export interface HistoryRetentionConfig {