feat: 实现 CellData 预处理与单独刷新

This commit is contained in:
2026-06-25 04:28:24 +08:00
parent 66a2eebdf9
commit c36dd21838
13 changed files with 1027 additions and 13 deletions
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import time
from datetime import datetime
from pathlib import Path
from threading import Thread
from fastapi import APIRouter, Body, HTTPException
from app import state
from app.api.routers.task_runtime import set_task_stage
from app.config import AppConfig, CACHE_DIR
from app.processor import ProcessLogger
from app.services.cell_data import refresh_cell_data
router = APIRouter(tags=["cell-data"])
@router.post("/api/cell-data/process/start")
async def start_cell_data_processing():
if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
task_id = "cell_data_" + datetime.now().strftime("%Y%m%d_%H%M%S")
work_dir = CACHE_DIR / task_id
work_dir.mkdir(parents=True, exist_ok=True)
logs: list[str] = []
current_stage = "locating"
def log_callback(message: str) -> None:
logs.append(message)
set_task_stage(task_id, current_stage, logs)
def stage_callback(stage: str) -> None:
nonlocal current_stage
current_stage = stage
set_task_stage(task_id, current_stage, logs)
logger = ProcessLogger(
log_file=work_dir / "log.txt",
callback=log_callback,
stage_callback=stage_callback,
)
app_config = state.current_config()
state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage}
state.global_task_lock.update(
{
"locked": True,
"task_id": task_id,
"stage": current_stage,
"started_at": datetime.now().isoformat(),
}
)
thread = Thread(target=_run_cell_data_processing, args=(task_id, work_dir, logger, app_config), daemon=True)
thread.start()
return {"success": True, "message": "CellData 处理已启动", "task_id": task_id, "stage": current_stage}
@router.post("/api/cell-data/process/status")
async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)):
if task_id in state.processing_tasks:
task = state.processing_tasks[task_id]
return {
"task_id": task_id,
"status": task.get("status", "processing"),
"stage": task.get("stage", "processing"),
"logs": task.get("logs", []),
"error": task.get("error"),
"result": task.get("result"),
"elapsed_time": task.get("elapsed_time"),
}
raise HTTPException(status_code=404, detail="任务不存在")
def _run_cell_data_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
started = time.time()
try:
result = refresh_cell_data(app_config, work_dir, logger)
elapsed = round(time.time() - started, 2)
state.processing_tasks[task_id] = {
"logs": logger.get_logs(),
"status": "completed",
"stage": "completed",
"elapsed_time": elapsed,
"result": {
"selected_files": result.selected_files,
"parsed_rows": result.parsed_rows,
"imported_rows": result.imported_rows,
"skipped_rows": result.skipped_rows,
},
}
except Exception as exc:
logger.error(str(exc))
state.processing_tasks[task_id] = {
"logs": logger.get_logs(),
"status": "failed",
"stage": "failed",
"error": str(exc),
"elapsed_time": round(time.time() - started, 2),
}
finally:
state.reset_task_lock()
+84
View File
@@ -1,4 +1,5 @@
import json
import re
from datetime import datetime
from typing import Any
from urllib.parse import quote
@@ -11,6 +12,7 @@ from app import state
from app.config import (
CellDataConfig,
DataMappingsConfig,
DEFAULT_CELL_DATA_MAPPING,
HistoryRetentionConfig,
MetrixConfig,
MySQLConfig,
@@ -103,6 +105,11 @@ async def update_cell_data_remote_config(config: dict[str, Any] = Body(...)):
state.config.cell_data = CellDataConfig(
remote_data=RemoteDataConfig.from_dict(config),
mysql=current.mysql,
scan_paths=current.scan_paths,
year_dir_regex=current.year_dir_regex,
file_name_regex=current.file_name_regex,
file_time_regex=current.file_time_regex,
mapping=current.mapping,
).normalized()
state.config.save()
return {"success": True, "message": "CellData 远程数据源配置已更新", "update": state.config.update}
@@ -115,6 +122,11 @@ async def update_cell_data_mysql_config(config: dict[str, Any] = Body(...)):
state.config.cell_data = CellDataConfig(
remote_data=current.remote_data,
mysql=MySQLConfig.from_dict(config, default_dbname="celldata"),
scan_paths=current.scan_paths,
year_dir_regex=current.year_dir_regex,
file_name_regex=current.file_name_regex,
file_time_regex=current.file_time_regex,
mapping=current.mapping,
).normalized()
state.config.save()
return {"success": True, "message": "CellData 数据库配置已更新", "update": state.config.update}
@@ -136,6 +148,36 @@ async def test_cell_data_mysql_connection(config: dict[str, Any] | None = Body(N
return _test_mysql_config(mysql_config)
@router.post("/api/config/cell-data/settings")
async def update_cell_data_settings(config: dict[str, Any] = Body(...)):
validation = _validate_cell_data_settings(config)
if not validation["success"]:
raise HTTPException(status_code=400, detail=validation["message"])
state.reload_config()
current = state.config.cell_data.normalized()
state.config.cell_data = CellDataConfig(
remote_data=current.remote_data,
mysql=current.mysql,
scan_paths=config.get("scan_paths", current.scan_paths),
year_dir_regex=str(config.get("year_dir_regex", current.year_dir_regex)),
file_name_regex=str(config.get("file_name_regex", current.file_name_regex)),
file_time_regex=str(config.get("file_time_regex", current.file_time_regex)),
mapping=config.get("mapping", current.mapping),
).normalized()
state.config.save()
return {"success": True, "message": "CellData 规则已更新", "update": state.config.update}
@router.post("/api/config/cell-data/settings/validate")
async def validate_cell_data_settings(config: dict[str, Any] = Body(...)):
return _validate_cell_data_settings(config)
@router.get("/api/config/cell-data/mapping/default")
async def get_default_cell_data_mapping():
return DEFAULT_CELL_DATA_MAPPING
@router.post("/api/config/history-retention")
async def update_history_retention(config: dict[str, Any] = Body(...)):
state.reload_config()
@@ -254,3 +296,45 @@ def _test_mysql_config(config: MySQLConfig) -> dict[str, Any]:
except Exception as exc:
return {"success": False, "message": str(exc)}
def _validate_cell_data_settings(config: dict[str, Any]) -> dict[str, Any]:
scan_paths = config.get("scan_paths", [])
if not isinstance(scan_paths, list) or not any(str(path).strip() for path in scan_paths):
return {"success": False, "message": "请至少配置一个扫描路径"}
for key in ("year_dir_regex", "file_name_regex", "file_time_regex"):
try:
re.compile(str(config.get(key, "")))
except re.error as exc:
return {"success": False, "message": f"{key} 正则无效: {exc}"}
mapping = config.get("mapping")
if not isinstance(mapping, dict):
return {"success": False, "message": "映射规则必须是 JSON 对象"}
if not str(mapping.get("target_table", "")).strip():
return {"success": False, "message": "映射规则缺少 target_table"}
key_config = mapping.get("key")
if not isinstance(key_config, dict) or not key_config.get("field") or not key_config.get("expr"):
return {"success": False, "message": "映射规则缺少 key.field 或 key.expr"}
sources = mapping.get("sources")
if not isinstance(sources, list) or not sources:
return {"success": False, "message": "映射规则至少需要一个 sources 项"}
for index, source in enumerate(sources, 1):
if not isinstance(source, dict):
return {"success": False, "message": f"sources 第 {index} 项必须是对象"}
if not source.get("band") or not source.get("file_prefix"):
return {"success": False, "message": f"sources 第 {index} 项缺少 band 或 file_prefix"}
fields = source.get("fields")
if not isinstance(fields, dict) or not fields:
return {"success": False, "message": f"sources 第 {index} 项缺少 fields"}
for target, rule in fields.items():
if not str(target).strip():
return {"success": False, "message": f"sources 第 {index} 项存在空目标字段"}
if isinstance(rule, str) and rule.strip():
continue
if isinstance(rule, dict) and "value" in rule:
continue
return {"success": False, "message": f"{target} 的映射规则无效"}
return {"success": True, "message": "映射规则有效"}
+3
View File
@@ -14,6 +14,7 @@ from app.api.routers.task_runtime import (
)
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
from app.processor import DataProcessor, ProcessLogger
from app.services.cell_data import refresh_cell_data
from app.services.license import LicenseError, check_processing_allowed
from app.services.platform import PlatformStorageDownloader, make_source_downloader
from app.services.pipeline import RESULT_TABLES, run_import_and_report
@@ -150,6 +151,8 @@ def _run_remote_processing(
state.history_manager.update(task_id, file_count=download_result.file_count)
refresh_cell_data(app_config, work_dir, logger)
if app_config.warehouse_type == "metrix":
started = time.time()
run_import_and_report(work_dir, app_config, logger)
+2
View File
@@ -13,6 +13,7 @@ from app.api.routers.task_runtime import (
)
from app.config import AppConfig
from app.processor import DataProcessor, ProcessLogger
from app.services.cell_data import refresh_cell_data
from app.services.license import LicenseError, check_processing_allowed
@@ -168,6 +169,7 @@ def _task_finished(task_id: str) -> bool:
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
try:
refresh_cell_data(app_config, work_dir, logger)
if app_config.warehouse_type == "metrix":
import time