feat: problems local stats, user status, admin panel enhancements, rating text
- Problems page: replace Luogu pass rate with local submission stats
(local_submit_count, local_ac_count)
- Problems page: add user AC/fail status column (user_ac, user_fail_count)
- Admin users: add total_submissions and total_ac columns
- Admin users: add detail panel with submissions/rating/redeem tabs
- Admin: new endpoint GET /api/v1/admin/users/{id}/rating-history
- Rating history: note field includes problem title via JOIN
- Me page: translate task codes to friendly labels with icons
- Me page: problem links in rating history are clickable
- Wrong book service, learning note scoring, note image controller
- Backend SQL uses batch queries for performance
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/services/redeem_service.h"
|
||||
#include "csp/services/solution_access_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
@@ -129,6 +130,8 @@ void AdminController::listUsers(
|
||||
one["username"] = item.username;
|
||||
one["rating"] = item.rating;
|
||||
one["created_at"] = Json::Int64(item.created_at);
|
||||
one["total_submissions"] = item.total_submissions;
|
||||
one["total_ac"] = item.total_ac;
|
||||
arr.append(one);
|
||||
}
|
||||
|
||||
@@ -353,4 +356,33 @@ void AdminController::listRedeemRecords(
|
||||
}
|
||||
}
|
||||
|
||||
void AdminController::userRatingHistory(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
int64_t user_id) {
|
||||
try {
|
||||
if (!RequireAdminUserId(req, cb).has_value()) return;
|
||||
|
||||
const int limit = ParseClampedInt(req->getParameter("limit"), 200, 1, 500);
|
||||
|
||||
services::SolutionAccessService svc(csp::AppState::Instance().db());
|
||||
const auto items = svc.ListRatingHistory(user_id, limit);
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& item : items) {
|
||||
Json::Value j;
|
||||
j["type"] = item.type;
|
||||
j["created_at"] = Json::Int64(item.created_at);
|
||||
j["change"] = item.change;
|
||||
j["note"] = item.note;
|
||||
arr.append(j);
|
||||
}
|
||||
cb(JsonOk(arr));
|
||||
} catch (const std::invalid_argument&) {
|
||||
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
|
||||
} catch (const std::exception& e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace csp::controllers
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include "csp/services/solution_access_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
#include "csp/services/wrong_book_service.h"
|
||||
#include "csp/services/crypto.h"
|
||||
#include "csp/services/problem_service.h"
|
||||
#include "csp/services/learning_note_scoring_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -298,6 +301,217 @@ void MeController::upsertWrongBookNote(
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::scoreWrongBookNote(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
cb(JsonError(drogon::k400BadRequest, "body must be json"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string note = (*json).get("note", "").asString();
|
||||
if (note.empty()) {
|
||||
cb(JsonError(drogon::k400BadRequest, "note required"));
|
||||
return;
|
||||
}
|
||||
if (note.size() > 8000) {
|
||||
cb(JsonError(drogon::k400BadRequest, "note too long"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::ProblemService problem_svc(csp::AppState::Instance().db());
|
||||
const auto p = problem_svc.GetById(problem_id);
|
||||
if (!p.has_value()) {
|
||||
cb(JsonError(drogon::k404NotFound, "problem not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::LearningNoteScoringService scorer(csp::AppState::Instance().db());
|
||||
const auto result = scorer.Score(note, *p);
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
// ensure note saved
|
||||
wrong_book.UpsertNote(*user_id, problem_id, note);
|
||||
wrong_book.UpsertNoteScore(*user_id, problem_id, result.score, result.rating, result.feedback_md);
|
||||
|
||||
Json::Value data;
|
||||
data["user_id"] = Json::Int64(*user_id);
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["note"] = note;
|
||||
data["note_score"] = result.score;
|
||||
data["note_rating"] = result.rating;
|
||||
data["note_feedback_md"] = result.feedback_md;
|
||||
data["model_name"] = result.model_name;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void MeController::uploadWrongBookNoteImages(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto files = req->getUploadedFiles();
|
||||
if (files.empty()) {
|
||||
cb(JsonError(drogon::k400BadRequest, "no files"));
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path dir = "/data/note_images";
|
||||
fs::create_directories(dir);
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
|
||||
Json::CharReaderBuilder rb;
|
||||
Json::Value arr(Json::arrayValue);
|
||||
{
|
||||
const std::string current = wrong_book.GetNoteImagesJson(*user_id, problem_id);
|
||||
Json::Value parsed;
|
||||
std::string errs;
|
||||
std::unique_ptr<Json::CharReader> r(rb.newCharReader());
|
||||
if (r->parse(current.data(), current.data() + current.size(), &parsed, &errs) && parsed.isArray()) {
|
||||
arr = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const int kMaxImages = 9;
|
||||
if ((int)arr.size() >= kMaxImages) {
|
||||
cb(JsonError(drogon::k400BadRequest, "too many images"));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value out_items(Json::arrayValue);
|
||||
|
||||
for (const auto &f : files) {
|
||||
if ((int)arr.size() >= kMaxImages) break;
|
||||
|
||||
const auto ct_hdr = f.getFileType();
|
||||
if (ct_hdr.rfind("image/", 0) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (f.fileLength() > 5 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string ext = f.getFileName();
|
||||
auto pos = ext.find_last_of('.');
|
||||
ext = (pos == std::string::npos) ? std::string(".png") : ext.substr(pos);
|
||||
if (ext.size() > 10) ext = ".png";
|
||||
|
||||
const std::string filename =
|
||||
"u" + std::to_string(*user_id) + "_p" + std::to_string(problem_id) + "_" +
|
||||
crypto::RandomHex(12) + ext;
|
||||
|
||||
const fs::path dest = dir / filename;
|
||||
f.saveAs(dest.string());
|
||||
|
||||
arr.append(filename);
|
||||
|
||||
Json::Value it;
|
||||
it["filename"] = filename;
|
||||
it["url"] = std::string("/files/note-images/") + filename;
|
||||
out_items.append(it);
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
const std::string new_json = Json::writeString(wb, arr);
|
||||
wrong_book.SetNoteImagesJson(*user_id, problem_id, new_json);
|
||||
|
||||
Json::Value data;
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["items"] = out_items;
|
||||
data["note_images"] = arr;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MeController::deleteWrongBookNoteImage(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
int64_t problem_id) {
|
||||
try {
|
||||
const auto user_id = RequireAuth(req, cb);
|
||||
if (!user_id.has_value()) return;
|
||||
|
||||
const auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
cb(JsonError(drogon::k400BadRequest, "body must be json"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string filename = (*json).get("filename", "").asString();
|
||||
if (filename.empty() || filename.find("..") != std::string::npos || filename.find('/') != std::string::npos || filename.find('\\\\') != std::string::npos) {
|
||||
cb(JsonError(drogon::k400BadRequest, "bad filename"));
|
||||
return;
|
||||
}
|
||||
|
||||
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
||||
const std::string current = wrong_book.GetNoteImagesJson(*user_id, problem_id);
|
||||
|
||||
Json::CharReaderBuilder rb;
|
||||
Json::Value arr(Json::arrayValue);
|
||||
{
|
||||
Json::Value parsed;
|
||||
std::string errs;
|
||||
std::unique_ptr<Json::CharReader> r(rb.newCharReader());
|
||||
if (r->parse(current.data(), current.data() + current.size(), &parsed, &errs) && parsed.isArray()) {
|
||||
arr = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
Json::Value next(Json::arrayValue);
|
||||
bool removed = false;
|
||||
for (const auto &v : arr) {
|
||||
if (v.isString() && v.asString() == filename) {
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
next.append(v);
|
||||
}
|
||||
if (!removed) {
|
||||
cb(JsonError(drogon::k404NotFound, "image not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path full = fs::path("/data/note_images") / filename;
|
||||
if (fs::exists(full) && fs::is_regular_file(full)) {
|
||||
std::error_code ec;
|
||||
fs::remove(full, ec);
|
||||
}
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb["indentation"] = "";
|
||||
wrong_book.SetNoteImagesJson(*user_id, problem_id, Json::writeString(wb, next));
|
||||
|
||||
Json::Value data;
|
||||
data["problem_id"] = Json::Int64(problem_id);
|
||||
data["deleted"] = true;
|
||||
data["filename"] = filename;
|
||||
data["note_images"] = next;
|
||||
cb(JsonOk(data));
|
||||
} catch (const std::exception &e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void MeController::deleteWrongBookItem(
|
||||
const drogon::HttpRequestPtr &req,
|
||||
std::function<void(const drogon::HttpResponsePtr &)> &&cb,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#include "csp/controllers/note_image_controller.h"
|
||||
|
||||
#include <drogon/HttpResponse.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace csp::controllers {
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsSafeFilename(const std::string& name) {
|
||||
if (name.empty()) return false;
|
||||
if (name.size() > 200) return false;
|
||||
if (name.find("..") != std::string::npos) return false;
|
||||
if (name.find('/') != std::string::npos) return false;
|
||||
if (name.find('\\') != std::string::npos) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void NoteImageController::getNoteImage(
|
||||
const drogon::HttpRequestPtr& /*req*/,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
|
||||
const std::string& filename) {
|
||||
if (!IsSafeFilename(filename)) {
|
||||
auto resp = drogon::HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(drogon::k400BadRequest);
|
||||
resp->setBody("bad filename");
|
||||
cb(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path base = "/data/note_images";
|
||||
const fs::path full = base / filename;
|
||||
|
||||
if (!fs::exists(full) || !fs::is_regular_file(full)) {
|
||||
auto resp = drogon::HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(drogon::k404NotFound);
|
||||
resp->setBody("not found");
|
||||
cb(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
auto resp = drogon::HttpResponse::newFileResponse(full.string());
|
||||
resp->addHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
cb(resp);
|
||||
}
|
||||
|
||||
} // namespace csp::controllers
|
||||
@@ -13,8 +13,11 @@
|
||||
#include <exception>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace csp::controllers {
|
||||
|
||||
namespace {
|
||||
@@ -85,11 +88,82 @@ void ProblemController::list(
|
||||
q.order_by = req->getParameter("order_by");
|
||||
q.order = req->getParameter("order");
|
||||
|
||||
services::ProblemService svc(csp::AppState::Instance().db());
|
||||
auto& db = csp::AppState::Instance().db();
|
||||
services::ProblemService svc(db);
|
||||
const auto result = svc.List(q);
|
||||
|
||||
// Collect problem IDs for batch stats query.
|
||||
std::vector<int64_t> pids;
|
||||
pids.reserve(result.items.size());
|
||||
for (const auto& p : result.items) pids.push_back(p.id);
|
||||
|
||||
// Batch query: local submission stats per problem.
|
||||
struct ProbStats { int submit_count = 0; int ac_count = 0; };
|
||||
std::unordered_map<int64_t, ProbStats> stats_map;
|
||||
if (!pids.empty()) {
|
||||
std::string placeholders;
|
||||
for (size_t i = 0; i < pids.size(); ++i) {
|
||||
if (i) placeholders += ",";
|
||||
placeholders += "?";
|
||||
}
|
||||
const std::string stats_sql =
|
||||
"SELECT problem_id, COUNT(*) as cnt, "
|
||||
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac "
|
||||
"FROM submissions WHERE problem_id IN (" + placeholders + ") "
|
||||
"GROUP BY problem_id";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
sqlite3_prepare_v2(db.raw(), stats_sql.c_str(), -1, &st, nullptr);
|
||||
for (size_t i = 0; i < pids.size(); ++i)
|
||||
sqlite3_bind_int64(st, static_cast<int>(i + 1), pids[i]);
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const int64_t pid = sqlite3_column_int64(st, 0);
|
||||
stats_map[pid] = {sqlite3_column_int(st, 1), sqlite3_column_int(st, 2)};
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
// Optional: per-user submission status (if authenticated).
|
||||
struct UserStatus { bool ac = false; int fail_count = 0; };
|
||||
std::unordered_map<int64_t, UserStatus> user_map;
|
||||
std::string auth_err;
|
||||
const auto opt_user_id = GetAuthedUserId(req, auth_err);
|
||||
if (opt_user_id.has_value() && !pids.empty()) {
|
||||
std::string placeholders;
|
||||
for (size_t i = 0; i < pids.size(); ++i) {
|
||||
if (i) placeholders += ",";
|
||||
placeholders += "?";
|
||||
}
|
||||
const std::string user_sql =
|
||||
"SELECT problem_id, "
|
||||
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac, "
|
||||
"SUM(CASE WHEN status!='AC' THEN 1 ELSE 0 END) as fail "
|
||||
"FROM submissions WHERE user_id=? AND problem_id IN (" + placeholders + ") "
|
||||
"GROUP BY problem_id";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
sqlite3_prepare_v2(db.raw(), user_sql.c_str(), -1, &st, nullptr);
|
||||
sqlite3_bind_int64(st, 1, *opt_user_id);
|
||||
for (size_t i = 0; i < pids.size(); ++i)
|
||||
sqlite3_bind_int64(st, static_cast<int>(i + 2), pids[i]);
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const int64_t pid = sqlite3_column_int64(st, 0);
|
||||
user_map[pid] = {sqlite3_column_int(st, 1) > 0, sqlite3_column_int(st, 2)};
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
Json::Value arr(Json::arrayValue);
|
||||
for (const auto& p : result.items) arr.append(domain::ToJson(p));
|
||||
for (const auto& p : result.items) {
|
||||
Json::Value j = domain::ToJson(p);
|
||||
auto it = stats_map.find(p.id);
|
||||
j["local_submit_count"] = it != stats_map.end() ? it->second.submit_count : 0;
|
||||
j["local_ac_count"] = it != stats_map.end() ? it->second.ac_count : 0;
|
||||
if (opt_user_id.has_value()) {
|
||||
auto uit = user_map.find(p.id);
|
||||
j["user_ac"] = uit != user_map.end() && uit->second.ac;
|
||||
j["user_fail_count"] = uit != user_map.end() ? uit->second.fail_count : 0;
|
||||
}
|
||||
arr.append(j);
|
||||
}
|
||||
|
||||
Json::Value payload;
|
||||
payload["items"] = arr;
|
||||
|
||||
在新工单中引用
屏蔽一个用户