ByteNoteByteNote

字节笔记本

2026年7月20日

Gin + GORM 多对多关系实战:无外键设计与手动关联查询

API中转
¥120

Gin + GORM 多对多关系实战:无外键设计与手动关联查询

GORM 的多对多关系默认使用中间表和外键约束。但在生产环境中,很多团队选择不用物理外键,改由应用层保证数据一致性。本文用 Gin + GORM 实现学生-课程的多对多关系,从基础版到完全无外键的最终版。

基础版:GORM 默认的多对多

最简单的写法,让 GORM 自动管理中间表:

go
type Student struct {
    ID       uint      `json:"id" gorm:"primaryKey"`
    Name     string    `json:"name"`
    Courses  []Course  `json:"courses" gorm:"many2many:student_courses;"`
}

type Course struct {
    ID       uint      `json:"id" gorm:"primaryKey"`
    Name     string    `json:"name"`
    Students []Student `json:"students" gorm:"many2many:student_courses;"`
}

GORM 的 AutoMigrate 会自动创建 student_courses 中间表,并添加外键约束。

使用:

go
// 添加关联
db.Model(&student).Association("Courses").Append(&course)

// 查询时预加载
db.Preload("Courses").Find(&students)

进阶版:带状态的中间表

实际业务中,中间表通常需要额外字段。比如学生选课后有「待处理 / 处理中 / 已完成 / 已取消」的状态。

go
type StudentCourse struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    StudentID uint      `json:"student_id" gorm:"index:idx_student_course,unique"`
    CourseID  uint      `json:"course_id" gorm:"index:idx_student_course,unique"`
    Status    string    `json:"status" gorm:"type:enum('待处理','处理中','已完成','已取消');default:'待处理'"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

注意这里用了独立自增 ID 作主键,而不是 (StudentID, CourseID) 复合主键。好处是方便单独通过 ID 更新状态。

完全无外键版

去掉 GORM 的关联标签,三张表彼此独立:

go
type Student struct {
    ID   uint   `json:"id" gorm:"primaryKey"`
    Name string `json:"name" gorm:"index"`
}

type Course struct {
    ID   uint   `json:"id" gorm:"primaryKey"`
    Name string `json:"name" gorm:"index"`
}

type StudentCourse struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    StudentID uint      `json:"student_id" gorm:"index:idx_student_course,unique"`
    CourseID  uint      `json:"course_id" gorm:"index:idx_student_course,unique"`
    Status    string    `json:"status" gorm:"type:enum('待处理','处理中','已完成','已取消');default:'待处理'"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

唯一联合索引 idx_student_course 防止同一个学生重复选同一门课,但不会创建外键约束。

关键区别

GORM 的 foreignKey / references / constraint 标签不仅影响 ORM 层面的关联查询,还会让 AutoMigrate 在数据库层面生成物理外键约束。如果你明确不要外键,就不能用这些标签,需要移除所有关联声明。

手动关联查询

去掉 GORM 关联标签后,Preload 不可用,需要手动查询组装。

响应结构体

