Compare commits
10
Commits
933225784b
...
5f02edb5c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f02edb5c3 | ||
|
|
c6fda262db | ||
|
|
da77d8eb6b | ||
|
|
f5fc96c300 | ||
|
|
429aaf6283 | ||
|
|
2a3775ffd5 | ||
|
|
4899e66c68 | ||
|
|
b145a007eb | ||
|
|
c834349fac | ||
|
|
f22263ab45 |
@@ -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))"
|
||||
|
||||
@@ -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)
|
||||
@@ -63,9 +90,12 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
||||
# ================== 系统数据备份(导出/导入) ==================
|
||||
# 导出 zip 与恢复前快照存放;导入暂存目录;待下次启动生效的恢复目录
|
||||
# (运行时产物,不入 git,见 .gitignore data/backups 等)
|
||||
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
|
||||
RESTORE_STAGING_DIR = os.path.join(DATA_DIR, "restore_staging")
|
||||
RESTORE_PENDING_DIR = os.path.join(DATA_DIR, "restore_pending")
|
||||
#
|
||||
# 三个目录都支持用环境变量改到别处——自动化测试必须把恢复目录指到临时位置,
|
||||
# 否则测试造出来的"待生效恢复任务"会在服务下次重启时被真当成用户的操作消费掉。
|
||||
BACKUP_DIR = _env("DATA_BACKUP_DIR", "") or os.path.join(DATA_DIR, "backups")
|
||||
RESTORE_STAGING_DIR = _env("DATA_RESTORE_STAGING_DIR", "") or os.path.join(DATA_DIR, "restore_staging")
|
||||
RESTORE_PENDING_DIR = _env("DATA_RESTORE_PENDING_DIR", "") or os.path.join(DATA_DIR, "restore_pending")
|
||||
|
||||
# ================== SSH 到部署机(已废弃,STF 相关操作退役) ==================
|
||||
# 阶段 3 后代码不再使用 SSH;保留配置仅供手动运维 220(如 docker stop stf)。
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""数据库连接装配: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": str(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
|
||||
# SQLite(回退模式)也照样打标签:这样"从 dev 的 SQLite 导出的备份"在
|
||||
# 预览里就能标出来源环境,跨环境导入的防呆才有依据。
|
||||
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 env_badge():
|
||||
"""给页面顶部徽标用的环境信息(不含密码)。
|
||||
|
||||
防混库的第 4 层:光靠启动日志不够——用户点开任何页面都该一眼看出
|
||||
自己正在操作哪个库。生产用红底。
|
||||
"""
|
||||
try:
|
||||
from core.models import db
|
||||
backend = "sqlite" if is_sqlite(db.engine.url) else "mysql"
|
||||
except Exception:
|
||||
backend = "?"
|
||||
try:
|
||||
target = describe_target()
|
||||
except Exception:
|
||||
target = DB_HOST or "(未配置)"
|
||||
return {"env": DEPLOY_ENV, "is_prod": DEPLOY_ENV == "prod",
|
||||
"backend": backend, "target": target}
|
||||
|
||||
|
||||
def _now():
|
||||
import time
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -61,9 +61,10 @@ def _fmt(ts=None):
|
||||
def get_settings():
|
||||
"""读发现配置(app_meta,缺键用 config 默认值补齐)。"""
|
||||
with _ctx():
|
||||
from core.db_config import meta_get
|
||||
|
||||
def _get(key, default):
|
||||
v = db.session.execute(
|
||||
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar()
|
||||
v = meta_get(key)
|
||||
return v if v is not None else default
|
||||
try:
|
||||
subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS
|
||||
@@ -100,10 +101,8 @@ def save_settings(enabled=None, subnets=None, interval=None, port=None, auto_cla
|
||||
except (TypeError, ValueError):
|
||||
return False, "参数不合法"
|
||||
with _ctx():
|
||||
def _put(key, value):
|
||||
db.session.execute(
|
||||
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
|
||||
{"k": key, "v": str(value)})
|
||||
from core.db_config import meta_set
|
||||
_put = meta_set # 方言中立 upsert(app_meta.key 在 MySQL 里是保留字)
|
||||
if enabled is not None:
|
||||
_put(_K_ENABLED, "1" if enabled else "0")
|
||||
if subnets is not None:
|
||||
|
||||
+239
-54
@@ -17,6 +17,7 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import sqlite3
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
@@ -31,12 +32,33 @@ _log = get_logger("core.models")
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def _long_text():
|
||||
"""长文本列:SQLite 用 TEXT,MySQL 用 MEDIUMTEXT。
|
||||
|
||||
裸 TEXT 在 MySQL 只有 64KB(而且是"字节"),AI 会话消息、任务参数这类
|
||||
JSON 文本很容易超;超了在严格模式下直接报错(不是截断)。
|
||||
"""
|
||||
from sqlalchemy.dialects.mysql import MEDIUMTEXT
|
||||
return db.Text().with_variant(MEDIUMTEXT(), "mysql")
|
||||
|
||||
|
||||
def _DOUBLE():
|
||||
"""REAL/DOUBLE:MySQL 的 FLOAT 是单精度,评分这类值会出现 7.8000001 这种尾巴。"""
|
||||
from sqlalchemy.dialects.mysql import DOUBLE
|
||||
return DOUBLE()
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
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")
|
||||
@@ -261,35 +283,108 @@ class PendingDevice(db.Model):
|
||||
"fingerprint": self.fingerprint or ""}
|
||||
|
||||
|
||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||
class AppMeta(db.Model):
|
||||
"""全局 KV 配置(AI 配置、设备发现参数、schema_version、库环境标签…)。
|
||||
|
||||
⚠️ 列名沿用历史的 `key`/`value`:**`key` 在 MySQL 里是保留字**,
|
||||
因此不要直接拼裸 SQL 读写本表,统一走 `core/db_config.meta_get/meta_set`
|
||||
(方言中立、自动按方言加引号)。
|
||||
|
||||
ORM 属性名用 `k`:`key` 在 SQLAlchemy 声明式 API 里是保留名,不能直接当属性。
|
||||
"""
|
||||
__tablename__ = "app_meta"
|
||||
k = db.Column("key", db.String(64), primary_key=True)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppMeta {self.k}>"
|
||||
|
||||
|
||||
class AgentExperience(db.Model):
|
||||
"""AI 控制台经验记忆:任务成功后的操作配方,下次相似任务检索注入。
|
||||
|
||||
recipe/tool_seq 存 JSON 文本;hits 是被召回引用的次数(巡检按它判重要性)。
|
||||
"""
|
||||
__tablename__ = "agent_experience"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
task_prompt = db.Column(_long_text(), default="")
|
||||
recipe = db.Column(_long_text(), default="")
|
||||
tool_seq = db.Column(_long_text(), default="")
|
||||
hits = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.String(20), default="")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AgentExperience {self.id}>"
|
||||
|
||||
|
||||
class ExperienceAudit(db.Model):
|
||||
"""经验巡检记录(AI 质检):疑似有问题的经验标 pending,删除只走人工确认。"""
|
||||
__tablename__ = "experience_audit"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
exp_id = db.Column(db.Integer, nullable=False) # 对应的 agent_experience.id
|
||||
verdict = db.Column(db.String(20), default="") # keep / delete
|
||||
score = db.Column(db.Float().with_variant(_DOUBLE(), "mysql"), default=0)
|
||||
reason = db.Column(_long_text(), default="")
|
||||
hits = db.Column(db.Integer, default=0) # 巡检时的引用次数
|
||||
action = db.Column(db.String(20), default="pending") # pending/kept/deleted
|
||||
audited_at = db.Column(db.String(20), default="")
|
||||
|
||||
|
||||
class AgentAction(db.Model):
|
||||
"""动作库:带语义名的可复用动作单元(含元素定位、不含坐标)。
|
||||
|
||||
与任务级配方(agent_experience)互补;执行前按 name/别名/App 召回注入。
|
||||
"""
|
||||
__tablename__ = "agent_action"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
app = db.Column(db.String(80), default="")
|
||||
aliases = db.Column(_long_text(), default="[]")
|
||||
params = db.Column(_long_text(), default="[]")
|
||||
steps = db.Column(_long_text(), nullable=False)
|
||||
preconditions = db.Column(_long_text(), default="")
|
||||
hits = db.Column(db.Integer, default=0)
|
||||
source_prompt = db.Column(_long_text(), default="")
|
||||
created_at = db.Column(db.String(20), default="")
|
||||
updated_at = db.Column(db.String(20), default="")
|
||||
|
||||
|
||||
class AgentConversation(db.Model):
|
||||
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
|
||||
__tablename__ = "agent_conversation"
|
||||
id = db.Column(db.String(20), primary_key=True)
|
||||
title = db.Column(db.String(100), default="")
|
||||
messages = db.Column(_long_text(), default="[]")
|
||||
created_at = db.Column(db.String(20), default="")
|
||||
updated_at = db.Column(db.String(20), default="")
|
||||
|
||||
|
||||
# 版本化 schema 迁移账本 (版本号, 说明, 数据回填 SQL 或 None)
|
||||
#
|
||||
# 注意:**建表与补列不再由这张表驱动**——改由 db.create_all() + _sync_columns()
|
||||
# 按模型定义自动完成(SQLite / MySQL 两种方言都正确)。这里只保留:
|
||||
# - 版本号(写进 app_meta.schema_version,备份/恢复时用来判断新旧)
|
||||
# - 未来可能需要的数据回填语句(纯数据操作,与方言无关)
|
||||
# 历史条目(v1~v6 建表/加列)保留在账本里以便追溯,SQL 位置一律为 None。
|
||||
SCHEMA_MIGRATIONS = [
|
||||
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
|
||||
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
|
||||
(2, "设备池:device 表(本地设备清单,替代 STF 池)",
|
||||
"CREATE TABLE IF NOT EXISTS device ("
|
||||
"serial VARCHAR(120) PRIMARY KEY,"
|
||||
"name VARCHAR(80) DEFAULT '',"
|
||||
"enabled BOOLEAN DEFAULT 1,"
|
||||
"note TEXT DEFAULT '',"
|
||||
"created_at VARCHAR(20) DEFAULT '')"),
|
||||
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
||||
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
|
||||
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)",
|
||||
"CREATE TABLE IF NOT EXISTS pending_device ("
|
||||
"serial VARCHAR(120) PRIMARY KEY,"
|
||||
"source VARCHAR(20) DEFAULT '',"
|
||||
"first_seen VARCHAR(20) DEFAULT '',"
|
||||
"last_seen VARCHAR(20) DEFAULT '')"),
|
||||
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)",
|
||||
"ALTER TABLE device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)",
|
||||
"ALTER TABLE pending_device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", None),
|
||||
(2, "设备池:device 表(本地设备清单,替代 STF 池)", None),
|
||||
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)", None),
|
||||
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None),
|
||||
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None),
|
||||
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None),
|
||||
]
|
||||
|
||||
# 唯一索引(部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
|
||||
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
|
||||
CURRENT_SCHEMA_VERSION = max(v for v, _, _ in SCHEMA_MIGRATIONS)
|
||||
|
||||
# 唯一索引(语义 = 部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
|
||||
# 名称唯一 = 设备的人可读标识;指纹唯一 = 一台物理设备在池中只能有一条记录
|
||||
_UNIQUE_INDEXES = (
|
||||
#
|
||||
# SQLite 支持带 WHERE 的部分索引,直接写;MySQL 5.7 不支持过滤索引,
|
||||
# 改用「虚拟生成列 + 唯一索引」复刻同一语义(唯一索引允许多个 NULL)——
|
||||
# 见 _ensure_unique_indexes() 的 MySQL 分支。
|
||||
_UNIQUE_INDEXES_SQLITE = (
|
||||
("ux_device_name", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
|
||||
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
|
||||
("ux_device_fingerprint", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
|
||||
@@ -297,66 +392,118 @@ _UNIQUE_INDEXES = (
|
||||
"AND fingerprint <> ''"),
|
||||
)
|
||||
|
||||
# MySQL:为空值生成 NULL 的伴随列,唯一索引建在它上面
|
||||
_MYSQL_UQ_COLUMNS = (
|
||||
# (表, 源列, 生成列名, 生成列类型)
|
||||
("device", "name", "name_uq", "VARCHAR(80)"),
|
||||
("device", "fingerprint", "fingerprint_uq", "VARCHAR(120)"),
|
||||
)
|
||||
_MYSQL_UNIQUE_INDEXES = (
|
||||
("ux_device_name", "device", "name_uq"),
|
||||
("ux_device_fingerprint", "device", "fingerprint_uq"),
|
||||
)
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||||
|
||||
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到 SQLite。
|
||||
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到数据库。
|
||||
|
||||
顺序:建表(create_all)→ 补列(_sync_columns)→ 版本账本 → 唯一索引 → 管理员/旧 JSON。
|
||||
建表与补列都以模型定义为准,SQLite / MySQL 两种方言自动匹配,不再手写 DDL。
|
||||
"""
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
_sync_columns()
|
||||
_migrate_schema()
|
||||
_ensure_unique_indexes()
|
||||
_ensure_default_admin()
|
||||
_migrate_old_json()
|
||||
|
||||
|
||||
def _migrate_schema():
|
||||
"""按 SCHEMA_MIGRATIONS 顺序执行版本化迁移,记录当前 schema_version。
|
||||
def _sync_columns():
|
||||
"""按模型定义补齐"实表缺的列"(幂等)。
|
||||
|
||||
create_all 只负责首次建表;结构变更必须走迁移,避免改了模型后老库对不上。
|
||||
取代原来靠 "duplicate column name" 报错文本判断的老写法:那是 SQLite 时代的
|
||||
容错路径,换 MySQL 后报错文本/错误码都不同,靠字符串匹配太脆。
|
||||
现在直接读数据库元数据(sqlalchemy.inspect)比对,缺什么补什么。
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.schema import CreateColumn
|
||||
|
||||
try:
|
||||
db.session.execute(text(
|
||||
"CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT)"))
|
||||
insp = inspect(db.engine)
|
||||
existing = set(insp.get_table_names())
|
||||
except Exception as e:
|
||||
_log.error(f"读取表结构失败,跳过补列: {e}")
|
||||
return
|
||||
|
||||
added = []
|
||||
for table in db.metadata.sorted_tables:
|
||||
if table.name not in existing:
|
||||
continue # 缺整表交给 create_all
|
||||
try:
|
||||
have = {c["name"] for c in insp.get_columns(table.name)}
|
||||
except Exception as e:
|
||||
_log.warning(f"读取 {table.name} 列失败: {e}")
|
||||
continue
|
||||
for col in table.columns:
|
||||
if col.name in have:
|
||||
continue
|
||||
try:
|
||||
ddl = CreateColumn(col).compile(dialect=db.engine.dialect)
|
||||
db.session.execute(
|
||||
text("ALTER TABLE {} ADD COLUMN {}".format(table.name, ddl)))
|
||||
db.session.commit()
|
||||
cur = db.session.execute(
|
||||
text("SELECT value FROM app_meta WHERE key='schema_version'")).scalar()
|
||||
current = int(cur) if cur else 0
|
||||
for version, desc, sql in SCHEMA_MIGRATIONS:
|
||||
added.append(f"{table.name}.{col.name}")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"补列失败 {table.name}.{col.name}: {e}")
|
||||
if added:
|
||||
_log.info("schema 补列: " + ", ".join(added))
|
||||
|
||||
|
||||
def _migrate_schema():
|
||||
"""维护 schema_version 账本 + 执行数据回填类迁移。
|
||||
|
||||
建表/加列已由 create_all + _sync_columns 按模型自动完成,本函数不再写 DDL;
|
||||
SCHEMA_MIGRATIONS 里带 SQL 的条目只允许是方言无关的数据操作。
|
||||
"""
|
||||
from core import db_config
|
||||
try:
|
||||
try:
|
||||
current = int(db_config.meta_get("schema_version") or 0)
|
||||
except Exception:
|
||||
current = 0
|
||||
for version, desc, backfill_sql in SCHEMA_MIGRATIONS:
|
||||
if version <= current:
|
||||
continue
|
||||
if sql:
|
||||
if backfill_sql:
|
||||
try:
|
||||
db.session.execute(text(sql))
|
||||
except Exception as e:
|
||||
# 幂等兜底:目标结构已存在也视为该迁移生效。典型场景——
|
||||
# create_all 已按当前模型把列/表直接建好(如 perms、device.model),
|
||||
# 而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
|
||||
# duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后
|
||||
# 仍记录版本号,一次启动即自愈;其它异常才中止本批迁移。
|
||||
if "duplicate column name" not in str(e).lower():
|
||||
raise
|
||||
db.session.rollback()
|
||||
_log.info(f"schema 迁移 {version} 目标已存在,跳过: {desc}")
|
||||
db.session.execute(
|
||||
text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"),
|
||||
{"v": str(version)})
|
||||
db.session.execute(text(backfill_sql))
|
||||
db.session.commit()
|
||||
_log.info(f"schema 迁移到版本 {version}: {desc}")
|
||||
_ensure_unique_indexes()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.error(f"迁移 v{version} 数据回填失败: {e}")
|
||||
_log.info(f"schema 迁移到版本 {version}(结构由模型自动同步): {desc}")
|
||||
if current < CURRENT_SCHEMA_VERSION:
|
||||
db_config.meta_set("schema_version", str(CURRENT_SCHEMA_VERSION))
|
||||
except Exception as e:
|
||||
_log.error(f"schema 迁移失败(不阻塞启动): {e}")
|
||||
|
||||
|
||||
def _ensure_unique_indexes():
|
||||
"""建设备池的唯一索引(幂等)。
|
||||
"""建"空值不参与唯一约束"的设备名/指纹唯一索引(幂等,方言分叉)。
|
||||
|
||||
单独抽出来是因为索引不属于某个版本迁移:老库升级后也要补建。
|
||||
历史数据若存在重复(名称/指纹撞车),建索引会失败——只告警不回滚、
|
||||
不阻塞启动,由管理页提示用户改名(唯一约束从此刻起对新数据生效)。
|
||||
"""
|
||||
for name, sql in _UNIQUE_INDEXES:
|
||||
if db.engine.dialect.name == "mysql":
|
||||
_ensure_unique_indexes_mysql()
|
||||
return
|
||||
for name, sql in _UNIQUE_INDEXES_SQLITE:
|
||||
try:
|
||||
db.session.execute(text(sql))
|
||||
db.session.commit()
|
||||
@@ -365,6 +512,44 @@ def _ensure_unique_indexes():
|
||||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||||
|
||||
|
||||
def _ensure_unique_indexes_mysql():
|
||||
"""MySQL 5.7 没有过滤索引,用「虚拟生成列 + 唯一索引」复刻部分索引语义。
|
||||
|
||||
生成列把空值映射成 NULL,而唯一索引允许多个 NULL —— 正好等于
|
||||
「空值不参与唯一约束」。生成列不进 ORM 模型(进了 create_all 会尝试写入它
|
||||
并报 Error 3105),所以只能在这里用 DDL 补。
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
coll = "utf8mb4_bin" # 与库默认一致:逐码点比较,等价 SQLite 的大小写敏感
|
||||
for table, col, gen, typ in _MYSQL_UQ_COLUMNS:
|
||||
try:
|
||||
have = {c["name"] for c in inspect(db.engine).get_columns(table)}
|
||||
if gen in have:
|
||||
continue
|
||||
db.session.execute(text(
|
||||
"ALTER TABLE {t} ADD COLUMN {g} {ty} COLLATE {coll} "
|
||||
"GENERATED ALWAYS AS (IF({c} IS NULL OR {c} = '', NULL, {c})) VIRTUAL"
|
||||
.format(t=table, g=gen, ty=typ, coll=coll, c=col)))
|
||||
db.session.commit()
|
||||
_log.info(f"已建生成列 {table}.{gen}(部分唯一索引的 MySQL 替代)")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"生成列 {table}.{gen} 创建失败: {e}")
|
||||
|
||||
for name, table, gen in _MYSQL_UNIQUE_INDEXES:
|
||||
try:
|
||||
have = {i["name"] for i in inspect(db.engine).get_indexes(table)}
|
||||
if name in have:
|
||||
continue
|
||||
db.session.execute(
|
||||
text("CREATE UNIQUE INDEX {} ON {}({})".format(name, table, gen)))
|
||||
db.session.commit()
|
||||
_log.info(f"已建唯一索引 {name}")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||||
|
||||
|
||||
def _ensure_default_admin():
|
||||
"""首次启动创建默认管理员 admin/admin123。"""
|
||||
if not User.query.filter_by(username="admin").first():
|
||||
|
||||
+278
-102
@@ -1,18 +1,25 @@
|
||||
"""系统数据备份导出/导入(数据库 + APK 文件)。
|
||||
|
||||
导出:用 sqlite3 在线备份 API 对 data/users.db 做一致性快照 → zip
|
||||
(users.db + manifest.json + 可选 apks/*.apk)。
|
||||
导入:上传(zip/db) → 暂存校验预览 → 应用(先把当前库快照到
|
||||
BACKUP_DIR/pre_restore_*.db,再把暂存库移到 RESTORE_PENDING_DIR)→
|
||||
下次启动 consume_pending_restore() 换位生效。
|
||||
**归档格式:zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)。**
|
||||
SQLite 在这里的角色是**交换格式**,不是运行时数据库——这在迁到 MySQL 之后依然成立,
|
||||
带来三个好处:
|
||||
1. 前端三个接口的请求/响应结构一行都不用改
|
||||
2. 校验逻辑(完整性 / 必需表 / schema 版本 / 未登记表反向自检)可以整体沿用
|
||||
3. 2026-09-13 之前导出的老备份继续可导入;也不依赖任何外部二进制
|
||||
(`sqlite3` 是 Python 标准库,python:slim 容器里现成)
|
||||
|
||||
为什么必须重启生效:web_server 单进程内 SQLAlchemy engine + 多个常驻后台线程
|
||||
(TaskManager 内存态 / 设备发现 / Agent 等)持有 users.db(WAL);Windows 下
|
||||
不能直接替换正被打开的文件,且 TaskManager 启动时才把 groups/jobs 读入内存。
|
||||
故导入只落一个「待生效恢复任务」,由 web_server.py 在 init_db 之前消费。
|
||||
导出:把**当前库**(MySQL 或 SQLite)整库读出来,写进一个临时 SQLite 文件 → zip。
|
||||
MySQL 下把这条连接提到 REPEATABLE READ,保证所有表读的是同一时刻。
|
||||
导入:上传(zip/db) → 暂存校验预览 → 应用(先自动导出一份当前库到
|
||||
BACKUP_DIR/pre_restore_*.zip 作安全网,再把暂存归档移到 RESTORE_PENDING_DIR)
|
||||
→ 下次启动 consume_pending_restore() 在**单个事务内**整库替换生效。
|
||||
|
||||
安全红线:全程只读现有库 / 新增或移动自己目录下的文件;绝不 kill-server、
|
||||
绝不 disconnect(与全项目一致)。
|
||||
为什么仍然要重启生效:TaskManager / device_pool 把任务与设备**缓存在内存**里,
|
||||
整库替换后内存副本全部失效。(SQLite 时代还有"Windows 无法替换被持有的文件"这层,
|
||||
改用 MySQL 之后只剩内存态这一层。)
|
||||
|
||||
安全红线:全程只读现有库 / 只写自己目录下的文件;替换数据用单事务,失败回滚,
|
||||
绝不留下半新半旧的库;绝不 kill-server、绝不 disconnect(与全项目一致)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -23,29 +30,24 @@ import uuid
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import create_engine, inspect, select, text
|
||||
|
||||
from config import (DATA_DIR, APK_DIR, BACKUP_DIR,
|
||||
RESTORE_STAGING_DIR, RESTORE_PENDING_DIR)
|
||||
from core.logger import get_logger
|
||||
from core.models import SCHEMA_MIGRATIONS
|
||||
from core.models import CURRENT_SCHEMA_VERSION, db
|
||||
|
||||
_log = get_logger("core.backup")
|
||||
|
||||
# 当前代码期望的 schema 版本(来自版本化迁移表)
|
||||
CURRENT_SCHEMA_VERSION = max(v[0] for v in SCHEMA_MIGRATIONS) if SCHEMA_MIGRATIONS else 0
|
||||
|
||||
DB_FILE = os.path.join(DATA_DIR, "users.db")
|
||||
|
||||
# 判定「本平台备份库」的必需表(缺任何一张即拒绝导入)
|
||||
REQUIRED_TABLES = ("app_meta", "user", "task_job", "device_group")
|
||||
# 预览/清单里展示行数的业务表(存在才统计)
|
||||
# ⚠️ 红线:**新增任何持久化表,必须同步登记到这里**——备份覆盖清单以此为准,
|
||||
# 漏登记的表在导出清单/预览里不可见(2026-09-10 动作库 agent_action 即因此
|
||||
# 被误判为"没有备份":数据其实在 users.db 快照里,只是清单没列)。
|
||||
SUMMARY_TABLES = (
|
||||
"app_meta", "user", "device_group", "task_job", "custom_action",
|
||||
"apk_file", "device", "pending_device",
|
||||
"agent_conversation", "agent_experience", "experience_audit", "agent_action",
|
||||
)
|
||||
# 备份覆盖清单:**由模型元数据派生**,不再手工维护。
|
||||
# 这样「新增持久化表必须登记进备份清单」这条红线从"靠人记"变成结构上不可能漏
|
||||
# (2026-09-10 动作库 agent_action 就漏过:数据其实在快照里,只是清单没列,
|
||||
# 导出预览里看不到那一行 → 被误判成"没备份")。
|
||||
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
TABLE_LABELS = {
|
||||
"app_meta": "系统配置(app_meta)",
|
||||
"user": "用户", "device_group": "设备分组", "task_job": "任务计划",
|
||||
@@ -66,9 +68,19 @@ def _ts():
|
||||
return time.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def _sqlite_uri(path):
|
||||
"""把路径转成 sqlite URI(兼容含空格/中文/反斜杠的 Windows 路径)。"""
|
||||
return "file:{}".format(quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
|
||||
|
||||
|
||||
def _readonly_uri(path):
|
||||
"""把文件路径转成 sqlite 只读 URI(兼容含空格/中文/反斜杠的 Windows 路径)。"""
|
||||
return "file:{}?mode=ro".format(quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
|
||||
"""sqlite 只读 URI。"""
|
||||
return _sqlite_uri(path) + "?mode=ro"
|
||||
|
||||
|
||||
def _sqlite_engine_url(path):
|
||||
"""SQLAlchemy 用的 SQLite URL(注意与 file: URI 不同,engine 不吃那个格式)。"""
|
||||
return "sqlite:///" + os.path.abspath(path).replace("\\", "/")
|
||||
|
||||
|
||||
def _connect_readonly(path):
|
||||
@@ -114,34 +126,6 @@ def _table_rows(con, table):
|
||||
return None
|
||||
|
||||
|
||||
# ================== 在线快照 ==================
|
||||
def snapshot_db(dest_path, src_path=None):
|
||||
"""对 src(默认当前 users.db)做 sqlite 在线一致快照到 dest_path。
|
||||
|
||||
用标准库备份 API(不依赖 SQLAlchemy engine,WAL 模式下读一致快照安全)。
|
||||
备份完成后把目标强制落回单文件(journal_mode=DELETE + checkpoint),
|
||||
避免副本以 WAL 模式残留 -wal/-shm 或主文件缺刚提交帧。
|
||||
"""
|
||||
src_path = src_path or DB_FILE
|
||||
if not os.path.exists(src_path):
|
||||
raise BackupError(f"数据库不存在: {src_path}")
|
||||
con = _connect_readonly(src_path)
|
||||
out = sqlite3.connect(dest_path)
|
||||
try:
|
||||
con.backup(out)
|
||||
try:
|
||||
out.execute("PRAGMA journal_mode=DELETE") # checkpoint 并转回 DELETE
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
finally:
|
||||
out.close()
|
||||
con.close()
|
||||
# 防御:确保没有任何残留附属文件
|
||||
remove_quiet(dest_path + "-wal")
|
||||
remove_quiet(dest_path + "-shm")
|
||||
|
||||
|
||||
# ================== 导出 ==================
|
||||
def _summary_info(con):
|
||||
"""表行数摘要(只列出存在的表)。"""
|
||||
rows = []
|
||||
@@ -168,13 +152,123 @@ def _prune_old_exports(max_age=3600):
|
||||
pass
|
||||
|
||||
|
||||
def _prune_stale_staging():
|
||||
"""清理超时未应用的暂存目录(TTL 后自动删除)。"""
|
||||
now = time.time()
|
||||
if not os.path.isdir(RESTORE_STAGING_DIR):
|
||||
return
|
||||
for name in os.listdir(RESTORE_STAGING_DIR):
|
||||
p = os.path.join(RESTORE_STAGING_DIR, name)
|
||||
try:
|
||||
if os.path.isdir(p) and now - os.path.getmtime(p) > _STAGE_TTL:
|
||||
remove_quiet(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _current_env_label():
|
||||
"""当前库登记的环境标签(dev/prod)与库唯一标识(未登记则空)。"""
|
||||
from core import db_config
|
||||
try:
|
||||
return db_config.meta_get("deployment_env") or "", db_config.meta_get("deployment_id") or ""
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
# ================== 导出:当前库 → SQLite 归档 ==================
|
||||
def dump_to_sqlite(dest_path):
|
||||
"""把当前数据库整库导出成一个 SQLite 文件(备份归档格式)。
|
||||
|
||||
一致性:MySQL 下把这条连接提到 REPEATABLE READ,让所有表读同一个时间点
|
||||
(应用全局是 READ COMMITTED,事务提交后自增/被改的行会"漂移")。
|
||||
SQLite 下不需要——单文件库天然一致。
|
||||
"""
|
||||
_ensure_dir(os.path.dirname(os.path.abspath(dest_path)))
|
||||
remove_quiet(dest_path)
|
||||
remove_quiet(dest_path + "-wal")
|
||||
remove_quiet(dest_path + "-shm")
|
||||
|
||||
out_eng = create_engine(_sqlite_engine_url(dest_path))
|
||||
is_mysql = db.engine.dialect.name == "mysql"
|
||||
conn = db.engine.connect()
|
||||
if is_mysql:
|
||||
conn = conn.execution_options(isolation_level="REPEATABLE READ")
|
||||
|
||||
dumped = []
|
||||
try:
|
||||
# 归档库用同一套模型建表(mysql_* 表选项在 SQLite 下被忽略)
|
||||
db.metadata.create_all(out_eng)
|
||||
src_cols = {}
|
||||
insp = inspect(conn)
|
||||
for table in db.metadata.sorted_tables:
|
||||
try:
|
||||
src_cols[table.name] = {c["name"] for c in insp.get_columns(table.name)}
|
||||
except Exception:
|
||||
src_cols[table.name] = set()
|
||||
|
||||
with out_eng.begin() as out:
|
||||
for table in db.metadata.sorted_tables:
|
||||
have = src_cols.get(table.name) or set()
|
||||
cols = [c for c in table.columns if c.name in have]
|
||||
if not cols:
|
||||
_log.warning(f"导出跳过 {table.name}:源库没有这张表")
|
||||
continue
|
||||
try:
|
||||
rows = [dict(r._mapping)
|
||||
for r in conn.execute(select(*cols))]
|
||||
except Exception as e:
|
||||
_log.error(f"导出读取 {table.name} 失败: {e}")
|
||||
raise BackupError(f"导出失败:读取 {table.name} 出错({e})")
|
||||
if rows:
|
||||
out.execute(table.insert(), rows)
|
||||
dumped.append((table.name, len(rows)))
|
||||
finally:
|
||||
conn.close()
|
||||
out_eng.dispose()
|
||||
|
||||
# 归档库收尾:落回单文件 + 补"空值不参与唯一"的唯一索引。
|
||||
# SQLAlchemy 建的 sqlite 连接会被 PRAGMA 监听器设成 WAL,留下 -wal/-shm;
|
||||
# zip 里只打包主文件,所以必须先 checkpoint 回 DELETE 模式再删附属文件。
|
||||
try:
|
||||
con = sqlite3.connect(dest_path)
|
||||
try:
|
||||
con.execute("PRAGMA journal_mode=DELETE")
|
||||
except sqlite3.Error as e:
|
||||
_log.warning(f"归档库切回单文件模式失败: {e}")
|
||||
for name, sql in (("ux_device_name",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
|
||||
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
|
||||
("ux_device_fingerprint",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
|
||||
"ON device(fingerprint) WHERE fingerprint IS NOT NULL "
|
||||
"AND fingerprint <> ''")):
|
||||
try:
|
||||
con.execute(sql)
|
||||
except Exception as e:
|
||||
_log.warning(f"归档库建索引 {name} 失败(数据可能有重复): {e}")
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception as e:
|
||||
_log.warning(f"归档库收尾失败(不影响导出): {e}")
|
||||
remove_quiet(dest_path + "-wal")
|
||||
remove_quiet(dest_path + "-shm")
|
||||
|
||||
_log.info("已导出到 SQLite 归档: %s(%d 张表)",
|
||||
os.path.basename(dest_path), len(dumped))
|
||||
return dumped
|
||||
|
||||
|
||||
# 兼容旧名(历史代码/脚本里叫 snapshot_db)
|
||||
snapshot_db = dump_to_sqlite
|
||||
|
||||
|
||||
def create_export(include_apk=True):
|
||||
"""生成导出 zip,返回 (zip_path, filename, manifest)。"""
|
||||
_ensure_dir(BACKUP_DIR)
|
||||
_prune_old_exports()
|
||||
base = os.path.join(BACKUP_DIR, f"export_{_ts()}")
|
||||
snap = base + ".snapshot.db"
|
||||
snapshot_db(snap)
|
||||
dump_to_sqlite(snap)
|
||||
|
||||
apk_meta = []
|
||||
if include_apk and os.path.isdir(APK_DIR):
|
||||
@@ -186,14 +280,18 @@ def create_export(include_apk=True):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
env_label, db_id = _current_env_label()
|
||||
zip_path = base + ".zip"
|
||||
con = _connect_readonly(snap)
|
||||
try:
|
||||
manifest = {
|
||||
"format": "auto_control_backup",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"schema_version": _read_schema_version(con),
|
||||
"db_backend": db.engine.dialect.name, # mysql / sqlite
|
||||
"deployment_env": env_label, # 来源环境(跨环境导入时告警)
|
||||
"source_db_id": db_id, # 来源库唯一标识
|
||||
"include_apk": include_apk,
|
||||
"tables": _summary_info(con),
|
||||
"apks": apk_meta,
|
||||
@@ -244,7 +342,7 @@ def validate_backup(db_file):
|
||||
if schema_version < CURRENT_SCHEMA_VERSION:
|
||||
warnings.append(
|
||||
f"备份 schema 较旧(v{schema_version} < 当前 v{CURRENT_SCHEMA_VERSION}),"
|
||||
f"应用后首次启动会自动迁移补齐结构与新表")
|
||||
f"缺失的表/列按当前结构补空,不会报错但那些数据回不来")
|
||||
elif schema_version > CURRENT_SCHEMA_VERSION:
|
||||
warnings.append(
|
||||
f"备份 schema 较新(v{schema_version} > 当前 v{CURRENT_SCHEMA_VERSION}),"
|
||||
@@ -252,14 +350,15 @@ def validate_backup(db_file):
|
||||
if missing_optional:
|
||||
warnings.append("备份缺少部分可选表(" + "、".join(
|
||||
TABLE_LABELS.get(t, t) for t in missing_optional)
|
||||
+ "),应用后启动会自动补建空表")
|
||||
+ "),导入后这些表为空")
|
||||
# 反向自检:备份里有"未登记"的表 → 提醒把它纳入覆盖清单(红线)
|
||||
extra_tables = sorted(t for t in tables
|
||||
if t not in SUMMARY_TABLES and not t.startswith("sqlite_"))
|
||||
if extra_tables:
|
||||
warnings.append("备份含未登记的其它表(" + "、".join(extra_tables)
|
||||
+ ")——如属业务数据,请登记进 core/system_backup.py 的覆盖清单")
|
||||
warnings.append("备份为全量快照:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
|
||||
+ ")——如属业务数据,请登记进 core/models.py 的模型与 "
|
||||
"core/system_backup.py 的 TABLE_LABELS")
|
||||
warnings.append("备份为全量数据:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
|
||||
return True, {
|
||||
"integrity": integrity,
|
||||
"schema_version": schema_version,
|
||||
@@ -277,7 +376,7 @@ def validate_backup(db_file):
|
||||
|
||||
# ================== 导入:暂存 + 预览 ==================
|
||||
def _extract_zip(zip_path, staging_dir):
|
||||
"""解压备份 zip:取顶层 users.db 与可选 apks/*.apk(防路径穿越,只用 basename)。"""
|
||||
"""解压备份 zip:取顶层 users.db、可选 apks/*.apk 与 manifest.json(防路径穿越)。"""
|
||||
db_dest = os.path.join(staging_dir, "users.db")
|
||||
apk_dest = os.path.join(staging_dir, "apks")
|
||||
got_db = False
|
||||
@@ -290,6 +389,10 @@ def _extract_zip(zip_path, staging_dir):
|
||||
if name == "users.db" and base == "users.db":
|
||||
z.extract(info, staging_dir) # 已确认顶层名字,无穿越
|
||||
got_db = True
|
||||
elif name == "manifest.json":
|
||||
with z.open(info) as src, open(
|
||||
os.path.join(staging_dir, "manifest.json"), "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
elif name.startswith("apks/") and base.lower().endswith(".apk"):
|
||||
_ensure_dir(apk_dest)
|
||||
with z.open(info) as src, open(
|
||||
@@ -300,18 +403,15 @@ def _extract_zip(zip_path, staging_dir):
|
||||
return db_dest
|
||||
|
||||
|
||||
def _prune_stale_staging():
|
||||
"""清理超时未应用的暂存目录(TTL 后自动删除)。"""
|
||||
now = time.time()
|
||||
if not os.path.isdir(RESTORE_STAGING_DIR):
|
||||
return
|
||||
for name in os.listdir(RESTORE_STAGING_DIR):
|
||||
p = os.path.join(RESTORE_STAGING_DIR, name)
|
||||
def _read_staged_manifest(staging_dir):
|
||||
p = os.path.join(staging_dir, "manifest.json")
|
||||
if not os.path.exists(p):
|
||||
return {}
|
||||
try:
|
||||
if os.path.isdir(p) and now - os.path.getmtime(p) > _STAGE_TTL:
|
||||
remove_quiet(p)
|
||||
except OSError:
|
||||
pass
|
||||
with open(p, encoding="utf-8") as f:
|
||||
return json.load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def stage_upload(file_storage):
|
||||
@@ -333,12 +433,25 @@ def stage_upload(file_storage):
|
||||
db_file = os.path.join(staging_dir, "users.db")
|
||||
os.replace(raw_path, db_file)
|
||||
else:
|
||||
raise BackupError("仅支持 .zip(平台导出)或 .db(sqlite 库)文件")
|
||||
raise BackupError("仅支持 .zip(平台导出)或 .db(备份库文件)")
|
||||
ok, info = validate_backup(db_file)
|
||||
if not ok:
|
||||
raise BackupError(info)
|
||||
info["file_name"] = orig_name
|
||||
info["file_size"] = os.path.getsize(db_file)
|
||||
|
||||
# 跨环境提示(防混库第 5 层):备份来自哪个环境、当前连的是哪个环境
|
||||
man = _read_staged_manifest(staging_dir)
|
||||
info["source_env"] = man.get("deployment_env") or ""
|
||||
info["source_db_id"] = man.get("source_db_id") or ""
|
||||
info["source_backend"] = man.get("db_backend") or "sqlite(旧版备份)"
|
||||
cur_env, _ = _current_env_label()
|
||||
info["current_env"] = cur_env
|
||||
if info["source_env"] and cur_env and info["source_env"] != cur_env:
|
||||
info["warnings"].append(
|
||||
f"该备份来自 {info['source_env']} 环境,而当前连接的是 {cur_env} 库 —— "
|
||||
f"导入会覆盖当前全部数据。确认这是你要的操作再继续(跨环境导入默认被拒绝)。")
|
||||
# 暂存目录里留住 manifest,apply 时还要用它做环境校验
|
||||
return token, info
|
||||
except BackupError:
|
||||
remove_quiet(staging_dir)
|
||||
@@ -349,8 +462,8 @@ def stage_upload(file_storage):
|
||||
|
||||
|
||||
# ================== 导入:应用(落待生效任务) ==================
|
||||
def apply_restore(token):
|
||||
"""校验 token → 快照当前库 → 把暂存库移到 restore_pending。
|
||||
def apply_restore(token, force_env_mismatch=False):
|
||||
"""校验 token → 自动备份当前库(zip 安全网)→ 把暂存归档移到 restore_pending。
|
||||
|
||||
返回 dict {ok, backup_name, message};失败抛 BackupError。token 形如 12 位 hex。
|
||||
"""
|
||||
@@ -364,25 +477,41 @@ def apply_restore(token):
|
||||
if not ok:
|
||||
raise BackupError(info)
|
||||
|
||||
# 1) 自动备份当前库(安全网,可回滚)
|
||||
# 跨环境硬停:默认不许把 prod 的备份灌进 dev 库(反之亦然)
|
||||
man = _read_staged_manifest(staging_dir)
|
||||
src_env = man.get("deployment_env") or ""
|
||||
cur_env, _ = _current_env_label()
|
||||
if src_env and cur_env and src_env != cur_env and not force_env_mismatch:
|
||||
raise BackupError(
|
||||
f"备份来自 {src_env} 环境,当前库是 {cur_env} 环境 —— 已拒绝导入。\n"
|
||||
f"如确需跨环境恢复,请在确认页勾选「允许跨环境导入」后重试。")
|
||||
|
||||
# 1) 自动备份当前库(安全网,可回滚;导出成 zip,能直接再导入回来)
|
||||
_ensure_dir(BACKUP_DIR)
|
||||
backup_name = f"pre_restore_{_ts()}.db"
|
||||
snapshot_db(os.path.join(BACKUP_DIR, backup_name))
|
||||
try:
|
||||
zip_path, _, _ = create_export(include_apk=True)
|
||||
backup_name = f"pre_restore_{_ts()}.zip"
|
||||
shutil.move(zip_path, os.path.join(BACKUP_DIR, backup_name))
|
||||
except Exception as e:
|
||||
raise BackupError(f"导入前自动备份当前库失败,已中止导入: {e}")
|
||||
|
||||
# 2) 落待生效恢复任务(旧未消费任务被覆盖——上一份已无意义)
|
||||
pending_dir = RESTORE_PENDING_DIR
|
||||
if os.path.isdir(pending_dir):
|
||||
remove_quiet(pending_dir)
|
||||
_ensure_dir(pending_dir)
|
||||
os.replace(db_file, os.path.join(pending_dir, "users.db"))
|
||||
if os.path.isdir(RESTORE_PENDING_DIR):
|
||||
remove_quiet(RESTORE_PENDING_DIR)
|
||||
_ensure_dir(RESTORE_PENDING_DIR)
|
||||
os.replace(db_file, os.path.join(RESTORE_PENDING_DIR, "users.db"))
|
||||
for extra in ("manifest.json",):
|
||||
src = os.path.join(staging_dir, extra)
|
||||
if os.path.exists(src):
|
||||
shutil.move(src, os.path.join(RESTORE_PENDING_DIR, extra))
|
||||
apk_src = os.path.join(staging_dir, "apks")
|
||||
if os.path.isdir(apk_src) and os.listdir(apk_src):
|
||||
shutil.move(apk_src, os.path.join(pending_dir, "apks"))
|
||||
shutil.move(apk_src, os.path.join(RESTORE_PENDING_DIR, "apks"))
|
||||
remove_quiet(staging_dir)
|
||||
|
||||
schema_version = info.get("schema_version", 0)
|
||||
_log.info(f"导入已落恢复任务: schema v{schema_version},"
|
||||
f"重启 web_server 后生效;当前库已快照 {backup_name}")
|
||||
f"重启 web_server 后生效;当前库已自动备份 {backup_name}")
|
||||
return {
|
||||
"ok": True,
|
||||
"backup_name": backup_name,
|
||||
@@ -390,32 +519,43 @@ def apply_restore(token):
|
||||
}
|
||||
|
||||
|
||||
# ================== 启动消费(web_server.py init_db 前调用) ==================
|
||||
def consume_pending_restore():
|
||||
"""把 restore_pending/users.db 换位为当前 users.db(删除旧 wal/shm,合并 apks)。
|
||||
# ================== 启动消费(init_db 之后、TaskManager 之前调用)==================
|
||||
def _pending_db_path():
|
||||
"""待生效恢复的归档路径(兼容旧版扁平布局)。"""
|
||||
p = os.path.join(RESTORE_PENDING_DIR, "users.db")
|
||||
return p if os.path.exists(p) else None
|
||||
|
||||
必须在 SQLAlchemy engine 首次打开 users.db 之前执行(web_server 装配时调用)。
|
||||
失败不阻塞启动:坏恢复文件会挪到 BACKUP_DIR/restore_failed_*/ 并继续用旧库。
|
||||
|
||||
def consume_pending_restore():
|
||||
"""把 restore_pending/users.db 的内容在**单个事务内**整库替换进来。
|
||||
|
||||
与 SQLite 时代的"换文件"不同:现在是 DELETE 全表 + 分块 INSERT,全程一个事务——
|
||||
任何一步失败就 rollback,当前数据保持原样(比换文件更安全)。
|
||||
|
||||
必须在 init_db() 之后调用(表已建好、app context 可用);失败不阻塞启动:
|
||||
坏归档挪到 BACKUP_DIR/restore_failed_*/ 并继续用当前数据。
|
||||
返回是否真的应用了恢复。
|
||||
"""
|
||||
pending_db = os.path.join(RESTORE_PENDING_DIR, "users.db")
|
||||
if not os.path.exists(pending_db):
|
||||
pending_db = _pending_db_path()
|
||||
if not pending_db:
|
||||
return False
|
||||
# 消费前轻量复验(防止意外损坏文件把主库换掉)
|
||||
# 消费前轻量复验(防止意外损坏的文件把主库换掉)
|
||||
ok, msg = validate_backup(pending_db)
|
||||
if not ok:
|
||||
fail_dir = os.path.join(BACKUP_DIR, f"restore_failed_{_ts()}")
|
||||
_ensure_dir(fail_dir)
|
||||
os.replace(pending_db, os.path.join(fail_dir, "users.db"))
|
||||
shutil.move(pending_db, os.path.join(fail_dir, "users.db"))
|
||||
remove_quiet(RESTORE_PENDING_DIR)
|
||||
_log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}")
|
||||
return False
|
||||
_ensure_dir(DATA_DIR)
|
||||
os.replace(pending_db, DB_FILE)
|
||||
for ext in ("-wal", "-shm"):
|
||||
p = DB_FILE + ext
|
||||
if os.path.exists(p):
|
||||
remove_quiet(p)
|
||||
|
||||
try:
|
||||
applied = _apply_archive_to_db(pending_db)
|
||||
except Exception as e:
|
||||
_log.error(f"应用备份恢复失败,已回滚(当前数据未改动): {e},"
|
||||
f"归档保留在 {RESTORE_PENDING_DIR} 供排查")
|
||||
return False
|
||||
|
||||
# 合并 apks(覆盖同名)
|
||||
apk_src = os.path.join(RESTORE_PENDING_DIR, "apks")
|
||||
if os.path.isdir(apk_src):
|
||||
@@ -427,6 +567,42 @@ def consume_pending_restore():
|
||||
os.remove(dst)
|
||||
shutil.move(src, dst)
|
||||
remove_quiet(RESTORE_PENDING_DIR)
|
||||
schema_version = msg.get("schema_version", 0) if isinstance(msg, dict) else "?"
|
||||
_log.info(f"已应用备份恢复: users.db 替换完成(schema v{schema_version}),apks 已合并")
|
||||
schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?"
|
||||
_log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并")
|
||||
return True
|
||||
|
||||
|
||||
def _apply_archive_to_db(db_file, chunk=500):
|
||||
"""把 SQLite 归档里的数据整库写进当前数据库,单事务、失败回滚。
|
||||
|
||||
用 DELETE 而不是 TRUNCATE:TRUNCATE 是 DDL,会隐式提交,破坏原子性。
|
||||
"""
|
||||
con = _connect_readonly(db_file)
|
||||
arch_tables = _list_tables(con)
|
||||
stats = []
|
||||
try:
|
||||
# 单事务:MySQL/SQLite 都支持事务内 DELETE + INSERT
|
||||
with db.engine.begin() as dst:
|
||||
for table in reversed(db.metadata.sorted_tables):
|
||||
dst.execute(table.delete())
|
||||
for table in db.metadata.sorted_tables:
|
||||
if table.name not in arch_tables:
|
||||
_log.warning(f"归档里没有 {table.name},该表导入后为空")
|
||||
continue
|
||||
have = {r[1] for r in con.execute(
|
||||
'PRAGMA table_info("%s")' % table.name)}
|
||||
cols = [c for c in table.columns if c.name in have]
|
||||
if not cols:
|
||||
_log.warning(f"归档表 {table.name} 没有可用列,跳过")
|
||||
continue
|
||||
sel = "SELECT {} FROM \"{}\"".format(
|
||||
", ".join('"%s"' % c.name for c in cols), table.name)
|
||||
rows = con.execute(sel).fetchall()
|
||||
names = [c.name for c in cols]
|
||||
for i in range(0, len(rows), chunk):
|
||||
batch = [dict(zip(names, r)) for r in rows[i:i + chunk]]
|
||||
dst.execute(table.insert(), batch)
|
||||
stats.append(f"{table.name}={len(rows)}")
|
||||
finally:
|
||||
con.close()
|
||||
return " ".join(stats)
|
||||
|
||||
+23
-11
@@ -610,37 +610,49 @@
|
||||
|
||||
### POST /api/system/backup/export
|
||||
|
||||
**JSON body**(不是表单):`{"include_apks": false}`。响应为 **zip 附件**:
|
||||
**JSON body**(不是表单):`{"include_apk": true}`(默认 true)。响应为 **zip 附件**:
|
||||
|
||||
```
|
||||
users.db # sqlite 在线备份 API 做的一致快照
|
||||
apks/*.apk # include_apks=true 时
|
||||
manifest.json # {format:"auto_control_backup", version, created_at, schema_version,
|
||||
# include_apk, tables:[{table,label,rows}], apks:[…], coverage_missing?}
|
||||
users.db # 当前库(MySQL 或回退 SQLite)整库一致快照成的 SQLite 归档
|
||||
apks/*.apk # include_apk=true 时
|
||||
manifest.json # {format:"auto_control_backup", version:2, created_at, schema_version,
|
||||
# db_backend, deployment_env, source_db_id, include_apk,
|
||||
# tables:[{table,label,rows}], apks:[…], coverage_missing?}
|
||||
```
|
||||
|
||||
> SQLite 在这里是**备份交换格式**而非运行时库:MySQL 下导出时把连接提到 REPEATABLE READ,
|
||||
> 保证 12 张表读的是同一时刻。
|
||||
|
||||
### POST /api/system/backup/preview
|
||||
|
||||
multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
```json
|
||||
{"ok": true, "token": "e172dc75e2f7",
|
||||
"preview": {"integrity": "ok", "schema_version": 4, "current_schema_version": 4,
|
||||
"tables": [{"table":"agent_action","label":"动作库","rows":4}],
|
||||
"preview": {"integrity": "ok", "schema_version": 6, "current_schema_version": 6,
|
||||
"source_env": "dev", "current_env": "dev", "source_backend": "mysql",
|
||||
"source_db_id": "704fbbb0…",
|
||||
"tables": [{"table":"agent_action","label":"动作库","rows":5}],
|
||||
"missing_optional": [], "extra_tables": [],
|
||||
"warnings": ["备份为全量快照,含用户口令哈希、AI 控制台 API Key 等敏感信息…"]}}
|
||||
"warnings": ["备份为全量数据,含用户口令哈希、AI 配置里的 API Key 等敏感信息…"]}}
|
||||
```
|
||||
|
||||
- 完整性 `PRAGMA integrity_check` 必须 `ok`;缺必需表(`app_meta`/`user`/`task_job`/`device_group`)直接拒绝
|
||||
- `extra_tables` 非空 = 备份含**未登记进覆盖清单**的表(提示去登记)
|
||||
- `extra_tables` 非空 = 备份含**未登记的表**(提示去登记)
|
||||
- `source_env` ≠ `current_env` 时 `warnings` 会多一条跨环境告警
|
||||
- 暂存 **TTL 30 分钟**,过期自动清理
|
||||
- 文件非法 / 未选文件 → 400
|
||||
|
||||
### POST /api/system/backup/apply
|
||||
|
||||
`{"token":"…"}` → 先自动把当前库快照到 `data/backups/pre_restore_<ts>.db`(安全网),再把暂存库落到 `data/restore_pending/`。
|
||||
`{"token":"…", "force_env_mismatch": false}` → 先自动把当前库导出成
|
||||
`data/backups/pre_restore_<ts>.zip`(安全网,可直接再导入回来),再把暂存归档落到
|
||||
`data/restore_pending/`。
|
||||
|
||||
**必须重启服务才生效**(`web_server.py` 在 `init_db` 之前消费该目录)。token 无效/暂存缺失 → 400。
|
||||
- **必须重启服务才生效**:`web_server.py` 在 `init_db` 之后、`TaskManager` 之前消费该目录
|
||||
- 重启时**单事务整库替换**(DELETE 全表 + 分块 INSERT),失败自动回滚,当前数据不受影响
|
||||
- 备份来源环境与当前库不符时**默认拒绝**(400),要跨环境须显式传 `force_env_mismatch: true`
|
||||
- token 无效/暂存缺失 → 400
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -75,9 +75,9 @@
|
||||
|
||||
| 阶段 | 位置 | 做了什么 |
|
||||
|------|------|---------|
|
||||
| **B** | `:28-41` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;`SQLALCHEMY_DATABASE_URI=sqlite:///data/users.db`;`LoginManager` + `login_view="auth.login"` |
|
||||
| **C** | `:43-50` | **恢复任务消费** `consume_pending_restore()` —— 必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件)。失败只记日志,不阻塞启动 |
|
||||
| **D** | `:53-61` | `init_db(app)`(建表 → 版本化迁移 → 默认管理员 → 旧 JSON 迁移);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
|
||||
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
|
||||
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
|
||||
| **D** | `:69-80` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
|
||||
|
||||
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
| `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` |
|
||||
| `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb |
|
||||
|
||||
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context + SQLite WAL + `busy_timeout=5000`"。
|
||||
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context" + 数据库自身的并发控制(MySQL 下是 InnoDB 行锁 + READ COMMITTED,回退 SQLite 时是 WAL + `busy_timeout=5000`)。
|
||||
|
||||
### 3.3 错峰与心跳
|
||||
|
||||
@@ -319,14 +319,14 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
|
||||
| 决策 | 为什么 | 代价 / 注意 |
|
||||
|------|--------|------------|
|
||||
| **SQLite + WAL** 而非 MySQL/PG | 单机部署、零运维;WAL 支持多线程读写 | 写并发有限;必须设 `busy_timeout`;`data/` 不入 git |
|
||||
| **数据库走 MySQL,SQLite 仅作回退** | 多环境(dev 库 / 正式库)需要同一套代码指向不同库;备份/恢复也不再受"文件被占用"掣肘 | 多一套连接配置与防混库校验(`core/db_config.py`);`DB_HOST` 为空时回退 SQLite,生产禁止静默回退 |
|
||||
| **threading 而非 asyncio** | uiautomator2 是同步阻塞库;每设备一线程模型直观 | 线程数随设备数增长;跨线程访问 DB 必须自推 app context |
|
||||
| **状态放内存** 而非落库 | 状态每秒都变,落库纯属浪费 | 重启丢失运行中状态(进程重启 = 任务终止) |
|
||||
| **蓝图按功能域拆包** | `web_server.py` 只做装配,路由就近维护 | 拆包易出现 import 遗漏 → 用 `scripts/regression_test.py` 兜底 |
|
||||
| **单页应用 + 全局脚本** | 无构建步骤、无 npm 依赖,改完强刷即生效 | 11 个文件共享全局作用域,需手工维护加载顺序 |
|
||||
| **任务类型注册表** | 新增任务类型不动调度器 | 删改类型要做兼容(未知类型必须显式报错,不能静默) |
|
||||
| **自建设备池**(不用 STF) | 规避共享 adb transport 的历史坑,清单可控 | 需自己处理在线状态、型号、自动发现 |
|
||||
| **备份"重启生效"** | Windows 无法替换被持有的 db;调度器/worker 持有内存态 | 导入后必须重启(在 `init_db` 之前消费恢复任务) |
|
||||
| **备份"重启生效"** | 调度器 / worker / 设备池都把任务与设备**缓存在内存**里,整库替换后内存副本全部失效 | 导入后必须重启(重启时消费恢复任务)。SQLite 时代还有"Windows 无法替换被持有的文件"这层原因,改用 MySQL 后只剩内存态这一层 |
|
||||
| **uiautodev 独立子进程** | 元素抓取是重活,隔离崩溃、可单独重启 | 固定端口 20242;PID 文件防重复拉起(杀之前必须校验 cmdline,防误杀同容器进程) |
|
||||
| **MCP 独立端口** | 外部 AI 用标准协议接入,不侵入 Web 会话 | MCP 端点自身无鉴权,靠网络隔离;写操作用开关门控 |
|
||||
|
||||
|
||||
+102
-67
@@ -2,7 +2,7 @@
|
||||
|
||||
> 适用读者:改后端 / 排数据问题的开发者与运维。
|
||||
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(运行时架构)、[DEPLOY.md](DEPLOY.md) §数据备份(备份覆盖红线)、[API.md](API.md)(消费这些数据的接口)。
|
||||
> **表结构以 `core/models.py` 与各模块的建表 SQL 为准**,本文是它们的映射说明。
|
||||
> **表结构以 `core/models.py` 的 ORM 模型为唯一准**(2026-09-13 起各模块的裸建表 SQL 已全部并入模型),本文是它们的映射说明。
|
||||
|
||||
---
|
||||
|
||||
@@ -10,32 +10,49 @@
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| 引擎 | SQLite(单文件 `data/users.db`) |
|
||||
| driver | Flask-SQLAlchemy(SQLAlchemy 2.x) |
|
||||
| 连接 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL` |
|
||||
| 建表方式 | ① 模型表:`db.create_all()`;② 版本化迁移 `SCHEMA_MIGRATIONS`;③ 幂等原生 `CREATE TABLE IF NOT EXISTS` |
|
||||
| 主库文件 | `data/users.db`(运行时另有 `-wal` / `-shm`) |
|
||||
| 当前 schema 版本 | `app_meta.schema_version = 4` |
|
||||
| 引擎 | MySQL(正式用法;目标由 `.env` 的 `DEPLOY_ENV` + `DB_*` 决定)/SQLite(回退模式,`DB_HOST` 为空时) |
|
||||
| driver | Flask-SQLAlchemy(SQLAlchemy 2.x)+ PyMySQL |
|
||||
| 连接参数 | 见 `core/db_config.py`:utf8mb4、排序规则 `utf8mb4_bin`、`pool_pre_ping`、`pool_recycle=1800`、隔离级别 READ COMMITTED、`sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION` |
|
||||
| SQLite 回退时的 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`(监听器按连接类型守卫,MySQL 连接不会执行) |
|
||||
| 建表方式 | **唯一真相是模型**:`db.create_all()`(建缺表)+ `_sync_columns()`(补缺列);`SCHEMA_MIGRATIONS` 只作版本账本与数据回填 |
|
||||
| 库位置 | MySQL:由 `DB_HOST/DB_NAME` 指定;SQLite:`data/users.db` |
|
||||
| 当前 schema 版本 | `app_meta.schema_version = 6` |
|
||||
|
||||
**表清单(12 张业务表 + 1 张 SQLite 内部表)**
|
||||
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
|
||||
|
||||
| # | 表 | 来源 | 用途 |
|
||||
|---|---|------|------|
|
||||
| 1 | `user` | 模型 | 登录用户与权限 |
|
||||
| 2 | `device_group` | 模型 | 设备分组(JSON 存 serial 列表) |
|
||||
| 3 | `task_job` | 模型 | 任务计划 |
|
||||
| 4 | `custom_action` | 模型 | 自定义动作(可复用步骤包) |
|
||||
| 5 | `apk_file` | 模型 | APK 记录 |
|
||||
| 6 | `device` | 模型 + 迁移 v2/v3 | 设备池 |
|
||||
| 7 | `pending_device` | 模型 + 迁移 v4 | 待确认的发现设备 |
|
||||
| 8 | `app_meta` | 原生(迁移前建) | KV 配置(schema 版本、AI 配置、发现配置) |
|
||||
| 9 | `agent_conversation` | 原生(`web/agent_api.py`) | AI 控制台会话 |
|
||||
| 10 | `agent_experience` | 原生(`web/agent_api.py`) | 经验库(任务级配方) |
|
||||
| 11 | `experience_audit` | 原生(`web/agent_api.py`) | 经验巡检结论 |
|
||||
| 12 | `agent_action` | 原生(`web/agent_api.py`) | 动作库(命名动作) |
|
||||
| — | `sqlite_sequence` | SQLite 内部 | `AUTOINCREMENT` 的附带产物(备份自检时按 `sqlite_` 前缀排除) |
|
||||
| `DEPLOY_ENV` | 期望库名 | 用途 |
|
||||
|---|---|---|
|
||||
| `dev` | `auto_control_dev` | 本地开发 |
|
||||
| `prod` | `auto_control` | 正式环境(220 容器) |
|
||||
|
||||
> **没有外键、没有关系(relationship)、没有索引**:全部靠应用层维护一致性。分组 ↔ 设备是多对多的 **JSON 列表**(`device_group.serials`),删除设备不会级联清理分组里的 serial。
|
||||
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
|
||||
|
||||
**表清单(12 张,全部是 `core/models.py` 里的 ORM 模型)**
|
||||
|
||||
| # | 表 | 用途 |
|
||||
|---|---|------|
|
||||
| 1 | `user` | 登录用户与权限 |
|
||||
| 2 | `device_group` | 设备分组(JSON 存 serial 列表) |
|
||||
| 3 | `task_job` | 任务计划 |
|
||||
| 4 | `custom_action` | 自定义动作(可复用步骤包) |
|
||||
| 5 | `apk_file` | APK 记录 |
|
||||
| 6 | `device` | 设备池 |
|
||||
| 7 | `pending_device` | 待确认的发现设备 |
|
||||
| 8 | `app_meta` | KV 配置(schema 版本、AI 配置、发现配置、库环境标签) |
|
||||
| 9 | `agent_conversation` | AI 控制台会话 |
|
||||
| 10 | `agent_experience` | 经验库(任务级配方) |
|
||||
| 11 | `experience_audit` | 经验巡检结论 |
|
||||
| 12 | `agent_action` | 动作库(命名动作) |
|
||||
|
||||
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
|
||||
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
|
||||
> 全都建不出来,而异常被 `except: pass` 吞掉 —— 表现为「经验库/动作库静默失灵」。
|
||||
> 现在全部升为模型,建表只有一条路径。
|
||||
|
||||
> **没有外键、没有关系(relationship)**:全部靠应用层维护一致性。分组 ↔ 设备是多对多的
|
||||
> **JSON 列表**(`device_group.serials`),删除设备不会级联清理分组里的 serial。
|
||||
>
|
||||
> **唯一索引只有两个**(设备名 / 指纹,见 §4.2),且都是「空值不参与唯一约束」的语义。
|
||||
|
||||
---
|
||||
|
||||
@@ -140,50 +157,64 @@
|
||||
| `experience_audit` | `id`(PK) · `exp_id` · `verdict`(keep/delete) · `score`(REAL) · `reason` · `hits` · `action`(pending/kept/deleted) · `audited_at` |
|
||||
| `agent_action` | `id`(PK) · `name` · `app` · `aliases`(JSON) · `params`(JSON) · `steps`(JSON) · `preconditions` · `hits` · `source_prompt` · `created_at` · `updated_at` |
|
||||
|
||||
> ⚠️ 这四张表**不走 `SCHEMA_MIGRATIONS`,无版本管理**,由各模块首次使用时 `CREATE TABLE IF NOT EXISTS` 幂等创建;建表失败会被 `except: pass` 吞掉(后续 SQL 才会报错)。新增此类表时,务必同时登记进备份覆盖清单(§6)。
|
||||
> 2026-09-13 起这四张表**已升为 ORM 模型**(见 §1 的说明),建表统一走 `db.create_all()`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 迁移机制
|
||||
|
||||
### 4.1 版本化迁移 `SCHEMA_MIGRATIONS`
|
||||
### 4.1 建表与补列(以模型为准)
|
||||
|
||||
| 版本 | 内容 | SQL 幂等性 |
|
||||
|------|------|-----------|
|
||||
| 1 | `user` 增加 `perms` | `ALTER TABLE`(非幂等,靠 duplicate column 兜底) |
|
||||
| 2 | 建 `device` 表(**无 `model` 列**) | 幂等(`IF NOT EXISTS`) |
|
||||
| 3 | `device` 增加 `model` | `ALTER TABLE`(同上兜底) |
|
||||
| 4 | 建 `pending_device` 表 | 幂等 |
|
||||
| 5 | `device` 增加 `fingerprint`(设备指纹) | `ALTER TABLE`(靠 duplicate column 兜底) |
|
||||
| 6 | `pending_device` 增加 `fingerprint` | 同上 |
|
||||
**模型定义是唯一真相**。启动时:
|
||||
|
||||
执行器 `_migrate_schema()`:
|
||||
| 步骤 | 做什么 | 幂等性 |
|
||||
|------|--------|--------|
|
||||
| `db.create_all()` | 建**缺的表**(模型里有的都在) | 幂等 |
|
||||
| `_sync_columns()` | 用 `sqlalchemy.inspect` 比对模型与实表,**补实表缺的列** | 幂等 |
|
||||
| `_migrate_schema()` | 维护 `app_meta.schema_version` 账本 + 执行数据回填 | 幂等 |
|
||||
| `_ensure_unique_indexes()` | 建设备名/指纹唯一索引(见 §4.2) | 幂等 |
|
||||
| `_ensure_default_admin()` | 首次创建 `admin/admin123` | 幂等 |
|
||||
| `_migrate_old_json()` | 旧 `groups.json`/`jobs.json` 一次性迁移 | 见 §4.3 |
|
||||
|
||||
1. 先 `CREATE TABLE IF NOT EXISTS app_meta(...)`(幂等)
|
||||
2. 读 `app_meta.schema_version`(缺省 0)
|
||||
3. 只执行 `version > current` 的迁移;**SQL 报 `duplicate column name` 时回滚该语句但仍记版本号**(自愈:`create_all` 已建列而版本未记录时不再卡住),其它异常才抛出
|
||||
4. 每条迁移后 `INSERT OR REPLACE app_meta('schema_version', v)` 并提交
|
||||
5. 整体包 try:失败只记 error 日志,**不阻塞启动**
|
||||
> 2026-09-13 之前,补列靠 `ALTER TABLE` 报错文本里有没有 `duplicate column name` 来判断
|
||||
> 「列已存在」——那是 SQLite 时代的写法,换方言(MySQL 的错误码/文本都不同)就失效了。
|
||||
> 现在改为直接读数据库元数据比对,**缺什么补什么,两种方言一套代码**。
|
||||
|
||||
### 4.2 唯一索引(设备身份)
|
||||
|
||||
`_migrate_schema()` 末尾会补建两个**部分唯一索引**(幂等,与版本号无关,老库升级时也会补):
|
||||
两个「空值不参与唯一约束」的唯一索引,与版本号无关,每次启动都补建:
|
||||
|
||||
| 索引 | 作用 |
|
||||
|------|------|
|
||||
| `ux_device_name` | 设备**名称唯一**(`WHERE name <> ''`,兼容历史空名) |
|
||||
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(`WHERE fingerprint <> ''`) |
|
||||
| `ux_device_name` | 设备**名称唯一**(空名不参与,兼容历史未命名设备) |
|
||||
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(空指纹不参与) |
|
||||
|
||||
实现按方言分叉(`_ensure_unique_indexes()`):
|
||||
|
||||
- **SQLite**:直接用带 `WHERE name <> ''` 的**部分索引**
|
||||
- **MySQL 5.7**:不支持过滤索引,改用「**虚拟生成列 + 唯一索引**」——
|
||||
生成列把空值映射成 `NULL`(`IF(col IS NULL OR col='', NULL, col)`),
|
||||
而唯一索引允许多个 `NULL`,正好等于「空值不参与唯一」。生成列名 `name_uq` /
|
||||
`fingerprint_uq`,**只由 DDL 添加、不进 ORM 模型**(进了 `create_all` 会尝试写入
|
||||
它并报 Error 3105)。
|
||||
|
||||
> 用**部分索引**而不是表级约束:历史数据可能有空名称/空指纹,`<> ''` 让它们不参与唯一性判断。
|
||||
> 若历史数据里已有重复(建索引失败),只告警不回滚、不阻塞启动——约束从此刻起对新数据生效,
|
||||
> 老的重复行由管理页「改名」处理。
|
||||
|
||||
### 4.2.1 排序规则为什么必须是 `utf8mb4_bin`
|
||||
|
||||
SQLite 的文本比较是**逐字节**的(大小写敏感)。MySQL 默认的 `utf8mb4_general_ci`
|
||||
是大小写**不**敏感,会让 `Admin`/`admin`、`Phone1`/`phone1` 被判成重复,唯一索引和
|
||||
等值查询语义全变。所以库、表、连接三处都统一用 `utf8mb4_bin`(逐码点比较,对合法
|
||||
UTF-8 等价于字节序)。
|
||||
|
||||
唯一的语义差异:`LIKE` 在 `_bin` 下是大小写敏感的(SQLite 对 ASCII 默认不敏感)。
|
||||
当前代码里没有任何 `LIKE`/`ilike` 查询,暂无影响。
|
||||
|
||||
### 4.3 旧 JSON 迁移(一次性)
|
||||
|
||||
启动时若存在 `data/groups.json` / `data/jobs.json`:对应表为空则导入,随后把文件重命名为 `<name>.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
|
||||
|
||||
`init_db(app)` 顺序:`db.init_app` → `create_all()` → `_migrate_schema()` → `_ensure_default_admin()`(首次创建 `admin/admin123`)→ `_migrate_old_json()`。
|
||||
|
||||
---
|
||||
|
||||
## 5. `app_meta` 键清单
|
||||
@@ -200,37 +231,37 @@
|
||||
| `discovery_subnets` | 扫描网段 JSON 数组 | 同上 |
|
||||
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
|
||||
| `discovery_port` | adb 探测端口(1-65535) | 同上 |
|
||||
| `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 |
|
||||
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
|
||||
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
|
||||
| `deployment_claimed_at` | 标签写入时间 | 同上 |
|
||||
|
||||
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
|
||||
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
|
||||
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 备份覆盖清单(红线)
|
||||
|
||||
`core/system_backup.py` 的 `SUMMARY_TABLES`(12 张,与库中实际业务表一一对应):
|
||||
`core/system_backup.py` 的 `SUMMARY_TABLES` **由模型元数据派生**:
|
||||
|
||||
| 表 | 中文标签 |
|
||||
|----|---------|
|
||||
| `app_meta` | 系统配置(app_meta) |
|
||||
| `user` | 用户 |
|
||||
| `device_group` | 设备分组 |
|
||||
| `task_job` | 任务计划 |
|
||||
| `custom_action` | 自定义动作 |
|
||||
| `apk_file` | APK 记录 |
|
||||
| `device` | 设备池 |
|
||||
| `pending_device` | 待连接设备 |
|
||||
| `agent_conversation` | AI 会话 |
|
||||
| `agent_experience` | 经验库 |
|
||||
| `experience_audit` | 经验巡检 |
|
||||
| `agent_action` | 动作库 |
|
||||
```python
|
||||
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
```
|
||||
|
||||
也就是说——**新增一张 ORM 表,自动就进备份覆盖清单**,不可能再漏。
|
||||
`TABLE_LABELS`(预览页的中文标签)仍是手工维护,缺标签时回退显示表名。
|
||||
|
||||
**双向自检**:
|
||||
|
||||
- **导出侧**:登记在 `SUMMARY_TABLES` 但快照里缺失 → 写入 `manifest.coverage_missing` + 日志告警
|
||||
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警"请登记进 `core/system_backup.py` 的覆盖清单"(字段 `extra_tables`)
|
||||
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入
|
||||
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警(字段 `extra_tables`)
|
||||
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入(这四项是"判定这是不是本平台备份"的最小集合,故意手工维护)
|
||||
|
||||
> **红线**:**新增任何持久化表(含原生建表的 agent 类表),必须同时登记进 `SUMMARY_TABLES` 与 `TABLE_LABELS`**,并更新 [DEPLOY.md](DEPLOY.md) §数据备份。历史教训:`agent_action` 曾漏登记,导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
|
||||
> **红线**:新增持久化表时**同时补 `TABLE_LABELS` 的中文标签**并更新 [DEPLOY.md](DEPLOY.md) §数据备份。
|
||||
> 覆盖清单本身不再需要手工登记(已由 metadata 派生)。历史教训:`agent_action` 曾漏登记,
|
||||
> 导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
|
||||
|
||||
---
|
||||
|
||||
@@ -238,11 +269,15 @@
|
||||
|
||||
| 路径 | 内容 | 进 git |
|
||||
|------|------|--------|
|
||||
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库 | 否 |
|
||||
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库(**仅回退模式用**;连 MySQL 时这些文件不被读写,可留作历史归档) | 否 |
|
||||
| `data/apks/*.apk` | 上传的 APK | 否 |
|
||||
| `data/backups/` | 导出临时 zip、`pre_restore_*.db`(导入前安全网)、`restore_failed_*` | 否 |
|
||||
| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | 否 |
|
||||
| `data/restore_staging/<token>/` | 导入暂存(TTL 1800s 自动清理) | 否 |
|
||||
| `data/restore_pending/` | 待生效恢复任务(重启时消费) | 否 |
|
||||
| `data/restore_pending/` | 待生效恢复任务(重启时单事务消费) | 否 |
|
||||
|
||||
> 后三个目录可用环境变量改到别处(`DATA_BACKUP_DIR` / `DATA_RESTORE_STAGING_DIR` /
|
||||
> `DATA_RESTORE_PENDING_DIR`)——自动化测试必须这么做,否则测试造的待生效恢复任务
|
||||
> 会被服务当成用户的操作在下次重启时消费掉。
|
||||
| `data/mcp_audit.log` | MCP 调用审计(路径由 `MCP_AUDIT_FILE` 指定) | 否 |
|
||||
| `data/uiauto.pid` | uiautodev 子进程 PID | 否 |
|
||||
| `data/*.json.migrated` | 旧 JSON 迁移归档 | 否 |
|
||||
|
||||
+81
-13
@@ -63,11 +63,25 @@ pip install -r requirements.txt
|
||||
|
||||
```ini
|
||||
WEB_SECRET_KEY=<随机 64 hex> # 不配则每次重启登录态失效
|
||||
DEPLOY_ENV=prod # 声明"这是生产环境",与库名 auto_control 绑定
|
||||
DB_HOST=<MySQL 主机> # 数据库目标(不配则回退 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 的密码> # 改过 admin 密码必须同步,否则 MCP 登录失败
|
||||
```
|
||||
|
||||
**防混库**(`DEPLOY_ENV` 与库名/库标签双向校验,配置项说明见 [DEVELOPMENT.md](DEVELOPMENT.md) §4.2
|
||||
与 `.env.example` 的「数据库」段):启动时若
|
||||
「`.env` 声明的环境」与「库名」或「库中登记的 `app_meta.deployment_env`」不符,**直接拒绝启动**并打印
|
||||
两边分别是什么。每次启动还会打印一条横幅写明当前连的是哪个库(生产用 WARNING 级),
|
||||
页面顶部也常驻一个环境徽标(生产红底 `PROD · <库>`,开发灰底 `DEV`),
|
||||
**浏览器标签名也带环境前缀**(开发 `dev-设备自动化后台`,生产 `设备自动化后台`)——
|
||||
同时开着两个环境时一眼分得清哪个标签是哪个。
|
||||
两个逃生阀 `DB_ALLOW_ENV_MISMATCH` / `DB_ALLOW_SQLITE_FALLBACK` 默认关闭。
|
||||
|
||||
其余配置项与默认值见 [DEVELOPMENT.md](DEVELOPMENT.md) §配置速查。
|
||||
|
||||
### 2.3 启动
|
||||
@@ -172,25 +186,56 @@ tail -20 logs/web.log # 无 ERROR/Traceback
|
||||
|
||||
**「系统 → 数据备份 / 导入恢复」**(仅管理员):
|
||||
|
||||
- **导出**:`POST /api/system/backup/export` → sqlite 在线备份 API 做一致快照 → 打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)
|
||||
- **导入**:上传 zip/`.db` → 校验预览(完整性 / 必需表 / schema 版本 / 未登记表告警)→ 确认后自动把当前库快照到 `data/backups/pre_restore_*.db`(安全网)→ 落 `data/restore_pending/` → **重启服务生效**
|
||||
- **导出**:`POST /api/system/backup/export` → 把当前库(MySQL 或回退模式的 SQLite)
|
||||
整库一致快照成一份 SQLite 归档 → 打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)
|
||||
- **导入**:上传 zip/`.db` → 校验预览(完整性 / 必需表 / schema 版本 / 未登记表 / **来源环境**)
|
||||
→ 确认后自动把当前库导出一份 `data/backups/pre_restore_*.zip`(安全网,可再导入回来)
|
||||
→ 落 `data/restore_pending/` → **重启服务生效**
|
||||
|
||||
> **SQLite 在这里是"备份交换格式",不是运行时数据库。** 所以归档里永远是一个 `users.db`,
|
||||
> 无论平台连的是 MySQL 还是 SQLite —— 前端接口、校验逻辑、老备份的兼容性都不用变,
|
||||
> 也不依赖 `mysqldump` 这类外部二进制(容器是 `python:slim`,没有 MySQL 客户端)。
|
||||
|
||||
**恢复是怎么生效的**:重启时 `consume_pending_restore()` 在**单个事务内** `DELETE` 全表 +
|
||||
分块 `INSERT`,任何一步失败就回滚——当前数据保持原样,不会留下半新半旧的库。
|
||||
坏归档会被挪到 `data/backups/restore_failed_*/` 且不阻塞启动。
|
||||
|
||||
**跨环境导入默认拒绝**:备份的 manifest 里记了来源环境(`deployment_env`),
|
||||
若与当前库环境不一致(例如拿生产备份灌 dev 库),预览会弹黄框告警、直接应用会被拒绝;
|
||||
确需跨环境时在预览页勾选「允许跨环境导入」。
|
||||
|
||||
### 5.2 备份覆盖清单(红线)
|
||||
|
||||
覆盖清单 = `core/system_backup.py` 的 `SUMMARY_TABLES`,当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / `apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / `experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
|
||||
覆盖清单 = `core/system_backup.py` 的 `SUMMARY_TABLES`,**由模型元数据自动派生**:
|
||||
|
||||
> **新增任何持久化表,必须同步登记进该清单**——否则导出预览里看不到它,会被误判为"没有备份"(2026-09-10 动作库 `agent_action` 就踩过:数据其实在快照里,只是清单漏列)。
|
||||
> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警);导入侧有**反向自检**(备份含未登记表 → 预览告警)。
|
||||
```python
|
||||
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
```
|
||||
|
||||
当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
|
||||
`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` /
|
||||
`experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
|
||||
|
||||
> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action`
|
||||
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
|
||||
> 还需要手工做的只有:给新表补 `TABLE_LABELS` 的中文标签。
|
||||
> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警);
|
||||
> 导入侧有**反向自检**(备份含未登记表 → 预览告警)。
|
||||
|
||||
### 5.3 目录与手工备份
|
||||
|
||||
| 目录 | 用途 |
|
||||
|------|------|
|
||||
| `data/backups/` | 导出临时 zip、`pre_restore_*.db`、`restore_failed_*` |
|
||||
| `data/restore_staging/` | 导入暂存(TTL 30 分钟自动清理) |
|
||||
| `data/restore_pending/` | 待生效恢复任务(重启时消费) |
|
||||
| 目录 | 用途 | 可否用环境变量改 |
|
||||
|------|------|------------------|
|
||||
| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | `DATA_BACKUP_DIR` |
|
||||
| `data/restore_staging/` | 导入暂存(TTL 30 分钟自动清理) | `DATA_RESTORE_STAGING_DIR` |
|
||||
| `data/restore_pending/` | 待生效恢复任务(重启时消费) | `DATA_RESTORE_PENDING_DIR` |
|
||||
|
||||
手工整目录备份 `data/` 仍可作兜底,但**整库恢复建议走内置功能**(在线一致快照 + 预恢复备份 + 重启原子生效,避免手工替换被 WAL/占用文件破坏)。
|
||||
> 这三个环境变量主要给**自动化测试**用:测试必须把恢复目录指到临时位置,
|
||||
> 否则测试造出来的"待生效恢复任务"会在服务下次重启时被当成用户的操作消费掉。
|
||||
|
||||
连 MySQL 时 `data/users.db` 及其 `-wal`/`-shm` **不再被读写**(只有回退模式才用),
|
||||
可以留作历史归档。手工整目录备份 `data/` 仍可作兜底,但**整库恢复建议走内置功能**
|
||||
(一致快照 + 预恢复备份 + 重启时事务替换)。
|
||||
|
||||
> ⚠️ 备份 zip 含**用户口令哈希与 AI 控制台 API Key(明文)**,注意保管与传输。
|
||||
|
||||
@@ -226,9 +271,32 @@ docker restart python-app # 容器场景
|
||||
|
||||
### 7.2 数据库迁移
|
||||
|
||||
- 表结构变化由 `init_db()` 自动处理:`create_all()` 建新表 + `SCHEMA_MIGRATIONS` 补列(幂等,失败不阻塞启动)
|
||||
- **表结构变化**由 `init_db()` 自动处理:`create_all()` 建缺表 + `_sync_columns()` 补缺列(幂等,失败不阻塞启动,见 [DATA_MODEL.md](DATA_MODEL.md) §4)
|
||||
- 旧 `groups.json` / `jobs.json` 首次启动自动迁移并归档为 `.migrated`
|
||||
- **跨版本恢复**:用「导入恢复」上传旧库 → 预览会提示 schema 版本差异 → 应用后重启(新版本会自动补迁移)
|
||||
- **跨版本恢复**:用「导入恢复」上传旧库 → 预览会提示 schema 版本差异 → 应用后重启(新版本会自动补列/补空表)
|
||||
|
||||
#### SQLite → MySQL(一次性)
|
||||
|
||||
首次把平台从单文件 SQLite 换到 MySQL 时,用迁移脚本搬数据:
|
||||
|
||||
```bash
|
||||
# 0) 先在 .env 里配好 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME(或用 --target-url)
|
||||
# 1) 干跑:审计源库 + 建目标 schema,不搬数据
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --dry-run
|
||||
# 2) 正式搬(目标库已有的数据会被整表替换;单事务,失败自动回滚)
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev
|
||||
# 3) 复验(只比对不写)
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode verify
|
||||
```
|
||||
|
||||
脚本做的事:源库完整性校验 → **列宽审计**(SQLite 不强制长度,MySQL 严格模式下超长会直接报错,
|
||||
所以先拦下来)→ 建目标 schema(复用平台的 `create_all` + 补列 + 唯一索引)→ 逐表搬行
|
||||
(单事务)→ **逐表行数 + 全行 SHA-256 比对** → 写库环境标签与 `schema_version`。
|
||||
|
||||
> `--env prod` 必须额外加 `--allow-prod`,交互终端还要手打 `prod` 确认(这是唯一会往生产库灌数据的入口)。
|
||||
> 目标库若已登记为别的环境(`app_meta.deployment_env`),除非 `--force-env` 否则拒绝。
|
||||
|
||||
建库建账号见 `scripts/sql/init_mysql_5.7.sql`(utf8mb4 + `utf8mb4_bin`,两个库对应 dev/prod)。
|
||||
|
||||
### 7.3 跨机迁移(换部署机)
|
||||
|
||||
|
||||
+7
-1
@@ -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`) |
|
||||
@@ -140,6 +145,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||
|------|------|------|------|
|
||||
| `WEB_SECRET_KEY` | 未配置则随机 | `web_server.py` | 会话密钥,**生产必须固定** |
|
||||
| `DISABLE_SCHEDULER` | 未设 | `core/task_manager.py` | 设任意值则**不启动 cron 调度器**(测试用) |
|
||||
| `DATA_BACKUP_DIR` / `DATA_RESTORE_STAGING_DIR` / `DATA_RESTORE_PENDING_DIR` | `data/backups` 等 | `config.py` | 备份/恢复目录改到别处;**自动化测试必须指到临时目录**(否则测试造的待生效恢复任务会在下次重启被当成用户操作消费) |
|
||||
| `MCP_ENABLED` | `1` | `scripts/start.sh` | =0 则不后台拉起 MCP |
|
||||
| `MCP_ALLOW_WRITE` | `0`(脚本内强制 1) | `mcp_server/config.py` | 写操作总开关 |
|
||||
| `MCP_PLATFORM_URL` / `_USER` / `_PASS` | `http://127.0.0.1:18050` / `admin` / 空 | `mcp_server/config.py` | 登录平台的凭据(改过 admin 密码要同步) |
|
||||
|
||||
+5
-9
@@ -32,14 +32,6 @@
|
||||
- **涉及**:`web_server.py`(注册钩子)+ 需要回归所有写接口(前端、脚本、MCP 的 `platform_client` 都已带 token,风险点是第三方直接调 API)。
|
||||
- **验收**:注册后所有写接口在缺 token 时返回 403,且前端/MCP 全链路正常。
|
||||
|
||||
### A4. 路由回归脚本在 Windows 上不可用
|
||||
|
||||
- **现象**:`python scripts/regression_test.py` 直接报 `AttributeError: module 'signal' has no attribute 'alarm'` 退出。
|
||||
- **原因**:脚本用了 Unix 专有的 `signal.alarm`(第 29 行),docstring 里的示例路径也是 `.venv/bin/python`。
|
||||
- **影响**:本机(Windows)开发时没有"一条命令扫全接口 500"的手段——A1/A2 这类 import 遗漏本可被它第一时间发现。
|
||||
- **涉及**:`scripts/regression_test.py`(把 `signal.alarm` 换成跨平台超时,或 `hasattr` 守卫)。
|
||||
- **验收**:Windows 与 Linux 都能跑;能扫出 A1。
|
||||
|
||||
### A5. 前端 `--card-line` 变量未定义
|
||||
|
||||
- **现象**:AI 控制台的会话/经验/动作模态框边框失效(`border:1px solid var(--card-line)` 解析为无效值)。
|
||||
@@ -74,7 +66,7 @@
|
||||
- `ANDROID_ADB_SERVER_ADDRESS/HOST/PORT` 会被 `.env` 注入 `os.environ`,**adb 客户端**据此把所有调用指向 220;`ANDROID_ADB_SERVER_HOST` 是非标准键(adb 不认)。
|
||||
- 期望:在配置速查里写明该行为与取舍,或本地去掉这三行改走本机 adb。
|
||||
|
||||
- [ ] **`.gitignore` 漏项**:`data/mcp_audit.log` 与 `data/uiauto.pid` 未被忽略(`git status` 会显示为未跟踪);`data/apks/` 目录无占位文件。
|
||||
- [ ] **`.gitignore` 漏项**:`data/mcp_audit.log` 已补(`data/*.log`,2026-09-13);**`data/uiauto.pid` 仍未忽略**(`git status` 会显示为未跟踪);`data/apks/` 目录无占位文件。
|
||||
|
||||
- [ ] 220 的 Tailscale / adb server(5037) 可达性巡检(曾出现连不通超时)。
|
||||
|
||||
@@ -129,3 +121,7 @@
|
||||
- [x] 监控页任务卡去掉「编辑/删除」,只留执行/停用 + 显示**覆盖设备**。
|
||||
- [x] 备份覆盖清单补 `agent_action` / `app_meta` + 导出/导入双向自检。
|
||||
- [x] AI 控制台:Markdown 渲染、推理链可折叠、token 用量显示。
|
||||
- [x] 路由回归脚本 Windows 可用(2026-09-13:`signal.alarm` 加 `hasattr` 守卫)+ 新增安全闸:
|
||||
配置或目标库被标为 `prod` 时**拒绝运行**(脚本会发真实写请求,不能拿生产数据做实验)。
|
||||
- [x] 数据库从 SQLite 迁到 MySQL:连接层多环境配置 + 防混库校验、schema 收敛到 ORM metadata、
|
||||
备份/恢复改为「SQLite 归档 + 单事务整库替换」、迁移脚本(2026-09-13)。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 SQLite 库整体迁移进 MySQL(一次性工具,长期保留)。
|
||||
|
||||
用途:
|
||||
* 平台从 SQLite 迁到 MySQL 时搬数据
|
||||
* 从老备份(导出 zip 里的 users.db)恢复进 MySQL
|
||||
* 换环境(dev 库 ↔ 正式库)
|
||||
|
||||
用法示例:
|
||||
# 先干跑:只审计源库 + 建 schema,不搬数据
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --dry-run
|
||||
|
||||
# 正式迁(目标库已有数据会被整表替换)
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode replace
|
||||
|
||||
# 迁完只做校验(逐表 SHA-256 比对,不写任何数据)
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode verify
|
||||
|
||||
# 生产(必须显式二次确认)
|
||||
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env prod --allow-prod --yes
|
||||
|
||||
目标库怎么定:默认读 .env / 环境变量的 DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME
|
||||
(也可用 --target-url 直接给完整连接串)。脚本**不会**碰源库(只读打开)。
|
||||
|
||||
安全设计:
|
||||
* 全程只读源库;目标库的写入在**单个事务**里,失败回滚,可放心重跑
|
||||
* 搬完做逐表行数 + 逐表全行 SHA-256 校验,不通过就报错
|
||||
* `--env prod` 必须额外 `--allow-prod`,交互终端还要手打 prod 确认
|
||||
* 目标库若已登记为别的环境(app_meta.deployment_env),除非 --force-env 否则拒绝
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
# Windows 控制台默认 GBK,输出里带 ✔ 之类的符号会直接抛 UnicodeEncodeError
|
||||
# (本脚本是运维在终端里手跑的,崩在最后一步会让人以为迁移失败)
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect, text as sa_text # noqa: E402
|
||||
|
||||
|
||||
def _fail(msg):
|
||||
print("\n[错误] " + msg)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _readonly_uri(path):
|
||||
from urllib.parse import quote
|
||||
return "file:{}?mode=ro".format(
|
||||
quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
|
||||
|
||||
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(description="SQLite → MySQL 数据迁移")
|
||||
ap.add_argument("--sqlite", default=os.path.join(ROOT, "data", "users.db"),
|
||||
help="源 SQLite 库路径(默认 data/users.db)")
|
||||
ap.add_argument("--env", choices=["dev", "prod"], required=True,
|
||||
help="目标库属于哪个环境(写入 app_meta.deployment_env)")
|
||||
ap.add_argument("--mode", choices=["replace", "verify"], default="replace",
|
||||
help="replace=建 schema 并搬数据;verify=只比对不写")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="只审计源库并建 schema,不搬数据")
|
||||
ap.add_argument("--schema-only", action="store_true",
|
||||
help="只建目标库的表结构,不搬数据")
|
||||
ap.add_argument("--target-url", default="",
|
||||
help="目标连接串(默认取 .env / 环境变量里的 DB_*)")
|
||||
ap.add_argument("--allow-prod", action="store_true",
|
||||
help="允许对生产库操作(--env prod 时必填)")
|
||||
ap.add_argument("--yes", action="store_true",
|
||||
help="非交互环境下的最终确认(与 --allow-prod 一起用)")
|
||||
ap.add_argument("--force-env", action="store_true",
|
||||
help="目标库已登记为别的环境时仍然继续(危险)")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
# ================== 1. 源库审计 ==================
|
||||
def audit_source(src_path, tables):
|
||||
"""校验源库可读、完整,并做列宽审计。返回 {表: {列: 最大长度}}。"""
|
||||
if not os.path.exists(src_path):
|
||||
_fail("源库不存在: " + src_path)
|
||||
con = sqlite3.connect(_readonly_uri(src_path), uri=True)
|
||||
con.text_factory = str
|
||||
try:
|
||||
integrity = con.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
print(f"源库完整性: {integrity}")
|
||||
if integrity != "ok":
|
||||
_fail("源库完整性校验失败,请先修复源库再迁移")
|
||||
|
||||
have = {r[0] for r in con.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
print(f"源库表({len(have)}): " + ", ".join(sorted(have)))
|
||||
missing = [t for t in tables if t not in have]
|
||||
if missing:
|
||||
print(f" 注意:模型里有但源库没有的表(迁移后为空): {', '.join(missing)}")
|
||||
|
||||
# 列宽审计:SQLite 不强制长度,MySQL 严格模式下超长会直接报错
|
||||
widths = {}
|
||||
for t in sorted(have):
|
||||
if t.startswith("sqlite_"):
|
||||
continue
|
||||
widths[t] = {}
|
||||
cols = [r[1] for r in con.execute('PRAGMA table_info("%s")' % t)]
|
||||
for c in cols:
|
||||
try:
|
||||
n = con.execute(
|
||||
'SELECT MAX(LENGTH("%s")) FROM "%s"' % (c, t)).fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
continue
|
||||
if n:
|
||||
widths[t][c] = n
|
||||
return widths
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def check_widths(widths):
|
||||
"""把源库实测列宽与模型声明比对,超限直接拦下(MySQL 严格模式会报错)。"""
|
||||
from core.models import db
|
||||
problems = []
|
||||
print("\n列宽审计(源库实测最大长度 / 模型上限):")
|
||||
for table in db.metadata.sorted_tables:
|
||||
w = widths.get(table.name) or {}
|
||||
for col in table.columns:
|
||||
n = w.get(col.name)
|
||||
limit = getattr(col.type, "length", None)
|
||||
if n is None or not limit:
|
||||
continue
|
||||
flag = ""
|
||||
if n > limit:
|
||||
flag = " <== 超出!"
|
||||
problems.append(f"{table.name}.{col.name}: {n} > {limit}")
|
||||
if n > limit * 0.6:
|
||||
print(f" {table.name}.{col.name}: {n}/{limit}{flag}")
|
||||
if problems:
|
||||
_fail("以下列的实际数据超过模型声明长度,请先加长模型列宽或清理数据:\n "
|
||||
+ "\n ".join(problems))
|
||||
print(" (只打印长度超过上限 60% 的列;全部在限内)")
|
||||
|
||||
|
||||
# ================== 2. 目标库 schema ==================
|
||||
def build_target_app(uri, env):
|
||||
"""用目标连接串建一个最小 Flask app,复用平台自己的建表逻辑。"""
|
||||
os.environ["DEPLOY_ENV"] = env
|
||||
os.environ["DATABASE_URL"] = uri
|
||||
from flask import Flask
|
||||
from core.models import db # noqa: E402
|
||||
|
||||
app = Flask("migrate")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = uri
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
from core import db_config
|
||||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
# 只做结构:建表 / 补列 / 唯一索引(跳过默认管理员与旧 JSON 迁移)
|
||||
from core.models import _ensure_unique_indexes, _sync_columns
|
||||
db.create_all()
|
||||
_sync_columns()
|
||||
_ensure_unique_indexes()
|
||||
return app
|
||||
|
||||
|
||||
# ================== 3. 搬数据 + 校验 ==================
|
||||
def _rows_from_sqlite(con, table, cols):
|
||||
names = [c.name for c in cols]
|
||||
sel = "SELECT {} FROM \"{}\"".format(
|
||||
", ".join('"%s"' % n for n in names), table.name)
|
||||
cur = con.execute(sel)
|
||||
return names, cur.fetchall()
|
||||
|
||||
|
||||
def _norm(v):
|
||||
"""把驱动层返回的值统一成可跨库比较的形态。
|
||||
|
||||
两个库的类型系统不一样,直接比 Python 对象会误报:
|
||||
* 布尔:SQLite 存 1/0(原始读出来是 int),走 ORM 读回来是 True/False
|
||||
* 整数值的浮点:SQLite 的 REAL 列里可能存着 7,MySQL 的 DOUBLE 读回来是 7.0
|
||||
* 文本:某些驱动可能给 bytes
|
||||
"""
|
||||
if isinstance(v, bool):
|
||||
return int(v)
|
||||
if isinstance(v, float) and v.is_integer():
|
||||
return int(v)
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
return v.decode("utf-8", "replace")
|
||||
return v
|
||||
|
||||
|
||||
def _canonical(names, rows):
|
||||
"""把整表行规范化成一个字符串,用于跨库比对(行序无关:先排序再哈希)。"""
|
||||
packed = [json.dumps([_norm(r[i]) for i in range(len(names))],
|
||||
ensure_ascii=False, default=str,
|
||||
sort_keys=False) for r in rows]
|
||||
packed.sort()
|
||||
h = hashlib.sha256()
|
||||
for p in packed:
|
||||
h.update(p.encode("utf-8"))
|
||||
h.update(b"\n")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def copy_and_verify(src_path, table_order, mode, chunk=500):
|
||||
"""搬运并校验。返回 (逐表统计, 是否全部一致)。
|
||||
|
||||
mode="replace":DELETE 全表 + 插入 + 回读比对(单事务,失败整体回滚)
|
||||
mode="verify" :只回读比对,不写任何数据
|
||||
"""
|
||||
from core.models import db
|
||||
|
||||
con = sqlite3.connect(_readonly_uri(src_path), uri=True)
|
||||
con.text_factory = str
|
||||
stats = []
|
||||
try:
|
||||
have = {r[0] for r in con.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
with db.engine.begin() as dst: # 单事务:失败整体回滚
|
||||
if mode == "replace":
|
||||
for table in reversed(table_order):
|
||||
dst.execute(table.delete())
|
||||
for table in table_order:
|
||||
if table.name not in have:
|
||||
stats.append((table.name, 0, "源库无此表", "-"))
|
||||
continue
|
||||
db_cols = {c["name"] for c in sa_inspect(dst).get_columns(table.name)}
|
||||
cols = [c for c in table.columns if c.name in db_cols]
|
||||
names, rows = _rows_from_sqlite(con, table, cols)
|
||||
src_hash = _canonical(names, rows)
|
||||
if mode == "replace":
|
||||
for i in range(0, len(rows), chunk):
|
||||
batch = [dict(zip(names, r)) for r in rows[i:i + chunk]]
|
||||
dst.execute(table.insert(), batch)
|
||||
stats.append((table.name, len(rows), src_hash))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
# 回读目标库重算(verify 模式只有这一步)
|
||||
with db.engine.connect() as dst:
|
||||
out = []
|
||||
for (name, srows, shash) in stats:
|
||||
table = next((t for t in table_order if t.name == name), None)
|
||||
if table is None or shash == "-":
|
||||
out.append((name, srows, "-", "源库无此表", "-"))
|
||||
continue
|
||||
db_cols = {c["name"] for c in sa_inspect(dst).get_columns(name)}
|
||||
names = [c.name for c in table.columns if c.name in db_cols]
|
||||
# 用原生 SQL 回读:走 ORM/Core 的 typed select 会做类型转换
|
||||
# (Boolean→True/False),与源库驱动层读出来的 1/0 对不上
|
||||
prep = dst.dialect.identifier_preparer
|
||||
sel = "SELECT {} FROM {}".format(
|
||||
", ".join(prep.quote(n) for n in names), prep.quote(name))
|
||||
rows = [tuple(r) for r in dst.execute(sa_text(sel)).fetchall()]
|
||||
thash = _canonical(names, rows)
|
||||
same = (len(rows) == srows) and (thash == shash)
|
||||
out.append((name, srows, len(rows), "一致" if same else "不一致", shash))
|
||||
return out, all(r[3] == "一致" or r[3] == "源库无此表" for r in out)
|
||||
|
||||
|
||||
def print_stats(stats):
|
||||
print("\n%-20s %6s %6s %-10s %s" % ("表", "源", "目标", "结果", "SHA-256(前12)"))
|
||||
for (name, srows, trows, verdict, shash) in stats:
|
||||
print("%-20s %6s %6s %-10s %s" % (name, srows, trows, verdict, (shash or "-")[:12]))
|
||||
|
||||
|
||||
# ================== 主流程 ==================
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if args.env == "prod":
|
||||
if not args.allow_prod:
|
||||
_fail("--env prod 必须同时给 --allow-prod(这条命令会写生产库)")
|
||||
if sys.stdin.isatty() and not args.yes:
|
||||
print("即将对【生产库】执行迁移。")
|
||||
if input("请输入 prod 确认: ").strip() != "prod":
|
||||
_fail("未确认,已中止")
|
||||
elif not args.yes:
|
||||
_fail("非交互环境下请追加 --yes 明确确认")
|
||||
|
||||
from core import db_config
|
||||
uri = args.target_url or db_config.build_db_uri()
|
||||
if db_config.is_sqlite(uri):
|
||||
_fail("目标仍是 SQLite —— 请在 .env 里配好 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME,"
|
||||
"或用 --target-url 指定 MySQL 连接串")
|
||||
print(f"源库 : {args.sqlite}")
|
||||
print(f"目标库 : {db_config.describe_target(uri)} (环境 {args.env})")
|
||||
|
||||
from core.models import db
|
||||
tables = list(db.metadata.sorted_tables)
|
||||
print(f"模型表({len(tables)}): " + ", ".join(t.name for t in tables))
|
||||
|
||||
widths = audit_source(args.sqlite, [t.name for t in tables])
|
||||
check_widths(widths)
|
||||
|
||||
if args.mode == "verify":
|
||||
# 不建 schema、不写数据,直接比对(用于迁移后复验)
|
||||
os.environ["DEPLOY_ENV"] = args.env
|
||||
os.environ["DATABASE_URL"] = uri
|
||||
from flask import Flask
|
||||
app = Flask("verify")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = uri
|
||||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
stats, ok = copy_and_verify(args.sqlite, tables, "verify")
|
||||
print_stats(stats)
|
||||
print("\n校验结果: " + ("全部一致 OK" if ok else "存在不一致 FAIL"))
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
# 目标库环境标签检查(防止误把 dev 数据灌进生产库)
|
||||
app = build_target_app(uri, args.env)
|
||||
with app.app_context():
|
||||
recorded = db_config.meta_get("deployment_env")
|
||||
if recorded and recorded != args.env and not args.force_env:
|
||||
_fail(f"目标库已登记为 {recorded} 环境,与 --env {args.env} 不符。"
|
||||
f"确认无误请加 --force-env")
|
||||
if not recorded:
|
||||
print(f"目标库首次使用,将登记为 {args.env} 环境")
|
||||
|
||||
if args.schema_only or args.dry_run:
|
||||
print("\n[dry-run] schema 已建好,未搬数据。")
|
||||
if args.dry_run:
|
||||
print(" 去掉 --dry-run 即正式搬运。")
|
||||
return
|
||||
|
||||
print("\n开始搬运(单事务,失败自动回滚)…")
|
||||
t0 = time.time()
|
||||
stats, ok = copy_and_verify(args.sqlite, tables, "replace")
|
||||
print_stats(stats)
|
||||
if not ok:
|
||||
_fail("搬运后校验不一致 —— 事务已回滚,目标库保持原样。请把上面的表格发给我排查")
|
||||
print(f"\n搬运完成,用时 {time.time() - t0:.1f}s,逐表校验一致 OK")
|
||||
|
||||
# 4) 写库标签与版本
|
||||
src = sqlite3.connect(_readonly_uri(args.sqlite), uri=True)
|
||||
try:
|
||||
src_id = None
|
||||
try:
|
||||
row = src.execute(
|
||||
"SELECT value FROM app_meta WHERE key='deployment_id'").fetchone()
|
||||
src_id = row[0] if row else None
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
finally:
|
||||
src.close()
|
||||
db_config.meta_set("deployment_env", args.env)
|
||||
db_config.meta_set("deployment_id", src_id or uuid.uuid4().hex)
|
||||
db_config.meta_set("deployment_claimed_at",
|
||||
time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
from core.models import CURRENT_SCHEMA_VERSION
|
||||
db_config.meta_set("schema_version", str(CURRENT_SCHEMA_VERSION))
|
||||
print(f"已登记库环境标签: {args.env}(部署标识 {db_config.meta_get('deployment_id')})")
|
||||
print("\n下一步:把 .env 的 DEPLOY_ENV/DB_* 配好,重启 web_server 即连到新库。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,29 +21,79 @@ import signal
|
||||
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Windows 控制台默认 GBK:汇总里的 ⚠/❌/✅ 会抛 UnicodeEncodeError,
|
||||
# 而且崩在"打印汇总"这一步 —— 看起来像脚本挂了、其实探测已经全跑完
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
|
||||
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
|
||||
os.environ["DISABLE_SCHEDULER"] = "1"
|
||||
|
||||
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
|
||||
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间。
|
||||
# Windows 没有 signal.alarm(会 AttributeError 直接崩),那里就靠调用方自己掐时间。
|
||||
if hasattr(signal, "alarm"):
|
||||
signal.alarm(300)
|
||||
|
||||
from web_server import app # noqa: E402 (导入即完成 app 装配,与生产一致)
|
||||
|
||||
# ================== 安全闸:绝不对生产库跑写操作 ==================
|
||||
# 本脚本会发真实写请求(设备池增删、剪贴板注入、亮灭屏…)。跑在连生产库的配置上
|
||||
# 就是拿正式数据做实验——直接拒绝启动。
|
||||
from core import db_config # noqa: E402
|
||||
|
||||
if db_config.DEPLOY_ENV == "prod":
|
||||
print("=" * 70)
|
||||
print("拒绝运行:当前 .env 声明 DEPLOY_ENV=prod,本脚本会发真实写请求。")
|
||||
print("回归测试请在 dev 环境跑(或先把 .env 指向 dev 库/临时库)。")
|
||||
print("=" * 70)
|
||||
sys.exit(3)
|
||||
with app.app_context():
|
||||
_labeled = db_config.meta_get("deployment_env")
|
||||
if _labeled == "prod":
|
||||
print("=" * 70)
|
||||
print("拒绝运行:目标库 %s 被登记为 prod 环境(app_meta.deployment_env)。"
|
||||
% db_config.describe_target())
|
||||
print("回归测试会写数据,不能对生产库执行。")
|
||||
print("=" * 70)
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
# 流式/二进制接口:无法用普通 GET 断言,跳过(已有单独验证路径)
|
||||
_SKIP_PREFIXES = (
|
||||
"/api/screen/stream", "/api/screen/thumb",
|
||||
"/api/uiauto/screenshot", "/api/device/screenshot",
|
||||
)
|
||||
# 测试用的目标设备:运行期从设备池里挑一台真实的(占位串是池空时的兜底)。
|
||||
# 以前这里写死 Tailscale 时代的 100.100.10.11:5555,设备早换了 → 每次回归都要等
|
||||
# 好几轮 30s 的 adb connect 超时,还会误报"关键 POST 失败"。
|
||||
_TEST_SERIAL = "100.100.10.11:5555"
|
||||
_SERIAL_PH = "@SERIAL@"
|
||||
|
||||
|
||||
def _pick_serial():
|
||||
"""从设备池里挑一台启用的设备当测试目标(池空则返回占位串)。"""
|
||||
try:
|
||||
from core.models import Device, db as _db
|
||||
with app.app_context():
|
||||
d = _db.session.query(Device).filter(Device.enabled.is_(True)).order_by(
|
||||
Device.serial).first()
|
||||
return d.serial if d else _TEST_SERIAL
|
||||
except Exception:
|
||||
return _TEST_SERIAL
|
||||
|
||||
|
||||
# 关键业务 POST:用真实参数验证(这些覆盖了核心链路)
|
||||
_KEY_POSTS = [
|
||||
("一键亮屏", "/api/device/screen_all", {"mode": "on"}),
|
||||
("一键息屏", "/api/device/screen_all", {"mode": "off"}),
|
||||
("剪贴板注入", "/api/tools/clipboard/set",
|
||||
{"serials": ["100.100.10.11:5555"], "text": "回归测试"}),
|
||||
{"serials": [_SERIAL_PH], "text": "回归测试"}),
|
||||
("测试步骤(wait)", "/api/steps/test",
|
||||
{"serial": "100.100.10.11:5555",
|
||||
{"serial": _SERIAL_PH,
|
||||
"step": {"type": "wait", "label": "等待", "params": {"max": 1, "min": 1, "probability": 100}}}),
|
||||
("adb 终端执行", "/api/adb/cmd", {"cmd": "adb devices"}),
|
||||
("adb 红线拦截", "/api/adb/cmd", {"cmd": "adb kill-server"}),
|
||||
@@ -53,10 +103,21 @@ _KEY_POSTS = [
|
||||
_EXPECT_REJECT = {"/api/adb/cmd"}
|
||||
|
||||
|
||||
def _sub_serial(obj):
|
||||
"""把请求体里的 @SERIAL@ 占位替换成运行期选定的设备串(支持嵌套 list/dict)。"""
|
||||
if isinstance(obj, str):
|
||||
return _TEST_SERIAL if obj == _SERIAL_PH else obj
|
||||
if isinstance(obj, list):
|
||||
return [_sub_serial(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sub_serial(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
|
||||
def _fill_path_params(rule):
|
||||
"""把路由路径参数 <xxx> 替换为测试值(serial 用真实设备)。"""
|
||||
url = rule
|
||||
url = url.replace("<serial>", "100.100.10.11:5555")
|
||||
url = url.replace("<serial>", _TEST_SERIAL)
|
||||
url = url.replace("<apk_id>", "x")
|
||||
url = url.replace("<action_id>", "x")
|
||||
url = url.replace("<device_id>", "x")
|
||||
@@ -135,7 +196,12 @@ def main():
|
||||
print(f"写操作路由: {len(write_rules)} 个(空 body 探测)")
|
||||
|
||||
# ========== 3. 关键业务 POST(真实参数) ==========
|
||||
global _TEST_SERIAL
|
||||
_TEST_SERIAL = _pick_serial()
|
||||
print(f"测试目标设备: {_TEST_SERIAL}")
|
||||
for name, url, body in _KEY_POSTS:
|
||||
# 占位符换成运行期挑到的真实设备(含嵌套列表)
|
||||
body = _sub_serial(body)
|
||||
try:
|
||||
r = c.post(url, json=body, headers=H)
|
||||
j = r.get_json() if r.is_json else None
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- ==============================================================================
|
||||
-- auto_control —— MySQL 5.7 环境初始化(建库 + 建账号 + 授权)
|
||||
--
|
||||
-- 用法(在 MySQL 主机上以 root 执行):
|
||||
-- mysql -uroot -p < scripts/sql/init_mysql_5.7.sql
|
||||
--
|
||||
-- 建两个库,与 .env 的 DEPLOY_ENV 一一绑定(core/db_config.py 会强制校验):
|
||||
-- auto_control_dev ← 本地开发机(DEPLOY_ENV=dev)
|
||||
-- auto_control ← 生产 220 容器(DEPLOY_ENV=prod)
|
||||
--
|
||||
-- 字符集固定 utf8mb4 + utf8mb4_bin:
|
||||
-- * utf8mb4:中文/emoji 都不能丢(MySQL 的 "utf8" 是 3 字节残缺版)
|
||||
-- * _bin :逐码点比较(大小写敏感),等价 SQLite 的字节序语义。
|
||||
-- 用默认的 utf8mb4_general_ci 会让 Admin/admin、Phone1/phone1 被判重复,
|
||||
-- 唯一索引与等值查询语义全变。
|
||||
--
|
||||
-- ⚠️ 执行前把下面的 <改我> 换成强口令,并把主机段按实际情况收紧:
|
||||
-- '192.168.20.%' 局域网
|
||||
-- '100.100.10.%' Tailscale(若走 tailnet 连库)
|
||||
-- 220 容器是 host 网络,出口 IP 取决于路由,两条都要覆盖;图省事可用 '%' + 强口令。
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS auto_control
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
CREATE DATABASE IF NOT EXISTS auto_control_dev
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
|
||||
-- 一个专用账号,只授权这两个库(不用 root 跑应用)
|
||||
CREATE USER IF NOT EXISTS 'auto_control'@'%' IDENTIFIED BY '<改我>';
|
||||
GRANT ALL PRIVILEGES ON auto_control.* TO 'auto_control'@'%';
|
||||
GRANT ALL PRIVILEGES ON auto_control_dev.* TO 'auto_control'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 授权说明:
|
||||
-- * 应用需要 DDL 权限(建表 / 补列 / 建生成列与索引),所以给的是 ALL;
|
||||
-- 若你的 DBA 只肯给 DML,就先用下面的「可选」段预建 schema,再只授
|
||||
-- SELECT/INSERT/UPDATE/DELETE。
|
||||
-- * 表结构由平台启动时自动创建(db.create_all + _sync_columns),
|
||||
-- 正常情况下不需要手工建表。
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 可选:只给 DML 权限时,先让平台在别的环境把 schema 建好,再由本脚本导入。
|
||||
-- 也可用迁移脚本的「先建 schema」路径:
|
||||
-- python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --schema-only
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 校验(执行完可以跑一下):
|
||||
-- SELECT schema_name, default_character_set_name, default_collation_name
|
||||
-- FROM information_schema.schemata WHERE schema_name LIKE 'auto_control%';
|
||||
-- SHOW GRANTS FOR 'auto_control'@'%';
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -79,14 +79,19 @@ function renderRestorePreview(p){
|
||||
const rows=(p.tables||[]).map(t=>
|
||||
'<tr><td>'+esc(t.label||t.table)+'</td><td>'+esc(t.table)+'</td><td>'+esc(t.rows)+'</td></tr>').join('');
|
||||
const warns=(p.warnings||[]).map(w=>'<li>'+esc(w)+'</li>').join('');
|
||||
const crossEnv=!!(p.source_env&&p.current_env&&p.source_env!==p.current_env);
|
||||
box.innerHTML=
|
||||
'<table class="table table-hover table-sm" style="max-width:560px">'+
|
||||
'<tbody>'+
|
||||
'<tr><th style="width:120px">文件</th><td>'+esc(p.file_name||'')+'('+(p.file_size!=null?fmtSize(p.file_size):'')+')</td></tr>'+
|
||||
'<tr><th>库结构版本</th><td>备份 v'+esc(p.schema_version)+' / 当前 v'+esc(p.current_schema_version)+'</td></tr>'+
|
||||
'<tr><th>来源 / 当前环境</th><td>'+(p.source_env?esc(p.source_env):'(旧版备份,未记录)')+' → '+esc(p.current_env||'未登记')+'</td></tr>'+
|
||||
'<tr><th>完整性</th><td>'+esc(p.integrity||'')+'</td></tr>'+
|
||||
'</tbody>'+
|
||||
'</table>'+
|
||||
(crossEnv?'<label style="display:block;margin:8px 0;padding:8px 12px;background:#fdecea;border:1px solid #f5c6cb;border-radius:6px">'+
|
||||
'<input type="checkbox" id="restore-force-env"> '+
|
||||
'允许跨环境导入('+esc(p.source_env)+' → '+esc(p.current_env)+')—— 默认拒绝</label>':'')+
|
||||
'<div class="section-title" style="margin-top:8px">表数据行数</div>'+
|
||||
'<table class="table table-hover table-sm" style="max-width:560px"><thead><tr><th>业务</th><th>表</th><th>行数</th></tr></thead><tbody>'+(rows||'<tr><td colspan="3">—</td></tr>')+'</tbody></table>'+
|
||||
(warns?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin-top:8px"><ul style="margin:0;padding-left:18px">'+warns+'</ul></div>':'');
|
||||
@@ -104,10 +109,12 @@ async function applyRestore(){
|
||||
const applyBtn=document.getElementById('btn-apply-restore');
|
||||
const st=document.getElementById('restore-status');
|
||||
if(!_restoreToken)return;
|
||||
if(!confirm('确认应用导入?\n\n系统会先自动备份当前库(data/backups/pre_restore_*.db),再把待导入数据覆盖现有全部数据。\n导入将在重启 web_server 后生效。\n\n确定继续?'))return;
|
||||
const forceEl=document.getElementById('restore-force-env');
|
||||
const force=!!(forceEl&&forceEl.checked);
|
||||
if(!confirm('确认应用导入?\n\n系统会先自动备份当前库(data/backups/pre_restore_*.zip),再把待导入数据覆盖现有全部数据。\n导入将在重启 web_server 后生效。\n\n确定继续?'))return;
|
||||
if(applyBtn)applyBtn.disabled=true;
|
||||
if(st)st.textContent='正在生成恢复任务…';
|
||||
const r=await apiPost('/api/system/backup/apply',{token:_restoreToken});
|
||||
const r=await apiPost('/api/system/backup/apply',{token:_restoreToken,force_env_mismatch:force});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
const box=document.getElementById('restore-preview');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 · 设备自动化后台</title>
|
||||
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}登录 · 设备自动化后台</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设备自动化后台</title>
|
||||
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备自动化后台</title>
|
||||
<style>
|
||||
/* ========== 仪表控制台主题(Instrument Console) ==========
|
||||
自托管字体:Bricolage Grotesque(展示/数字)+ IBM Plex Mono(数据/串号)
|
||||
@@ -46,6 +46,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--primary);box-shadow:0 0 0 0 rgba(15,118,110,.6);animation:brandPulse 2.4s infinite}
|
||||
@keyframes brandPulse{0%{box-shadow:0 0 0 0 rgba(15,118,110,.45)}70%{box-shadow:0 0 0 8px rgba(15,118,110,0)}100%{box-shadow:0 0 0 0 rgba(15,118,110,0)}}
|
||||
.navbar .tabs{display:flex;gap:2px;flex:1;align-self:stretch}
|
||||
.env-badge{margin-right:18px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700;letter-spacing:.5px;background:#2b3038;color:#9aa3af;border:1px solid #3a4048;white-space:nowrap}
|
||||
.env-badge-prod{background:#4a1519;color:#ff9b9b;border-color:#8a2b31}
|
||||
.navbar .tab{position:relative;padding:0 18px;color:#9aa3af;font-size:14px;font-weight:500;cursor:pointer;border:none;background:none;transition:color .15s;letter-spacing:.2px}
|
||||
.navbar .tab::after{content:"";position:absolute;left:14px;right:14px;bottom:0;height:2px;border-radius:2px;background:var(--primary);transform:scaleX(0);transition:transform .18s}
|
||||
.navbar .tab:hover{color:#e5e7eb}
|
||||
@@ -392,6 +394,12 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<!-- 顶部导航 -->
|
||||
<nav class="navbar">
|
||||
<div class="brand"><span class="brand-dot"></span>设备自动化后台</div>
|
||||
{% if db_env %}
|
||||
<span class="env-badge{% if db_env.is_prod %} env-badge-prod{% endif %}"
|
||||
title="数据库目标:{{ db_env.target }}({{ db_env.backend }})">
|
||||
{% if db_env.is_prod %}PROD · {{ db_env.target }}{% else %}{{ db_env.env|upper }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
|
||||
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
|
||||
@@ -930,10 +938,11 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<!-- 子分栏 1:数据备份 -->
|
||||
<div class="sub-panel active" id="system-sub-backup">
|
||||
<div class="help">
|
||||
<b>导出</b>当前系统数据为 zip:含 <code>users.db</code> 在线一致快照(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
|
||||
<b>导出</b>当前系统数据为 zip:把数据库(MySQL 或回退模式的 SQLite)整库一致快照成
|
||||
<code>users.db</code> 归档(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
|
||||
自定义动作 / APK 记录 / 设备池 / 待连接设备 / AI 会话 / 经验库 / 经验巡检 / <b>动作库</b> / 系统配置)+ 可选的
|
||||
<code>apks/</code> 应用文件。恢复用:换机 / 整库迁移 / 出问题前留底。
|
||||
<br><span class="text-muted">清单见预览的「表数据行数」;新增持久化表须登记进备份覆盖清单(红线,见 doc/DEPLOY.md §5.2)。</span>
|
||||
<br><span class="text-muted">清单见预览的「表数据行数」(由模型元数据自动派生,新增表不会漏);备份里记录了来源环境,跨环境导入默认拒绝。</span>
|
||||
</div>
|
||||
<div class="toolbar" style="gap:8px">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
||||
@@ -951,7 +960,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div class="sub-panel" id="system-sub-restore">
|
||||
<div class="help">
|
||||
<b>导入恢复</b>:上传平台导出的 <code>.zip</code> 或 <code>.db</code> → 先<b>校验预览</b> →
|
||||
确认后系统会<b>自动备份当前库</b>并把恢复任务挂起,<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据)。
|
||||
确认后系统会<b>自动备份当前库</b>(<code>data/backups/pre_restore_*.zip</code>,可直接再导入回来)并把恢复任务挂起,
|
||||
<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据;数据替换在单个事务里完成,失败会自动回滚)。
|
||||
<br><span class="text-muted">备份来自另一个环境(dev↔prod)时默认拒绝导入,需在预览页显式勾选「允许跨环境导入」。</span>
|
||||
</div>
|
||||
<div class="toolbar" style="gap:8px">
|
||||
<input type="file" id="restore-file" accept=".zip,.db">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设备监控大屏</title>
|
||||
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备监控大屏</title>
|
||||
<style>
|
||||
/* ========== 监控大屏:深色控制室风格 ========== */
|
||||
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
|
||||
|
||||
+31
-71
@@ -60,14 +60,14 @@ def set_app(app):
|
||||
|
||||
|
||||
def _meta_get(key):
|
||||
return db.session.execute(
|
||||
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() or ""
|
||||
# 走方言中立助手(app_meta 的列名 key 在 MySQL 里是保留字,裸 SQL 会语法错)
|
||||
from core.db_config import meta_get
|
||||
return meta_get(key) or ""
|
||||
|
||||
|
||||
def _meta_put(key, value):
|
||||
db.session.execute(
|
||||
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
|
||||
{"k": key, "v": str(value)})
|
||||
from core.db_config import meta_set
|
||||
meta_set(key, str(value))
|
||||
|
||||
|
||||
def _read_cfg():
|
||||
@@ -75,29 +75,10 @@ def _read_cfg():
|
||||
|
||||
|
||||
# ================== 经验记忆(自进化) ==================
|
||||
# agent_experience:任务成功后的操作配方,下次相似任务检索注入 system prompt。
|
||||
# 原始 SQLite(CREATE IF NOT EXISTS 幂等),不进模型层迁移。
|
||||
_EXP_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS agent_experience (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_prompt TEXT DEFAULT '',
|
||||
recipe TEXT DEFAULT '',
|
||||
tool_seq TEXT DEFAULT '',
|
||||
hits INTEGER DEFAULT 0,
|
||||
created_at VARCHAR(20) DEFAULT '')"""
|
||||
|
||||
# 经验巡检(AI 质检):每日定时把经验交给模型评审,疑似问题标 pending 待人工确认。
|
||||
# 删除只允许人工(前端 confirm / 本 API),巡检绝不自动删。
|
||||
_AUDIT_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS experience_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exp_id INTEGER NOT NULL,
|
||||
verdict TEXT DEFAULT '', -- keep 保留 / delete 建议删除
|
||||
score REAL DEFAULT 0, -- 0-10 可用性评分
|
||||
reason TEXT DEFAULT '', -- 模型给出的理由
|
||||
hits INTEGER DEFAULT 0, -- 巡检时的引用次数(hits>0 被删要更谨慎)
|
||||
action TEXT DEFAULT 'pending', -- pending 待人工确认 / kept 已保留 / deleted 已删除
|
||||
audited_at VARCHAR(20) DEFAULT '')"""
|
||||
# agent_experience / experience_audit / agent_action / agent_conversation 四张表
|
||||
# 已在 core/models.py 里定义为 ORM 模型(唯一真相),建表统一由 db.create_all() 完成。
|
||||
# 以前这里是各写一段 SQLite 的 CREATE TABLE + try/except: pass —— 换 MySQL 后
|
||||
# AUTOINCREMENT / TEXT DEFAULT 全都建不出来,而异常被吞掉,表现为"功能静默失灵"。""
|
||||
|
||||
# 巡检评审提示词:模型只判「保留/建议删除」+ 评分 + 理由,不直接删。
|
||||
_AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存放 AI 成功操作手机后提炼的
|
||||
@@ -119,12 +100,20 @@ _AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存
|
||||
只输出 JSON:{{"keep": true或false, "score": 0到10的整数, "reason": "一句话中文理由"}}"""
|
||||
|
||||
|
||||
def _ensure_exp_table():
|
||||
def _ensure_tables():
|
||||
"""确保 agent 系四张表存在(幂等)。create_all 只建缺失的表,开销可以忽略。
|
||||
|
||||
失败必须落日志:以前是 except: pass,MySQL 下建表失败会被完全吞掉,
|
||||
表不存在 → 后续查询全报错,而日志里什么都看不到。
|
||||
"""
|
||||
try:
|
||||
db.session.execute(db.text(_EXP_TABLE))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
db.create_all()
|
||||
except Exception as e:
|
||||
_log.error(f"agent 表创建失败: {e}")
|
||||
|
||||
|
||||
def _ensure_exp_table():
|
||||
_ensure_tables()
|
||||
|
||||
|
||||
def _bigrams(text):
|
||||
@@ -366,19 +355,7 @@ def _save_experience(prompt, recipe, tool_seq):
|
||||
# steps 直接用编辑器 schema(open_app/click/input_text…),带**元素定位**
|
||||
# (selector_type/selector_value),**不含坐标**(分辨率/旋转/改版即失效)。
|
||||
# 复用:执行前按 name/别名/App 召回并注入 system prompt,模型可跳过重新探索。
|
||||
_ACTION_TABLE = (
|
||||
"CREATE TABLE IF NOT EXISTS agent_action ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"name TEXT NOT NULL,"
|
||||
"app TEXT DEFAULT '',"
|
||||
"aliases TEXT DEFAULT '[]',"
|
||||
"params TEXT DEFAULT '[]',"
|
||||
"steps TEXT NOT NULL,"
|
||||
"preconditions TEXT DEFAULT '',"
|
||||
"hits INTEGER DEFAULT 0,"
|
||||
"source_prompt TEXT DEFAULT '',"
|
||||
"created_at TEXT DEFAULT '',"
|
||||
"updated_at TEXT DEFAULT '')")
|
||||
# 建表见 core/models.py 的 AgentAction
|
||||
|
||||
# 允许沉淀的步骤类型(编辑器 STEP_TYPES 子集;显式排除 click_xy 等坐标类)
|
||||
_ACTION_STEP_TYPES = {
|
||||
@@ -398,11 +375,7 @@ _ACTION_TOOLS = {
|
||||
|
||||
|
||||
def _ensure_action_table():
|
||||
try:
|
||||
db.session.execute(db.text(_ACTION_TABLE))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
_ensure_tables()
|
||||
|
||||
|
||||
def _brief_result(result):
|
||||
@@ -731,11 +704,7 @@ _audit_state = {"running": False, "last": "", "last_summary": ""}
|
||||
|
||||
|
||||
def _ensure_audit_table():
|
||||
try:
|
||||
db.session.execute(db.text(_AUDIT_TABLE))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
_ensure_tables()
|
||||
|
||||
|
||||
def _review_one_exp(cfg, prompt, recipe, tool_seq, hits):
|
||||
@@ -1002,22 +971,11 @@ def agent_actions_save():
|
||||
|
||||
# ================== 历史会话(DeepSeek 式:多会话持久化) ==================
|
||||
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
|
||||
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
|
||||
_CONV_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS agent_conversation (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
title VARCHAR(100) DEFAULT '',
|
||||
messages TEXT DEFAULT '[]',
|
||||
created_at VARCHAR(20) DEFAULT '',
|
||||
updated_at VARCHAR(20) DEFAULT '')"""
|
||||
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。建表见 core/models.py。
|
||||
|
||||
|
||||
def _ensure_conv_table():
|
||||
try:
|
||||
db.session.execute(db.text(_CONV_TABLE))
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
_ensure_tables()
|
||||
|
||||
|
||||
def _conv_msgs(conv_id):
|
||||
@@ -1181,13 +1139,15 @@ def agent_run():
|
||||
try:
|
||||
from datetime import datetime as _dt2
|
||||
now = _dt2.now().strftime("%Y-%m-%d %H:%M")
|
||||
# 上面刚确认过会话不存在,普通 INSERT 即可;
|
||||
# 并发下撞主键就回滚忽略(原来用 SQLite 专有的 INSERT OR IGNORE)
|
||||
db.session.execute(db.text(
|
||||
"INSERT OR IGNORE INTO agent_conversation(id, messages, "
|
||||
"INSERT INTO agent_conversation(id, messages, "
|
||||
"created_at, updated_at) VALUES(:i, '[]', :c, :c)"),
|
||||
{"i": conv_id, "c": now})
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
db.session.rollback()
|
||||
with _lock:
|
||||
if _run["state"] == "running":
|
||||
return jsonify({"ok": False, "error":
|
||||
|
||||
+9
-3
@@ -66,10 +66,16 @@ def api_system_backup_preview():
|
||||
@bp.route("/api/system/backup/apply", methods=["POST"])
|
||||
@admin_required
|
||||
def api_system_backup_apply():
|
||||
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。"""
|
||||
token = (request.json or {}).get("token", "")
|
||||
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。
|
||||
|
||||
force_env_mismatch 默认 False:备份来源环境与当前库环境不符时拒绝导入,
|
||||
要跨环境(如把生产数据导进 dev 库)必须显式传 true。
|
||||
"""
|
||||
body = request.json or {}
|
||||
token = body.get("token", "")
|
||||
force = bool(body.get("force_env_mismatch", False))
|
||||
try:
|
||||
res = sb.apply_restore(str(token).strip())
|
||||
res = sb.apply_restore(str(token).strip(), force_env_mismatch=force)
|
||||
except sb.BackupError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
except Exception as e:
|
||||
|
||||
+33
-5
@@ -33,24 +33,52 @@ 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)
|
||||
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
|
||||
|
||||
# 消费「待生效的备份恢复」:若存在 data/restore_pending/users.db,在 engine 首次
|
||||
# 打开数据库之前换位。导入必须重启生效——TaskManager 启动时读库入内存,
|
||||
# Windows 无法热替换正被持有的库文件。
|
||||
|
||||
def _consume_pending_restore():
|
||||
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
|
||||
|
||||
必须在 init_db 之后调用:现在是「单事务整库替换」而不是换文件,
|
||||
要先有建好的表和 app context。导入仍然要重启才生效——因为 TaskManager /
|
||||
device_pool 把任务与设备缓存在内存里,整库替换后内存副本全部失效。
|
||||
"""
|
||||
try:
|
||||
from core.system_backup import consume_pending_restore
|
||||
with app.app_context():
|
||||
consume_pending_restore()
|
||||
except Exception as _e:
|
||||
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
|
||||
|
||||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||||
init_db(app)
|
||||
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
|
||||
_consume_pending_restore()
|
||||
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
|
||||
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
|
||||
try:
|
||||
with app.app_context(): # db.engine / app_meta 读取都需要 app context
|
||||
db_config.verify_deployment_label()
|
||||
db_config.print_banner()
|
||||
# 页面顶部环境徽标(防混库第 4 层:点开任何页面都能看出连的是哪个库)
|
||||
app.jinja_env.globals["db_env"] = db_config.env_badge()
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user