feat: expand platform management, admin controls, and learning workflows

这个提交包含在:
Codex CLI
2026-02-15 15:41:56 +08:00
父节点 ad29a9f62d
当前提交 f209ae82da
修改 75 个文件,包含 9663 行新增794 行删除

查看文件

@@ -0,0 +1,54 @@
#pragma once
#include <drogon/HttpController.h>
#include <cstdint>
namespace csp::controllers {
class AdminController : public drogon::HttpController<AdminController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
ADD_METHOD_TO(AdminController::updateUserRating,
"/api/v1/admin/users/{1}/rating",
drogon::Patch);
ADD_METHOD_TO(AdminController::listRedeemItems, "/api/v1/admin/redeem-items", drogon::Get);
ADD_METHOD_TO(AdminController::createRedeemItem, "/api/v1/admin/redeem-items", drogon::Post);
ADD_METHOD_TO(AdminController::updateRedeemItem,
"/api/v1/admin/redeem-items/{1}",
drogon::Patch);
ADD_METHOD_TO(AdminController::deleteRedeemItem,
"/api/v1/admin/redeem-items/{1}",
drogon::Delete);
ADD_METHOD_TO(AdminController::listRedeemRecords,
"/api/v1/admin/redeem-records",
drogon::Get);
METHOD_LIST_END
void listUsers(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateUserRating(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id);
void listRedeemItems(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void createRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void updateRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id);
void deleteRedeemItem(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id);
void listRedeemRecords(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};
} // namespace csp::controllers

查看文件

@@ -8,12 +8,32 @@ class MetaController : public drogon::HttpController<MetaController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get);
ADD_METHOD_TO(MetaController::backendLogs, "/api/v1/backend/logs", drogon::Get);
ADD_METHOD_TO(MetaController::kbRefreshStatus, "/api/v1/backend/kb/refresh", drogon::Get);
ADD_METHOD_TO(MetaController::triggerKbRefresh, "/api/v1/backend/kb/refresh", drogon::Post);
ADD_METHOD_TO(MetaController::triggerMissingSolutions, "/api/v1/backend/solutions/generate-missing",
drogon::Post);
ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post);
METHOD_LIST_END
void openapi(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void backendLogs(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void kbRefreshStatus(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void triggerKbRefresh(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void triggerMissingSolutions(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
void mcp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};

查看文件

@@ -12,6 +12,7 @@ class SubmissionController : public drogon::HttpController<SubmissionController>
ADD_METHOD_TO(SubmissionController::submitProblem, "/api/v1/problems/{1}/submit", drogon::Post);
ADD_METHOD_TO(SubmissionController::listSubmissions, "/api/v1/submissions", drogon::Get);
ADD_METHOD_TO(SubmissionController::getSubmission, "/api/v1/submissions/{1}", drogon::Get);
ADD_METHOD_TO(SubmissionController::analyzeSubmission, "/api/v1/submissions/{1}/analysis", drogon::Post);
ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post);
METHOD_LIST_END
@@ -26,6 +27,10 @@ class SubmissionController : public drogon::HttpController<SubmissionController>
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id);
void analyzeSubmission(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id);
void runCpp(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb);
};

查看文件

@@ -73,6 +73,7 @@ struct Submission {
std::string code;
SubmissionStatus status = SubmissionStatus::Pending;
int32_t score = 0;
int32_t rating_delta = 0;
int32_t time_ms = 0;
int32_t memory_kb = 0;
std::string compile_log;

查看文件

@@ -20,6 +20,7 @@ class AuthService {
// Throws on error; for controller we will catch and convert to JSON.
AuthResult Register(const std::string& username, const std::string& password);
AuthResult Login(const std::string& username, const std::string& password);
void ResetPassword(const std::string& username, const std::string& new_password);
std::optional<int> VerifyToken(const std::string& token);

查看文件

@@ -8,7 +8,11 @@
namespace csp::services {
struct ImportRunOptions {
std::string mode = "luogu";
bool clear_all_problems = false;
std::string local_pdf_dir;
int target_total = 5000;
int workers = 3;
};
class ImportRunner {

查看文件

@@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
namespace csp::services {
class KbImportRunner {
public:
static KbImportRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(const std::string& trigger);
bool IsRunning() const;
std::string LastCommand() const;
std::optional<int> LastExitCode() const;
int64_t LastStartedAt() const;
int64_t LastFinishedAt() const;
std::string LastTrigger() const;
private:
KbImportRunner() = default;
mutable std::mutex mu_;
std::string db_path_;
bool running_ = false;
std::string last_command_;
std::optional<int> last_exit_code_;
int64_t last_started_at_ = 0;
int64_t last_finished_at_ = 0;
std::string last_trigger_;
};
} // namespace csp::services

查看文件

@@ -1,28 +1,59 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <cstddef>
#include <deque>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <unordered_map>
namespace csp::services {
class ProblemSolutionRunner {
public:
struct TriggerMissingSummary {
int missing_total = 0;
int candidate_count = 0;
int queued_count = 0;
};
static ProblemSolutionRunner& Instance();
void Configure(std::string db_path);
bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions);
TriggerMissingSummary TriggerMissingAsync(db::SqliteDb& db,
int64_t created_by,
int max_solutions,
int limit);
void AutoStartMissingIfEnabled(db::SqliteDb& db);
bool IsRunning(int64_t problem_id) const;
size_t PendingCount() const;
private:
struct Task {
int64_t problem_id = 0;
int64_t job_id = 0;
int max_solutions = 3;
};
ProblemSolutionRunner() = default;
void StartWorkerIfNeededLocked();
void WorkerLoop();
void RecoverQueuedJobsLocked();
void StartAutoPumpIfNeeded(db::SqliteDb* db, int max_solutions, int limit, int interval_sec);
std::string db_path_;
mutable std::mutex mu_;
std::set<int64_t> running_problem_ids_;
std::deque<Task> queue_;
std::unordered_map<int64_t, size_t> pending_problem_counts_;
size_t pending_jobs_ = 0;
bool worker_running_ = false;
bool recovered_from_db_ = false;
bool auto_pump_started_ = false;
};
} // namespace csp::services

查看文件

@@ -34,6 +34,7 @@ struct ProblemSolution {
struct ProblemSolutionJob {
int64_t id = 0;
int64_t problem_id = 0;
std::string problem_title;
std::string status;
int progress = 0;
std::string message;
@@ -60,7 +61,13 @@ class ProblemWorkspaceService {
int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions);
std::optional<ProblemSolutionJob> GetLatestSolutionJob(int64_t problem_id);
std::vector<ProblemSolutionJob> ListRecentSolutionJobs(int limit);
std::vector<ProblemSolutionJob> ListSolutionJobsByStatus(const std::string& status,
int limit);
std::vector<ProblemSolution> ListSolutions(int64_t problem_id);
int CountProblemsWithoutSolutions();
std::vector<int64_t> ListProblemIdsWithoutSolutions(int limit,
bool exclude_queued_or_running_jobs);
private:
db::SqliteDb& db_;

查看文件

@@ -0,0 +1,77 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace csp::services {
struct RedeemItem {
int64_t id = 0;
std::string name;
std::string description;
std::string unit_label;
int holiday_cost = 0;
int studyday_cost = 0;
bool is_active = true;
bool is_global = true;
int64_t created_by = 0;
int64_t created_at = 0;
int64_t updated_at = 0;
};
struct RedeemItemWrite {
std::string name;
std::string description;
std::string unit_label = "小时";
int holiday_cost = 5;
int studyday_cost = 25;
bool is_active = true;
bool is_global = true;
};
struct RedeemRecord {
int64_t id = 0;
int64_t user_id = 0;
int64_t item_id = 0;
std::string item_name;
int quantity = 1;
std::string day_type;
int unit_cost = 0;
int total_cost = 0;
std::string note;
int64_t created_at = 0;
std::string username;
};
struct RedeemRequest {
int64_t user_id = 0;
int64_t item_id = 0;
int quantity = 1;
std::string day_type = "studyday";
std::string note;
};
class RedeemService {
public:
explicit RedeemService(db::SqliteDb& db) : db_(db) {}
std::vector<RedeemItem> ListItems(bool include_inactive);
std::optional<RedeemItem> GetItemById(int64_t item_id);
RedeemItem CreateItem(int64_t admin_user_id, const RedeemItemWrite& input);
RedeemItem UpdateItem(int64_t item_id, const RedeemItemWrite& input);
void DeactivateItem(int64_t item_id);
std::vector<RedeemRecord> ListRecordsByUser(int64_t user_id, int limit);
std::vector<RedeemRecord> ListRecordsAll(std::optional<int64_t> user_id, int limit);
RedeemRecord Redeem(const RedeemRequest& request);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,44 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include <cstdint>
#include <optional>
#include <string>
namespace csp::services {
struct SolutionViewChargeResult {
bool granted = true;
bool charged = false;
bool daily_free = false;
int cost = 0;
int rating_before = 0;
int rating_after = 0;
int daily_used_count = 0;
int64_t viewed_at = 0;
std::string day_key;
std::string deny_reason;
};
struct SolutionViewStats {
bool has_viewed = false;
int total_views = 0;
int total_cost = 0;
std::optional<int64_t> last_viewed_at;
};
class SolutionAccessService {
public:
explicit SolutionAccessService(db::SqliteDb& db) : db_(db) {}
// Daily policy: first answer view is free, then each full view costs 2 rating.
SolutionViewChargeResult ConsumeSolutionView(int64_t user_id, int64_t problem_id);
SolutionViewStats QueryUserProblemViewStats(int64_t user_id, int64_t problem_id);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -0,0 +1,36 @@
#pragma once
#include "csp/db/sqlite_db.h"
#include "csp/domain/entities.h"
#include <cstdint>
#include <optional>
#include <string>
namespace csp::services {
struct SubmissionFeedback {
int64_t submission_id = 0;
std::string feedback_md;
std::string links_json;
std::string model_name;
std::string status;
int64_t created_at = 0;
int64_t updated_at = 0;
};
class SubmissionFeedbackService {
public:
explicit SubmissionFeedbackService(db::SqliteDb& db) : db_(db) {}
std::optional<SubmissionFeedback> GetBySubmissionId(int64_t submission_id);
SubmissionFeedback GenerateAndSave(const domain::Submission& submission,
const domain::Problem& problem,
bool force_refresh);
private:
db::SqliteDb& db_;
};
} // namespace csp::services

查看文件

@@ -9,12 +9,19 @@
namespace csp::services {
struct UserListResult {
std::vector<domain::GlobalLeaderboardEntry> items;
int total_count = 0;
};
class UserService {
public:
explicit UserService(db::SqliteDb& db) : db_(db) {}
std::optional<domain::User> GetById(int64_t id);
std::vector<domain::GlobalLeaderboardEntry> GlobalLeaderboard(int limit = 100);
UserListResult ListUsers(int page, int page_size);
void SetRating(int64_t user_id, int rating);
private:
db::SqliteDb& db_;

查看文件

@@ -0,0 +1,320 @@
#include "csp/controllers/admin_controller.h"
#include "csp/app_state.h"
#include "csp/services/redeem_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <cctype>
#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;
}
int ParseClampedInt(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));
}
bool ParseBoolLike(const std::string& raw, bool default_value) {
if (raw.empty()) return default_value;
std::string v = raw;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return default_value;
}
std::optional<int64_t> ParseOptionalInt64(const std::string& raw) {
if (raw.empty()) return std::nullopt;
return std::stoll(raw);
}
services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) {
services::RedeemItemWrite write;
write.name = json.get("name", "").asString();
write.description = json.get("description", "").asString();
write.unit_label = json.get("unit_label", "小时").asString();
write.holiday_cost = json.get("holiday_cost", 5).asInt();
write.studyday_cost = json.get("studyday_cost", 25).asInt();
write.is_active = json.get("is_active", true).asBool();
write.is_global = json.get("is_global", true).asBool();
return write;
}
Json::Value ToJson(const services::RedeemItem& item) {
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_by"] = Json::Int64(item.created_by);
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
return j;
}
std::optional<int64_t> RequireAdminUserId(
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;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
} // namespace
void AdminController::listUsers(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000);
const int page_size =
ParseClampedInt(req->getParameter("page_size"), 50, 1, 200);
services::UserService users(csp::AppState::Instance().db());
const auto result = users.ListUsers(page, page_size);
Json::Value arr(Json::arrayValue);
for (const auto& item : result.items) {
Json::Value one;
one["id"] = Json::Int64(item.user_id);
one["username"] = item.username;
one["rating"] = item.rating;
one["created_at"] = Json::Int64(item.created_at);
arr.append(one);
}
Json::Value payload;
payload["items"] = arr;
payload["total_count"] = result.total_count;
payload["page"] = page;
payload["page_size"] = page_size;
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 AdminController::updateUserRating(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t user_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
if (!(*json).isMember("rating")) {
cb(JsonError(drogon::k400BadRequest, "rating is required"));
return;
}
const int rating = (*json)["rating"].asInt();
if (rating < 0) {
cb(JsonError(drogon::k400BadRequest, "rating must be >= 0"));
return;
}
services::UserService users(csp::AppState::Instance().db());
users.SetRating(user_id, rating);
const auto updated = users.GetById(user_id);
if (!updated.has_value()) {
cb(JsonError(drogon::k404NotFound, "user not found"));
return;
}
Json::Value payload;
payload["id"] = Json::Int64(updated->id);
payload["username"] = updated->username;
payload["rating"] = updated->rating;
payload["updated"] = true;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid rating"));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::listRedeemItems(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const bool include_inactive =
ParseBoolLike(req->getParameter("include_inactive"), true);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto items = redeem.ListItems(include_inactive);
Json::Value arr(Json::arrayValue);
for (const auto& item : items) arr.append(ToJson(item));
cb(JsonOk(arr));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::createRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
const auto admin_user_id = RequireAdminUserId(req, cb);
if (!admin_user_id.has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const auto input = ParseRedeemItemWrite(*json);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto item = redeem.CreateItem(*admin_user_id, input);
cb(JsonOk(ToJson(item)));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::updateRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto json = req->getJsonObject();
if (!json) {
cb(JsonError(drogon::k400BadRequest, "body must be json"));
return;
}
const auto input = ParseRedeemItemWrite(*json);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto item = redeem.UpdateItem(item_id, input);
cb(JsonOk(ToJson(item)));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::deleteRedeemItem(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t item_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::RedeemService redeem(csp::AppState::Instance().db());
redeem.DeactivateItem(item_id);
Json::Value payload;
payload["id"] = Json::Int64(item_id);
payload["deleted"] = true;
cb(JsonOk(payload));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void AdminController::listRedeemRecords(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto user_id = ParseOptionalInt64(req->getParameter("user_id"));
const int limit = ParseClampedInt(req->getParameter("limit"), 200, 1, 500);
services::RedeemService redeem(csp::AppState::Instance().db());
const auto rows = redeem.ListRecordsAll(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["username"] = row.username;
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()));
}
}
} // namespace csp::controllers

查看文件

@@ -3,9 +3,12 @@
#include "csp/app_state.h"
#include "csp/services/import_runner.h"
#include "csp/services/import_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
@@ -40,6 +43,25 @@ int ParsePositiveInt(const std::string& s,
return std::max(min_value, std::min(max_value, v));
}
std::optional<int64_t> RequireAdminUserId(
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;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
Json::Value ToJson(const services::ImportJob& job) {
Json::Value j;
j["id"] = Json::Int64(job.id);
@@ -94,9 +116,11 @@ Json::Value ToJson(const services::ImportJobItem& item) {
} // namespace
void ImportController::latestJob(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportService svc(csp::AppState::Instance().db());
Json::Value payload;
const auto job = svc.GetLatestJob();
@@ -113,10 +137,12 @@ void ImportController::latestJob(
}
void ImportController::jobById(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportService svc(csp::AppState::Instance().db());
const auto job = svc.GetById(job_id);
if (!job.has_value()) {
@@ -136,6 +162,8 @@ void ImportController::jobItems(
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t job_id) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportJobItemQuery query;
query.status = req->getParameter("status");
query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000);
@@ -164,12 +192,26 @@ void ImportController::runJob(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
services::ImportRunOptions opts;
const auto json = req->getJsonObject();
if (json) {
if ((*json).isMember("mode")) {
opts.mode = (*json)["mode"].asString();
}
opts.clear_all_problems =
(*json).isMember("clear_all_problems") &&
(*json)["clear_all_problems"].asBool();
if ((*json).isMember("local_pdf_dir")) {
opts.local_pdf_dir = (*json)["local_pdf_dir"].asString();
}
if ((*json).isMember("target_total")) {
opts.target_total = std::max(1, std::min(50000, (*json)["target_total"].asInt()));
}
if ((*json).isMember("workers")) {
opts.workers = std::max(1, std::min(16, (*json)["workers"].asInt()));
}
}
const bool started =
services::ImportRunner::Instance().TriggerAsync("manual", opts);
@@ -181,6 +223,7 @@ void ImportController::runJob(
Json::Value payload;
payload["started"] = true;
payload["running"] = true;
payload["mode"] = opts.mode;
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));

查看文件

@@ -9,10 +9,12 @@
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "csp/services/submission_service.h"
#include "csp/services/user_service.h"
#include "http_auth.h"
#include <algorithm>
#include <exception>
#include <optional>
#include <string>
namespace csp::controllers {
@@ -47,6 +49,25 @@ int ParsePositiveInt(const std::string& s,
return std::max(min_value, std::min(max_value, v));
}
std::optional<int64_t> RequireAdminUserId(
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;
}
services::UserService users(csp::AppState::Instance().db());
const auto user = users.GetById(*user_id);
if (!user.has_value() || user->username != "admin") {
cb(JsonError(drogon::k403Forbidden, "admin only"));
return std::nullopt;
}
return user_id;
}
Json::Value BuildOpenApiSpec() {
Json::Value root;
root["openapi"] = "3.1.0";
@@ -173,6 +194,8 @@ void MetaController::backendLogs(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const int limit =
ParsePositiveInt(req->getParameter("limit"), 100, 1, 500);
const int running_limit =
@@ -274,9 +297,11 @@ void MetaController::backendLogs(
}
void MetaController::kbRefreshStatus(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
try {
if (!RequireAdminUserId(req, cb).has_value()) return;
const auto& runner = services::KbImportRunner::Instance();
Json::Value payload;
payload["running"] = runner.IsRunning();
@@ -299,12 +324,7 @@ 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;
}
if (!RequireAdminUserId(req, cb).has_value()) return;
auto& runner = services::KbImportRunner::Instance();
const bool started = runner.TriggerAsync("manual");
@@ -332,12 +352,8 @@ 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;
}
const auto user_id = RequireAdminUserId(req, cb);
if (!user_id.has_value()) return;
int limit = 50000;
int max_solutions = 3;

查看文件

@@ -5,6 +5,7 @@
#include "csp/services/problem_service.h"
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include "csp/services/solution_access_service.h"
#include "http_auth.h"
#include <algorithm>
@@ -192,7 +193,7 @@ void ProblemController::saveDraft(
}
void ProblemController::listSolutions(
const drogon::HttpRequestPtr& /*req*/,
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t problem_id) {
try {
@@ -203,28 +204,72 @@ void ProblemController::listSolutions(
}
const auto rows = svc.ListSolutions(problem_id);
const bool has_solutions = !rows.empty();
const auto latest_job = svc.GetLatestSolutionJob(problem_id);
const std::string mode = req->getParameter("mode");
const bool need_full = mode == "full";
Json::Value arr(Json::arrayValue);
for (const auto& item : rows) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["problem_id"] = Json::Int64(item.problem_id);
j["variant"] = item.variant;
j["title"] = item.title;
j["idea_md"] = item.idea_md;
j["explanation_md"] = item.explanation_md;
j["code_cpp"] = item.code_cpp;
j["complexity"] = item.complexity;
j["tags_json"] = item.tags_json;
j["source"] = item.source;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
Json::Value access(Json::objectValue);
access["required"] = true;
access["daily_free_quota"] = 1;
access["cost_after_free"] = 2;
if (need_full && has_solutions) {
std::string auth_error;
const auto user_id = GetAuthedUserId(req, auth_error);
if (!user_id.has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto charge = access_svc.ConsumeSolutionView(*user_id, problem_id);
if (!charge.granted) {
cb(JsonError(drogon::k402PaymentRequired,
"rating 不足:首次免费后每次查看答案需 2 分"));
return;
}
access["mode"] = "full";
access["charged"] = charge.charged;
access["daily_free"] = charge.daily_free;
access["cost"] = charge.cost;
access["day_key"] = charge.day_key;
access["daily_used_count"] = charge.daily_used_count;
access["rating_before"] = charge.rating_before;
access["rating_after"] = charge.rating_after;
access["viewed_at"] = Json::Int64(charge.viewed_at);
for (const auto& item : rows) {
Json::Value j;
j["id"] = Json::Int64(item.id);
j["problem_id"] = Json::Int64(item.problem_id);
j["variant"] = item.variant;
j["title"] = item.title;
j["idea_md"] = item.idea_md;
j["explanation_md"] = item.explanation_md;
j["code_cpp"] = item.code_cpp;
j["complexity"] = item.complexity;
j["tags_json"] = item.tags_json;
j["source"] = item.source;
j["created_at"] = Json::Int64(item.created_at);
j["updated_at"] = Json::Int64(item.updated_at);
arr.append(j);
}
} else {
access["mode"] = "preview";
access["charged"] = false;
access["daily_free"] = false;
access["cost"] = 0;
access["daily_used_count"] = 0;
}
Json::Value payload;
payload["items"] = arr;
payload["has_solutions"] = has_solutions;
payload["answer_status"] = has_solutions ? "已有" : "待生成";
payload["access"] = access;
payload["runner_running"] =
services::ProblemSolutionRunner::Instance().IsRunning(problem_id);
if (latest_job.has_value()) {
@@ -280,16 +325,20 @@ void ProblemController::generateSolutions(
}
const int64_t job_id = svc.CreateSolutionJob(problem_id, *user_id, max_solutions);
const bool started = services::ProblemSolutionRunner::Instance().TriggerAsync(
auto& runner = services::ProblemSolutionRunner::Instance();
const bool queued = runner.TriggerAsync(
problem_id, job_id, max_solutions);
if (!started) {
cb(JsonError(drogon::k409Conflict, "solution generation is already running"));
if (!queued) {
cb(JsonError(drogon::k500InternalServerError,
"solution generation queue is unavailable"));
return;
}
Json::Value payload;
payload["queued"] = true;
payload["started"] = true;
payload["job_id"] = Json::Int64(job_id);
payload["pending_jobs"] = Json::UInt64(runner.PendingCount());
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));

查看文件

@@ -4,11 +4,16 @@
#include "csp/domain/enum_strings.h"
#include "csp/domain/json.h"
#include "csp/services/contest_service.h"
#include "csp/services/problem_service.h"
#include "csp/services/solution_access_service.h"
#include "csp/services/submission_feedback_service.h"
#include "csp/services/submission_service.h"
#include "http_auth.h"
#include <algorithm>
#include <cctype>
#include <exception>
#include <memory>
#include <optional>
#include <string>
@@ -49,6 +54,32 @@ std::optional<int64_t> ParseOptionalInt64(const std::string& s) {
return std::stoll(s);
}
bool ParseBoolLike(const std::string& s, bool default_value) {
if (s.empty()) return default_value;
std::string v = s;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return default_value;
}
Json::Value ParseLinksArray(const std::string& links_json) {
Json::CharReaderBuilder builder;
Json::Value parsed;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(links_json.data(),
links_json.data() + links_json.size(),
&parsed,
&errs) ||
!parsed.isArray()) {
return Json::Value(Json::arrayValue);
}
return parsed;
}
} // namespace
void SubmissionController::submitProblem(
@@ -153,7 +184,91 @@ void SubmissionController::getSubmission(
cb(JsonError(drogon::k404NotFound, "submission not found"));
return;
}
cb(JsonOk(domain::ToJson(*s)));
Json::Value payload = domain::ToJson(*s);
payload["code"] = s->code;
services::SolutionAccessService access_svc(csp::AppState::Instance().db());
const auto stats =
access_svc.QueryUserProblemViewStats(s->user_id, s->problem_id);
payload["has_viewed_answer"] = stats.has_viewed;
payload["answer_view_count"] = stats.total_views;
payload["answer_view_total_cost"] = stats.total_cost;
if (stats.last_viewed_at.has_value()) {
payload["last_answer_view_at"] = Json::Int64(*stats.last_viewed_at);
} else {
payload["last_answer_view_at"] = Json::nullValue;
}
services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db());
if (const auto feedback = feedback_svc.GetBySubmissionId(s->id);
feedback.has_value()) {
Json::Value analysis;
analysis["feedback_md"] = feedback->feedback_md;
analysis["links"] = ParseLinksArray(feedback->links_json);
analysis["model_name"] = feedback->model_name;
analysis["status"] = feedback->status;
analysis["created_at"] = Json::Int64(feedback->created_at);
analysis["updated_at"] = Json::Int64(feedback->updated_at);
payload["analysis"] = analysis;
} else {
payload["analysis"] = Json::nullValue;
}
cb(JsonOk(payload));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}
}
void SubmissionController::analyzeSubmission(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
int64_t submission_id) {
try {
std::string auth_error;
if (!GetAuthedUserId(req, auth_error).has_value()) {
cb(JsonError(drogon::k401Unauthorized, auth_error));
return;
}
bool force_refresh = ParseBoolLike(req->getParameter("refresh"), false);
if (const auto json = req->getJsonObject();
json && (*json).isMember("refresh")) {
force_refresh = (*json)["refresh"].asBool();
}
services::SubmissionService submission_svc(csp::AppState::Instance().db());
const auto submission = submission_svc.GetById(submission_id);
if (!submission.has_value()) {
cb(JsonError(drogon::k404NotFound, "submission not found"));
return;
}
services::ProblemService problem_svc(csp::AppState::Instance().db());
const auto problem = problem_svc.GetById(submission->problem_id);
if (!problem.has_value()) {
cb(JsonError(drogon::k404NotFound, "problem not found"));
return;
}
services::SubmissionFeedbackService feedback_svc(csp::AppState::Instance().db());
const auto feedback =
feedback_svc.GenerateAndSave(*submission, *problem, force_refresh);
Json::Value payload;
payload["submission_id"] = Json::Int64(feedback.submission_id);
payload["feedback_md"] = feedback.feedback_md;
payload["links"] = ParseLinksArray(feedback.links_json);
payload["model_name"] = feedback.model_name;
payload["status"] = feedback.status;
payload["created_at"] = Json::Int64(feedback.created_at);
payload["updated_at"] = Json::Int64(feedback.updated_at);
payload["refresh"] = force_refresh;
cb(JsonOk(payload));
} catch (const std::invalid_argument&) {
cb(JsonError(drogon::k400BadRequest, "invalid request field"));
} catch (const std::runtime_error& e) {
cb(JsonError(drogon::k400BadRequest, e.what()));
} catch (const std::exception& e) {
cb(JsonError(drogon::k500InternalServerError, e.what()));
}

查看文件

@@ -40,6 +40,7 @@ Json::Value ToJson(const Submission& s) {
j["language"] = ToString(s.language);
j["status"] = ToString(s.status);
j["score"] = s.score;
j["rating_delta"] = s.rating_delta;
j["time_ms"] = s.time_ms;
j["memory_kb"] = s.memory_kb;
j["compile_log"] = s.compile_log;

查看文件

@@ -3,6 +3,7 @@
#include "csp/app_state.h"
#include "csp/services/auth_service.h"
#include "csp/services/import_runner.h"
#include "csp/services/kb_import_runner.h"
#include "csp/services/problem_gen_runner.h"
#include "csp/services/problem_solution_runner.h"
@@ -16,6 +17,7 @@ int main(int argc, char** argv) {
csp::AppState::Instance().Init(db_path);
csp::services::ImportRunner::Instance().Configure(db_path);
csp::services::KbImportRunner::Instance().Configure(db_path);
csp::services::ProblemSolutionRunner::Instance().Configure(db_path);
csp::services::ProblemGenRunner::Instance().Configure(db_path);
@@ -26,10 +28,14 @@ int main(int argc, char** argv) {
if (u && p && std::string(u).size() > 0 && std::string(p).size() > 0) {
try {
csp::services::AuthService auth(csp::AppState::Instance().db());
auth.Register(u, p);
LOG_INFO << "seed admin user created: " << u;
try {
auth.Register(u, p);
LOG_INFO << "seed admin user created: " << u;
} catch (const std::exception&) {
auth.ResetPassword(u, p);
LOG_INFO << "seed admin password reset: " << u;
}
} catch (const std::exception& e) {
// Most likely UNIQUE constraint (already exists)
LOG_INFO << "seed admin user skipped: " << e.what();
}
}
@@ -37,6 +43,9 @@ int main(int argc, char** argv) {
// Auto-run PDF -> LLM import workflow on startup unless explicitly disabled.
csp::services::ImportRunner::Instance().AutoStartIfEnabled();
// Auto-queue missing problem solutions on startup (C++14 async generation).
csp::services::ProblemSolutionRunner::Instance().AutoStartMissingIfEnabled(
csp::AppState::Instance().db());
// Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default.
csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled();

查看文件

@@ -1,11 +1,16 @@
#include "csp/services/import_runner.h"
#include <drogon/drogon.h>
#include <json/json.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <memory>
#include <string>
#include <thread>
#include <utility>
#include <vector>
@@ -39,6 +44,13 @@ int EnvInt(const char* key, int default_value) {
}
}
std::string EnvString(const char* key, const std::string& default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
const std::string value(raw);
return value.empty() ? default_value : value;
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
@@ -52,7 +64,16 @@ std::string ShellQuote(const std::string& text) {
return out;
}
std::string ResolveScriptPath() {
std::string NormalizeMode(std::string mode) {
for (auto& c : mode) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (mode == "local_pdf_rag" || mode == "local-pdf-rag" || mode == "local_rag" ||
mode == "rag") {
return "local_pdf_rag";
}
return "luogu";
}
std::string ResolveLuoguScriptPath() {
const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
@@ -72,11 +93,28 @@ std::string ResolveScriptPath() {
return "/app/scripts/import_luogu_csp.py";
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveScriptPath();
const int workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
std::string ResolveLocalRagScriptPath() {
const char* env_path = std::getenv("OI_IMPORT_LOCAL_RAG_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/import_local_pdf_rag.py",
"scripts/import_local_pdf_rag.py",
"../scripts/import_local_pdf_rag.py",
"../../scripts/import_local_pdf_rag.py",
};
for (const auto& p : candidates) {
if (std::filesystem::exists(p)) return p;
}
return "/app/scripts/import_local_pdf_rag.py";
}
std::string BuildLuoguCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveLuoguScriptPath();
const int workers =
std::max(1, options.workers > 0 ? options.workers : EnvInt("OI_IMPORT_WORKERS", 3));
const int llm_limit = EnvInt("OI_IMPORT_LLM_LIMIT", 0);
const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0);
const bool skip_llm = EnvBool("OI_IMPORT_SKIP_LLM", false);
@@ -88,9 +126,8 @@ std::string BuildCommand(const std::string& db_path,
: std::string("winterant/oi");
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --workers " +
std::to_string(workers) + " --job-trigger " +
ShellQuote(trigger);
ShellQuote(db_path) + " --workers " + std::to_string(workers) +
" --job-trigger " + ShellQuote(trigger);
if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems);
if (skip_llm) cmd += " --skip-llm";
@@ -107,6 +144,125 @@ std::string BuildCommand(const std::string& db_path,
return cmd;
}
std::string BuildLocalRagCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string script_path = ResolveLocalRagScriptPath();
const int workers =
std::max(1, options.workers > 0 ? options.workers : EnvInt("OI_IMPORT_WORKERS", 3));
const int target_total = std::max(
1,
options.target_total > 0 ? options.target_total : EnvInt("OI_IMPORT_TARGET_TOTAL", 5000));
const int attempt_multiplier =
std::max(2, EnvInt("OI_IMPORT_RAG_ATTEMPT_MULTIPLIER", 8));
const std::string pdf_dir = options.local_pdf_dir.empty()
? EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs")
: options.local_pdf_dir;
std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --workers " + std::to_string(workers) +
" --target-total " + std::to_string(target_total) +
" --job-trigger " + ShellQuote(trigger) +
" --max-attempt-multiplier " +
std::to_string(attempt_multiplier);
if (!pdf_dir.empty()) {
cmd += " --pdf-dir " + ShellQuote(pdf_dir);
}
const char* dedupe = std::getenv("OI_IMPORT_RAG_DEDUPE_THRESHOLD");
if (dedupe && std::string(dedupe).size() > 0) {
cmd += " --dedupe-threshold " + ShellQuote(dedupe);
}
return cmd;
}
std::string BuildCommand(const std::string& db_path,
const std::string& trigger,
const ImportRunOptions& options) {
const std::string mode = NormalizeMode(options.mode);
if (mode == "local_pdf_rag") {
return BuildLocalRagCommand(db_path, trigger, options);
}
return BuildLuoguCommand(db_path, trigger, options);
}
std::optional<ImportRunOptions> ParseRunOptionsFromJson(const std::string& raw_json) {
if (raw_json.empty()) return std::nullopt;
Json::CharReaderBuilder builder;
Json::Value root;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(raw_json.data(), raw_json.data() + raw_json.size(), &root,
&errs) ||
!root.isObject()) {
return std::nullopt;
}
ImportRunOptions opts;
opts.mode = NormalizeMode(root.get("mode", root.get("source", "luogu")).asString());
opts.clear_all_problems = root.get("clear_all_problems", false).asBool();
opts.workers = std::max(1, root.get("workers", 3).asInt());
opts.target_total = std::max(1, root.get("target_total", 5000).asInt());
if (root.isMember("local_pdf_dir")) {
opts.local_pdf_dir = root["local_pdf_dir"].asString();
} else if (root.isMember("pdf_dir")) {
opts.local_pdf_dir = root["pdf_dir"].asString();
}
return opts;
}
std::optional<ImportRunOptions> RecoverInterruptedRunOptions(const std::string& db_path) {
if (!EnvBool("OI_IMPORT_RESUME_ON_RESTART", true)) return std::nullopt;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) !=
SQLITE_OK) {
if (db) sqlite3_close(db);
return std::nullopt;
}
sqlite3_busy_timeout(db, 5000);
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT options_json FROM import_jobs WHERE status='running' ORDER BY id DESC LIMIT 1";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return std::nullopt;
}
std::optional<ImportRunOptions> options;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* txt = sqlite3_column_text(stmt, 0);
const std::string raw = txt ? reinterpret_cast<const char*>(txt) : std::string();
options = ParseRunOptionsFromJson(raw);
if (!options.has_value()) {
ImportRunOptions fallback;
fallback.mode = "luogu";
options = fallback;
}
}
sqlite3_finalize(stmt);
if (options.has_value()) {
sqlite3_exec(
db,
"UPDATE import_jobs "
"SET status='interrupted',"
"last_error='backend restarted, auto-resume scheduled',"
"finished_at=strftime('%s','now'),updated_at=strftime('%s','now') "
"WHERE status='running'",
nullptr,
nullptr,
nullptr);
}
sqlite3_close(db);
return options;
}
} // namespace
ImportRunner& ImportRunner::Instance() {
@@ -115,8 +271,21 @@ ImportRunner& ImportRunner::Instance() {
}
void ImportRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
std::optional<ImportRunOptions> resume_opts;
{
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
resume_opts = RecoverInterruptedRunOptions(db_path_);
}
if (resume_opts.has_value()) {
const bool started = TriggerAsync("resume", *resume_opts);
if (started) {
LOG_INFO << "import runner resumed interrupted job";
} else {
LOG_INFO << "import runner resume skipped";
}
}
}
bool ImportRunner::TriggerAsync(const std::string& trigger,
@@ -147,7 +316,14 @@ bool ImportRunner::TriggerAsync(const std::string& trigger,
void ImportRunner::AutoStartIfEnabled() {
if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return;
const bool started = TriggerAsync("auto", ImportRunOptions{});
ImportRunOptions opts;
opts.mode = NormalizeMode(EnvString("OI_IMPORT_AUTO_MODE", "luogu"));
opts.workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3));
opts.target_total = std::max(1, EnvInt("OI_IMPORT_TARGET_TOTAL", 5000));
opts.local_pdf_dir = EnvString("OI_LOCAL_PDF_DIR", "/data/local_pdfs");
const bool started = TriggerAsync("auto", opts);
if (started) {
LOG_INFO << "import runner auto-started";
} else {

查看文件

@@ -0,0 +1,130 @@
#include "csp/services/kb_import_runner.h"
#include <drogon/drogon.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <mutex>
#include <thread>
#include <vector>
namespace csp::services {
namespace {
int64_t NowSec() {
using namespace std::chrono;
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_KB_IMPORT_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/import_kb_learning_resources.py",
"scripts/import_kb_learning_resources.py",
"../scripts/import_kb_learning_resources.py",
"../../scripts/import_kb_learning_resources.py",
};
for (const auto& path : candidates) {
if (std::filesystem::exists(path)) return path;
}
return "/app/scripts/import_kb_learning_resources.py";
}
std::string BuildCommand(const std::string& db_path) {
const std::string script_path = ResolveScriptPath();
return "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) +
" --timeout 20 --retries 5 --max-sources-per-track 3 --max-chars-per-source 900";
}
} // namespace
KbImportRunner& KbImportRunner::Instance() {
static KbImportRunner inst;
return inst;
}
void KbImportRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
}
bool KbImportRunner::TriggerAsync(const std::string& trigger) {
std::string command;
{
std::lock_guard<std::mutex> lock(mu_);
if (running_) return false;
if (db_path_.empty()) return false;
command = BuildCommand(db_path_);
running_ = true;
last_command_ = command;
last_trigger_ = trigger;
last_started_at_ = NowSec();
last_exit_code_.reset();
}
std::thread([this, command = std::move(command)]() {
LOG_INFO << "kb import started: " << command;
const int rc = std::system(command.c_str());
{
std::lock_guard<std::mutex> lock(mu_);
running_ = false;
last_exit_code_ = rc;
last_finished_at_ = NowSec();
}
LOG_INFO << "kb import finished, code=" << rc;
}).detach();
return true;
}
bool KbImportRunner::IsRunning() const {
std::lock_guard<std::mutex> lock(mu_);
return running_;
}
std::string KbImportRunner::LastCommand() const {
std::lock_guard<std::mutex> lock(mu_);
return last_command_;
}
std::optional<int> KbImportRunner::LastExitCode() const {
std::lock_guard<std::mutex> lock(mu_);
return last_exit_code_;
}
int64_t KbImportRunner::LastStartedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_started_at_;
}
int64_t KbImportRunner::LastFinishedAt() const {
std::lock_guard<std::mutex> lock(mu_);
return last_finished_at_;
}
std::string KbImportRunner::LastTrigger() const {
std::lock_guard<std::mutex> lock(mu_);
return last_trigger_;
}
} // namespace csp::services

查看文件

@@ -1,6 +1,13 @@
#include "csp/services/problem_solution_runner.h"
#include "csp/services/problem_workspace_service.h"
#include <drogon/drogon.h>
#include <sqlite3.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <thread>
@@ -39,6 +46,43 @@ std::string ResolveScriptPath() {
return "/app/scripts/generate_problem_solutions.py";
}
bool EnvBool(const char* key, bool default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
std::string val(raw);
for (auto& c : val) c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
if (val == "1" || val == "true" || val == "yes" || val == "on") return true;
if (val == "0" || val == "false" || val == "no" || val == "off") return false;
return default_value;
}
int EnvInt(const char* key, int default_value) {
const char* raw = std::getenv(key);
if (!raw) return default_value;
try {
return std::stoi(raw);
} catch (...) {
return default_value;
}
}
bool IsDatabaseLockedMessage(const std::string& msg) {
return msg.find("database is locked") != std::string::npos ||
msg.find("database is busy") != std::string::npos;
}
std::string BuildCommand(const std::string& db_path,
int64_t problem_id,
int64_t job_id,
int max_solutions) {
const std::string script_path = ResolveScriptPath();
const int clamped = std::max(1, std::min(5, max_solutions));
return "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path) + " --problem-id " + std::to_string(problem_id) +
" --job-id " + std::to_string(job_id) + " --max-solutions " +
std::to_string(clamped);
}
} // namespace
ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
@@ -49,38 +93,225 @@ ProblemSolutionRunner& ProblemSolutionRunner::Instance() {
void ProblemSolutionRunner::Configure(std::string db_path) {
std::lock_guard<std::mutex> lock(mu_);
db_path_ = std::move(db_path);
RecoverQueuedJobsLocked();
StartWorkerIfNeededLocked();
}
bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id,
int64_t job_id,
int max_solutions) {
std::string cmd;
{
std::lock_guard<std::mutex> lock(mu_);
if (db_path_.empty()) return false;
if (running_problem_ids_.count(problem_id) > 0) return false;
running_problem_ids_.insert(problem_id);
const std::string script_path = ResolveScriptPath();
const int clamped = std::max(1, std::min(5, max_solutions));
cmd = "python3 " + ShellQuote(script_path) + " --db-path " +
ShellQuote(db_path_) + " --problem-id " + std::to_string(problem_id) +
" --job-id " + std::to_string(job_id) + " --max-solutions " +
std::to_string(clamped);
}
std::thread([this, problem_id, command = std::move(cmd)]() {
std::system(command.c_str());
std::lock_guard<std::mutex> lock(mu_);
running_problem_ids_.erase(problem_id);
}).detach();
std::lock_guard<std::mutex> lock(mu_);
if (db_path_.empty()) return false;
Task task;
task.problem_id = problem_id;
task.job_id = job_id;
task.max_solutions = std::max(1, std::min(5, max_solutions));
queue_.push_back(task);
++pending_problem_counts_[problem_id];
++pending_jobs_;
StartWorkerIfNeededLocked();
return true;
}
ProblemSolutionRunner::TriggerMissingSummary
ProblemSolutionRunner::TriggerMissingAsync(db::SqliteDb& db,
int64_t created_by,
int max_solutions,
int limit) {
services::ProblemWorkspaceService workspace(db);
TriggerMissingSummary summary;
summary.missing_total = workspace.CountProblemsWithoutSolutions();
const int clamped_limit = std::max(1, std::min(200000, limit));
const int clamped_max_solutions = std::max(1, std::min(5, max_solutions));
const auto problem_ids = workspace.ListProblemIdsWithoutSolutions(
clamped_limit, true);
summary.candidate_count = static_cast<int>(problem_ids.size());
for (int64_t problem_id : problem_ids) {
int64_t job_id = 0;
bool created = false;
for (int attempt = 1; attempt <= 4; ++attempt) {
try {
job_id = workspace.CreateSolutionJob(
problem_id, created_by, clamped_max_solutions);
created = true;
break;
} catch (const std::exception& e) {
const std::string msg = e.what();
if (!IsDatabaseLockedMessage(msg) || attempt >= 4) {
LOG_ERROR << "create solution job failed for problem_id=" << problem_id
<< ", err=" << msg;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(120 * attempt));
}
}
if (!created) continue;
if (TriggerAsync(problem_id, job_id, clamped_max_solutions)) {
++summary.queued_count;
}
}
return summary;
}
void ProblemSolutionRunner::AutoStartMissingIfEnabled(db::SqliteDb& db) {
try {
if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) {
LOG_INFO << "solution auto-run missing skipped by env";
return;
}
const int limit = std::max(1, std::min(200000, EnvInt("CSP_SOLUTION_AUTO_LIMIT", 50000)));
const int max_solutions =
std::max(1, std::min(5, EnvInt("CSP_SOLUTION_AUTO_MAX_SOLUTIONS", 3)));
const int interval_sec =
std::max(3, std::min(300, EnvInt("CSP_SOLUTION_AUTO_INTERVAL_SEC", 12)));
const auto summary = TriggerMissingAsync(db, /*created_by=*/0, max_solutions, limit);
LOG_INFO << "solution auto-run missing queued=" << summary.queued_count
<< ", candidates=" << summary.candidate_count
<< ", missing_total=" << summary.missing_total
<< ", pending_jobs=" << PendingCount();
StartAutoPumpIfNeeded(&db, max_solutions, limit, interval_sec);
} catch (const std::exception& e) {
LOG_ERROR << "solution auto-run missing failed: " << e.what();
}
}
bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const {
std::lock_guard<std::mutex> lock(mu_);
return running_problem_ids_.count(problem_id) > 0;
return pending_problem_counts_.count(problem_id) > 0;
}
size_t ProblemSolutionRunner::PendingCount() const {
std::lock_guard<std::mutex> lock(mu_);
return pending_jobs_;
}
void ProblemSolutionRunner::StartWorkerIfNeededLocked() {
if (worker_running_ || queue_.empty()) return;
worker_running_ = true;
std::thread([this]() { WorkerLoop(); }).detach();
}
void ProblemSolutionRunner::WorkerLoop() {
while (true) {
Task task;
std::string db_path;
{
std::lock_guard<std::mutex> lock(mu_);
if (queue_.empty()) {
worker_running_ = false;
return;
}
task = queue_.front();
queue_.pop_front();
db_path = db_path_;
}
const std::string cmd =
BuildCommand(db_path, task.problem_id, task.job_id, task.max_solutions);
const int rc = std::system(cmd.c_str());
if (rc != 0) {
LOG_INFO << "solution job " << task.job_id
<< " finished with non-zero rc: " << rc;
}
std::lock_guard<std::mutex> lock(mu_);
auto it = pending_problem_counts_.find(task.problem_id);
if (it != pending_problem_counts_.end()) {
if (it->second <= 1) {
pending_problem_counts_.erase(it);
} else {
--it->second;
}
}
if (pending_jobs_ > 0) --pending_jobs_;
}
}
void ProblemSolutionRunner::RecoverQueuedJobsLocked() {
if (recovered_from_db_ || db_path_.empty()) return;
recovered_from_db_ = true;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path_.c_str(), &db, SQLITE_OPEN_READWRITE, nullptr) !=
SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
sqlite3_busy_timeout(db, 5000);
sqlite3_exec(
db,
"UPDATE problem_solution_jobs "
"SET status='queued',progress=0,message='requeued after backend restart',"
"started_at=NULL,finished_at=NULL,updated_at=strftime('%s','now') "
"WHERE status='running'",
nullptr,
nullptr,
nullptr);
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,problem_id,max_solutions FROM problem_solution_jobs "
"WHERE status='queued' ORDER BY id ASC";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return;
}
int recovered = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
Task task;
task.job_id = sqlite3_column_int64(stmt, 0);
task.problem_id = sqlite3_column_int64(stmt, 1);
task.max_solutions = sqlite3_column_int(stmt, 2);
queue_.push_back(task);
++pending_problem_counts_[task.problem_id];
++pending_jobs_;
++recovered;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
if (recovered > 0) {
LOG_INFO << "recovered queued solution jobs: " << recovered;
}
}
void ProblemSolutionRunner::StartAutoPumpIfNeeded(db::SqliteDb* db,
int max_solutions,
int limit,
int interval_sec) {
std::lock_guard<std::mutex> lock(mu_);
if (auto_pump_started_ || !db) return;
auto_pump_started_ = true;
std::thread([this, db, max_solutions, limit, interval_sec]() {
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(interval_sec));
if (!EnvBool("CSP_SOLUTION_AUTO_RUN_MISSING", true)) continue;
bool idle = false;
{
std::lock_guard<std::mutex> inner(mu_);
idle = pending_jobs_ == 0;
}
if (!idle) continue;
try {
const auto summary = TriggerMissingAsync(
*db, /*created_by=*/0, max_solutions, limit);
if (summary.queued_count > 0) {
LOG_INFO << "solution auto-pump queued=" << summary.queued_count
<< ", candidates=" << summary.candidate_count
<< ", missing_total=" << summary.missing_total
<< ", pending_jobs=" << PendingCount();
}
} catch (const std::exception& e) {
LOG_ERROR << "solution auto-pump failed: " << e.what();
}
}
}).detach();
}
} // namespace csp::services

查看文件

@@ -6,6 +6,7 @@
#include <chrono>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
@@ -191,6 +192,116 @@ std::optional<ProblemSolutionJob> ProblemWorkspaceService::GetLatestSolutionJob(
return row;
}
std::vector<ProblemSolutionJob> ProblemWorkspaceService::ListRecentSolutionJobs(
int limit) {
const int capped = std::max(1, std::min(500, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"ORDER BY j.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare recent solution jobs");
CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db, "bind recent jobs limit");
std::vector<ProblemSolutionJob> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
auto row = ReadSolutionJob(stmt);
row.problem_title = ColText(stmt, 11);
rows.push_back(std::move(row));
}
sqlite3_finalize(stmt);
return rows;
}
std::vector<ProblemSolutionJob> ProblemWorkspaceService::ListSolutionJobsByStatus(
const std::string& status,
int limit) {
const int capped = std::max(1, std::min(1000, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const bool queued = status == "queued";
const char* sql = queued
? "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"WHERE j.status=? "
"ORDER BY j.id ASC LIMIT ?"
: "SELECT j.id,j.problem_id,j.status,j.progress,j.message,j.created_by,j.max_solutions,"
"j.created_at,j.started_at,j.finished_at,j.updated_at,p.title "
"FROM problem_solution_jobs j "
"LEFT JOIN problems p ON p.id=j.problem_id "
"WHERE j.status=? "
"ORDER BY j.updated_at DESC, j.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare solution jobs by status");
CheckSqlite(sqlite3_bind_text(stmt, 1, status.c_str(), -1, SQLITE_TRANSIENT), db,
"bind solution jobs status");
CheckSqlite(sqlite3_bind_int(stmt, 2, capped), db, "bind solution jobs limit");
std::vector<ProblemSolutionJob> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
auto row = ReadSolutionJob(stmt);
row.problem_title = ColText(stmt, 11);
rows.push_back(std::move(row));
}
sqlite3_finalize(stmt);
return rows;
}
int ProblemWorkspaceService::CountProblemsWithoutSolutions() {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(*) "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare count missing solutions");
CheckSqlite(sqlite3_step(stmt), db, "step count missing solutions");
const int count = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return count;
}
std::vector<int64_t> ProblemWorkspaceService::ListProblemIdsWithoutSolutions(
int limit,
bool exclude_queued_or_running_jobs) {
const int capped = std::max(1, std::min(200000, limit));
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql_all =
"SELECT p.id "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) "
"ORDER BY p.id ASC LIMIT ?";
const char* sql_skip_busy =
"SELECT p.id "
"FROM problems p "
"WHERE NOT EXISTS (SELECT 1 FROM problem_solutions s WHERE s.problem_id=p.id) "
"AND NOT EXISTS ("
" SELECT 1 FROM problem_solution_jobs j "
" WHERE j.problem_id=p.id AND j.status IN ('queued','running')"
") "
"ORDER BY p.id ASC LIMIT ?";
const char* sql = exclude_queued_or_running_jobs ? sql_skip_busy : sql_all;
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list missing solution problem ids");
CheckSqlite(sqlite3_bind_int(stmt, 1, capped), db,
"bind list missing solution problem ids limit");
std::vector<int64_t> rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
rows.push_back(sqlite3_column_int64(stmt, 0));
}
sqlite3_finalize(stmt);
return rows;
}
std::vector<ProblemSolution> ProblemWorkspaceService::ListSolutions(int64_t problem_id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;

查看文件

@@ -0,0 +1,360 @@
#include "csp/services/redeem_service.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cctype>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
namespace {
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));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
RedeemItem ReadItem(sqlite3_stmt* stmt) {
RedeemItem item;
item.id = sqlite3_column_int64(stmt, 0);
item.name = ColText(stmt, 1);
item.description = ColText(stmt, 2);
item.unit_label = ColText(stmt, 3);
item.holiday_cost = sqlite3_column_int(stmt, 4);
item.studyday_cost = sqlite3_column_int(stmt, 5);
item.is_active = sqlite3_column_int(stmt, 6) != 0;
item.is_global = sqlite3_column_int(stmt, 7) != 0;
item.created_by = sqlite3_column_int64(stmt, 8);
item.created_at = sqlite3_column_int64(stmt, 9);
item.updated_at = sqlite3_column_int64(stmt, 10);
return item;
}
RedeemRecord ReadRecord(sqlite3_stmt* stmt) {
RedeemRecord row;
row.id = sqlite3_column_int64(stmt, 0);
row.user_id = sqlite3_column_int64(stmt, 1);
row.item_id = sqlite3_column_int64(stmt, 2);
row.item_name = ColText(stmt, 3);
row.quantity = sqlite3_column_int(stmt, 4);
row.day_type = ColText(stmt, 5);
row.unit_cost = sqlite3_column_int(stmt, 6);
row.total_cost = sqlite3_column_int(stmt, 7);
row.note = ColText(stmt, 8);
row.created_at = sqlite3_column_int64(stmt, 9);
row.username = ColText(stmt, 10);
return row;
}
std::string NormalizeDayType(std::string day_type) {
for (auto& c : day_type) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (day_type == "holiday" || day_type == "vacation" || day_type == "weekend") {
return "holiday";
}
return "studyday";
}
void ValidateItemWrite(const RedeemItemWrite& input) {
if (input.name.empty()) throw std::runtime_error("item name required");
if (input.name.size() > 120) throw std::runtime_error("item name too long");
if (input.description.size() > 1000) throw std::runtime_error("description too long");
if (input.unit_label.empty() || input.unit_label.size() > 20) {
throw std::runtime_error("invalid unit label");
}
if (input.holiday_cost < 0 || input.studyday_cost < 0) {
throw std::runtime_error("cost must be >= 0");
}
}
int QueryUserRating(sqlite3* db, int64_t user_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT rating FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query user rating");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
throw std::runtime_error("user not found");
}
const int rating = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return rating;
}
void DeductUserRating(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 deduct user rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind delta");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec deduct user rating");
sqlite3_finalize(stmt);
}
} // namespace
std::vector<RedeemItem> RedeemService::ListItems(bool include_inactive) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql_all =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items ORDER BY id ASC";
const char* sql_active =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE is_active=1 ORDER BY id ASC";
CheckSqlite(sqlite3_prepare_v2(db, include_inactive ? sql_all : sql_active, -1, &stmt, nullptr),
db,
"prepare list redeem items");
std::vector<RedeemItem> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
out.push_back(ReadItem(stmt));
}
sqlite3_finalize(stmt);
return out;
}
std::optional<RedeemItem> RedeemService::GetItemById(int64_t item_id) {
if (item_id <= 0) return std::nullopt;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at "
"FROM redeem_items WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get redeem item");
CheckSqlite(sqlite3_bind_int64(stmt, 1, item_id), db, "bind item_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto item = ReadItem(stmt);
sqlite3_finalize(stmt);
return item;
}
RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrite& input) {
ValidateItemWrite(input);
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
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(?,?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare create redeem item");
CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind name");
CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db,
"bind description");
CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db,
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, admin_user_id), db, "bind created_by");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "create redeem item");
sqlite3_finalize(stmt);
const auto item = GetItemById(sqlite3_last_insert_rowid(db));
if (!item.has_value()) throw std::runtime_error("create redeem item failed");
return *item;
}
RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& input) {
ValidateItemWrite(input);
if (item_id <= 0) throw std::runtime_error("invalid item_id");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"UPDATE redeem_items "
"SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,is_active=?,is_global=?,updated_at=? "
"WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare update redeem item");
CheckSqlite(sqlite3_bind_text(stmt, 1, input.name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind name");
CheckSqlite(sqlite3_bind_text(stmt, 2, input.description.c_str(), -1, SQLITE_TRANSIENT), db,
"bind description");
CheckSqlite(sqlite3_bind_text(stmt, 3, input.unit_label.c_str(), -1, SQLITE_TRANSIENT), db,
"bind unit_label");
CheckSqlite(sqlite3_bind_int(stmt, 4, input.holiday_cost), db, "bind holiday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 5, input.studyday_cost), db, "bind studyday_cost");
CheckSqlite(sqlite3_bind_int(stmt, 6, input.is_active ? 1 : 0), db, "bind is_active");
CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_global ? 1 : 0), db, "bind is_global");
CheckSqlite(sqlite3_bind_int64(stmt, 8, now), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 9, item_id), db, "bind item_id");
CheckSqlite(sqlite3_step(stmt), db, "update redeem item");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found");
const auto item = GetItemById(item_id);
if (!item.has_value()) throw std::runtime_error("reload redeem item failed");
return *item;
}
void RedeemService::DeactivateItem(int64_t item_id) {
if (item_id <= 0) throw std::runtime_error("invalid item_id");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE redeem_items SET is_active=0,updated_at=? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare deactivate redeem item");
CheckSqlite(sqlite3_bind_int64(stmt, 1, NowSec()), db, "bind updated_at");
CheckSqlite(sqlite3_bind_int64(stmt, 2, item_id), db, "bind item_id");
CheckSqlite(sqlite3_step(stmt), db, "deactivate redeem item");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("redeem item not found");
}
std::vector<RedeemRecord> RedeemService::ListRecordsByUser(int64_t user_id, int limit) {
return ListRecordsAll(user_id, limit);
}
std::vector<RedeemRecord> RedeemService::ListRecordsAll(std::optional<int64_t> user_id,
int limit) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int safe_limit = std::max(1, std::min(500, limit));
const char* sql_all =
"SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at,"
"COALESCE(u.username,'') "
"FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id "
"ORDER BY r.id DESC LIMIT ?";
const char* sql_by_user =
"SELECT r.id,r.user_id,r.item_id,r.item_name,r.quantity,r.day_type,r.unit_cost,r.total_cost,r.note,r.created_at,"
"COALESCE(u.username,'') "
"FROM redeem_records r LEFT JOIN users u ON u.id=r.user_id "
"WHERE r.user_id=? ORDER BY r.id DESC LIMIT ?";
CheckSqlite(sqlite3_prepare_v2(db, user_id.has_value() ? sql_by_user : sql_all, -1, &stmt, nullptr),
db,
"prepare list redeem records");
if (user_id.has_value()) {
CheckSqlite(sqlite3_bind_int64(stmt, 1, *user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int(stmt, 2, safe_limit), db, "bind limit");
} else {
CheckSqlite(sqlite3_bind_int(stmt, 1, safe_limit), db, "bind limit");
}
std::vector<RedeemRecord> out;
while (sqlite3_step(stmt) == SQLITE_ROW) {
out.push_back(ReadRecord(stmt));
}
sqlite3_finalize(stmt);
return out;
}
RedeemRecord RedeemService::Redeem(const RedeemRequest& request) {
if (request.user_id <= 0 || request.item_id <= 0) {
throw std::runtime_error("invalid user_id/item_id");
}
if (request.quantity <= 0 || request.quantity > 24) {
throw std::runtime_error("quantity must be in [1,24]");
}
if (request.note.size() > 1000) {
throw std::runtime_error("note too long");
}
sqlite3* db = db_.raw();
db_.Exec("BEGIN IMMEDIATE");
bool committed = false;
try {
const auto item = GetItemById(request.item_id);
if (!item.has_value()) {
throw std::runtime_error("redeem item not found");
}
if (!item->is_active) {
throw std::runtime_error("redeem item is inactive");
}
const std::string day_type = NormalizeDayType(request.day_type);
const int unit_cost = day_type == "holiday" ? item->holiday_cost : item->studyday_cost;
const int total_cost = unit_cost * request.quantity;
const int current_rating = QueryUserRating(db, request.user_id);
if (current_rating < total_cost) {
throw std::runtime_error("rating not enough");
}
DeductUserRating(db, request.user_id, total_cost);
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* sql =
"INSERT INTO redeem_records(user_id,item_id,item_name,quantity,day_type,unit_cost,total_cost,note,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert redeem record");
CheckSqlite(sqlite3_bind_int64(stmt, 1, request.user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, request.item_id), db, "bind item_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, item->name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind item_name");
CheckSqlite(sqlite3_bind_int(stmt, 4, request.quantity), db, "bind quantity");
CheckSqlite(sqlite3_bind_text(stmt, 5, day_type.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_type");
CheckSqlite(sqlite3_bind_int(stmt, 6, unit_cost), db, "bind unit_cost");
CheckSqlite(sqlite3_bind_int(stmt, 7, total_cost), db, "bind total_cost");
CheckSqlite(sqlite3_bind_text(stmt, 8, request.note.c_str(), -1, SQLITE_TRANSIENT), db,
"bind note");
CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert redeem record");
sqlite3_finalize(stmt);
const int64_t record_id = sqlite3_last_insert_rowid(db);
db_.Exec("COMMIT");
committed = true;
auto rows = ListRecordsAll(request.user_id, 1);
if (!rows.empty() && rows.front().id == record_id) {
return rows.front();
}
RedeemRecord fallback;
fallback.id = record_id;
fallback.user_id = request.user_id;
fallback.item_id = request.item_id;
fallback.item_name = item->name;
fallback.quantity = request.quantity;
fallback.day_type = day_type;
fallback.unit_cost = unit_cost;
fallback.total_cost = total_cost;
fallback.note = request.note;
fallback.created_at = now;
return fallback;
} catch (...) {
if (!committed) {
try {
db_.Exec("ROLLBACK");
} catch (...) {
}
}
throw;
}
}
} // namespace csp::services

