Version:0.1.0.dev.260205

feat: 🆕 完善course模块功能并优化批量导入接口

- 调整 task-class 模型代码并增加注释,使其更简洁易读 ✍️
- 结构调整:将课程相关接口从 schedule 分类移至独立的 course 分类 🚀
  - 修改接口 URL 从 /schedule 更改为 /course 🔄

fix: 🐛 修复批量导入课程接口重复导入相同课程的 bug
- 通过在数据库添加唯一约束解决此问题 🔐
- 这只是初步修复,后续会在 sv 层增加重复/时间冲突检测逻辑 ⚠️
- 引导用户修改课表与任务块时间冲突的机制待实现 

refactor: 🔨 重构 schedule 表单结构
- 将节次管理策略从字符串存储改为原子化存储(如 1-2 节更改为单独两条记录)
- 为后续冲突检查与智能排课做准备 🧠

perf: 🚀 优化批量导入课程接口性能
- 通过数据暂存内存中减少数据插入 MySQL 的次数 
This commit is contained in:
LoveLosita
2026-02-05 16:51:15 +08:00
parent 1bcbd41bec
commit 132b7095ac
10 changed files with 145 additions and 131 deletions

View File

@@ -3,6 +3,6 @@ package api
type ApiHandlers struct {
UserHandler *UserHandler
TaskHandler *TaskHandler
ScheduleHandler *ScheduleHandler
ScheduleHandler *CourseHandler
TaskClassHandler *TaskClassHandler
}

View File

