feat: add daily tasks and fix /admin139 admin entry
这个提交包含在:
@@ -19,10 +19,15 @@ add_library(csp_core
|
||||
src/services/wrong_book_service.cc
|
||||
src/services/kb_service.cc
|
||||
src/services/contest_service.cc
|
||||
src/services/daily_task_service.cc
|
||||
src/services/submission_service.cc
|
||||
src/services/solution_access_service.cc
|
||||
src/services/redeem_service.cc
|
||||
src/services/problem_workspace_service.cc
|
||||
src/services/problem_solution_runner.cc
|
||||
src/services/kb_import_runner.cc
|
||||
src/services/problem_gen_runner.cc
|
||||
src/services/submission_feedback_service.cc
|
||||
src/services/import_service.cc
|
||||
src/services/import_runner.cc
|
||||
src/domain/enum_strings.cc
|
||||
@@ -46,6 +51,7 @@ add_library(csp_web
|
||||
src/controllers/me_controller.cc
|
||||
src/controllers/contest_controller.cc
|
||||
src/controllers/leaderboard_controller.cc
|
||||
src/controllers/admin_controller.cc
|
||||
src/controllers/kb_controller.cc
|
||||
src/controllers/import_controller.cc
|
||||
src/controllers/meta_controller.cc
|
||||
@@ -87,6 +93,7 @@ add_executable(csp_tests
|
||||
tests/problem_service_test.cc
|
||||
tests/kb_service_test.cc
|
||||
tests/contest_service_test.cc
|
||||
tests/daily_task_service_test.cc
|
||||
tests/submission_service_test.cc
|
||||
tests/me_http_test.cc
|
||||
tests/problem_http_test.cc
|
||||
|
||||
@@ -10,6 +10,10 @@ class MeController : public drogon::HttpController<MeController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post);
|
||||
ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get);
|
||||
ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch);
|
||||
ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete);
|
||||
@@ -18,6 +22,18 @@ class MeController : public drogon::HttpController<MeController> {
|
||||
void profile(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void listRedeemItems(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void listRedeemRecords(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void createRedeemRecord(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void listDailyTasks(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
void listWrongBook(const drogon::HttpRequestPtr& req,
|
||||
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace csp::services {
|
||||
|
||||
struct DailyTaskItem {
|
||||
std::string code;
|
||||
std::string title;
|
||||
std::string description;
|
||||
int reward = 1;
|
||||
bool completed = false;
|
||||
int64_t completed_at = 0;
|
||||
};
|
||||
|
||||
class DailyTaskService {
|
||||
public:
|
||||
static constexpr const char* kTaskLoginCheckin = "login_checkin";
|
||||
static constexpr const char* kTaskDailySubmit = "daily_submit";
|
||||
static constexpr const char* kTaskFirstAc = "first_ac";
|
||||
static constexpr const char* kTaskCodeQuality = "code_quality";
|
||||
|
||||
explicit DailyTaskService(db::SqliteDb& db) : db_(db) {}
|
||||
|
||||
std::string CurrentDayKey() const;
|
||||
std::vector<DailyTaskItem> ListTodayTasks(int64_t user_id) const;
|
||||
|
||||
// Returns true when the task is completed for the first time today
|
||||
// (and reward is granted), false when already completed.
|
||||
bool CompleteTaskIfFirstToday(int64_t user_id, const std::string& task_code);
|
||||
|
||||
private:
|
||||
db::SqliteDb& db_;
|
||||
};
|
||||
|
||||
} // namespace csp::services
|
||||
@@ -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) {
|
||||
|
||||
@@ -208,6 +208,47 @@ void InsertContestProblem(sqlite3* db,
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
void InsertRedeemItem(sqlite3* db,
|
||||
const std::string& name,
|
||||
const std::string& description,
|
||||
const std::string& unit_label,
|
||||
int holiday_cost,
|
||||
int studyday_cost,
|
||||
int is_active,
|
||||
int is_global,
|
||||
int64_t created_by,
|
||||
int64_t created_at) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO redeem_items("
|
||||
"name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at"
|
||||
") VALUES(?,?,?,?,?,?,?,?,?,?)";
|
||||
ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare insert redeem_item");
|
||||
ThrowSqlite(sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind redeem_item.name");
|
||||
ThrowSqlite(sqlite3_bind_text(stmt, 2, description.c_str(), -1, SQLITE_TRANSIENT),
|
||||
db, "bind redeem_item.description");
|
||||
ThrowSqlite(sqlite3_bind_text(stmt, 3, unit_label.c_str(), -1, SQLITE_TRANSIENT),
|
||||
db, "bind redeem_item.unit_label");
|
||||
ThrowSqlite(sqlite3_bind_int(stmt, 4, holiday_cost), db,
|
||||
"bind redeem_item.holiday_cost");
|
||||
ThrowSqlite(sqlite3_bind_int(stmt, 5, studyday_cost), db,
|
||||
"bind redeem_item.studyday_cost");
|
||||
ThrowSqlite(sqlite3_bind_int(stmt, 6, is_active), db,
|
||||
"bind redeem_item.is_active");
|
||||
ThrowSqlite(sqlite3_bind_int(stmt, 7, is_global), db,
|
||||
"bind redeem_item.is_global");
|
||||
ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_by), db,
|
||||
"bind redeem_item.created_by");
|
||||
ThrowSqlite(sqlite3_bind_int64(stmt, 9, created_at), db,
|
||||
"bind redeem_item.created_at");
|
||||
ThrowSqlite(sqlite3_bind_int64(stmt, 10, created_at), db,
|
||||
"bind redeem_item.updated_at");
|
||||
ThrowSqlite(sqlite3_step(stmt), db, "insert redeem_item");
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SqliteDb SqliteDb::OpenFile(const std::string& path) {
|
||||
@@ -446,6 +487,71 @@ CREATE TABLE IF NOT EXISTS problem_solutions (
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS problem_solution_view_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
problem_id INTEGER NOT NULL,
|
||||
day_key TEXT NOT NULL,
|
||||
viewed_at INTEGER NOT NULL,
|
||||
charged INTEGER NOT NULL DEFAULT 0,
|
||||
cost INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS submission_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL UNIQUE,
|
||||
feedback_md TEXT NOT NULL DEFAULT "",
|
||||
links_json TEXT NOT NULL DEFAULT "[]",
|
||||
model_name TEXT NOT NULL DEFAULT "",
|
||||
status TEXT NOT NULL DEFAULT "ready",
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(submission_id) REFERENCES submissions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redeem_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT "",
|
||||
unit_label TEXT NOT NULL DEFAULT "小时",
|
||||
holiday_cost INTEGER NOT NULL DEFAULT 5,
|
||||
studyday_cost INTEGER NOT NULL DEFAULT 25,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_global INTEGER NOT NULL DEFAULT 1,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redeem_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
day_type TEXT NOT NULL DEFAULT "studyday",
|
||||
unit_cost INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT "",
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(item_id) REFERENCES redeem_items(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_task_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
task_code TEXT NOT NULL,
|
||||
day_key TEXT NOT NULL,
|
||||
reward INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, task_code, day_key)
|
||||
);
|
||||
)SQL");
|
||||
|
||||
// Backward-compatible schema upgrades for existing deployments.
|
||||
@@ -494,6 +600,11 @@ CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(j
|
||||
CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_problem ON problem_solution_view_logs(user_id, problem_id, viewed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_day ON problem_solution_view_logs(user_id, day_key, viewed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_items_active ON redeem_items(is_active, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_records_user_created ON redeem_records(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_task_logs_user_day ON daily_task_logs(user_id, day_key, created_at DESC);
|
||||
)SQL");
|
||||
}
|
||||
|
||||
@@ -602,6 +713,20 @@ void SeedDemoData(SqliteDb& db) {
|
||||
if (contest_id && p1) InsertContestProblem(raw, *contest_id, *p1, 1);
|
||||
if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2);
|
||||
}
|
||||
|
||||
if (CountRows(raw, "redeem_items") == 0) {
|
||||
InsertRedeemItem(
|
||||
raw,
|
||||
"私人玩游戏时间",
|
||||
"全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。",
|
||||
"小时",
|
||||
5,
|
||||
25,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
now);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace csp::db
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "csp/services/auth_service.h"
|
||||
|
||||
#include "csp/services/daily_task_service.h"
|
||||
#include "csp/services/crypto.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
@@ -113,9 +114,44 @@ AuthResult AuthService::Login(const std::string& username,
|
||||
CheckSqlite(sqlite3_step(ins), db, "insert session");
|
||||
sqlite3_finalize(ins);
|
||||
|
||||
try {
|
||||
DailyTaskService daily(db_);
|
||||
daily.CompleteTaskIfFirstToday(user_id, DailyTaskService::kTaskLoginCheckin);
|
||||
} catch (...) {
|
||||
// Login should not fail because of optional daily-task reward.
|
||||
}
|
||||
|
||||
return AuthResult{.user_id = user_id, .token = token, .expires_at = expires};
|
||||
}
|
||||
|
||||
void AuthService::ResetPassword(const std::string& username,
|
||||
const std::string& new_password) {
|
||||
if (username.empty() || new_password.size() < 6) {
|
||||
throw std::runtime_error("invalid username or password");
|
||||
}
|
||||
|
||||
const auto salt = crypto::RandomHex(16);
|
||||
const auto hash = crypto::Sha256Hex(salt + ":" + new_password);
|
||||
|
||||
sqlite3* db = db_.raw();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"UPDATE users SET password_salt=?,password_hash=? WHERE username=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare reset password");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 1, salt.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind salt");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 2, hash.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind hash");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 3, username.c_str(), -1, SQLITE_TRANSIENT),
|
||||
db, "bind username");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "exec reset password");
|
||||
sqlite3_finalize(stmt);
|
||||
if (sqlite3_changes(db) <= 0) {
|
||||
throw std::runtime_error("user not found");
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<int> AuthService::VerifyToken(const std::string& token) {
|
||||
if (token.empty()) return std::nullopt;
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "csp/services/daily_task_service.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace csp::services {
|
||||
|
||||
namespace {
|
||||
|
||||
struct DailyTaskDef {
|
||||
const char* code;
|
||||
const char* title;
|
||||
const char* description;
|
||||
int reward;
|
||||
};
|
||||
|
||||
constexpr std::array<DailyTaskDef, 4> kTaskDefs = {{
|
||||
{DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1},
|
||||
{DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1},
|
||||
{DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1},
|
||||
{DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1},
|
||||
}};
|
||||
|
||||
int64_t NowSec() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
||||
if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return;
|
||||
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||
}
|
||||
|
||||
const DailyTaskDef* FindTask(const std::string& code) {
|
||||
for (const auto& def : kTaskDefs) {
|
||||
if (code == def.code) return &def;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string BuildDayKey(int64_t ts_sec) {
|
||||
// Use UTC+8 day boundary for CSP users.
|
||||
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
|
||||
std::tm tmv{};
|
||||
gmtime_r(&shifted, &tmv);
|
||||
std::ostringstream out;
|
||||
out << std::setw(4) << std::setfill('0') << (tmv.tm_year + 1900) << '-'
|
||||
<< std::setw(2) << std::setfill('0') << (tmv.tm_mon + 1) << '-'
|
||||
<< std::setw(2) << std::setfill('0') << tmv.tm_mday;
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void AddRating(sqlite3* db, int64_t user_id, int delta) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "UPDATE users SET rating=rating+? WHERE id=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare add daily task rating");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind rating delta");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "exec add daily task rating");
|
||||
sqlite3_finalize(stmt);
|
||||
if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string DailyTaskService::CurrentDayKey() const {
|
||||
return BuildDayKey(NowSec());
|
||||
}
|
||||
|
||||
std::vector<DailyTaskItem> DailyTaskService::ListTodayTasks(int64_t user_id) const {
|
||||
if (user_id <= 0) throw std::runtime_error("invalid user_id");
|
||||
|
||||
sqlite3* db = db_.raw();
|
||||
const std::string day_key = CurrentDayKey();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"SELECT task_code,created_at,reward FROM daily_task_logs WHERE user_id=? AND day_key=?";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||
"prepare list daily tasks");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind day_key");
|
||||
|
||||
std::unordered_map<std::string, std::pair<int64_t, int>> done;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const unsigned char* raw = sqlite3_column_text(stmt, 0);
|
||||
std::string code = raw ? reinterpret_cast<const char*>(raw) : std::string();
|
||||
const int64_t created_at = sqlite3_column_int64(stmt, 1);
|
||||
const int reward = sqlite3_column_int(stmt, 2);
|
||||
done[code] = {created_at, reward};
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
std::vector<DailyTaskItem> out;
|
||||
out.reserve(kTaskDefs.size());
|
||||
for (const auto& def : kTaskDefs) {
|
||||
DailyTaskItem item;
|
||||
item.code = def.code;
|
||||
item.title = def.title;
|
||||
item.description = def.description;
|
||||
item.reward = def.reward;
|
||||
const auto it = done.find(item.code);
|
||||
if (it != done.end()) {
|
||||
item.completed = true;
|
||||
item.completed_at = it->second.first;
|
||||
item.reward = it->second.second > 0 ? it->second.second : def.reward;
|
||||
}
|
||||
out.push_back(std::move(item));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool DailyTaskService::CompleteTaskIfFirstToday(int64_t user_id,
|
||||
const std::string& task_code) {
|
||||
if (user_id <= 0) throw std::runtime_error("invalid user_id");
|
||||
const auto* task = FindTask(task_code);
|
||||
if (!task) throw std::runtime_error("unknown daily task");
|
||||
|
||||
sqlite3* db = db_.raw();
|
||||
const int64_t now = NowSec();
|
||||
const std::string day_key = BuildDayKey(now);
|
||||
|
||||
db_.Exec("BEGIN IMMEDIATE");
|
||||
bool committed = false;
|
||||
try {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* ins_sql =
|
||||
"INSERT OR IGNORE INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) "
|
||||
"VALUES(?,?,?,?,?)";
|
||||
CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db,
|
||||
"prepare insert daily task");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 2, task->code, -1, SQLITE_STATIC), db,
|
||||
"bind task_code");
|
||||
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||
"bind day_key");
|
||||
CheckSqlite(sqlite3_bind_int(stmt, 4, task->reward), db, "bind reward");
|
||||
CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at");
|
||||
CheckSqlite(sqlite3_step(stmt), db, "insert daily task");
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
const bool inserted = sqlite3_changes(db) > 0;
|
||||
if (inserted) {
|
||||
AddRating(db, user_id, task->reward);
|
||||
}
|
||||
|
||||
db_.Exec("COMMIT");
|
||||
committed = true;
|
||||
return inserted;
|
||||
} catch (...) {
|
||||
if (!committed) {
|
||||
try {
|
||||
db_.Exec("ROLLBACK");
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace csp::services
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "csp/domain/enum_strings.h"
|
||||
#include "csp/services/crypto.h"
|
||||
#include "csp/services/daily_task_service.h"
|
||||
#include "csp/services/wrong_book_service.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
@@ -97,7 +98,8 @@ JudgeOutcome JudgeCpp(const std::string& code,
|
||||
JudgeOutcome outcome;
|
||||
try {
|
||||
const std::string compile_cmd =
|
||||
"g++ -std=c++20 -O2 \"" + src.string() + "\" -o \"" + bin.string() +
|
||||
"g++ -std=gnu++14 -O2 -Wall -Wextra -Wpedantic \"" + src.string() +
|
||||
"\" -o \"" + bin.string() +
|
||||
"\" 2> \"" + compile_log.string() + "\"";
|
||||
const int compile_rc = std::system(compile_cmd.c_str());
|
||||
outcome.compile_log = ReadFile(compile_log);
|
||||
@@ -210,6 +212,15 @@ void AddRating(sqlite3* db, int64_t user_id, int delta) {
|
||||
|
||||
std::string ToStatusText(domain::SubmissionStatus s) { return domain::ToString(s); }
|
||||
|
||||
int CountCodeLines(const std::string& code) {
|
||||
if (code.empty()) return 0;
|
||||
int lines = 1;
|
||||
for (const char ch : code) {
|
||||
if (ch == '\n') ++lines;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateRequest& req) {
|
||||
@@ -264,6 +275,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque
|
||||
|
||||
const int64_t submission_id = sqlite3_last_insert_rowid(db);
|
||||
|
||||
try {
|
||||
DailyTaskService daily(db_);
|
||||
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskDailySubmit);
|
||||
if (CountCodeLines(req.code) > 10) {
|
||||
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskCodeQuality);
|
||||
}
|
||||
} catch (...) {
|
||||
// Daily task reward should not interrupt judging.
|
||||
}
|
||||
|
||||
JudgeOutcome outcome = JudgeCpp(req.code, problem->sample_input, problem->sample_output);
|
||||
const int score = outcome.status == domain::SubmissionStatus::AC ? 100 : 0;
|
||||
|
||||
@@ -288,9 +309,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque
|
||||
|
||||
WrongBookService wb(db_);
|
||||
if (outcome.status == domain::SubmissionStatus::AC) {
|
||||
try {
|
||||
DailyTaskService daily(db_);
|
||||
daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskFirstAc);
|
||||
} catch (...) {
|
||||
// Keep AC flow resilient.
|
||||
}
|
||||
|
||||
wb.Remove(req.user_id, req.problem_id);
|
||||
if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) {
|
||||
AddRating(db, req.user_id, problem->difficulty * 10);
|
||||
AddRating(db, req.user_id, 2);
|
||||
}
|
||||
} else {
|
||||
wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "csp/db/sqlite_db.h"
|
||||
#include "csp/services/auth_service.h"
|
||||
#include "csp/services/daily_task_service.h"
|
||||
#include "csp/services/user_service.h"
|
||||
|
||||
TEST_CASE("daily task reward only once per day") {
|
||||
auto db = csp::db::SqliteDb::OpenMemory();
|
||||
csp::db::ApplyMigrations(db);
|
||||
|
||||
csp::services::AuthService auth(db);
|
||||
const auto user = auth.Register("daily_task_user", "password123");
|
||||
|
||||
csp::services::UserService users(db);
|
||||
csp::services::DailyTaskService daily(db);
|
||||
|
||||
const auto before = users.GetById(user.user_id);
|
||||
REQUIRE(before.has_value());
|
||||
// Register includes auto-login, which should complete login_checkin once.
|
||||
REQUIRE(before->rating == 1);
|
||||
|
||||
REQUIRE(daily.CompleteTaskIfFirstToday(user.user_id,
|
||||
csp::services::DailyTaskService::kTaskDailySubmit));
|
||||
REQUIRE_FALSE(daily.CompleteTaskIfFirstToday(
|
||||
user.user_id, csp::services::DailyTaskService::kTaskDailySubmit));
|
||||
|
||||
const auto after = users.GetById(user.user_id);
|
||||
REQUIRE(after.has_value());
|
||||
REQUIRE(after->rating == 2);
|
||||
|
||||
const auto tasks = daily.ListTodayTasks(user.user_id);
|
||||
REQUIRE(tasks.size() == 4);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ const nextConfig: NextConfig = {
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/admin139/:path*",
|
||||
// Keep /admin139 as frontend admin entry page, only proxy nested API paths.
|
||||
source: "/admin139/:path+",
|
||||
destination: `${backendInternal}/:path*`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminEntryPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl font-semibold">后台管理入口</h1>
|
||||
<p className="mt-3 text-sm text-zinc-600">
|
||||
默认管理员账号:<span className="font-medium text-zinc-900">admin</span>,密码:
|
||||
<span className="font-medium text-zinc-900">whoami139</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/auth">
|
||||
去登录
|
||||
</Link>
|
||||
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-users">
|
||||
用户积分管理
|
||||
</Link>
|
||||
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-redeem">
|
||||
积分兑换管理
|
||||
</Link>
|
||||
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/backend-logs">
|
||||
后台任务日志
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
@@ -12,43 +12,322 @@ type Me = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function MePage() {
|
||||
const [data, setData] = useState<Me | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
unit_label: string;
|
||||
holiday_cost: number;
|
||||
studyday_cost: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
type RedeemRecord = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
day_type: string;
|
||||
unit_cost: number;
|
||||
total_cost: number;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RedeemCreateResp = RedeemRecord & {
|
||||
rating_after?: number;
|
||||
};
|
||||
|
||||
type DailyTaskItem = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reward: number;
|
||||
completed: boolean;
|
||||
completed_at?: number | null;
|
||||
};
|
||||
|
||||
type DailyTaskPayload = {
|
||||
day_key: string;
|
||||
total_reward: number;
|
||||
gained_reward: number;
|
||||
tasks: DailyTaskItem[];
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const [token, setToken] = useState("");
|
||||
const [profile, setProfile] = useState<Me | null>(null);
|
||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||||
const [dailyDayKey, setDailyDayKey] = useState("");
|
||||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||||
const [dailyGainedReward, setDailyGainedReward] = useState(0);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redeemLoading, setRedeemLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => items.find((item) => item.id === selectedItemId) ?? null,
|
||||
[items, selectedItemId]
|
||||
);
|
||||
|
||||
const unitCost = useMemo(() => {
|
||||
if (!selectedItem) return 0;
|
||||
return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost;
|
||||
}, [dayType, selectedItem]);
|
||||
|
||||
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setMsg("");
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录");
|
||||
const d = await apiFetch<Me>("/api/v1/me", {}, token);
|
||||
setData(d);
|
||||
const tk = readToken();
|
||||
setToken(tk);
|
||||
if (!tk) throw new Error("请先登录");
|
||||
|
||||
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
|
||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||||
]);
|
||||
setProfile(me);
|
||||
setItems(redeemItems ?? []);
|
||||
setRecords(redeemRecords ?? []);
|
||||
setDailyTasks(daily?.tasks ?? []);
|
||||
setDailyDayKey(daily?.day_key ?? "");
|
||||
setDailyTotalReward(daily?.total_reward ?? 0);
|
||||
setDailyGainedReward(daily?.gained_reward ?? 0);
|
||||
|
||||
if ((redeemItems ?? []).length > 0) {
|
||||
setSelectedItemId((prev) => prev || redeemItems[0].id);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
const redeem = async () => {
|
||||
setRedeemLoading(true);
|
||||
setError("");
|
||||
setMsg("");
|
||||
try {
|
||||
if (!token) throw new Error("请先登录");
|
||||
if (!selectedItemId) throw new Error("请选择兑换物品");
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
|
||||
|
||||
const created = await apiFetch<RedeemCreateResp>(
|
||||
"/api/v1/me/redeem/records",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
item_id: selectedItemId,
|
||||
quantity,
|
||||
day_type: dayType,
|
||||
note,
|
||||
}),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
setMsg(
|
||||
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
|
||||
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
|
||||
}。`
|
||||
);
|
||||
setNote("");
|
||||
await loadAll();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setRedeemLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">我的信息</h1>
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">我的信息与积分兑换</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||
<p>ID: {data.id}</p>
|
||||
<p>用户名: {data.username}</p>
|
||||
<p>Rating: {data.rating}</p>
|
||||
<p>创建时间: {new Date(data.created_at * 1000).toLocaleString()}</p>
|
||||
</div>
|
||||
{profile && (
|
||||
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||
<p>ID: {profile.id}</p>
|
||||
<p>用户名: {profile.username}</p>
|
||||
<p>Rating: {profile.rating}</p>
|
||||
<p>创建时间: {fmtTs(profile.created_at)}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold">每日任务</h2>
|
||||
<p className="text-xs text-zinc-600">
|
||||
{dailyDayKey ? `${dailyDayKey} · ` : ""}已获 {dailyGainedReward}/{dailyTotalReward} 分
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 divide-y">
|
||||
{dailyTasks.map((task) => (
|
||||
<article key={task.code} className="py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
{task.title} · +{task.reward}
|
||||
</p>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{task.completed ? "已完成" : "未完成"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{task.description}</p>
|
||||
{task.completed && (
|
||||
<p className="mt-1 text-xs text-zinc-500">完成时间:{fmtTs(task.completed_at)}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{!loading && dailyTasks.length === 0 && (
|
||||
<p className="py-3 text-sm text-zinc-500">今日任务尚未初始化,请稍后刷新。</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-base font-semibold">积分兑换物品</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
>
|
||||
选中
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{item.description || "-"}</p>
|
||||
<p className="mt-1 text-xs text-zinc-700">假期:{item.holiday_cost} / {item.unit_label}</p>
|
||||
<p className="text-xs text-zinc-700">学习日:{item.studyday_cost} / {item.unit_label}</p>
|
||||
</article>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">管理员尚未配置可兑换物品。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border p-3">
|
||||
<h3 className="text-sm font-medium">兑换表单</h3>
|
||||
<div className="mt-2 grid gap-2 md:grid-cols-2">
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={selectedItemId}
|
||||
onChange={(e) => setSelectedItemId(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>请选择兑换物品</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||
>
|
||||
<option value="holiday">假期时间(按假期单价)</option>
|
||||
<option value="studyday">学习日/非节假日(按学习日单价)</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||
placeholder="兑换时长(小时)"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-zinc-600">
|
||||
当前单价:{unitCost} / 小时;预计扣分:{totalCost}
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
onClick={() => void redeem()}
|
||||
disabled={redeemLoading || !selectedItemId}
|
||||
>
|
||||
{redeemLoading ? "兑换中..." : "确认兑换"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold">兑换记录</h2>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
|
||||
onClick={() => void loadAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 divide-y">
|
||||
{records.map((row) => (
|
||||
<article key={row.id} className="py-2 text-sm">
|
||||
<p>
|
||||
#{row.id} · {row.item_name} · {row.quantity} 小时 · {row.day_type === "holiday" ? "假期" : "学习日"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">
|
||||
单价 {row.unit_cost},总扣分 {row.total_cost} · {fmtTs(row.created_at)}
|
||||
</p>
|
||||
{row.note && <p className="text-xs text-zinc-500">备注:{row.note}</p>}
|
||||
</article>
|
||||
))}
|
||||
{!loading && records.length === 0 && <p className="py-3 text-sm text-zinc-500">暂无兑换记录。</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户