2026-06-25 07:43:27 +09:00
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
2026-06-28 23:48:13 +00:00
"math/rand"
2026-06-25 07:43:27 +09:00
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/generative-ai-go/genai"
2026-06-28 23:48:13 +00:00
"github.com/wailsapp/wails/v3/pkg/application"
2026-06-25 07:43:27 +09:00
"google.golang.org/api/option"
2026-06-28 23:48:13 +00:00
_ "modernc.org/sqlite"
2026-06-25 07:43:27 +09:00
)
2026-06-28 23:48:13 +00:00
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
Status string `json:"status"` // 'pending', 'approved', 'rejected'
ApiKey string `json:"apiKey"`
CreatedAt string `json:"createdAt"`
}
2026-06-25 07:43:27 +09:00
type Question struct {
ID int `json:"id"`
Source string `json:"source"`
Category string `json:"category"`
QuestionText string `json:"questionText"`
Choice1 string `json:"choice1"`
Choice2 string `json:"choice2"`
Choice3 string `json:"choice3"`
Choice4 string `json:"choice4"`
Choice5 string `json:"choice5"`
CorrectAnswer int `json:"correctAnswer"`
CorrectAnswerStr string `json:"correctAnswerStr"`
Explanation string `json:"explanation"`
Difficulty string `json:"difficulty"`
Passage string `json:"passage"`
ImageUrl string `json:"imageUrl"`
GeminiExplanation string `json:"geminiExplanation"`
IsBookmarked bool `json:"isBookmarked"`
Subject string `json:"subject"`
CorrectAnswers string `json:"correctAnswers"`
Oid int `json:"oid"`
}
type DescriptiveGradingRecord struct {
2026-07-04 07:42:19 +00:00
ID int `json:"id"`
QuestionID int `json:"questionId"`
Source string `json:"source"`
UserAnswer string `json:"userAnswer"`
Score int `json:"score"`
Evaluation string `json:"evaluation"`
Username string `json:"username"`
CreatedAt string `json:"createdAt"`
OcrImageUrl string `json:"ocrImageUrl"`
2026-06-25 07:43:27 +09:00
}
type CategoryInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type TargetInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type QuizService struct {
db * sql . DB
geminiKey string
2026-06-28 23:48:13 +00:00
app * application . App
}
func ( s * QuizService ) SetApp ( app * application . App ) {
s . app = app
2026-06-25 07:43:27 +09:00
}
func NewQuizService () * QuizService {
dbPath := os . Getenv ( "DB_PATH" )
if dbPath == "" {
// Prefer the database in the parent directory first if it exists,
// as it contains the real quiz database for development.
parentPath := filepath . Join ( ".." , "qple_quiz.db" )
if _ , err := os . Stat ( parentPath ); err == nil {
dbPath = parentPath
} else {
dbPath = "qple_quiz.db"
}
}
log . Printf ( "Connecting to database at: %s" , dbPath )
db , err := sql . Open ( "sqlite" , dbPath )
if err != nil {
log . Fatalf ( "Failed to open database: %v" , err )
}
// Create QuizExplain table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS QuizExplain (
id INTEGER NOT NULL,
source TEXT NOT NULL,
explain TEXT,
PRIMARY KEY (source, id),
FOREIGN KEY (source, id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
)
` )
if err != nil {
log . Fatalf ( "Failed to create QuizExplain table: %v" , err )
}
2026-06-28 23:48:13 +00:00
// Create Users table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS Users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
nickname TEXT NOT NULL,
phone TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
` )
if err != nil {
log . Fatalf ( "Failed to create Users table: %v" , err )
}
// Create UserCategories table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS UserCategories (
username TEXT NOT NULL,
category TEXT NOT NULL,
PRIMARY KEY (username, category)
)
` )
if err != nil {
log . Fatalf ( "Failed to create UserCategories table: %v" , err )
}
// Insert default admin account if not exists
var adminCount int
err = db . QueryRow ( "SELECT COUNT(*) FROM Users WHERE username = 'admin'" ). Scan ( & adminCount )
if err == nil && adminCount == 0 {
_ , err = db . Exec ( "INSERT INTO Users (username, password, nickname, phone, status) VALUES ('admin', '150295', '관리자', '010-0000-0000', 'approved')" )
if err != nil {
log . Printf ( "Warning: Failed to seed admin account: %v" , err )
} else {
log . Println ( "Database Seed: Created default admin account." )
}
}
// Create Bookmark table with username if it doesn't exist
2026-06-25 07:43:27 +09:00
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS Bookmark (
2026-06-28 23:48:13 +00:00
username TEXT NOT NULL DEFAULT 'admin',
2026-06-25 07:43:27 +09:00
question_id INTEGER NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
2026-06-28 23:48:13 +00:00
PRIMARY KEY (username, source, question_id),
2026-06-25 07:43:27 +09:00
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
)
` )
if err != nil {
log . Fatalf ( "Failed to create Bookmark table: %v" , err )
}
2026-06-28 23:48:13 +00:00
// Bookmark table schema migration if legacy schema (without username) exists
var hasUsernameInBookmark int
err = db . QueryRow ( "SELECT COUNT(*) FROM pragma_table_info('Bookmark') WHERE name='username'" ). Scan ( & hasUsernameInBookmark )
if err == nil && hasUsernameInBookmark == 0 {
log . Println ( "Database Migration: Upgrading Bookmark table schema to include username..." )
_ , err = db . Exec ( "ALTER TABLE Bookmark RENAME TO Bookmark_old" )
if err != nil {
log . Printf ( "Warning: Failed to rename Bookmark table for migration: %v" , err )
} else {
_ , err = db . Exec ( `
CREATE TABLE Bookmark (
username TEXT NOT NULL DEFAULT 'admin',
question_id INTEGER NOT NULL,
source TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (username, source, question_id),
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
)
` )
if err != nil {
log . Fatalf ( "Failed to create new Bookmark table during migration: %v" , err )
}
_ , err = db . Exec ( "INSERT OR IGNORE INTO Bookmark (username, question_id, source, created_at) SELECT 'admin', question_id, source, created_at FROM Bookmark_old" )
if err != nil {
log . Printf ( "Warning: Failed to copy old bookmark data: %v" , err )
}
_ , err = db . Exec ( "DROP TABLE Bookmark_old" )
if err != nil {
log . Printf ( "Warning: Failed to drop old Bookmark table: %v" , err )
}
log . Println ( "Database Migration: Bookmark table successfully migrated." )
}
}
2026-06-25 07:43:27 +09:00
// Create DescriptiveGrading table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS DescriptiveGrading (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL,
source TEXT NOT NULL,
user_answer TEXT NOT NULL,
score INTEGER NOT NULL,
evaluation TEXT NOT NULL,
username TEXT NOT NULL DEFAULT 'admin',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (source, question_id) REFERENCES QuizQuestions (source, id) ON DELETE CASCADE
)
` )
if err != nil {
log . Fatalf ( "Failed to create DescriptiveGrading table: %v" , err )
}
2026-07-04 07:42:19 +00:00
// Alter DescriptiveGrading to add ocr_image_url if it doesn't exist
_ , _ = db . Exec ( `ALTER TABLE DescriptiveGrading ADD COLUMN ocr_image_url TEXT DEFAULT ''` )
2026-06-28 23:48:13 +00:00
// Create GeminiKeys table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS GeminiKeys (
api_key TEXT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
` )
if err != nil {
log . Fatalf ( "Failed to create GeminiKeys table: %v" , err )
}
// Create GeminiSettings table if it doesn't exist
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS GeminiSettings (
key TEXT PRIMARY KEY,
value TEXT
)
` )
if err != nil {
log . Fatalf ( "Failed to create GeminiSettings table: %v" , err )
}
2026-06-25 07:43:27 +09:00
// Ensure subject column exists in QuizQuestions table
var hasSubject int
err = db . QueryRow ( "SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='subject'" ). Scan ( & hasSubject )
if err == nil && hasSubject == 0 {
_ , err = db . Exec ( "ALTER TABLE QuizQuestions ADD COLUMN subject TEXT DEFAULT ''" )
if err != nil {
log . Printf ( "Warning: Failed to add subject column: %v" , err )
} else {
log . Println ( "Database Migration: Added subject column to QuizQuestions table." )
}
}
// Ensure correct_answers column exists in QuizQuestions table
var hasCorrectAnswers int
err = db . QueryRow ( "SELECT COUNT(*) FROM pragma_table_info('QuizQuestions') WHERE name='correct_answers'" ). Scan ( & hasCorrectAnswers )
if err == nil && hasCorrectAnswers == 0 {
_ , err = db . Exec ( "ALTER TABLE QuizQuestions ADD COLUMN correct_answers TEXT DEFAULT ''" )
if err != nil {
log . Printf ( "Warning: Failed to add correct_answers column: %v" , err )
} else {
log . Println ( "Database Migration: Added correct_answers column to QuizQuestions table." )
}
}
2026-06-28 23:48:13 +00:00
// Ensure api_key column exists in Users table
var hasApiKey int
err = db . QueryRow ( "SELECT COUNT(*) FROM pragma_table_info('Users') WHERE name='api_key'" ). Scan ( & hasApiKey )
if err == nil && hasApiKey == 0 {
_ , err = db . Exec ( "ALTER TABLE Users ADD COLUMN api_key TEXT DEFAULT ''" )
if err != nil {
log . Printf ( "Warning: Failed to add api_key column to Users table: %v" , err )
} else {
log . Println ( "Database Migration: Added api_key column to Users table." )
}
}
2026-06-25 07:43:27 +09:00
return & QuizService {
db : db ,
// Initialize this via some UI setting later, or env var
geminiKey : "" ,
}
}
// SetGeminiKey sets the API key for Gemini calls
func ( s * QuizService ) SetGeminiKey ( key string ) {
s . geminiKey = key
2026-06-28 23:48:13 +00:00
// Also save it to the multiple keys DB as well to prevent losing it
if strings . TrimSpace ( key ) != "" {
_ = s . AddGeminiKey ( key )
}
}
// AddGeminiKey adds a new Gemini API Key to database
func ( s * QuizService ) AddGeminiKey ( key string ) error {
trimmed := strings . TrimSpace ( key )
if trimmed == "" {
return fmt . Errorf ( "API Key cannot be empty" )
}
_ , err := s . db . Exec ( "INSERT OR IGNORE INTO GeminiKeys (api_key) VALUES (?)" , trimmed )
return err
}
// DeleteGeminiKey deletes a Gemini API Key from database
func ( s * QuizService ) DeleteGeminiKey ( key string ) error {
_ , err := s . db . Exec ( "DELETE FROM GeminiKeys WHERE api_key = ?" , key )
return err
}
// GetGeminiKeys returns all stored Gemini API Keys
func ( s * QuizService ) GetGeminiKeys () ([] string , error ) {
rows , err := s . db . Query ( "SELECT api_key FROM GeminiKeys ORDER BY created_at ASC" )
if err != nil {
return nil , err
}
defer rows . Close ()
var keys [] string
for rows . Next () {
var k string
if err := rows . Scan ( & k ); err == nil {
keys = append ( keys , k )
}
}
return keys , nil
}
// GetPreferredModel returns the user's preferred Gemini model from database
func ( s * QuizService ) GetPreferredModel () ( string , error ) {
var model string
err := s . db . QueryRow ( "SELECT value FROM GeminiSettings WHERE key = 'preferred_model'" ). Scan ( & model )
if err == sql . ErrNoRows {
return "gemini-3.1-flash-lite" , nil // default
}
return model , err
}
// SetPreferredModel saves the user's preferred Gemini model to database
func ( s * QuizService ) SetPreferredModel ( modelName string ) error {
trimmed := strings . TrimSpace ( modelName )
if trimmed == "" {
return fmt . Errorf ( "model name cannot be empty" )
}
_ , err := s . db . Exec ( "INSERT OR REPLACE INTO GeminiSettings (key, value) VALUES ('preferred_model', ?)" , trimmed )
return err
2026-06-25 07:43:27 +09:00
}
func ( s * QuizService ) GetCategories () [] CategoryInfo {
rows , err := s . db . Query ( "SELECT category, COUNT(*) as count FROM QuizQuestions WHERE category IS NOT NULL AND category != '' GROUP BY category" )
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var categories [] CategoryInfo
for rows . Next () {
var cat CategoryInfo
if err := rows . Scan ( & cat . Name , & cat . Count ); err != nil {
continue
}
categories = append ( categories , cat )
}
return categories
}
func ( s * QuizService ) GetTargets ( category string ) [] TargetInfo {
rows , err := s . db . Query ( "SELECT difficulty, COUNT(*) as count FROM QuizQuestions WHERE category = ? AND difficulty IS NOT NULL AND difficulty != '' GROUP BY difficulty" , category )
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var targets [] TargetInfo
for rows . Next () {
var t TargetInfo
if err := rows . Scan ( & t . Name , & t . Count ); err != nil {
continue
}
targets = append ( targets , t )
}
return targets
}
func ( s * QuizService ) GetSubjects ( category , difficulty string ) [] string {
rows , err := s . db . Query ( "SELECT DISTINCT subject FROM QuizQuestions WHERE category = ? AND difficulty = ? AND subject IS NOT NULL AND subject != '' ORDER BY subject" , category , difficulty )
if err != nil {
log . Printf ( "Query error in GetSubjects: %v" , err )
return nil
}
defer rows . Close ()
var subjects [] string
for rows . Next () {
var sub string
if err := rows . Scan ( & sub ); err != nil {
continue
}
subjects = append ( subjects , sub )
}
return subjects
}
2026-06-28 23:48:13 +00:00
func ( s * QuizService ) GetQuestions ( username , category , difficulty , subject string ) [] Question {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5,
q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url,
COALESCE(e.explain, '') as geminiExplanation,
2026-06-28 23:48:13 +00:00
EXISTS(SELECT 1 FROM Bookmark WHERE username = ? AND source = q.source AND question_id = q.id) as isBookmarked,
2026-06-25 07:43:27 +09:00
COALESCE(q.subject, '') as subject,
COALESCE(q.correct_answers, '') as correct_answers,
COALESCE(q.oid, 0) as oid
FROM QuizQuestions q
LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id
WHERE q.category = ?
`
var args [] interface {}
2026-06-28 23:48:13 +00:00
args = append ( args , username , category )
2026-06-25 07:43:27 +09:00
if difficulty != "" && difficulty != "All" {
query += " AND q.difficulty = ?"
args = append ( args , difficulty )
}
if subject != "" && subject != "All" && subject != "dummy" {
query += " AND q.subject = ?"
args = append ( args , subject )
}
rows , err := s . db . Query ( query , args ... )
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var questions [] Question
for rows . Next () {
var q Question
err := rows . Scan (
& q . ID , & q . Source , & q . Category , & q . QuestionText , & q . Choice1 , & q . Choice2 , & q . Choice3 , & q . Choice4 , & q . Choice5 ,
& q . CorrectAnswer , & q . CorrectAnswerStr , & q . Explanation , & q . Difficulty , & q . Passage , & q . ImageUrl ,
& q . GeminiExplanation , & q . IsBookmarked , & q . Subject , & q . CorrectAnswers , & q . Oid ,
)
if err != nil {
log . Printf ( "Scan error: %v" , err )
continue
}
questions = append ( questions , q )
}
return questions
}
2026-06-28 23:48:13 +00:00
// AddBookmark adds a source and question ID to the Bookmark table for a user
func ( s * QuizService ) AddBookmark ( username , source string , questionID int ) error {
_ , err := s . db . Exec ( "INSERT OR IGNORE INTO Bookmark (username, source, question_id) VALUES (?, ?, ?)" , username , source , questionID )
2026-06-25 07:43:27 +09:00
return err
}
2026-06-28 23:48:13 +00:00
// RemoveBookmark removes a source and question ID from the Bookmark table for a user
func ( s * QuizService ) RemoveBookmark ( username , source string , questionID int ) error {
_ , err := s . db . Exec ( "DELETE FROM Bookmark WHERE username = ? AND source = ? AND question_id = ?" , username , source , questionID )
2026-06-25 07:43:27 +09:00
return err
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedCategories retrieves categories that have bookmarked questions for a user
func ( s * QuizService ) GetBookmarkedCategories ( username string ) [] CategoryInfo {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.category, COUNT(*) as count
FROM Bookmark b
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category IS NOT NULL AND q.category != ''
2026-06-25 07:43:27 +09:00
GROUP BY q.category
`
2026-06-28 23:48:13 +00:00
rows , err := s . db . Query ( query , username )
2026-06-25 07:43:27 +09:00
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var categories [] CategoryInfo
for rows . Next () {
var cat CategoryInfo
if err := rows . Scan ( & cat . Name , & cat . Count ); err != nil {
continue
}
categories = append ( categories , cat )
}
return categories
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedTargets retrieves difficulties (targets) that have bookmarked questions within a category for a user
func ( s * QuizService ) GetBookmarkedTargets ( username , category string ) [] TargetInfo {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.difficulty, COUNT(*) as count
FROM Bookmark b
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category = ? AND q.difficulty IS NOT NULL AND q.difficulty != ''
2026-06-25 07:43:27 +09:00
GROUP BY q.difficulty
`
2026-06-28 23:48:13 +00:00
rows , err := s . db . Query ( query , username , category )
2026-06-25 07:43:27 +09:00
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var targets [] TargetInfo
for rows . Next () {
var t TargetInfo
if err := rows . Scan ( & t . Name , & t . Count ); err != nil {
continue
}
targets = append ( targets , t )
}
return targets
}
2026-06-28 23:48:13 +00:00
// GetBookmarkedQuestions retrieves all bookmarked questions for a given category and difficulty for a user
func ( s * QuizService ) GetBookmarkedQuestions ( username , category , difficulty string ) [] Question {
2026-06-25 07:43:27 +09:00
query := `
SELECT q.id, q.source, q.category, q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5,
q.correct_answer, q.correct_answer_str, q.explanation, q.difficulty, q.passage, q.image_url,
COALESCE(e.explain, '') as geminiExplanation,
1 as isBookmarked,
COALESCE(q.subject, '') as subject,
COALESCE(q.correct_answers, '') as correct_answers,
COALESCE(q.oid, 0) as oid
FROM Bookmark b
JOIN QuizQuestions q ON b.source = q.source AND b.question_id = q.id
LEFT JOIN QuizExplain e ON q.source = e.source AND q.id = e.id
2026-06-28 23:48:13 +00:00
WHERE b.username = ? AND q.category = ?
2026-06-25 07:43:27 +09:00
`
2026-06-28 23:48:13 +00:00
var args [] interface {}
args = append ( args , username , category )
if difficulty != "" && difficulty != "All" {
query += " AND q.difficulty = ?"
args = append ( args , difficulty )
}
rows , err := s . db . Query ( query , args ... )
2026-06-25 07:43:27 +09:00
if err != nil {
log . Printf ( "Query error: %v" , err )
return nil
}
defer rows . Close ()
var questions [] Question
for rows . Next () {
var q Question
err := rows . Scan (
& q . ID , & q . Source , & q . Category , & q . QuestionText , & q . Choice1 , & q . Choice2 , & q . Choice3 , & q . Choice4 , & q . Choice5 ,
& q . CorrectAnswer , & q . CorrectAnswerStr , & q . Explanation , & q . Difficulty , & q . Passage , & q . ImageUrl ,
& q . GeminiExplanation , & q . IsBookmarked , & q . Subject , & q . CorrectAnswers , & q . Oid ,
)
if err != nil {
log . Printf ( "Scan error: %v" , err )
continue
}
questions = append ( questions , q )
}
return questions
}
func ( s * QuizService ) SaveAiExplanation ( source string , id int , explanation string ) error {
_ , err := s . db . Exec ( `
INSERT INTO QuizExplain (source, id, explain) VALUES (?, ?, ?)
ON CONFLICT(source, id) DO UPDATE SET explain=excluded.explain
` , source , id , explanation )
return err
}
func parseBase64Image ( base64Str string ) ( string , [] byte , error ) {
mimeType := "image/png" // default fallback
rawData := base64Str
if strings . HasPrefix ( base64Str , "data:" ) {
parts := strings . SplitN ( base64Str , ";base64," , 2 )
if len ( parts ) == 2 {
mimeType = strings . TrimPrefix ( parts [ 0 ], "data:" )
rawData = parts [ 1 ]
}
}
rawData = strings . ReplaceAll ( rawData , "\n" , "" )
rawData = strings . ReplaceAll ( rawData , "\r" , "" )
rawData = strings . ReplaceAll ( rawData , " " , "" )
decoded , err := base64 . StdEncoding . DecodeString ( rawData )
if err != nil {
return "" , nil , err
}
return mimeType , decoded , nil
}
2026-06-28 23:48:13 +00:00
func minVal ( a , b int ) int {
if a < b {
return a
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
return b
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
func ( s * QuizService ) executeGeminiWithRetry ( ctx context . Context , parts [] genai . Part ) ( * genai . GenerateContentResponse , error ) {
keys , err := s . GetGeminiKeys ()
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return nil , fmt . Errorf ( "failed to load API keys: %v" , err )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if len ( keys ) == 0 {
if s . geminiKey != "" {
keys = [] string { s . geminiKey }
2026-06-25 07:43:27 +09:00
} else {
2026-06-28 23:48:13 +00:00
return nil , fmt . Errorf ( "Gemini API key is not set. Please add at least one key in the settings." )
2026-06-25 07:43:27 +09:00
}
}
2026-06-28 23:48:13 +00:00
defaultModels := [] string {
"gemini-3.1-flash-lite" ,
"gemini-3.5-flash" ,
"gemini-2.5-flash" ,
"gemini-2.0-flash-lite" ,
"gemini-1.5-flash" ,
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
prefModel , err := s . GetPreferredModel ()
var models [] string
if err == nil && prefModel != "" {
models = append ( models , prefModel )
for _ , m := range defaultModels {
if m != prefModel {
models = append ( models , m )
}
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
} else {
models = defaultModels
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
var lastErr error
for _ , modelName := range models {
log . Printf ( "[Gemini] Trying model: %s" , modelName )
shuffledKeys := make ([] string , len ( keys ))
copy ( shuffledKeys , keys )
rand . Seed ( time . Now (). UnixNano ())
rand . Shuffle ( len ( shuffledKeys ), func ( i , j int ) {
shuffledKeys [ i ], shuffledKeys [ j ] = shuffledKeys [ j ], shuffledKeys [ i ]
})
for _ , key := range shuffledKeys {
client , err := genai . NewClient ( ctx , option . WithAPIKey ( key ))
if err != nil {
lastErr = err
log . Printf ( "[Gemini] Failed to create client with key (ending in ...%s): %v" , key [ len ( key ) - minVal ( 4 , len ( key )):], err )
continue
}
model := client . GenerativeModel ( modelName )
resp , apiErr := model . GenerateContent ( ctx , parts ... )
if apiErr == nil {
client . Close ()
return resp , nil
}
client . Close ()
lastErr = apiErr
log . Printf ( "[Gemini] API Key (ending in ...%s) failed: %v. Trying next key immediately." , key [ len ( key ) - minVal ( 4 , len ( key )):], apiErr )
}
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if lastErr != nil {
return nil , fmt . Errorf ( "all models and keys failed. Last error: %v" , lastErr )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
return nil , fmt . Errorf ( "all models and keys failed without error info" )
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
func ( s * QuizService ) emitEvent ( name string , data interface {}) {
if s . app != nil {
s . app . Event . Emit ( name , data )
} else {
log . Printf ( "Warning: cannot emit event %s, app instance is nil" , name )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
func ( s * QuizService ) AskGemini ( source string , id int , prompt string , base64Image string ) ( string , error ) {
// Start background goroutine for Gemini explanation creation
go func () {
ctx := context . Background ()
// Build parts (Prompt text + Optional base64 image + auto-extracted question assets)
var parts [] genai . Part
parts = append ( parts , genai . Text ( prompt ))
if base64Image != "" {
mimeType , decodedData , err := parseBase64Image ( base64Image )
if err == nil {
parts = append ( parts , genai . Blob {
MIMEType : mimeType ,
Data : decodedData ,
})
log . Printf ( "Successfully attached base64 image asset to Gemini (MIME: %s, size: %d bytes)" , mimeType , len ( decodedData ))
} else {
log . Printf ( "Error decoding base64 image for Gemini: %v" , err )
}
}
// Automatically scan and attach all image assets associated with this question
parts = s . attachQuestionAssets ( parts , source , id )
resp , err := s . executeGeminiWithRetry ( ctx , parts )
if err != nil {
log . Printf ( "Background Gemini error for question %d: %v" , id , err )
s . emitEvent ( "ai:explanation:done" , map [ string ] interface {}{
"source" : source ,
"id" : id ,
"error" : err . Error (),
})
return
}
if len ( resp . Candidates ) == 0 || len ( resp . Candidates [ 0 ]. Content . Parts ) == 0 {
log . Printf ( "Background Gemini returned no response for question %d" , id )
s . emitEvent ( "ai:explanation:done" , map [ string ] interface {}{
"source" : source ,
"id" : id ,
"error" : "No response from Gemini" ,
})
return
}
explanation := fmt . Sprintf ( "%v" , resp . Candidates [ 0 ]. Content . Parts [ 0 ])
// Save to DB automatically
err = s . SaveAiExplanation ( source , id , explanation )
if err != nil {
log . Printf ( "Failed to save explanation to DB: %v" , err )
}
s . emitEvent ( "ai:explanation:done" , map [ string ] interface {}{
"source" : source ,
"id" : id ,
"explanation" : explanation ,
"error" : "" ,
})
}()
return "started" , nil
2026-06-25 07:43:27 +09:00
}
2026-07-04 08:01:01 +00:00
func ( s * QuizService ) PerformGeminiOcr ( base64Image string ) ( string , error ) {
if base64Image == "" {
return "" , fmt . Errorf ( "Empty image data" )
}
mimeType , decodedData , err := parseBase64Image ( base64Image )
if err != nil {
return "" , fmt . Errorf ( "Failed to parse base64 image: %v" , err )
}
ctx := context . Background ()
prompt := "이 이미지에서 손글씨 또는 인쇄된 글자(한글/영어 등)를 오직 텍스트로만 정확하게 추출해 주세요. 질문에 대한 해설이나 부가적인 설명(예: '추출한 텍스트:', 마크다운 장식 등)은 일절 제외하고, 이미지에 써져 있는 내용 그대로만 텍스트로 반환해야 합니다."
parts := [] genai . Part {
genai . Text ( prompt ),
genai . Blob {
MIMEType : mimeType ,
Data : decodedData ,
},
}
resp , err := s . executeGeminiWithRetry ( ctx , parts )
if err != nil {
return "" , fmt . Errorf ( "Gemini API call failed: %v" , err )
}
if len ( resp . Candidates ) == 0 || len ( resp . Candidates [ 0 ]. Content . Parts ) == 0 {
return "" , fmt . Errorf ( "No text detected by Gemini" )
}
resultText := fmt . Sprintf ( "%v" , resp . Candidates [ 0 ]. Content . Parts [ 0 ])
return strings . TrimSpace ( resultText ), nil
}
2026-06-25 07:43:27 +09:00
func ( s * QuizService ) attachQuestionAssets ( parts [] genai . Part , source string , id int ) [] genai . Part {
var questionText , choice1 , choice2 , choice3 , choice4 , choice5 , passage , explanation , imageURL string
queryQ := `
SELECT q.question_text, q.choice1, q.choice2, q.choice3, q.choice4, COALESCE(q.choice5, '') as choice5, q.passage, q.explanation, q.image_url
FROM QuizQuestions q
WHERE q.source = ? AND q.id = ?
`
_ = s . db . QueryRow ( queryQ , source , id ). Scan ( & questionText , & choice1 , & choice2 , & choice3 , & choice4 , & choice5 , & passage , & explanation , & imageURL )
var aiExplanation string
_ = s . db . QueryRow ( "SELECT explain FROM QuizExplain WHERE source = ? AND id = ?" , source , id ). Scan ( & aiExplanation )
// Combine all texts to scan for assets
combinedTexts := strings . Join ([] string {
questionText , choice1 , choice2 , choice3 , choice4 , choice5 , passage , explanation , aiExplanation , imageURL ,
}, " \n " )
// Scan for unique asset paths
uniqueAssets := make ( map [ string ] bool )
assetRegex := regexp . MustCompile ( `/assets/[a-zA-Z0-9_\-\.]+` )
matches := assetRegex . FindAllString ( combinedTexts , - 1 )
for _ , m := range matches {
uniqueAssets [ m ] = true
}
for assetPath := range uniqueAssets {
filename := strings . TrimPrefix ( assetPath , "/assets/" )
var filePath string
2026-06-28 23:48:13 +00:00
path1 := filepath . Join ( "assets" , filename )
path2 := filepath . Join ( "frontend" , "public" , "assets" , filename )
path3 := filepath . Join ( "qple_quiz_app" , "frontend" , "public" , "assets" , filename )
path4 := filepath . Join ( ".." , "qple_quiz_app" , "frontend" , "public" , "assets" , filename )
2026-06-25 07:43:27 +09:00
if _ , err := os . Stat ( path1 ); err == nil {
filePath = path1
} else if _ , err := os . Stat ( path2 ); err == nil {
filePath = path2
} else if _ , err := os . Stat ( path3 ); err == nil {
filePath = path3
2026-06-28 23:48:13 +00:00
} else if _ , err := os . Stat ( path4 ); err == nil {
filePath = path4
2026-06-25 07:43:27 +09:00
}
if filePath != "" {
data , err := os . ReadFile ( filePath )
if err == nil {
ext := strings . ToLower ( filepath . Ext ( filePath ))
mimeType := "image/png" // default fallback
if ext == ".pdf" {
mimeType = "application/pdf"
} else if ext == ".jpg" || ext == ".jpeg" {
mimeType = "image/jpeg"
} else if ext == ".webp" {
mimeType = "image/webp"
} else if ext == ".gif" {
mimeType = "image/gif"
}
parts = append ( parts , genai . Blob {
MIMEType : mimeType ,
Data : data ,
})
log . Printf ( "Successfully attached local asset to Gemini: %s" , filePath )
} else {
log . Printf ( "Error reading local asset for Gemini: %v" , err )
}
}
}
return parts
}
2026-06-28 23:48:13 +00:00
func ( s * QuizService ) GetDescriptiveGradingHistory ( username string , source string , questionID int ) [] DescriptiveGradingRecord {
2026-06-25 07:43:27 +09:00
query := `
2026-07-04 07:42:19 +00:00
SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') as created_at, COALESCE(ocr_image_url, '') as ocr_image_url
2026-06-25 07:43:27 +09:00
FROM DescriptiveGrading
2026-06-28 23:48:13 +00:00
WHERE username = ? AND source = ? AND question_id = ?
2026-06-25 07:43:27 +09:00
ORDER BY created_at DESC
`
2026-06-28 23:48:13 +00:00
rows , err := s . db . Query ( query , username , source , questionID )
2026-06-25 07:43:27 +09:00
if err != nil {
log . Printf ( "Query error in GetDescriptiveGradingHistory: %v" , err )
return nil
}
defer rows . Close ()
var records [] DescriptiveGradingRecord
for rows . Next () {
var r DescriptiveGradingRecord
2026-07-04 07:42:19 +00:00
err := rows . Scan ( & r . ID , & r . QuestionID , & r . Source , & r . UserAnswer , & r . Score , & r . Evaluation , & r . Username , & r . CreatedAt , & r . OcrImageUrl )
2026-06-25 07:43:27 +09:00
if err != nil {
log . Printf ( "Scan error in GetDescriptiveGradingHistory: %v" , err )
continue
}
records = append ( records , r )
}
return records
}
2026-07-04 07:42:19 +00:00
func ( s * QuizService ) GradeDescriptiveAnswer ( username string , source string , questionID int , userAnswer string , ocrImageBase64 string ) ( * DescriptiveGradingRecord , error ) {
2026-06-25 07:43:27 +09:00
var qText , passage , modelAnswer string
queryQ := `SELECT question_text, passage, explanation FROM QuizQuestions WHERE source = ? AND id = ?`
err := s . db . QueryRow ( queryQ , source , questionID ). Scan ( & qText , & passage , & modelAnswer )
if err != nil {
return nil , fmt . Errorf ( "Failed to query question details: %v" , err )
}
if modelAnswer == "" {
var aiExplain string
errExplain := s . db . QueryRow ( "SELECT explain FROM QuizExplain WHERE source = ? AND id = ?" , source , questionID ). Scan ( & aiExplain )
if errExplain == nil && aiExplain != "" {
modelAnswer = aiExplain
}
}
2026-07-04 07:42:19 +00:00
var ocrImageUrl string
if ocrImageBase64 != "" {
base64Data := ocrImageBase64
ext := "png"
if idx := strings . Index ( base64Data , ";base64," ); idx != - 1 {
header := base64Data [: idx ]
if strings . Contains ( header , "image/jpeg" ) || strings . Contains ( header , "image/jpg" ) {
ext = "jpg"
} else if strings . Contains ( header , "image/webp" ) {
ext = "webp"
}
base64Data = base64Data [ idx + 8 :]
}
dec , err := base64 . StdEncoding . DecodeString ( base64Data )
if err == nil {
os . MkdirAll ( "assets/ocr_uploads" , 0755 )
safeSource := sanitizeFilename ( source )
filename := fmt . Sprintf ( "ocr_%s_%s_%d_%d.%s" , username , safeSource , questionID , time . Now (). UnixNano (), ext )
localPath := filepath . Join ( "assets" , "ocr_uploads" , filename )
err = os . WriteFile ( localPath , dec , 0644 )
if err == nil {
ocrImageUrl = "/assets/ocr_uploads/" + filename
} else {
log . Printf ( "Failed to write OCR image file: %v" , err )
}
} else {
log . Printf ( "Failed to decode base64 OCR image: %v" , err )
}
}
2026-06-28 23:48:13 +00:00
// Start background goroutine for Gemini grading
go func () {
ctx := context . Background ()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
prompt := fmt . Sprintf ( `사용자가 작성한 주관식/서술형 답안을 모범답안과 문제에 대조하여 공정하게 채점해 주세요.
2026-06-25 07:43:27 +09:00
[문제 정보]
- 지문: %s
- 문제: %s
- 모범 답안: %s
[사용자 제출 답안]
%s
결과는 반드시 아래의 JSON 포맷으로만 응답해 주세요. 포맷 이외의 텍스트(Markdown 코드 블럭 등)는 포함하지 마세요.
{
"score": <0~100 사이의 정수 점수>,
"evaluation": "<잘한 부분, 아쉬운 부분, 보완점에 대한 상세한 평가 내용 (한글 마크다운 형식)>"
}
` , passage , qText , modelAnswer , userAnswer )
2026-06-28 23:48:13 +00:00
var parts [] genai . Part
parts = append ( parts , genai . Text ( prompt ))
parts = s . attachQuestionAssets ( parts , source , questionID )
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
resp , err := s . executeGeminiWithRetry ( ctx , parts )
if err != nil {
log . Printf ( "Background grading error for question %d: %v" , questionID , err )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : err . Error (),
})
return
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if len ( resp . Candidates ) == 0 || len ( resp . Candidates [ 0 ]. Content . Parts ) == 0 {
log . Printf ( "Background grading returned no response for question %d" , questionID )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : "No response from Gemini" ,
})
return
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
respText := fmt . Sprintf ( "%v" , resp . Candidates [ 0 ]. Content . Parts [ 0 ])
cleanJSON := strings . TrimSpace ( respText )
if strings . HasPrefix ( cleanJSON , "```json" ) {
cleanJSON = strings . TrimPrefix ( cleanJSON , "```json" )
cleanJSON = strings . TrimSuffix ( cleanJSON , "```" )
} else if strings . HasPrefix ( cleanJSON , "```" ) {
cleanJSON = strings . TrimPrefix ( cleanJSON , "```" )
cleanJSON = strings . TrimSuffix ( cleanJSON , "```" )
}
cleanJSON = strings . TrimSpace ( cleanJSON )
var result struct {
Score int `json:"score"`
Evaluation string `json:"evaluation"`
}
err = json . Unmarshal ([] byte ( cleanJSON ), & result )
if err != nil {
log . Printf ( "Failed to parse Gemini response JSON: %q, error: %v" , cleanJSON , err )
result . Score = 70
result . Evaluation = respText
}
stmt , err := s . db . Prepare ( `
2026-07-04 07:42:19 +00:00
INSERT INTO DescriptiveGrading (source, question_id, user_answer, score, evaluation, username, ocr_image_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
2026-06-28 23:48:13 +00:00
` )
if err != nil {
log . Printf ( "Failed to prepare INSERT statement: %v" , err )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : err . Error (),
})
return
}
defer stmt . Close ()
2026-07-04 07:42:19 +00:00
res , err := stmt . Exec ( source , questionID , userAnswer , result . Score , result . Evaluation , username , ocrImageUrl )
2026-06-28 23:48:13 +00:00
if err != nil {
log . Printf ( "Failed to execute INSERT statement: %v" , err )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : err . Error (),
})
return
}
lastID , err := res . LastInsertId ()
if err != nil {
log . Printf ( "Failed to get last insert ID: %v" , err )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : err . Error (),
})
return
}
var record DescriptiveGradingRecord
err = s . db . QueryRow ( `
2026-07-04 07:42:19 +00:00
SELECT id, question_id, source, user_answer, score, evaluation, username, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime'), COALESCE(ocr_image_url, '') as ocr_image_url
2026-06-28 23:48:13 +00:00
FROM DescriptiveGrading
WHERE id = ?
2026-07-04 07:42:19 +00:00
` , lastID ). Scan ( & record . ID , & record . QuestionID , & record . Source , & record . UserAnswer , & record . Score , & record . Evaluation , & record . Username , & record . CreatedAt , & record . OcrImageUrl )
2026-06-28 23:48:13 +00:00
if err != nil {
log . Printf ( "Failed to scan grading record: %v" , err )
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"error" : err . Error (),
})
return
}
// Emit completed event
s . emitEvent ( "ai:grading:done" , map [ string ] interface {}{
"source" : source ,
"questionId" : questionID ,
"record" : record ,
"error" : "" ,
})
}()
// Return a temporary processing record immediately
return & DescriptiveGradingRecord {
2026-07-04 07:42:19 +00:00
ID : - 1 ,
QuestionID : questionID ,
Source : source ,
UserAnswer : userAnswer ,
Score : - 1 , // -1 means in progress
Evaluation : "채점 진행 중..." ,
Username : username ,
CreatedAt : time . Now (). Format ( "2006-01-02 15:04:05" ),
OcrImageUrl : ocrImageUrl ,
2026-06-28 23:48:13 +00:00
}, nil
}
2026-07-04 07:42:19 +00:00
func sanitizeFilename ( s string ) string {
r := strings . NewReplacer ( "/" , "_" , "\\" , "_" , ":" , "_" , "*" , "_" , "?" , "_" , "\"" , "_" , "<" , "_" , ">" , "_" , "|" , "_" , " " , "_" )
return r . Replace ( s )
}
2026-06-28 23:48:13 +00:00
// Login authenticates a user
func ( s * QuizService ) Login ( username , password string ) ( * User , error ) {
var u User
err := s . db . QueryRow ( `
SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime')
FROM Users
WHERE username = ? AND password = ?
` , username , password ). Scan ( & u . ID , & u . Username , & u . Password , & u . Nickname , & u . Phone , & u . Status , & u . ApiKey , & u . CreatedAt )
if err == sql . ErrNoRows {
return nil , fmt . Errorf ( "아이디 또는 비밀번호가 올바르지 않습니다." )
} else if err != nil {
return nil , fmt . Errorf ( "로그인 중 오류가 발생했습니다: %v" , err )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
if u . Status == "pending" {
return nil , fmt . Errorf ( "가입 승인 대기 중입니다. 관리자의 승인을 기다려 주세요." )
} else if u . Status == "rejected" {
return nil , fmt . Errorf ( "가입 신청이 거절되었습니다. 관리자에게 문의하세요." )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
return & u , nil
}
// Register creates a new registration application
func ( s * QuizService ) Register ( username , password , nickname , phone , apiKey string ) error {
username = strings . TrimSpace ( username )
password = strings . TrimSpace ( password )
nickname = strings . TrimSpace ( nickname )
phone = strings . TrimSpace ( phone )
apiKey = strings . TrimSpace ( apiKey )
if username == "" || password == "" || nickname == "" || phone == "" {
return fmt . Errorf ( "모든 항목을 입력해 주세요." )
}
var count int
err := s . db . QueryRow ( "SELECT COUNT(*) FROM Users WHERE username = ?" , username ). Scan ( & count )
if err == nil && count > 0 {
return fmt . Errorf ( "이미 존재하는 아이디입니다." )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
_ , err = s . db . Exec ( `
INSERT INTO Users (username, password, nickname, phone, api_key, status)
VALUES (?, ?, ?, ?, ?, 'pending')
` , username , password , nickname , phone , apiKey )
return err
}
// ChangePassword updates user password
func ( s * QuizService ) ChangePassword ( username , oldPassword , newPassword string ) error {
username = strings . TrimSpace ( username )
oldPassword = strings . TrimSpace ( oldPassword )
newPassword = strings . TrimSpace ( newPassword )
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
if oldPassword == "" || newPassword == "" {
return fmt . Errorf ( "비밀번호를 입력해 주세요." )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
var count int
err := s . db . QueryRow ( "SELECT COUNT(*) FROM Users WHERE username = ? AND password = ?" , username , oldPassword ). Scan ( & count )
if err != nil || count == 0 {
return fmt . Errorf ( "현재 비밀번호가 일치하지 않습니다." )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
_ , err = s . db . Exec ( "UPDATE Users SET password = ? WHERE username = ?" , newPassword , username )
return err
}
// GetPendingRegistrations retrieves list of pending sign-ups (admin only)
func ( s * QuizService ) GetPendingRegistrations () ([] User , error ) {
rows , err := s . db . Query ( `
SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime')
FROM Users
WHERE status = 'pending'
ORDER BY created_at ASC
` )
if err != nil {
return nil , err
}
defer rows . Close ()
var list [] User
for rows . Next () {
var u User
err := rows . Scan ( & u . ID , & u . Username , & u . Password , & u . Nickname , & u . Phone , & u . Status , & u . ApiKey , & u . CreatedAt )
if err == nil {
list = append ( list , u )
}
}
return list , nil
}
// ApproveRegistration approves a registration request
func ( s * QuizService ) ApproveRegistration ( username string ) error {
// 1. Get user's API Key
var apiKey string
err := s . db . QueryRow ( "SELECT COALESCE(api_key, '') FROM Users WHERE username = ?" , username ). Scan ( & apiKey )
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return err
}
// 2. If API Key exists, add it to system GeminiKeys table
apiKey = strings . TrimSpace ( apiKey )
if apiKey != "" {
_ , _ = s . db . Exec ( "INSERT OR IGNORE INTO GeminiKeys (key) VALUES (?)" , apiKey )
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
// 3. Update status to approved
_ , err = s . db . Exec ( "UPDATE Users SET status = 'approved' WHERE username = ?" , username )
return err
}
// RejectRegistration rejects a registration request
func ( s * QuizService ) RejectRegistration ( username string ) error {
_ , err := s . db . Exec ( "UPDATE Users SET status = 'rejected' WHERE username = ?" , username )
return err
}
// GetUsers returns list of all users (admin only)
func ( s * QuizService ) GetUsers () ([] User , error ) {
rows , err := s . db . Query ( `
SELECT id, username, password, nickname, phone, status, COALESCE(api_key, '') as api_key, strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime')
FROM Users
ORDER BY username ASC
2026-06-25 07:43:27 +09:00
` )
if err != nil {
return nil , err
}
2026-06-28 23:48:13 +00:00
defer rows . Close ()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
var list [] User
for rows . Next () {
var u User
err := rows . Scan ( & u . ID , & u . Username , & u . Password , & u . Nickname , & u . Phone , & u . Status , & u . ApiKey , & u . CreatedAt )
if err == nil {
list = append ( list , u )
}
}
return list , nil
}
// GetUserAllowedCategories returns allowed category names for a specific user
func ( s * QuizService ) GetUserAllowedCategories ( username string ) ([] string , error ) {
rows , err := s . db . Query ( "SELECT category FROM UserCategories WHERE username = ?" , username )
2026-06-25 07:43:27 +09:00
if err != nil {
return nil , err
}
2026-06-28 23:48:13 +00:00
defer rows . Close ()
var list [] string
for rows . Next () {
var cat string
if err := rows . Scan ( & cat ); err == nil {
list = append ( list , cat )
}
}
return list , nil
}
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
// SetUserAllowedCategories replaces user's allowed categories
func ( s * QuizService ) SetUserAllowedCategories ( username string , categories [] string ) error {
tx , err := s . db . Begin ()
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return err
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
defer tx . Rollback ()
2026-06-25 07:43:27 +09:00
2026-06-28 23:48:13 +00:00
_ , err = tx . Exec ( "DELETE FROM UserCategories WHERE username = ?" , username )
2026-06-25 07:43:27 +09:00
if err != nil {
2026-06-28 23:48:13 +00:00
return err
2026-06-25 07:43:27 +09:00
}
2026-06-28 23:48:13 +00:00
for _ , cat := range categories {
if strings . TrimSpace ( cat ) == "" {
continue
}
_ , err = tx . Exec ( "INSERT INTO UserCategories (username, category) VALUES (?, ?)" , username , cat )
if err != nil {
return err
}
}
return tx . Commit ()
}
// GetCategoriesForUser retrieves categories based on user authorization
func ( s * QuizService ) GetCategoriesForUser ( username string ) [] CategoryInfo {
if username == "admin" {
return s . GetCategories ()
}
rows , err := s . db . Query ( `
SELECT q.category, COUNT(*) as count
FROM QuizQuestions q
JOIN UserCategories uc ON q.category = uc.category
WHERE uc.username = ? AND q.category IS NOT NULL AND q.category != ''
GROUP BY q.category
` , username )
if err != nil {
log . Printf ( "Query error in GetCategoriesForUser: %v" , err )
return nil
}
defer rows . Close ()
var list [] CategoryInfo
for rows . Next () {
var c CategoryInfo
if err := rows . Scan ( & c . Name , & c . Count ); err == nil {
list = append ( list , c )
}
}
return list
}
// UpdateUserApiKey updates a user's personal API key in Users table and seeds it to GeminiKeys
func ( s * QuizService ) UpdateUserApiKey ( username , apiKey string ) error {
username = strings . TrimSpace ( username )
apiKey = strings . TrimSpace ( apiKey )
_ , err := s . db . Exec ( "UPDATE Users SET api_key = ? WHERE username = ?" , apiKey , username )
if err != nil {
return err
}
if apiKey != "" {
_ , _ = s . db . Exec ( "INSERT OR IGNORE INTO GeminiKeys (key) VALUES (?)" , apiKey )
}
return nil
}
// DeleteUser deletes a user from Users table (admin only)
func ( s * QuizService ) DeleteUser ( username string ) error {
username = strings . TrimSpace ( username )
if username == "admin" {
return fmt . Errorf ( "관리자 계정은 삭제할 수 없습니다." )
}
tx , err := s . db . Begin ()
if err != nil {
return err
}
defer tx . Rollback ()
// Delete user relations
_ , _ = tx . Exec ( "DELETE FROM UserCategories WHERE username = ?" , username )
_ , _ = tx . Exec ( "DELETE FROM DescriptiveGrading WHERE username = ?" , username )
_ , _ = tx . Exec ( "DELETE FROM Bookmark WHERE username = ?" , username )
// Delete user
_ , err = tx . Exec ( "DELETE FROM Users WHERE username = ?" , username )
if err != nil {
return err
}
return tx . Commit ()
}
// UpdateUserInfoForce updates user details by admin force (admin only)
func ( s * QuizService ) UpdateUserInfoForce ( username , nickname , phone , password string ) error {
username = strings . TrimSpace ( username )
nickname = strings . TrimSpace ( nickname )
phone = strings . TrimSpace ( phone )
password = strings . TrimSpace ( password )
if username == "" || nickname == "" || phone == "" {
return fmt . Errorf ( "아이디, 닉네임, 연락처는 필수 입력 항목입니다." )
}
if password != "" {
_ , err := s . db . Exec ( "UPDATE Users SET nickname = ?, phone = ?, password = ? WHERE username = ?" , nickname , phone , password , username )
return err
} else {
_ , err := s . db . Exec ( "UPDATE Users SET nickname = ?, phone = ? WHERE username = ?" , nickname , phone , username )
return err
}
2026-06-25 07:43:27 +09:00
}