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 行删除

查看文件

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

查看文件

@@ -362,6 +362,10 @@ CREATE TABLE IF NOT EXISTS wrong_book (
problem_id INTEGER NOT NULL,
last_submission_id INTEGER,
note TEXT NOT NULL DEFAULT "",
note_score INTEGER NOT NULL DEFAULT 0,
note_rating INTEGER NOT NULL DEFAULT 0,
note_feedback_md TEXT NOT NULL DEFAULT "",
note_scored_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY(user_id, problem_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -577,6 +581,11 @@ CREATE TABLE IF NOT EXISTS daily_task_logs (
EnsureColumn(db, "problem_drafts", "stdin", "stdin TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solution_jobs", "max_solutions",
"max_solutions INTEGER NOT NULL DEFAULT 3");
EnsureColumn(db, "wrong_book", "note_score", "note_score INTEGER NOT NULL DEFAULT 0");
EnsureColumn(db, "wrong_book", "note_rating", "note_rating INTEGER NOT NULL DEFAULT 0");
EnsureColumn(db, "wrong_book", "note_feedback_md", "note_feedback_md TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "wrong_book", "note_scored_at", "note_scored_at INTEGER NOT NULL DEFAULT 0");
EnsureColumn(db, "wrong_book", "note_images_json", "note_images_json TEXT NOT NULL DEFAULT '[]'");
EnsureColumn(db, "problem_solutions", "variant", "variant INTEGER NOT NULL DEFAULT 1");
EnsureColumn(db, "problem_solutions", "idea_md", "idea_md TEXT NOT NULL DEFAULT ''");
EnsureColumn(db, "problem_solutions", "explanation_md",
@@ -727,6 +736,154 @@ void SeedDemoData(SqliteDb& db) {
0,
now);
}
// Always seed C++基础课程任务(幂等:按 slug 检测存在)。
{
const auto existing = QueryOneId(raw, "SELECT id FROM problems WHERE slug='cpp-basic-01-hello' LIMIT 1");
if (!existing.has_value()) {
const int64_t created = now;
struct CourseItem {
const char* slug;
const char* title;
int diff;
const char* source;
const char* md;
const char* tags[6];
};
const CourseItem items[] = {
{
"cpp-basic-01-hello",
"C++基础01环境配置与Hello WorldVSCode",
1,
"course:cpp-basic:01",
R"MD(# C++基础01环境配置与Hello WorldVSCode
## 学习目标
- VSCode C++14
-
- `main()``#include <iostream>``cout`
## 推荐视频(观看后写笔记)
- VSCode + mingw64 C/C++BV1tg411N7Fq
- https://www.bilibili.com/video/BV1tg411N7Fq/
- C++01 BV1dK4y137bk
- https://www.bilibili.com/video/BV1dK4y137bk/
## 参考图文
- LoongBa GCC/VSCode/HelloWorld
- https://github.com/LoongBa/Cpp_Beginner_Guide
## 练习(完成至少 2 题)
- B2002 Hello, World! https://www.luogu.com.cn/problem/B2002
- P1000 https://www.luogu.com.cn/problem/P1000
## 提交要求
-
1)
2) HelloWorld
3) 3
)MD",
{"cpp-basic", "vscode", "io", "", "", ""}
},
{
"cpp-basic-02-io",
"C++基础02输入输出与变量",
1,
"course:cpp-basic:02",
R"MD(# C++基础02输入输出与变量
## 学习目标
- `cin` `cout`
- `int / long long / double / char / string`
## 推荐视频
- C++02 03
- https://www.bilibili.com/video/BV1dK4y137bk/
## 练习
- P1001 A+B Problem https://www.luogu.com.cn/problem/P1001
- B2008 (a+b)×c https://www.luogu.com.cn/problem/B2008
- P5704 https://www.luogu.com.cn/problem/P5704
## 提交要求
- / `cin/cout` /
)MD",
{"cpp-basic", "io", "types", "", "", ""}
},
{
"cpp-basic-03-branch",
"C++基础03分支结构if / switch",
2,
"course:cpp-basic:03",
R"MD(# C++基础03分支结构if / switch
## 学习目标
- `if / else if / else`
-
## 练习
- B2035 https://www.luogu.com.cn/problem/B2035
- P5711 https://www.luogu.com.cn/problem/P5711
- P1909 https://www.luogu.com.cn/problem/P1909
## 提交要求
- 0/
)MD",
{"cpp-basic", "branch", "logic", "", "", ""}
},
{
"cpp-basic-04-loop",
"C++基础04循环结构for / while",
2,
"course:cpp-basic:04",
R"MD(# C++基础04循环结构for / while
## 学习目标
-
## 练习
- B2083 https://www.luogu.com.cn/problem/B2083
- P1421 https://www.luogu.com.cn/problem/P1421
## 提交要求
- //+
)MD",
{"cpp-basic", "loop", "debug", "", "", ""}
},
{
"cpp-basic-05-array",
"C++基础05数组入门一维",
3,
"course:cpp-basic:05",
R"MD(# C++基础05数组入门一维
## 学习目标
-
## 练习
- P1427 https://www.luogu.com.cn/problem/P1427
- P1428 https://www.luogu.com.cn/problem/P1428
## 提交要求
- 0/1
)MD",
{"cpp-basic", "array", "", "", "", ""}
}
};
for (const auto& it : items) {
InsertProblem(raw, it.slug, it.title, it.md, it.diff, it.source, "", "", created);
const auto pid = QueryOneId(raw, std::string("SELECT id FROM problems WHERE slug='") + it.slug + "' LIMIT 1");
if (pid.has_value()) {
for (const char* tag : it.tags) {
if (!tag || !*tag) continue;
InsertProblemTag(raw, *pid, tag);
}
}
}
}
}
}
} // namespace csp::db