查看文件

@@ -0,0 +1,203 @@
#include "csp/services/solution_access_service.h"
#include <sqlite3.h>
#include <chrono>
#include <ctime>
#include <stdexcept>
#include <string>
namespace csp::services {
namespace {
constexpr int kViewCost = 2;
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));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string BuildDayKeyChina(int64_t ts_sec) {
const std::time_t shifted = static_cast<std::time_t>(ts_sec + 8 * 3600);
std::tm tm {};
gmtime_r(&shifted, &tm);
char buf[16] = {0};
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm);
return std::string(buf);
}
int QueryRating(sqlite3* db, int64_t user_id) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT rating FROM users WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query rating");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
throw std::runtime_error("user not found");
}
const int rating = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return rating;
}
int QueryDailyUsage(sqlite3* db, int64_t user_id, const std::string& day_key) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(1) FROM problem_solution_view_logs WHERE user_id=? AND day_key=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query daily usage");
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");
CheckSqlite(sqlite3_step(stmt), db, "step query daily usage");
const int used = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
return used;
}
void InsertViewLog(sqlite3* db,
int64_t user_id,
int64_t problem_id,
const std::string& day_key,
int64_t viewed_at,
bool charged,
int cost) {
sqlite3_stmt* stmt = nullptr;
const char* sql =
"INSERT INTO problem_solution_view_logs(user_id,problem_id,day_key,viewed_at,charged,cost,created_at) "
"VALUES(?,?,?,?,?,?,?)";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare insert solution view log");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db,
"bind day_key");
CheckSqlite(sqlite3_bind_int64(stmt, 4, viewed_at), db, "bind viewed_at");
CheckSqlite(sqlite3_bind_int(stmt, 5, charged ? 1 : 0), db, "bind charged");
CheckSqlite(sqlite3_bind_int(stmt, 6, cost), db, "bind cost");
CheckSqlite(sqlite3_bind_int64(stmt, 7, viewed_at), db, "bind created_at");
CheckSqlite(sqlite3_step(stmt), db, "insert solution view log");
sqlite3_finalize(stmt);
}
void DeductRating(sqlite3* db, int64_t user_id, int cost) {
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 deduct rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, cost), db, "bind cost");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "deduct rating");
sqlite3_finalize(stmt);
}
} // namespace
SolutionViewChargeResult SolutionAccessService::ConsumeSolutionView(
int64_t user_id,
int64_t problem_id) {
if (user_id <= 0 || problem_id <= 0) {
throw std::runtime_error("invalid user_id/problem_id");
}
sqlite3* db = db_.raw();
const int64_t now = NowSec();
const std::string day_key = BuildDayKeyChina(now);
db_.Exec("BEGIN IMMEDIATE");
bool committed = false;
try {
SolutionViewChargeResult result;
result.day_key = day_key;
result.viewed_at = now;
const int used_before = QueryDailyUsage(db, user_id, day_key);
result.daily_used_count = used_before;
const int rating_before = QueryRating(db, user_id);
result.rating_before = rating_before;
if (used_before <= 0) {
result.daily_free = true;
result.charged = false;
result.cost = 0;
result.rating_after = rating_before;
} else {
if (rating_before < kViewCost) {
result.granted = false;
result.charged = false;
result.cost = kViewCost;
result.rating_after = rating_before;
result.deny_reason = "rating not enough";
db_.Exec("ROLLBACK");
return result;
}
result.daily_free = false;
result.charged = true;
result.cost = kViewCost;
result.rating_after = rating_before - kViewCost;
DeductRating(db, user_id, kViewCost);
}
InsertViewLog(db,
user_id,
problem_id,
day_key,
now,
result.charged,
result.cost);
result.daily_used_count = used_before + 1;
db_.Exec("COMMIT");
committed = true;
return result;
} catch (...) {
if (!committed) {
try {
db_.Exec("ROLLBACK");
} catch (...) {
}
}
throw;
}
}
SolutionViewStats SolutionAccessService::QueryUserProblemViewStats(
int64_t user_id,
int64_t problem_id) {
SolutionViewStats stats;
if (user_id <= 0 || problem_id <= 0) return stats;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT COUNT(1),COALESCE(SUM(cost),0),MAX(viewed_at) "
"FROM problem_solution_view_logs WHERE user_id=? AND problem_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare query solution view stats");
CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id");
CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id");
CheckSqlite(sqlite3_step(stmt), db, "step query solution view stats");
stats.total_views = sqlite3_column_int(stmt, 0);
stats.total_cost = sqlite3_column_int(stmt, 1);
if (sqlite3_column_type(stmt, 2) != SQLITE_NULL) {
stats.last_viewed_at = sqlite3_column_int64(stmt, 2);
}
stats.has_viewed = stats.total_views > 0;
sqlite3_finalize(stmt);
return stats;
}
} // namespace csp::services

