* feat(engine): webview 契约给统计概览增加首词命中率字段并支持 null 分支 * feat(server,ui-html): 统计新增首词命中率(决策点记录、stats v2 迁移与设置页展示) * fix(server): 首词命中计数补日语标点排除与并发迁移复查 * fix(ui-html): 统计卡片改用自适应列宽,避免长数字被省略号截断 * fix(server,installer): 修复首词命中率随附测试在 CI 的失败 三条 server 用例是首词命中率改动新加、但从未跑绿:RawDb 在迁移用例里对尚不存在的库开只读写连接,补上 SQLITE_OPEN_CREATE;09-19 写了一次次选,按契约它是一次真实测量(0%),断言却要求 nullopt,改为断言 0.0,并把 nullopt 覆盖移到迁移用例(total==0 的那天)。安装器 preserve-user-data 从 stats_pipe.cpp 取库名,而 EnsureStatsStoreOpen 连同 stats.db 字面量已挪到 stats_candidate_capture.cpp,改到新位置。 * fix(server,ui-html): 首词命中率写库移出按键路径,修正迁移与统计口径 - 选词计数改由后台 SerialTaskRunner 写库,标点收尾与选词路径不再同步等 SQLite 锁 - 迁移以表结构为准,不再被版本号挡住;缺列时在 IMMEDIATE 事务里逐列补齐 - 标点在日期入口上收尾时按实际上屏的首条候选计数 - 五笔四码唯一自动上屏不计入;多段造词任一段非首位即不算命中 - 只有选词计数的日线不算打字日,不影响天数、日均与连续天数 - 卡片复用 formatFirstWordRatio 并展示累计命中率,概览复用 DailyRow::FirstWordRatio --------- Co-authored-by: fanlusky <1730976608@qq.com>
146 lines
5.1 KiB
C++
146 lines
5.1 KiB
C++
#pragma once
|
|
|
|
#include "schema.h"
|
|
#include <boost/json.hpp>
|
|
#include <cmath>
|
|
#include <string_view>
|
|
|
|
namespace metasequoia::webview
|
|
{
|
|
namespace detail
|
|
{
|
|
inline bool Matches(const boost::json::value &value, const boost::json::object &rule)
|
|
{
|
|
if (const auto *branches = rule.if_contains("anyOf"))
|
|
{
|
|
for (const auto &branch : branches->as_array())
|
|
if (Matches(value, branch.as_object()))
|
|
return true;
|
|
return false;
|
|
}
|
|
if (const auto *type = rule.if_contains("type"))
|
|
{
|
|
const auto &name = type->as_string();
|
|
if (name == "array")
|
|
{
|
|
if (!value.is_array())
|
|
return false;
|
|
for (const auto &item : value.as_array())
|
|
if (!Matches(item, rule.at("items").as_object()))
|
|
return false;
|
|
}
|
|
else if (name == "object")
|
|
{
|
|
if (!value.is_object())
|
|
return false;
|
|
const auto &object = value.as_object();
|
|
const auto &properties = rule.at("properties").as_object();
|
|
for (const auto &key : rule.at("required").as_array())
|
|
if (!object.contains(key.as_string()))
|
|
return false;
|
|
for (const auto &item : object)
|
|
{
|
|
if (const auto *property = properties.if_contains(item.key()))
|
|
{
|
|
if (!Matches(item.value(), property->as_object()))
|
|
return false;
|
|
}
|
|
else if (!rule.at("additionalProperties").as_bool())
|
|
return false;
|
|
}
|
|
}
|
|
else if (name == "string" && !value.is_string())
|
|
return false;
|
|
else if (name == "boolean" && !value.is_bool())
|
|
return false;
|
|
else if (name == "null" && !value.is_null())
|
|
return false;
|
|
else if (name == "number" || name == "integer")
|
|
{
|
|
if (!value.is_number())
|
|
return false;
|
|
const double number = value.to_number<double>();
|
|
if (!std::isfinite(number))
|
|
return false;
|
|
if (name == "integer" && (std::floor(number) != number || std::abs(number) > 9007199254740991.0))
|
|
return false;
|
|
}
|
|
}
|
|
if (const auto *values = rule.if_contains("enum"))
|
|
{
|
|
bool found = false;
|
|
for (const auto &allowed : values->as_array())
|
|
found = found || value == allowed;
|
|
if (!found)
|
|
return false;
|
|
}
|
|
if (const auto *minimum = rule.if_contains("minimum"))
|
|
if (value.to_number<double>() < minimum->to_number<double>())
|
|
return false;
|
|
if (const auto *maximum = rule.if_contains("maximum"))
|
|
if (value.to_number<double>() > maximum->to_number<double>())
|
|
return false;
|
|
return true;
|
|
}
|
|
} // namespace detail
|
|
|
|
// Missing version is the legacy v1 skin envelope; an explicit unknown version is never dispatched.
|
|
inline bool Validate(const boost::json::value &message, std::string_view direction, std::string_view surface = {})
|
|
{
|
|
static const auto schema = boost::json::parse(SchemaJson);
|
|
if (!message.is_object())
|
|
return false;
|
|
const auto &object = message.as_object();
|
|
const auto *type = object.if_contains("type");
|
|
if (!type || !type->is_string())
|
|
return false;
|
|
if (const auto *version = object.if_contains("protocolVersion"))
|
|
if (!version->is_number() || version->to_number<double>() != Version)
|
|
return false;
|
|
const auto *rules = schema.as_object().if_contains(direction);
|
|
if (!rules)
|
|
return false;
|
|
const auto *entry = rules->as_object().if_contains(type->as_string());
|
|
if (!entry)
|
|
return false;
|
|
const auto &spec = entry->as_object();
|
|
if (const auto *surfaces = spec.if_contains("surfaces"))
|
|
{
|
|
bool found = false;
|
|
for (const auto &allowed : surfaces->as_array())
|
|
found = found || allowed.as_string() == surface;
|
|
if (!found)
|
|
return false;
|
|
}
|
|
const auto *dataRule = spec.if_contains("data");
|
|
const auto *data = object.if_contains("data");
|
|
if (dataRule && (!data || !detail::Matches(*data, dataRule->as_object())))
|
|
return false;
|
|
if (!dataRule && data)
|
|
return false;
|
|
for (const auto &item : object)
|
|
{
|
|
if (item.key() == "type" || item.key() == "protocolVersion" || item.key() == "data")
|
|
continue;
|
|
const auto *properties = spec.if_contains("properties");
|
|
const auto *property = properties ? properties->as_object().if_contains(item.key()) : nullptr;
|
|
if (property)
|
|
{
|
|
if (!detail::Matches(item.value(), property->as_object()))
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
const auto *additional = spec.if_contains("additionalProperties");
|
|
if (!additional || !additional->as_bool())
|
|
return false;
|
|
}
|
|
}
|
|
if (const auto *required = spec.if_contains("required"))
|
|
for (const auto &key : required->as_array())
|
|
if (!object.contains(key.as_string()))
|
|
return false;
|
|
return true;
|
|
}
|
|
} // namespace metasequoia::webview
|