feat: 新增数据库前置检查,缺表按 db_init 预设自动建好(sector/sector_band_ref/cellinfo)

This commit is contained in:
2026-06-26 11:24:24 +08:00
parent 98f03b3e0e
commit 5914046dec
9 changed files with 254 additions and 34 deletions
+11 -34
View File
@@ -1,41 +1,18 @@
-- =============================================================================
-- CellData 数据处理脚本(在 celldata 库执行)
-- 1) 规范化 cellinfo.带宽
-- 2) 重建频段特征库 sector_band_ref(频点/PLMN -> 制式/频段 对应表)
-- 3) 由 cellinfo 逆推 sector(扇区/物理站/制式/频段/带宽/站型/网络)
-- 2) 由 cellinfo 逆推 sector(扇区/物理站/制式/频段/带宽/站型/网络)
-- —— 不清空 sector,仅补充缺失 CGI,已有行(含人工修正)保留不覆盖
-- 依赖的结构表(cellinfo / sector / 频段特征库 sector_band_ref)由程序前置检查
-- (app/db_init.py + db_init/ 目录)保证存在,缺表会自动按预设建好;sector_band_ref
-- 一经创建即持久保留,可在数据库中自行增改频段规则,本脚本不再重建以免冲掉自定义。
-- 逆推规则与准确率见 docs/sector_inference_research.md
-- =============================================================================
-- 1. 带宽规范化:空值补 0M,统一大写并以 M 结尾(如 20M)
UPDATE cellinfo SET `带宽` = IF(`带宽` IS NULL OR `带宽` = '', '0M', CONCAT(REPLACE(UPPER(`带宽`),'M',''), 'M'));
-- 2. 频段特征库:网络 + 频点区间(+PLMN) -> 制式/频段
-- 700M 与 广电 同频点 763.25,由 PLMN 区分(460-00=移动700M,460-15=中国广电)
-- D频 与 3DMM 同频点同 PLMN 同带宽,cellinfo 无法区分,统一判 D频(3DMM 为天线属性)
DROP TABLE IF EXISTS sector_band_ref;
CREATE TABLE sector_band_ref (
`网络` varchar(8),
`频点下限` decimal(10,2),
`频点上限` decimal(10,2),
`PLMN` varchar(16) NULL,
`制式` varchar(20),
`频段` varchar(20)
);
-- 频点区间为左闭右开 [频点下限, 频点上限)
INSERT INTO sector_band_ref (`网络`,`频点下限`,`频点上限`,`PLMN`,`制式`,`频段`) VALUES
('5G',4000,99999,NULL,'4.9G','4.9G'),
('5G',700,800,'460-00','700M','700M'),
('5G',700,800,'460-15','广电','广电'),
('5G',2000,3000,NULL,'2.6G','2.6G'),
('4G',900,1000,NULL,'FDD','FDD900'),
('4G',1000,1880,NULL,'FDD','FDD1800'),
('4G',1880,1920,NULL,'TDD','F频'),
('4G',1920,2110,NULL,'TDD','A频'),
('4G',2300,2400,NULL,'TDD','E频'),
('4G',2400,2700,NULL,'TDD','D频');
-- 3. 逆推暂存表:预处理小区名称(全角括号->半角、【】->()、去空格/制表符)
-- 2. 逆推暂存表:预处理小区名称(全角括号->半角、【】->()、去空格/制表符)
DROP TABLE IF EXISTS _sector_infer;
CREATE TABLE _sector_infer AS
SELECT
@@ -53,7 +30,7 @@ ALTER TABLE _sector_infer
ADD COLUMN `物理站` varchar(200),
ADD COLUMN `扇区` varchar(210);
-- 3.1 设备码([频率前缀]-Z[xx]-[扇区号])剥离得到 base;尾部数字串;站型判定
-- 2.1 设备码([频率前缀]-Z[xx]-[扇区号])剥离得到 base;尾部数字串;站型判定
-- 站型:设备码尾字母 W=室分;名称含"微小"=微站;其余=宏站
UPDATE _sector_infer SET
base = REGEXP_REPLACE(name1,'[A-Z0-9]+-Z[A-Z0-9]{2}-[0-9]+$',''),
@@ -63,7 +40,7 @@ UPDATE _sector_infer SET
WHEN name1 LIKE '%微小%' THEN '微站'
ELSE '宏站' END;
-- 3.2 制式/频段:按特征库匹配;未命中回落 cellinfo 原制式
-- 2.2 制式/频段:按特征库匹配;未命中回落 cellinfo 原制式
UPDATE _sector_infer t
JOIN sector_band_ref r
ON r.`网络` = t.`网络`
@@ -73,7 +50,7 @@ JOIN sector_band_ref r
SET t.`制式` = r.`制式`, t.`频段` = r.`频段`;
UPDATE _sector_infer SET `制式` = ci_zs, `频段` = ci_zs WHERE `制式` IS NULL;
-- 3.3 物理站(非 700M/广电):去码后删全部括号注记,保留(微小X)并回贴;
-- 2.3 物理站(非 700M/广电):去码后删全部括号注记,保留(微小X)并回贴;
-- 再去掉不闭合的悬空开括号与孤立右括号
UPDATE _sector_infer SET `物理站` = CONCAT(
TRIM(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(
@@ -81,7 +58,7 @@ UPDATE _sector_infer SET `物理站` = CONCAT(
IFNULL(REGEXP_SUBSTR(name1,'[(]微小[A-Z]?[)]'),''))
WHERE `制式` IS NULL OR `制式` NOT IN ('700M','广电');
-- 3.4 物理站(700M/广电):取以"(江门"开头的括号内容为真实站址;
-- 2.4 物理站(700M/广电):取以"(江门"开头的括号内容为真实站址;
-- 无则取外层(去 CBN- 前缀与注记)。括号内容以"共"开头属共建标注,归外层
UPDATE _sector_infer SET `物理站` =
CASE WHEN REGEXP_SUBSTR(base,'\\(江门[^()]*') <> ''
@@ -89,13 +66,13 @@ UPDATE _sector_infer SET `物理站` =
ELSE TRIM(REGEXP_REPLACE(REGEXP_REPLACE(base,'\\(.*$',''),'^CBN-?','')) END
WHERE `制式` IN ('700M','广电');
-- 3.5 扇区 = 物理站 + 扇区号(700M/广电取末位数字;其余取数字串 % 100)
-- 2.5 扇区 = 物理站 + 扇区号(700M/广电取末位数字;其余取数字串 % 100)
UPDATE _sector_infer SET `扇区` = CONCAT(`物理站`,
CASE WHEN numstr IS NULL OR numstr = '' THEN ''
WHEN `制式` IN ('700M','广电') THEN RIGHT(numstr,1)
ELSE CAST(CAST(numstr AS UNSIGNED) % 100 AS CHAR) END);
-- 4. 补缺写回 sector:仅插入 sector 中尚不存在的 CGI
-- 3. 补缺写回 sector:仅插入 sector 中尚不存在的 CGI
-- 已存在的 CGI 一律保留,避免人工修正(如 3DMM、人工纠正的物理站/带宽)被覆盖
INSERT INTO sector (CGI,`扇区`,`物理站`,`制式`,`频段`,`带宽`,`站型`,`网络`)
SELECT i.CGI, i.`扇区`, i.`物理站`, i.`制式`, i.`频段`, i.`带宽`, i.`站型`, i.`网络`
+115
View File
@@ -0,0 +1,115 @@
"""数据库前置检查:确保各库「必须存在的结构表」已建好,缺表则按 db_init/ 下的初始化 SQL 自动创建。
约定(见 db_init/README.md):
- 初始化 SQL 放在 BASE_DIR/db_init/,文件名形如 ``<库标识>.<表名>.sql``。
- 库标识 -> 实际连接(库名以用户配置为准,不写死):
celldata -> AppConfig.cell_data.mysql(CellData 库,始终直连 MySQL)
capacityreport -> AppConfig.mysql(主仓库,仅当 warehouse_type == 'mysql' 直连时检查)
- 仅当目标表「不存在」时执行对应 SQL;已存在则跳过整文件,
因此 sector_band_ref 这类带预设数据的表只在首次创建时写入,绝不覆盖用户自定义。
- 全过程 best-effort:单个库/单张表失败只记录日志并继续,不阻断启动或处理流程。
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import pymysql
from app.config import BASE_DIR, AppConfig, MySQLConfig
DB_INIT_DIR = BASE_DIR / "db_init"
def _log(logger, message: str) -> None:
if logger is not None:
try:
logger.info(message)
return
except Exception:
pass
print(message)
def _target_mysql(app_config: AppConfig, db_key: str) -> Optional[MySQLConfig]:
"""库标识 -> MySQL 连接配置;不适用(如主库走 Metrix)时返回 None。"""
if db_key == "celldata":
return app_config.cell_data.mysql.normalized()
if db_key == "capacityreport":
if app_config.warehouse_type != "mysql":
return None
return app_config.mysql.normalized()
return None
def _connect(mysql: MySQLConfig):
return pymysql.connect(
host=mysql.host,
port=mysql.port,
user=mysql.user,
password=mysql.passwd,
database=mysql.dbname,
charset="utf8mb4",
cursorclass=pymysql.cursors.Cursor,
autocommit=False,
)
def _existing_tables(conn) -> set[str]:
with conn.cursor() as cursor:
cursor.execute("SHOW TABLES")
return {str(row[0]).lower() for row in cursor.fetchall()}
def _run_sql_file(conn, path: Path) -> None:
from app.processor import DataProcessor
statements = DataProcessor.parse_sql_script(path.read_text(encoding="utf-8"))
with conn.cursor() as cursor:
for statement in statements:
cursor.execute(statement)
conn.commit()
def _discover() -> dict[str, list[tuple[str, Path]]]:
"""收集 db_init 下的初始化 SQL,按库标识分组:{库标识: [(表名, 路径), ...]}。"""
groups: dict[str, list[tuple[str, Path]]] = {}
if not DB_INIT_DIR.exists():
return groups
for path in sorted(DB_INIT_DIR.glob("*.sql")):
stem = path.stem # 例如 celldata.sector_band_ref
if "." not in stem:
continue
db_key, table = stem.split(".", 1)
groups.setdefault(db_key, []).append((table, path))
return groups
def ensure_required_tables(app_config: AppConfig, logger=None) -> None:
"""检查各库必须存在的表,缺则按初始化 SQL 建好。失败不抛出(仅记录日志)。"""
groups = _discover()
for db_key, items in groups.items():
mysql = _target_mysql(app_config, db_key)
if mysql is None or not mysql.dbname:
continue
try:
conn = _connect(mysql)
except Exception as exc: # noqa: BLE001
_log(logger, f"[前置检查] 连接库 {db_key}({mysql.dbname}@{mysql.host}:{mysql.port}) 失败,跳过:{exc}")
continue
try:
existing = _existing_tables(conn)
for table, path in items:
if table.lower() in existing:
continue
try:
_run_sql_file(conn, path)
_log(logger, f"[前置检查] {db_key}.{table} 不存在,已按 {path.name} 初始化建表")
except Exception as exc: # noqa: BLE001
try:
conn.rollback()
except Exception:
pass
_log(logger, f"[前置检查] 初始化 {db_key}.{table} 失败:{exc}")
finally:
conn.close()
+6
View File
@@ -43,6 +43,12 @@ LOGIN_ONLY_API_PREFIXES = (
@asynccontextmanager
async def app_lifespan(app: FastAPI):
try:
from app.config import AppConfig
from app.db_init import ensure_required_tables
ensure_required_tables(AppConfig.load())
except Exception as exc: # noqa: BLE001
print(f"[前置检查] 启动检查异常:{exc}")
state.auto_scheduler = AutoScheduler()
state.auto_scheduler.start()
try:
+6
View File
@@ -460,6 +460,12 @@ def refresh_cell_data(app_config: AppConfig, work_dir: Path, logger=None) -> Cel
def execute_celldata_script(script_path: Path, app_config: AppConfig, logger=None) -> None:
try:
from app.db_init import ensure_required_tables
ensure_required_tables(app_config, logger)
except Exception as exc: # noqa: BLE001
if logger:
logger.info(f"[前置检查] 执行异常:{exc}")
if not script_path.exists():
if logger:
logger.info("CellData 脚本文件不存在,跳过")
+51
View File
@@ -0,0 +1,51 @@
# db_init —— 数据库前置检查 / 结构表初始化
本目录存放各数据库「**必须存在的结构表**」的初始化 SQL。程序启动时、以及每次执行
CellData 脚本前,会由 `app/db_init.py` 的 `ensure_required_tables()` 自动检查:
**某张必需表不存在时,就执行对应的初始化 SQL 把它建好**,从而避免运行过程中因缺表报错。
## 文件命名规则
```
<库标识>.<表名>.sql
```
- `<库标识>` 决定连到哪个库(**实际库名以用户在「系统设置」里配置的为准,不写死**):
| 库标识 | 对应配置 | 说明 |
|---|---|---|
| `celldata` | `CellData.MySQL_DBInfo` | CellData 库,始终直连 MySQL |
| `capacityreport` | `MySQL_DBInfo`(主仓库) | 仅当仓库为「直连 MySQL」时检查;Metrix 模式跳过 |
- `<表名>` 为要检查/创建的表名(区分库标识后剩余部分,表名本身不含 `.`)。
例:`celldata.sector.sql` = 在 CellData 库里确保 `sector` 表存在。
## 执行时机与规则
- 触发点:应用启动(`app/main.py` lifespan)、执行 CellData 脚本前(`execute_celldata_script`)。
- 判定:用 `SHOW TABLES` 取现有表,**仅当目标表不存在**时才执行该 `.sql`;已存在则整文件跳过。
- 因此带预设数据的表(如 `sector_band_ref`)**只在首次创建时写入预设**,之后你在库里
对它的任何增改都会被保留,不会被覆盖或重置。
- 全过程 best-effort:单库连不上 / 单表初始化失败只记日志,不会中断启动或数据处理。
- 前提:**数据库本身需已存在**(本机制只建表,不建库)。
## 当前清单
### celldata(CellData 库)
| 文件 | 表 | 用途 |
|---|---|---|
| `celldata.cellinfo.sql` | `cellinfo` | 小区基础信息。正常由导入自动建,此处兜底。 |
| `celldata.sector.sql` | `sector` | 扇区表,CellData.sql 逆推写入目标。**不会被任何流程自动创建**,必须前置建好。 |
| `celldata.sector_band_ref.sql` | `sector_band_ref` | 频段特征库(频点区间+PLMN→制式/频段),含预设 10 条规则,供逆推用,可自行增改。 |
### capacityreport(主仓库)
当前**无需前置建表**:主仓库的原始表(`4G_UD`/`5G_UD`)、结果表(`4G_结果表`/`5G_结果表`)
以及从 CellData 复制过来的 `sector`/`cellinfo`,都由导入流程 / `ReportScript.sql` /
跨库复制以 `DROP TABLE IF EXISTS` + `CREATE` 动态生成,结构随源数据字段而定,
不宜在此预先固定结构(会与动态建表冲突)。如确有需要,按上面的命名规则新增
`capacityreport.<表名>.sql` 即可被自动纳入检查。
## 新增一张必需表
1. 在本目录新建 `<库标识>.<表名>.sql`,内容用 `CREATE TABLE IF NOT EXISTS ...`;
如需预设数据,在其后追加 `INSERT ...`(只会在表首次创建时执行)。
2. 无需改 Python,`ensure_required_tables()` 会自动发现并按需执行。
+17
View File
@@ -0,0 +1,17 @@
-- celldata.cellinfo:小区基础信息表
-- 正常由 CellData 导入流程自动创建(CREATE TABLE IF NOT EXISTS);此处为前置兜底,
-- 避免在尚未导入时执行 CellData.sql 的 `UPDATE cellinfo ...` 因缺表报错。
CREATE TABLE IF NOT EXISTS `cellinfo` (
`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;
+14
View File
@@ -0,0 +1,14 @@
-- celldata.sector:扇区表(CellData.sql 逆推写入的目标表)
-- 不会被任何导入流程自动创建,缺表会导致 CellData.sql 的 `INSERT INTO sector ...` 报错,
-- 因此必须前置建好结构。已有数据(含人工修正)由 CellData.sql「仅补缺不覆盖」保护。
CREATE TABLE IF NOT EXISTS `sector` (
`CGI` varchar(120) DEFAULT NULL,
`扇区` varchar(200) DEFAULT NULL,
`物理站` varchar(200) DEFAULT NULL,
`制式` varchar(50) DEFAULT NULL,
`频段` varchar(50) DEFAULT NULL,
`带宽` varchar(20) DEFAULT NULL,
`站型` varchar(50) DEFAULT NULL,
`网络` varchar(20) DEFAULT NULL,
KEY `CGI` (`CGI`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+24
View File
@@ -0,0 +1,24 @@
-- celldata.sector_band_ref:频段特征库(频点区间 + PLMN -> 制式/频段),供 CellData.sql 逆推使用
-- 频点区间为左闭右开 [频点下限, 频点上限);PLMN 为空表示该频段不分运营商。
-- 仅在该表不存在时由前置检查执行本文件(建表 + 写入下列预设);
-- 表已存在则完全跳过本文件,保留用户对特征库的自定义,不会被覆盖或重置。
CREATE TABLE IF NOT EXISTS `sector_band_ref` (
`网络` varchar(8) DEFAULT NULL,
`频点下限` decimal(10,2) DEFAULT NULL,
`频点上限` decimal(10,2) DEFAULT NULL,
`PLMN` varchar(16) DEFAULT NULL,
`制式` varchar(20) DEFAULT NULL,
`频段` varchar(20) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `sector_band_ref` (`网络`,`频点下限`,`频点上限`,`PLMN`,`制式`,`频段`) VALUES
('5G',4000,99999,NULL,'4.9G','4.9G'),
('5G',700,800,'460-00','700M','700M'),
('5G',700,800,'460-15','广电','广电'),
('5G',2000,3000,NULL,'2.6G','2.6G'),
('4G',900,1000,NULL,'FDD','FDD900'),
('4G',1000,1880,NULL,'FDD','FDD1800'),
('4G',1880,1920,NULL,'TDD','F频'),
('4G',1920,2110,NULL,'TDD','A频'),
('4G',2300,2400,NULL,'TDD','E频'),
('4G',2400,2700,NULL,'TDD','D频');
+10
View File
@@ -1003,6 +1003,16 @@ CellData 处理日志细化(`app/services/cell_data.py`):
- 准确率(MCP 实测,重叠 66001):网络 100%、制式 97.88%(排除 3DMM 即 100%)、频段 97.6%、带宽 99.75%、站型 99.94%、**物理站 99.59%、扇区 99.20%**;残差为不可还原的人工差异(室分多载波编号、700M 个别人工编号、源数据制表符等)。详见 `docs/sector_inference_research.md`。
- 关键坑:MySQL 字符串字面量会吞掉未知转义的反斜杠,正则字面量需写 `\\(`(.sql 文件)/ JSON 调用需 `\\\\(`;ICU 正则字符类内的 `[ ]` 易误判,统一把名称中的 `[]` 转 `()` 后只处理圆括号。
## 2026-06-26:数据库前置检查 / 缺表自动初始化(db_init)
- 目的:运行前自动检查两库「必须存在的结构表」,缺表则按预设 SQL 自动建好,避免运行中因缺表报错。
- 目录 `db_init/`(一个目录,文件名 `<库标识>.<表名>.sql`):`<库标识>` 决定连哪个库且**库名以用户配置为准**——`celldata`→`cell_data.mysql`(始终直连)、`capacityreport`→`mysql`(仅 `warehouse_type=='mysql'` 直连时检查,Metrix 模式跳过)。当前文件:`celldata.cellinfo.sql`、`celldata.sector.sql`、`celldata.sector_band_ref.sql`(含 10 条预设频段规则)。`README.md` 说明命名/时机/规则。
- 必须存在表分析:celldata 的 `sector` **无任何流程会自动建**(CellData.sql 直接 INSERT,缺表必报错),`sector_band_ref` 需持久+预设,`cellinfo` 兜底(正常由导入 `CREATE TABLE IF NOT EXISTS` 自建)。capacityreport 的 `4G_UD/5G_UD`、`4G_结果表/5G_结果表`、复制来的 `sector/cellinfo` 全由导入/ReportScript/跨库复制以 `DROP+CREATE` 动态生成(结构随源字段变),**不前置强建**以免冲突。
- 实现 `app/db_init.py::ensure_required_tables(app_config, logger=None)`:扫描 `db_init/*.sql` 按库分组;`SHOW TABLES` 取现有表,**仅当目标表不存在**时用 `DataProcessor.parse_sql_script` 拆分并执行该文件(故 `sector_band_ref` 预设只在首建时写入,绝不覆盖用户自定义);逐库/逐表 try/except,best-effort 不阻断。
- 接入:`app/main.py` lifespan 启动时调用;`execute_celldata_script` 执行 CellData.sql 前调用(确保 sector/sector_band_ref 就位)。
- CellData.sql 同步:**移除原 `DROP TABLE sector_band_ref; CREATE; INSERT` 重建块**,改为依赖前置检查持久维护(用户可在库中自行增改频段规则不被冲掉);其余逆推逻辑不变,小节重排为 1/2.x/3。
- 前提:数据库本身需已存在(本机制只建表不建库)。本地无 pymysql(运行态在 Docker,requirements 已含),端到端以 py_compile + 三表 CREATE 语法(MCP)验证。
## 2026-06-26:离线可用性全面审计(运行期零外网)
结论:**运行期已完全离线就绪,无需改运行时代码**;外网仅「构建期」需要。审计证据: