本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。 ## 主要更新 ### 后端架构重构 - 新增 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>
190 lines
5.1 KiB
Go
190 lines
5.1 KiB
Go
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;")
|
||
}
|