Files
App/src/storage/database.cpp
HiepLM 50a2587cef
Some checks failed
Test / test (push) Has been cancelled
update User, User group, IO modules
2026-06-23 11:06:26 +07:00

663 lines
18 KiB
C++

#include "storage/database.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <sqlite3.h>
#include <cstdio>
namespace lm {
namespace {
bool execSql(sqlite3* db, const char* sql, std::string& err)
{
char* msg = nullptr;
const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg);
if (rc != SQLITE_OK)
{
err = msg ? msg : sqlite3_errstr(rc);
sqlite3_free(msg);
return false;
}
return true;
}
const char* kSchemaSql = R"SQL(
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
name TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS layout_profiles (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS maps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
site_id TEXT,
created_by TEXT NOT NULL DEFAULT '',
created_by_user TEXT,
created_by_group TEXT,
width REAL,
height REAL,
resolution REAL,
origin_x REAL DEFAULT 0,
origin_y REAL DEFAULT 0,
origin_yaw REAL DEFAULT 0,
image_file TEXT,
yaml_file TEXT,
zones_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS sounds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
file_name TEXT,
duration_ms INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
volume INTEGER NOT NULL DEFAULT 100,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
map_id TEXT,
file_path TEXT,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS transitions (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL,
from_map_id TEXT NOT NULL,
to_map_id TEXT NOT NULL,
start_position_id TEXT NOT NULL,
goal_position_id TEXT NOT NULL,
mission_id TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS io_modules (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL,
name TEXT NOT NULL,
module_type TEXT NOT NULL,
ip_address TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_by_group TEXT NOT NULL DEFAULT '',
connected INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_by_user TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dashboard_edit_groups (
dashboard_id TEXT NOT NULL,
group_id TEXT NOT NULL,
PRIMARY KEY (dashboard_id, group_id),
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id TEXT PRIMARY KEY,
dashboard_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
mission_id TEXT,
mission_group TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboard_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_dashboard_id TEXT
);
)SQL";
} // namespace
Database::Database(std::filesystem::path data_dir)
: data_dir_(std::move(data_dir)), db_path_(data_dir_ / "RBS.db")
{
}
void Database::close()
{
if (db_)
{
sqlite3_close(db_);
db_ = nullptr;
}
}
bool Database::openDb(std::string& err)
{
std::error_code ec;
std::filesystem::create_directories(data_dir_, ec);
const auto legacy_path = data_dir_ / "test3.db";
if (!std::filesystem::exists(db_path_) && std::filesystem::exists(legacy_path))
{
std::filesystem::rename(legacy_path, db_path_, ec);
for (const char* suffix : {"-wal", "-shm"})
{
const auto from = legacy_path.string() + suffix;
const auto to = db_path_.string() + suffix;
if (std::filesystem::exists(from))
std::filesystem::rename(from, to, ec);
}
}
const int rc = sqlite3_open(db_path_.string().c_str(), &db_);
if (rc != SQLITE_OK)
{
err = sqlite3_errmsg(db_);
db_ = nullptr;
return false;
}
sqlite3_busy_timeout(db_, 5000);
if (!execSql(db_, "PRAGMA journal_mode=WAL;", err))
return false;
if (!execSql(db_, "PRAGMA synchronous=NORMAL;", err))
return false;
if (!execSql(db_, "PRAGMA foreign_keys=ON;", err))
return false;
return true;
}
bool Database::ensureDataDirs(std::string& err)
{
std::error_code ec;
for (const auto& dir : {mapsDir(), soundsDir(), recordingsDir()})
{
if (!std::filesystem::create_directories(dir, ec) && ec)
{
err = "failed to create directory: " + dir.string();
return false;
}
}
return true;
}
bool Database::migrateLegacyMapsDir(std::string& err)
{
if (data_dir_.filename() != "data")
return true;
const std::filesystem::path legacy = "maps";
const auto target = mapsDir();
if (!std::filesystem::is_directory(legacy) || legacy == target)
return true;
std::error_code ec;
std::filesystem::create_directories(target, ec);
if (ec)
{
err = "failed to create maps directory: " + target.string();
return false;
}
for (const auto& entry : std::filesystem::directory_iterator(legacy, ec))
{
if (ec || !entry.is_directory())
continue;
const auto dest = target / entry.path().filename();
if (std::filesystem::exists(dest))
continue;
std::filesystem::rename(entry.path(), dest, ec);
if (ec)
{
ec.clear();
std::filesystem::copy(entry.path(), dest, std::filesystem::copy_options::recursive, ec);
if (ec)
{
err = "failed to migrate map directory: " + entry.path().string();
return false;
}
std::filesystem::remove_all(entry.path(), ec);
ec.clear();
}
}
return true;
}
bool Database::applySchema(std::string& err)
{
return execSql(db_, kSchemaSql, err);
}
namespace {
bool tableHasColumn(sqlite3* db, const char* table, const char* column)
{
std::string sql = "PRAGMA table_info(";
sql += table;
sql += ")";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return false;
bool found = false;
while (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
if (name && column == std::string(name))
{
found = true;
break;
}
}
sqlite3_finalize(stmt);
return found;
}
} // namespace
bool Database::applySchemaMigrations(std::string& err)
{
std::string ver = getMeta("schema_version").value_or("1");
if (ver == "1")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS sites ("
"id TEXT PRIMARY KEY, name TEXT NOT NULL, "
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
err))
return false;
if (!tableHasColumn(db_, "maps", "site_id"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN site_id TEXT", err))
return false;
}
if (!tableHasColumn(db_, "maps", "created_by"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
return false;
}
const std::string now = IdUtil::nowIso8601();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT OR IGNORE INTO sites(id, name, created_at, updated_at) "
"VALUES('site_configuration', 'ConfigurationSite', ?1, ?1)",
-1,
&stmt,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(stmt, 1, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
if (sqlite3_prepare_v2(db_,
"UPDATE maps SET site_id = 'site_configuration' WHERE site_id IS NULL OR site_id = ''",
-1,
&stmt,
nullptr) == SQLITE_OK)
{
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
setMeta("schema_version", "2");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "2")
{
if (!tableHasColumn(db_, "maps", "created_by_user"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_user TEXT", err))
return false;
}
if (!tableHasColumn(db_, "maps", "created_by_group"))
{
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_group TEXT", err))
return false;
}
setMeta("schema_version", "3");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "3")
{
if (!tableHasColumn(db_, "sounds", "volume"))
{
if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN volume INTEGER NOT NULL DEFAULT 100", err))
return false;
}
if (!tableHasColumn(db_, "sounds", "is_system"))
{
if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0", err))
return false;
}
setMeta("schema_version", "4");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "4")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS transitions ("
"id TEXT PRIMARY KEY, "
"site_id TEXT NOT NULL, "
"from_map_id TEXT NOT NULL, "
"to_map_id TEXT NOT NULL, "
"start_position_id TEXT NOT NULL, "
"goal_position_id TEXT NOT NULL, "
"mission_id TEXT NOT NULL, "
"created_by TEXT NOT NULL DEFAULT '', "
"created_at TEXT NOT NULL, "
"updated_at TEXT NOT NULL"
")",
err))
return false;
setMeta("schema_version", "5");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "5")
{
if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
return false;
setMeta("schema_version", "6");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "6")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS io_modules ("
"id TEXT PRIMARY KEY, "
"site_id TEXT NOT NULL, "
"name TEXT NOT NULL, "
"module_type TEXT NOT NULL, "
"ip_address TEXT NOT NULL, "
"created_by TEXT NOT NULL DEFAULT '', "
"created_by_group TEXT NOT NULL DEFAULT '', "
"connected INTEGER NOT NULL DEFAULT 0, "
"created_at TEXT NOT NULL, "
"updated_at TEXT NOT NULL, "
"FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE"
")",
err))
return false;
setMeta("schema_version", "7");
}
return true;
}
std::optional<std::string> Database::getMeta(const std::string& key) const
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT value FROM meta WHERE key = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
std::optional<std::string> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* val = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (val)
out = val;
}
sqlite3_finalize(stmt);
return out;
}
bool Database::setMeta(const std::string& key, const std::string& value)
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO meta(key, value) VALUES(?1, ?2) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::getDocument(const std::string& name, nlohmann::json& out) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT content FROM documents WHERE name = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
bool found = false;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (text)
{
try
{
out = nlohmann::json::parse(text);
found = true;
}
catch (...)
{
found = false;
}
}
}
sqlite3_finalize(stmt);
return found;
}
bool Database::setDocument(const std::string& name, const nlohmann::json& doc)
{
std::lock_guard<std::mutex> lock(mu_);
const std::string now = IdUtil::nowIso8601();
const std::string body = doc.dump();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO documents(name, content, updated_at) VALUES(?1, ?2, ?3) "
"ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
std::optional<nlohmann::json> Database::getLayoutProfile(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT content FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
{
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (text)
{
try
{
out = nlohmann::json::parse(text);
}
catch (...)
{
out = std::nullopt;
}
}
}
sqlite3_finalize(stmt);
return out;
}
bool Database::setLayoutProfile(const nlohmann::json& profile)
{
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
return false;
const std::string id = profile["id"].get<std::string>();
const std::string now = IdUtil::nowIso8601();
const std::string body = profile.dump();
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO layout_profiles(id, content, updated_at) VALUES(?1, ?2, ?3) "
"ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
-1,
&stmt,
nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::deleteLayoutProfile(const std::string& id)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok;
}
bool Database::migrateFromJsonIfNeeded(std::string& err)
{
if (getMeta("json_imported"))
return true;
const auto importDoc = [&](const std::string& name, const std::filesystem::path& path) {
if (!std::filesystem::exists(path))
return;
try
{
const auto doc = nlohmann::json::parse(FileUtil::readBinary(path));
setDocument(name, doc);
}
catch (...)
{
}
};
importDoc("auth", data_dir_ / "auth.json");
importDoc("missions", data_dir_ / "missions.json");
importDoc("mission_queue", data_dir_ / "mission_queue.json");
importDoc("robot_runtime", data_dir_ / "robot_runtime.json");
const auto state_path = data_dir_ / "state.json";
if (std::filesystem::exists(state_path))
{
try
{
setDocument("state", nlohmann::json::parse(FileUtil::readBinary(state_path)));
}
catch (...)
{
}
}
const auto models_dir = data_dir_ / "models";
if (std::filesystem::is_directory(models_dir))
{
for (const auto& entry : std::filesystem::directory_iterator(models_dir))
{
if (!entry.is_regular_file() || entry.path().extension() != ".json")
continue;
try
{
const auto profile = nlohmann::json::parse(FileUtil::readBinary(entry.path()));
setLayoutProfile(profile);
}
catch (...)
{
}
}
}
setMeta("schema_version", "1");
setMeta("json_imported", IdUtil::nowIso8601());
std::fprintf(stderr, "SQLite: imported JSON data into %s\n", db_path_.string().c_str());
return true;
}
bool Database::init(std::string& err)
{
if (!openDb(err))
return false;
if (!applySchema(err))
return false;
if (!applySchemaMigrations(err))
return false;
if (!ensureDataDirs(err))
return false;
if (!migrateLegacyMapsDir(err))
return false;
if (!migrateFromJsonIfNeeded(err))
return false;
if (!getMeta("schema_version"))
setMeta("schema_version", "1");
return true;
}
} // namespace lm