查看文件

@@ -59,6 +59,23 @@ Json::Value ToJson(const WrongBookItem& w) {
j["last_submission_id"] = Json::nullValue;
}
j["note"] = w.note;
j["note_score"] = w.note_score;
j["note_rating"] = w.note_rating;
j["note_feedback_md"] = w.note_feedback_md;
if (!w.note_images_json.empty()) {
Json::Value parsed;
Json::CharReaderBuilder b;
std::string errs;
std::unique_ptr<Json::CharReader> r(b.newCharReader());
if (r->parse(w.note_images_json.data(), w.note_images_json.data() + w.note_images_json.size(), &parsed, &errs) && parsed.isArray()) {
j["note_images"] = parsed;
} else {
j["note_images"] = Json::arrayValue;
}
} else {
j["note_images"] = Json::arrayValue;
}
j["note_scored_at"] = Json::Int64(w.note_scored_at);
j["updated_at"] = Json::Int64(w.updated_at);
return j;
}

查看文件

@@ -0,0 +1,135 @@
#include "csp/services/learning_note_scoring_service.h"
#include "csp/services/crypto.h"
#include <json/json.h>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <sys/wait.h>
#include <vector>
namespace csp::services {
namespace {
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string JsonToString(const Json::Value& value) {
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
return Json::writeString(builder, value);
}
int ExitCodeFromSystem(int rc) {
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc);
return -1;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_NOTE_SCORING_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/analyze_learning_note.py",
"scripts/analyze_learning_note.py",
"../scripts/analyze_learning_note.py",
"../../scripts/analyze_learning_note.py",
};
for (const auto& path : candidates) {
if (std::filesystem::exists(path)) return path;
}
return "/app/scripts/analyze_learning_note.py";
}
} // namespace
LearningNoteScoreResult LearningNoteScoringService::Score(
const std::string& note,
const domain::Problem& problem) {
Json::Value input;
input["problem_id"] = Json::Int64(problem.id);
input["problem_title"] = problem.title;
input["problem_statement"] = problem.statement_md;
input["note"] = note;
namespace fs = std::filesystem;
const fs::path temp_file =
fs::path("/tmp") / ("csp_note_scoring_" + crypto::RandomHex(8) + ".json");
{
std::ofstream out(temp_file, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) throw std::runtime_error("failed to create temp input file");
out << JsonToString(input);
}
const std::string script = ResolveScriptPath();
const std::string cmd =
"/usr/bin/timeout 45s python3 " + ShellQuote(script) + " --input-file " +
ShellQuote(temp_file.string()) + " 2>&1";
std::string output;
int exit_code = -1;
{
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
fs::remove(temp_file);
throw std::runtime_error("failed to start note scoring script");
}
char buffer[4096];
while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
output += buffer;
}
exit_code = ExitCodeFromSystem(pclose(pipe));
}
fs::remove(temp_file);
if (exit_code != 0) {
throw std::runtime_error("note scoring script failed: " + output);
}
Json::CharReaderBuilder builder;
Json::Value parsed;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(output.data(), output.data() + output.size(), &parsed, &errs) ||
!parsed.isObject()) {
throw std::runtime_error("note scoring script returned invalid json");
}
LearningNoteScoreResult r;
r.score = parsed.get("score", 0).asInt();
r.rating = parsed.get("rating", 0).asInt();
r.feedback_md = parsed.get("feedback_md", "").asString();
r.model_name = parsed.get("model_name", "").asString();
if (r.score < 0) r.score = 0;
if (r.score > 100) r.score = 100;
if (r.rating < 1) r.rating = 1;
if (r.rating > 10) r.rating = 10;
if (r.feedback_md.empty()) {
r.feedback_md =
"### 笔记评分\n- 未能生成详细点评,请补充:学习目标、关键概念、代码片段、踩坑与修复。";
}
if (r.model_name.empty()) r.model_name = "unknown";
return r;
}
} // namespace csp::services

查看文件

@@ -243,9 +243,11 @@ SolutionAccessService::ListRatingHistory(int64_t user_id, int limit) {
// Union query for history
const char *sql =
"SELECT 'solution_view' as type, created_at, -cost as change, ('Problem "
"' || problem_id) as note "
"FROM problem_solution_view_logs WHERE user_id=? AND cost > 0 "
"SELECT 'solution_view' as type, v.created_at, -v.cost as change, "
"('Problem ' || v.problem_id || ':' || COALESCE(p.title,'')) as note "
"FROM problem_solution_view_logs v "
"LEFT JOIN problems p ON p.id=v.problem_id "
"WHERE v.user_id=? AND v.cost > 0 "
"UNION ALL "
"SELECT 'daily_task' as type, created_at, reward as change, task_code as "
"note "

查看文件

@@ -92,7 +92,13 @@ UserListResult UserService::ListUsers(int page, int page_size) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,username,rating,created_at FROM users ORDER BY id ASC LIMIT ? OFFSET ?";
"SELECT u.id,u.username,u.rating,u.created_at,"
"COALESCE(s.cnt,0),COALESCE(s.ac,0) "
"FROM users u "
"LEFT JOIN (SELECT user_id, COUNT(*) as cnt, "
"SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as ac "
"FROM submissions GROUP BY user_id) s ON s.user_id=u.id "
"ORDER BY u.id ASC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list users");
CheckSqlite(sqlite3_bind_int(stmt, 1, safe_size), db, "bind limit");
@@ -104,6 +110,8 @@ UserListResult UserService::ListUsers(int page, int page_size) {
e.username = ColText(stmt, 1);
e.rating = sqlite3_column_int(stmt, 2);
e.created_at = sqlite3_column_int64(stmt, 3);
e.total_submissions = sqlite3_column_int(stmt, 4);
e.total_ac = sqlite3_column_int(stmt, 5);
result.items.push_back(std::move(e));
}
sqlite3_finalize(stmt);

查看文件

@@ -33,7 +33,7 @@ std::vector<WrongBookEntry> WrongBookService::ListByUser(int64_t user_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.updated_at,p.title "
"SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.note_score,w.note_rating,w.note_feedback_md,w.note_images_json,w.note_scored_at,w.updated_at,p.title "
"FROM wrong_book w "
"JOIN problems p ON p.id=w.problem_id "
"WHERE w.user_id=? ORDER BY w.updated_at DESC";
@@ -52,8 +52,13 @@ std::vector<WrongBookEntry> WrongBookService::ListByUser(int64_t user_id) {
e.item.last_submission_id = sqlite3_column_int64(stmt, 2);
}
e.item.note = ColText(stmt, 3);
e.item.updated_at = sqlite3_column_int64(stmt, 4);
e.problem_title = ColText(stmt, 5);
e.item.note_score = sqlite3_column_int(stmt, 4);
e.item.note_rating = sqlite3_column_int(stmt, 5);
e.item.note_feedback_md = ColText(stmt, 6);
e.item.note_images_json = ColText(stmt, 7);
e.item.note_scored_at = sqlite3_column_int64(stmt, 8);
e.item.updated_at = sqlite3_column_int64(stmt, 9);
e.problem_title = ColText(stmt, 10);
out.push_back(std::move(e));
}
sqlite3_finalize(stmt);
@@ -81,6 +86,41 @@ void WrongBookService::UpsertNote(int64_t user_id,
sqlite3_finalize(stmt);
}
void WrongBookService::UpsertNoteScore(int64_t user_id,
int64_t problem_id,
int32_t note_score,
int32_t note_rating,
const std::string& note_feedback_md) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at,note_score,note_rating,note_feedback_md,note_scored_at) "
"VALUES(?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id,problem_id) DO UPDATE SET "
"note_score=excluded.note_score,"
"note_rating=excluded.note_rating,"
"note_feedback_md=excluded.note_feedback_md,"
"note_scored_at=excluded.note_scored_at,"
"updated_at=excluded.updated_at";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare wrong_book upsert note score");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_null(stmt, 3), db, "bind last_submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_TRANSIENT), db, "bind note");
const int64_t now = NowSec();
CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int(stmt, 6, note_score), db, "bind note_score");
CheckSqlite(sqlite3_bind_int(stmt, 7, note_rating), db, "bind note_rating");
CheckSqlite(sqlite3_bind_text(stmt, 8, note_feedback_md.c_str(), -1, SQLITE_TRANSIENT), db,
"bind note_feedback_md");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind note_scored_at");
CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert note score");
sqlite3_finalize(stmt);
}
void WrongBookService::UpsertBySubmission(int64_t user_id,
int64_t problem_id,
int64_t submission_id,
@@ -108,6 +148,41 @@ void WrongBookService::UpsertBySubmission(int64_t user_id,
sqlite3_finalize(stmt);
}
std::string WrongBookService::GetNoteImagesJson(int64_t user_id, int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT note_images_json FROM wrong_book WHERE user_id=? AND problem_id=? LIMIT 1";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare wrong_book get note_images_json");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
std::string out = "[]";
if (sqlite3_step(stmt) == SQLITE_ROW) {
out = ColText(stmt, 0);
if (out.empty()) out = "[]";
}
sqlite3_finalize(stmt);
return out;
}
void WrongBookService::SetNoteImagesJson(int64_t user_id, int64_t problem_id, const std::string& note_images_json) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at,note_images_json) "
"VALUES(?,?,?,?,?,?) "
"ON CONFLICT(user_id,problem_id) DO UPDATE SET note_images_json=excluded.note_images_json,updated_at=excluded.updated_at";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare wrong_book set note_images_json");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_null(stmt, 3), db, "bind last_submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_TRANSIENT), db, "bind note");
CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at");
CheckSqlite(sqlite3_bind_text(stmt, 6, note_images_json.c_str(), -1, SQLITE_TRANSIENT), db, "bind note_images_json");
CheckSqlite(sqlite3_step(stmt), db, "wrong_book set note_images_json");
sqlite3_finalize(stmt);
}
void WrongBookService::Remove(int64_t user_id, int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;