AnCao/internal/services/daily_exam_service.go
yanlongqi 960f557ca4 添加每日一练功能(未完成排行榜前端)
后端功能:
- 添加Exam模型is_system字段标识系统试卷
- 创建每日一练服务,使用PostgreSQL分布式锁
- 集成cron定时任务,每天凌晨1点自动生成试卷
- 自动分享给所有用户(批量插入)
- API权限控制:系统试卷禁止删除和再次分享
- 添加GetDailyExamRanking API返回排行榜

前端功能:
- 添加is_system类型定义
- 系统试卷显示"系统"标签
- 系统试卷隐藏删除和分享按钮
- 添加getDailyExamRanking API方法

技术亮点:
- 使用PostgreSQL Advisory Lock实现分布式锁
- 使用robfig/cron/v3调度定时任务
- 批量插入提升分享性能

待完成:首页添加每日一练排行榜组件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 00:26:51 +08:00

202 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}