"""容量看板分析接口。 基于 4G/5G 结果表(含富集 + 高负荷判定 + 优化建议列)做聚合分析,供前端容量看板展示。 读经 `make_warehouse`(直连 MySQL 或 Metrix 平台),分析对象为“主仓库”里的结果表。 """ from __future__ import annotations import csv from datetime import datetime from typing import Any from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from starlette.background import BackgroundTask from app import state from app.config import CACHE_DIR from app.utils.files import remove_file_safely from app.warehouse import make_warehouse router = APIRouter(tags=["dashboard"]) # 每个制式(结果表)的关键列映射 RAT = { "4g": { "table": "4G_结果表", "id": "CGI", "name": "小区名称", "ul": "上行PUSCH利用率", "dl": "下行PDSCH利用率", "ul_label": "上行PUSCH利用率", "dl_label": "下行PDSCH利用率", "users": "YY-RRC连接建立最大用户数", }, "5g": { "table": "5G_结果表", "id": "NCGI", "name": "CU小区配置名称", "ul": "上行PRB平均利用率", "dl": "下行PRB平均利用率", "ul_label": "上行PRB利用率", "dl_label": "下行PRB利用率", "users": "RRC连接平均连接用户数", }, } FLOW = "日均流量(GB)" PROBLEMS = ["高负荷", "利用率预警", "高流量预警"] def _db(): return make_warehouse(state.current_config()) def _esc(value: str) -> str: return str(value).replace("\\", "\\\\").replace("'", "''") def _rows(db, sql: str) -> list[dict[str, Any]]: ok, result = db.execute_sql(sql) if not ok: raise HTTPException(status_code=500, detail=str(result)) return result if isinstance(result, list) else [] def _num(value: Any, digits: int | None = None) -> float | int: try: num = float(value) except (TypeError, ValueError): return 0 if digits is None: return int(num) return round(num, digits) def _rat(rat: str) -> dict[str, str]: cfg = RAT.get((rat or "").lower()) if not cfg: raise HTTPException(status_code=400, detail="rat 仅支持 4g / 5g") return cfg def _existing_tables(db) -> set[str]: try: return {str(t).lower() for t in db.get_tables()} except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=500, detail=f"读取数据表失败: {exc}") from exc @router.get("/api/dashboard/status") def dashboard_status(): tables = _existing_tables(_db()) has_4g = RAT["4g"]["table"].lower() in tables has_5g = RAT["5g"]["table"].lower() in tables return {"has_4g": has_4g, "has_5g": has_5g, "ready": has_4g and has_5g} @router.get("/api/dashboard/overview") def dashboard_overview(rat: str = Query("4g")): cfg = _rat(rat) db = _db() if cfg["table"].lower() not in _existing_tables(db): raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") t = f"`{cfg['table']}`" ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" summary = _rows(db, ( f"SELECT COUNT(*) total," f" SUM(`高负荷问题`='高负荷') high_load," f" SUM(`高负荷问题`='利用率预警') util_warn," f" SUM(`高负荷问题`='高流量预警') flow_warn," f" SUM(`高负荷问题` IS NULL) normal," f" ROUND(AVG({ul})*100,1) avg_ul," f" ROUND(AVG({dl})*100,1) avg_dl," f" ROUND(MAX({dl})*100,1) max_dl," f" ROUND(AVG({flow}),2) avg_flow," f" ROUND(SUM({flow}),1) total_flow" f" FROM {t}" )) s = summary[0] if summary else {} summary_out = { "total": _num(s.get("total")), "high_load": _num(s.get("high_load")), "util_warn": _num(s.get("util_warn")), "flow_warn": _num(s.get("flow_warn")), "normal": _num(s.get("normal")), "avg_ul": _num(s.get("avg_ul"), 1), "avg_dl": _num(s.get("avg_dl"), 1), "max_dl": _num(s.get("max_dl"), 1), "avg_flow": _num(s.get("avg_flow"), 2), "total_flow": _num(s.get("total_flow"), 1), } problem_pie = [ {"name": "高负荷", "value": summary_out["high_load"]}, {"name": "利用率预警", "value": summary_out["util_warn"]}, {"name": "高流量预警", "value": summary_out["flow_warn"]}, {"name": "正常", "value": summary_out["normal"]}, ] def group_by(col: str, limit: int = 0, skip_unknown: bool = False) -> list[dict[str, Any]]: limit_sql = f" LIMIT {int(limit)}" if limit else "" where_sql = f" WHERE `{col}` IS NOT NULL AND `{col}` <> ''" if skip_unknown else "" rows = _rows(db, ( f"SELECT IFNULL(`{col}`,'未知') name, COUNT(*) total," f" SUM(`是否高负荷小区`='是') high," f" SUM(`高负荷问题` IS NOT NULL) flagged" f" FROM {t}{where_sql} GROUP BY `{col}` ORDER BY flagged DESC, total DESC{limit_sql}" )) return [ {"name": str(r.get("name")), "total": _num(r.get("total")), "high": _num(r.get("high")), "flagged": _num(r.get("flagged"))} for r in rows ] def util_hist(col: str) -> list[dict[str, Any]]: rows = _rows(db, ( f"SELECT LEAST(FLOOR({col}*10),9) b, COUNT(*) c FROM {t}" f" WHERE {col} IS NOT NULL GROUP BY b ORDER BY b" )) bucket_map = {int(_num(r.get("b"))): _num(r.get("c")) for r in rows} return [{"bucket": f"{i*10}-{i*10+10}%", "value": bucket_map.get(i, 0)} for i in range(10)] top_rows = _rows(db, ( f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`," f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station," f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," f" IFNULL(`高负荷问题`,'') problem" f" FROM {t} WHERE `高负荷问题`='高负荷' ORDER BY {dl} DESC, {flow} DESC LIMIT 10" )) top_cells = [ {"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")), "band": str(r.get("band")), "station": str(r.get("station")), "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), "flow": _num(r.get("flow"), 2), "users": _num(r.get("users")), "problem": str(r.get("problem"))} for r in top_rows ] return { "rat": rat.lower(), "labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]}, "summary": summary_out, "problem_pie": problem_pie, "by_system": group_by("制式", skip_unknown=True), "by_station": group_by("站型", skip_unknown=True), "by_freq": group_by("频段", limit=12, skip_unknown=True), "ul_hist": util_hist(ul), "dl_hist": util_hist(dl), "top_cells": top_cells, } @router.get("/api/dashboard/cells") def dashboard_cells( rat: str = Query("4g"), problem: str = Query(""), keyword: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), ): cfg = _rat(rat) db = _db() if cfg["table"].lower() not in _existing_tables(db): raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") t = f"`{cfg['table']}`" ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" wheres = ["`高负荷问题` IS NOT NULL"] if problem in PROBLEMS: wheres = [f"`高负荷问题`='{problem}'"] if keyword.strip(): kw = _esc(keyword.strip()) wheres.append(f"(`{cfg['id']}` LIKE '%{kw}%' OR `{cfg['name']}` LIKE '%{kw}%')") where_sql = " WHERE " + " AND ".join(wheres) total = _num(_rows(db, f"SELECT COUNT(*) c FROM {t}{where_sql}")[0].get("c")) offset = (page - 1) * page_size rows = _rows(db, ( f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`," f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station, IFNULL(`频段`,'') freq," f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," f" IFNULL(`高负荷问题`,'') problem, IFNULL(`是否高负荷小区`,'否') is_high" f" FROM {t}{where_sql}" f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC" f" LIMIT {int(page_size)} OFFSET {int(offset)}" )) items = [ {"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")), "band": str(r.get("band")), "station": str(r.get("station")), "freq": str(r.get("freq")), "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), "flow": _num(r.get("flow"), 2), "users": _num(r.get("users")), "problem": str(r.get("problem")), "is_high": str(r.get("is_high"))} for r in rows ] return {"items": items, "total": total, "page": page, "page_size": page_size} @router.get("/api/dashboard/export") def dashboard_export(rat: str = Query("4g"), problem: str = Query(""), keyword: str = Query("")): cfg = _rat(rat) db = _db() if cfg["table"].lower() not in _existing_tables(db): raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理") t = f"`{cfg['table']}`" ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`" wheres = ["`高负荷问题` IS NOT NULL"] if problem in PROBLEMS: wheres = [f"`高负荷问题`='{problem}'"] if keyword.strip(): kw = _esc(keyword.strip()) wheres.append(f"(`{cfg['id']}` LIKE '%{kw}%' OR `{cfg['name']}` LIKE '%{kw}%')") where_sql = " WHERE " + " AND ".join(wheres) rows = _rows(db, ( f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') sys," f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station, IFNULL(`频段`,'') freq," f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users," f" IFNULL(`高负荷问题`,'') problem, IFNULL(`优化建议`,'') suggestion" f" FROM {t}{where_sql}" f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC" )) CACHE_DIR.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"问题小区清单_{rat.lower()}_{timestamp}.csv" filepath = CACHE_DIR / filename header = [cfg["id"], "小区名称", "制式", "带宽", "站型", "频段", "上行利用率(%)", "下行利用率(%)", "日均流量(GB)", "用户数", "高负荷问题", "优化建议"] try: with filepath.open("w", encoding="utf-8-sig", newline="") as handle: writer = csv.writer(handle) writer.writerow(header) for r in rows: writer.writerow([ r.get("id"), r.get("name"), r.get("sys"), r.get("band"), r.get("station"), r.get("freq"), r.get("ul"), r.get("dl"), r.get("flow"), r.get("users"), r.get("problem"), r.get("suggestion"), ]) except Exception: remove_file_safely(filepath) raise return FileResponse( path=str(filepath), filename=filename, media_type="text/csv", background=BackgroundTask(remove_file_safely, filepath), ) @router.get("/api/dashboard/cell") def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)): cfg = _rat(rat) db = _db() if cfg["table"].lower() not in _existing_tables(db): raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在") t = f"`{cfg['table']}`" cell_id = _esc(id) rows = _rows(db, f"SELECT * FROM {t} WHERE `{cfg['id']}`='{cell_id}' LIMIT 1") if not rows: raise HTTPException(status_code=404, detail="未找到该小区") row = rows[0] # 同扇区同 PLMN 的兄弟小区(用于详情页的均衡上下文) sector = str(row.get("扇区") or "") siblings: list[dict[str, Any]] = [] if sector: plmn = _esc("-".join(str(id).split("-")[:2])) sector_e = _esc(sector) dl = f"`{cfg['dl']}`" sib_rows = _rows(db, ( f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`带宽`,'') band," f" IFNULL(`频段`,'') freq, ROUND(`{cfg['ul']}`*100,1) ul, ROUND({dl}*100,1) dl," f" ROUND(`{FLOW}`,2) flow, IFNULL(`高负荷问题`,'正常') problem" f" FROM {t} WHERE `扇区`='{sector_e}' AND SUBSTRING_INDEX(`{cfg['id']}`,'-',2)='{plmn}'" f" AND `{cfg['id']}`<>'{cell_id}' ORDER BY {dl} DESC LIMIT 30" )) siblings = [ {"id": str(r.get("id")), "name": str(r.get("name")), "band": str(r.get("band")), "freq": str(r.get("freq")), "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), "flow": _num(r.get("flow"), 2), "problem": str(r.get("problem"))} for r in sib_rows ] # 原始行转为字符串友好的 dict(数值保留,None→空) detail = {str(k): (v if v is not None else "") for k, v in row.items()} return { "rat": rat.lower(), "id": str(row.get(cfg["id"]) or id), "name": str(row.get(cfg["name"]) or ""), "labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]}, "id_field": cfg["id"], "name_field": cfg["name"], "ul_field": cfg["ul"], "dl_field": cfg["dl"], "users_field": cfg["users"], "flow_field": FLOW, "detail": detail, "siblings": siblings, }