添加每日一练功能(未完成排行榜前端)
后端功能: - 添加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/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
|
||||
|
||||
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/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=
|
||||
|
||||
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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:"-"` // 该试卷的分享记录(作为被分享试卷)
|
||||
|
||||
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/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)
|
||||
// }
|
||||
// }()
|
||||
}
|
||||
|
||||
@ -94,3 +94,24 @@ export const getExam = (examId: number, showAnswer?: boolean) => {
|
||||
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
||||
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,
|
||||
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 = () => {
|
||||
<div className={styles.coverInfo}>
|
||||
<h3
|
||||
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}
|
||||
</h3>
|
||||
{exam.is_system && (
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
color="orange"
|
||||
className={styles.shareTag}
|
||||
>
|
||||
系统
|
||||
</Tag>
|
||||
)}
|
||||
{exam.is_shared && exam.shared_by && (
|
||||
<Tag
|
||||
icon={<ShareAltOutlined />}
|
||||
@ -324,8 +335,8 @@ const ExamManagement: React.FC = () => {
|
||||
>
|
||||
{exam.has_in_progress_exam ? '继续' : '考试'}
|
||||
</Button>,
|
||||
// 只有自己创建的试卷才能分享
|
||||
!exam.is_shared && (
|
||||
// 只有非系统且自己创建的试卷才能分享
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ShareAltOutlined />}
|
||||
@ -359,8 +370,8 @@ const ExamManagement: React.FC = () => {
|
||||
>
|
||||
打印
|
||||
</Button>,
|
||||
// 只有自己创建的试卷才能删除
|
||||
!exam.is_shared && (
|
||||
// 只有非系统且自己创建的试卷才能删除
|
||||
!exam.is_system && !exam.is_shared && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user