查看文件

@@ -0,0 +1,228 @@
#include "csp/services/submission_feedback_service.h"
#include "csp/domain/enum_strings.h"
#include "csp/services/crypto.h"
#include <json/json.h>
#include <sqlite3.h>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <sys/wait.h>
#include <vector>
namespace csp::services {
namespace {
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));
}
std::string ColText(sqlite3_stmt* stmt, int col) {
const unsigned char* txt = sqlite3_column_text(stmt, col);
return txt ? reinterpret_cast<const char*>(txt) : std::string();
}
std::string ShellQuote(const std::string& text) {
std::string out = "'";
for (char c : text) {
if (c == '\'') {
out += "'\"'\"'";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
std::string ResolveScriptPath() {
const char* env_path = std::getenv("CSP_SUBMISSION_ANALYSIS_SCRIPT_PATH");
if (env_path && std::filesystem::exists(env_path)) return env_path;
const std::vector<std::string> candidates = {
"/app/scripts/analyze_submission_feedback.py",
"scripts/analyze_submission_feedback.py",
"../scripts/analyze_submission_feedback.py",
"../../scripts/analyze_submission_feedback.py",
};
for (const auto& path : candidates) {
if (std::filesystem::exists(path)) return path;
}
return "/app/scripts/analyze_submission_feedback.py";
}
int ExitCodeFromSystem(int rc) {
if (rc == -1) return -1;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc);
return -1;
}
SubmissionFeedback ReadSubmissionFeedback(sqlite3_stmt* stmt) {
SubmissionFeedback row;
row.submission_id = sqlite3_column_int64(stmt, 0);
row.feedback_md = ColText(stmt, 1);
row.links_json = ColText(stmt, 2);
row.model_name = ColText(stmt, 3);
row.status = ColText(stmt, 4);
row.created_at = sqlite3_column_int64(stmt, 5);
row.updated_at = sqlite3_column_int64(stmt, 6);
return row;
}
std::string JsonToString(const Json::Value& value) {
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
return Json::writeString(builder, value);
}
} // namespace
std::optional<SubmissionFeedback> SubmissionFeedbackService::GetBySubmissionId(
int64_t submission_id) {
if (submission_id <= 0) return std::nullopt;
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT submission_id,feedback_md,links_json,model_name,status,created_at,updated_at "
"FROM submission_feedback WHERE submission_id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission feedback");
CheckSqlite(sqlite3_bind_int64(stmt, 1, submission_id), db,
"bind submission_id");
if (sqlite3_step(stmt) != SQLITE_ROW) {
sqlite3_finalize(stmt);
return std::nullopt;
}
auto row = ReadSubmissionFeedback(stmt);
sqlite3_finalize(stmt);
return row;
}
SubmissionFeedback SubmissionFeedbackService::GenerateAndSave(
const domain::Submission& submission,
const domain::Problem& problem,
bool force_refresh) {
if (!force_refresh) {
if (const auto existing = GetBySubmissionId(submission.id); existing.has_value()) {
return *existing;
}
}
Json::Value input;
input["submission_id"] = Json::Int64(submission.id);
input["problem_id"] = Json::Int64(problem.id);
input["problem_title"] = problem.title;
input["problem_statement"] = problem.statement_md;
input["sample_input"] = problem.sample_input;
input["sample_output"] = problem.sample_output;
input["status"] = domain::ToString(submission.status);
input["score"] = submission.score;
input["time_ms"] = submission.time_ms;
input["language"] = domain::ToString(submission.language);
input["code"] = submission.code;
input["compile_log"] = submission.compile_log;
input["runtime_log"] = submission.runtime_log;
namespace fs = std::filesystem;
const fs::path temp_file =
fs::path("/tmp") / ("csp_submission_feedback_" + crypto::RandomHex(8) + ".json");
{
std::ofstream out(temp_file, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) throw std::runtime_error("failed to create temp input file");
out << JsonToString(input);
}
const std::string script = ResolveScriptPath();
const std::string cmd =
"/usr/bin/timeout 45s python3 " + ShellQuote(script) + " --input-file " +
ShellQuote(temp_file.string()) + " 2>&1";
std::string output;
int exit_code = -1;
{
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
fs::remove(temp_file);
throw std::runtime_error("failed to start feedback script");
}
char buffer[4096];
while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
output += buffer;
}
exit_code = ExitCodeFromSystem(pclose(pipe));
}
fs::remove(temp_file);
if (exit_code != 0) {
throw std::runtime_error("feedback script failed: " + output);
}
Json::CharReaderBuilder builder;
Json::Value parsed;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
if (!reader->parse(output.data(), output.data() + output.size(), &parsed, &errs) ||
!parsed.isObject()) {
throw std::runtime_error("feedback script returned invalid json");
}
const std::string feedback_md = parsed.get("feedback_md", "").asString();
const std::string model_name = parsed.get("model_name", "").asString();
const std::string status = parsed.get("status", "ready").asString();
const Json::Value links = parsed.isMember("links") ? parsed["links"] : Json::Value(Json::arrayValue);
const std::string links_json = JsonToString(links);
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const int64_t now = NowSec();
const char* upsert_sql =
"INSERT INTO submission_feedback(submission_id,feedback_md,links_json,model_name,status,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(submission_id) DO UPDATE SET "
"feedback_md=excluded.feedback_md,"
"links_json=excluded.links_json,"
"model_name=excluded.model_name,"
"status=excluded.status,"
"updated_at=excluded.updated_at";
CheckSqlite(sqlite3_prepare_v2(db, upsert_sql, -1, &stmt, nullptr), db,
"prepare upsert submission feedback");
CheckSqlite(sqlite3_bind_int64(stmt, 1, submission.id), db, "bind submission_id");
CheckSqlite(sqlite3_bind_text(stmt, 2, feedback_md.c_str(), -1, SQLITE_TRANSIENT), db,
"bind feedback_md");
CheckSqlite(sqlite3_bind_text(stmt, 3, links_json.c_str(), -1, SQLITE_TRANSIENT), db,
"bind links_json");
CheckSqlite(sqlite3_bind_text(stmt, 4, model_name.c_str(), -1, SQLITE_TRANSIENT), db,
"bind model_name");
CheckSqlite(sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT), db,
"bind status");
CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind created_at");
CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind updated_at");
CheckSqlite(sqlite3_step(stmt), db, "upsert submission feedback");
sqlite3_finalize(stmt);
const auto saved = GetBySubmissionId(submission.id);
if (!saved.has_value()) {
throw std::runtime_error("submission feedback saved but reload failed");
}
return *saved;
}
} // namespace csp::services

