New Database

This commit is contained in:
喆OvO
2026-08-11 22:07:55 +08:00
parent cbbb2e6d34
commit ceb4404c6a
28 changed files with 1825 additions and 768 deletions
+263
View File
@@ -0,0 +1,263 @@
"""Database administration: export / import / format detection.
Only Root may call these (enforced at the route layer). All operations are
atomic: an import runs inside a single transaction — any failing row rolls the
whole import back, leaving the current database untouched.
Formats:
- ``sqlite`` — a legacy SQLite database file (magic bytes ``SQLite format 3``).
Tables not present in the current SCHEMA are dropped (e.g. camera_config);
the legacy single-user `auth_config` row (keyed by id=1) is attributed to
Root (user 0); missing columns are filled with their SCHEMA DEFAULT.
- ``json`` — the self-contained backup produced by :func:`export_database`
(``{"format": "iot-dboard-backup", ...}``).
"""
import json
import os
import sqlite3
import tempfile
from datetime import datetime, timezone
from backend.models import (
get_db, SCHEMA, DB_VERSION, _CREATE_TABLE_RE, _table_columns,
_read_db_version, _ensure_defaults, _fix_identity_sequences,
)
# Tables whose contents are exported/imported (everything except the schema
# version row and transient session tokens).
_EXCLUDE_TABLES = {"meta", "auth_tokens"}
def _schema_table_names() -> list:
return [m.group(1) for m in _CREATE_TABLE_RE.finditer(SCHEMA)]
def detect_format(data: bytes) -> str:
"""Detect the database file format: 'sqlite', 'json' or 'unknown'."""
if data[:16] == b"SQLite format 3\x00":
return "sqlite"
try:
obj = json.loads(data.decode("utf-8"))
if isinstance(obj, dict) and obj.get("format") == "iot-dboard-backup":
return "json"
except Exception:
pass
return "unknown"
def export_database(db_path: str) -> dict:
"""Snapshot every data table (except auth_tokens) into a JSON-serializable dict."""
conn = get_db(db_path)
try:
tables = _schema_table_names()
out = {
"format": "iot-dboard-backup",
"backup_version": 1,
"db_version": _read_db_version(conn) or DB_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"tables": {},
}
for t in tables:
if t in _EXCLUDE_TABLES:
continue
cols = _table_columns(conn, t)
if not cols:
continue
rows = conn.execute(
f"SELECT {', '.join(cols)} FROM {t} ORDER BY 1"
).fetchall()
out["tables"][t] = {
"columns": cols,
"rows": [[r[c] for c in cols] for r in rows],
}
return out
finally:
conn.close()
def export_database_json(db_path: str) -> str:
"""Export as a compact JSON string (files are small; no pretty printing)."""
return json.dumps(export_database(db_path), ensure_ascii=False, default=str)
# ─── SQLite import ───────────────────────────
def _sqlite_tables(conn_old) -> list:
return [
r["name"] for r in conn_old.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
]
def _sqlite_rows(conn_old, table: str, cols: list) -> list:
if not cols:
return []
col_str = ", ".join(cols)
return conn_old.execute(f"SELECT {col_str} FROM {table}").fetchall()
def _read_sqlite(data: bytes) -> dict:
"""Read a legacy SQLite file into {table: {"columns": [...], "rows": [...]}}.
auth_config special case: legacy single-user row (id=1) belongs to Root."""
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".db")
with os.fdopen(fd, "wb") as f:
f.write(data)
conn_old = sqlite3.connect(f"file:{tmp_path}?mode=ro", uri=True)
conn_old.row_factory = sqlite3.Row
try:
tables = {}
for t in _sqlite_tables(conn_old):
cols = [r["name"] for r in conn_old.execute(f"PRAGMA table_info({t})").fetchall()]
if not cols:
continue
rows = []
for row in _sqlite_rows(conn_old, t, cols):
rows.append([row[c] for c in cols])
tables[t] = {"columns": cols, "rows": rows}
return tables
finally:
conn_old.close()
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
def _import_table(conn, table: str, data: dict, where: str, skipped: list) -> int:
"""Insert rows of one table into the target PG database (strict mode).
Returns the number of rows inserted."""
src_cols = data.get("columns") or []
rows = data.get("rows") or []
if not src_cols or not rows:
return 0
dst_cols = _table_columns(conn, table)
if not dst_cols:
skipped.append(table) # table not in schema — dropped (e.g. camera_config)
return 0
if table == "auth_config":
return _import_auth_config(conn, data, where)
# Only common columns are copied; missing target columns get their DEFAULT.
common = [c for c in dst_cols if c in src_cols]
if not common:
skipped.append(table)
return 0
col_str = ", ".join(common)
placeholders = ", ".join("?" * len(common))
sql = f"INSERT INTO {table} ({col_str}) VALUES ({placeholders})"
n = 0
for i, row in enumerate(rows):
vals = []
for c in common:
vals.append(row[src_cols.index(c)])
try:
conn.execute(sql, tuple(vals))
n += 1
except Exception as e:
raise ValueError(
f"{where}: 表 {table} 第 {i + 1} 行导入失败: {e}"
) from e
return n
def _import_auth_config(conn, data: dict, where: str) -> int:
"""Special case: the legacy auth_config table is keyed by `id` (single user,
id=1); the new table is keyed by `user_id`. The legacy row belongs to Root."""
src_cols = data.get("columns") or []
rows = data.get("rows") or []
if not src_cols or not rows:
return 0
dst_cols = _table_columns(conn, "auth_config")
n = 0
for i, row in enumerate(rows):
d = {c: row[src_cols.index(c)] for c in src_cols}
if "user_id" in src_cols:
uid = d.get("user_id") or 0
else:
old_id = d.get("id", 1)
uid = 0 if old_id == 1 else (old_id or 0)
common = [c for c in dst_cols if c in src_cols and c not in ("id", "user_id")]
if not common:
continue
col_str = ", ".join(common)
placeholders = ", ".join("?" * len(common))
sql = f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})"
try:
conn.execute(sql, (uid, *[d[c] for c in common]))
n += 1
except Exception as e:
raise ValueError(
f"{where}: auth_config 第 {i + 1} 行导入失败: {e}"
) from e
return n
# ─── Restore core (shared by sqlite + json imports) ─────
def _restore(db_path: str, tables: dict, where: str) -> dict:
"""Atomic restore: TRUNCATE every data table, insert the given rows, then
re-bootstrap defaults and stamp the schema version. Any failure rolls back
the whole import (the shim rolls back on error, keeping it atomic)."""
conn = get_db(db_path)
stats: dict[str, int] = {}
skipped: list = []
try:
# 1. Wipe current data (TRUNCATE is transactional in PostgreSQL).
all_tables = _schema_table_names()
truncate_list = [t for t in all_tables if t != "meta"]
if truncate_list:
conn.execute("TRUNCATE TABLE " + ", ".join(truncate_list) + " CASCADE")
# 2. Insert data (strict: first failing row aborts everything).
for t in all_tables:
if t in _EXCLUDE_TABLES:
continue
if t in tables:
stats[t] = _import_table(conn, t, tables[t], where, skipped)
# 3. Re-bootstrap defaults (Root user, config rows) + stamp version.
_ensure_defaults(conn)
conn.execute("UPDATE meta SET version=? WHERE id=1", (DB_VERSION,))
conn.execute("INSERT OR IGNORE INTO meta (id, version) VALUES (1, ?)", (DB_VERSION,))
_fix_identity_sequences(conn)
conn.commit()
if skipped:
stats["_skipped_tables"] = skipped
return stats
except Exception:
try:
conn.rollback()
except Exception:
pass
raise
finally:
conn.close()
def import_sqlite(db_path: str, data: bytes) -> dict:
"""Import a legacy SQLite database file (atomic)."""
tables = _read_sqlite(data)
return _restore(db_path, tables, "SQLite 导入")
def import_json(db_path: str, data: dict) -> dict:
"""Import a JSON backup produced by export_database (atomic)."""
if not isinstance(data, dict) or data.get("format") != "iot-dboard-backup":
raise ValueError("不是有效的备份文件(缺少 iot-dboard-backup 标记)")
tables = data.get("tables") or {}
if not isinstance(tables, dict):
raise ValueError("备份文件结构无效:tables 缺失")
return _restore(db_path, tables, "备份导入")
def import_blob(db_path: str, data: bytes) -> dict:
"""Import a file, auto-detecting sqlite vs json backup."""
fmt = detect_format(data)
if fmt == "sqlite":
return import_sqlite(db_path, data)
if fmt == "json":
return import_json(db_path, json.loads(data.decode("utf-8")))
raise ValueError("无法识别的数据库文件格式(仅支持 SQLite 数据库或本系统导出的备份 JSON)")