feat: add daily tasks and fix /admin139 admin entry
这个提交包含在:
@@ -2,12 +2,16 @@
|
||||
|
||||
#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/user_service.h"
|
||||
#include "csp/services/wrong_book_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace csp::controllers {
|
||||
@@ -44,6 +48,15 @@ std::optional<int64_t> RequireAuth(const drogon::HttpRequestPtr& req,
|
||||
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(
|
||||
@@ -66,6 +79,161 @@ void MeController::profile(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
#include "csp/app_state.h"
|
||||
#include "csp/domain/enum_strings.h"
|
||||
#include "csp/domain/json.h"
|
||||
#include "csp/services/kb_import_runner.h"
|
||||
#include "csp/services/problem_gen_runner.h"
|
||||
#include "csp/services/problem_service.h"
|
||||
#include "csp/services/problem_solution_runner.h"
|
||||
#include "csp/services/problem_workspace_service.h"
|
||||
#include "csp/services/submission_service.h"
|
||||
#include "http_auth.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
@@ -25,6 +29,24 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode 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;
|
||||
}
|
||||
|
||||
int ParsePositiveInt(const std::string& s,
|
||||
int default_value,
|
||||
int min_value,
|
||||
int max_value) {
|
||||
if (s.empty()) return default_value;
|
||||
const int v = std::stoi(s);
|
||||
return std::max(min_value, std::min(max_value, v));
|
||||
}
|
||||
|
||||
Json::Value BuildOpenApiSpec() {
|
||||
Json::Value root;
|
||||
root["openapi"] = "3.1.0";
|
||||
@@ -54,11 +76,28 @@ Json::Value BuildOpenApiSpec() {
|
||||
paths["/api/v1/run/cpp"]["post"]["summary"] = "C++ 试运行";
|
||||
paths["/api/v1/submissions"]["get"]["summary"] = "提交记录";
|
||||
paths["/api/v1/submissions/{id}"]["get"]["summary"] = "提交详情";
|
||||
paths["/api/v1/submissions/{id}/analysis"]["post"]["summary"] = "提交评测建议(LLM)";
|
||||
paths["/api/v1/admin/users"]["get"]["summary"] = "管理员用户列表";
|
||||
paths["/api/v1/admin/users/{id}/rating"]["patch"]["summary"] = "管理员修改用户积分";
|
||||
paths["/api/v1/admin/redeem-items"]["get"]["summary"] = "管理员查看积分兑换物品";
|
||||
paths["/api/v1/admin/redeem-items"]["post"]["summary"] = "管理员新增积分兑换物品";
|
||||
paths["/api/v1/admin/redeem-items/{id}"]["patch"]["summary"] = "管理员修改积分兑换物品";
|
||||
paths["/api/v1/admin/redeem-items/{id}"]["delete"]["summary"] = "管理员下架积分兑换物品";
|
||||
paths["/api/v1/admin/redeem-records"]["get"]["summary"] = "管理员查看兑换记录";
|
||||
paths["/api/v1/me/redeem/items"]["get"]["summary"] = "我的可兑换物品列表";
|
||||
paths["/api/v1/me/redeem/records"]["get"]["summary"] = "我的兑换记录";
|
||||
paths["/api/v1/me/redeem/records"]["post"]["summary"] = "创建兑换记录";
|
||||
paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表";
|
||||
|
||||
paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务";
|
||||
paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务";
|
||||
paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态";
|
||||
paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题(RAG+去重)";
|
||||
paths["/api/v1/backend/logs"]["get"]["summary"] = "后台日志(题解任务队列)";
|
||||
paths["/api/v1/backend/kb/refresh"]["get"]["summary"] = "知识库资料更新状态";
|
||||
paths["/api/v1/backend/kb/refresh"]["post"]["summary"] = "手动一键更新知识库资料";
|
||||
paths["/api/v1/backend/solutions/generate-missing"]["post"]["summary"] =
|
||||
"异步补全所有缺失题解";
|
||||
|
||||
paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口";
|
||||
|
||||
@@ -130,6 +169,205 @@ void MetaController::openapi(
|
||||
cb(resp);
|
||||
}
|
||||
|
||||
void MetaController::backendLogs(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
try {
|
||||
const int limit =
|
||||
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
|
||||
const int running_limit =
|
||||
ParsePositiveInt(req->getParameter("running_limit"), 20, 1, 200);
|
||||
const int queued_limit =
|
||||
ParsePositiveInt(req->getParameter("queued_limit"), 100, 1, 1000);
|
||||
|
||||
services::ProblemWorkspaceService workspace(csp::AppState::Instance().db());
|
||||
const auto jobs = workspace.ListRecentSolutionJobs(limit);
|
||||
const auto running_jobs = workspace.ListSolutionJobsByStatus(
|
||||
"running", running_limit);
|
||||
const auto queued_jobs = workspace.ListSolutionJobsByStatus(
|
||||
"queued", queued_limit);
|
||||
auto& runner = services::ProblemSolutionRunner::Instance();
|
||||
|
||||
Json::Value items(Json::arrayValue);
|
||||
for (const auto& job : jobs) {
|
||||
Json::Value j;
|
||||
j["id"] = Json::Int64(job.id);
|
||||
j["problem_id"] = Json::Int64(job.problem_id);
|
||||
j["problem_title"] = job.problem_title;
|
||||
j["status"] = job.status;
|
||||
j["progress"] = job.progress;
|
||||
j["message"] = job.message;
|
||||
j["created_by"] = Json::Int64(job.created_by);
|
||||
j["max_solutions"] = job.max_solutions;
|
||||
j["created_at"] = Json::Int64(job.created_at);
|
||||
if (job.started_at.has_value()) {
|
||||
j["started_at"] = Json::Int64(*job.started_at);
|
||||
} else {
|
||||
j["started_at"] = Json::nullValue;
|
||||
}
|
||||
if (job.finished_at.has_value()) {
|
||||
j["finished_at"] = Json::Int64(*job.finished_at);
|
||||
} else {
|
||||
j["finished_at"] = Json::nullValue;
|
||||
}
|
||||
j["updated_at"] = Json::Int64(job.updated_at);
|
||||
j["runner_pending"] = runner.IsRunning(job.problem_id);
|
||||
items.append(j);
|
||||
}
|
||||
|
||||
Json::Value running_items(Json::arrayValue);
|
||||
Json::Value running_problem_ids(Json::arrayValue);
|
||||
for (const auto& job : running_jobs) {
|
||||
Json::Value j;
|
||||
j["id"] = Json::Int64(job.id);
|
||||
j["problem_id"] = Json::Int64(job.problem_id);
|
||||
j["problem_title"] = job.problem_title;
|
||||
j["status"] = job.status;
|
||||
j["progress"] = job.progress;
|
||||
j["message"] = job.message;
|
||||
j["updated_at"] = Json::Int64(job.updated_at);
|
||||
if (job.started_at.has_value()) {
|
||||
j["started_at"] = Json::Int64(*job.started_at);
|
||||
} else {
|
||||
j["started_at"] = Json::nullValue;
|
||||
}
|
||||
running_items.append(j);
|
||||
running_problem_ids.append(Json::Int64(job.problem_id));
|
||||
}
|
||||
|
||||
Json::Value queued_items(Json::arrayValue);
|
||||
Json::Value queued_problem_ids(Json::arrayValue);
|
||||
for (const auto& job : queued_jobs) {
|
||||
Json::Value j;
|
||||
j["id"] = Json::Int64(job.id);
|
||||
j["problem_id"] = Json::Int64(job.problem_id);
|
||||
j["problem_title"] = job.problem_title;
|
||||
j["status"] = job.status;
|
||||
j["progress"] = job.progress;
|
||||
j["message"] = job.message;
|
||||
j["updated_at"] = Json::Int64(job.updated_at);
|
||||
queued_items.append(j);
|
||||
queued_problem_ids.append(Json::Int64(job.problem_id));
|
||||
}
|
||||
|
||||
Json::Value payload;
|
||||
payload["items"] = items;
|
||||
payload["running_jobs"] = running_items;
|
||||
payload["queued_jobs"] = queued_items;
|
||||
payload["running_problem_ids"] = running_problem_ids;
|
||||
payload["queued_problem_ids"] = queued_problem_ids;
|
||||
payload["running_count"] = static_cast<int>(running_jobs.size());
|
||||
payload["queued_count_preview"] = static_cast<int>(queued_jobs.size());
|
||||
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
|
||||
payload["missing_problems"] = workspace.CountProblemsWithoutSolutions();
|
||||
payload["limit"] = limit;
|
||||
payload["running_limit"] = running_limit;
|
||||
payload["queued_limit"] = queued_limit;
|
||||
cb(JsonOk(payload));
|
||||
} 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 MetaController::kbRefreshStatus(
|
||||
const drogon::HttpRequestPtr& /*req*/,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
try {
|
||||
const auto& runner = services::KbImportRunner::Instance();
|
||||
Json::Value payload;
|
||||
payload["running"] = runner.IsRunning();
|
||||
payload["last_command"] = runner.LastCommand();
|
||||
payload["last_trigger"] = runner.LastTrigger();
|
||||
if (const auto rc = runner.LastExitCode(); rc.has_value()) {
|
||||
payload["last_exit_code"] = *rc;
|
||||
} else {
|
||||
payload["last_exit_code"] = Json::nullValue;
|
||||
}
|
||||
payload["last_started_at"] = Json::Int64(runner.LastStartedAt());
|
||||
payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt());
|
||||
cb(JsonOk(payload));
|
||||
} catch (const std::exception& e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MetaController::triggerKbRefresh(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
try {
|
||||
std::string auth_error;
|
||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
||||
if (!user_id.has_value()) {
|
||||
cb(JsonError(drogon::k401Unauthorized, auth_error));
|
||||
return;
|
||||
}
|
||||
|
||||
auto& runner = services::KbImportRunner::Instance();
|
||||
const bool started = runner.TriggerAsync("manual");
|
||||
|
||||
Json::Value payload;
|
||||
payload["started"] = started;
|
||||
payload["message"] = started ? "已触发异步资料更新" : "当前已有资料更新任务在运行中";
|
||||
payload["running"] = runner.IsRunning();
|
||||
payload["last_command"] = runner.LastCommand();
|
||||
payload["last_trigger"] = runner.LastTrigger();
|
||||
if (const auto rc = runner.LastExitCode(); rc.has_value()) {
|
||||
payload["last_exit_code"] = *rc;
|
||||
} else {
|
||||
payload["last_exit_code"] = Json::nullValue;
|
||||
}
|
||||
payload["last_started_at"] = Json::Int64(runner.LastStartedAt());
|
||||
payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt());
|
||||
cb(JsonOk(payload));
|
||||
} catch (const std::exception& e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MetaController::triggerMissingSolutions(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
try {
|
||||
std::string auth_error;
|
||||
const auto user_id = GetAuthedUserId(req, auth_error);
|
||||
if (!user_id.has_value()) {
|
||||
cb(JsonError(drogon::k401Unauthorized, auth_error));
|
||||
return;
|
||||
}
|
||||
|
||||
int limit = 50000;
|
||||
int max_solutions = 3;
|
||||
const auto json = req->getJsonObject();
|
||||
if (json && (*json).isMember("limit")) {
|
||||
limit = std::max(1, std::min(200000, (*json)["limit"].asInt()));
|
||||
}
|
||||
if (json && (*json).isMember("max_solutions")) {
|
||||
max_solutions = std::max(1, std::min(5, (*json)["max_solutions"].asInt()));
|
||||
}
|
||||
|
||||
auto& db = csp::AppState::Instance().db();
|
||||
auto& runner = services::ProblemSolutionRunner::Instance();
|
||||
const auto summary = runner.TriggerMissingAsync(
|
||||
db, *user_id, max_solutions, limit);
|
||||
|
||||
Json::Value payload;
|
||||
payload["started"] = true;
|
||||
payload["missing_total"] = summary.missing_total;
|
||||
payload["candidate_count"] = summary.candidate_count;
|
||||
payload["queued_count"] = summary.queued_count;
|
||||
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
|
||||
payload["limit"] = limit;
|
||||
payload["max_solutions"] = max_solutions;
|
||||
cb(JsonOk(payload));
|
||||
} catch (const std::exception& e) {
|
||||
cb(JsonError(drogon::k500InternalServerError, e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void MetaController::mcp(
|
||||
const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户