refactor: 重构后端结构并迁移前端到 Vue3
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""CapacityReport backend package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API package."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API router package."""
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth import create_jwt_token, get_auth_config, save_auth_password
|
||||
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/api/login")
|
||||
async def login(
|
||||
username: str = Body(..., embed=True),
|
||||
password: str = Body(..., embed=True),
|
||||
):
|
||||
auth = get_auth_config()
|
||||
if username != auth["username"] or password != auth["password"]:
|
||||
return JSONResponse(status_code=401, content={"detail": "账号或密码错误"})
|
||||
|
||||
token = create_jwt_token({"user": username})
|
||||
return {"success": True, "token": token}
|
||||
|
||||
|
||||
@router.post("/api/change-password")
|
||||
async def change_password(
|
||||
current_password: str = Body(..., embed=True),
|
||||
new_password: str = Body(..., embed=True),
|
||||
):
|
||||
auth = get_auth_config()
|
||||
if current_password != auth["password"]:
|
||||
return JSONResponse(status_code=400, content={"detail": "当前密码错误"})
|
||||
if len(new_password) < 4:
|
||||
return JSONResponse(status_code=400, content={"detail": "新密码长度不能少于 4 位"})
|
||||
|
||||
save_auth_password(new_password)
|
||||
return {"success": True, "message": "密码修改成功"}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import CACHE_DIR
|
||||
from app.utils.files import format_size, get_dir_size
|
||||
|
||||
|
||||
router = APIRouter(tags=["cache"])
|
||||
|
||||
|
||||
@router.get("/api/cache/size")
|
||||
async def get_cache_size():
|
||||
if not CACHE_DIR.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"size_bytes": 0,
|
||||
"size_formatted": "0 B",
|
||||
"file_count": 0,
|
||||
"dir_count": 0,
|
||||
}
|
||||
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
|
||||
try:
|
||||
for item in CACHE_DIR.iterdir():
|
||||
if item.name == "history.json":
|
||||
continue
|
||||
if item.is_dir():
|
||||
dir_count += 1
|
||||
elif item.is_file():
|
||||
file_count += 1
|
||||
total_size += get_dir_size(item)
|
||||
except (PermissionError, OSError) as exc:
|
||||
return {"success": False, "error": str(exc), "size_formatted": "计算失败"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"size_bytes": total_size,
|
||||
"size_formatted": format_size(total_size),
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app import state
|
||||
from app.config import CONFIG_FILE
|
||||
|
||||
|
||||
router = APIRouter(tags=["config"])
|
||||
|
||||
|
||||
@router.get("/api/config")
|
||||
async def get_config():
|
||||
return state.config.to_dict()
|
||||
|
||||
|
||||
@router.get("/api/config/full")
|
||||
async def get_config_full():
|
||||
return state.config.to_dict_full()
|
||||
|
||||
|
||||
@router.post("/api/config/mysql")
|
||||
async def update_mysql_config(
|
||||
host: str = Body(...),
|
||||
port: int = Body(...),
|
||||
user: str = Body(...),
|
||||
passwd: str = Body(...),
|
||||
dbname: str = Body(...),
|
||||
):
|
||||
state.config.mysql.host = host
|
||||
state.config.mysql.port = port
|
||||
state.config.mysql.user = user
|
||||
state.config.mysql.passwd = passwd
|
||||
state.config.mysql.dbname = dbname
|
||||
state.config.save()
|
||||
return {"success": True, "message": "数据库配置已更新", "update": state.config.update}
|
||||
|
||||
|
||||
@router.post("/api/config/sheet-filter")
|
||||
async def update_sheet_filter(filters: list[str] = Body(...)):
|
||||
state.config.sheet_filter = filters
|
||||
state.config.save()
|
||||
return {"success": True, "message": "Sheet 过滤规则已更新", "update": state.config.update}
|
||||
|
||||
|
||||
@router.post("/api/config/extract-fields")
|
||||
async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
|
||||
state.config.extract_fields = fields
|
||||
state.config.save()
|
||||
return {"success": True, "message": "字段映射配置已更新", "update": state.config.update}
|
||||
|
||||
|
||||
@router.get("/api/config/download")
|
||||
async def download_config():
|
||||
if not CONFIG_FILE.exists():
|
||||
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"Configure_{timestamp}.json"
|
||||
return FileResponse(path=str(CONFIG_FILE), filename=filename, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/api/config/upload")
|
||||
async def upload_config(file: UploadFile = File(...)):
|
||||
if not file.filename or not file.filename.endswith(".json"):
|
||||
raise HTTPException(status_code=400, detail="只支持 JSON 格式的配置文件")
|
||||
|
||||
try:
|
||||
data = json.loads((await file.read()).decode("utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("配置文件格式错误:必须是 JSON 对象")
|
||||
|
||||
_apply_config_data(data)
|
||||
state.config.save()
|
||||
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="配置文件格式错误:不是有效的 JSON 文件") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc
|
||||
|
||||
|
||||
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])
|
||||
|
||||
if "SheetFilter" in data:
|
||||
state.config.sheet_filter = data["SheetFilter"] if isinstance(data["SheetFilter"], list) else []
|
||||
|
||||
if "ExtractField" in data:
|
||||
state.config.extract_fields = data["ExtractField"] if isinstance(data["ExtractField"], list) else []
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app import state
|
||||
from app.config import CACHE_DIR
|
||||
from app.database import DatabaseManager
|
||||
|
||||
|
||||
router = APIRouter(tags=["database"])
|
||||
|
||||
|
||||
@router.post("/api/database/test")
|
||||
async def test_database():
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
success, message = db.test_connection()
|
||||
return {"success": success, "message": message}
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.get("/api/database/info")
|
||||
async def get_database_info():
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
return {"success": True, **db.get_server_info()}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.get("/api/database/tables")
|
||||
@router.post("/api/database/tables")
|
||||
async def get_tables():
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
return {"tables": db.get_tables()}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/info")
|
||||
async def get_table_info(table_name: str = Body(..., embed=True)):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
return db.get_table_info(table_name)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/data")
|
||||
async def query_table_data(
|
||||
table_name: str = Body(..., embed=True),
|
||||
page: int = Body(1),
|
||||
page_size: int = Body(50),
|
||||
order_by: Optional[str] = Body(None),
|
||||
order_dir: str = Body("ASC"),
|
||||
):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/query")
|
||||
async def query_table_with_filter(
|
||||
table_name: str = Body(..., embed=True),
|
||||
page: int = Body(1),
|
||||
page_size: int = Body(50),
|
||||
filters: Optional[dict[str, str]] = Body(None),
|
||||
order_by: Optional[str] = Body(None),
|
||||
order_dir: str = Body("ASC"),
|
||||
):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
return db.query_table(
|
||||
table_name,
|
||||
page,
|
||||
page_size,
|
||||
filters=filters or {},
|
||||
order_by=order_by,
|
||||
order_dir=order_dir,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/truncate")
|
||||
async def truncate_table(table_name: str = Body(..., embed=True)):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
db.truncate_table(table_name)
|
||||
return {"success": True, "message": f"表 {table_name} 已清空"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/drop")
|
||||
async def drop_table(table_name: str = Body(..., embed=True)):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
db.drop_table(table_name)
|
||||
return {"success": True, "message": f"表 {table_name} 已删除"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/table/drop-all")
|
||||
async def drop_all_tables():
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
result = db.drop_all_tables()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已删除 {result['dropped_count']} 个表",
|
||||
"dropped_count": result["dropped_count"],
|
||||
"tables": result["tables"],
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/database/execute")
|
||||
async def execute_sql(sql: str = Body(..., embed=True)):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
success, result = db.execute_sql(sql)
|
||||
if success:
|
||||
return {"success": True, "result": result}
|
||||
raise HTTPException(status_code=400, detail=result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
|
||||
@router.post("/api/download")
|
||||
async def download_table(
|
||||
table_name: str = Body(..., embed=True),
|
||||
file_format: str = Body("csv", alias="format"),
|
||||
):
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
result = db.query_table(table_name, page=1, page_size=1000000)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
df = pd.DataFrame(result["data"])
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{table_name}_{timestamp}.{file_format}"
|
||||
filepath = CACHE_DIR / filename
|
||||
|
||||
if file_format == "csv":
|
||||
df.to_csv(filepath, index=False, encoding="utf-8-sig")
|
||||
media_type = "text/csv"
|
||||
else:
|
||||
df.to_excel(filepath, index=False)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
return FileResponse(path=str(filepath), filename=filename, media_type=media_type)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import state
|
||||
from app.database import DatabaseManager
|
||||
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
checks = {
|
||||
"app": {"status": "ok"},
|
||||
"database": {"status": "unknown"},
|
||||
}
|
||||
|
||||
try:
|
||||
db_manager = DatabaseManager(state.config)
|
||||
try:
|
||||
server_info = db_manager.get_server_info()
|
||||
checks["database"] = {
|
||||
"status": "ok",
|
||||
"version": server_info.get("version", "unknown"),
|
||||
"load_data_infile": server_info.get("load_data_infile", False),
|
||||
}
|
||||
finally:
|
||||
db_manager.dispose()
|
||||
except Exception as exc:
|
||||
checks["database"] = {"status": "error", "message": str(exc)}
|
||||
|
||||
is_healthy = all(check.get("status") == "ok" for check in checks.values())
|
||||
return {
|
||||
"status": "healthy" if is_healthy else "unhealthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"version": "2.0.2",
|
||||
"uptime_pid": os.getpid(),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from app import state
|
||||
from app.utils.files import format_size, get_dir_size
|
||||
|
||||
|
||||
router = APIRouter(tags=["history"])
|
||||
|
||||
|
||||
@router.post("/api/history")
|
||||
async def get_history(limit: int = Body(50, embed=True)):
|
||||
return {"records": state.history_manager.list(limit)}
|
||||
|
||||
|
||||
@router.post("/api/history/delete")
|
||||
async def delete_history(record_id: str = Body(..., embed=True)):
|
||||
if state.history_manager.delete(record_id):
|
||||
return {"success": True, "message": "删除成功"}
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
|
||||
@router.post("/api/history/clear")
|
||||
async def clear_history():
|
||||
count = state.history_manager.clear()
|
||||
return {"success": True, "deleted": count}
|
||||
|
||||
|
||||
@router.post("/api/history/detail")
|
||||
async def get_history_detail(record_id: str = Body(..., embed=True)):
|
||||
record = state.history_manager.get(record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
result = record.to_dict()
|
||||
result["logs"] = state.history_manager.get_logs(record_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/history/size")
|
||||
async def get_history_size(record_id: str = Body(..., embed=True)):
|
||||
record = state.history_manager.get(record_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
work_dir = Path(record.work_dir)
|
||||
if not work_dir.exists():
|
||||
return {"success": True, "size": 0, "size_formatted": "0 B"}
|
||||
|
||||
size = get_dir_size(work_dir)
|
||||
return {"success": True, "size": size, "size_formatted": format_size(size)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from app import state
|
||||
from app.config import CACHE_DIR, SQL_SCRIPT
|
||||
from app.processor import DataProcessor, ProcessLogger
|
||||
|
||||
|
||||
router = APIRouter(tags=["script"])
|
||||
|
||||
|
||||
@router.get("/api/script/content")
|
||||
async def get_script_content():
|
||||
try:
|
||||
if not SQL_SCRIPT.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"content": "# SQL 脚本文件不存在,请在此编写脚本\n",
|
||||
"modified": None,
|
||||
"path": str(SQL_SCRIPT),
|
||||
}
|
||||
|
||||
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"success": True,
|
||||
"content": SQL_SCRIPT.read_text(encoding="utf-8"),
|
||||
"modified": modified,
|
||||
"path": str(SQL_SCRIPT),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/api/script/execute")
|
||||
async def execute_script():
|
||||
if state.global_task_lock["locked"]:
|
||||
raise HTTPException(status_code=409, detail="已有任务在运行,请等待完成")
|
||||
|
||||
task_id = f"script_{uuid.uuid4().hex[:8]}"
|
||||
state.global_task_lock.update(
|
||||
{
|
||||
"locked": True,
|
||||
"task_id": task_id,
|
||||
"stage": "processing",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
logs: list[str] = []
|
||||
|
||||
def log_callback(message: str) -> None:
|
||||
logs.append(message)
|
||||
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "processing"}
|
||||
|
||||
logger = ProcessLogger(log_file=None, callback=log_callback)
|
||||
state.processing_tasks[task_id] = {"logs": [], "status": "processing"}
|
||||
|
||||
thread = Thread(target=_run_script, args=(task_id, logger, logs), daemon=True)
|
||||
thread.start()
|
||||
return {"success": True, "message": "脚本执行任务已启动", "task_id": task_id}
|
||||
|
||||
|
||||
@router.post("/api/script/save")
|
||||
async def save_script_content(content: str = Body(..., embed=True)):
|
||||
try:
|
||||
if SQL_SCRIPT.exists():
|
||||
shutil.copy(SQL_SCRIPT, SQL_SCRIPT.with_suffix(".sql.bak"))
|
||||
|
||||
SQL_SCRIPT.write_text(content, encoding="utf-8")
|
||||
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {"success": True, "message": "脚本保存成功", "modified": modified}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
def _run_script(task_id: str, logger: ProcessLogger, logs: list[str]) -> None:
|
||||
temp_work_dir: Path | None = None
|
||||
try:
|
||||
logger.info("开始执行 SQL 脚本...")
|
||||
temp_work_dir = CACHE_DIR / task_id
|
||||
temp_work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
processor = DataProcessor(state.config, temp_work_dir, logger)
|
||||
processor._execute_sql_script()
|
||||
logger.success("SQL 脚本执行完成")
|
||||
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "completed"}
|
||||
except Exception as exc:
|
||||
logger.error(f"SQL 脚本执行失败: {exc}")
|
||||
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "failed"}
|
||||
finally:
|
||||
if temp_work_dir and temp_work_dir.exists():
|
||||
shutil.rmtree(temp_work_dir, ignore_errors=True)
|
||||
state.reset_task_lock()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.services.runtime import (
|
||||
is_supervisor_running,
|
||||
restart_via_supervisor,
|
||||
terminate_current_process,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["service"])
|
||||
|
||||
|
||||
@router.post("/api/service/restart")
|
||||
async def restart_service():
|
||||
if is_supervisor_running():
|
||||
success, message = restart_via_supervisor()
|
||||
return {"success": success, "message": message, "method": "supervisor"}
|
||||
|
||||
if platform.system() != "Windows":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["supervisorctl", "restart", "fastapi"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "服务正在通过 supervisor 重启...",
|
||||
"method": "supervisor",
|
||||
}
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "重启操作超时,请检查 supervisor 状态",
|
||||
"method": "supervisor",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
thread = Thread(target=_delayed_exit, daemon=True)
|
||||
thread.start()
|
||||
return {"success": True, "message": "服务正在重启,请稍后刷新页面...", "method": "signal"}
|
||||
|
||||
|
||||
@router.get("/api/service/status")
|
||||
async def get_service_status():
|
||||
return {
|
||||
"status": "running",
|
||||
"version": "2.0.2",
|
||||
"platform": platform.system(),
|
||||
"supervisor": is_supervisor_running(),
|
||||
"pid": os.getpid(),
|
||||
"python_version": platform.python_version(),
|
||||
}
|
||||
|
||||
|
||||
def _delayed_exit() -> None:
|
||||
time.sleep(1)
|
||||
terminate_current_process()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from app import state
|
||||
from app.processor import DataProcessor, ProcessLogger
|
||||
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/api/task/status")
|
||||
async def get_global_task_status():
|
||||
if state.global_task_lock["locked"]:
|
||||
task_id = state.global_task_lock["task_id"]
|
||||
if task_id and _task_finished(task_id):
|
||||
state.reset_task_lock()
|
||||
return {"has_active": False}
|
||||
|
||||
return {
|
||||
"has_active": True,
|
||||
"task_id": task_id,
|
||||
"stage": state.global_task_lock["stage"],
|
||||
"started_at": state.global_task_lock["started_at"],
|
||||
"logs": [],
|
||||
}
|
||||
|
||||
active_tasks = {
|
||||
task_id: task
|
||||
for task_id, task in state.processing_tasks.items()
|
||||
if task.get("status") == "processing"
|
||||
}
|
||||
if active_tasks:
|
||||
task_id = next(iter(active_tasks))
|
||||
return {
|
||||
"has_active": True,
|
||||
"task_id": task_id,
|
||||
"stage": "processing",
|
||||
"logs": active_tasks[task_id].get("logs", []),
|
||||
}
|
||||
|
||||
return {"has_active": False}
|
||||
|
||||
|
||||
@router.post("/api/task/lock")
|
||||
async def lock_task(task_id: str = Body(..., embed=True)):
|
||||
if state.global_task_lock["locked"]:
|
||||
raise HTTPException(status_code=409, detail="已有任务在运行")
|
||||
|
||||
state.global_task_lock.update(
|
||||
{
|
||||
"locked": True,
|
||||
"task_id": task_id,
|
||||
"stage": "uploading",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
return {"success": True, "message": "任务已锁定"}
|
||||
|
||||
|
||||
@router.post("/api/task/unlock")
|
||||
async def unlock_task(task_id: str | None = Body(None, embed=True)):
|
||||
if task_id and state.global_task_lock["task_id"] != task_id:
|
||||
raise HTTPException(status_code=403, detail="无权解锁此任务")
|
||||
|
||||
state.reset_task_lock()
|
||||
return {"success": True, "message": "任务已解锁"}
|
||||
|
||||
|
||||
@router.get("/api/process/active")
|
||||
async def get_active_task():
|
||||
return await get_global_task_status()
|
||||
|
||||
|
||||
@router.post("/api/process/start")
|
||||
async def start_processing(task_id: str = Body(..., embed=True)):
|
||||
record = state.history_manager.get(task_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if record.status == "processing":
|
||||
raise HTTPException(status_code=400, detail="任务正在处理中")
|
||||
|
||||
work_dir = Path(record.work_dir)
|
||||
if not work_dir.exists():
|
||||
raise HTTPException(status_code=400, detail="工作目录不存在")
|
||||
|
||||
logs: list[str] = []
|
||||
|
||||
def log_callback(message: str) -> None:
|
||||
logs.append(message)
|
||||
state.processing_tasks[task_id] = {"logs": logs.copy(), "status": "processing"}
|
||||
|
||||
logger = ProcessLogger(log_file=work_dir / "log.txt", callback=log_callback)
|
||||
state.history_manager.update(task_id, status="processing")
|
||||
state.processing_tasks[task_id] = {"logs": [], "status": "processing"}
|
||||
state.global_task_lock.update(
|
||||
{
|
||||
"locked": True,
|
||||
"task_id": task_id,
|
||||
"stage": "processing",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
thread = Thread(target=_run_processing, args=(task_id, work_dir, logger), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return {"success": True, "message": "处理任务已启动", "task_id": task_id}
|
||||
|
||||
|
||||
@router.post("/api/process/status")
|
||||
async def get_processing_status(task_id: str = Body(..., embed=True)):
|
||||
if task_id in state.processing_tasks:
|
||||
task_info = state.processing_tasks[task_id]
|
||||
logs = task_info.get("logs") or state.history_manager.get_logs(task_id)
|
||||
return {"task_id": task_id, "status": task_info["status"], "logs": logs}
|
||||
|
||||
record = state.history_manager.get(task_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": record.status,
|
||||
"logs": state.history_manager.get_logs(task_id),
|
||||
"elapsed_time": record.elapsed_time,
|
||||
"error": record.error,
|
||||
}
|
||||
|
||||
|
||||
def _task_finished(task_id: str) -> bool:
|
||||
record = state.history_manager.get(task_id)
|
||||
if record and record.status in {"completed", "failed"}:
|
||||
return True
|
||||
|
||||
task_info: dict[str, Any] | None = state.processing_tasks.get(task_id)
|
||||
return bool(task_info and task_info.get("status") in {"completed", "failed"})
|
||||
|
||||
|
||||
def _run_processing(task_id: str, work_dir: Path, logger: ProcessLogger) -> None:
|
||||
try:
|
||||
processor = DataProcessor(state.config, work_dir, logger)
|
||||
result = processor.process()
|
||||
status = "completed" if result.get("success") else "failed"
|
||||
state.history_manager.update(
|
||||
task_id,
|
||||
status=status,
|
||||
elapsed_time=result.get("elapsed_time", 0),
|
||||
error=result.get("error"),
|
||||
result_tables=["4G_结果表", "5G_结果表"],
|
||||
)
|
||||
state.processing_tasks[task_id] = {
|
||||
"logs": state.history_manager.get_logs(task_id),
|
||||
"status": status,
|
||||
}
|
||||
except Exception as 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),
|
||||
"status": "failed",
|
||||
}
|
||||
finally:
|
||||
state.reset_task_lock()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from app import state
|
||||
from app.config import CACHE_DIR
|
||||
|
||||
|
||||
router = APIRouter(tags=["upload"])
|
||||
|
||||
|
||||
@router.post("/api/upload/create")
|
||||
async def create_upload_session():
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
work_dir = CACHE_DIR / session_id
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state.upload_sessions[session_id] = {
|
||||
"work_dir": work_dir,
|
||||
"files": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return {"success": True, "session_id": session_id, "work_dir": str(work_dir)}
|
||||
|
||||
|
||||
@router.post("/api/upload")
|
||||
async def upload_files(
|
||||
files: list[UploadFile] = File(...),
|
||||
session_id: Optional[str] = None,
|
||||
):
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="没有上传文件")
|
||||
|
||||
is_new_session = False
|
||||
if not session_id or session_id not in state.upload_sessions:
|
||||
if state.global_task_lock["locked"]:
|
||||
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
|
||||
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
work_dir = CACHE_DIR / session_id
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state.global_task_lock.update(
|
||||
{
|
||||
"locked": True,
|
||||
"task_id": session_id,
|
||||
"stage": "uploading",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
is_new_session = True
|
||||
|
||||
state.upload_sessions[session_id] = {
|
||||
"work_dir": work_dir,
|
||||
"files": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
else:
|
||||
if state.global_task_lock["locked"] and state.global_task_lock["task_id"] != session_id:
|
||||
raise HTTPException(status_code=409, detail="已有其他任务在运行")
|
||||
|
||||
session: dict[str, Any] = state.upload_sessions[session_id]
|
||||
work_dir = session["work_dir"]
|
||||
|
||||
try:
|
||||
saved_files: list[str] = []
|
||||
for file in files:
|
||||
if not file.filename:
|
||||
continue
|
||||
|
||||
file_path = work_dir / file.filename
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_bytes(await file.read())
|
||||
|
||||
saved_files.append(file.filename)
|
||||
session["files"].append(file.filename)
|
||||
|
||||
record = state.history_manager.get(session_id)
|
||||
if record:
|
||||
state.history_manager.update(session_id, file_count=len(session["files"]))
|
||||
else:
|
||||
state.history_manager.create(work_dir, len(session["files"]), record_id=session_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": session_id,
|
||||
"session_id": session_id,
|
||||
"work_dir": str(work_dir),
|
||||
"file_count": len(saved_files),
|
||||
"total_files": len(session["files"]),
|
||||
"files": saved_files,
|
||||
}
|
||||
except Exception as exc:
|
||||
if is_new_session:
|
||||
state.reset_task_lock()
|
||||
raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc
|
||||
|
||||
|
||||
@router.post("/api/upload/complete/{session_id}")
|
||||
async def complete_upload_session(session_id: str):
|
||||
if session_id not in state.upload_sessions:
|
||||
raise HTTPException(status_code=404, detail="上传会话不存在")
|
||||
|
||||
session = state.upload_sessions[session_id]
|
||||
state.history_manager.update(session_id, file_count=len(session["files"]))
|
||||
|
||||
return {"success": True, "session_id": session_id, "total_files": len(session["files"])}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import base64
|
||||
import configparser
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import BASE_DIR
|
||||
|
||||
|
||||
SECRET_KEY = "CapaReportSecretKey2026"
|
||||
AUTH_INI_PATH = BASE_DIR / "auth.ini"
|
||||
DEFAULT_USERNAME = "root"
|
||||
DEFAULT_PASSWORD = "Capacity"
|
||||
|
||||
|
||||
def _ensure_auth_ini() -> None:
|
||||
if AUTH_INI_PATH.exists():
|
||||
return
|
||||
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg["auth"] = {"username": DEFAULT_USERNAME, "password": DEFAULT_PASSWORD}
|
||||
with AUTH_INI_PATH.open("w", encoding="utf-8") as file:
|
||||
cfg.write(file)
|
||||
|
||||
|
||||
def get_auth_config() -> dict[str, str]:
|
||||
_ensure_auth_ini()
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(AUTH_INI_PATH, encoding="utf-8")
|
||||
return {
|
||||
"username": cfg.get("auth", "username", fallback=DEFAULT_USERNAME),
|
||||
"password": cfg.get("auth", "password", fallback=DEFAULT_PASSWORD),
|
||||
}
|
||||
|
||||
|
||||
def save_auth_password(new_password: str) -> None:
|
||||
_ensure_auth_ini()
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(AUTH_INI_PATH, encoding="utf-8")
|
||||
cfg.set("auth", "password", new_password)
|
||||
with AUTH_INI_PATH.open("w", encoding="utf-8") as file:
|
||||
cfg.write(file)
|
||||
|
||||
|
||||
def create_jwt_token(data: dict, expires_in: int = 86400 * 30) -> str:
|
||||
header = _encode_json({"alg": "HS256", "typ": "JWT"})
|
||||
payload_data = data.copy()
|
||||
payload_data["exp"] = int(time.time()) + expires_in
|
||||
payload = _encode_json(payload_data)
|
||||
signature = _sign(header, payload)
|
||||
return f"{header}.{payload}.{signature}"
|
||||
|
||||
|
||||
def verify_jwt_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
header, payload, signature = token.split(".")
|
||||
if not hmac.compare_digest(signature, _sign(header, payload)):
|
||||
return None
|
||||
|
||||
payload_padded = payload + "=" * ((4 - len(payload) % 4) % 4)
|
||||
data = json.loads(base64.urlsafe_b64decode(payload_padded).decode())
|
||||
if data.get("exp", 0) < int(time.time()):
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _encode_json(data: dict) -> str:
|
||||
raw = json.dumps(data, separators=(",", ":")).encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _sign(header: str, payload: str) -> str:
|
||||
raw = hmac.new(
|
||||
SECRET_KEY.encode(),
|
||||
f"{header}.{payload}".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
+5
-4
@@ -2,12 +2,13 @@
|
||||
数据库连接与操作模块 - 性能优化版
|
||||
"""
|
||||
import pymysql
|
||||
import sqlalchemy
|
||||
from sqlalchemy import create_engine, text, event
|
||||
from sqlalchemy.pool import QueuePool
|
||||
from urllib.parse import quote
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from app.config import AppConfig
|
||||
|
||||
|
||||
+88
-1352
File diff suppressed because it is too large
Load Diff
+4
-8
@@ -1,19 +1,18 @@
|
||||
"""
|
||||
数据处理核心模块 - 性能优化版
|
||||
"""
|
||||
import chardet
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
import multiprocessing
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import sqlparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from io import StringIO
|
||||
|
||||
from app.config import AppConfig, SQL_SCRIPT
|
||||
from app.database import DatabaseManager
|
||||
@@ -259,7 +258,6 @@ class DataProcessor:
|
||||
|
||||
def _detect_encoding(self, file_path: Path) -> str:
|
||||
"""快速检测文件编码(只读取前 8KB)"""
|
||||
import chardet
|
||||
with open(file_path, 'rb') as f:
|
||||
# 只读取前 8KB,足够检测编码,比 64KB 快很多
|
||||
result = chardet.detect(f.read(8192))
|
||||
@@ -377,7 +375,6 @@ class DataProcessor:
|
||||
temp_dir = self.work_dir / '.temp'
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -405,8 +402,7 @@ class DataProcessor:
|
||||
如果失败则自动回退到 bulk_insert 方式
|
||||
临时文件放在工作目录的 .temp 子目录中
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
|
||||
# 检测是否支持 LOAD DATA INFILE
|
||||
if not self._check_load_data_support():
|
||||
# 不支持,直接使用 bulk_insert
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Service helpers."""
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_supervisor_running() -> bool:
|
||||
if os.environ.get("SUPERVISOR_ENABLED") == "1":
|
||||
return True
|
||||
|
||||
supervisor_sock = Path("/var/run/supervisor.sock")
|
||||
if supervisor_sock.exists() and _command_succeeds(["supervisorctl", "status"], timeout=5):
|
||||
return True
|
||||
|
||||
try:
|
||||
import psutil # pyright: ignore[reportMissingModuleSource]
|
||||
|
||||
parent = psutil.Process().parent()
|
||||
if parent and "supervisor" in parent.name().lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _command_succeeds(["pgrep", "-f", "supervisord"], timeout=2)
|
||||
|
||||
|
||||
def restart_via_supervisor() -> tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["supervisorctl", "restart", "fastapi"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, "服务正在通过 supervisor 重启..."
|
||||
|
||||
error_msg = result.stderr or result.stdout or "未知错误"
|
||||
return False, f"重启失败: {error_msg}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "重启操作超时"
|
||||
except FileNotFoundError:
|
||||
return False, "找不到 supervisorctl 命令"
|
||||
except Exception as exc:
|
||||
return False, f"重启异常: {exc}"
|
||||
|
||||
|
||||
def terminate_current_process() -> None:
|
||||
if platform.system() == "Windows":
|
||||
os._exit(0)
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
def _command_succeeds(command: list[str], timeout: int) -> bool:
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
return result.returncode == 0 and bool(result.stdout.strip() or command[0] == "supervisorctl")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Any
|
||||
|
||||
from app.config import AppConfig
|
||||
from app.history import HistoryManager
|
||||
|
||||
|
||||
config = AppConfig.load()
|
||||
history_manager = HistoryManager()
|
||||
processing_tasks: dict[str, dict[str, Any]] = {}
|
||||
upload_sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
global_task_lock: dict[str, Any] = {
|
||||
"locked": False,
|
||||
"task_id": None,
|
||||
"stage": None,
|
||||
"started_at": None,
|
||||
}
|
||||
|
||||
|
||||
def reset_task_lock() -> None:
|
||||
global_task_lock["locked"] = False
|
||||
global_task_lock["task_id"] = None
|
||||
global_task_lock["stage"] = None
|
||||
global_task_lock["started_at"] = None
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Utility helpers."""
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_dir_size(path: Path) -> int:
|
||||
total_size = 0
|
||||
try:
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
|
||||
if path.is_dir():
|
||||
for item in path.iterdir():
|
||||
total_size += get_dir_size(item)
|
||||
except (PermissionError, OSError):
|
||||
return total_size
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
|
||||
value = float(size_bytes)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if value < 1024.0:
|
||||
return f"{value:.2f} {unit}"
|
||||
value /= 1024.0
|
||||
return f"{value:.2f} PB"
|
||||
|
||||
Reference in New Issue
Block a user