Files
msime-windows/server/src/statistics/stats_store.cpp
T
Jia Sui ffcd1aee28 feat: 输入统计(DLL 采集、Server 聚合存储与设置页展示)
- windows: 三上屏出口 + OnTestKeyDown 直通字符采集,修复半角数字恒零
- engine: 统计 Web 消息契约(statsRequest/statsResponse)
- server: 统计管道接收、聚合、SQLite 存储、保留策略与每日自动清理
- ui-html: 设置页「统计」模块(卡片、热图、明细、开关、清理)
- docs: PRIVACY / README 改为内置可选统计
2026-09-20 19:08:16 +08:00

663 lines
19 KiB
C++

#include "statistics/stats_store.h"
#include "engine/core/data_path.h"
#include <sqlite3.h>
#include <cstdlib>
namespace MsimeStats
{
namespace
{
const char *kSchema = R"SQL(
CREATE TABLE IF NOT EXISTS stats_daily(
day_key INTEGER PRIMARY KEY,
cjk INTEGER NOT NULL DEFAULT 0,
latin INTEGER NOT NULL DEFAULT 0,
digit INTEGER NOT NULL DEFAULT 0,
punct INTEGER NOT NULL DEFAULT 0,
other INTEGER NOT NULL DEFAULT 0,
active_ms INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS stats_hourly(
day_key INTEGER NOT NULL,
hour INTEGER NOT NULL,
chars INTEGER NOT NULL DEFAULT 0,
active_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day_key, hour));
CREATE TABLE IF NOT EXISTS stats_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL);
)SQL";
const char *kUpsertDailySql =
"INSERT INTO stats_daily(day_key, cjk, latin, digit, punct, other, active_ms) "
"VALUES(?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(day_key) DO UPDATE SET "
"cjk = cjk + excluded.cjk, latin = latin + excluded.latin, digit = digit + excluded.digit, "
"punct = punct + excluded.punct, other = other + excluded.other, "
"active_ms = active_ms + excluded.active_ms";
const char *kUpsertHourlySql = "INSERT INTO stats_hourly(day_key, hour, chars, active_ms) "
"VALUES(?, ?, ?, ?) "
"ON CONFLICT(day_key, hour) DO UPDATE SET "
"chars = chars + excluded.chars, active_ms = active_ms + excluded.active_ms";
std::string SqliteMessage(sqlite3 *db)
{
return db != nullptr ? std::string(sqlite3_errmsg(db)) : std::string("sqlite handle is null");
}
std::string Describe(const char *what, sqlite3 *db)
{
return std::string("stats store: ") + what + ": " + SqliteMessage(db);
}
} // namespace
bool ParseRetention(const std::string &value, Retention &out)
{
if (value == "30d")
{
out = Retention::Days30;
}
else if (value == "90d")
{
out = Retention::Days90;
}
else if (value == "180d")
{
out = Retention::Days180;
}
else if (value == "365d")
{
out = Retention::Days365;
}
else if (value == "forever")
{
out = Retention::Forever;
}
else
{
return false;
}
return true;
}
bool RetentionCutoff(Retention retention, int today_day_key, int &cutoff_day_key)
{
const int days = static_cast<int>(retention);
if (days <= 0)
{
return false;
}
cutoff_day_key = AddDays(today_day_key, -(days - 1));
return true;
}
Store::Store(sqlite3 *db, std::filesystem::path path) : db_(db), path_(std::move(path))
{
}
Store::~Store()
{
if (db_ != nullptr)
{
sqlite3_close(db_);
db_ = nullptr;
}
}
std::unique_ptr<Store> Store::Open(const std::filesystem::path &path, std::string &error)
{
// A UTF-8 path keeps SQLite on its own UTF-16 conversion; path::string()
// would go through the ANSI code page and corrupt non-ASCII profile paths.
const std::string utf8_path = metasequoia::path_to_utf8(path);
sqlite3 *raw = nullptr;
if (sqlite3_open_v2(utf8_path.c_str(), &raw, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX,
nullptr) != SQLITE_OK)
{
error = raw != nullptr ? SqliteMessage(raw) : std::string("stats store: unable to open database");
if (raw != nullptr)
{
sqlite3_close(raw);
}
return nullptr;
}
std::unique_ptr<Store> store(new Store(raw, path));
if (!store->Initialize(error))
{
return nullptr;
}
return store;
}
bool Store::Initialize(std::string &error)
{
const char *pragmas[] = {"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA busy_timeout=5000"};
for (const char *pragma : pragmas)
{
if (!Exec(pragma, error))
{
return false;
}
}
if (!Exec(kSchema, error))
{
return false;
}
return SetMeta(kStoreSchemaVersion, kStoreSchemaVersion, error);
}
bool Store::Exec(const char *sql, std::string &error) const
{
char *message = nullptr;
if (sqlite3_exec(db_, sql, nullptr, nullptr, &message) != SQLITE_OK)
{
error = message != nullptr ? std::string(message) : SqliteMessage(db_);
sqlite3_free(message);
return false;
}
return true;
}
bool Store::BeginTransaction(std::string &error)
{
// BEGIN IMMEDIATE instead of a deferred transaction: the database is shared
// between two processes, and a deferred transaction that only takes the
// write lock at its first INSERT can fail with SQLITE_BUSY without the busy
// handler having been able to wait.
return Exec("BEGIN IMMEDIATE", error);
}
void Store::Rollback()
{
std::string ignored;
Exec("ROLLBACK", ignored);
}
bool Store::Commit(std::string &error)
{
if (!Exec("COMMIT", error))
{
Rollback();
return false;
}
return true;
}
bool Store::RefreshFirstDay(std::string &error)
{
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT MIN(day_key) FROM stats_daily", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("read first_day", db_);
return false;
}
int64_t minimum = 0;
bool present = false;
const int step = sqlite3_step(statement);
if (step == SQLITE_ROW)
{
if (sqlite3_column_type(statement, 0) != SQLITE_NULL)
{
minimum = sqlite3_column_int64(statement, 0);
present = true;
}
}
else if (step != SQLITE_DONE)
{
error = Describe("read first_day", db_);
sqlite3_finalize(statement);
return false;
}
sqlite3_finalize(statement);
if (!present)
{
return Exec("DELETE FROM stats_meta WHERE key = 'first_day'", error);
}
return WriteMeta(kStoreMetaFirstDay, std::to_string(minimum), error);
}
bool Store::WriteMeta(const std::string &key, const std::string &value, std::string &error)
{
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO stats_meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
-1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("write meta", db_);
return false;
}
sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement, 2, value.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("write meta", db_);
}
sqlite3_finalize(statement);
return ok;
}
bool Store::DeleteMetaRow(const std::string &key, std::string &error)
{
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM stats_meta WHERE key = ?", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("delete meta", db_);
return false;
}
sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("delete meta", db_);
}
sqlite3_finalize(statement);
return ok;
}
bool Store::Upsert(const Batch &batch, std::string &error)
{
if (batch.Empty())
{
return true;
}
std::lock_guard<std::mutex> lock(mutex_);
if (!BeginTransaction(error))
{
return false;
}
bool ok = true;
if (!batch.days.empty())
{
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, kUpsertDailySql, -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("prepare daily upsert", db_);
ok = false;
}
else
{
for (const DayDelta &day : batch.days)
{
sqlite3_bind_int64(statement, 1, day.day_key);
sqlite3_bind_int64(statement, 2, day.cjk);
sqlite3_bind_int64(statement, 3, day.latin);
sqlite3_bind_int64(statement, 4, day.digit);
sqlite3_bind_int64(statement, 5, day.punct);
sqlite3_bind_int64(statement, 6, day.other);
sqlite3_bind_int64(statement, 7, day.active_ms);
if (sqlite3_step(statement) != SQLITE_DONE)
{
error = Describe("write daily", db_);
ok = false;
break;
}
sqlite3_reset(statement);
}
sqlite3_finalize(statement);
}
}
if (ok && !batch.hours.empty())
{
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, kUpsertHourlySql, -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("prepare hourly upsert", db_);
ok = false;
}
else
{
for (const HourDelta &hour : batch.hours)
{
sqlite3_bind_int64(statement, 1, hour.day_key);
sqlite3_bind_int64(statement, 2, hour.hour);
sqlite3_bind_int64(statement, 3, hour.chars);
sqlite3_bind_int64(statement, 4, hour.active_ms);
if (sqlite3_step(statement) != SQLITE_DONE)
{
error = Describe("write hourly", db_);
ok = false;
break;
}
sqlite3_reset(statement);
}
sqlite3_finalize(statement);
}
}
if (ok)
{
ok = RefreshFirstDay(error);
}
if (!ok)
{
Rollback();
return false;
}
return Commit(error);
}
bool Store::DailyRows(std::vector<DailyRow> &rows, std::string &error) const
{
std::lock_guard<std::mutex> lock(mutex_);
rows.clear();
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_,
"SELECT day_key, cjk, latin, digit, punct, other, active_ms FROM stats_daily "
"ORDER BY day_key",
-1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("query daily", db_);
return false;
}
int step = SQLITE_OK;
while ((step = sqlite3_step(statement)) == SQLITE_ROW)
{
DailyRow row;
row.day_key = static_cast<int>(sqlite3_column_int64(statement, 0));
row.cjk = sqlite3_column_int64(statement, 1);
row.latin = sqlite3_column_int64(statement, 2);
row.digit = sqlite3_column_int64(statement, 3);
row.punct = sqlite3_column_int64(statement, 4);
row.other = sqlite3_column_int64(statement, 5);
row.active_ms = sqlite3_column_int64(statement, 6);
rows.push_back(row);
}
const bool ok = step == SQLITE_DONE;
if (!ok)
{
error = Describe("query daily", db_);
rows.clear();
}
sqlite3_finalize(statement);
return ok;
}
bool Store::HourlyRows(int day_key, std::vector<HourlyRow> &rows, std::string &error) const
{
std::lock_guard<std::mutex> lock(mutex_);
rows.clear();
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_,
"SELECT day_key, hour, chars, active_ms FROM stats_hourly WHERE day_key = ? "
"ORDER BY hour",
-1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("query hourly", db_);
return false;
}
sqlite3_bind_int64(statement, 1, day_key);
int step = SQLITE_OK;
while ((step = sqlite3_step(statement)) == SQLITE_ROW)
{
HourlyRow row;
row.day_key = static_cast<int>(sqlite3_column_int64(statement, 0));
row.hour = static_cast<int>(sqlite3_column_int64(statement, 1));
row.chars = sqlite3_column_int64(statement, 2);
row.active_ms = sqlite3_column_int64(statement, 3);
rows.push_back(row);
}
const bool ok = step == SQLITE_DONE;
if (!ok)
{
error = Describe("query hourly", db_);
rows.clear();
}
sqlite3_finalize(statement);
return ok;
}
bool Store::Meta(const std::string &key, std::string &value, bool &present, std::string &error) const
{
std::lock_guard<std::mutex> lock(mutex_);
value.clear();
present = false;
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "SELECT value FROM stats_meta WHERE key = ?", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("read meta", db_);
return false;
}
sqlite3_bind_text(statement, 1, key.c_str(), -1, SQLITE_TRANSIENT);
const int step = sqlite3_step(statement);
if (step == SQLITE_ROW)
{
const unsigned char *text = sqlite3_column_text(statement, 0);
value = text != nullptr ? reinterpret_cast<const char *>(text) : std::string();
present = true;
}
else if (step != SQLITE_DONE)
{
error = Describe("read meta", db_);
sqlite3_finalize(statement);
return false;
}
sqlite3_finalize(statement);
return true;
}
bool Store::SetMeta(const std::string &key, const std::string &value, std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
if (!BeginTransaction(error))
{
return false;
}
if (!WriteMeta(key, value, error))
{
Rollback();
return false;
}
return Commit(error);
}
bool Store::ClearThrough(int keep_from_day_key, int64_t &removed_days, std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
removed_days = 0;
if (!BeginTransaction(error))
{
return false;
}
bool ok = true;
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM stats_daily WHERE day_key < ?", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("clear daily", db_);
ok = false;
}
else
{
sqlite3_bind_int64(statement, 1, keep_from_day_key);
ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("clear daily", db_);
}
else
{
removed_days = sqlite3_changes(db_);
}
sqlite3_finalize(statement);
}
if (ok)
{
statement = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM stats_hourly WHERE day_key < ?", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("clear hourly", db_);
ok = false;
}
else
{
sqlite3_bind_int64(statement, 1, keep_from_day_key);
ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("clear hourly", db_);
}
sqlite3_finalize(statement);
}
}
if (ok)
{
ok = RefreshFirstDay(error);
}
if (!ok)
{
Rollback();
return false;
}
return Commit(error);
}
bool Store::ClearAll(int64_t &removed_days, std::string &error)
{
std::lock_guard<std::mutex> lock(mutex_);
removed_days = 0;
if (!BeginTransaction(error))
{
return false;
}
// Unconditional deletes, not ClearThrough(0): day keys are positive, so a
// "day_key < 0" predicate would remove nothing.
bool ok = true;
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM stats_daily", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("clear all daily", db_);
ok = false;
}
else
{
ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("clear all daily", db_);
}
else
{
removed_days = sqlite3_changes(db_);
}
sqlite3_finalize(statement);
}
if (ok)
{
statement = nullptr;
if (sqlite3_prepare_v2(db_, "DELETE FROM stats_hourly", -1, &statement, nullptr) != SQLITE_OK)
{
error = Describe("clear all hourly", db_);
ok = false;
}
else
{
ok = sqlite3_step(statement) == SQLITE_DONE;
if (!ok)
{
error = Describe("clear all hourly", db_);
}
sqlite3_finalize(statement);
}
}
if (ok)
{
// RefreshFirstDay deletes the empty first_day key, so the overview
// falls back to the empty state.
ok = RefreshFirstDay(error);
}
if (ok)
{
// The retention marker goes too: after a wipe there is nothing left to
// keep, and the next write may start a fresh once-per-day cycle.
ok = DeleteMetaRow(kStoreMetaLastRetentionDay, error);
}
if (!ok)
{
Rollback();
return false;
}
return Commit(error);
}
bool RunDailyRetention(Store &store, Retention retention, int today_day_key, RetentionOutcome &outcome,
std::string &error)
{
outcome = RetentionOutcome{};
std::string marker;
bool present = false;
if (!store.Meta(kStoreMetaLastRetentionDay, marker, present, error))
{
return false;
}
int last_day_key = 0;
if (present)
{
char *end = nullptr;
const long long parsed = std::strtoll(marker.c_str(), &end, 10);
// A malformed marker falls back to 0: the cleanup then runs once and
// rewrites the key, which repairs a hand-edited database instead of
// stalling the policy forever.
if (!marker.empty() && end != marker.c_str() && *end == '\0' && parsed > 0)
{
last_day_key = static_cast<int>(parsed);
}
}
if (last_day_key == today_day_key)
{
outcome.already_ran_today = true;
return true;
}
int cutoff_day_key = 0;
if (RetentionCutoff(retention, today_day_key, cutoff_day_key))
{
if (!store.ClearThrough(cutoff_day_key, outcome.removed_days, error))
{
return false;
}
}
// Forever still writes the marker: the once-per-day check is about the
// day, not about the policy, so a later switch to a finite window still
// cleans up on the next day's first write.
return store.SetMeta(kStoreMetaLastRetentionDay, std::to_string(today_day_key), error);
}
bool Store::FirstDay(int &day_key, bool &present, std::string &error) const
{
std::string value;
if (!Meta(kStoreMetaFirstDay, value, present, error))
{
return false;
}
day_key = 0;
if (!present)
{
return true;
}
char *end = nullptr;
const long long parsed = std::strtoll(value.c_str(), &end, 10);
if (value.empty() || end == value.c_str() || *end != '\0')
{
error = "stats store: invalid first_day value \"" + value + "\"";
present = false;
return false;
}
day_key = static_cast<int>(parsed);
return true;
}
} // namespace MsimeStats