go
type StudentCourseDetail struct {
    ID        uint      `json:"id"`
    StudentID uint      `json:"student_id"`
    CourseID  uint      `json:"course_id"`
    Status    string    `json:"status"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Student   Student   `json:"student"`
    Course    Course    `json:"course"`
}

查询接口

go
r.GET("/students/:id/courses", func(c *gin.Context) {
    // 1. 查中间表
    var studentCourses []StudentCourse
    if err := db.Where("student_id = ?", c.Param("id")).
        Find(&studentCourses).Error; err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    // 2. 手动组装关联数据
    var details []StudentCourseDetail
    for _, sc := range studentCourses {
        var student Student
        var course Course
        if db.First(&student, sc.StudentID).Error != nil { continue }
        if db.First(&course, sc.CourseID).Error != nil { continue }

        details = append(details, StudentCourseDetail{
            ID: sc.ID, StudentID: sc.StudentID, CourseID: sc.CourseID,
            Status: sc.Status, CreatedAt: sc.CreatedAt, UpdatedAt: sc.UpdatedAt,
            Student: student, Course: course,
        })
    }

    c.JSON(200, details)
})

按状态筛选:

go
r.GET("/students/:id/courses/status/:status", func(c *gin.Context) {
    var studentCourses []StudentCourse
    db.Where("student_id = ? AND status = ?",
        c.Param("id"), c.Param("status")).Find(&studentCourses)
    // 组装逻辑同上...
})

REST API 完整实现

go
func main() {
    r := gin.Default()
    db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    db.AutoMigrate(&Student{}, &Course{}, &StudentCourse{})

    // 创建学生
    r.POST("/students", func(c *gin.Context) {
        var student Student
        if err := c.ShouldBindJSON(&student); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        db.Create(&student)
        c.JSON(201, student)
    })

    // 创建课程
    r.POST("/courses", func(c *gin.Context) {
        var course Course
        if err := c.ShouldBindJSON(&course); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        db.Create(&course)
        c.JSON(201, course)
    })

    // 添加学生-课程关联
    r.POST("/students/:studentId/courses/:courseId", func(c *gin.Context) {
        studentID, _ := strconv.Atoi(c.Param("studentId"))
        courseID, _ := strconv.Atoi(c.Param("courseId"))

        // 检查是否存在
        var existing StudentCourse
        if db.Where("student_id = ? AND course_id = ?", studentID, courseID).
            First(&existing).RowsAffected > 0 {
            c.JSON(409, gin.H{"error": "关联已存在"})
            return
        }

        sc := StudentCourse{
            StudentID: uint(studentID),
            CourseID:  uint(courseID),
            Status:    "待处理",
        }
        db.Create(&sc)
        c.JSON(201, sc)
    })

    // 更新状态(带白名单校验)
    r.PUT("/student-courses/:id/status", func(c *gin.Context) {
        var req struct {
            Status string `json:"status" binding:"required"`
        }
        if err := c.ShouldBindJSON(&req); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }

        validStatuses := map[string]bool{
            "待处理": true, "处理中": true,
            "已完成": true, "已取消": true,
        }
        if !validStatuses[req.Status] {
            c.JSON(400, gin.H{"error": "Invalid status"})
            return
        }

        result := db.Model(&StudentCourse{}).
            Where("id = ?", c.Param("id")).
            Update("status", req.Status)
        if result.RowsAffected == 0 {
            c.JSON(404, gin.H{"error": "记录不存在"})
            return
        }
        c.JSON(200, gin.H{"message": "更新成功"})
    })

    // 查询学生的所有课程
    r.GET("/students/:id/courses", func(c *gin.Context) {
        var studentCourses []StudentCourse
        db.Where("student_id = ?", c.Param("id")).Find(&studentCourses)

        var details []StudentCourseDetail
        for _, sc := range studentCourses {
            var student Student
            var course Course
            if db.First(&student, sc.StudentID).Error != nil { continue }
            if db.First(&course, sc.CourseID).Error != nil { continue }
            details = append(details, StudentCourseDetail{
                ID: sc.ID, StudentID: sc.StudentID, CourseID: sc.CourseID,
                Status: sc.Status, Student: student, Course: course,
            })
        }
        c.JSON(200, details)
    })

    r.Run(":8080")
}

外键 vs 无外键的权衡

维度有外键无外键
数据完整性数据库自动保证应用层 + 事务保证
Preload可用不可用,需手动查询
开发效率低(代码量增加)
运维灵活度低(改表结构受限)
分库分表困难方便
性能外键检查有开销无额外开销

选择哪种方式取决于团队规范和业务场景。中小项目用 GORM 默认的外键方式更高效,大型项目或微服务架构通常选择无外键。

总结

GORM 的多对多关系可以通过 many2many 标签快速实现,但如果需要中间表携带额外字段或完全去掉外键,就需要用显式的中间表模型。去掉 GORM 关联标签后,Preload 不可用,需要手动多次查询并组装数据。这种方式代码量更大,但换来了更高的灵活性和运维自由度。

分享: