From ad29a9f62d39eba81e24fd2ef4934b93e5062837 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 15 Feb 2026 12:51:42 +0800 Subject: [PATCH] feat: add daily tasks and fix /admin139 admin entry --- backend/CMakeLists.txt | 7 + .../include/csp/controllers/me_controller.h | 16 + .../include/csp/services/daily_task_service.h | 40 +++ backend/src/controllers/me_controller.cc | 168 +++++++++ backend/src/controllers/meta_controller.cc | 238 +++++++++++++ backend/src/db/sqlite_db.cc | 125 +++++++ backend/src/services/auth_service.cc | 36 ++ backend/src/services/daily_task_service.cc | 170 +++++++++ backend/src/services/submission_service.cc | 32 +- backend/tests/daily_task_service_test.cc | 34 ++ frontend/next.config.ts | 3 +- frontend/src/app/admin139/page.tsx | 28 ++ frontend/src/app/me/page.tsx | 333 ++++++++++++++++-- 13 files changed, 1200 insertions(+), 30 deletions(-) create mode 100644 backend/include/csp/services/daily_task_service.h create mode 100644 backend/src/services/daily_task_service.cc create mode 100644 backend/tests/daily_task_service_test.cc create mode 100644 frontend/src/app/admin139/page.tsx diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index b883d60..1ab3e10 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -19,10 +19,15 @@ add_library(csp_core src/services/wrong_book_service.cc src/services/kb_service.cc src/services/contest_service.cc + src/services/daily_task_service.cc src/services/submission_service.cc + src/services/solution_access_service.cc + src/services/redeem_service.cc src/services/problem_workspace_service.cc src/services/problem_solution_runner.cc + src/services/kb_import_runner.cc src/services/problem_gen_runner.cc + src/services/submission_feedback_service.cc src/services/import_service.cc src/services/import_runner.cc src/domain/enum_strings.cc @@ -46,6 +51,7 @@ add_library(csp_web src/controllers/me_controller.cc src/controllers/contest_controller.cc src/controllers/leaderboard_controller.cc + src/controllers/admin_controller.cc src/controllers/kb_controller.cc src/controllers/import_controller.cc src/controllers/meta_controller.cc @@ -87,6 +93,7 @@ add_executable(csp_tests tests/problem_service_test.cc tests/kb_service_test.cc tests/contest_service_test.cc + tests/daily_task_service_test.cc tests/submission_service_test.cc tests/me_http_test.cc tests/problem_http_test.cc diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h index e0ad2d4..b0feb41 100644 --- a/backend/include/csp/controllers/me_controller.h +++ b/backend/include/csp/controllers/me_controller.h @@ -10,6 +10,10 @@ class MeController : public drogon::HttpController { public: METHOD_LIST_BEGIN ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get); + ADD_METHOD_TO(MeController::listRedeemItems, "/api/v1/me/redeem/items", drogon::Get); + ADD_METHOD_TO(MeController::listRedeemRecords, "/api/v1/me/redeem/records", drogon::Get); + ADD_METHOD_TO(MeController::createRedeemRecord, "/api/v1/me/redeem/records", drogon::Post); + ADD_METHOD_TO(MeController::listDailyTasks, "/api/v1/me/daily-tasks", drogon::Get); ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get); ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch); ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete); @@ -18,6 +22,18 @@ class MeController : public drogon::HttpController { void profile(const drogon::HttpRequestPtr& req, std::function&& cb); + void listRedeemItems(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void listRedeemRecords(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void createRedeemRecord(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void listDailyTasks(const drogon::HttpRequestPtr& req, + std::function&& cb); + void listWrongBook(const drogon::HttpRequestPtr& req, std::function&& cb); diff --git a/backend/include/csp/services/daily_task_service.h b/backend/include/csp/services/daily_task_service.h new file mode 100644 index 0000000..102afbf --- /dev/null +++ b/backend/include/csp/services/daily_task_service.h @@ -0,0 +1,40 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include + +namespace csp::services { + +struct DailyTaskItem { + std::string code; + std::string title; + std::string description; + int reward = 1; + bool completed = false; + int64_t completed_at = 0; +}; + +class DailyTaskService { + public: + static constexpr const char* kTaskLoginCheckin = "login_checkin"; + static constexpr const char* kTaskDailySubmit = "daily_submit"; + static constexpr const char* kTaskFirstAc = "first_ac"; + static constexpr const char* kTaskCodeQuality = "code_quality"; + + explicit DailyTaskService(db::SqliteDb& db) : db_(db) {} + + std::string CurrentDayKey() const; + std::vector ListTodayTasks(int64_t user_id) const; + + // Returns true when the task is completed for the first time today + // (and reward is granted), false when already completed. + bool CompleteTaskIfFirstToday(int64_t user_id, const std::string& task_code); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc index 6d8bc4f..97b42e0 100644 --- a/backend/src/controllers/me_controller.cc +++ b/backend/src/controllers/me_controller.cc @@ -2,12 +2,16 @@ #include "csp/app_state.h" #include "csp/domain/json.h" +#include "csp/services/daily_task_service.h" +#include "csp/services/redeem_service.h" #include "csp/services/user_service.h" #include "csp/services/wrong_book_service.h" #include "http_auth.h" +#include #include #include +#include #include namespace csp::controllers { @@ -44,6 +48,15 @@ std::optional RequireAuth(const drogon::HttpRequestPtr& req, return user_id; } +int ParseClampedInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int value = std::stoi(s); + return std::max(min_value, std::min(max_value, value)); +} + } // namespace void MeController::profile( @@ -66,6 +79,161 @@ void MeController::profile( } } +void MeController::listRedeemItems( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + if (!RequireAuth(req, cb).has_value()) return; + + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto items = redeem.ListItems(false); + + Json::Value arr(Json::arrayValue); + for (const auto& item : items) { + Json::Value j; + j["id"] = Json::Int64(item.id); + j["name"] = item.name; + j["description"] = item.description; + j["unit_label"] = item.unit_label; + j["holiday_cost"] = item.holiday_cost; + j["studyday_cost"] = item.studyday_cost; + j["is_active"] = item.is_active; + j["is_global"] = item.is_global; + j["created_at"] = Json::Int64(item.created_at); + j["updated_at"] = Json::Int64(item.updated_at); + arr.append(j); + } + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::listRedeemRecords( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + const int limit = ParseClampedInt(req->getParameter("limit"), 100, 1, 500); + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto rows = redeem.ListRecordsByUser(*user_id, limit); + + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) { + Json::Value j; + j["id"] = Json::Int64(row.id); + j["user_id"] = Json::Int64(row.user_id); + j["item_id"] = Json::Int64(row.item_id); + j["item_name"] = row.item_name; + j["quantity"] = row.quantity; + j["day_type"] = row.day_type; + j["unit_cost"] = row.unit_cost; + j["total_cost"] = row.total_cost; + j["note"] = row.note; + j["created_at"] = Json::Int64(row.created_at); + arr.append(j); + } + cb(JsonOk(arr)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::createRedeemRecord( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + services::RedeemRequest request; + request.user_id = *user_id; + request.item_id = (*json).get("item_id", 0).asInt64(); + request.quantity = (*json).get("quantity", 1).asInt(); + request.day_type = (*json).get("day_type", "studyday").asString(); + request.note = (*json).get("note", "").asString(); + + services::RedeemService redeem(csp::AppState::Instance().db()); + const auto row = redeem.Redeem(request); + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + + Json::Value j; + j["id"] = Json::Int64(row.id); + j["user_id"] = Json::Int64(row.user_id); + j["item_id"] = Json::Int64(row.item_id); + j["item_name"] = row.item_name; + j["quantity"] = row.quantity; + j["day_type"] = row.day_type; + j["unit_cost"] = row.unit_cost; + j["total_cost"] = row.total_cost; + j["note"] = row.note; + j["created_at"] = Json::Int64(row.created_at); + if (user.has_value()) { + j["rating_after"] = user->rating; + } + cb(JsonOk(j)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::listDailyTasks( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + services::DailyTaskService tasks(csp::AppState::Instance().db()); + const auto rows = tasks.ListTodayTasks(*user_id); + + Json::Value arr(Json::arrayValue); + int total_reward = 0; + int gained_reward = 0; + for (const auto& row : rows) { + Json::Value j; + j["code"] = row.code; + j["title"] = row.title; + j["description"] = row.description; + j["reward"] = row.reward; + j["completed"] = row.completed; + if (row.completed) { + j["completed_at"] = Json::Int64(row.completed_at); + gained_reward += row.reward; + } else { + j["completed_at"] = Json::nullValue; + } + total_reward += row.reward; + arr.append(j); + } + + Json::Value out; + out["day_key"] = tasks.CurrentDayKey(); + out["total_reward"] = total_reward; + out["gained_reward"] = gained_reward; + out["tasks"] = arr; + cb(JsonOk(out)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + void MeController::listWrongBook( const drogon::HttpRequestPtr& req, std::function&& cb) { diff --git a/backend/src/controllers/meta_controller.cc b/backend/src/controllers/meta_controller.cc index 9cafbf0..727f58c 100644 --- a/backend/src/controllers/meta_controller.cc +++ b/backend/src/controllers/meta_controller.cc @@ -3,9 +3,13 @@ #include "csp/app_state.h" #include "csp/domain/enum_strings.h" #include "csp/domain/json.h" +#include "csp/services/kb_import_runner.h" #include "csp/services/problem_gen_runner.h" #include "csp/services/problem_service.h" +#include "csp/services/problem_solution_runner.h" +#include "csp/services/problem_workspace_service.h" #include "csp/services/submission_service.h" +#include "http_auth.h" #include #include @@ -25,6 +29,24 @@ drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, return resp; } +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +int ParsePositiveInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int v = std::stoi(s); + return std::max(min_value, std::min(max_value, v)); +} + Json::Value BuildOpenApiSpec() { Json::Value root; root["openapi"] = "3.1.0"; @@ -54,11 +76,28 @@ Json::Value BuildOpenApiSpec() { paths["/api/v1/run/cpp"]["post"]["summary"] = "C++ 试运行"; paths["/api/v1/submissions"]["get"]["summary"] = "提交记录"; paths["/api/v1/submissions/{id}"]["get"]["summary"] = "提交详情"; + paths["/api/v1/submissions/{id}/analysis"]["post"]["summary"] = "提交评测建议(LLM)"; + paths["/api/v1/admin/users"]["get"]["summary"] = "管理员用户列表"; + paths["/api/v1/admin/users/{id}/rating"]["patch"]["summary"] = "管理员修改用户积分"; + paths["/api/v1/admin/redeem-items"]["get"]["summary"] = "管理员查看积分兑换物品"; + paths["/api/v1/admin/redeem-items"]["post"]["summary"] = "管理员新增积分兑换物品"; + paths["/api/v1/admin/redeem-items/{id}"]["patch"]["summary"] = "管理员修改积分兑换物品"; + paths["/api/v1/admin/redeem-items/{id}"]["delete"]["summary"] = "管理员下架积分兑换物品"; + paths["/api/v1/admin/redeem-records"]["get"]["summary"] = "管理员查看兑换记录"; + paths["/api/v1/me/redeem/items"]["get"]["summary"] = "我的可兑换物品列表"; + paths["/api/v1/me/redeem/records"]["get"]["summary"] = "我的兑换记录"; + paths["/api/v1/me/redeem/records"]["post"]["summary"] = "创建兑换记录"; + paths["/api/v1/me/daily-tasks"]["get"]["summary"] = "我的每日任务列表"; paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务"; paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务"; paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态"; paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题(RAG+去重)"; + paths["/api/v1/backend/logs"]["get"]["summary"] = "后台日志(题解任务队列)"; + paths["/api/v1/backend/kb/refresh"]["get"]["summary"] = "知识库资料更新状态"; + paths["/api/v1/backend/kb/refresh"]["post"]["summary"] = "手动一键更新知识库资料"; + paths["/api/v1/backend/solutions/generate-missing"]["post"]["summary"] = + "异步补全所有缺失题解"; paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口"; @@ -130,6 +169,205 @@ void MetaController::openapi( cb(resp); } +void MetaController::backendLogs( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const int limit = + ParsePositiveInt(req->getParameter("limit"), 100, 1, 500); + const int running_limit = + ParsePositiveInt(req->getParameter("running_limit"), 20, 1, 200); + const int queued_limit = + ParsePositiveInt(req->getParameter("queued_limit"), 100, 1, 1000); + + services::ProblemWorkspaceService workspace(csp::AppState::Instance().db()); + const auto jobs = workspace.ListRecentSolutionJobs(limit); + const auto running_jobs = workspace.ListSolutionJobsByStatus( + "running", running_limit); + const auto queued_jobs = workspace.ListSolutionJobsByStatus( + "queued", queued_limit); + auto& runner = services::ProblemSolutionRunner::Instance(); + + Json::Value items(Json::arrayValue); + for (const auto& job : jobs) { + Json::Value j; + j["id"] = Json::Int64(job.id); + j["problem_id"] = Json::Int64(job.problem_id); + j["problem_title"] = job.problem_title; + j["status"] = job.status; + j["progress"] = job.progress; + j["message"] = job.message; + j["created_by"] = Json::Int64(job.created_by); + j["max_solutions"] = job.max_solutions; + j["created_at"] = Json::Int64(job.created_at); + if (job.started_at.has_value()) { + j["started_at"] = Json::Int64(*job.started_at); + } else { + j["started_at"] = Json::nullValue; + } + if (job.finished_at.has_value()) { + j["finished_at"] = Json::Int64(*job.finished_at); + } else { + j["finished_at"] = Json::nullValue; + } + j["updated_at"] = Json::Int64(job.updated_at); + j["runner_pending"] = runner.IsRunning(job.problem_id); + items.append(j); + } + + Json::Value running_items(Json::arrayValue); + Json::Value running_problem_ids(Json::arrayValue); + for (const auto& job : running_jobs) { + Json::Value j; + j["id"] = Json::Int64(job.id); + j["problem_id"] = Json::Int64(job.problem_id); + j["problem_title"] = job.problem_title; + j["status"] = job.status; + j["progress"] = job.progress; + j["message"] = job.message; + j["updated_at"] = Json::Int64(job.updated_at); + if (job.started_at.has_value()) { + j["started_at"] = Json::Int64(*job.started_at); + } else { + j["started_at"] = Json::nullValue; + } + running_items.append(j); + running_problem_ids.append(Json::Int64(job.problem_id)); + } + + Json::Value queued_items(Json::arrayValue); + Json::Value queued_problem_ids(Json::arrayValue); + for (const auto& job : queued_jobs) { + Json::Value j; + j["id"] = Json::Int64(job.id); + j["problem_id"] = Json::Int64(job.problem_id); + j["problem_title"] = job.problem_title; + j["status"] = job.status; + j["progress"] = job.progress; + j["message"] = job.message; + j["updated_at"] = Json::Int64(job.updated_at); + queued_items.append(j); + queued_problem_ids.append(Json::Int64(job.problem_id)); + } + + Json::Value payload; + payload["items"] = items; + payload["running_jobs"] = running_items; + payload["queued_jobs"] = queued_items; + payload["running_problem_ids"] = running_problem_ids; + payload["queued_problem_ids"] = queued_problem_ids; + payload["running_count"] = static_cast(running_jobs.size()); + payload["queued_count_preview"] = static_cast(queued_jobs.size()); + payload["pending_jobs"] = Json::UInt64(runner.PendingCount()); + payload["missing_problems"] = workspace.CountProblemsWithoutSolutions(); + payload["limit"] = limit; + payload["running_limit"] = running_limit; + payload["queued_limit"] = queued_limit; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MetaController::kbRefreshStatus( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + const auto& runner = services::KbImportRunner::Instance(); + Json::Value payload; + payload["running"] = runner.IsRunning(); + payload["last_command"] = runner.LastCommand(); + payload["last_trigger"] = runner.LastTrigger(); + if (const auto rc = runner.LastExitCode(); rc.has_value()) { + payload["last_exit_code"] = *rc; + } else { + payload["last_exit_code"] = Json::nullValue; + } + payload["last_started_at"] = Json::Int64(runner.LastStartedAt()); + payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt()); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MetaController::triggerKbRefresh( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + auto& runner = services::KbImportRunner::Instance(); + const bool started = runner.TriggerAsync("manual"); + + Json::Value payload; + payload["started"] = started; + payload["message"] = started ? "已触发异步资料更新" : "当前已有资料更新任务在运行中"; + payload["running"] = runner.IsRunning(); + payload["last_command"] = runner.LastCommand(); + payload["last_trigger"] = runner.LastTrigger(); + if (const auto rc = runner.LastExitCode(); rc.has_value()) { + payload["last_exit_code"] = *rc; + } else { + payload["last_exit_code"] = Json::nullValue; + } + payload["last_started_at"] = Json::Int64(runner.LastStartedAt()); + payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt()); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MetaController::triggerMissingSolutions( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + int limit = 50000; + int max_solutions = 3; + const auto json = req->getJsonObject(); + if (json && (*json).isMember("limit")) { + limit = std::max(1, std::min(200000, (*json)["limit"].asInt())); + } + if (json && (*json).isMember("max_solutions")) { + max_solutions = std::max(1, std::min(5, (*json)["max_solutions"].asInt())); + } + + auto& db = csp::AppState::Instance().db(); + auto& runner = services::ProblemSolutionRunner::Instance(); + const auto summary = runner.TriggerMissingAsync( + db, *user_id, max_solutions, limit); + + Json::Value payload; + payload["started"] = true; + payload["missing_total"] = summary.missing_total; + payload["candidate_count"] = summary.candidate_count; + payload["queued_count"] = summary.queued_count; + payload["pending_jobs"] = Json::UInt64(runner.PendingCount()); + payload["limit"] = limit; + payload["max_solutions"] = max_solutions; + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + void MetaController::mcp( const drogon::HttpRequestPtr& req, std::function&& cb) { diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index f4a9414..175ac4e 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -208,6 +208,47 @@ void InsertContestProblem(sqlite3* db, sqlite3_finalize(stmt); } +void InsertRedeemItem(sqlite3* db, + const std::string& name, + const std::string& description, + const std::string& unit_label, + int holiday_cost, + int studyday_cost, + int is_active, + int is_global, + int64_t created_by, + int64_t created_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO redeem_items(" + "name,description,unit_label,holiday_cost,studyday_cost,is_active,is_global,created_by,created_at,updated_at" + ") VALUES(?,?,?,?,?,?,?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert redeem_item"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT), db, + "bind redeem_item.name"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, description.c_str(), -1, SQLITE_TRANSIENT), + db, "bind redeem_item.description"); + ThrowSqlite(sqlite3_bind_text(stmt, 3, unit_label.c_str(), -1, SQLITE_TRANSIENT), + db, "bind redeem_item.unit_label"); + ThrowSqlite(sqlite3_bind_int(stmt, 4, holiday_cost), db, + "bind redeem_item.holiday_cost"); + ThrowSqlite(sqlite3_bind_int(stmt, 5, studyday_cost), db, + "bind redeem_item.studyday_cost"); + ThrowSqlite(sqlite3_bind_int(stmt, 6, is_active), db, + "bind redeem_item.is_active"); + ThrowSqlite(sqlite3_bind_int(stmt, 7, is_global), db, + "bind redeem_item.is_global"); + ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_by), db, + "bind redeem_item.created_by"); + ThrowSqlite(sqlite3_bind_int64(stmt, 9, created_at), db, + "bind redeem_item.created_at"); + ThrowSqlite(sqlite3_bind_int64(stmt, 10, created_at), db, + "bind redeem_item.updated_at"); + ThrowSqlite(sqlite3_step(stmt), db, "insert redeem_item"); + sqlite3_finalize(stmt); +} + } // namespace SqliteDb SqliteDb::OpenFile(const std::string& path) { @@ -446,6 +487,71 @@ CREATE TABLE IF NOT EXISTS problem_solutions ( updated_at INTEGER NOT NULL, FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE ); + +CREATE TABLE IF NOT EXISTS problem_solution_view_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + day_key TEXT NOT NULL, + viewed_at INTEGER NOT NULL, + charged INTEGER NOT NULL DEFAULT 0, + cost INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS submission_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + submission_id INTEGER NOT NULL UNIQUE, + feedback_md TEXT NOT NULL DEFAULT "", + links_json TEXT NOT NULL DEFAULT "[]", + model_name TEXT NOT NULL DEFAULT "", + status TEXT NOT NULL DEFAULT "ready", + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(submission_id) REFERENCES submissions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS redeem_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT "", + unit_label TEXT NOT NULL DEFAULT "小时", + holiday_cost INTEGER NOT NULL DEFAULT 5, + studyday_cost INTEGER NOT NULL DEFAULT 25, + is_active INTEGER NOT NULL DEFAULT 1, + is_global INTEGER NOT NULL DEFAULT 1, + created_by INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS redeem_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + day_type TEXT NOT NULL DEFAULT "studyday", + unit_cost INTEGER NOT NULL DEFAULT 0, + total_cost INTEGER NOT NULL DEFAULT 0, + note TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(item_id) REFERENCES redeem_items(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS daily_task_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_code TEXT NOT NULL, + day_key TEXT NOT NULL, + reward INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, task_code, day_key) +); )SQL"); // Backward-compatible schema upgrades for existing deployments. @@ -494,6 +600,11 @@ CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(j CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id); +CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_problem ON problem_solution_view_logs(user_id, problem_id, viewed_at DESC); +CREATE INDEX IF NOT EXISTS idx_solution_view_logs_user_day ON problem_solution_view_logs(user_id, day_key, viewed_at DESC); +CREATE INDEX IF NOT EXISTS idx_redeem_items_active ON redeem_items(is_active, id); +CREATE INDEX IF NOT EXISTS idx_redeem_records_user_created ON redeem_records(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_daily_task_logs_user_day ON daily_task_logs(user_id, day_key, created_at DESC); )SQL"); } @@ -602,6 +713,20 @@ void SeedDemoData(SqliteDb& db) { if (contest_id && p1) InsertContestProblem(raw, *contest_id, *p1, 1); if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2); } + + if (CountRows(raw, "redeem_items") == 0) { + InsertRedeemItem( + raw, + "私人玩游戏时间", + "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。", + "小时", + 5, + 25, + 1, + 1, + 0, + now); + } } } // namespace csp::db diff --git a/backend/src/services/auth_service.cc b/backend/src/services/auth_service.cc index 5c7bb68..6eba367 100644 --- a/backend/src/services/auth_service.cc +++ b/backend/src/services/auth_service.cc @@ -1,5 +1,6 @@ #include "csp/services/auth_service.h" +#include "csp/services/daily_task_service.h" #include "csp/services/crypto.h" #include @@ -113,9 +114,44 @@ AuthResult AuthService::Login(const std::string& username, CheckSqlite(sqlite3_step(ins), db, "insert session"); sqlite3_finalize(ins); + try { + DailyTaskService daily(db_); + daily.CompleteTaskIfFirstToday(user_id, DailyTaskService::kTaskLoginCheckin); + } catch (...) { + // Login should not fail because of optional daily-task reward. + } + return AuthResult{.user_id = user_id, .token = token, .expires_at = expires}; } +void AuthService::ResetPassword(const std::string& username, + const std::string& new_password) { + if (username.empty() || new_password.size() < 6) { + throw std::runtime_error("invalid username or password"); + } + + const auto salt = crypto::RandomHex(16); + const auto hash = crypto::Sha256Hex(salt + ":" + new_password); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "UPDATE users SET password_salt=?,password_hash=? WHERE username=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare reset password"); + CheckSqlite(sqlite3_bind_text(stmt, 1, salt.c_str(), -1, SQLITE_TRANSIENT), db, + "bind salt"); + CheckSqlite(sqlite3_bind_text(stmt, 2, hash.c_str(), -1, SQLITE_TRANSIENT), db, + "bind hash"); + CheckSqlite(sqlite3_bind_text(stmt, 3, username.c_str(), -1, SQLITE_TRANSIENT), + db, "bind username"); + CheckSqlite(sqlite3_step(stmt), db, "exec reset password"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) { + throw std::runtime_error("user not found"); + } +} + std::optional AuthService::VerifyToken(const std::string& token) { if (token.empty()) return std::nullopt; diff --git a/backend/src/services/daily_task_service.cc b/backend/src/services/daily_task_service.cc new file mode 100644 index 0000000..cc6ba8d --- /dev/null +++ b/backend/src/services/daily_task_service.cc @@ -0,0 +1,170 @@ +#include "csp/services/daily_task_service.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +struct DailyTaskDef { + const char* code; + const char* title; + const char* description; + int reward; +}; + +constexpr std::array kTaskDefs = {{ + {DailyTaskService::kTaskLoginCheckin, "登录签到", "登录签到 1 分(本日首次可得)", 1}, + {DailyTaskService::kTaskDailySubmit, "每日提交", "每日提交 1 分(本日首次可得)", 1}, + {DailyTaskService::kTaskFirstAc, "正确一题", "正确一题 1 分(本日首次可得)", 1}, + {DailyTaskService::kTaskCodeQuality, "代码达标", "代码超过 10 行 1 分(本日首次可得)", 1}, +}}; + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +const DailyTaskDef* FindTask(const std::string& code) { + for (const auto& def : kTaskDefs) { + if (code == def.code) return &def; + } + return nullptr; +} + +std::string BuildDayKey(int64_t ts_sec) { + // Use UTC+8 day boundary for CSP users. + const std::time_t shifted = static_cast(ts_sec + 8 * 3600); + std::tm tmv{}; + gmtime_r(&shifted, &tmv); + std::ostringstream out; + out << std::setw(4) << std::setfill('0') << (tmv.tm_year + 1900) << '-' + << std::setw(2) << std::setfill('0') << (tmv.tm_mon + 1) << '-' + << std::setw(2) << std::setfill('0') << tmv.tm_mday; + return out.str(); +} + +void AddRating(sqlite3* db, int64_t user_id, int delta) { + sqlite3_stmt* stmt = nullptr; + const char* sql = "UPDATE users SET rating=rating+? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare add daily task rating"); + CheckSqlite(sqlite3_bind_int(stmt, 1, delta), db, "bind rating delta"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_step(stmt), db, "exec add daily task rating"); + sqlite3_finalize(stmt); + if (sqlite3_changes(db) <= 0) throw std::runtime_error("user not found"); +} + +} // namespace + +std::string DailyTaskService::CurrentDayKey() const { + return BuildDayKey(NowSec()); +} + +std::vector DailyTaskService::ListTodayTasks(int64_t user_id) const { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + + sqlite3* db = db_.raw(); + const std::string day_key = CurrentDayKey(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT task_code,created_at,reward FROM daily_task_logs WHERE user_id=? AND day_key=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list daily tasks"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, day_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind day_key"); + + std::unordered_map> done; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* raw = sqlite3_column_text(stmt, 0); + std::string code = raw ? reinterpret_cast(raw) : std::string(); + const int64_t created_at = sqlite3_column_int64(stmt, 1); + const int reward = sqlite3_column_int(stmt, 2); + done[code] = {created_at, reward}; + } + sqlite3_finalize(stmt); + + std::vector out; + out.reserve(kTaskDefs.size()); + for (const auto& def : kTaskDefs) { + DailyTaskItem item; + item.code = def.code; + item.title = def.title; + item.description = def.description; + item.reward = def.reward; + const auto it = done.find(item.code); + if (it != done.end()) { + item.completed = true; + item.completed_at = it->second.first; + item.reward = it->second.second > 0 ? it->second.second : def.reward; + } + out.push_back(std::move(item)); + } + return out; +} + +bool DailyTaskService::CompleteTaskIfFirstToday(int64_t user_id, + const std::string& task_code) { + if (user_id <= 0) throw std::runtime_error("invalid user_id"); + const auto* task = FindTask(task_code); + if (!task) throw std::runtime_error("unknown daily task"); + + sqlite3* db = db_.raw(); + const int64_t now = NowSec(); + const std::string day_key = BuildDayKey(now); + + db_.Exec("BEGIN IMMEDIATE"); + bool committed = false; + try { + sqlite3_stmt* stmt = nullptr; + const char* ins_sql = + "INSERT OR IGNORE INTO daily_task_logs(user_id,task_code,day_key,reward,created_at) " + "VALUES(?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &stmt, nullptr), db, + "prepare insert daily task"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, task->code, -1, SQLITE_STATIC), db, + "bind task_code"); + CheckSqlite(sqlite3_bind_text(stmt, 3, day_key.c_str(), -1, SQLITE_TRANSIENT), db, + "bind day_key"); + CheckSqlite(sqlite3_bind_int(stmt, 4, task->reward), db, "bind reward"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "insert daily task"); + sqlite3_finalize(stmt); + + const bool inserted = sqlite3_changes(db) > 0; + if (inserted) { + AddRating(db, user_id, task->reward); + } + + db_.Exec("COMMIT"); + committed = true; + return inserted; + } catch (...) { + if (!committed) { + try { + db_.Exec("ROLLBACK"); + } catch (...) { + } + } + throw; + } +} + +} // namespace csp::services diff --git a/backend/src/services/submission_service.cc b/backend/src/services/submission_service.cc index 67605c3..2687f0b 100644 --- a/backend/src/services/submission_service.cc +++ b/backend/src/services/submission_service.cc @@ -2,6 +2,7 @@ #include "csp/domain/enum_strings.h" #include "csp/services/crypto.h" +#include "csp/services/daily_task_service.h" #include "csp/services/wrong_book_service.h" #include @@ -97,7 +98,8 @@ JudgeOutcome JudgeCpp(const std::string& code, JudgeOutcome outcome; try { const std::string compile_cmd = - "g++ -std=c++20 -O2 \"" + src.string() + "\" -o \"" + bin.string() + + "g++ -std=gnu++14 -O2 -Wall -Wextra -Wpedantic \"" + src.string() + + "\" -o \"" + bin.string() + "\" 2> \"" + compile_log.string() + "\""; const int compile_rc = std::system(compile_cmd.c_str()); outcome.compile_log = ReadFile(compile_log); @@ -210,6 +212,15 @@ void AddRating(sqlite3* db, int64_t user_id, int delta) { std::string ToStatusText(domain::SubmissionStatus s) { return domain::ToString(s); } +int CountCodeLines(const std::string& code) { + if (code.empty()) return 0; + int lines = 1; + for (const char ch : code) { + if (ch == '\n') ++lines; + } + return lines; +} + } // namespace domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateRequest& req) { @@ -264,6 +275,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque const int64_t submission_id = sqlite3_last_insert_rowid(db); + try { + DailyTaskService daily(db_); + daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskDailySubmit); + if (CountCodeLines(req.code) > 10) { + daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskCodeQuality); + } + } catch (...) { + // Daily task reward should not interrupt judging. + } + JudgeOutcome outcome = JudgeCpp(req.code, problem->sample_input, problem->sample_output); const int score = outcome.status == domain::SubmissionStatus::AC ? 100 : 0; @@ -288,9 +309,16 @@ domain::Submission SubmissionService::CreateAndJudge(const SubmissionCreateReque WrongBookService wb(db_); if (outcome.status == domain::SubmissionStatus::AC) { + try { + DailyTaskService daily(db_); + daily.CompleteTaskIfFirstToday(req.user_id, DailyTaskService::kTaskFirstAc); + } catch (...) { + // Keep AC flow resilient. + } + wb.Remove(req.user_id, req.problem_id); if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) { - AddRating(db, req.user_id, problem->difficulty * 10); + AddRating(db, req.user_id, 2); } } else { wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id, diff --git a/backend/tests/daily_task_service_test.cc b/backend/tests/daily_task_service_test.cc new file mode 100644 index 0000000..611e562 --- /dev/null +++ b/backend/tests/daily_task_service_test.cc @@ -0,0 +1,34 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" +#include "csp/services/daily_task_service.h" +#include "csp/services/user_service.h" + +TEST_CASE("daily task reward only once per day") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + + csp::services::AuthService auth(db); + const auto user = auth.Register("daily_task_user", "password123"); + + csp::services::UserService users(db); + csp::services::DailyTaskService daily(db); + + const auto before = users.GetById(user.user_id); + REQUIRE(before.has_value()); + // Register includes auto-login, which should complete login_checkin once. + REQUIRE(before->rating == 1); + + REQUIRE(daily.CompleteTaskIfFirstToday(user.user_id, + csp::services::DailyTaskService::kTaskDailySubmit)); + REQUIRE_FALSE(daily.CompleteTaskIfFirstToday( + user.user_id, csp::services::DailyTaskService::kTaskDailySubmit)); + + const auto after = users.GetById(user.user_id); + REQUIRE(after.has_value()); + REQUIRE(after->rating == 2); + + const auto tasks = daily.ListTodayTasks(user.user_id); + REQUIRE(tasks.size() == 4); +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 582afd3..a8b2a63 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -14,7 +14,8 @@ const nextConfig: NextConfig = { return [ { - source: "/admin139/:path*", + // Keep /admin139 as frontend admin entry page, only proxy nested API paths. + source: "/admin139/:path+", destination: `${backendInternal}/:path*`, }, ]; diff --git a/frontend/src/app/admin139/page.tsx b/frontend/src/app/admin139/page.tsx new file mode 100644 index 0000000..dbca36e --- /dev/null +++ b/frontend/src/app/admin139/page.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; + +export default function AdminEntryPage() { + return ( +
+

后台管理入口

+

+ 默认管理员账号:admin,密码: + whoami139 +

+ +
+ + 去登录 + + + 用户积分管理 + + + 积分兑换管理 + + + 后台任务日志 + +
+
+ ); +} diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx index 987c061..592b119 100644 --- a/frontend/src/app/me/page.tsx +++ b/frontend/src/app/me/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { apiFetch } from "@/lib/api"; import { readToken } from "@/lib/auth"; @@ -12,43 +12,322 @@ type Me = { created_at: number; }; +type RedeemItem = { + id: number; + name: string; + description: string; + unit_label: string; + holiday_cost: number; + studyday_cost: number; + is_active: boolean; +}; + +type RedeemRecord = { + id: number; + user_id: number; + item_id: number; + item_name: string; + quantity: number; + day_type: string; + unit_cost: number; + total_cost: number; + note: string; + created_at: number; +}; + +type RedeemCreateResp = RedeemRecord & { + rating_after?: number; +}; + +type DailyTaskItem = { + code: string; + title: string; + description: string; + reward: number; + completed: boolean; + completed_at?: number | null; +}; + +type DailyTaskPayload = { + day_key: string; + total_reward: number; + gained_reward: number; + tasks: DailyTaskItem[]; +}; + +function fmtTs(v: number | null | undefined): string { + if (!v) return "-"; + return new Date(v * 1000).toLocaleString(); +} + export default function MePage() { - const [data, setData] = useState(null); - const [error, setError] = useState(""); + const [token, setToken] = useState(""); + const [profile, setProfile] = useState(null); + const [items, setItems] = useState([]); + const [records, setRecords] = useState([]); + const [dailyTasks, setDailyTasks] = useState([]); + const [dailyDayKey, setDailyDayKey] = useState(""); + const [dailyTotalReward, setDailyTotalReward] = useState(0); + const [dailyGainedReward, setDailyGainedReward] = useState(0); + + const [selectedItemId, setSelectedItemId] = useState(0); + const [quantity, setQuantity] = useState(1); + const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday"); + const [note, setNote] = useState(""); + const [loading, setLoading] = useState(false); + const [redeemLoading, setRedeemLoading] = useState(false); + const [error, setError] = useState(""); + const [msg, setMsg] = useState(""); + + const selectedItem = useMemo( + () => items.find((item) => item.id === selectedItemId) ?? null, + [items, selectedItemId] + ); + + const unitCost = useMemo(() => { + if (!selectedItem) return 0; + return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost; + }, [dayType, selectedItem]); + + const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]); + + const loadAll = async () => { + setLoading(true); + setError(""); + setMsg(""); + try { + const tk = readToken(); + setToken(tk); + if (!tk) throw new Error("请先登录"); + + const [me, redeemItems, redeemRecords, daily] = await Promise.all([ + apiFetch("/api/v1/me", {}, tk), + apiFetch("/api/v1/me/redeem/items", {}, tk), + apiFetch("/api/v1/me/redeem/records?limit=200", {}, tk), + apiFetch("/api/v1/me/daily-tasks", {}, tk), + ]); + setProfile(me); + setItems(redeemItems ?? []); + setRecords(redeemRecords ?? []); + setDailyTasks(daily?.tasks ?? []); + setDailyDayKey(daily?.day_key ?? ""); + setDailyTotalReward(daily?.total_reward ?? 0); + setDailyGainedReward(daily?.gained_reward ?? 0); + + if ((redeemItems ?? []).length > 0) { + setSelectedItemId((prev) => prev || redeemItems[0].id); + } + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; useEffect(() => { - const load = async () => { - setLoading(true); - setError(""); - try { - const token = readToken(); - if (!token) throw new Error("请先登录"); - const d = await apiFetch("/api/v1/me", {}, token); - setData(d); - } catch (e: unknown) { - setError(String(e)); - } finally { - setLoading(false); - } - }; - void load(); + void loadAll(); }, []); + const redeem = async () => { + setRedeemLoading(true); + setError(""); + setMsg(""); + try { + if (!token) throw new Error("请先登录"); + if (!selectedItemId) throw new Error("请选择兑换物品"); + if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0"); + + const created = await apiFetch( + "/api/v1/me/redeem/records", + { + method: "POST", + body: JSON.stringify({ + item_id: selectedItemId, + quantity, + day_type: dayType, + note, + }), + }, + token + ); + + setMsg( + `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${ + typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : "" + }。` + ); + setNote(""); + await loadAll(); + } catch (e: unknown) { + setError(String(e)); + } finally { + setRedeemLoading(false); + } + }; + return ( -
-

我的信息

+
+

我的信息与积分兑换

{loading &&

加载中...

} {error &&

{error}

} + {msg &&

{msg}

} - {data && ( -
-

ID: {data.id}

-

用户名: {data.username}

-

Rating: {data.rating}

-

创建时间: {new Date(data.created_at * 1000).toLocaleString()}

-
+ {profile && ( +
+

ID: {profile.id}

+

用户名: {profile.username}

+

Rating: {profile.rating}

+

创建时间: {fmtTs(profile.created_at)}

+
)} + +
+
+

每日任务

+

+ {dailyDayKey ? `${dailyDayKey} · ` : ""}已获 {dailyGainedReward}/{dailyTotalReward} 分 +

+
+
+ {dailyTasks.map((task) => ( +
+
+

+ {task.title} · +{task.reward} +

+ + {task.completed ? "已完成" : "未完成"} + +
+

{task.description}

+ {task.completed && ( +

完成时间:{fmtTs(task.completed_at)}

+ )} +
+ ))} + {!loading && dailyTasks.length === 0 && ( +

今日任务尚未初始化,请稍后刷新。

+ )} +
+
+ +
+

积分兑换物品

+

+ 示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分) +

+ +
+ {items.map((item) => ( +
+
+

{item.name}

+ +
+

{item.description || "-"}

+

假期:{item.holiday_cost} / {item.unit_label}

+

学习日:{item.studyday_cost} / {item.unit_label}

+
+ ))} + {!loading && items.length === 0 && ( +

管理员尚未配置可兑换物品。

+ )} +
+ +
+

兑换表单

+
+ + + + + setQuantity(Math.max(1, Number(e.target.value) || 1))} + placeholder="兑换时长(小时)" + /> + + setNote(e.target.value)} + placeholder="备注(可选)" + /> +
+ +

+ 当前单价:{unitCost} / 小时;预计扣分:{totalCost} +

+ + +
+
+ +
+
+

兑换记录

+ +
+ +
+ {records.map((row) => ( +
+

+ #{row.id} · {row.item_name} · {row.quantity} 小时 · {row.day_type === "holiday" ? "假期" : "学习日"} +

+

+ 单价 {row.unit_cost},总扣分 {row.total_cost} · {fmtTs(row.created_at)} +

+ {row.note &&

备注:{row.note}

} +
+ ))} + {!loading && records.length === 0 &&

暂无兑换记录。

} +
+
); }