查看文件

@@ -341,12 +341,16 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> u
sqlite3_stmt* stmt = nullptr;
std::string sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE 1=1 ";
if (user_id.has_value()) sql += "AND user_id=? ";
if (problem_id.has_value()) sql += "AND problem_id=? ";
if (contest_id.has_value()) sql += "AND contest_id=? ";
sql += "ORDER BY id DESC LIMIT ? OFFSET ?";
"SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at,"
"CASE WHEN s.status='AC' AND NOT EXISTS ("
" SELECT 1 FROM submissions s2 "
" WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE 1=1 ";
if (user_id.has_value()) sql += "AND s.user_id=? ";
if (problem_id.has_value()) sql += "AND s.problem_id=? ";
if (contest_id.has_value()) sql += "AND s.contest_id=? ";
sql += "ORDER BY s.id DESC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db,
"prepare list submissions");
@@ -385,6 +389,7 @@ std::vector<domain::Submission> SubmissionService::List(std::optional<int64_t> u
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
s.rating_delta = sqlite3_column_int(stmt, 13);
out.push_back(std::move(s));
}
sqlite3_finalize(stmt);
@@ -395,8 +400,12 @@ std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at "
"FROM submissions WHERE id=?";
"SELECT s.id,s.user_id,s.problem_id,s.contest_id,s.language,s.code,s.status,s.score,s.time_ms,s.memory_kb,s.compile_log,s.runtime_log,s.created_at,"
"CASE WHEN s.status='AC' AND NOT EXISTS ("
" SELECT 1 FROM submissions s2 "
" WHERE s2.user_id=s.user_id AND s2.problem_id=s.problem_id AND s2.status='AC' AND s2.id<s.id"
") THEN 2 ELSE 0 END AS rating_delta "
"FROM submissions s WHERE s.id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare get submission");
CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id");
@@ -424,6 +433,7 @@ std::optional<domain::Submission> SubmissionService::GetById(int64_t id) {
s.compile_log = ColText(stmt, 10);
s.runtime_log = ColText(stmt, 11);
s.created_at = sqlite3_column_int64(stmt, 12);
s.rating_delta = sqlite3_column_int(stmt, 13);
sqlite3_finalize(stmt);
return s;
}

