From bd300ac5971a7b4fbcf5666de3295fc294db4232 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Feb 2026 18:38:56 +0800 Subject: [PATCH] feat: redeem items support optional duration (permanent/timed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add duration_minutes column to redeem_items (0 = permanent) - Backend: update RedeemItem/RedeemItemWrite structs, all CRUD SQL - Backend: EnsureColumn migration for existing databases - Frontend: add duration selector dropdown (永久/15min/30min/1h/2h/3h/custom) - Frontend: show ♾️ Permanent or ⏱️ duration in item list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/include/csp/services/redeem_service.h | 2 + backend/src/controllers/admin_controller.cc | 2 + backend/src/db/sqlite_db.cc | 3 ++ backend/src/services/redeem_service.cc | 43 ++++++++++--------- frontend/src/app/admin-redeem/page.tsx | 38 ++++++++++++++++ 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/backend/include/csp/services/redeem_service.h b/backend/include/csp/services/redeem_service.h index 0a68709..46cb4b7 100644 --- a/backend/include/csp/services/redeem_service.h +++ b/backend/include/csp/services/redeem_service.h @@ -16,6 +16,7 @@ struct RedeemItem { std::string unit_label; int holiday_cost = 0; int studyday_cost = 0; + int duration_minutes = 0; // 0 = permanent bool is_active = true; bool is_global = true; int64_t created_by = 0; @@ -29,6 +30,7 @@ struct RedeemItemWrite { std::string unit_label = "小时"; int holiday_cost = 5; int studyday_cost = 25; + int duration_minutes = 0; // 0 = permanent bool is_active = true; bool is_global = true; }; diff --git a/backend/src/controllers/admin_controller.cc b/backend/src/controllers/admin_controller.cc index 0fc7c44..8315eb5 100644 --- a/backend/src/controllers/admin_controller.cc +++ b/backend/src/controllers/admin_controller.cc @@ -68,6 +68,7 @@ services::RedeemItemWrite ParseRedeemItemWrite(const Json::Value& json) { 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.duration_minutes = json.get("duration_minutes", 0).asInt(); write.is_active = json.get("is_active", true).asBool(); write.is_global = json.get("is_global", true).asBool(); return write; @@ -81,6 +82,7 @@ Json::Value ToJson(const services::RedeemItem& item) { j["unit_label"] = item.unit_label; j["holiday_cost"] = item.holiday_cost; j["studyday_cost"] = item.studyday_cost; + j["duration_minutes"] = item.duration_minutes; j["is_active"] = item.is_active; j["is_global"] = item.is_global; j["created_by"] = Json::Int64(item.created_by); diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index 3efa83b..956f1fd 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS redeem_items ( unit_label TEXT NOT NULL DEFAULT "小时", holiday_cost INTEGER NOT NULL DEFAULT 5, studyday_cost INTEGER NOT NULL DEFAULT 25, + duration_minutes INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, is_global INTEGER NOT NULL DEFAULT 1, created_by INTEGER NOT NULL DEFAULT 0, @@ -594,6 +595,8 @@ CREATE TABLE IF NOT EXISTS daily_task_logs ( EnsureColumn(db, "problem_solutions", "complexity", "complexity TEXT NOT NULL DEFAULT ''"); EnsureColumn(db, "problem_solutions", "tags_json", "tags_json TEXT NOT NULL DEFAULT '[]'"); + EnsureColumn(db, "redeem_items", "duration_minutes", + "duration_minutes INTEGER NOT NULL DEFAULT 0"); // Build indexes after compatibility ALTERs so old schemas won't fail on // missing columns (e.g. legacy submissions table without contest_id). diff --git a/backend/src/services/redeem_service.cc b/backend/src/services/redeem_service.cc index 329a673..6878c84 100644 --- a/backend/src/services/redeem_service.cc +++ b/backend/src/services/redeem_service.cc @@ -36,11 +36,12 @@ RedeemItem ReadItem(sqlite3_stmt* stmt) { 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); + item.duration_minutes = sqlite3_column_int(stmt, 6); + item.is_active = sqlite3_column_int(stmt, 7) != 0; + item.is_global = sqlite3_column_int(stmt, 8) != 0; + item.created_by = sqlite3_column_int64(stmt, 9); + item.created_at = sqlite3_column_int64(stmt, 10); + item.updated_at = sqlite3_column_int64(stmt, 11); return item; } @@ -114,10 +115,10 @@ std::vector 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 " + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,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 " + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,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, @@ -136,7 +137,7 @@ std::optional RedeemService::GetItemById(int64_t item_id) { 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 " + "SELECT id,name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,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"); @@ -156,8 +157,8 @@ RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrit 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(?,?,?,?,?,?,?,?,?,?)"; + "INSERT INTO redeem_items(name,description,unit_label,holiday_cost,studyday_cost,duration_minutes,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, @@ -168,11 +169,12 @@ RedeemItem RedeemService::CreateItem(int64_t admin_user_id, const RedeemItemWrit "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_bind_int(stmt, 6, input.duration_minutes), db, "bind duration_minutes"); + CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_active ? 1 : 0), db, "bind is_active"); + CheckSqlite(sqlite3_bind_int(stmt, 8, input.is_global ? 1 : 0), db, "bind is_global"); + CheckSqlite(sqlite3_bind_int64(stmt, 9, admin_user_id), db, "bind created_by"); + CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 11, now), db, "bind updated_at"); CheckSqlite(sqlite3_step(stmt), db, "create redeem item"); sqlite3_finalize(stmt); @@ -190,7 +192,7 @@ RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& inp 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=? " + "SET name=?,description=?,unit_label=?,holiday_cost=?,studyday_cost=?,duration_minutes=?,is_active=?,is_global=?,updated_at=? " "WHERE id=?"; CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, "prepare update redeem item"); @@ -202,10 +204,11 @@ RedeemItem RedeemService::UpdateItem(int64_t item_id, const RedeemItemWrite& inp "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_bind_int(stmt, 6, input.duration_minutes), db, "bind duration_minutes"); + CheckSqlite(sqlite3_bind_int(stmt, 7, input.is_active ? 1 : 0), db, "bind is_active"); + CheckSqlite(sqlite3_bind_int(stmt, 8, input.is_global ? 1 : 0), db, "bind is_global"); + CheckSqlite(sqlite3_bind_int64(stmt, 9, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 10, 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"); diff --git a/frontend/src/app/admin-redeem/page.tsx b/frontend/src/app/admin-redeem/page.tsx index 404e003..81916c3 100644 --- a/frontend/src/app/admin-redeem/page.tsx +++ b/frontend/src/app/admin-redeem/page.tsx @@ -14,6 +14,7 @@ type RedeemItem = { unit_label: string; holiday_cost: number; studyday_cost: number; + duration_minutes: number; is_active: boolean; is_global: boolean; created_at: number; @@ -40,6 +41,7 @@ type ItemForm = { unit_label: string; holiday_cost: number; studyday_cost: number; + duration_minutes: number; is_active: boolean; is_global: boolean; }; @@ -50,6 +52,7 @@ const DEFAULT_FORM: ItemForm = { unit_label: "hour", holiday_cost: 5, studyday_cost: 25, + duration_minutes: 0, is_active: true, is_global: true, }; @@ -148,6 +151,7 @@ export default function AdminRedeemPage() { unit_label: item.unit_label, holiday_cost: item.holiday_cost, studyday_cost: item.studyday_cost, + duration_minutes: item.duration_minutes, is_active: item.is_active, is_global: item.is_global, }); @@ -217,6 +221,34 @@ export default function AdminRedeemPage() { setForm((prev) => ({ ...prev, studyday_cost: Math.max(0, Number(e.target.value) || 0) })) } /> +
+ + {form.duration_minutes === -1 && ( + { + const v = Math.max(1, Number(e.target.value) || 1); + setForm((prev) => ({ ...prev, duration_minutes: v })); + }} + /> + )} +