添加每日一练功能(未完成排行榜前端)

后端功能:
- 添加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:
燕陇琪 2025-12-02 00:26:51 +08:00
parent a77242c844
commit 960f557ca4
8 changed files with 383 additions and 6 deletions

1
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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),
},
})
}

View File

@ -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:"-"` // 该试卷的分享记录(作为被分享试卷)

View 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
View File

@ -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)
// }
// }()
}

View File

@ -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')
}

View File

@ -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