查看文件

@@ -2,8 +2,10 @@
#include <sqlite3.h>
#include <algorithm>
#include <stdexcept>
#include <string>
#include <utility>
namespace csp::services {
@@ -70,4 +72,58 @@ std::vector<domain::GlobalLeaderboardEntry> UserService::GlobalLeaderboard(int l
return out;
}
UserListResult UserService::ListUsers(int page, int page_size) {
const int safe_page = std::max(1, page);
const int safe_size = std::max(1, std::min(200, page_size));
const int offset = (safe_page - 1) * safe_size;
sqlite3* db = db_.raw();
UserListResult result;
{
sqlite3_stmt* count_stmt = nullptr;
const char* count_sql = "SELECT COUNT(1) FROM users";
CheckSqlite(sqlite3_prepare_v2(db, count_sql, -1, &count_stmt, nullptr), db,
"prepare count users");
CheckSqlite(sqlite3_step(count_stmt), db, "step count users");
result.total_count = sqlite3_column_int(count_stmt, 0);
sqlite3_finalize(count_stmt);
}
sqlite3_stmt* stmt = nullptr;
const char* sql =
"SELECT id,username,rating,created_at FROM users ORDER BY id ASC LIMIT ? OFFSET ?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare list users");
CheckSqlite(sqlite3_bind_int(stmt, 1, safe_size), db, "bind limit");
CheckSqlite(sqlite3_bind_int(stmt, 2, offset), db, "bind offset");
while (sqlite3_step(stmt) == SQLITE_ROW) {
domain::GlobalLeaderboardEntry e;
e.user_id = sqlite3_column_int64(stmt, 0);
e.username = ColText(stmt, 1);
e.rating = sqlite3_column_int(stmt, 2);
e.created_at = sqlite3_column_int64(stmt, 3);
result.items.push_back(std::move(e));
}
sqlite3_finalize(stmt);
return result;
}
void UserService::SetRating(int64_t user_id, int rating) {
if (user_id <= 0) throw std::runtime_error("invalid user_id");
if (rating < 0) throw std::runtime_error("rating must be >= 0");
sqlite3* db = db_.raw();
sqlite3_stmt* stmt = nullptr;
const char* sql = "UPDATE users SET rating=? WHERE id=?";
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
"prepare set user rating");
CheckSqlite(sqlite3_bind_int(stmt, 1, rating), db, "bind rating");
CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id");
CheckSqlite(sqlite3_step(stmt), db, "exec set user rating");
sqlite3_finalize(stmt);
if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found");
}
} // namespace csp::services

查看文件

@@ -42,7 +42,21 @@ TEST_CASE("problem workspace service drafts and solution jobs") {
REQUIRE(latest->id == job_id);
REQUIRE(latest->status == "queued");
REQUIRE(latest->max_solutions == 3);
REQUIRE(latest->problem_title.empty());
const auto recent = svc.ListRecentSolutionJobs(10);
REQUIRE(recent.size() == 1);
REQUIRE(recent.front().id == job_id);
REQUIRE(recent.front().problem_id == pid);
REQUIRE(!recent.front().problem_title.empty());
const auto solutions = svc.ListSolutions(pid);
REQUIRE(solutions.empty());
REQUIRE(svc.CountProblemsWithoutSolutions() >= 1);
const auto missing_all = svc.ListProblemIdsWithoutSolutions(10, false);
REQUIRE(!missing_all.empty());
const auto missing_skip_busy = svc.ListProblemIdsWithoutSolutions(10, true);
REQUIRE(!missing_skip_busy.empty());
REQUIRE(missing_skip_busy.size() < missing_all.size());
}