From 62281b5047eaea4f03f5e30c20201093e6305d1b Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Mon, 1 Dec 2025 22:32:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AF=95=E5=8D=B7=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E7=B3=BB=E7=BB=9F=EF=BC=9A=E4=BF=AE=E5=A4=8D=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8C=B9=E9=85=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。 ## 主要更新 ### 后端架构重构 - 新增 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 --- cmd/cleanup/main.go | 38 +++++ cmd/migrate_exam_shares.go | 189 +++++++++++++++++++++++ internal/database/database.go | 1 + internal/handlers/exam_handler.go | 247 ++++++++++++++---------------- internal/models/exam.go | 34 +++- internal/models/exam_share.go | 30 ++++ web/src/pages/ExamManagement.tsx | 20 +-- web/src/types/exam.ts | 6 +- 8 files changed, 413 insertions(+), 152 deletions(-) create mode 100644 cmd/cleanup/main.go create mode 100644 cmd/migrate_exam_shares.go create mode 100644 internal/models/exam_share.go diff --git a/cmd/cleanup/main.go b/cmd/cleanup/main.go new file mode 100644 index 0000000..df0593f --- /dev/null +++ b/cmd/cleanup/main.go @@ -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") +} diff --git a/cmd/migrate_exam_shares.go b/cmd/migrate_exam_shares.go new file mode 100644 index 0000000..6e13c99 --- /dev/null +++ b/cmd/migrate_exam_shares.go @@ -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;") +} diff --git a/internal/database/database.go b/internal/database/database.go index e61ca6d..13166a6 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -38,6 +38,7 @@ func InitDB() error { &models.WrongQuestionHistory{}, // 错题历史表 &models.UserAnswerRecord{}, // 用户答题记录表 &models.Exam{}, // 考试表(试卷) + &models.ExamShare{}, // 试卷分享关联表 &models.ExamRecord{}, // 考试记录表 &models.ExamUserAnswer{}, // 用户答案表 ) diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index 7855e57..faea29d 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -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,76 +169,74 @@ 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 { - stats := ExamWithStats{ - Exam: exam, - QuestionCount: len(questionIDs), - } - - // 查询该试卷的考试记录统计(当前用户) - 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"). - First(&record).Error; err == nil { - 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"). - First(&inProgressRecord).Error; err == nil { - stats.HasInProgressExam = true - 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"). - Distinct("user_id"). - Count(&participantCount) - stats.ParticipantCount = int(participantCount) - - result = append(result, stats) + 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"). + First(&record).Error; err == nil { + stats.BestScore = record.Score + } + + var inProgressRecord models.ExamRecord + if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress"). + Order("created_at DESC"). + First(&inProgressRecord).Error; err == nil { + stats.HasInProgressExam = true + stats.InProgressRecordID = inProgressRecord.ID + } + + // 参与人数统计(简化:直接统计该试卷的不同用户) + var participantCount int64 + db.Model(&models.ExamRecord{}). + 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{ @@ -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++ } diff --git a/internal/models/exam.go b/internal/models/exam.go index 7b88fe1..0c5b938 100644 --- a/internal/models/exam.go +++ b/internal/models/exam.go @@ -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 考试记录 diff --git a/internal/models/exam_share.go b/internal/models/exam_share.go new file mode 100644 index 0000000..814b3b8 --- /dev/null +++ b/internal/models/exam_share.go @@ -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" +} diff --git a/web/src/pages/ExamManagement.tsx b/web/src/pages/ExamManagement.tsx index 8e0022f..24a56e6 100644 --- a/web/src/pages/ExamManagement.tsx +++ b/web/src/pages/ExamManagement.tsx @@ -465,7 +465,13 @@ const ExamManagement: React.FC = () => { {record.status === 'in_progress' ? ( - ) : ( - + {record.score} 分 )} @@ -504,18 +510,6 @@ const ExamManagement: React.FC = () => { 查看详情 )} - {record.status === 'graded' && ( - - )} )} diff --git a/web/src/types/exam.ts b/web/src/types/exam.ts index ddaf1e6..f37c77e 100644 --- a/web/src/types/exam.ts +++ b/web/src/types/exam.ts @@ -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