From af8e8bd80480f319f0711224001267aaaa170f2f Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Wed, 4 Feb 2026 19:26:22 +0800 Subject: [PATCH] =?UTF-8?q?Version:0.0.5.dev.260204=20feat:=20=F0=9F=8F=97?= =?UTF-8?q?=EF=B8=8F=20=E5=AE=8C=E6=88=90=E4=BB=BB=E5=8A=A1=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=88=97=E8=A1=A8=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8E=A5=E5=8F=A3=E5=B9=B6=E9=80=9A=E8=BF=87=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 历经复杂嵌套逻辑处理 🌀 - 实现创建任务分类接口 ✅ - 实现获取任务分类列表接口 📋 - 接口测试全部通过 🧪 perf: 🚀 下个版本将为任务分类列表接口加入 Redis 缓存以提升查询速度 ⚡ --- backend/api/container.go | 7 +- backend/api/task-class.go | 51 +++++++++++++++ backend/cmd/start.go | 10 ++- backend/conv/task-class.go | 96 +++++++++++++++++++++++++++ backend/dao/task-class.go | 49 ++++++++++++++ backend/model/task-class.go | 120 ++++++++++++++++++++++++++++++++++ backend/routers/routers.go | 6 ++ backend/service/task-class.go | 51 +++++++++++++++ 8 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 backend/api/task-class.go create mode 100644 backend/conv/task-class.go create mode 100644 backend/dao/task-class.go create mode 100644 backend/model/task-class.go create mode 100644 backend/service/task-class.go diff --git a/backend/api/container.go b/backend/api/container.go index 8466a59..4669cb9 100644 --- a/backend/api/container.go +++ b/backend/api/container.go @@ -1,7 +1,8 @@ package api type ApiHandlers struct { - UserHandler *UserHandler - TaskHandler *TaskHandler - ScheduleHandler *ScheduleHandler + UserHandler *UserHandler + TaskHandler *TaskHandler + ScheduleHandler *ScheduleHandler + TaskClassHandler *TaskClassHandler } diff --git a/backend/api/task-class.go b/backend/api/task-class.go new file mode 100644 index 0000000..e273068 --- /dev/null +++ b/backend/api/task-class.go @@ -0,0 +1,51 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/LoveLosita/smartflow/backend/service" + "github.com/gin-gonic/gin" +) + +type TaskClassHandler struct { + svc *service.TaskClassService +} + +// NewTaskClassHandler:组装 Handler 的“工厂” +func NewTaskClassHandler(svc *service.TaskClassService) *TaskClassHandler { + return &TaskClassHandler{ + svc: svc, // 把传进来的 Service 揣进口袋里 + } +} + +func (api *TaskClassHandler) UserAddTaskClass(c *gin.Context) { + var req model.UserAddTaskClassRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + userIDInterface := c.GetInt("user_id") + err = api.svc.AddTaskClass(&req, userIDInterface) + if err != nil { + if errors.Is(err, respond.WrongParamType) { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + c.JSON(http.StatusInternalServerError, respond.InternalError(err)) + } + c.JSON(http.StatusOK, respond.Ok) +} + +func (api *TaskClassHandler) UserGetTaskClassInfos(c *gin.Context) { + userIDInterface := c.GetInt("user_id") + resp, err := api.svc.GetUserTaskClassInfos(userIDInterface) + if err != nil { + c.JSON(http.StatusInternalServerError, respond.InternalError(err)) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 9d2af27..dca74e5 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -44,19 +44,23 @@ func Start() { cacheRepo := dao.NewCacheDAO(rdb) taskRepo := dao.NewTaskDAO(db) scheduleRepo := dao.NewScheduleDAO(db) + taskClassRepo := dao.NewTaskClassDAO(db) //service 层 userService := service.NewUserService(userRepo, cacheRepo) taskSv := service.NewTaskService(taskRepo) scheduleService := service.NewScheduleService(scheduleRepo) + taskClassService := service.NewTaskClassService(taskClassRepo) //api 层 userApi := api.NewUserHandler(userService) taskApi := api.NewTaskHandler(taskSv) scheduleApi := api.NewScheduleHandler(scheduleService) + taskClassApi := api.NewTaskClassHandler(taskClassService) handlers := &api.ApiHandlers{ - UserHandler: userApi, - TaskHandler: taskApi, - ScheduleHandler: scheduleApi, + UserHandler: userApi, + TaskHandler: taskApi, + ScheduleHandler: scheduleApi, + TaskClassHandler: taskClassApi, } r := routers.RegisterRouters(handlers, cacheRepo) routers.StartEngine(r) diff --git a/backend/conv/task-class.go b/backend/conv/task-class.go new file mode 100644 index 0000000..111f528 --- /dev/null +++ b/backend/conv/task-class.go @@ -0,0 +1,96 @@ +package conv + +import ( + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" +) + +const dateLayout = "2006-01-02" + +func parseDatePtr(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + t, err := time.ParseInLocation(dateLayout, s, time.Local) + if err != nil { + return nil, err + } + return &t, nil +} + +func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID int) (*model.TaskClass, []model.TaskClassItem, error) { + startDate, err := parseDatePtr(req.StartDate) + if err != nil { + return nil, nil, respond.WrongParamType + } + endDate, err := parseDatePtr(req.EndDate) + if err != nil { + return nil, nil, respond.WrongParamType + } + //1.填充section1,2 + taskClass := model.TaskClass{ + Name: &req.Name, + Mode: &req.Mode, + StartDate: startDate, + EndDate: endDate, + UserID: &userID, + } + //2.填充section3 + taskClass.TotalSlots = &req.Config.TotalSlots + taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse + taskClass.Strategy = &req.Config.Strategy + //处理 ExcludedSlots 切片为 JSON 字符串 + if len(req.Config.ExcludedSlots) > 0 { + //转换为 JSON 字符串 + excludedSlotsJSON := "[" + for i, slot := range req.Config.ExcludedSlots { + excludedSlotsJSON += string(rune(slot + '0')) //简单转换为字符 + if i != len(req.Config.ExcludedSlots)-1 { + excludedSlotsJSON += "," + } + } + excludedSlotsJSON += "]" + taskClass.ExcludedSlots = &excludedSlotsJSON + } else { + emptyJSON := "[]" + taskClass.ExcludedSlots = &emptyJSON + } + //3.开始构建 items + var items []model.TaskClassItem + for _, itemReq := range req.Items { + item := model.TaskClassItem{ //填充section 2 + Order: &itemReq.Order, + Content: &itemReq.Content, + EmbeddedTime: itemReq.EmbeddedTime, + Status: nil, + } + items = append(items, item) + } + return &taskClass, items, nil +} + +func timeOrZero(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} + +func TaskClassModelToResponse(taskClasses []model.TaskClass) *model.UserGetTaskClassesResponse { + var resp model.UserGetTaskClassesResponse + for _, tc := range taskClasses { + tcResp := model.TaskClassSummary{ + ID: tc.ID, + Name: *tc.Name, + Mode: *tc.Mode, + StartDate: timeOrZero(tc.StartDate), + EndDate: timeOrZero(tc.EndDate), + TotalSlots: *tc.TotalSlots, + Strategy: *tc.Strategy, + } + resp.TaskClasses = append(resp.TaskClasses, tcResp) + } + return &resp +} diff --git a/backend/dao/task-class.go b/backend/dao/task-class.go new file mode 100644 index 0000000..0ac587f --- /dev/null +++ b/backend/dao/task-class.go @@ -0,0 +1,49 @@ +package dao + +import ( + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" +) + +type TaskClassDAO struct { + // 这是一个口袋,用来装数据库连接实例 + db *gorm.DB +} + +// NewTaskClassDAO 创建TaskClassDAO实例 +// NewTaskClassDAO 接收一个 *gorm.DB,并把它塞进结构体的口袋里 +func NewTaskClassDAO(db *gorm.DB) *TaskClassDAO { + return &TaskClassDAO{ + db: db, + } +} + +// AddTaskClass 为指定用户添加任务类 +func (dao *TaskClassDAO) AddTaskClass(taskClass *model.TaskClass) (int, error) { + err := dao.db.Create(taskClass).Error + if err != nil { + return 0, err + } + return taskClass.ID, nil +} + +func (dao *TaskClassDAO) AddTaskClassItems(items []model.TaskClassItem) error { + return dao.db.Create(&items).Error +} + +// Transaction 在一个事务中执行传入的函数,供 service 层复用(自动提交/回滚) +// 规则:fn 返回 nil -> commit;fn 返回 error 或 panic -> rollback +func (dao *TaskClassDAO) Transaction(fn func(txDAO *TaskClassDAO) error) error { + return dao.db.Transaction(func(tx *gorm.DB) error { + return fn(NewTaskClassDAO(tx)) + }) +} + +func (dao *TaskClassDAO) GetUserTaskClasses(userID int) ([]model.TaskClass, error) { + var taskClasses []model.TaskClass + err := dao.db.Where("user_id = ?", userID).Find(&taskClasses).Error + if err != nil { + return nil, err + } + return taskClasses, nil +} diff --git a/backend/model/task-class.go b/backend/model/task-class.go new file mode 100644 index 0000000..1bd6efc --- /dev/null +++ b/backend/model/task-class.go @@ -0,0 +1,120 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +type TaskClass struct { + //section 1 + ID int `gorm:"column:id;primaryKey;autoIncrement"` + UserID *int `gorm:"column:user_id;index:idx_task_classes_user_id"` + //section 2 + Name *string `gorm:"column:name;size:255"` + Mode *string `gorm:"column:mode;type:enum('auto','manual')"` + StartDate *time.Time `gorm:"column:start_date"` + EndDate *time.Time `gorm:"column:end_date"` + //section 3 + TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` + AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` + Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` + ExcludedSlots *string `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` +} + +// 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 表示未安排 +} + +type TaskClassItem struct { + //section 1 + ID int `gorm:"column:id;primaryKey;autoIncrement"` + CategoryID *int `gorm:"column:category_id"` //对应 TaskClass 的 ID + //section 2 + Order *int `gorm:"column:order"` + Content *string `gorm:"column:content;type:text"` + EmbeddedTime *TargetTime `gorm:"column:embedded_time;type:json;comment:目标时间{date,section_from,section_to}"` + Status *int `gorm:"column:status;comment:1:未安排, 2:已应用"` +} + +type TargetTime struct { + Date string `json:"date"` // 例: 2025-12-22 + SectionFrom int `json:"section_from"` // 起始节次 + SectionTo int `json:"section_to"` // 结束节次 +} + +func (t *TargetTime) Value() (driver.Value, error) { + if t == nil { + return nil, nil + } + // 💡 关键:调用 json.Marshal 将结构体转为 []byte + // 这样 GORM 就能把这一串 JSON 存进数据库的 text/json 字段了 + return json.Marshal(t) +} + +func (t *TargetTime) Scan(value any) error { + if value == nil { + // 如果数据库是 NULL,保持指针对应的对象为零值即可 + // 或者在业务层判断 nil + return nil + } + + var data []byte + switch v := value.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("TargetTime: 不支持的扫描类型: %T", value) + } + + return json.Unmarshal(data, t) +} + +func (TaskClassItem) TableName() string { + return "task_items" +} + +const ( + 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"` +} diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 190802e..a5216b2 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -60,6 +60,12 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO) *gin.Engine scheduleGroup.POST("/validate", handlers.ScheduleHandler.CheckUserCourse) scheduleGroup.POST("/import-courses", handlers.ScheduleHandler.AddUserCourses) } + taskClassGroup := apiGroup.Group("/task-class") + { + taskClassGroup.Use(middleware.JWTTokenAuth(cache)) + taskClassGroup.POST("/add", handlers.TaskClassHandler.UserAddTaskClass) + taskClassGroup.GET("/list", handlers.TaskClassHandler.UserGetTaskClassInfos) + } } // 初始化Gin引擎 log.Println("Routes setup completed") diff --git a/backend/service/task-class.go b/backend/service/task-class.go new file mode 100644 index 0000000..e7da6b7 --- /dev/null +++ b/backend/service/task-class.go @@ -0,0 +1,51 @@ +package service + +import ( + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/model" +) + +type TaskClassService struct { + // 这里可以添加数据库连接或其他依赖 + taskClassRepo *dao.TaskClassDAO +} + +func NewTaskClassService(taskClassRepo *dao.TaskClassDAO) *TaskClassService { + return &TaskClassService{ + taskClassRepo: taskClassRepo, + } +} + +// AddTaskClass 为指定用户添加任务类 +func (sv *TaskClassService) AddTaskClass(req *model.UserAddTaskClassRequest, userID int) error { + return sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { + //1.先转换 + taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID) + if err != nil { + return err + } + //2.插入 task class 并获取 ID + taskClassID, err := txDAO.AddTaskClass(taskClass) + if err != nil { + return err + } + //3.插入 items + for i := range items { + items[i].CategoryID = &taskClassID // 关联 task class ID + } + if err := txDAO.AddTaskClassItems(items); err != nil { + return err + } + return nil + }) +} + +func (sv *TaskClassService) GetUserTaskClassInfos(userID int) (*model.UserGetTaskClassesResponse, error) { + taskClasses, err := sv.taskClassRepo.GetUserTaskClasses(userID) + if err != nil { + return nil, err + } + resp := conv.TaskClassModelToResponse(taskClasses) + return resp, nil +}