添加每日一练功能(未完成排行榜前端)
后端功能: - 添加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>
This commit is contained in:
parent
a77242c844
commit
960f557ca4
1
go.mod
1
go.mod
@ -41,6 +41,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // 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/sashabaranov/go-openai v1.41.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
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 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||||
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
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=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
@ -734,6 +734,15 @@ func DeleteExam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统试卷
|
||||||
|
if exam.IsSystem {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "系统试卷不允许删除",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 软删除
|
// 软删除
|
||||||
if err := db.Delete(&exam).Error; err != nil {
|
if err := db.Delete(&exam).Error; err != nil {
|
||||||
log.Printf("删除试卷失败: %v", err)
|
log.Printf("删除试卷失败: %v", err)
|
||||||
@ -951,6 +960,15 @@ func ShareExam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统试卷
|
||||||
|
if exam.IsSystem {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "系统试卷不允许再次分享",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 开始事务
|
// 开始事务
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
defer func() {
|
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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type Exam struct {
|
|||||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
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
|
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:"-"` // 该试卷的分享记录(作为被分享试卷)
|
Shares []ExamShare `gorm:"foreignKey:ExamID" json:"-"` // 该试卷的分享记录(作为被分享试卷)
|
||||||
|
|||||||
201
internal/services/daily_exam_service.go
Normal file
201
internal/services/daily_exam_service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
41
main.go
41
main.go
@ -4,11 +4,13 @@ import (
|
|||||||
"ankao/internal/database"
|
"ankao/internal/database"
|
||||||
"ankao/internal/handlers"
|
"ankao/internal/handlers"
|
||||||
"ankao/internal/middleware"
|
"ankao/internal/middleware"
|
||||||
|
"ankao/internal/services"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -81,6 +83,7 @@ func main() {
|
|||||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||||
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
|
auth.GET("/users/shareable", handlers.GetShareableUsers) // 获取可分享的用户列表
|
||||||
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
|
auth.POST("/exams/:id/share", handlers.ShareExam) // 分享试卷
|
||||||
|
auth.GET("/daily-exam/ranking", handlers.GetDailyExamRanking) // 获取每日一练排行榜
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题库管理API(需要管理员权限)
|
// 题库管理API(需要管理员权限)
|
||||||
@ -114,6 +117,9 @@ func main() {
|
|||||||
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
MaxHeaderBytes: 1 << 20, // 最大请求头:1MB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动定时任务
|
||||||
|
startCronJobs()
|
||||||
|
|
||||||
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
log.Printf("服务器启动在端口 %s,超时配置:读/写 5分钟", port)
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
@ -121,3 +127,38 @@ func main() {
|
|||||||
panic("服务器启动失败: " + err.Error())
|
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)
|
||||||
|
// }
|
||||||
|
// }()
|
||||||
|
}
|
||||||
|
|||||||
@ -94,3 +94,24 @@ export const getExam = (examId: number, showAnswer?: boolean) => {
|
|||||||
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
||||||
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
|
return request.post<ApiResponse<SubmitExamResponse>>(`/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')
|
||||||
|
}
|
||||||
|
|||||||
@ -31,7 +31,8 @@ import {
|
|||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined
|
TeamOutlined,
|
||||||
|
CrownOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as examApi from '../api/exam'
|
import * as examApi from '../api/exam'
|
||||||
import styles from './ExamManagement.module.less'
|
import styles from './ExamManagement.module.less'
|
||||||
@ -50,6 +51,7 @@ interface ExamListItem {
|
|||||||
participant_count: number // 共享试卷的参与人数
|
participant_count: number // 共享试卷的参与人数
|
||||||
created_at: string
|
created_at: string
|
||||||
is_shared?: boolean
|
is_shared?: boolean
|
||||||
|
is_system?: boolean // 是否为系统试卷
|
||||||
shared_by?: {
|
shared_by?: {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
@ -299,10 +301,19 @@ const ExamManagement: React.FC = () => {
|
|||||||
<div className={styles.coverInfo}>
|
<div className={styles.coverInfo}>
|
||||||
<h3
|
<h3
|
||||||
className={styles.examTitle}
|
className={styles.examTitle}
|
||||||
style={{ marginBottom: exam.is_shared && exam.shared_by ? '12px' : '0' }}
|
style={{ marginBottom: (exam.is_system || (exam.is_shared && exam.shared_by)) ? '12px' : '0' }}
|
||||||
>
|
>
|
||||||
{exam.title}
|
{exam.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
{exam.is_system && (
|
||||||
|
<Tag
|
||||||
|
icon={<CrownOutlined />}
|
||||||
|
color="orange"
|
||||||
|
className={styles.shareTag}
|
||||||
|
>
|
||||||
|
系统
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
{exam.is_shared && exam.shared_by && (
|
{exam.is_shared && exam.shared_by && (
|
||||||
<Tag
|
<Tag
|
||||||
icon={<ShareAltOutlined />}
|
icon={<ShareAltOutlined />}
|
||||||
@ -324,8 +335,8 @@ const ExamManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||||
</Button>,
|
</Button>,
|
||||||
// 只有自己创建的试卷才能分享
|
// 只有非系统且自己创建的试卷才能分享
|
||||||
!exam.is_shared && (
|
!exam.is_system && !exam.is_shared && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ShareAltOutlined />}
|
icon={<ShareAltOutlined />}
|
||||||
@ -359,8 +370,8 @@ const ExamManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
打印
|
打印
|
||||||
</Button>,
|
</Button>,
|
||||||
// 只有自己创建的试卷才能删除
|
// 只有非系统且自己创建的试卷才能删除
|
||||||
!exam.is_shared && (
|
!exam.is_system && !exam.is_shared && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user