diff --git a/Configure.json b/Configure.json
index 485e616..c72bac6 100644
--- a/Configure.json
+++ b/Configure.json
@@ -1,5 +1,7 @@
{
- "Update": "2026/05/25 17:57:25",
+ "Update": "2026/06/24 23:16:46",
+ "SourceType": "sftp",
+ "WarehouseType": "mysql",
"MySQL_DBInfo": {
"host": "127.0.0.1",
"port": 3306,
@@ -7,6 +9,183 @@
"passwd": "123456",
"dbname": "CapacityReport"
},
+ "Metrix": {
+ "base_url": "http://host.docker.internal:8000",
+ "storage_id": "",
+ "database_conn_id": "",
+ "target_database": "",
+ "recent_days": 7,
+ "token": ""
+ },
+ "DataMappings": {
+ "directories": [
+ {
+ "path": "4G",
+ "table": "4G_UD",
+ "ready_rule": "daily"
+ },
+ {
+ "path": "5G",
+ "table": "5G_UD",
+ "ready_rule": "daily"
+ },
+ {
+ "path": "RJ/2.6G/2.6RJGD",
+ "table": "2_6GRJGD",
+ "ready_rule": "auto"
+ },
+ {
+ "path": "RJ/2.6G/2.6RJYD",
+ "table": "2_6GRJYD",
+ "ready_rule": "auto"
+ },
+ {
+ "path": "RJ/700M/700RJGD",
+ "table": "700MRJGD",
+ "ready_rule": "auto"
+ },
+ {
+ "path": "RJ/700M/700RJYD",
+ "table": "700MRJYD",
+ "ready_rule": "auto"
+ }
+ ],
+ "table_field_mappings": {
+ "2_6GRJGD": [
+ {
+ "Source": "开始时间",
+ "Target": "开始时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "结束时间",
+ "Target": "结束时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "gNBId",
+ "Target": "gNBId",
+ "Type": "string"
+ },
+ {
+ "Source": "cellId",
+ "Target": "cellId",
+ "Type": "string"
+ },
+ {
+ "Source": "gNBplmn",
+ "Target": "gNBplmn",
+ "Type": "string"
+ },
+ {
+ "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)",
+ "Target": "上下行总流量_GB",
+ "Type": "float"
+ }
+ ],
+ "2_6GRJYD": [
+ {
+ "Source": "开始时间",
+ "Target": "开始时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "结束时间",
+ "Target": "结束时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "gNBId",
+ "Target": "gNBId",
+ "Type": "string"
+ },
+ {
+ "Source": "cellId",
+ "Target": "cellId",
+ "Type": "string"
+ },
+ {
+ "Source": "gNBplmn",
+ "Target": "gNBplmn",
+ "Type": "string"
+ },
+ {
+ "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)",
+ "Target": "上下行总流量_GB",
+ "Type": "float"
+ }
+ ],
+ "700MRJGD": [
+ {
+ "Source": "开始时间",
+ "Target": "开始时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "结束时间",
+ "Target": "结束时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "gNBId",
+ "Target": "gNBId",
+ "Type": "string"
+ },
+ {
+ "Source": "cellId",
+ "Target": "cellId",
+ "Type": "string"
+ },
+ {
+ "Source": "gNBplmn",
+ "Target": "gNBplmn",
+ "Type": "string"
+ },
+ {
+ "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)",
+ "Target": "上下行总流量_GB",
+ "Type": "float"
+ }
+ ],
+ "700MRJYD": [
+ {
+ "Source": "开始时间",
+ "Target": "开始时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "结束时间",
+ "Target": "结束时间",
+ "Type": "datetime"
+ },
+ {
+ "Source": "gNBId",
+ "Target": "gNBId",
+ "Type": "string"
+ },
+ {
+ "Source": "cellId",
+ "Target": "cellId",
+ "Type": "string"
+ },
+ {
+ "Source": "gNBplmn",
+ "Target": "gNBplmn",
+ "Type": "string"
+ },
+ {
+ "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)",
+ "Target": "上下行总流量_GB",
+ "Type": "float"
+ },
+ {
+ "Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)-YD(GB)",
+ "Target": "上下行总流量_GB",
+ "Type": "float"
+ }
+ ]
+ }
+ },
"RemoteData": {
"enabled": true,
"protocol": "sftp",
@@ -18,7 +197,7 @@
"timeout": 30,
"auto_delete_source": true,
"auto_scheduler": {
- "enabled": true,
+ "enabled": false,
"check_interval_hours": 1,
"expected_directories": [],
"week_offset": 0
@@ -29,51 +208,6 @@
"enabled": true,
"keep_count": 1
},
- "DataMappings": {
- "directories": [
- {"path": "4G", "table": "4G_UD", "ready_rule": "daily"},
- {"path": "5G", "table": "5G_UD", "ready_rule": "daily"},
- {"path": "RJ/2.6G/2.6RJGD", "table": "2_6GRJGD", "ready_rule": "auto"},
- {"path": "RJ/2.6G/2.6RJYD", "table": "2_6GRJYD", "ready_rule": "auto"},
- {"path": "RJ/700M/700RJGD", "table": "700MRJGD", "ready_rule": "auto"},
- {"path": "RJ/700M/700RJYD", "table": "700MRJYD", "ready_rule": "auto"}
- ],
- "table_field_mappings": {
- "2_6GRJGD": [
- {"Source": "开始时间", "Target": "开始时间", "Type": "datetime"},
- {"Source": "结束时间", "Target": "结束时间", "Type": "datetime"},
- {"Source": "gNBId", "Target": "gNBId", "Type": "string"},
- {"Source": "cellId", "Target": "cellId", "Type": "string"},
- {"Source": "gNBplmn", "Target": "gNBplmn", "Type": "string"},
- {"Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)", "Target": "上下行总流量_GB", "Type": "float"}
- ],
- "2_6GRJYD": [
- {"Source": "开始时间", "Target": "开始时间", "Type": "datetime"},
- {"Source": "结束时间", "Target": "结束时间", "Type": "datetime"},
- {"Source": "gNBId", "Target": "gNBId", "Type": "string"},
- {"Source": "cellId", "Target": "cellId", "Type": "string"},
- {"Source": "gNBplmn", "Target": "gNBplmn", "Type": "string"},
- {"Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)", "Target": "上下行总流量_GB", "Type": "float"}
- ],
- "700MRJGD": [
- {"Source": "开始时间", "Target": "开始时间", "Type": "datetime"},
- {"Source": "结束时间", "Target": "结束时间", "Type": "datetime"},
- {"Source": "gNBId", "Target": "gNBId", "Type": "string"},
- {"Source": "cellId", "Target": "cellId", "Type": "string"},
- {"Source": "gNBplmn", "Target": "gNBplmn", "Type": "string"},
- {"Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-GD(GB)", "Target": "上下行总流量_GB", "Type": "float"}
- ],
- "700MRJYD": [
- {"Source": "开始时间", "Target": "开始时间", "Type": "datetime"},
- {"Source": "结束时间", "Target": "结束时间", "Type": "datetime"},
- {"Source": "gNBId", "Target": "gNBId", "Type": "string"},
- {"Source": "cellId", "Target": "cellId", "Type": "string"},
- {"Source": "gNBplmn", "Target": "gNBplmn", "Type": "string"},
- {"Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)-YD(GB)", "Target": "上下行总流量_GB", "Type": "float"},
- {"Source": "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)-YD(GB)", "Target": "上下行总流量_GB", "Type": "float"}
- ]
- }
- },
"SheetFilter": [
"指标(计数器)",
"Template"
@@ -334,4 +468,4 @@
"Type": "float"
}
]
-}
+}
\ No newline at end of file
diff --git a/app/api/routers/config.py b/app/api/routers/config.py
index 88dcecf..dca6981 100644
--- a/app/api/routers/config.py
+++ b/app/api/routers/config.py
@@ -3,18 +3,22 @@ from datetime import datetime
from typing import Any
from urllib.parse import quote
+import pymysql
from fastapi import APIRouter, Body, HTTPException, UploadFile, File
from fastapi.responses import Response
from app import state
from app.config import (
+ CellDataConfig,
DataMappingsConfig,
HistoryRetentionConfig,
MetrixConfig,
+ MySQLConfig,
RemoteDataConfig,
SOURCE_TYPES,
WAREHOUSE_TYPES,
)
+from app.services.remote_download import RemoteDataDownloader
router = APIRouter(tags=["config"])
@@ -92,6 +96,46 @@ async def update_data_mappings_config(config: dict[str, Any] = Body(...)):
return {"success": True, "message": "数据目录映射已更新", "update": state.config.update}
+@router.post("/api/config/cell-data/remote")
+async def update_cell_data_remote_config(config: dict[str, Any] = Body(...)):
+ state.reload_config()
+ current = state.config.cell_data.normalized()
+ state.config.cell_data = CellDataConfig(
+ remote_data=RemoteDataConfig.from_dict(config),
+ mysql=current.mysql,
+ ).normalized()
+ state.config.save()
+ return {"success": True, "message": "CellData 远程数据源配置已更新", "update": state.config.update}
+
+
+@router.post("/api/config/cell-data/mysql")
+async def update_cell_data_mysql_config(config: dict[str, Any] = Body(...)):
+ state.reload_config()
+ current = state.config.cell_data.normalized()
+ state.config.cell_data = CellDataConfig(
+ remote_data=current.remote_data,
+ mysql=MySQLConfig.from_dict(config, default_dbname="celldata"),
+ ).normalized()
+ state.config.save()
+ return {"success": True, "message": "CellData 数据库配置已更新", "update": state.config.update}
+
+
+@router.post("/api/config/cell-data/remote/test")
+async def test_cell_data_remote_connection(config: dict[str, Any] | None = Body(None)):
+ try:
+ remote_config = RemoteDataConfig.from_dict(config) if config else state.current_config().cell_data.remote_data
+ RemoteDataDownloader(remote_config).test_connection()
+ return {"success": True, "message": "CellData 远程服务器连接成功"}
+ except Exception as exc:
+ return {"success": False, "message": f"连接失败: {exc}"}
+
+
+@router.post("/api/config/cell-data/mysql/test")
+async def test_cell_data_mysql_connection(config: dict[str, Any] | None = Body(None)):
+ mysql_config = MySQLConfig.from_dict(config, default_dbname="celldata") if config else state.current_config().cell_data.mysql
+ return _test_mysql_config(mysql_config)
+
+
@router.post("/api/config/history-retention")
async def update_history_retention(config: dict[str, Any] = Body(...)):
state.reload_config()
@@ -167,9 +211,7 @@ def _apply_config_data(data: dict[str, Any]) -> None:
mysql_data = data.get("MySQL_DBInfo")
if isinstance(mysql_data, dict):
- for key in ("host", "port", "user", "passwd", "dbname"):
- if key in mysql_data:
- setattr(state.config.mysql, key, mysql_data[key])
+ state.config.mysql = MySQLConfig.from_dict(mysql_data)
if "SheetFilter" in data:
state.config.sheet_filter = data["SheetFilter"] if isinstance(data["SheetFilter"], list) else []
@@ -181,7 +223,34 @@ def _apply_config_data(data: dict[str, Any]) -> None:
if isinstance(remote_data, dict):
state.config.remote_data = RemoteDataConfig.from_dict(remote_data)
+ cell_data = data.get("CellData")
+ if isinstance(cell_data, dict):
+ state.config.cell_data = CellDataConfig.from_dict(cell_data)
+
history_retention = data.get("HistoryRetention")
if isinstance(history_retention, dict):
state.config.history_retention = HistoryRetentionConfig.from_dict(history_retention)
+
+def _test_mysql_config(config: MySQLConfig) -> dict[str, Any]:
+ normalized = config.normalized()
+ try:
+ conn = pymysql.connect(
+ host=normalized.host,
+ port=normalized.port,
+ user=normalized.user,
+ password=normalized.passwd,
+ database=normalized.dbname,
+ charset="utf8mb4",
+ cursorclass=pymysql.cursors.DictCursor,
+ connect_timeout=10,
+ )
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("SELECT 1")
+ finally:
+ conn.close()
+ return {"success": True, "message": "连接成功"}
+ except Exception as exc:
+ return {"success": False, "message": str(exc)}
+
diff --git a/app/config.py b/app/config.py
index 6902b9e..f5b50ac 100644
--- a/app/config.py
+++ b/app/config.py
@@ -33,6 +33,42 @@ class MySQLConfig:
passwd: str = ""
dbname: str = "CapacityReport"
+ def normalized(self) -> "MySQLConfig":
+ try:
+ port = int(self.port or 3306)
+ except (TypeError, ValueError):
+ port = 3306
+ return MySQLConfig(
+ host=str(self.host or "").strip() or "localhost",
+ port=port,
+ user=str(self.user or "").strip(),
+ passwd=self.passwd or "",
+ dbname=str(self.dbname or "").strip(),
+ )
+
+ def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
+ normalized = self.normalized()
+ data = {
+ "host": normalized.host,
+ "port": normalized.port,
+ "user": normalized.user,
+ "dbname": normalized.dbname,
+ }
+ if include_password:
+ data["passwd"] = normalized.passwd
+ return data
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any] | None, default_dbname: str = "CapacityReport") -> "MySQLConfig":
+ data = data or {}
+ return cls(
+ host=str(data.get("host", "localhost")),
+ port=data.get("port", 3306),
+ user=str(data.get("user", "root")),
+ passwd=str(data.get("passwd", "")),
+ dbname=str(data.get("dbname", default_dbname)),
+ ).normalized()
+
# Source backends: direct FTP/SFTP, or Metrix storage platform.
SOURCE_TYPES = ("ftp", "sftp", "metrix")
@@ -292,6 +328,43 @@ class RemoteDataConfig:
).normalized()
+@dataclass
+class CellDataConfig:
+ """CellData side data source and database connection used by later processing steps."""
+ remote_data: RemoteDataConfig = field(
+ default_factory=lambda: RemoteDataConfig(
+ enabled=False,
+ protocol="sftp",
+ remote_dir="/CellData",
+ )
+ )
+ mysql: MySQLConfig = field(default_factory=lambda: MySQLConfig(dbname="celldata"))
+
+ def normalized(self) -> "CellDataConfig":
+ return CellDataConfig(
+ remote_data=self.remote_data.normalized(),
+ mysql=self.mysql.normalized(),
+ )
+
+ def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
+ normalized = self.normalized()
+ return {
+ "remote_data": normalized.remote_data.to_dict(include_password=include_password),
+ "mysql": normalized.mysql.to_dict(include_password=include_password),
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any] | None) -> "CellDataConfig":
+ data = data or {}
+ default = cls()
+ remote_data = data.get("RemoteData") or data.get("remote_data")
+ mysql_data = data.get("MySQL_DBInfo") or data.get("mysql")
+ return cls(
+ remote_data=RemoteDataConfig.from_dict(remote_data) if isinstance(remote_data, dict) else default.remote_data,
+ mysql=MySQLConfig.from_dict(mysql_data, default_dbname="celldata") if isinstance(mysql_data, dict) else default.mysql,
+ ).normalized()
+
+
@dataclass
class HistoryRetentionConfig:
enabled: bool = False
@@ -346,6 +419,7 @@ class AppConfig:
metrix: MetrixConfig = field(default_factory=MetrixConfig)
data_mappings: DataMappingsConfig = field(default_factory=DataMappingsConfig)
remote_data: RemoteDataConfig = field(default_factory=RemoteDataConfig)
+ cell_data: CellDataConfig = field(default_factory=CellDataConfig)
history_retention: HistoryRetentionConfig = field(default_factory=HistoryRetentionConfig)
sheet_filter: List[str] = field(default_factory=list)
extract_fields: List[Dict[str, Any]] = field(default_factory=list)
@@ -359,17 +433,11 @@ class AppConfig:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
- mysql_data = data.get("MySQL_DBInfo", {})
- mysql_config = MySQLConfig(
- host=mysql_data.get("host", "localhost"),
- port=mysql_data.get("port", 3306),
- user=mysql_data.get("user", "root"),
- passwd=mysql_data.get("passwd", ""),
- dbname=mysql_data.get("dbname", "CapacityReport")
- )
+ mysql_config = MySQLConfig.from_dict(data.get("MySQL_DBInfo"))
remote_config = RemoteDataConfig.from_dict(data.get("RemoteData"))
metrix_config = MetrixConfig.from_dict(data.get("Metrix"))
data_mappings = DataMappingsConfig.from_dict(data.get("DataMappings"))
+ cell_data = CellDataConfig.from_dict(data.get("CellData"))
history_retention = HistoryRetentionConfig.from_dict(data.get("HistoryRetention"))
return cls(
@@ -380,6 +448,7 @@ class AppConfig:
metrix=metrix_config,
data_mappings=data_mappings,
remote_data=remote_config,
+ cell_data=cell_data,
history_retention=history_retention,
sheet_filter=data.get("SheetFilter", []),
extract_fields=data.get("ExtractField", [])
@@ -399,16 +468,11 @@ class AppConfig:
"Update": self.update,
"SourceType": self.source_type,
"WarehouseType": self.warehouse_type,
- "MySQL_DBInfo": {
- "host": self.mysql.host,
- "port": self.mysql.port,
- "user": self.mysql.user,
- "passwd": self.mysql.passwd,
- "dbname": self.mysql.dbname
- },
+ "MySQL_DBInfo": self.mysql.normalized().to_dict(include_password=True),
"Metrix": self.metrix.normalized().to_dict(include_token=True),
"DataMappings": self.data_mappings.normalized().to_dict(),
"RemoteData": self.remote_data.normalized().to_dict(include_password=True),
+ "CellData": self.cell_data.normalized().to_dict(include_password=True),
"HistoryRetention": self.history_retention.normalized().to_dict(),
"SheetFilter": self.sheet_filter,
"ExtractField": self.extract_fields
@@ -420,15 +484,11 @@ class AppConfig:
"update": self.update,
"source_type": self.source_type,
"warehouse_type": self.warehouse_type,
- "mysql": {
- "host": self.mysql.host,
- "port": self.mysql.port,
- "user": self.mysql.user,
- "dbname": self.mysql.dbname
- },
+ "mysql": self.mysql.normalized().to_dict(),
"metrix": self.metrix.normalized().to_dict(),
"data_mappings": self.data_mappings.normalized().to_dict(),
"remote_data": self.remote_data.normalized().to_dict(),
+ "cell_data": self.cell_data.normalized().to_dict(),
"history_retention": self.history_retention.normalized().to_dict(),
"sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields
@@ -440,16 +500,11 @@ class AppConfig:
"update": self.update,
"source_type": self.source_type,
"warehouse_type": self.warehouse_type,
- "mysql": {
- "host": self.mysql.host,
- "port": self.mysql.port,
- "user": self.mysql.user,
- "passwd": self.mysql.passwd,
- "dbname": self.mysql.dbname
- },
+ "mysql": self.mysql.normalized().to_dict(include_password=True),
"metrix": self.metrix.normalized().to_dict(include_token=True),
"data_mappings": self.data_mappings.normalized().to_dict(),
"remote_data": self.remote_data.normalized().to_dict(include_password=True),
+ "cell_data": self.cell_data.normalized().to_dict(include_password=True),
"history_retention": self.history_retention.normalized().to_dict(),
"sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields
diff --git a/docs/project_context.md b/docs/project_context.md
index 5646bf5..1b0a380 100644
--- a/docs/project_context.md
+++ b/docs/project_context.md
@@ -703,3 +703,11 @@
- 自动调度:原 RJ 专用检查改为通用自动粒度检查。`ready_rule=auto` 的目录会从普通每日扫描中排除,并单独按最新 ZIP 判断日/周粒度;如果配置里只有自动粒度目录,只要这些目录就绪也可触发调度。
- 前端:设置页「规则映射」左侧独立滚动配置栏中只保留一个「数据目录映射」卡片,每行可编辑目录、暂存表与就绪规则,并保存到 `/api/config/data-mappings`。后续新增目录类映射继续加同一张表,不再新增配置卡片。
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。
+
+## 2026-06-24:新增 CellData 远程源与数据库配置入口
+
+- 背景:后续需要引入 CellData 自动化处理,处理完成报表 SQL 后还会从 CellData 数据库表匹配数据并写入结果表。本轮先只落配置与界面,不接入实际提取/处理/入库流水线。
+- 配置:新增顶层 `CellData` 配置块,包含 `RemoteData`(FTP/SFTP 连接,默认远程目录 `/CellData`,不参与现有自动调度)与 `MySQL_DBInfo`(默认库名 `celldata`)。CellData 数据库可与主仓库 MySQL 相同,也可指向独立数据库。
+- 后端接口:新增 `/api/config/cell-data/remote`、`/api/config/cell-data/mysql` 保存接口,以及 `/api/config/cell-data/remote/test`、`/api/config/cell-data/mysql/test` 测试接口;测试逻辑分别复用 `RemoteDataDownloader` 和独立 PyMySQL `SELECT 1`。
+- 前端:系统设置「数据库」页新增「CellData 数据库配置」卡片;「远程数据源」页新增「CellData 数据源」卡片;「数据源 / 仓库」页说明 CellData 为独立辅助数据源,不影响主数据源/仓库选择。
+- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。
diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue
index 6338ed6..177640a 100644
--- a/frontend/src/components/SettingsPanel.vue
+++ b/frontend/src/components/SettingsPanel.vue
@@ -22,7 +22,7 @@
两侧可独立选择:源用 FTP/SFTP 或 Metrix 存储平台;仓库用本地 MySQL 或 Metrix 数据库平台。选 Metrix 时在下方填写连接;选 FTP/SFTP 或 MySQL 时在对应标签页填写。源目录在「远程数据源」标签页的「远程目录」。
+两侧可独立选择:源用 FTP/SFTP 或 Metrix 存储平台;仓库用本地 MySQL 或 Metrix 数据库平台。CellData 是后续处理使用的辅助数据源,独立配置,不影响这里的主数据源/仓库选择。
后续处理会从该库复制 CellData 所需表到临时处理区,产出结果后再清理。通常可与主 MySQL 相同,也可以指向独立 CellData 数据库。
+用于后续 CellData 存储、提取、处理和入库流程;当前只保存连接信息并支持连接测试,不参与现有容量报表处理链路。
+