From 5914046dec3ca32f7ea05cd38a2e3e236c0ba0f7 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Fri, 26 Jun 2026 11:24:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=89=8D=E7=BD=AE=E6=A3=80=E6=9F=A5=EF=BC=8C=E7=BC=BA?= =?UTF-8?q?=E8=A1=A8=E6=8C=89=20db=5Finit=20=E9=A2=84=E8=AE=BE=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=BB=BA=E5=A5=BD=EF=BC=88sector/sector=5Fband=5Fref/?= =?UTF-8?q?cellinfo=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CellData.sql | 45 +++-------- app/db_init.py | 115 +++++++++++++++++++++++++++ app/main.py | 6 ++ app/services/cell_data.py | 6 ++ db_init/README.md | 51 ++++++++++++ db_init/celldata.cellinfo.sql | 17 ++++ db_init/celldata.sector.sql | 14 ++++ db_init/celldata.sector_band_ref.sql | 24 ++++++ docs/project_context.md | 10 +++ 9 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 app/db_init.py create mode 100644 db_init/README.md create mode 100644 db_init/celldata.cellinfo.sql create mode 100644 db_init/celldata.sector.sql create mode 100644 db_init/celldata.sector_band_ref.sql diff --git a/CellData.sql b/CellData.sql index 6a4aa8b..3566c61 100644 --- a/CellData.sql +++ b/CellData.sql @@ -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.`网络` diff --git a/app/db_init.py b/app/db_init.py new file mode 100644 index 0000000..a8e9cab --- /dev/null +++ b/app/db_init.py @@ -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() diff --git a/app/main.py b/app/main.py index 4cf50b1..8d419d1 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/services/cell_data.py b/app/services/cell_data.py index cec0f1a..24eea67 100644 --- a/app/services/cell_data.py +++ b/app/services/cell_data.py @@ -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 脚本文件不存在,跳过") diff --git a/db_init/README.md b/db_init/README.md new file mode 100644 index 0000000..b38eb0c --- /dev/null +++ b/db_init/README.md @@ -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()` 会自动发现并按需执行。 diff --git a/db_init/celldata.cellinfo.sql b/db_init/celldata.cellinfo.sql new file mode 100644 index 0000000..683ecdf --- /dev/null +++ b/db_init/celldata.cellinfo.sql @@ -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; diff --git a/db_init/celldata.sector.sql b/db_init/celldata.sector.sql new file mode 100644 index 0000000..e133f87 --- /dev/null +++ b/db_init/celldata.sector.sql @@ -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; diff --git a/db_init/celldata.sector_band_ref.sql b/db_init/celldata.sector_band_ref.sql new file mode 100644 index 0000000..cfe3791 --- /dev/null +++ b/db_init/celldata.sector_band_ref.sql @@ -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频'); diff --git a/docs/project_context.md b/docs/project_context.md index 28d9d10..0ebddea 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -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:离线可用性全面审计(运行期零外网) 结论:**运行期已完全离线就绪,无需改运行时代码**;外网仅「构建期」需要。审计证据: