- Score max changed from 100 to 60, rating max from 10 to 6 - Note scoring now awards actual rating points (delta-based) - Re-scoring only awards/deducts the difference - Rating history shows note_score entries with problem link - LLM prompt includes problem statement context for better evaluation - LLM scoring dimensions: 题意理解/思路算法/代码记录/踩坑反思 (15 each) - Minecraft-themed UI: 矿石鉴定, 探索笔记, 存入宝典, etc. - Fallback scoring adjusted for 60-point scale - Handle LLM markdown code fence wrapping in response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
586 行
18 KiB
C++
586 行
18 KiB
C++
#include "csp/controllers/me_controller.h"
|
|
|
|
#include "csp/app_state.h"
|
|
#include "csp/domain/json.h"
|
|
#include "csp/services/daily_task_service.h"
|
|
#include "csp/services/redeem_service.h"
|
|
#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 <drogon/MultiPart.h>
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <memory>
|
|
#include <exception>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
|
|
namespace csp::controllers {
|
|
|
|
namespace {
|
|
|
|
drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code,
|
|
const std::string &msg) {
|
|
Json::Value j;
|
|
j["ok"] = false;
|
|
j["error"] = msg;
|
|
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
|
|
resp->setStatusCode(code);
|
|
return resp;
|
|
}
|
|
|
|
drogon::HttpResponsePtr JsonOk(const Json::Value &data) {
|
|
Json::Value j;
|
|
j["ok"] = true;
|
|
j["data"] = data;
|
|
auto resp = drogon::HttpResponse::newHttpJsonResponse(j);
|
|
resp->setStatusCode(drogon::k200OK);
|
|
return resp;
|
|
}
|
|
|
|
std::optional<int64_t>
|
|
RequireAuth(const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &cb) {
|
|
std::string auth_error;
|
|
const auto user_id = GetAuthedUserId(req, auth_error);
|
|
if (!user_id.has_value()) {
|
|
cb(JsonError(drogon::k401Unauthorized, auth_error));
|
|
return std::nullopt;
|
|
}
|
|
return user_id;
|
|
}
|
|
|
|
int ParseClampedInt(const std::string &s, int default_value, int min_value,
|
|
int max_value) {
|
|
if (s.empty())
|
|
return default_value;
|
|
const int value = std::stoi(s);
|
|
return std::max(min_value, std::min(max_value, value));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void MeController::profile(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
services::UserService users(csp::AppState::Instance().db());
|
|
const auto user = users.GetById(*user_id);
|
|
if (!user.has_value()) {
|
|
cb(JsonError(drogon::k404NotFound, "user not found"));
|
|
return;
|
|
}
|
|
|
|
cb(JsonOk(domain::ToPublicJson(*user)));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::listRedeemItems(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
if (!RequireAuth(req, cb).has_value())
|
|
return;
|
|
|
|
services::RedeemService redeem(csp::AppState::Instance().db());
|
|
const auto items = redeem.ListItems(false);
|
|
|
|
Json::Value arr(Json::arrayValue);
|
|
for (const auto &item : items) {
|
|
Json::Value j;
|
|
j["id"] = Json::Int64(item.id);
|
|
j["name"] = item.name;
|
|
j["description"] = item.description;
|
|
j["unit_label"] = item.unit_label;
|
|
j["holiday_cost"] = item.holiday_cost;
|
|
j["studyday_cost"] = item.studyday_cost;
|
|
j["is_active"] = item.is_active;
|
|
j["is_global"] = item.is_global;
|
|
j["created_at"] = Json::Int64(item.created_at);
|
|
j["updated_at"] = Json::Int64(item.updated_at);
|
|
arr.append(j);
|
|
}
|
|
cb(JsonOk(arr));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::listRedeemRecords(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
|
services::RedeemService redeem(csp::AppState::Instance().db());
|
|
const auto rows = redeem.ListRecordsByUser(*user_id, limit);
|
|
|
|
Json::Value arr(Json::arrayValue);
|
|
for (const auto &row : rows) {
|
|
Json::Value j;
|
|
j["id"] = Json::Int64(row.id);
|
|
j["user_id"] = Json::Int64(row.user_id);
|
|
j["item_id"] = Json::Int64(row.item_id);
|
|
j["item_name"] = row.item_name;
|
|
j["quantity"] = row.quantity;
|
|
j["day_type"] = row.day_type;
|
|
j["unit_cost"] = row.unit_cost;
|
|
j["total_cost"] = row.total_cost;
|
|
j["note"] = row.note;
|
|
j["created_at"] = Json::Int64(row.created_at);
|
|
arr.append(j);
|
|
}
|
|
cb(JsonOk(arr));
|
|
} catch (const std::invalid_argument &) {
|
|
cb(JsonError(drogon::k400BadRequest, "invalid query parameter"));
|
|
} catch (const std::out_of_range &) {
|
|
cb(JsonError(drogon::k400BadRequest, "query parameter out of range"));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::createRedeemRecord(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
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;
|
|
}
|
|
|
|
services::RedeemRequest request;
|
|
request.user_id = *user_id;
|
|
request.item_id = (*json).get("item_id", 0).asInt64();
|
|
request.quantity = (*json).get("quantity", 1).asInt();
|
|
request.day_type = (*json).get("day_type", "studyday").asString();
|
|
request.note = (*json).get("note", "").asString();
|
|
|
|
services::RedeemService redeem(csp::AppState::Instance().db());
|
|
const auto row = redeem.Redeem(request);
|
|
|
|
services::UserService users(csp::AppState::Instance().db());
|
|
const auto user = users.GetById(*user_id);
|
|
|
|
Json::Value j;
|
|
j["id"] = Json::Int64(row.id);
|
|
j["user_id"] = Json::Int64(row.user_id);
|
|
j["item_id"] = Json::Int64(row.item_id);
|
|
j["item_name"] = row.item_name;
|
|
j["quantity"] = row.quantity;
|
|
j["day_type"] = row.day_type;
|
|
j["unit_cost"] = row.unit_cost;
|
|
j["total_cost"] = row.total_cost;
|
|
j["note"] = row.note;
|
|
j["created_at"] = Json::Int64(row.created_at);
|
|
if (user.has_value()) {
|
|
j["rating_after"] = user->rating;
|
|
}
|
|
cb(JsonOk(j));
|
|
} catch (const std::runtime_error &e) {
|
|
cb(JsonError(drogon::k400BadRequest, e.what()));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::listDailyTasks(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
services::DailyTaskService tasks(csp::AppState::Instance().db());
|
|
const auto rows = tasks.ListTodayTasks(*user_id);
|
|
|
|
Json::Value arr(Json::arrayValue);
|
|
int total_reward = 0;
|
|
int gained_reward = 0;
|
|
for (const auto &row : rows) {
|
|
Json::Value j;
|
|
j["code"] = row.code;
|
|
j["title"] = row.title;
|
|
j["description"] = row.description;
|
|
j["reward"] = row.reward;
|
|
j["completed"] = row.completed;
|
|
if (row.completed) {
|
|
j["completed_at"] = Json::Int64(row.completed_at);
|
|
gained_reward += row.reward;
|
|
} else {
|
|
j["completed_at"] = Json::nullValue;
|
|
}
|
|
total_reward += row.reward;
|
|
arr.append(j);
|
|
}
|
|
|
|
Json::Value out;
|
|
out["day_key"] = tasks.CurrentDayKey();
|
|
out["total_reward"] = total_reward;
|
|
out["gained_reward"] = gained_reward;
|
|
out["tasks"] = arr;
|
|
cb(JsonOk(out));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::listWrongBook(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
|
const auto rows = wrong_book.ListByUser(*user_id);
|
|
|
|
Json::Value arr(Json::arrayValue);
|
|
for (const auto &row : rows) {
|
|
Json::Value item = domain::ToJson(row.item);
|
|
item["problem_title"] = row.problem_title;
|
|
arr.append(item);
|
|
}
|
|
|
|
cb(JsonOk(arr));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::upsertWrongBookNote(
|
|
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.size() > 4000) {
|
|
cb(JsonError(drogon::k400BadRequest, "note too long"));
|
|
return;
|
|
}
|
|
|
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
|
wrong_book.UpsertNote(*user_id, problem_id, note);
|
|
|
|
Json::Value data;
|
|
data["user_id"] = Json::Int64(*user_id);
|
|
data["problem_id"] = Json::Int64(problem_id);
|
|
data["note"] = note;
|
|
cb(JsonOk(data));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Get previous score to calculate rating delta
|
|
const int prev_rating = wrong_book.GetNoteRating(*user_id, problem_id);
|
|
wrong_book.UpsertNoteScore(*user_id, problem_id, result.score, result.rating, result.feedback_md);
|
|
|
|
// Award (or adjust) rating points: delta = new_rating - prev_rating
|
|
const int delta = result.rating - prev_rating;
|
|
if (delta != 0) {
|
|
wrong_book.AwardNoteRating(*user_id, problem_id, delta);
|
|
}
|
|
|
|
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;
|
|
|
|
drogon::MultiPartParser parser;
|
|
if (parser.parse(req) != 0) {
|
|
cb(JsonError(drogon::k400BadRequest, "bad multipart"));
|
|
return;
|
|
}
|
|
const auto& files = parser.getFiles();
|
|
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;
|
|
|
|
// Allow common image extensions only (frontend also restricts accept=image/*)
|
|
std::string name_for_ext = f.getFileName();
|
|
auto dot = name_for_ext.find_last_of('.');
|
|
std::string ext_check = (dot == std::string::npos) ? std::string("") : name_for_ext.substr(dot);
|
|
for (auto &c : ext_check) c = (char)std::tolower((unsigned char)c);
|
|
if (!(ext_check==".png" || ext_check==".jpg" || ext_check==".jpeg" || ext_check==".gif" || ext_check==".webp")) {
|
|
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,
|
|
int64_t problem_id) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
services::WrongBookService wrong_book(csp::AppState::Instance().db());
|
|
wrong_book.Remove(*user_id, problem_id);
|
|
|
|
Json::Value data;
|
|
data["user_id"] = Json::Int64(*user_id);
|
|
data["problem_id"] = Json::Int64(problem_id);
|
|
data["deleted"] = true;
|
|
cb(JsonOk(data));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
|
|
void MeController::listRatingHistory(
|
|
const drogon::HttpRequestPtr &req,
|
|
std::function<void(const drogon::HttpResponsePtr &)> &&cb) {
|
|
try {
|
|
const auto user_id = RequireAuth(req, cb);
|
|
if (!user_id.has_value())
|
|
return;
|
|
|
|
const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500);
|
|
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
|
|
const auto rows = access_svc.ListRatingHistory(*user_id, limit);
|
|
|
|
Json::Value arr(Json::arrayValue);
|
|
for (const auto &row : rows) {
|
|
Json::Value j;
|
|
j["type"] = row.type;
|
|
j["created_at"] = Json::Int64(row.created_at);
|
|
j["change"] = row.change;
|
|
j["note"] = row.note;
|
|
arr.append(j);
|
|
}
|
|
cb(JsonOk(arr));
|
|
} catch (const std::exception &e) {
|
|
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
|
}
|
|
}
|
|
} // namespace csp::controllers
|