diff --git a/.env.example b/.env.example index 06c9266..f165010 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,33 @@ # - 完整说明见 doc/DEVELOPMENT.md §4 配置速查、doc/DEPLOY.md §2.2 # ============================================================================== +# ==================== 数据库 ==================== +# 平台用 MySQL(正式用法)。DEPLOY_ENV 声明"这份配置连的是哪个环境的库", +# 并与库名一一绑定,启动时会互相校验,不符直接拒绝启动: +# DEPLOY_ENV=dev → 期望库名 auto_control_dev(本地开发,可随意折腾) +# DEPLOY_ENV=prod → 期望库名 auto_control (正式数据) +# 首次连接的库会被打上环境标签(app_meta.deployment_env);标签与 DEPLOY_ENV +# 不符时拒绝启动——这是防止「开发配置连到生产库」的最后一道闸。 +DEPLOY_ENV=dev +DB_HOST= +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME=auto_control_dev +# 排序规则用 _bin(逐码点比较,等价 SQLite 的大小写敏感语义),别改成 _general_ci +DB_CHARSET=utf8mb4 +DB_COLLATION=utf8mb4_bin + +# 完整连接串:优先级最高,用于脚本临时指向别的库(一般不用配) +# DATABASE_URL=mysql+pymysql://user:pass@host:3306/auto_control_dev?charset=utf8mb4 + +# 逃生阀(默认关,仅在明确知道后果时打开) +# DB_ALLOW_ENV_MISMATCH=1 环境与库名/库标签不符时仍启动(危险) +# DB_ALLOW_SQLITE_FALLBACK=1 生产环境 DB_HOST 为空时允许回退 SQLite(回滚用) +# DB_ALLOW_ENV_MISMATCH=0 +# DB_ALLOW_SQLITE_FALLBACK=0 + + # ==================== Web 服务 ==================== # 会话密钥【必须,生产务必固定】:不配则每次启动随机生成,重启后登录态失效。 # 生成:python -c "import secrets; print(secrets.token_hex(32))" diff --git a/config.py b/config.py index 94ef392..135c238 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,33 @@ else: # 部署后需执行 chmod +x bin/adb/adb ADB_PATH = os.path.join(_ADB_DIR, "adb") +# ================== 数据库 ================== +# 平台支持 MySQL(正式用法)与 SQLite(仅作回退,见 core/db_config.py)。 +# +# DEPLOY_ENV 声明"这套配置连的是哪个环境的库",与库名一一绑定: +# dev → auto_control_dev(本地开发,可随意折腾) +# prod → auto_control (正式数据) +# 启动时会校验「.env 声明的环境」与「目标库登记的环境」是否一致, +# 不一致直接拒绝启动——防止把开发配置连到生产库上。 +DEPLOY_ENV = _env("DEPLOY_ENV", "dev").strip().lower() + +DB_HOST = _env("DB_HOST", "") # 为空则回退 SQLite(生产禁止静默回退) +DB_PORT = int(_env("DB_PORT", "3306")) +DB_USER = _env("DB_USER", "") +DB_PASSWORD = _env("DB_PASSWORD", "") # 写入 .env,不要提交到 git +DB_NAME = _env("DB_NAME", "") +DB_CHARSET = _env("DB_CHARSET", "utf8mb4") +# 排序规则用 _bin(逐码点比较,等价 SQLite 的大小写敏感语义)。 +# 用默认的 utf8mb4_general_ci 会让 Admin/admin、Phone1/phone1 被判重复。 +DB_COLLATION = _env("DB_COLLATION", "utf8mb4_bin") + +# 完整连接串(优先级最高,迁移/回归脚本用它临时指向别的库) +DATABASE_URL = _env("DATABASE_URL", "") + +# 逃生阀(默认关;仅在你明确知道自己在干什么时打开) +DB_ALLOW_ENV_MISMATCH = _env("DB_ALLOW_ENV_MISMATCH", "0") == "1" +DB_ALLOW_SQLITE_FALLBACK = _env("DB_ALLOW_SQLITE_FALLBACK", "0") == "1" + # ================== Web 后台 ================== WEB_HOST = "0.0.0.0" # 端口必须避开 Windows 动态端口范围(本机被改成 1024-15000,默认 49152-65535) diff --git a/core/db_config.py b/core/db_config.py new file mode 100644 index 0000000..028e967 --- /dev/null +++ b/core/db_config.py @@ -0,0 +1,269 @@ +"""数据库连接装配:URI 组装、引擎参数、环境防呆校验、启动横幅。 + +为什么单独一个模块: + web_server 要装配 app、迁移脚本/回归脚本要复用同一套 URI 与校验逻辑; + 塞进 config.py 会把它撑肥,塞进 models.py 会引入循环依赖。 + +支持两种后端: + - **MySQL**(DB_HOST 非空):正式用法,dev 库 / 正式库由 .env 的 DEPLOY_ENV 决定 + - **SQLite**(DB_HOST 为空):仅作迁移期回退;生产禁止静默回退,需 DB_ALLOW_SQLITE_FALLBACK=1 + +防呆设计(曾经把生产数据搞混过,这层是刚需): + 1. 库名与环境绑定:dev → auto_control_dev,prod → auto_control,对不上拒绝启动 + 2. 库标签:app_meta.deployment_env 记录"这个库属于哪个环境",不匹配硬停 + 3. 启动横幅:每次启动都打印目标库,prod 用 WARNING 级(tail 日志必然可见) +""" +import os +import uuid + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url + +from config import (DATABASE_URL, DB_ALLOW_ENV_MISMATCH, DB_ALLOW_SQLITE_FALLBACK, + DB_CHARSET, DB_COLLATION, DB_HOST, DB_NAME, DB_PASSWORD, + DB_PORT, DB_USER, DATA_DIR, DEPLOY_ENV) +from core.logger import get_logger + +_log = get_logger("core.db") + +# 环境 ↔ 库名绑定:防止「dev 的 .env 抄错成正式库名」这类事故 +_EXPECTED_DB_NAME = {"dev": "auto_control_dev", "prod": "auto_control"} + +_REQUIRED_MYSQL_VERSION = "5.7" + +# app_meta 里记录库身份的键 +_K_ENV, _K_ID, _K_CLAIMED = "deployment_env", "deployment_id", "deployment_claimed_at" + + +class DBConfigError(Exception): + """数据库配置错误(消息直接面向运维,要说清怎么改)。""" + + +# ================== URI 与引擎参数 ================== +def is_sqlite(uri): + return str(uri).startswith("sqlite") + + +def build_db_uri(): + """按 .env 组装连接串。DATABASE_URL 优先级最高(脚本临时指向别的库用)。""" + if DATABASE_URL: + return DATABASE_URL + if DB_HOST: + from urllib.parse import quote_plus + return "mysql+pymysql://{}:{}@{}:{}/{}?charset={}".format( + quote_plus(DB_USER), quote_plus(DB_PASSWORD), + DB_HOST, DB_PORT, DB_NAME, DB_CHARSET) + # 回退 SQLite:生产环境必须显式开逃生阀,否则拒绝启动(静默降级本身就是混库温床) + if DEPLOY_ENV == "prod" and not DB_ALLOW_SQLITE_FALLBACK: + raise DBConfigError( + "生产环境(DEPLOY_ENV=prod)未配置 DB_HOST,且未显式允许回退 SQLite。\n" + " 如确需临时回退到 sqlite,请在 .env 里加 DB_ALLOW_SQLITE_FALLBACK=1\n" + " 否则请补全 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME。") + return "sqlite:///" + os.path.join(DATA_DIR, "users.db") + + +def engine_options(uri): + """create_engine 参数。SQLite 与 MySQL 需求不同,必须分叉。""" + if is_sqlite(uri): + return {"pool_pre_ping": True} + return { + # 长驻后台线程(TaskManager/device_discovery)长时间空闲后, + # 连接可能已被 MySQL 的 wait_timeout 掐断 / 中间链路掉线 + "pool_pre_ping": True, + "pool_recycle": 1800, + "pool_size": 10, + "max_overflow": 20, + "pool_timeout": 30, + # 接近 SQLite「逐语句读最新提交」的语义,并显著降低间隙锁/死锁概率。 + # 备份导出需要一致性快照时会单独把那个连接提到 REPEATABLE READ。 + "isolation_level": "READ COMMITTED", + "connect_args": { + "charset": DB_CHARSET, + # 保留 STRICT_TRANS_TABLES(超长写入报错而不是静默截断); + # 去掉 5.7 默认带的 ONLY_FULL_GROUP_BY,避免给未来埋雷 + "init_command": "SET sql_mode='STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + }, + } + + +def create_engine_from_uri(uri=None): + """独立建一个 engine(不经过 Flask-SQLAlchemy)。脚本与启动前探活用。""" + uri = uri or build_db_uri() + return create_engine(uri, **engine_options(uri)) + + +def describe_target(uri=None): + """人类可读的目标描述(**不含密码**),用于日志与横幅。""" + uri = uri or build_db_uri() + try: + u = make_url(uri) + except Exception: + return str(uri) + if u.get_backend_name() == "sqlite": + return u.database + return "{}@{}:{}/{}".format(u.username or "", u.host or "", u.port or 3306, + u.database or "") + + +# ================== app_meta 读写(方言中立)================== +def _q(name): + """按当前方言给标识符加引号:MySQL 里 key 是保留字,必须反引号。""" + from core.models import db + return db.engine.dialect.identifier_preparer.quote(name) + + +def meta_get(key, default=None): + from core.models import db + row = db.session.execute( + text("SELECT value FROM app_meta WHERE {} = :k".format(_q("key"))), + {"k": key}).fetchone() + return row[0] if row else default + + +def meta_set(key, value): + """方言中立 upsert:SQLite 用 INSERT OR REPLACE,MySQL 用 ON DUPLICATE KEY。""" + from core.models import db + k = _q("key") + if db.engine.dialect.name == "mysql": + sql = ("INSERT INTO app_meta ({k}, value) VALUES (:k, :v) " + "ON DUPLICATE KEY UPDATE value = VALUES(value)").format(k=k) + else: + sql = "INSERT OR REPLACE INTO app_meta ({k}, value) VALUES (:k, :v)".format(k=k) + db.session.execute(text(sql), {"k": key, "v": value}) + db.session.commit() + + +# ================== 启动前校验(不需要业务表)================== +def check_connection(uri=None): + """连接探活 + 环境/库名一致性校验。失败抛 DBConfigError,由调用方决定是否退出。 + + 不需要业务表,因此可以在 init_db 之前跑,做到"配置错就早点死"。 + """ + uri = uri or build_db_uri() + + if is_sqlite(uri): + _log.warning("数据库为 SQLite(%s)—— 回退模式,非正式用法", describe_target(uri)) + return {"backend": "sqlite", "target": describe_target(uri)} + + if DEPLOY_ENV not in _EXPECTED_DB_NAME: + raise DBConfigError( + "DEPLOY_ENV 取值非法: {!r}(只允许 dev / prod)".format(DEPLOY_ENV)) + + try: + eng = create_engine_from_uri(uri) + with eng.connect() as conn: + version = conn.execute(text("SELECT VERSION()")).scalar() or "" + db_name = conn.execute(text("SELECT DATABASE()")).scalar() or "" + eng.dispose() + except DBConfigError: + raise + except Exception as e: + raise DBConfigError( + "连接数据库失败: {}\n 目标: {}\n 请检查 .env 的 DB_HOST/DB_PORT/" + "DB_USER/DB_PASSWORD 与本机到该主机的网络。/".format(e, describe_target(uri))) + + if not version.startswith(_REQUIRED_MYSQL_VERSION): + _log.warning("MySQL 版本为 %s,本项目按 %s.x 验证(更高版本通常兼容," + "但请留意差异)", version, _REQUIRED_MYSQL_VERSION) + + expected = _EXPECTED_DB_NAME[DEPLOY_ENV] + if db_name != expected and not DB_ALLOW_ENV_MISMATCH: + raise DBConfigError( + "环境与库名不匹配,已拒绝启动:\n" + " .env 声明 DEPLOY_ENV={} → 期望库名 {}\n" + " 实际连接的库 → {}\n" + " 目标: {}\n" + "如确认无误(例如临时指向别的库),可在 .env 加 DB_ALLOW_ENV_MISMATCH=1。" + .format(DEPLOY_ENV, expected, db_name, describe_target(uri))) + + return {"backend": "mysql", "version": version, "database": db_name, + "target": describe_target(uri)} + + +# ================== 库标签校验(需要 app_meta 表,init_db 之后调用)================== +def verify_deployment_label(): + """核对 app_meta 里登记的库环境,与 .env 声明的环境是否一致。 + + 首次见到某个库时会自动"认领"(写入标签)——生产库的标签由迁移脚本显式写入, + 所以正常情况下不会走到自动认领分支。 + """ + from core.models import db + if is_sqlite(db.engine.url): + return + + recorded = meta_get(_K_ENV) + if recorded is None: + meta_set(_K_ENV, DEPLOY_ENV) + meta_set(_K_ID, uuid.uuid4().hex) + meta_set(_K_CLAIMED, _now()) + _log.warning("首次见到该库,已登记为 %s 环境(部署标识 %s)", + DEPLOY_ENV, meta_get(_K_ID)) + return + + if recorded != DEPLOY_ENV and not DB_ALLOW_ENV_MISMATCH: + raise DBConfigError( + "目标库登记的环境与 .env 声明不符,已拒绝启动:\n" + " .env 声明 → {}\n" + " 库中登记({})→ {}\n" + " 目标: {}\n" + "这通常意味着你正把「{} 的配置」连到「{} 的库」上。\n" + "如确需这样操作,请在 .env 加 DB_ALLOW_ENV_MISMATCH=1。" + .format(DEPLOY_ENV, meta_get(_K_CLAIMED) or "认领时间未知", recorded, + describe_target(), DEPLOY_ENV, recorded)) + elif recorded != DEPLOY_ENV: + _log.warning("库环境标签不符已按 DB_ALLOW_ENV_MISMATCH=1 放行:声明 %s / 库登记 %s", + DEPLOY_ENV, recorded) + + +# ================== 启动横幅 ================== +def print_banner(): + """每次启动打印一次「我现在连的是哪个库」。prod 用 WARNING 级。""" + from core.models import db + eng = db.engine + uri = eng.url + lines = [] + if is_sqlite(uri): + lines.append(" 数据库 : SQLite 回退模式 <<< 非正式用法 >>>") + lines.append(" 目标 : " + describe_target()) + else: + try: + with eng.connect() as conn: + version = conn.execute(text("SELECT VERSION()")).scalar() or "" + db_name = conn.execute(text("SELECT DATABASE()")).scalar() or "" + coll = conn.execute(text( + "SELECT @@collation_database")).scalar() or "" + n_tables = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE()")).scalar() or 0 + counts = [] + for t in ("device", "task_job", "device_group", "agent_conversation"): + try: + n = db.session.execute( + text("SELECT COUNT(*) FROM {}".format(t))).scalar() + counts.append("{}={}".format(t, n)) + except Exception: + db.session.rollback() + lines.append(" MySQL : {} 库 {} ({})".format(version, db_name, coll)) + lines.append(" 目标 : " + describe_target()) + lines.append(" 表 : {} 张".format(n_tables)) + lines.append(" 数据量 : " + " ".join(counts)) + except Exception as e: + lines.append(" 连接信息采集失败: {}".format(e)) + lines.append(" 库标签 : {}({} 认领)".format( + meta_get(_K_ENV) or "未登记", meta_get(_K_CLAIMED) or "—")) + + tag = "生产库 PROD" if DEPLOY_ENV == "prod" else "开发库 DEV" + bar = "=" * 64 + head = ("{} 数据库已连接 [ {} ]{}".format( + bar, tag, " <<< 这是正式数据,操作前想清楚 >>>" if DEPLOY_ENV == "prod" else "")) + body = "\n".join(lines) + text_block = "\n{}\n{}\n{}".format(head, body, bar) + if DEPLOY_ENV == "prod": + _log.warning(text_block) + else: + _log.info(text_block) + + +def _now(): + import time + return time.strftime("%Y-%m-%d %H:%M:%S") diff --git a/core/models.py b/core/models.py index 1264698..218566a 100644 --- a/core/models.py +++ b/core/models.py @@ -17,6 +17,7 @@ import os import json import hashlib +import sqlite3 from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin @@ -36,7 +37,12 @@ def _sqlite_pragma(dbapi_connection, connection_record): """SQLite 并发写优化:WAL 模式 + 忙等待超时 + 降同步级别。 多 worker 后台线程同时写库(任务参数/分组)时,避免 database is locked。 + + 注意:这个监听器挂在 Engine 基类上,对**所有方言**的连接都会触发; + MySQL 下执行 PRAGMA 会直接报语法错误导致连接失败,所以必须先判类型。 """ + if not isinstance(dbapi_connection, sqlite3.Connection): + return cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA busy_timeout=5000") diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index 27835a8..6249762 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -63,11 +63,22 @@ pip install -r requirements.txt ```ini WEB_SECRET_KEY=<随机 64 hex> # 不配则每次重启登录态失效 +DEPLOY_ENV=prod # 声明"这是生产环境",与库名 auto_control 绑定 +DB_HOST= # 数据库目标(不配则回退 SQLite,生产会拒绝启动) +DB_USER=<账号> +DB_PASSWORD=<口令> +DB_NAME=auto_control # USB_ADB_HOST=100.100.10.1 # USB 设备所在的部署机(默认值通常可用) # TAILSCALE_API_KEY=<...> # 需要 Tailscale 管理功能时 # MCP_PLATFORM_PASS= # 改过 admin 密码必须同步,否则 MCP 登录失败 ``` +**防混库**(`DEPLOY_ENV` 与库名/库标签双向校验,配置项说明见 [DEVELOPMENT.md](DEVELOPMENT.md) §4.2 +与 `.env.example` 的「数据库」段):启动时若 +「`.env` 声明的环境」与「库名」或「库中登记的 `app_meta.deployment_env`」不符,**直接拒绝启动**并打印 +两边分别是什么。每次启动还会打印一条横幅写明当前连的是哪个库(生产用 WARNING 级)。两个逃生阀 +`DB_ALLOW_ENV_MISMATCH` / `DB_ALLOW_SQLITE_FALLBACK` 默认关闭。 + 其余配置项与默认值见 [DEVELOPMENT.md](DEVELOPMENT.md) §配置速查。 ### 2.3 启动 diff --git a/doc/DEVELOPMENT.md b/doc/DEVELOPMENT.md index 8e4aba5..8ad63d7 100644 --- a/doc/DEVELOPMENT.md +++ b/doc/DEVELOPMENT.md @@ -117,7 +117,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \ ### 4.1 配置文件与优先级 - `config.py`:**程序级常量**(端口、路径、USB 远程 adb 等),改它要重启 -- `.env`(项目根,不入 git):**密钥与可覆盖配置**,逐行解析 + `os.environ.setdefault`(**真实环境变量优先**) +- `.env`(项目根,不入 git):**密钥与可覆盖配置**,逐行解析 + `os.environ.setdefault`(**真实环境变量优先**);**数据库目标(`DEPLOY_ENV` + `DB_*`)也在这里** - `app_meta`(数据库 KV):**运行时可改的配置**(AI 配置、设备发现参数),在界面上改 ### 4.2 `config.py` 常量 @@ -128,6 +128,11 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \ | `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | 硬编码 | 18050 避开 Windows 动态端口段 | | `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 代码计算 | | | `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR` | `data/backups` / `data/restore_staging` / `data/restore_pending` | 代码计算 | 备份相关 | +| `DEPLOY_ENV` | `dev` | `.env` 可覆盖 | 声明这套配置连哪个环境的库;与库名绑定(dev→`auto_control_dev`,prod→`auto_control`),不符拒绝启动 | +| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 空 / `3306` / 空 / 空 / 空 | `.env` 可覆盖 | MySQL 目标;`DB_HOST` 为空则回退 SQLite(**生产禁止静默回退**,需 `DB_ALLOW_SQLITE_FALLBACK=1`) | +| `DB_CHARSET` / `DB_COLLATION` | `utf8mb4` / `utf8mb4_bin` | `.env` 可覆盖 | 排序规则必须用 `_bin`(逐码点比较,等价 SQLite 的大小写敏感语义) | +| `DATABASE_URL` | 空 | `.env` 可覆盖 | 完整连接串,优先级最高;脚本临时指向别的库时用 | +| `DB_ALLOW_ENV_MISMATCH` / `DB_ALLOW_SQLITE_FALLBACK` | `0` | `.env` | 逃生阀,见 [DEPLOY.md](DEPLOY.md) | | `USB_ADB_HOST` / `USB_ADB_PORT` | `100.100.10.1` / `5037` | `.env` 可覆盖 | USB 设备所在部署机的远程 adb server | | `TAILSCALE_API_KEY` / `TAILSCALE_TAILNET` | 空 | `.env` | Tailscale 管理功能 | | `DISCOVERY_PORT` / `DISCOVERY_INTERVAL` | `5555` / `60` | `.env` 可覆盖 | 设备发现(网段在工具页配置,存 `app_meta`) | diff --git a/requirements.txt b/requirements.txt index 7ac78cd..f263fb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,10 @@ Flask>=2.3,<4.0 Flask-Login>=0.6 Flask-SQLAlchemy>=3.0,<4.0 +# MySQL 驱动(纯 Python,slim 容器里无需编译依赖)。 +# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。 +PyMySQL>=1.1 + # 定时任务调度 APScheduler>=3.10,<4.0 diff --git a/scripts/start.sh b/scripts/start.sh index 957f60e..155709f 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -16,7 +16,7 @@ python3 - <<'PY' import importlib.util, sys mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler", "uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL", - "fastmcp") + "fastmcp", "pymysql") if all(importlib.util.find_spec(m) for m in mods): try: import cv2 @@ -78,4 +78,7 @@ if [ -f mcp_server/mcp_server.py ] && [ "${MCP_ENABLED:-1}" = "1" ]; then python3 -m mcp_server.mcp_server > /tmp/mcp_server.log 2>&1 & fi +# 启动前把"连的是哪个库"打在最显眼的位置(web_server 启动横幅是权威, +# 这里再报一次是防止日志只截到容器入口这几行时看不出环境) +echo "[start] DEPLOY_ENV=${DEPLOY_ENV:-<未设置,默认 dev>} DB_NAME=${DB_NAME:-<未设置>} DB_HOST=${DB_HOST:-<未设置>}" exec python -u web_server.py diff --git a/web_server.py b/web_server.py index bbe9e5b..f965ab3 100644 --- a/web_server.py +++ b/web_server.py @@ -33,8 +33,17 @@ if not os.environ.get("WEB_SECRET_KEY"): app.config["SECRET_KEY"] = _web_secret # debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启 app.config["TEMPLATES_AUTO_RELOAD"] = True -app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( - os.path.dirname(os.path.abspath(__file__)), "data", "users.db") +# 数据库目标与连接参数统一由 core/db_config 装配(.env 的 DEPLOY_ENV / DB_* 决定)。 +# 这里就把配置校验做完:连不上、环境与库名不匹配 → 直接拒绝启动,不要带着错配置跑起来。 +from core import db_config +try: + _DB_URI = db_config.build_db_uri() + db_config.check_connection(_DB_URI) +except db_config.DBConfigError as _e: + _log.error("数据库配置校验失败,拒绝启动:\n%s", _e) + raise SystemExit(2) +app.config["SQLALCHEMY_DATABASE_URI"] = _DB_URI +app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(_DB_URI) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False login_manager = LoginManager(app) @@ -51,6 +60,15 @@ except Exception as _e: # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) init_db(app) +# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」, +# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后) +try: + with app.app_context(): # db.engine / app_meta 读取都需要 app context + db_config.verify_deployment_label() + db_config.print_banner() +except db_config.DBConfigError as _e: + _log.error("数据库环境校验失败,拒绝启动:\n%s", _e) + raise SystemExit(2) # 设备池(本地清单 + adb 在线状态):任务调度的数据源 from core import device_pool device_pool.init_app(app)