feat: 实时重载配置文件

This commit is contained in:
2026-05-19 17:20:46 +08:00
parent 59b14b4938
commit db543395ba
9 changed files with 61 additions and 30 deletions
+9 -3
View File
@@ -15,12 +15,12 @@ router = APIRouter(tags=["config"])
@router.get("/api/config") @router.get("/api/config")
async def get_config(): async def get_config():
return state.config.to_dict() return state.current_config().to_dict()
@router.get("/api/config/full") @router.get("/api/config/full")
async def get_config_full(): async def get_config_full():
return state.config.to_dict_full() return state.current_config().to_dict_full()
@router.post("/api/config/mysql") @router.post("/api/config/mysql")
@@ -31,6 +31,7 @@ async def update_mysql_config(
passwd: str = Body(...), passwd: str = Body(...),
dbname: str = Body(...), dbname: str = Body(...),
): ):
state.reload_config()
state.config.mysql.host = host state.config.mysql.host = host
state.config.mysql.port = port state.config.mysql.port = port
state.config.mysql.user = user state.config.mysql.user = user
@@ -42,6 +43,7 @@ async def update_mysql_config(
@router.post("/api/config/remote") @router.post("/api/config/remote")
async def update_remote_config(config: dict[str, Any] = Body(...)): async def update_remote_config(config: dict[str, Any] = Body(...)):
state.reload_config()
state.config.remote_data = RemoteDataConfig.from_dict(config) state.config.remote_data = RemoteDataConfig.from_dict(config)
state.config.save() state.config.save()
return {"success": True, "message": "远程数据配置已更新", "update": state.config.update} return {"success": True, "message": "远程数据配置已更新", "update": state.config.update}
@@ -49,6 +51,7 @@ async def update_remote_config(config: dict[str, Any] = Body(...)):
@router.post("/api/config/history-retention") @router.post("/api/config/history-retention")
async def update_history_retention(config: dict[str, Any] = Body(...)): async def update_history_retention(config: dict[str, Any] = Body(...)):
state.reload_config()
state.config.history_retention = HistoryRetentionConfig.from_dict(config) state.config.history_retention = HistoryRetentionConfig.from_dict(config)
state.config.save() state.config.save()
return {"success": True, "message": "处理历史保留配置已更新", "update": state.config.update} return {"success": True, "message": "处理历史保留配置已更新", "update": state.config.update}
@@ -56,6 +59,7 @@ async def update_history_retention(config: dict[str, Any] = Body(...)):
@router.post("/api/config/sheet-filter") @router.post("/api/config/sheet-filter")
async def update_sheet_filter(filters: list[str] = Body(...)): async def update_sheet_filter(filters: list[str] = Body(...)):
state.reload_config()
state.config.sheet_filter = filters state.config.sheet_filter = filters
state.config.save() state.config.save()
return {"success": True, "message": "Sheet 过滤规则已更新", "update": state.config.update} return {"success": True, "message": "Sheet 过滤规则已更新", "update": state.config.update}
@@ -63,6 +67,7 @@ async def update_sheet_filter(filters: list[str] = Body(...)):
@router.post("/api/config/extract-fields") @router.post("/api/config/extract-fields")
async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)): async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
state.reload_config()
state.config.extract_fields = fields state.config.extract_fields = fields
state.config.save() state.config.save()
return {"success": True, "message": "字段映射配置已更新", "update": state.config.update} return {"success": True, "message": "字段映射配置已更新", "update": state.config.update}
@@ -72,7 +77,7 @@ async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
async def download_config(): async def download_config():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"Configure_{timestamp}.json" filename = f"Configure_{timestamp}.json"
content = json.dumps(state.config.to_file_dict(), ensure_ascii=False, indent=2) content = json.dumps(state.current_config().to_file_dict(), ensure_ascii=False, indent=2)
return Response( return Response(
content=content, content=content,
media_type="application/json", media_type="application/json",
@@ -90,6 +95,7 @@ async def upload_config(file: UploadFile = File(...)):
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("配置文件格式错误:必须是 JSON 对象") raise ValueError("配置文件格式错误:必须是 JSON 对象")
state.reload_config()
_apply_config_data(data) _apply_config_data(data)
state.config.save() state.config.save()
return {"success": True, "message": "配置文件上传成功", "update": state.config.update} return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
+15 -11
View File
@@ -53,16 +53,20 @@ def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame:
return pd.DataFrame(result["data"], columns=columns) return pd.DataFrame(result["data"], columns=columns)
def _db() -> DatabaseManager:
return DatabaseManager(state.current_config())
@router.post("/api/database/test") @router.post("/api/database/test")
async def test_database(): async def test_database():
db = DatabaseManager(state.config) db = _db()
success, message = db.test_connection() success, message = db.test_connection()
return {"success": success, "message": message} return {"success": success, "message": message}
@router.get("/api/database/info") @router.get("/api/database/info")
async def get_database_info(): async def get_database_info():
db = DatabaseManager(state.config) db = _db()
try: try:
return {"success": True, **db.get_server_info()} return {"success": True, **db.get_server_info()}
except Exception as exc: except Exception as exc:
@@ -72,7 +76,7 @@ async def get_database_info():
@router.get("/api/database/tables") @router.get("/api/database/tables")
@router.post("/api/database/tables") @router.post("/api/database/tables")
async def get_tables(): async def get_tables():
db = DatabaseManager(state.config) db = _db()
try: try:
return {"tables": db.get_tables()} return {"tables": db.get_tables()}
except Exception as exc: except Exception as exc:
@@ -81,7 +85,7 @@ async def get_tables():
@router.post("/api/database/table/info") @router.post("/api/database/table/info")
async def get_table_info(table_name: str = Body(..., embed=True)): async def get_table_info(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config) db = _db()
try: try:
return db.get_table_info(table_name) return db.get_table_info(table_name)
except Exception as exc: except Exception as exc:
@@ -96,7 +100,7 @@ async def query_table_data(
order_by: Optional[str] = Body(None), order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"), order_dir: str = Body("ASC"),
): ):
db = DatabaseManager(state.config) db = _db()
try: try:
return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir) return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir)
except Exception as exc: except Exception as exc:
@@ -112,7 +116,7 @@ async def query_table_with_filter(
order_by: Optional[str] = Body(None), order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"), order_dir: str = Body("ASC"),
): ):
db = DatabaseManager(state.config) db = _db()
try: try:
return db.query_table( return db.query_table(
table_name, table_name,
@@ -128,7 +132,7 @@ async def query_table_with_filter(
@router.post("/api/database/table/truncate") @router.post("/api/database/table/truncate")
async def truncate_table(table_name: str = Body(..., embed=True)): async def truncate_table(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config) db = _db()
try: try:
db.truncate_table(table_name) db.truncate_table(table_name)
return {"success": True, "message": f"表 {table_name} 已清空"} return {"success": True, "message": f"表 {table_name} 已清空"}
@@ -138,7 +142,7 @@ async def truncate_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop") @router.post("/api/database/table/drop")
async def drop_table(table_name: str = Body(..., embed=True)): async def drop_table(table_name: str = Body(..., embed=True)):
db = DatabaseManager(state.config) db = _db()
try: try:
db.drop_table(table_name) db.drop_table(table_name)
return {"success": True, "message": f"表 {table_name} 已删除"} return {"success": True, "message": f"表 {table_name} 已删除"}
@@ -148,7 +152,7 @@ async def drop_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop-all") @router.post("/api/database/table/drop-all")
async def drop_all_tables(): async def drop_all_tables():
db = DatabaseManager(state.config) db = _db()
try: try:
result = db.drop_all_tables() result = db.drop_all_tables()
return { return {
@@ -163,7 +167,7 @@ async def drop_all_tables():
@router.post("/api/database/execute") @router.post("/api/database/execute")
async def execute_sql(sql: str = Body(..., embed=True)): async def execute_sql(sql: str = Body(..., embed=True)):
db = DatabaseManager(state.config) db = _db()
try: try:
success, result = db.execute_sql(sql) success, result = db.execute_sql(sql)
if success: if success:
@@ -190,7 +194,7 @@ async def download_table(
if file_format == "csv" and len(requested_tables) != 1: if file_format == "csv" and len(requested_tables) != 1:
raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表") raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表")
db = DatabaseManager(state.config) db = _db()
try: try:
available_tables = set(db.get_tables()) available_tables = set(db.get_tables())
missing_tables = [name for name in requested_tables if name not in available_tables] missing_tables = [name for name in requested_tables if name not in available_tables]
+1 -1
View File
@@ -18,7 +18,7 @@ async def health_check():
} }
try: try:
db_manager = DatabaseManager(state.config) db_manager = DatabaseManager(state.current_config())
server_info = db_manager.get_server_info() server_info = db_manager.get_server_info()
checks["database"] = { checks["database"] = {
"status": "ok", "status": "ok",
+7 -5
View File
@@ -6,7 +6,7 @@ from typing import Any
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException
from app import state from app import state
from app.config import CACHE_DIR, RemoteDataConfig from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
from app.processor import DataProcessor, ProcessLogger from app.processor import DataProcessor, ProcessLogger
from app.services.remote_download import RemoteDataDownloader from app.services.remote_download import RemoteDataDownloader
@@ -16,7 +16,7 @@ router = APIRouter(tags=["remote"])
@router.post("/api/remote/test") @router.post("/api/remote/test")
async def test_remote_connection(config: dict[str, Any] | None = Body(None)): async def test_remote_connection(config: dict[str, Any] | None = Body(None)):
remote_config = RemoteDataConfig.from_dict(config) if config else state.config.remote_data remote_config = RemoteDataConfig.from_dict(config) if config else state.current_config().remote_data
try: try:
RemoteDataDownloader(remote_config).test_connection() RemoteDataDownloader(remote_config).test_connection()
return {"success": True, "message": "远程服务器连接成功"} return {"success": True, "message": "远程服务器连接成功"}
@@ -29,7 +29,8 @@ async def start_remote_processing():
if state.global_task_lock["locked"]: if state.global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
remote_config = state.config.remote_data.normalized() app_config = state.current_config()
remote_config = app_config.remote_data.normalized()
if not remote_config.enabled: if not remote_config.enabled:
raise HTTPException(status_code=400, detail="请先启用远程数据配置") raise HTTPException(status_code=400, detail="请先启用远程数据配置")
@@ -68,7 +69,7 @@ async def start_remote_processing():
thread = Thread( thread = Thread(
target=_run_remote_processing, target=_run_remote_processing,
args=(task_id, work_dir, remote_config, logger), args=(task_id, work_dir, app_config, remote_config, logger),
daemon=True, daemon=True,
) )
thread.start() thread.start()
@@ -94,6 +95,7 @@ def _set_task_stage(task_id: str, stage: str, logs: list[str], status: str = "pr
def _run_remote_processing( def _run_remote_processing(
task_id: str, task_id: str,
work_dir: Path, work_dir: Path,
app_config: AppConfig,
remote_config: RemoteDataConfig, remote_config: RemoteDataConfig,
logger: ProcessLogger, logger: ProcessLogger,
) -> None: ) -> None:
@@ -115,7 +117,7 @@ def _run_remote_processing(
state.history_manager.update(task_id, file_count=download_result.file_count) state.history_manager.update(task_id, file_count=download_result.file_count)
logger.set_stage("extracting") logger.set_stage("extracting")
processor = DataProcessor(state.config, work_dir, logger) processor = DataProcessor(app_config, work_dir, logger)
result = processor.process() result = processor.process()
status = "completed" if result.get("success") else "failed" status = "completed" if result.get("success") else "failed"
if status == "completed" and remote_config.auto_delete_source: if status == "completed" and remote_config.auto_delete_source:
+5 -5
View File
@@ -7,7 +7,7 @@ from threading import Thread
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException
from app import state from app import state
from app.config import CACHE_DIR, SQL_SCRIPT from app.config import AppConfig, CACHE_DIR, SQL_SCRIPT
from app.processor import DataProcessor, ProcessLogger from app.processor import DataProcessor, ProcessLogger
@@ -59,8 +59,9 @@ async def execute_script():
logger = ProcessLogger(log_file=None, callback=log_callback) logger = ProcessLogger(log_file=None, callback=log_callback)
state.processing_tasks[task_id] = {"logs": [], "status": "processing"} state.processing_tasks[task_id] = {"logs": [], "status": "processing"}
app_config = state.current_config()
thread = Thread(target=_run_script, args=(task_id, logger, logs), daemon=True) thread = Thread(target=_run_script, args=(task_id, logger, logs, app_config), daemon=True)
thread.start() thread.start()
return {"success": True, "message": "脚本执行任务已启动", "task_id": task_id} return {"success": True, "message": "脚本执行任务已启动", "task_id": task_id}
@@ -78,14 +79,14 @@ async def save_script_content(content: str = Body(..., embed=True)):
return {"success": False, "error": str(exc)} return {"success": False, "error": str(exc)}
def _run_script(task_id: str, logger: ProcessLogger, logs: list[str]) -> None: def _run_script(task_id: str, logger: ProcessLogger, logs: list[str], app_config: AppConfig) -> None:
temp_work_dir: Path | None = None temp_work_dir: Path | None = None
try: try:
logger.info("开始执行 SQL 脚本...") logger.info("开始执行 SQL 脚本...")
temp_work_dir = CACHE_DIR / task_id temp_work_dir = CACHE_DIR / task_id
temp_work_dir.mkdir(parents=True, exist_ok=True) temp_work_dir.mkdir(parents=True, exist_ok=True)
processor = DataProcessor(state.config, temp_work_dir, logger) processor = DataProcessor(app_config, temp_work_dir, logger)
processor._execute_sql_script() processor._execute_sql_script()
logger.success("SQL 脚本执行完成") logger.success("SQL 脚本执行完成")
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "completed"} state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "completed"}
@@ -96,4 +97,3 @@ def _run_script(task_id: str, logger: ProcessLogger, logs: list[str]) -> None:
if temp_work_dir and temp_work_dir.exists(): if temp_work_dir and temp_work_dir.exists():
shutil.rmtree(temp_work_dir, ignore_errors=True) shutil.rmtree(temp_work_dir, ignore_errors=True)
state.reset_task_lock() state.reset_task_lock()
+5 -3
View File
@@ -6,6 +6,7 @@ from typing import Any
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException
from app import state from app import state
from app.config import AppConfig
from app.processor import DataProcessor, ProcessLogger from app.processor import DataProcessor, ProcessLogger
@@ -104,6 +105,7 @@ async def start_processing(task_id: str = Body(..., embed=True)):
callback=log_callback, callback=log_callback,
stage_callback=stage_callback, stage_callback=stage_callback,
) )
app_config = state.current_config()
state.history_manager.update(task_id, status="processing") state.history_manager.update(task_id, status="processing")
state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage}
state.global_task_lock.update( state.global_task_lock.update(
@@ -115,7 +117,7 @@ async def start_processing(task_id: str = Body(..., embed=True)):
} }
) )
thread = Thread(target=_run_processing, args=(task_id, work_dir, logger), daemon=True) thread = Thread(target=_run_processing, args=(task_id, work_dir, logger, app_config), daemon=True)
thread.start() thread.start()
return {"success": True, "message": "处理任务已启动", "task_id": task_id} return {"success": True, "message": "处理任务已启动", "task_id": task_id}
@@ -166,9 +168,9 @@ def _set_task_stage(task_id: str, stage: str, logs: list[str], status: str = "pr
state.global_task_lock["stage"] = stage state.global_task_lock["stage"] = stage
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger) -> None: def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None:
try: try:
processor = DataProcessor(state.config, work_dir, logger) processor = DataProcessor(app_config, work_dir, logger)
result = processor.process() result = processor.process()
status = "completed" if result.get("success") else "failed" status = "completed" if result.get("success") else "failed"
state.history_manager.update( state.history_manager.update(
+1 -1
View File
@@ -119,6 +119,6 @@ app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
print(f"CapacityReport v{APP_VERSION}") print(f"CapacityReport v{APP_VERSION}")
print(f"配置更新时间: {state.config.update}") print(f"配置更新时间: {state.current_config().update}")
print(f"前端地址: http://localhost:{APP_PORT}") print(f"前端地址: http://localhost:{APP_PORT}")
uvicorn.run(app, host=APP_HOST, port=APP_PORT, reload=False) uvicorn.run(app, host=APP_HOST, port=APP_PORT, reload=False)
+12 -1
View File
@@ -24,8 +24,19 @@ def reset_task_lock() -> None:
global_task_lock["started_at"] = None global_task_lock["started_at"] = None
def reload_config() -> AppConfig:
"""从 Configure.json 重新加载配置,保证接口读取到最新文件内容。"""
global config
config = AppConfig.load()
return config
def current_config() -> AppConfig:
return reload_config()
def apply_history_retention() -> int: def apply_history_retention() -> int:
retention = config.history_retention.normalized() retention = current_config().history_retention.normalized()
if not retention.enabled: if not retention.enabled:
return 0 return 0
return history_manager.prune_finished(retention.keep_count) return history_manager.prune_finished(retention.keep_count)
+6
View File
@@ -1,4 +1,10 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-19:配置按请求实时重载
- `app/state.py` 新增 `reload_config()` 和 `current_config()`,后端接口不再长期依赖启动时的 `state.config` 快照;读取配置、下载配置、数据库接口、健康检查、本地处理、远程处理和脚本执行入口都会从 `Configure.json` 重新加载最新配置。
- 配置保存类接口会先重载当前文件再修改对应配置块并保存,避免用户手工更新 `Configure.json` 后,被某个单项保存接口用旧内存配置覆盖。
- 本地/远程处理任务启动时会读取一次最新配置并作为任务快照传入 `DataProcessor`;任务运行过程中不再反复重载,避免处理中途改配置导致同一任务前后规则不一致。
- 已执行 `.venv\Scripts\python.exe -m compileall app`、`uvx --offline ruff check .` 和 `npm run build`,均通过;前端构建仅保留 Vite 大 chunk 提示。
## 2026-05-19:补全字段映射配置 ## 2026-05-19:补全字段映射配置
- 当前本地 `Configure.json` 的 `ExtractField` 已按旧版可用映射补全:`基站名称` 增加 `ENBFunction名称`,`ERAB流量` 增加 `ERAB流量(新高负荷)_1538186901014-7-0`,`上行流量_GB/下行流量_GB` 增加 `上行流量(GB)/下行流量(GB)`。 - 当前本地 `Configure.json` 的 `ExtractField` 已按旧版可用映射补全:`基站名称` 增加 `ENBFunction名称`,`ERAB流量` 增加 `ERAB流量(新高负荷)_1538186901014-7-0`,`上行流量_GB/下行流量_GB` 增加 `上行流量(GB)/下行流量(GB)`。
- 4G/5G 数值字段显式补回 `Type: float/int`,避免依赖 SQL 脚本推断类型;`AppConfig.load()` 已验证能读取 32 个字段映射和正确的 `SheetFilter`。 - 4G/5G 数值字段显式补回 `Type: float/int`,避免依赖 SQL 脚本推断类型;`AppConfig.load()` 已验证能读取 32 个字段映射和正确的 `SheetFilter`。