362 lines
14 KiB
Python
362 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import csv
|
|
import re
|
|
import stat
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable
|
|
|
|
import chardet
|
|
import pymysql
|
|
|
|
from app.config import AppConfig, CellDataConfig
|
|
from app.services.remote_download import RemoteDataDownloader, RemoteFileInfo
|
|
|
|
LogFn = Callable[[str], None]
|
|
|
|
CELLINFO_COLUMNS = [
|
|
"CGI",
|
|
"eNodeBID",
|
|
"CellID",
|
|
"PLMN",
|
|
"基站名称",
|
|
"小区名称",
|
|
"频点",
|
|
"带宽",
|
|
"制式",
|
|
"功率",
|
|
"网络",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SelectedZip:
|
|
scan_path: str
|
|
band: str
|
|
remote_file: RemoteFileInfo
|
|
timestamp: str
|
|
|
|
|
|
@dataclass
|
|
class CellDataResult:
|
|
selected_files: int = 0
|
|
parsed_rows: int = 0
|
|
imported_rows: int = 0
|
|
skipped_rows: int = 0
|
|
|
|
|
|
class CellDataProcessor:
|
|
def __init__(self, app_config: AppConfig, work_dir: Path, logger=None):
|
|
self.app_config = app_config
|
|
self.config = app_config.cell_data.normalized()
|
|
self.work_dir = work_dir
|
|
self.logger = logger
|
|
self.downloader = RemoteDataDownloader(self.config.remote_data, self._log)
|
|
self.year_dir_re = re.compile(self.config.year_dir_regex)
|
|
self.file_name_re = re.compile(self.config.file_name_regex)
|
|
self.file_time_re = re.compile(self.config.file_time_regex)
|
|
|
|
def run(self) -> CellDataResult:
|
|
if not self.config.remote_data.enabled:
|
|
self._log("CellData 数据源未启用,跳过")
|
|
return CellDataResult()
|
|
|
|
self._set_stage("locating")
|
|
selected = self._select_latest_zip_files()
|
|
if not selected:
|
|
raise RuntimeError("未找到可处理的 CellData ZIP 文件")
|
|
self._log(f"已选择 {len(selected)} 个 CellData ZIP 文件")
|
|
|
|
self._set_stage("downloading")
|
|
local_files = self._download_selected(selected)
|
|
|
|
self._set_stage("parsing")
|
|
result = CellDataResult(selected_files=len(selected))
|
|
rows = self._parse_zip_files(local_files, result)
|
|
if not rows:
|
|
raise RuntimeError("CellData ZIP 中未解析到有效数据")
|
|
|
|
self._set_stage("importing")
|
|
result.imported_rows = self._replace_cellinfo(rows)
|
|
self._log(f"CellData 导入完成,共 {result.imported_rows} 行")
|
|
return result
|
|
|
|
def _select_latest_zip_files(self) -> list[SelectedZip]:
|
|
selected: list[SelectedZip] = []
|
|
for template in self.config.scan_paths:
|
|
scan_path = self._resolve_scan_path(template)
|
|
band_dirs = [
|
|
entry
|
|
for entry in self._list_dir(scan_path)
|
|
if entry["type"] == "directory"
|
|
]
|
|
for band_dir in band_dirs:
|
|
band = str(band_dir["name"])
|
|
files = [
|
|
entry
|
|
for entry in self._list_dir(str(band_dir["path"]))
|
|
if entry["type"] == "file" and self.file_name_re.search(str(entry["name"]))
|
|
]
|
|
latest = self._latest_file(files)
|
|
if latest:
|
|
selected.append(
|
|
SelectedZip(
|
|
scan_path=scan_path,
|
|
band=band,
|
|
remote_file=self._remote_file_info(str(latest["path"]), int(latest.get("size") or 0)),
|
|
timestamp=str(latest["timestamp"]),
|
|
)
|
|
)
|
|
self._log(f"{band}: {latest['name']}")
|
|
return selected
|
|
|
|
def _resolve_scan_path(self, template: str) -> str:
|
|
path = self._replace_date_placeholders(str(template).replace("\\", "/").strip())
|
|
if not path.startswith("/"):
|
|
path = self._join_remote_path(self.config.remote_data.remote_dir, path)
|
|
if "{maxyear}" not in path:
|
|
return path
|
|
|
|
parts = [part for part in path.split("/") if part]
|
|
maxyear_index = next((index for index, part in enumerate(parts) if "{maxyear}" in part), -1)
|
|
if maxyear_index < 0:
|
|
return path
|
|
parent = "/" + "/".join(parts[:maxyear_index]) if maxyear_index else "/"
|
|
year_segment = parts[maxyear_index]
|
|
years: list[int] = []
|
|
for entry in self._list_dir(parent):
|
|
if entry["type"] != "directory":
|
|
continue
|
|
match = self.year_dir_re.search(str(entry["name"]))
|
|
if match:
|
|
years.append(int(match.group("year")))
|
|
if not years:
|
|
raise RuntimeError(f"未找到年份目录: {parent}")
|
|
max_year = str(max(years))
|
|
parts[maxyear_index] = year_segment.replace("{maxyear}", max_year)
|
|
return "/" + "/".join(parts)
|
|
|
|
def _replace_date_placeholders(self, path: str) -> str:
|
|
now = datetime.now()
|
|
return (
|
|
path.replace("{yyyy}", now.strftime("%Y"))
|
|
.replace("{yyyymm}", now.strftime("%Y%m"))
|
|
.replace("{yyyymmdd}", now.strftime("%Y%m%d"))
|
|
)
|
|
|
|
def _latest_file(self, files: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
candidates: list[dict[str, Any]] = []
|
|
for item in files:
|
|
match = self.file_time_re.search(str(item["name"]))
|
|
if not match:
|
|
continue
|
|
timestamp = match.group("timestamp")
|
|
candidates.append({**item, "timestamp": timestamp})
|
|
if not candidates:
|
|
return None
|
|
return max(candidates, key=lambda item: str(item["timestamp"]))
|
|
|
|
def _download_selected(self, selected: list[SelectedZip]) -> list[tuple[SelectedZip, Path]]:
|
|
target_dir = self.work_dir / "cell_data"
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
local_files: list[tuple[SelectedZip, Path]] = []
|
|
if self.config.remote_data.protocol == "ftp":
|
|
with self.downloader._ftp_client() as ftp: # noqa: SLF001 - reuse existing connection helpers
|
|
for item in selected:
|
|
local_path = target_dir / item.band / item.remote_file.name
|
|
self.downloader._download_ftp_file(ftp, item.remote_file.path, local_path, self._download_result(), item.remote_file.size) # noqa: SLF001
|
|
local_files.append((item, local_path))
|
|
return local_files
|
|
|
|
ssh = self.downloader._sftp_ssh_client() # noqa: SLF001
|
|
try:
|
|
with ssh.open_sftp() as sftp:
|
|
for item in selected:
|
|
local_path = target_dir / item.band / item.remote_file.name
|
|
self.downloader._download_sftp_file(sftp, item.remote_file.path, local_path, self._download_result(), item.remote_file.size) # noqa: SLF001
|
|
local_files.append((item, local_path))
|
|
finally:
|
|
ssh.close()
|
|
return local_files
|
|
|
|
@staticmethod
|
|
def _download_result():
|
|
from app.services.remote_download import RemoteDownloadResult
|
|
|
|
return RemoteDownloadResult()
|
|
|
|
def _parse_zip_files(self, local_files: list[tuple[SelectedZip, Path]], result: CellDataResult) -> list[dict[str, str]]:
|
|
mapping = self.config.mapping
|
|
key_config = mapping["key"]
|
|
rows_by_key: dict[str, dict[str, str]] = {}
|
|
for selected, local_path in local_files:
|
|
with zipfile.ZipFile(local_path) as zf:
|
|
for source in mapping["sources"]:
|
|
if source["band"] != selected.band:
|
|
continue
|
|
for info in zf.infolist():
|
|
name = Path(info.filename).name
|
|
if not name.lower().endswith(".csv"):
|
|
continue
|
|
if not name.startswith(source["file_prefix"]):
|
|
continue
|
|
raw = zf.read(info.filename)
|
|
text = self._decode_csv(raw)
|
|
reader = csv.DictReader(text.splitlines())
|
|
for csv_row in reader:
|
|
row = self._map_row(source["fields"], csv_row)
|
|
key = self._render_expr(str(key_config["expr"]), row)
|
|
if not key or "--" in key:
|
|
result.skipped_rows += 1
|
|
continue
|
|
row[str(key_config["field"])] = key
|
|
rows_by_key[key] = {column: row.get(column, "") for column in CELLINFO_COLUMNS}
|
|
result.parsed_rows += 1
|
|
return list(rows_by_key.values())
|
|
|
|
def _map_row(self, fields: dict[str, Any], csv_row: dict[str, str]) -> dict[str, str]:
|
|
row: dict[str, str] = {}
|
|
for target, rule in fields.items():
|
|
if isinstance(rule, str):
|
|
row[target] = str(csv_row.get(rule, "") or "").strip()
|
|
elif isinstance(rule, dict) and "value" in rule:
|
|
row[target] = str(rule.get("value", "") or "").strip()
|
|
else:
|
|
row[target] = ""
|
|
return row
|
|
|
|
@staticmethod
|
|
def _render_expr(expr: str, row: dict[str, str]) -> str:
|
|
def replace(match):
|
|
return row.get(match.group(1), "")
|
|
|
|
return re.sub(r"\{([^{}]+)\}", replace, expr).strip()
|
|
|
|
@staticmethod
|
|
def _decode_csv(raw: bytes) -> str:
|
|
candidates = ["utf-8-sig", "utf-8", "gb18030", "gbk"]
|
|
detected = (chardet.detect(raw).get("encoding") or "").lower()
|
|
if detected and detected not in candidates:
|
|
candidates.append(detected)
|
|
for encoding in dict.fromkeys(candidates):
|
|
try:
|
|
return raw.decode(encoding).lstrip("\ufeff")
|
|
except (LookupError, UnicodeDecodeError):
|
|
pass
|
|
return raw.decode("utf-8", errors="replace").lstrip("\ufeff")
|
|
|
|
def _replace_cellinfo(self, rows: list[dict[str, str]]) -> int:
|
|
mysql = self.config.mysql.normalized()
|
|
table = str(self.config.mapping.get("target_table") or "cellinfo")
|
|
conn = pymysql.connect(
|
|
host=mysql.host,
|
|
port=mysql.port,
|
|
user=mysql.user,
|
|
password=mysql.passwd,
|
|
database=mysql.dbname,
|
|
charset="utf8mb4",
|
|
cursorclass=pymysql.cursors.DictCursor,
|
|
autocommit=False,
|
|
)
|
|
try:
|
|
with conn.cursor() as cursor:
|
|
self._ensure_cellinfo_table(cursor, table)
|
|
cursor.execute(f"TRUNCATE TABLE `{table}`")
|
|
placeholders = ", ".join(["%s"] * len(CELLINFO_COLUMNS))
|
|
columns = ", ".join(f"`{column}`" for column in CELLINFO_COLUMNS)
|
|
values = [tuple(row.get(column, "") for column in CELLINFO_COLUMNS) for row in rows]
|
|
for start in range(0, len(values), 1000):
|
|
cursor.executemany(
|
|
f"INSERT INTO `{table}` ({columns}) VALUES ({placeholders})",
|
|
values[start:start + 1000],
|
|
)
|
|
conn.commit()
|
|
return len(rows)
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
@staticmethod
|
|
def _ensure_cellinfo_table(cursor, table: str) -> None:
|
|
cursor.execute(
|
|
f"""
|
|
CREATE TABLE IF NOT EXISTS `{table}` (
|
|
`CGI` varchar(120) DEFAULT NULL,
|
|
`eNodeBID` int DEFAULT NULL,
|
|
`CellID` int DEFAULT NULL,
|
|
`PLMN` varchar(100) DEFAULT NULL,
|
|
`基站名称` varchar(200) DEFAULT NULL,
|
|
`小区名称` varchar(200) DEFAULT NULL,
|
|
`频点` varchar(50) DEFAULT NULL,
|
|
`带宽` varchar(20) DEFAULT NULL,
|
|
`制式` varchar(50) DEFAULT NULL,
|
|
`功率` varchar(100) DEFAULT NULL,
|
|
`网络` varchar(20) DEFAULT NULL,
|
|
KEY `CGI` (`CGI`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
"""
|
|
)
|
|
|
|
def _list_dir(self, remote_path: str) -> list[dict[str, Any]]:
|
|
if self.config.remote_data.protocol == "ftp":
|
|
with self.downloader._ftp_client() as ftp: # noqa: SLF001
|
|
return [
|
|
{
|
|
"name": name,
|
|
"type": "directory" if entry_type == "dir" else "file",
|
|
"size": size,
|
|
"path": self._join_remote_path(remote_path, name),
|
|
}
|
|
for name, entry_type, size in self.downloader._list_ftp_entries(ftp, remote_path) # noqa: SLF001
|
|
if name not in {".", ".."}
|
|
]
|
|
|
|
ssh = self.downloader._sftp_ssh_client() # noqa: SLF001
|
|
try:
|
|
with ssh.open_sftp() as sftp:
|
|
return [
|
|
{
|
|
"name": item.filename,
|
|
"type": "directory" if stat.S_ISDIR(item.st_mode) else "file",
|
|
"size": int(getattr(item, "st_size", 0) or 0),
|
|
"path": self._join_remote_path(remote_path, item.filename),
|
|
}
|
|
for item in sftp.listdir_attr(remote_path)
|
|
if item.filename not in {".", ".."}
|
|
]
|
|
finally:
|
|
ssh.close()
|
|
|
|
def _remote_file_info(self, remote_path: str, size: int) -> RemoteFileInfo:
|
|
return self.downloader._remote_file_info(remote_path, size) # noqa: SLF001
|
|
|
|
@staticmethod
|
|
def _join_remote_path(parent: str, child: str) -> str:
|
|
parent = parent.replace("\\", "/").rstrip("/")
|
|
child = child.replace("\\", "/").strip("/")
|
|
if not parent:
|
|
return child
|
|
if parent == "/":
|
|
return f"/{child}"
|
|
return f"{parent}/{child}"
|
|
|
|
def _set_stage(self, stage: str) -> None:
|
|
if hasattr(self.logger, "set_stage"):
|
|
self.logger.set_stage(stage)
|
|
|
|
def _log(self, message: str) -> None:
|
|
if self.logger:
|
|
if hasattr(self.logger, "info"):
|
|
self.logger.info(message)
|
|
else:
|
|
self.logger(message)
|
|
|
|
|
|
def refresh_cell_data(app_config: AppConfig, work_dir: Path, logger=None) -> CellDataResult:
|
|
return CellDataProcessor(app_config, work_dir, logger).run()
|