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>
这个提交包含在:
cryptocommuniums-afk
2026-02-16 17:35:22 +08:00
父节点 7860414ae5
当前提交 cfbe9a0363
修改 22 个文件,包含 1366 行新增26 行删除

查看文件

@@ -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,