@@ -12,19 +12,19 @@ import (
"github.com/gin-gonic/gin"
)
type ScheduleHandler struct {
type CourseHandler struct {
// 伸出手:准备接住 Service
service *service.ScheduleService
service *service.CourseService
}
// NewScheduleHandler 创建 ScheduleHandler 实例
func NewScheduleHandler(service *service.ScheduleService) *ScheduleHandler {
return &ScheduleHandler{
// NewCourseHandler 创建 CourseHandler 实例
func NewCourseHandler(service *service.CourseService) *CourseHandler {
return &CourseHandler{
service: service,
}
}
func (sa *ScheduleHandler) CheckUserCourse(c *gin.Context) {
func (sa *CourseHandler) CheckUserCourse(c *gin.Context) {
//1.从请求中获取课程信息
var req model.UserCheckCourseRequest
err := c.ShouldBindJSON(&req)
@@ -42,7 +42,7 @@ func (sa *ScheduleHandler) CheckUserCourse(c *gin.Context) {
}
}
func (sa *ScheduleHandler) AddUserCourses(c *gin.Context) {
func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
//1.从请求中获取课程信息
var req model.UserImportCoursesRequest
err := c.ShouldBindJSON(&req)

View File

@@ -43,23 +43,23 @@ func Start() {
userRepo := dao.NewUserDAO(db)
cacheRepo := dao.NewCacheDAO(rdb)
taskRepo := dao.NewTaskDAO(db)
scheduleRepo := dao.NewScheduleDAO(db)
courseRepo := dao.NewCourseDAO(db)
taskClassRepo := dao.NewTaskClassDAO(db)
//service 层
userService := service.NewUserService(userRepo, cacheRepo)
taskSv := service.NewTaskService(taskRepo)
scheduleService := service.NewScheduleService(scheduleRepo)
courseService := service.NewCourseService(courseRepo)
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo)
//api 层
userApi := api.NewUserHandler(userService)
taskApi := api.NewTaskHandler(taskSv)
scheduleApi := api.NewScheduleHandler(scheduleService)
courseApi := api.NewCourseHandler(courseService)
taskClassApi := api.NewTaskClassHandler(taskClassService)
handlers := &api.ApiHandlers{
UserHandler: userApi,
TaskHandler: taskApi,
ScheduleHandler: scheduleApi,
ScheduleHandler: courseApi,
TaskClassHandler: taskClassApi,
}
r := routers.RegisterRouters(handlers, cacheRepo)

24
backend/dao/course.go Normal file
View File

@@ -0,0 +1,24 @@
package dao
import (
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
type CourseDAO struct {
db *gorm.DB
}
// NewCourseDAO 创建ScheduleDAO实例
func NewCourseDAO(db *gorm.DB) *CourseDAO {
return &CourseDAO{
db: db,
}
}
func (dao *CourseDAO) AddUserCourses(courses []model.Schedule) error {
if err := dao.db.Create(&courses).Error; err != nil {
return err
}
return nil
}

View File

@@ -1,24 +0,0 @@
package dao
import (
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
type ScheduleDAO struct {
db *gorm.DB
}
// NewScheduleDAO 创建ScheduleDAO实例
func NewScheduleDAO(db *gorm.DB) *ScheduleDAO {
return &ScheduleDAO{
db: db,
}
}
func (dao *ScheduleDAO) AddUserCourses(courses []model.Schedule) error {
if err := dao.db.Create(&courses).Error; err != nil {
return err
}
return nil
}

18
backend/model/course.go Normal file
View File

@@ -0,0 +1,18 @@
package model
type UserImportCoursesRequest struct {
Courses []UserCheckCourseRequest `json:"courses"`
}
type UserCheckCourseRequest struct {
CourseName string `json:"course_name"`
Location string `json:"location"`
IsAllowTasks bool `json:"is_allow_tasks"`
Arrangements []struct {
StartWeek int `json:"start_week"`
EndWeek int `json:"end_week"`
DayOfWeek int `json:"day_of_week"`
StartSection int `json:"start_section"`
EndSection int `json:"end_section"`
} `json:"arrangements"`
}

View File

@@ -3,29 +3,12 @@ package model
type Schedule struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int `gorm:"column:user_id;index" json:"user_id"`
Type string `gorm:"type:enum('course','task');comment:'course / task'" json:"type"`
RelID int `gorm:"column:rel_id;comment:'关联 course_id 或 task_item_id'" json:"rel_id"`
CanBeEmbedded bool `gorm:"column:can_be_embedded;comment:'是否允许嵌入水课'" json:"can_be_embedded"`
EmbeddedTaskID *uint `gorm:"column:embedded_task_id;comment:'若为水课嵌入记录任务ID'" json:"embedded_task_id"`
Week int `gorm:"column:week" json:"week"`
DayOfWeek int `gorm:"column:day_of_week" json:"day_of_week"`
Sections string `gorm:"type:varchar(255)" json:"sections"`
Status string `gorm:"type:enum('normal','interrupted');default:'normal'" json:"status"`
}
type UserImportCoursesRequest struct {
Courses []UserCheckCourseRequest `json:"courses"`
}
type UserCheckCourseRequest struct {
CourseName string `json:"course_name"`
Location string `json:"location"`
IsAllowTasks bool `json:"is_allow_tasks"`
Arrangements []struct {
StartWeek int `json:"start_week"`
EndWeek int `json:"end_week"`
DayOfWeek int `json:"day_of_week"`
StartSection int `json:"start_section"`
EndSection int `json:"end_section"`
} `json:"arrangements"`
Type string `gorm:"type:enum('course','task');comment:course / task" json:"type"`
RelID int `gorm:"column:rel_id;comment:关联 course_id 或 task_item_id" json:"rel_id"`
EmbeddedTaskID *int `gorm:"column:embedded_task_id;index;comment:若为水课嵌入记录任务ID" json:"embedded_task_id"`
Week int `gorm:"column:week;uniqueIndex:idx_user_slot_atomic,priority:2;comment:周次 (1-25)" json:"week"`
DayOfWeek int `gorm:"column:day_of_week;uniqueIndex:idx_user_slot_atomic,priority:3;comment:星期 (1-7)" json:"day_of_week"`
Section int `gorm:"column:section;uniqueIndex:idx_user_slot_atomic,priority:4;comment:原子化节次 (1-12)" json:"section"`
Status string `gorm:"type:enum('normal','interrupted');default:normal" json:"status"`
CanBeEmbedded bool `gorm:"column:can_be_embedded;not null;comment:是否允许嵌入任务" json:"can_be_embedded"`
}

View File

@@ -7,6 +7,7 @@ import (
"time"
)
// TaskClass 用于和数据库中的 task_classes 表进行映射
type TaskClass struct {
//section 1
ID int `gorm:"column:id;primaryKey;autoIncrement"`
@@ -24,33 +25,7 @@ type TaskClass struct {
Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem
}
// TableName 设定 TaskClass 的表名为 task_classes
func (TaskClass) TableName() string {
return "task_classes"
}
type UserAddTaskClassRequest struct {
Name string `json:"name" binding:"required"`
StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD
EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD
Mode string `json:"mode" binding:"required,oneof=auto manual"`
Config UserAddTaskClassConfig `json:"config" binding:"required"`
Items []UserAddTaskClassItemRequest `json:"items" binding:"required"`
}
type UserAddTaskClassConfig struct {
TotalSlots int `json:"total_slots" binding:"required,min=1"`
AllowFillerCourse bool `json:"allow_filler_course"`
Strategy string `json:"strategy" binding:"required,oneof=steady rapid"`
ExcludedSlots []int `json:"excluded_slots"`
}
type UserAddTaskClassItemRequest struct {
Order int `json:"order" binding:"required,min=1"`
Content string `json:"content" binding:"required"`
EmbeddedTime *TargetTime `json:"embedded_time"` // 例: 2025-12-22 1-2节; nil 表示未安排
}
// TaskClassItem 用于和数据库中的 task_items 表进行映射
type TaskClassItem struct {
//section 1
ID int `gorm:"column:id;primaryKey;autoIncrement"`
@@ -62,12 +37,55 @@ type TaskClassItem struct {
Status *int `gorm:"column:status;comment:1:未安排, 2:已应用"`
}
// UserAddTaskClassRequest 用于处理用户添加任务类别的请求
type UserAddTaskClassRequest struct {
Name string `json:"name" binding:"required"`
StartDate string `json:"start_date" binding:"required"` // YYYY-MM-DD
EndDate string `json:"end_date" binding:"required"` // YYYY-MM-DD
Mode string `json:"mode" binding:"required,oneof=auto manual"`
Config UserAddTaskClassConfig `json:"config" binding:"required"`
Items []UserAddTaskClassItemRequest `json:"items" binding:"required"`
}
// UserAddTaskClassConfig 用于处理用户添加任务类别时的配置部分
type UserAddTaskClassConfig struct {
TotalSlots int `json:"total_slots" binding:"required,min=1"`
AllowFillerCourse bool `json:"allow_filler_course"`
Strategy string `json:"strategy" binding:"required,oneof=steady rapid"`
ExcludedSlots []int `json:"excluded_slots"`
}
// UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分
type UserAddTaskClassItemRequest struct {
Order int `json:"order" binding:"required,min=1"`
Content string `json:"content" binding:"required"`
EmbeddedTime *TargetTime `json:"embedded_time"` // 例: 2025-12-22 1-2节; nil 表示未安排
}
// TargetTime 表示任务块的目标时间
type TargetTime struct {
Date string `json:"date"` // 例: 2025-12-22
SectionFrom int `json:"section_from"` // 起始节次
SectionTo int `json:"section_to"` // 结束节次
}
// UserGetTaskClassesResponse 用于返回用户的任务类列表,展示简要信息
type UserGetTaskClassesResponse struct {
TaskClasses []TaskClassSummary `json:"task_classes"`
}
// TaskClassSummary 提供任务类别的简要信息
type TaskClassSummary struct {
ID int `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Strategy string `json:"strategy"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
TotalSlots int `json:"total_slots"`
}
// Value 实现 driver.Valuer 接口,负责将 TargetTime 转换为数据库存储的格式
func (t *TargetTime) Value() (driver.Value, error) {
if t == nil {
return nil, nil
@@ -77,6 +95,7 @@ func (t *TargetTime) Value() (driver.Value, error) {
return json.Marshal(t)
}
// Scan 实现 sql.Scanner 接口,负责将数据库中的值转换为 TargetTime 结构体
func (t *TargetTime) Scan(value any) error {
if value == nil {
// 如果数据库是 NULL保持指针对应的对象为零值即可
@@ -97,25 +116,18 @@ func (t *TargetTime) Scan(value any) error {
return json.Unmarshal(data, t)
}
// TableName 指定 TaskClass 对应的数据库表名
func (TaskClass) TableName() string {
return "task_classes"
}
// TableName 指定 TaskClassItem 对应的数据库表名
func (TaskClassItem) TableName() string {
return "task_items"
}
// 任务块状态常量
const (
TaskItemStatusUnscheduled = 1
TaskItemStatusApplied = 2
TaskItemStatusUnscheduled = 1 // 未安排
TaskItemStatusApplied = 2 // 已应用
)
type UserGetTaskClassesResponse struct {
TaskClasses []TaskClassSummary `json:"task_classes"`
}
type TaskClassSummary struct {
ID int `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Strategy string `json:"strategy"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
TotalSlots int `json:"total_slots"`
}

View File

@@ -54,11 +54,11 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO) *gin.Engine
taskGroup.POST("/create", handlers.TaskHandler.AddTask)
taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks)
}
scheduleGroup := apiGroup.Group("/schedule")
courseGroup := apiGroup.Group("/course")
{
scheduleGroup.Use(middleware.JWTTokenAuth(cache))
scheduleGroup.POST("/validate", handlers.ScheduleHandler.CheckUserCourse)
scheduleGroup.POST("/import-courses", handlers.ScheduleHandler.AddUserCourses)
courseGroup.Use(middleware.JWTTokenAuth(cache))
courseGroup.POST("/validate", handlers.ScheduleHandler.CheckUserCourse)
courseGroup.POST("/import", handlers.ScheduleHandler.AddUserCourses)
}
taskClassGroup := apiGroup.Group("/task-class")
{

View File

@@ -2,21 +2,20 @@ package service
import (
"context"
"fmt"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
type ScheduleService struct {
type CourseService struct {
// 伸出手:准备接住 DAO
dao *dao.ScheduleDAO
dao *dao.CourseDAO
}
// NewScheduleService 创建 ScheduleService 实例
func NewScheduleService(dao *dao.ScheduleDAO) *ScheduleService {
return &ScheduleService{
// NewCourseService 创建 CourseService 实例
func NewCourseService(dao *dao.CourseDAO) *CourseService {
return &CourseService{
dao: dao,
}
}
@@ -34,7 +33,7 @@ func CheckSingleCourse(req model.UserCheckCourseRequest) bool {
}
// AddUserCourses 添加用户课程表
func (ss *ScheduleService) AddUserCourses(ctx context.Context, req model.UserImportCoursesRequest, userID int) error {
func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImportCoursesRequest, userID int) error {
//1.先校验参数是否正确
for _, course := range req.Courses {
result := CheckSingleCourse(course)
@@ -43,30 +42,32 @@ func (ss *ScheduleService) AddUserCourses(ctx context.Context, req model.UserImp
}
}
//2.转换为 Schedule 切片
var finalSchedules []model.Schedule
for _, course := range req.Courses {
var schedules []model.Schedule
for _, arrangement := range course.Arrangements {
for week := arrangement.StartWeek; week <= arrangement.EndWeek; week++ {
sections := fmt.Sprintf("%d-%d", arrangement.StartSection, arrangement.EndSection)
schedule := model.Schedule{
Type: "course",
Week: week,
DayOfWeek: arrangement.DayOfWeek,
Sections: sections,
Status: "normal",
UserID: userID,
CanBeEmbedded: course.IsAllowTasks,
for section := arrangement.StartSection; section <= arrangement.EndSection; section++ {
schedule := model.Schedule{
Type: "course",
Week: week,
DayOfWeek: arrangement.DayOfWeek,
Section: section,
Status: "normal",
UserID: userID,
CanBeEmbedded: course.IsAllowTasks,
}
schedules = append(schedules, schedule)
}
schedules = append(schedules, schedule)
}
}
//3.调用 DAO 方法添加课程
err := ss.dao.AddUserCourses(schedules)
if err != nil {
return err
}
finalSchedules = append(finalSchedules, schedules...)
}
//3.调用 DAO 方法添加课程
err := ss.dao.AddUserCourses(finalSchedules)
if err != nil {
return err
}
//4.返回结果
return nil
}