[Server] Serve report aggregates through the database interface (#7845)

cmdReportStats and cmdReportUserInfo still ran their queries inline through
Servatrice_DatabaseInterface and built the response in the socket handler. Add
ReportStatsRecord, ReportUserInfoRecord and their count records to
Server_DatabaseInterface with fail-closed defaults, implement getReportStats and
getReportUserInfo with the previous statements and query order, and keep
presence flags for the fields the old code set only on a successful query so
the proto2 wire output is unchanged. The stats cache decision and every response
code stay in the handler.
This commit is contained in:
BruebachL 2026-11-10 12:16:28 +01:00 • committed by GitHub
parent 2ba80cb61d
commit a54e13756c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 309 additions and 144 deletions

View file

@ -424,6 +424,66 @@ public:
{
return false;
}
/** @brief One report category and how many reports it covers. */
struct ReportCategoryCountRecord
{
QString category;
int count = 0;
};
/** @brief One reported or reporting user and how many reports involve them. */
struct ReportTopUserRecord
{
QString userName;
int count = 0;
};
/** @brief Aggregated report queue statistics for the moderator overview. */
struct ReportStatsRecord
{
int totalReports = 0;
int totalPending = 0;
int totalAssigned = 0;
int totalResolved = 0;
int reportsLast24h = 0;
int reportsLast7d = 0;
int reportsLast30d = 0;
int reportsThisWeek = 0;
bool hasReportsLastWeek = false;
int reportsLastWeek = 0;
bool hasAvgResolutionHours = false;
double avgResolutionHours = 0.0;
QList<ReportCategoryCountRecord> categoryCounts;
QList<ReportTopUserRecord> topReportedUsers;
QList<ReportTopUserRecord> topReporters;
/// False when a non-overview query failed, which keeps the result out of the cache.
bool allQueriesOk = true;
};
virtual bool getReportStats(ReportStatsRecord & /* stats */)
{
return false;
}
/** @brief Report history and moderation counts for one user, for the moderator overview. */
struct ReportUserInfoRecord
{
bool found = false;
bool isAdmin = false;
bool isActive = false;
qint64 registrationTime = 0;
bool hasAdminNotes = false;
QString adminNotes;
bool hasTotalReports = false;
int totalReports = 0;
bool hasTotalBans = false;
int totalBans = 0;
bool hasTotalWarns = false;
int totalWarns = 0;
bool hasLastLogin = false;
qint64 lastLogin = 0;
QList<ServerInfo_Report> recentReports;
};
virtual bool getReportUserInfo(const QString & /* userName */, ReportUserInfoRecord & /* info */)
{
return false;
}
/** @brief One resolved or dismissed report whose reporter has not been told yet. */
struct PendingReportNotification
{

View file

@ -3843,6 +3843,204 @@ bool Servatrice_DatabaseInterface::getReportComments(int reportId, QList<ServerI
return true;
}
bool Servatrice_DatabaseInterface::getReportStats(ReportStatsRecord &stats)
{
if (!checkSql()) {
return false;
}
QSqlQuery *overviewQuery =
prepareQuery("SELECT COUNT(*) AS total, "
"SUM(status IN ('open', 'assigned')) AS pending, "
"SUM(status = 'assigned') AS assigned, "
"SUM(status IN ('resolved', 'dismissed')) AS resolved, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, "
"SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week "
"FROM {prefix}_reports");
if (!execSqlQuery(overviewQuery) || !overviewQuery->next()) {
return false;
}
stats.totalReports = overviewQuery->value(0).toInt();
stats.totalPending = overviewQuery->value(1).toInt();
stats.totalAssigned = overviewQuery->value(2).toInt();
stats.totalResolved = overviewQuery->value(3).toInt();
stats.reportsLast24h = overviewQuery->value(4).toInt();
stats.reportsLast7d = overviewQuery->value(5).toInt();
stats.reportsLast30d = overviewQuery->value(6).toInt();
stats.reportsThisWeek = overviewQuery->value(7).toInt();
stats.allQueriesOk = true;
QSqlQuery *lastWeekQuery =
prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), "
"INTERVAL WEEKDAY(CURDATE()) + 7 DAY) "
"AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)");
if (execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) {
stats.hasReportsLastWeek = true;
stats.reportsLastWeek = lastWeekQuery->value(0).toInt();
} else {
stats.allQueriesOk = false;
}
QSqlQuery *avgResQuery =
prepareQuery("SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) "
"FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL");
if (execSqlQuery(avgResQuery) && avgResQuery->next()) {
if (!avgResQuery->value(0).isNull()) {
stats.hasAvgResolutionHours = true;
stats.avgResolutionHours = avgResQuery->value(0).toDouble();
}
} else {
stats.allQueriesOk = false;
}
QSqlQuery *catQuery =
prepareQuery("SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC "
"LIMIT 10");
if (execSqlQuery(catQuery)) {
while (catQuery->next()) {
ReportCategoryCountRecord record;
record.category = catQuery->value(0).toString();
record.count = catQuery->value(1).toInt();
stats.categoryCounts.append(record);
}
} else {
stats.allQueriesOk = false;
}
QSqlQuery *topReportedQuery = prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports "
"GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10");
if (execSqlQuery(topReportedQuery)) {
while (topReportedQuery->next()) {
ReportTopUserRecord record;
record.userName = topReportedQuery->value(0).toString();
record.count = topReportedQuery->value(1).toInt();
stats.topReportedUsers.append(record);
}
} else {
stats.allQueriesOk = false;
}
QSqlQuery *topReporterQuery = prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports "
"GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10");
if (execSqlQuery(topReporterQuery)) {
while (topReporterQuery->next()) {
ReportTopUserRecord record;
record.userName = topReporterQuery->value(0).toString();
record.count = topReporterQuery->value(1).toInt();
stats.topReporters.append(record);
}
} else {
stats.allQueriesOk = false;
}
return true;
}
bool Servatrice_DatabaseInterface::getReportUserInfo(const QString &userName, ReportUserInfoRecord &info)
{
if (!checkSql()) {
return false;
}
QSqlQuery *userQuery = prepareQuery("SELECT u.admin, u.active, u.registrationDate, u.adminnotes "
"FROM {prefix}_users u "
"WHERE u.name = :name");
userQuery->bindValue(":name", userName);
if (!execSqlQuery(userQuery)) {
return false;
}
if (!userQuery->next()) {
return true;
}
info.found = true;
info.isAdmin = userQuery->value(0).toBool();
info.isActive = userQuery->value(1).toBool();
info.registrationTime = userQuery->value(2).toDateTime().toSecsSinceEpoch();
if (!userQuery->value(3).isNull()) {
info.hasAdminNotes = true;
info.adminNotes = userQuery->value(3).toString();
}
QSqlQuery *reportCountQuery =
prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reported_user_name = :name");
reportCountQuery->bindValue(":name", userName);
if (execSqlQuery(reportCountQuery) && reportCountQuery->next()) {
info.hasTotalReports = true;
info.totalReports = reportCountQuery->value(0).toInt();
}
QSqlQuery *banCountQuery = prepareQuery("SELECT COUNT(*) FROM {prefix}_bans WHERE user_name = :name");
banCountQuery->bindValue(":name", userName);
if (execSqlQuery(banCountQuery) && banCountQuery->next()) {
info.hasTotalBans = true;
info.totalBans = banCountQuery->value(0).toInt();
}
QSqlQuery *warnCountQuery = prepareQuery("SELECT COUNT(*) FROM {prefix}_warnings WHERE user_name = :name");
warnCountQuery->bindValue(":name", userName);
if (execSqlQuery(warnCountQuery) && warnCountQuery->next()) {
info.hasTotalWarns = true;
info.totalWarns = warnCountQuery->value(0).toInt();
}
QSqlQuery *lastLoginQuery = prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) "
"FROM {prefix}_user_analytics a "
"JOIN {prefix}_users u ON u.id = a.id "
"WHERE u.name = :name");
lastLoginQuery->bindValue(":name", userName);
if (execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) {
info.hasLastLogin = true;
info.lastLogin = lastLoginQuery->value(0).toLongLong();
}
QSqlQuery *recentQuery = prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, ru.name AS assigned_mod_name "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users ru ON r.assigned_to = ru.id "
"WHERE r.reported_user_name = :name "
"ORDER BY r.created_at DESC LIMIT 10");
recentQuery->bindValue(":name", userName);
if (execSqlQuery(recentQuery)) {
while (recentQuery->next()) {
ServerInfo_Report report;
report.set_report_id(recentQuery->value(0).toInt());
report.set_reporter_name(recentQuery->value(1).toString().toStdString());
report.set_reported_user_name(recentQuery->value(2).toString().toStdString());
if (!recentQuery->value(3).isNull()) {
report.set_game_id(recentQuery->value(3).toInt());
}
report.set_category(recentQuery->value(4).toString().toStdString());
report.set_description(recentQuery->value(5).toString().toStdString());
report.set_report_time(recentQuery->value(6).toDateTime().toSecsSinceEpoch());
report.set_status(recentQuery->value(7).toString().toStdString());
if (!recentQuery->value(8).isNull()) {
report.set_resolution_note(recentQuery->value(8).toString().toStdString());
}
if (!recentQuery->value(9).isNull()) {
report.set_assigned_mod_name(recentQuery->value(9).toString().toStdString());
}
info.recentReports.append(report);
}
}
return true;
}
int Servatrice_DatabaseInterface::countReportsSubmittedInLastDay(int reporterId)
{
if (!checkSql()) {

View file

@ -301,6 +301,8 @@ public:
Server_DatabaseInterface::ReportLookupResult
getReportForDetails(int reportId, int &reporterId, ServerInfo_Report &report) override;
bool getReportComments(int reportId, QList<ServerInfo_ReportComment> &comments) override;
bool getReportStats(ReportStatsRecord &stats) override;
bool getReportUserInfo(const QString &userName, ReportUserInfoRecord &info) override;
bool addReport(int reporterId,
const QString &reporterName,
int reportedUserId,

View file

@ -1992,99 +1992,50 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportResolve(const Com
Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Command_ReportUserInfo &cmd,
ResponseContainer &rc)
{
if (!sqlInterface->checkSql()) {
if (!databaseInterface->checkSql()) {
return Response::RespInternalError;
}
QString userName = nameFromStdString(cmd.user_name());
const QString userName = nameFromStdString(cmd.user_name());
QSqlQuery *userQuery = sqlInterface->prepareQuery("SELECT u.admin, u.active, u.registrationDate, u.adminnotes "
"FROM {prefix}_users u "
"WHERE u.name = :name");
userQuery->bindValue(":name", userName);
if (!sqlInterface->execSqlQuery(userQuery)) {
Server_DatabaseInterface::ReportUserInfoRecord userReportInfo;
if (!databaseInterface->getReportUserInfo(userName, userReportInfo)) {
return Response::RespInternalError;
}
if (!userQuery->next()) {
if (!userReportInfo.found) {
return Response::RespNameNotFound;
}
Response_ReportUserInfo *re = new Response_ReportUserInfo;
re->set_user_name(cmd.user_name());
re->set_is_admin(userQuery->value(0).toBool());
re->set_is_active(userQuery->value(1).toBool());
re->set_registration_time(userQuery->value(2).toDateTime().toSecsSinceEpoch());
re->set_is_admin(userReportInfo.isAdmin);
re->set_is_active(userReportInfo.isActive);
re->set_registration_time(userReportInfo.registrationTime);
if (!userQuery->value(3).isNull()) {
re->set_admin_notes(userQuery->value(3).toString().toStdString());
if (userReportInfo.hasAdminNotes) {
re->set_admin_notes(userReportInfo.adminNotes.toStdString());
}
QSqlQuery *reportCountQuery =
sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reported_user_name = :name");
reportCountQuery->bindValue(":name", userName);
if (sqlInterface->execSqlQuery(reportCountQuery) && reportCountQuery->next()) {
re->set_total_reports(reportCountQuery->value(0).toInt());
if (userReportInfo.hasTotalReports) {
re->set_total_reports(userReportInfo.totalReports);
}
QSqlQuery *banCountQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_bans WHERE user_name = :name");
banCountQuery->bindValue(":name", userName);
if (sqlInterface->execSqlQuery(banCountQuery) && banCountQuery->next()) {
re->set_total_bans(banCountQuery->value(0).toInt());
if (userReportInfo.hasTotalBans) {
re->set_total_bans(userReportInfo.totalBans);
}
QSqlQuery *warnCountQuery =
sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_warnings WHERE user_name = :name");
warnCountQuery->bindValue(":name", userName);
if (sqlInterface->execSqlQuery(warnCountQuery) && warnCountQuery->next()) {
re->set_total_warns(warnCountQuery->value(0).toInt());
if (userReportInfo.hasTotalWarns) {
re->set_total_warns(userReportInfo.totalWarns);
}
QSqlQuery *lastLoginQuery = sqlInterface->prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) "
"FROM {prefix}_user_analytics a "
"JOIN {prefix}_users u ON u.id = a.id "
"WHERE u.name = :name");
lastLoginQuery->bindValue(":name", userName);
if (sqlInterface->execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) {
re->set_last_login(lastLoginQuery->value(0).toLongLong());
if (userReportInfo.hasLastLogin) {
re->set_last_login(userReportInfo.lastLogin);
}
QSqlQuery *recentQuery =
sqlInterface->prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, "
"r.category, r.description, r.created_at, r.status, "
"r.resolution_note, ru.name AS assigned_mod_name "
"FROM {prefix}_reports r "
"LEFT JOIN {prefix}_users ru ON r.assigned_to = ru.id "
"WHERE r.reported_user_name = :name "
"ORDER BY r.created_at DESC LIMIT 10");
recentQuery->bindValue(":name", userName);
if (sqlInterface->execSqlQuery(recentQuery)) {
while (recentQuery->next()) {
ServerInfo_Report *info = re->add_recent_reports();
info->set_report_id(recentQuery->value(0).toInt());
info->set_reporter_name(recentQuery->value(1).toString().toStdString());
info->set_reported_user_name(recentQuery->value(2).toString().toStdString());
if (!recentQuery->value(3).isNull()) {
info->set_game_id(recentQuery->value(3).toInt());
}
info->set_category(recentQuery->value(4).toString().toStdString());
info->set_description(recentQuery->value(5).toString().toStdString());
info->set_report_time(recentQuery->value(6).toDateTime().toSecsSinceEpoch());
info->set_status(recentQuery->value(7).toString().toStdString());
if (!recentQuery->value(8).isNull()) {
info->set_resolution_note(recentQuery->value(8).toString().toStdString());
}
if (!recentQuery->value(9).isNull()) {
info->set_assigned_mod_name(recentQuery->value(9).toString().toStdString());
}
}
for (const ServerInfo_Report &report : userReportInfo.recentReports) {
re->add_recent_reports()->CopyFrom(report);
}
rc.setResponseExtension(re);
@ -2167,7 +2118,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Co
Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */,
ResponseContainer &rc)
{
if (!sqlInterface->checkSql()) {
if (!databaseInterface->checkSql()) {
return Response::RespInternalError;
}
@ -2176,94 +2127,48 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Comma
return Response::RespOk;
}
Response_ReportStats *re = new Response_ReportStats;
QSqlQuery *overviewQuery = sqlInterface->prepareQuery(
"SELECT COUNT(*) AS total, "
"SUM(status IN ('open', 'assigned')) AS pending, "
"SUM(status = 'assigned') AS assigned, "
"SUM(status IN ('resolved', 'dismissed')) AS resolved, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, "
"SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, "
"SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week "
"FROM {prefix}_reports");
if (!sqlInterface->execSqlQuery(overviewQuery) || !overviewQuery->next()) {
delete re;
Server_DatabaseInterface::ReportStatsRecord stats;
if (!databaseInterface->getReportStats(stats)) {
return Response::RespInternalError;
}
re->set_total_reports(overviewQuery->value(0).toInt());
re->set_total_pending(overviewQuery->value(1).toInt());
re->set_total_assigned(overviewQuery->value(2).toInt());
re->set_total_resolved(overviewQuery->value(3).toInt());
re->set_reports_last_24h(overviewQuery->value(4).toInt());
re->set_reports_last_7d(overviewQuery->value(5).toInt());
re->set_reports_last_30d(overviewQuery->value(6).toInt());
re->set_reports_this_week(overviewQuery->value(7).toInt());
auto *re = new Response_ReportStats;
re->set_total_reports(stats.totalReports);
re->set_total_pending(stats.totalPending);
re->set_total_assigned(stats.totalAssigned);
re->set_total_resolved(stats.totalResolved);
re->set_reports_last_24h(stats.reportsLast24h);
re->set_reports_last_7d(stats.reportsLast7d);
re->set_reports_last_30d(stats.reportsLast30d);
re->set_reports_this_week(stats.reportsThisWeek);
bool allQueriesOk = true;
QSqlQuery *lastWeekQuery =
sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), "
"INTERVAL WEEKDAY(CURDATE()) + 7 DAY) "
"AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)");
if (sqlInterface->execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) {
re->set_reports_last_week(lastWeekQuery->value(0).toInt());
} else {
allQueriesOk = false;
if (stats.hasReportsLastWeek) {
re->set_reports_last_week(stats.reportsLastWeek);
}
QSqlQuery *avgResQuery = sqlInterface->prepareQuery(
"SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) "
"FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL");
if (sqlInterface->execSqlQuery(avgResQuery) && avgResQuery->next()) {
if (!avgResQuery->value(0).isNull()) {
re->set_avg_resolution_hours(avgResQuery->value(0).toDouble());
}
} else {
allQueriesOk = false;
if (stats.hasAvgResolutionHours) {
re->set_avg_resolution_hours(stats.avgResolutionHours);
}
QSqlQuery *catQuery = sqlInterface->prepareQuery(
"SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC LIMIT 10");
if (sqlInterface->execSqlQuery(catQuery)) {
while (catQuery->next()) {
ReportCategoryCount *cc = re->add_category_counts();
cc->set_category(catQuery->value(0).toString().toStdString());
cc->set_count(catQuery->value(1).toInt());
}
} else {
allQueriesOk = false;
for (const Server_DatabaseInterface::ReportCategoryCountRecord &count : stats.categoryCounts) {
ReportCategoryCount *cc = re->add_category_counts();
cc->set_category(count.category.toStdString());
cc->set_count(count.count);
}
QSqlQuery *topReportedQuery =
sqlInterface->prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports "
"GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10");
if (sqlInterface->execSqlQuery(topReportedQuery)) {
while (topReportedQuery->next()) {
ReportTopUser *tu = re->add_top_reported_users();
tu->set_user_name(topReportedQuery->value(0).toString().toStdString());
tu->set_count(topReportedQuery->value(1).toInt());
}
} else {
allQueriesOk = false;
for (const Server_DatabaseInterface::ReportTopUserRecord &user : stats.topReportedUsers) {
ReportTopUser *tu = re->add_top_reported_users();
tu->set_user_name(user.userName.toStdString());
tu->set_count(user.count);
}
QSqlQuery *topReporterQuery =
sqlInterface->prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports "
"GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10");
if (sqlInterface->execSqlQuery(topReporterQuery)) {
while (topReporterQuery->next()) {
ReportTopUser *tu = re->add_top_reporters();
tu->set_user_name(topReporterQuery->value(0).toString().toStdString());
tu->set_count(topReporterQuery->value(1).toInt());
}
} else {
allQueriesOk = false;
for (const Server_DatabaseInterface::ReportTopUserRecord &user : stats.topReporters) {
ReportTopUser *tu = re->add_top_reporters();
tu->set_user_name(user.userName.toStdString());
tu->set_count(user.count);
}
if (allQueriesOk) {
if (stats.allQueriesOk) {
servatrice->cacheReportStats(*re);
}