重构试卷分享系统:修复类型不匹配问题

本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。

## 主要更新

### 后端架构重构
- 新增 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:
燕陇琪 2025-12-01 22:32:54 +08:00
parent 2cc0c154dc
commit 62281b5047
8 changed files with 413 additions and 152 deletions

38
cmd/cleanup/main.go Normal file
View 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
View 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;")
}

View File

@ -38,6 +38,7 @@ func InitDB() error {
&models.WrongQuestionHistory{}, // 错题历史表 &models.WrongQuestionHistory{}, // 错题历史表
&models.UserAnswerRecord{}, // 用户答题记录表 &models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表(试卷) &models.Exam{}, // 考试表(试卷)
&models.ExamShare{}, // 试卷分享关联表
&models.ExamRecord{}, // 考试记录表 &models.ExamRecord{}, // 考试记录表
&models.ExamUserAnswer{}, // 用户答案表 &models.ExamUserAnswer{}, // 用户答案表
) )

View File

@ -152,20 +152,15 @@ func GetExamList(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询用户创建的试卷(包括被分享的试卷) // 获取用户可访问的试卷(拥有的 + 被分享的)
var exams []models.Exam exams, err := models.GetAccessibleExams(userID.(int64), db)
if err := db.Where("user_id = ? AND status = ?", userID, "active"). if err != nil {
Preload("SharedBy", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "nickname")
}).
Order("created_at DESC").
Find(&exams).Error; err != nil {
log.Printf("查询试卷列表失败: %v", err) log.Printf("查询试卷列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"})
return return
} }
// 为每个试卷计算题目数量和获取考试记录统计 // 为每个试卷添加统计信息
type ExamWithStats struct { type ExamWithStats struct {
models.Exam models.Exam
QuestionCount int `json:"question_count"` QuestionCount int `json:"question_count"`
@ -174,76 +169,74 @@ func GetExamList(c *gin.Context) {
HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试 HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试
InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID
ParticipantCount int `json:"participant_count"` // 共享试卷的参与人数(所有用户) 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)) result := make([]ExamWithStats, 0, len(exams))
for _, exam := range exams { for _, exam := range exams {
var questionIDs []uint var questionIDs []uint
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil { if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
stats := ExamWithStats{ continue
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)
} }
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{ c.JSON(http.StatusOK, gin.H{
@ -515,15 +508,32 @@ func GetExamRecord(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询考试记录 // 查询考试记录(不限制用户,因为可能是查看共享试卷的其他用户记录)
var record models.ExamRecord var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", recordID, userID). if err := db.Where("id = ?", recordID).
Preload("Exam"). Preload("Exam").
First(&record).Error; err != nil { First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return 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 表读取所有答案 // 从 exam_user_answers 表读取所有答案
var userAnswers []models.ExamUserAnswer var userAnswers []models.ExamUserAnswer
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil { 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") examIDStr := c.Query("exam_id")
db := database.GetDB() db := database.GetDB()
// 如果指定了试卷ID,需要判断是否为共享试卷 // 如果指定了试卷ID
if examIDStr != "" { if examIDStr != "" {
examID, err := strconv.ParseUint(examIDStr, 10, 32) examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil { if err != nil {
@ -650,42 +659,22 @@ func GetExamRecordList(c *gin.Context) {
return return
} }
// 查询试卷信息 // 查询试卷并检查权限
var exam models.Exam var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
return return
} }
// 获取所有相关试卷ID包括原始试卷和所有分享副本 // 检查用户是否有权限访问该试卷
var relatedExamIDs []uint if !exam.IsAccessibleBy(userID.(int64), db) {
relatedExamIDs = append(relatedExamIDs, uint(examID)) c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权限访问"})
return
// 如果这是被分享的试卷,找到原始试卷
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 records []models.ExamRecord 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("Exam").
Preload("User", func(db *gorm.DB) *gorm.DB { Preload("User", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "nickname", "avatar") return db.Select("id", "username", "nickname", "avatar")
@ -704,7 +693,7 @@ func GetExamRecordList(c *gin.Context) {
return return
} }
// 如果没有指定试卷ID返回当前用户的记录 // 没有指定试卷ID返回当前用户的所有记录
var records []models.ExamRecord var records []models.ExamRecord
if err := db.Where("user_id = ?", userID). if err := db.Where("user_id = ?", userID).
Preload("Exam"). Preload("Exam").
@ -955,9 +944,9 @@ func ShareExam(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询原始试卷 // 查询原始试卷,确认用户有权限分享
var originalExam models.Exam var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&originalExam).Error; err != nil { if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在或无权限"})
return return
} }
@ -970,19 +959,19 @@ func ShareExam(c *gin.Context) {
} }
}() }()
sharedByID := uint(userID.(int64)) sharedByID := userID.(int64)
sharedCount := 0 sharedCount := 0
now := time.Now()
// 为每个用户创建分享副本 // 为每个用户创建分享记录
for _, targetUserID := range req.UserIDs { for _, targetUserID := range req.UserIDs {
// 检查是否已分享给该用户 // 检查是否已分享
var existingExam models.Exam var existingShare models.ExamShare
err := tx.Where("user_id = ? AND shared_by_id = ? AND question_ids = ? AND deleted_at IS NULL", err := tx.Where("exam_id = ? AND shared_to_id = ?", uint(examID), int64(targetUserID)).
targetUserID, sharedByID, originalExam.QuestionIDs).First(&existingExam).Error First(&existingShare).Error
if err == nil { if err == nil {
// 已存在,跳过 continue // 已存在,跳过
continue
} else if err != gorm.ErrRecordNotFound { } else if err != gorm.ErrRecordNotFound {
tx.Rollback() tx.Rollback()
log.Printf("检查分享记录失败: %v", err) log.Printf("检查分享记录失败: %v", err)
@ -990,26 +979,20 @@ func ShareExam(c *gin.Context) {
return return
} }
// 创建试卷副本 // 创建分享记录
sharedExam := models.Exam{ share := models.ExamShare{
UserID: targetUserID, ExamID: uint(examID),
Title: originalExam.Title, SharedByID: sharedByID,
TotalScore: originalExam.TotalScore, SharedToID: int64(targetUserID),
Duration: originalExam.Duration, SharedAt: now,
PassScore: originalExam.PassScore,
QuestionIDs: originalExam.QuestionIDs,
Status: "active",
IsShared: true,
SharedByID: &sharedByID,
} }
if err := tx.Create(&sharedExam).Error; err != nil { if err := tx.Create(&share).Error; err != nil {
tx.Rollback() tx.Rollback()
log.Printf("创建分享试卷失败: %v", err) log.Printf("创建分享记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "分享失败"})
return return
} }
sharedCount++ sharedCount++
} }

View File

@ -20,11 +20,37 @@ 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
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 考试记录 // ExamRecord 考试记录

View 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"
}

View File

@ -465,7 +465,13 @@ const ExamManagement: React.FC = () => {
{record.status === 'in_progress' ? ( {record.status === 'in_progress' ? (
<span>-</span> <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} {record.score}
</span> </span>
)} )}
@ -504,18 +510,6 @@ const ExamManagement: React.FC = () => {
</Button> </Button>
)} )}
{record.status === 'graded' && (
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => {
setRecordsDrawerVisible(false)
navigate(`/exam/result/${record.id}`)
}}
>
</Button>
)}
</Space> </Space>
</Card> </Card>
)} )}

View File

@ -105,9 +105,9 @@ export type ExamListResponse = Array<{
has_in_progress_exam: boolean has_in_progress_exam: boolean
in_progress_record_id?: number in_progress_record_id?: number
participant_count: number // 共享试卷的参与人数 participant_count: number // 共享试卷的参与人数
is_shared: boolean // 是否为分享的试卷 is_shared: boolean // 是否为分享的试卷(原:是否为分享副本)
shared_by_id?: number // 分享人ID shared_by_id?: number // 分享人ID(已废弃,使用 shared_by
shared_by?: { // 分享人信息 shared_by?: { // 分享人信息(原:副本创建者)
id: number id: number
username: string username: string
nickname: string nickname: string