diff --git a/go.mod b/go.mod index fbedccf..4375675 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect diff --git a/go.sum b/go.sum index f65f269..38df200 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index faea29d..d005d12 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -734,6 +734,15 @@ func DeleteExam(c *gin.Context) { return } + // 检查是否为系统试卷 + if exam.IsSystem { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "系统试卷不允许删除", + }) + return + } + // 软删除 if err := db.Delete(&exam).Error; err != nil { log.Printf("删除试卷失败: %v", err) @@ -951,6 +960,15 @@ func ShareExam(c *gin.Context) { return } + // 检查是否为系统试卷 + if exam.IsSystem { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "系统试卷不允许再次分享", + }) + return + } + // 开始事务 tx := db.Begin() defer func() { @@ -1011,3 +1029,84 @@ func ShareExam(c *gin.Context) { }, }) } + +// GetDailyExamRanking 获取每日一练排行榜 +func GetDailyExamRanking(c *gin.Context) { + _, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + db := database.GetDB() + + // 获取今天的每日一练试卷 + today := time.Now() + title := fmt.Sprintf("%d年%02d月%02d日的每日一练", + today.Year(), today.Month(), today.Day()) + + var exam models.Exam + err := db.Where("is_system = ? AND title = ?", true, title).First(&exam).Error + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "今日每日一练尚未生成", + }) + return + } + + // 查询该试卷的成绩排行榜(取最高分) + type RankingItem struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Score float64 `json:"score"` + TimeSpent int `json:"time_spent"` // 答题用时 + Rank int `json:"rank"` + } + + var rankings []RankingItem + + // SQL查询:每个用户的最高分 + err = db.Table("exam_records"). + Select(` + exam_records.user_id, + users.username, + users.nickname, + users.avatar, + MAX(exam_records.score) as score, + MIN(exam_records.time_spent) as time_spent + `). + Joins("JOIN users ON users.id = exam_records.user_id"). + Where("exam_records.exam_id = ? AND exam_records.status = ?", exam.ID, "graded"). + Group("exam_records.user_id, users.username, users.nickname, users.avatar"). + Order("score DESC, time_spent ASC"). + Limit(50). // 显示前50名 + Find(&rankings).Error + + if err != nil { + log.Printf("查询排行榜失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "查询排行榜失败", + }) + return + } + + // 添加排名 + for i := range rankings { + rankings[i].Rank = i + 1 + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "exam_id": exam.ID, + "exam_title": exam.Title, + "rankings": rankings, + "total": len(rankings), + }, + }) +} + diff --git a/internal/models/exam.go b/internal/models/exam.go index 0c5b938..6333ab2 100644 --- a/internal/models/exam.go +++ b/internal/models/exam.go @@ -20,6 +20,7 @@ type Exam struct { PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数 QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组) Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived + IsSystem bool `gorm:"default:false;index" json:"is_system"` // 是否为系统试卷 // 关联关系 Shares []ExamShare `gorm:"foreignKey:ExamID" json:"-"` // 该试卷的分享记录(作为被分享试卷) diff --git a/internal/services/daily_exam_service.go b/internal/services/daily_exam_service.go new file mode 100644 index 0000000..6bb3a00 --- /dev/null +++ b/internal/services/daily_exam_service.go @@ -0,0 +1,201 @@ +package services + +import ( + "ankao/internal/database" + "ankao/internal/models" + "encoding/json" + "fmt" + "log" + "math/rand" + "time" + + "gorm.io/gorm" +) + +type DailyExamService struct { + db *gorm.DB +} + +func NewDailyExamService() *DailyExamService { + return &DailyExamService{ + db: database.GetDB(), + } +} + +// GenerateDailyExam 生成每日一练试卷 +func (s *DailyExamService) GenerateDailyExam() error { + // 1. 获取分布式锁(使用日期作为锁ID) + today := time.Now().Format("20060102") + lockID := hashString(today) // 使用日期哈希作为锁ID + + var locked bool + if err := s.db.Raw("SELECT pg_try_advisory_lock(?)", lockID).Scan(&locked).Error; err != nil { + return fmt.Errorf("获取锁失败: %w", err) + } + + if !locked { + log.Println("其他实例正在生成每日一练,跳过") + return nil + } + defer s.db.Exec("SELECT pg_advisory_unlock(?)", lockID) + + // 2. 检查今天是否已生成 + todayStart := time.Now().Truncate(24 * time.Hour) + todayEnd := todayStart.Add(24 * time.Hour) + + var count int64 + s.db.Model(&models.Exam{}). + Where("is_system = ? AND created_at >= ? AND created_at < ?", + true, todayStart, todayEnd). + Count(&count) + + if count > 0 { + log.Println("今日每日一练已生成,跳过") + return nil + } + + // 3. 生成试卷标题 + now := time.Now() + title := fmt.Sprintf("%d年%02d月%02d日的每日一练", + now.Year(), now.Month(), now.Day()) + + // 4. 随机选择题目(使用与创建试卷相同的逻辑) + questionIDs, totalScore, err := s.selectQuestions() + if err != nil { + return fmt.Errorf("选择题目失败: %w", err) + } + + questionIDsJSON, _ := json.Marshal(questionIDs) + + // 5. 创建试卷(使用第一个用户作为创建者,但标记为系统试卷) + // 获取第一个用户ID + var firstUser models.User + if err := s.db.Order("id ASC").First(&firstUser).Error; err != nil { + return fmt.Errorf("查询用户失败: %w", err) + } + + exam := models.Exam{ + UserID: firstUser.ID, // 使用第一个用户作为创建者 + Title: title, + TotalScore: int(totalScore), + Duration: 60, + PassScore: 80, + QuestionIDs: questionIDsJSON, + Status: "active", + IsSystem: true, // 标记为系统试卷 + } + + if err := s.db.Create(&exam).Error; err != nil { + return fmt.Errorf("创建试卷失败: %w", err) + } + + log.Printf("成功创建每日一练试卷: ID=%d, Title=%s", exam.ID, exam.Title) + + // 6. 分享给所有用户 + if err := s.shareToAllUsers(exam.ID, firstUser.ID); err != nil { + log.Printf("分享试卷失败: %v", err) + // 不返回错误,因为试卷已创建成功 + } + + return nil +} + +// selectQuestions 选择题目(复用现有逻辑) +func (s *DailyExamService) selectQuestions() ([]int64, float64, error) { + questionTypes := []struct { + Type string + Count int + Score float64 + }{ + {Type: "fill-in-blank", Count: 20, Score: 2.0}, // 40分 + {Type: "true-false", Count: 10, Score: 1.0}, // 10分 + {Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分 + {Type: "multiple-selection", Count: 10, Score: 2.0}, // 20分 + {Type: "short-answer", Count: 1, Score: 10.0}, // 10分 + {Type: "ordinary-essay", Count: 1, Score: 10.0}, // 10分(普通涉密人员论述题) + {Type: "management-essay", Count: 1, Score: 10.0}, // 10分(保密管理人员论述题) + } + + var allQuestionIDs []int64 + var totalScore float64 + + for _, qt := range questionTypes { + var questions []models.PracticeQuestion + if err := s.db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil { + return nil, 0, err + } + + // 检查题目数量是否足够 + if len(questions) < qt.Count { + return nil, 0, fmt.Errorf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", + qt.Type, qt.Count, len(questions)) + } + + // 随机抽取 (Fisher-Yates 洗牌算法) + rand.Seed(time.Now().UnixNano()) + for i := len(questions) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + questions[i], questions[j] = questions[j], questions[i] + } + selectedQuestions := questions[:qt.Count] + + for _, q := range selectedQuestions { + allQuestionIDs = append(allQuestionIDs, q.ID) + } + + totalScore += float64(qt.Count) * qt.Score + } + + // 随机打乱题目ID顺序 + rand.Seed(time.Now().UnixNano()) + for i := len(allQuestionIDs) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i] + } + + return allQuestionIDs, totalScore, nil +} + +// shareToAllUsers 分享给所有用户 +func (s *DailyExamService) shareToAllUsers(examID uint, sharedByID uint) error { + // 查询所有用户(排除创建者) + var users []models.User + if err := s.db.Where("id != ?", sharedByID).Find(&users).Error; err != nil { + return err + } + + // 批量创建分享记录 + now := time.Now() + shares := make([]models.ExamShare, 0, len(users)) + for _, user := range users { + shares = append(shares, models.ExamShare{ + ExamID: examID, + SharedByID: int64(sharedByID), + SharedToID: int64(user.ID), + SharedAt: now, + }) + } + + if len(shares) > 0 { + // 批量插入 + if err := s.db.Create(&shares).Error; err != nil { + return err + } + log.Printf("成功分享给 %d 个用户", len(shares)) + } + + return nil +} + +// hashString 计算字符串哈希值(用于生成锁ID) +func hashString(s string) int64 { + var hash int64 + for _, c := range s { + hash = hash*31 + int64(c) + } + // 确保返回正数 + if hash < 0 { + hash = -hash + } + return hash +} diff --git a/main.go b/main.go index 4b29415..5ac4e57 100644 --- a/main.go +++ b/main.go @@ -4,11 +4,13 @@ import ( "ankao/internal/database" "ankao/internal/handlers" "ankao/internal/middleware" + "ankao/internal/services" "log" "net/http" "time" "github.com/gin-gonic/gin" + "github.com/robfig/cron/v3" ) func main() { @@ -81,6 +83,7 @@ func main() { auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案 auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表 auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷 + auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜 } // 题库管理API(需要管理员权限) @@ -114,6 +117,9 @@ func main() { MaxHeaderBytes: 1 << 20, // 最大请求头:1MB } + // 启动定时任务 + startCronJobs() + log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port) // 启动服务器 @@ -121,3 +127,38 @@ func main() { panic("服务器启动失败: " + err.Error()) } } + +// startCronJobs 启动定时任务 +func startCronJobs() { + // 创建定时任务调度器(使用中国时区 UTC+8) + c := cron.New(cron.WithLocation(time.FixedZone("CST", 8*3600))) + + // 每天凌晨1点执行 + _, err := c.AddFunc("0 1 * * *", func() { + log.Println("开始生成每日一练...") + service := services.NewDailyExamService() + if err := service.GenerateDailyExam(); err != nil { + log.Printf("生成每日一练失败: %v", err) + } else { + log.Println("每日一练生成成功") + } + }) + + if err != nil { + log.Printf("添加定时任务失败: %v", err) + return + } + + // 启动调度器 + c.Start() + log.Println("定时任务已启动:每天凌晨1点生成每日一练") + + // 可选:应用启动时立即生成一次(用于测试) + // go func() { + // log.Println("应用启动,立即生成一次每日一练...") + // service := services.NewDailyExamService() + // if err := service.GenerateDailyExam(); err != nil { + // log.Printf("生成每日一练失败: %v", err) + // } + // }() +} diff --git a/web/src/api/exam.ts b/web/src/api/exam.ts index 3155df3..887a5cf 100644 --- a/web/src/api/exam.ts +++ b/web/src/api/exam.ts @@ -94,3 +94,24 @@ export const getExam = (examId: number, showAnswer?: boolean) => { export const submitExam = (examId: number, data: SubmitExamRequest) => { return request.post>(`/exam/${examId}/submit`, data) } + +// 获取每日一练排行榜 +export const getDailyExamRanking = () => { + return request<{ + success: boolean + data: { + exam_id: number + exam_title: string + rankings: Array<{ + user_id: number + username: string + nickname: string + avatar: string + score: number + time_spent: number + rank: number + }> + total: number + } + }>('/daily-exam/ranking') +} diff --git a/web/src/pages/ExamManagement.tsx b/web/src/pages/ExamManagement.tsx index 4e49f67..f36064d 100644 --- a/web/src/pages/ExamManagement.tsx +++ b/web/src/pages/ExamManagement.tsx @@ -31,7 +31,8 @@ import { ArrowLeftOutlined, ShareAltOutlined, UserOutlined, - TeamOutlined + TeamOutlined, + CrownOutlined } from '@ant-design/icons' import * as examApi from '../api/exam' import styles from './ExamManagement.module.less' @@ -50,6 +51,7 @@ interface ExamListItem { participant_count: number // 共享试卷的参与人数 created_at: string is_shared?: boolean + is_system?: boolean // 是否为系统试卷 shared_by?: { id: number username: string @@ -299,10 +301,19 @@ const ExamManagement: React.FC = () => {

{exam.title}

+ {exam.is_system && ( + } + color="orange" + className={styles.shareTag} + > + 系统 + + )} {exam.is_shared && exam.shared_by && ( } @@ -324,8 +335,8 @@ const ExamManagement: React.FC = () => { > {exam.has_in_progress_exam ? '继续' : '考试'} , - // 只有自己创建的试卷才能分享 - !exam.is_shared && ( + // 只有非系统且自己创建的试卷才能分享 + !exam.is_system && !exam.is_shared && ( , - // 只有自己创建的试卷才能删除 - !exam.is_shared && ( + // 只有非系统且自己创建的试卷才能删除 + !exam.is_system && !exam.is_shared && (