feat: 增加远程自动调度处理
This commit is contained in:
@@ -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`:查询授权状态
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
@@ -339,6 +340,43 @@ class DataProcessor:
|
||||
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 文件(用于并行)"""
|
||||
processed = 0
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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` 联动**:当自动调度开启时,前端应将"处理成功后删除源文件"开关灰显为强制开启状态
|
||||
@@ -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]`,避免日期选择组件在弹窗内出现占位但输入框不可见的问题。
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user