AnCao/cmd/migrate_exam_shares.go
yanlongqi 62281b5047 重构试卷分享系统:修复类型不匹配问题
本次提交重构了试卷分享功能,从"副本模式"改为"关联表模式",并修复了关键的类型不匹配问题。

## 主要更新

### 后端架构重构
- 新增 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>
2025-12-01 22:32:54 +08:00

190 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;")
}