重构试卷分享系统:修复类型不匹配问题
本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。 ## 主要更新 ### 后端架构重构 - 新增 ExamShare 关联表模型,替代原有的试卷副本方式 - 修复 User.ID (int64) 与 ExamShare 外键的类型不匹配问题 - 更新所有相关 API 以使用新的关联表架构 - 添加 IsAccessibleBy 和 GetAccessibleExams 权限检查方法 ### 类型系统修复 - ExamShare.SharedByID/SharedToID: uint → int64 - IsAccessibleBy/GetAccessibleExams 参数: uint → int64 - 修复所有涉及用户ID类型转换的代码 ### 新增工具 - cmd/migrate_exam_shares.go: 数据迁移脚本(旧数据迁移) - cmd/cleanup/main.go: 数据库表清理工具 ### API 更新 - ShareExam: 创建分享关联记录而非复制试卷 - GetExamList: 返回分享人信息和参与人数统计 - GetExamRecord: 支持查看共享试卷的其他用户记录 - GetExamRecordList: 按试卷ID查询所有用户的考试记录 ### 前端更新 - 更新 TypeScript 类型定义以匹配新的 API 响应 - 添加分享人标签显示("来自 XXX") - 考试记录列表显示所有参与者信息 ## 技术细节 - 使用 GORM 外键关联和 Preload 优化查询 - 添加唯一索引防止重复分享 - 事务保护数据一致性 - 软删除支持数据恢复 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2cc0c154dc
commit
62281b5047
38
cmd/cleanup/main.go
Normal file
38
cmd/cleanup/main.go
Normal file
@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ankao/pkg/config"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 直接连接数据库,不使用 InitDB
|
||||
cfg := config.GetDatabaseConfig()
|
||||
dsn := cfg.GetDSN()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("数据库连接失败:", err)
|
||||
}
|
||||
|
||||
log.Println("开始清理 exam_shares 表...")
|
||||
|
||||
// 删除 exam_shares 表(如果存在)
|
||||
if err := db.Exec("DROP TABLE IF EXISTS exam_shares CASCADE").Error; err != nil {
|
||||
log.Fatal("删除 exam_shares 表失败:", err)
|
||||
}
|
||||
|
||||
log.Println("✓ 已删除 exam_shares 表")
|
||||
log.Println("\n清理完成!现在可以重新运行主程序。")
|
||||
fmt.Println("\n执行步骤:")
|
||||
fmt.Println("1. go run main.go # 这会自动创建正确的表结构")
|
||||
fmt.Println("2. 如果有旧的分享数据需要迁移,运行:")
|
||||
fmt.Println(" go run cmd/migrate_exam_shares.go")
|
||||
}
|
||||
189
cmd/migrate_exam_shares.go
Normal file
189
cmd/migrate_exam_shares.go
Normal file
@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OldExam 旧的试卷模型(用于迁移)
|
||||
type OldExam struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
Title string `gorm:"type:varchar(200);default:''"`
|
||||
TotalScore int `gorm:"not null;default:100"`
|
||||
Duration int `gorm:"not null;default:60"`
|
||||
PassScore int `gorm:"not null;default:60"`
|
||||
QuestionIDs []byte `gorm:"type:json"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active'"`
|
||||
IsShared bool `gorm:"default:false"`
|
||||
SharedByID *uint `gorm:"index"`
|
||||
}
|
||||
|
||||
func (OldExam) TableName() string {
|
||||
return "exams"
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
if err := database.InitDB(); err != nil {
|
||||
log.Fatal("数据库初始化失败:", err)
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
log.Println("开始迁移试卷分享数据...")
|
||||
|
||||
// 1. 查找所有被分享的试卷副本
|
||||
var sharedExams []OldExam
|
||||
if err := db.Where("is_shared = ? AND shared_by_id IS NOT NULL", true).
|
||||
Find(&sharedExams).Error; err != nil {
|
||||
log.Fatal("查询分享试卷失败:", err)
|
||||
}
|
||||
|
||||
log.Printf("找到 %d 份分享试卷副本", len(sharedExams))
|
||||
|
||||
if len(sharedExams) == 0 {
|
||||
log.Println("没有需要迁移的数据,退出。")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 按 shared_by_id + question_ids 分组
|
||||
type ShareGroup struct {
|
||||
SharedByID uint
|
||||
QuestionIDs string
|
||||
Exams []OldExam
|
||||
}
|
||||
|
||||
groupMap := make(map[string]*ShareGroup)
|
||||
for _, exam := range sharedExams {
|
||||
if exam.SharedByID == nil {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%d_%s", *exam.SharedByID, string(exam.QuestionIDs))
|
||||
if group, exists := groupMap[key]; exists {
|
||||
group.Exams = append(group.Exams, exam)
|
||||
} else {
|
||||
groupMap[key] = &ShareGroup{
|
||||
SharedByID: *exam.SharedByID,
|
||||
QuestionIDs: string(exam.QuestionIDs),
|
||||
Exams: []OldExam{exam},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("分组后共 %d 组", len(groupMap))
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
// 3. 处理每个分组
|
||||
for _, group := range groupMap {
|
||||
// 查找原始试卷
|
||||
var originalExam OldExam
|
||||
if err := db.Where("user_id = ? AND question_ids = ? AND is_shared = ?",
|
||||
group.SharedByID, group.QuestionIDs, false).
|
||||
First(&originalExam).Error; err != nil {
|
||||
log.Printf("未找到原始试卷: shared_by_id=%d, 跳过该组", group.SharedByID)
|
||||
failCount += len(group.Exams)
|
||||
continue
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
|
||||
migrationSuccess := true
|
||||
|
||||
// 4. 为每个分享副本创建关联记录
|
||||
for _, sharedExam := range group.Exams {
|
||||
// 创建分享记录
|
||||
share := models.ExamShare{
|
||||
ExamID: originalExam.ID,
|
||||
SharedByID: group.SharedByID,
|
||||
SharedToID: sharedExam.UserID,
|
||||
SharedAt: sharedExam.CreatedAt,
|
||||
}
|
||||
|
||||
if err := tx.Create(&share).Error; err != nil {
|
||||
log.Printf("创建分享记录失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
// 5. 更新考试记录,将 exam_id 指向原始试卷
|
||||
if err := tx.Model(&models.ExamRecord{}).
|
||||
Where("exam_id = ?", sharedExam.ID).
|
||||
Update("exam_id", originalExam.ID).Error; err != nil {
|
||||
log.Printf("更新考试记录失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
// 6. 软删除分享副本
|
||||
if err := tx.Delete(&sharedExam).Error; err != nil {
|
||||
log.Printf("删除分享副本失败: %v", err)
|
||||
tx.Rollback()
|
||||
failCount += len(group.Exams)
|
||||
migrationSuccess = false
|
||||
break
|
||||
}
|
||||
|
||||
if migrationSuccess {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if migrationSuccess {
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
log.Printf("提交事务失败: %v", err)
|
||||
failCount += len(group.Exams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("迁移完成: 成功 %d, 失败 %d", successCount, failCount)
|
||||
|
||||
// 7. 验证迁移结果
|
||||
log.Println("\n开始验证迁移结果...")
|
||||
|
||||
// 统计 exam_shares 表记录数
|
||||
var shareCount int64
|
||||
db.Model(&models.ExamShare{}).Count(&shareCount)
|
||||
log.Printf("exam_shares 表记录数: %d", shareCount)
|
||||
|
||||
// 统计剩余的分享副本数(应该为0)
|
||||
var remainingSharedExams int64
|
||||
db.Model(&OldExam{}).Where("is_shared = ?", true).Count(&remainingSharedExams)
|
||||
log.Printf("剩余分享副本数: %d (应该为0)", remainingSharedExams)
|
||||
|
||||
// 检查是否有孤立的考试记录
|
||||
var orphanRecords int64
|
||||
db.Raw(`
|
||||
SELECT COUNT(*) FROM exam_records er
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM exams e WHERE e.id = er.exam_id AND e.deleted_at IS NULL
|
||||
)
|
||||
`).Scan(&orphanRecords)
|
||||
log.Printf("孤立的考试记录数: %d (应该为0)", orphanRecords)
|
||||
|
||||
if remainingSharedExams == 0 && orphanRecords == 0 {
|
||||
log.Println("\n✓ 迁移验证通过!")
|
||||
} else {
|
||||
log.Println("\n✗ 迁移验证失败,请检查数据!")
|
||||
}
|
||||
|
||||
log.Println("\n注意: 如果验证通过,可以考虑在未来某个时间点执行以下SQL移除旧字段:")
|
||||
log.Println(" ALTER TABLE exams DROP COLUMN is_shared;")
|
||||
log.Println(" ALTER TABLE exams DROP COLUMN shared_by_id;")
|
||||
}
|
||||
@ -38,6 +38,7 @@ func InitDB() error {
|
||||
&models.WrongQuestionHistory{}, // 错题历史表
|
||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||
&models.Exam{}, // 考试表(试卷)
|
||||
&models.ExamShare{}, // 试卷分享关联表
|
||||
&models.ExamRecord{}, // 考试记录表
|
||||
&models.ExamUserAnswer{}, // 用户答案表
|
||||
)
|
||||
|
||||
@ -152,20 +152,15 @@ func GetExamList(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询用户创建的试卷(包括被分享的试卷)
|
||||
var exams []models.Exam
|
||||
if err := db.Where("user_id = ? AND status = ?", userID, "active").
|
||||
Preload("SharedBy", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "username", "nickname")
|
||||
}).
|
||||
Order("created_at DESC").
|
||||
Find(&exams).Error; err != nil {
|
||||
// 获取用户可访问的试卷(拥有的 + 被分享的)
|
||||
exams, err := models.GetAccessibleExams(userID.(int64), db)
|
||||
if err != nil {
|
||||
log.Printf("查询试卷列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个试卷计算题目数量和获取考试记录统计
|
||||
// 为每个试卷添加统计信息
|
||||
type ExamWithStats struct {
|
||||
models.Exam
|
||||
QuestionCount int `json:"question_count"`
|
||||
@ -174,23 +169,50 @@ func GetExamList(c *gin.Context) {
|
||||
HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试
|
||||
InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID
|
||||
ParticipantCount int `json:"participant_count"` // 共享试卷的参与人数(所有用户)
|
||||
IsShared bool `json:"is_shared"` // 是否为分享的试卷
|
||||
SharedBy *struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
} `json:"shared_by,omitempty"`
|
||||
}
|
||||
|
||||
result := make([]ExamWithStats, 0, len(exams))
|
||||
for _, exam := range exams {
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil {
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stats := ExamWithStats{
|
||||
Exam: exam,
|
||||
QuestionCount: len(questionIDs),
|
||||
}
|
||||
|
||||
// 查询该试卷的考试记录统计(当前用户)
|
||||
// 检查是否为分享试卷
|
||||
if exam.UserID != uint(userID.(int64)) {
|
||||
stats.IsShared = true
|
||||
// 查询分享人信息
|
||||
var share models.ExamShare
|
||||
if err := db.Where("exam_id = ? AND shared_to_id = ?", exam.ID, userID.(int64)).
|
||||
Preload("SharedBy").First(&share).Error; err == nil && share.SharedBy != nil {
|
||||
stats.SharedBy = &struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
}{
|
||||
ID: share.SharedBy.ID,
|
||||
Username: share.SharedBy.Username,
|
||||
Nickname: share.SharedBy.Nickname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前用户的统计
|
||||
var count int64
|
||||
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
|
||||
stats.AttemptCount = int(count)
|
||||
|
||||
// 查询最高分(当前用户)
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
|
||||
Order("score DESC").
|
||||
@ -198,7 +220,6 @@ func GetExamList(c *gin.Context) {
|
||||
stats.BestScore = record.Score
|
||||
}
|
||||
|
||||
// 查询是否有进行中的考试(status为in_progress)
|
||||
var inProgressRecord models.ExamRecord
|
||||
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
|
||||
Order("created_at DESC").
|
||||
@ -207,44 +228,16 @@ func GetExamList(c *gin.Context) {
|
||||
stats.InProgressRecordID = inProgressRecord.ID
|
||||
}
|
||||
|
||||
// 计算共享试卷的参与人数
|
||||
// 获取所有相关试卷ID(包括原始试卷和所有分享副本)
|
||||
var relatedExamIDs []uint
|
||||
relatedExamIDs = append(relatedExamIDs, exam.ID)
|
||||
|
||||
// 如果这是被分享的试卷,找到原始试卷
|
||||
if exam.IsShared && exam.SharedByID != nil {
|
||||
var originalExam models.Exam
|
||||
if err := db.Where("user_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
*exam.SharedByID, exam.QuestionIDs).First(&originalExam).Error; err == nil {
|
||||
relatedExamIDs = append(relatedExamIDs, originalExam.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找所有基于该试卷的分享副本
|
||||
var sharedExams []models.Exam
|
||||
sharedByID := exam.UserID
|
||||
if exam.IsShared && exam.SharedByID != nil {
|
||||
sharedByID = *exam.SharedByID
|
||||
}
|
||||
if err := db.Where("shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
sharedByID, exam.QuestionIDs).Find(&sharedExams).Error; err == nil {
|
||||
for _, se := range sharedExams {
|
||||
relatedExamIDs = append(relatedExamIDs, se.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计所有相关试卷的已完成考试的不同用户数
|
||||
// 参与人数统计(简化:直接统计该试卷的不同用户)
|
||||
var participantCount int64
|
||||
db.Model(&models.ExamRecord{}).
|
||||
Where("exam_id IN ? AND status = ?", relatedExamIDs, "graded").
|
||||
Where("exam_id = ? AND status = ?", exam.ID, "graded").
|
||||
Distinct("user_id").
|
||||
Count(&participantCount)
|
||||
stats.ParticipantCount = int(participantCount)
|
||||
|
||||
result = append(result, stats)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@ -515,15 +508,32 @@ func GetExamRecord(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
// 查询考试记录(不限制用户,因为可能是查看共享试卷的其他用户记录)
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).
|
||||
if err := db.Where("id = ?", recordID).
|
||||
Preload("Exam").
|
||||
First(&record).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限:只有以下情况可以查看
|
||||
// 1. 记录属于当前用户
|
||||
// 2. 当前用户有权限访问该试卷
|
||||
if record.UserID != uint(userID.(int64)) {
|
||||
// 不是自己的记录,检查是否有权限访问该试卷
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if !exam.IsAccessibleBy(userID.(int64), db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限查看此考试记录"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 从 exam_user_answers 表读取所有答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||
@ -639,10 +649,9 @@ func GetExamRecordList(c *gin.Context) {
|
||||
}
|
||||
|
||||
examIDStr := c.Query("exam_id")
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 如果指定了试卷ID,需要判断是否为共享试卷
|
||||
// 如果指定了试卷ID
|
||||
if examIDStr != "" {
|
||||
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||
if err != nil {
|
||||
@ -650,42 +659,22 @@ func GetExamRecordList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 查询试卷信息
|
||||
// 查询试卷并检查权限
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
||||
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有相关试卷ID(包括原始试卷和所有分享副本)
|
||||
var relatedExamIDs []uint
|
||||
relatedExamIDs = append(relatedExamIDs, uint(examID))
|
||||
|
||||
// 如果这是被分享的试卷,找到原始试卷
|
||||
if exam.IsShared && exam.SharedByID != nil {
|
||||
var originalExam models.Exam
|
||||
if err := db.Where("user_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
*exam.SharedByID, exam.QuestionIDs).First(&originalExam).Error; err == nil {
|
||||
relatedExamIDs = append(relatedExamIDs, originalExam.ID)
|
||||
}
|
||||
// 检查用户是否有权限访问该试卷
|
||||
if !exam.IsAccessibleBy(userID.(int64), db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限访问"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找所有基于该试卷的分享副本
|
||||
var sharedExams []models.Exam
|
||||
sharedByID := exam.UserID
|
||||
if exam.IsShared && exam.SharedByID != nil {
|
||||
sharedByID = *exam.SharedByID
|
||||
}
|
||||
if err := db.Where("shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
sharedByID, exam.QuestionIDs).Find(&sharedExams).Error; err == nil {
|
||||
for _, se := range sharedExams {
|
||||
relatedExamIDs = append(relatedExamIDs, se.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有相关试卷的考试记录(包含用户信息)
|
||||
// 查询该试卷的所有已完成考试记录(包含用户信息)
|
||||
var records []models.ExamRecord
|
||||
if err := db.Where("exam_id IN ? AND status = ?", relatedExamIDs, "graded").
|
||||
if err := db.Where("exam_id = ? AND status = ?", examID, "graded").
|
||||
Preload("Exam").
|
||||
Preload("User", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "username", "nickname", "avatar")
|
||||
@ -704,7 +693,7 @@ func GetExamRecordList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有指定试卷ID,只返回当前用户的记录
|
||||
// 没有指定试卷ID,返回当前用户的所有记录
|
||||
var records []models.ExamRecord
|
||||
if err := db.Where("user_id = ?", userID).
|
||||
Preload("Exam").
|
||||
@ -955,9 +944,9 @@ func ShareExam(c *gin.Context) {
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询原始试卷
|
||||
var originalExam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).Error; err != nil {
|
||||
// 查询原始试卷,确认用户有权限分享
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
|
||||
return
|
||||
}
|
||||
@ -970,19 +959,19 @@ func ShareExam(c *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
sharedByID := uint(userID.(int64))
|
||||
sharedByID := userID.(int64)
|
||||
sharedCount := 0
|
||||
now := time.Now()
|
||||
|
||||
// 为每个用户创建分享副本
|
||||
// 为每个用户创建分享记录
|
||||
for _, targetUserID := range req.UserIDs {
|
||||
// 检查是否已经分享给该用户
|
||||
var existingExam models.Exam
|
||||
err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL",
|
||||
targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error
|
||||
// 检查是否已分享
|
||||
var existingShare models.ExamShare
|
||||
err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)).
|
||||
First(&existingShare).Error
|
||||
|
||||
if err == nil {
|
||||
// 已存在,跳过
|
||||
continue
|
||||
continue // 已存在,跳过
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
tx.Rollback()
|
||||
log.Printf("检查分享记录失败: %v", err)
|
||||
@ -990,26 +979,20 @@ func ShareExam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建试卷副本
|
||||
sharedExam := models.Exam{
|
||||
UserID: targetUserID,
|
||||
Title: originalExam.Title,
|
||||
TotalScore: originalExam.TotalScore,
|
||||
Duration: originalExam.Duration,
|
||||
PassScore: originalExam.PassScore,
|
||||
QuestionIDs: originalExam.QuestionIDs,
|
||||
Status: "active",
|
||||
IsShared: true,
|
||||
SharedByID: &sharedByID,
|
||||
// 创建分享记录
|
||||
share := models.ExamShare{
|
||||
ExamID: uint(examID),
|
||||
SharedByID: sharedByID,
|
||||
SharedToID: int64(targetUserID),
|
||||
SharedAt: now,
|
||||
}
|
||||
|
||||
if err := tx.Create(&sharedExam).Error; err != nil {
|
||||
if err := tx.Create(&share).Error; err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("创建分享试卷失败: %v", err)
|
||||
log.Printf("创建分享记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
|
||||
return
|
||||
}
|
||||
|
||||
sharedCount++
|
||||
}
|
||||
|
||||
|
||||
@ -20,11 +20,37 @@ 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
|
||||
IsShared bool `gorm:"default:false" json:"is_shared"` // 是否为分享的试卷
|
||||
SharedByID *uint `gorm:"index" json:"shared_by_id,omitempty"` // 分享人ID (如果是分享的试卷)
|
||||
|
||||
// 关联
|
||||
SharedBy *User `gorm:"foreignKey:SharedByID" json:"shared_by,omitempty"` // 分享人信息
|
||||
// 关联关系
|
||||
Shares []ExamShare `gorm:"foreignKey:ExamID" json:"-"` // 该试卷的分享记录(作为被分享试卷)
|
||||
SharedToMe []ExamShare `gorm:"foreignKey:SharedToID" json:"-"` // 分享给我的记录(作为接收者)
|
||||
}
|
||||
|
||||
// IsAccessibleBy 检查用户是否有权限访问试卷
|
||||
func (e *Exam) IsAccessibleBy(userID int64, db *gorm.DB) bool {
|
||||
// 用户是试卷创建者
|
||||
if int64(e.UserID) == userID {
|
||||
return true
|
||||
}
|
||||
// 检查是否被分享给该用户
|
||||
var count int64
|
||||
db.Model(&ExamShare{}).Where("exam_id = ? AND shared_to_id = ?", e.ID, userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetAccessibleExams 获取用户可访问的所有试卷(拥有的+被分享的)
|
||||
func GetAccessibleExams(userID int64, db *gorm.DB) ([]Exam, error) {
|
||||
var exams []Exam
|
||||
|
||||
// 子查询:被分享的试卷ID
|
||||
subQuery := db.Model(&ExamShare{}).Select("exam_id").Where("shared_to_id = ?", userID)
|
||||
|
||||
// 查询:用户拥有的 OR 被分享的试卷
|
||||
err := db.Where("user_id = ? OR id IN (?)", uint(userID), subQuery).
|
||||
Order("created_at DESC").
|
||||
Find(&exams).Error
|
||||
|
||||
return exams, err
|
||||
}
|
||||
|
||||
// ExamRecord 考试记录
|
||||
|
||||
30
internal/models/exam_share.go
Normal file
30
internal/models/exam_share.go
Normal file
@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ExamShare 试卷分享关联表
|
||||
type ExamShare struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
ExamID uint `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"exam_id"`
|
||||
SharedByID int64 `gorm:"not null;index" json:"shared_by_id"`
|
||||
SharedToID int64 `gorm:"not null;uniqueIndex:uk_exam_shared_to" json:"shared_to_id"`
|
||||
SharedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"shared_at"`
|
||||
|
||||
// 关联关系
|
||||
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
||||
SharedBy *User `gorm:"foreignKey:SharedByID;references:ID" json:"shared_by,omitempty"`
|
||||
SharedTo *User `gorm:"foreignKey:SharedToID;references:ID" json:"shared_to,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ExamShare) TableName() string {
|
||||
return "exam_shares"
|
||||
}
|
||||
@ -465,7 +465,13 @@ const ExamManagement: React.FC = () => {
|
||||
{record.status === 'in_progress' ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
|
||||
<span style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: record.is_passed ? '#52c41a' : '#ff4d4f',
|
||||
lineHeight: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}>
|
||||
{record.score} 分
|
||||
</span>
|
||||
)}
|
||||
@ -504,18 +510,6 @@ const ExamManagement: React.FC = () => {
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
{record.status === 'graded' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => {
|
||||
setRecordsDrawerVisible(false)
|
||||
navigate(`/exam/result/${record.id}`)
|
||||
}}
|
||||
>
|
||||
查看答案
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -105,9 +105,9 @@ export type ExamListResponse = Array<{
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
participant_count: number // 共享试卷的参与人数
|
||||
is_shared: boolean // 是否为分享的试卷
|
||||
shared_by_id?: number // 分享人ID
|
||||
shared_by?: { // 分享人信息
|
||||
is_shared: boolean // 是否为被分享的试卷(原:是否为分享副本)
|
||||
shared_by_id?: number // 分享人ID(已废弃,使用 shared_by)
|
||||
shared_by?: { // 分享人信息(原:副本创建者)
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user