Version: 0.3.2.dev.260214

feat:  新增获取当前正在进行的任务接口并完成测试

- 若当前为上课时间,返回当前任务 📚
- 若当前为下课/空闲时间,返回下一个任务 ➡️
- 若存在嵌入任务,支持嵌套返回结构 🧩
- 接口已测试通过 🧪

docs: 📝 小幅更新 README

fix: 🐛 修复获取最近已完成任务列表接口的遗漏问题

- 修复无法获取嵌入在课程中的任务问题 🔧

fix: 🐛 修复删除日程接口的字段遗漏问题

- 若删除的是单独任务块,补充删除 task_item 表的 embedded_time 字段
- 避免数据残留与状态异常 
This commit is contained in:
LoveLosita
2026-02-14 21:46:02 +08:00
parent 63500b3b2a
commit dad1eade93
11 changed files with 347 additions and 48 deletions

View File

@@ -68,7 +68,7 @@
前者,用户只需要在队列的下沉列表中找到该任务然后点击一下灰色的勾即可(模仿了滴答清单的设计)。
至于后者,由于后者为动态任务,所以用户需要手动去"最近已完成任务"的清单里面选择该任务然后恢复。
至于后者,由于后者为动态任务,所以用户需要手动去"最近已完成任务"的清单里面选择该任务然后恢复,此时任务会自动回到未安排状态。目前暂不支持课程的撤回,课程方面的改动目前仅支持删除,其它操作后续考虑开发
# 2 产品逻辑与设计

View File

@@ -106,3 +106,40 @@ func (s *ScheduleAPI) GetUserRecentCompletedSchedules(c *gin.Context) {
//3.返回数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, completedSchedules))
}
func (s *ScheduleAPI) GetUserOngoingSchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
//2.调用服务层方法获取用户正在进行的日程事件
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源
ongoingSchedule, err := s.scheduleService.GetUserOngoingSchedule(ctx, userID)
if err != nil {
respond.DealWithError(c, err)
return
}
//3.返回数据给前端
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, ongoingSchedule))
}
func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) {
// 1. 从请求上下文中获取用户ID
userID := c.GetInt("user_id")
// 2. 获取要撤销的任务块ID
eventID := c.Query("event_id")
intEventID, err := strconv.Atoi(eventID)
if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
//3.调用服务层方法撤销任务块的安排
/*ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel() // 记得释放资源*/
err = s.scheduleService.RevocateUserTaskClassItem(context.Background(), userID, intEventID)
if err != nil {
respond.DealWithError(c, err)
return
}
//4.返回撤销成功的响应给前端
c.JSON(http.StatusOK, respond.Ok)
}

View File

@@ -256,9 +256,9 @@ func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSc
return weekDTO
}
func SchedulesToRecentCompletedSchedules(schedules []model.Schedule) model.UserRecentCompletedScheduleResponse {
// 1. 初始化结果集
result := model.UserRecentCompletedScheduleResponse{
func SchedulesToRecentCompletedSchedules(schedules []model.Schedule) *model.UserRecentCompletedScheduleResponse {
// 1. 初始化结果集,确保即使为空也返回空数组而非 nil
result := &model.UserRecentCompletedScheduleResponse{
Events: make([]model.RecentCompletedEventBrief, 0),
}
@@ -270,29 +270,55 @@ func SchedulesToRecentCompletedSchedules(schedules []model.Schedule) model.UserR
seen := make(map[int]bool)
for _, s := range schedules {
// 2. 检查这个逻辑事件是否已经添加
// 2. 检查这个逻辑事件(课程或任务块)是否已经处理
if seen[s.EventID] {
continue
}
// 3. 格式化结束时间
// 3. 确定显示的“名分”和“类型”
displayName := s.Event.Name
displayType := s.Event.Type
// 🚀 关键逻辑:如果存在嵌入任务,则“鸠占鹊巢”
// 即使载体是 course只要里面塞了任务我们就对外宣称这是一个 task
if s.EmbeddedTask != nil && s.EmbeddedTask.Content != nil {
displayName = *s.EmbeddedTask.Content
displayType = "embedded_task"
}
// 4. 格式化结束时间 (即完成时间)
strTime := s.Event.EndTime.Format("2006-01-02 15:04:05")
// 4. 构造 Brief
// 5. 构造 Brief
temp := model.RecentCompletedEventBrief{
// 注意:这里 ID 必须改用 s.EventID (逻辑事件ID)
// 否则如果你传 s.ID前端拿到的是原子槽位的ID依然不唯一
// ID 统一使用 EventID确保唯一性且方便前端追踪逻辑块
ID: s.EventID,
Name: s.Event.Name,
Type: s.Event.Type,
Name: displayName,
Type: displayType,
CompletedTime: strTime,
}
result.Events = append(result.Events, temp)
// 5. 标记该事件已处理
// 6. 标记该事件已处理
seen[s.EventID] = true
}
return result
}
func SchedulesToUserOngoingSchedule(schedules []model.Schedule) *model.OngoingSchedule {
if len(schedules) == 0 {
return nil
}
//取第一个 Schedule 的 Event 作为正在进行的事件
ongoing := schedules[0]
return &model.OngoingSchedule{
ID: ongoing.EventID,
Name: ongoing.Event.Name,
Type: ongoing.Event.Type,
Location: *ongoing.Event.Location,
StartTime: ongoing.Event.StartTime,
EndTime: ongoing.Event.EndTime,
}
}

View File

@@ -163,18 +163,18 @@ func (d *CacheDAO) DeleteUserWeeklyScheduleFromCache(ctx context.Context, userID
return d.client.Del(ctx, key).Err()
}
func (d *CacheDAO) GetUserRecentCompletedSchedulesFromCache(ctx context.Context, userID, index, limit int) (model.UserRecentCompletedScheduleResponse, error) {
func (d *CacheDAO) GetUserRecentCompletedSchedulesFromCache(ctx context.Context, userID, index, limit int) (*model.UserRecentCompletedScheduleResponse, error) {
key := fmt.Sprintf("smartflow:recent_completed_schedules:%d:%d:%d", userID, index, limit)
var resp model.UserRecentCompletedScheduleResponse
val, err := d.client.Get(ctx, key).Result()
if err != nil {
return resp, err // 注意:如果是 redis.Nil交给 Service 层处理查库逻辑
return &resp, err // 注意:如果是 redis.Nil交给 Service 层处理查库逻辑
}
err = json.Unmarshal([]byte(val), &resp)
return resp, err
return &resp, err
}
func (d *CacheDAO) SetUserRecentCompletedSchedulesToCache(ctx context.Context, userID, index, limit int, resp model.UserRecentCompletedScheduleResponse) error {
func (d *CacheDAO) SetUserRecentCompletedSchedulesToCache(ctx context.Context, userID, index, limit int, resp *model.UserRecentCompletedScheduleResponse) error {
key := fmt.Sprintf("smartflow:recent_completed_schedules:%d:%d:%d", userID, index, limit)
data, err := json.Marshal(resp)
if err != nil {
@@ -206,3 +206,41 @@ func (d *CacheDAO) DeleteUserRecentCompletedSchedulesFromCache(ctx context.Conte
}
return nil
}
func (d *CacheDAO) GetUserOngoingScheduleFromCache(ctx context.Context, userID int) (*model.OngoingSchedule, error) {
key := fmt.Sprintf("smartflow:ongoing_schedule:%d", userID)
var schedule model.OngoingSchedule
val, err := d.client.Get(ctx, key).Result()
if err != nil {
return &schedule, err // 注意:如果是 redis.Nil交给 Service 层处理查库逻辑
}
if val == "null" {
return nil, nil // 之前缓存过没有正在进行的日程,直接返回 nil
}
err = json.Unmarshal([]byte(val), &schedule)
return &schedule, err
}
func (d *CacheDAO) SetUserOngoingScheduleToCache(ctx context.Context, userID int, schedule *model.OngoingSchedule) error {
if schedule == nil {
// 如果没有正在进行的日程,设置空值并短暂过期,避免频繁查库
key := fmt.Sprintf("smartflow:ongoing_schedule:%d", userID)
return d.client.Set(ctx, key, "null", 5*time.Minute).Err()
}
key := fmt.Sprintf("smartflow:ongoing_schedule:%d", userID)
data, err := json.Marshal(schedule)
if err != nil {
return err
}
// 设置过期时间为到 endTime 的剩余时间(若已过期则不写入缓存)
ttl := time.Until(schedule.EndTime)
if ttl <= 0 {
return nil
}
return d.client.Set(ctx, key, data, ttl).Err()
}
func (d *CacheDAO) DeleteUserOngoingScheduleFromCache(ctx context.Context, userID int) error {
key := fmt.Sprintf("smartflow:ongoing_schedule:%d", userID)
return d.client.Del(ctx, key).Err()
}

View File

@@ -304,15 +304,33 @@ func (d *ScheduleDAO) GetUserWeeklySchedule(ctx context.Context, userID, week in
}
func (d *ScheduleDAO) DeleteScheduleEventAndSchedule(ctx context.Context, eventID int, userID int) error {
//级联删除:先删 schedules自动删 schedule_events
res := d.db.WithContext(ctx).Where("id=? AND user_id=?", eventID, userID).Delete(&model.ScheduleEvent{})
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先查出要删除的 schedules让 GORM 在 Delete 时能带上模型字段(供钩子读取 UserID/Week
var schedules []model.Schedule
if err := tx.
Where("event_id = ? AND user_id = ?", eventID, userID).
Find(&schedules).Error; err != nil {
return err
}
// 显式删子表 schedules触发 schedules 的 GORM Delete 回调/插件)
if len(schedules) > 0 {
if err := tx.Delete(&schedules).Error; err != nil {
return err
}
}
// 再删父表 schedule_events同样触发回调/插件)
res := tx.Where("id = ? AND user_id = ?", eventID, userID).
Delete(&model.ScheduleEvent{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return respond.WrongScheduleEventID // 事件不存在或不属于该用户,统一返回错误
return respond.WrongScheduleEventID
}
return nil
})
}
func (d *ScheduleDAO) GetScheduleTypeByEventID(ctx context.Context, eventID, userID int) (string, error) {
@@ -452,8 +470,11 @@ func (d *ScheduleDAO) GetUserRecentCompletedSchedules(ctx context.Context, nowTi
Preload("Event").
Preload("EmbeddedTask").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
Where("schedules.user_id = ? AND schedule_events.type = ? AND schedule_events.end_time < ?",
userID, "task", nowTime).
// 修改后的核心逻辑:
// 1. 用户匹配 & 已结束
// 2. 满足 (事件本身是任务) OR (虽然是课程但嵌入了任务)
Where("schedules.user_id = ? AND schedule_events.end_time < ? AND (schedule_events.type = ? OR schedules.embedded_task_id IS NOT NULL)",
userID, nowTime, "task").
Order("schedule_events.end_time DESC"). // 命中索引
Offset(index).
Limit(limit).
@@ -484,3 +505,55 @@ func (d *ScheduleDAO) GetScheduleEventWeekByID(ctx context.Context, eventID int)
}
return *r.Week, nil
}
func (d *ScheduleDAO) GetUserOngoingSchedule(ctx context.Context, userID int, nowTime time.Time) ([]model.Schedule, error) {
var schedules []model.Schedule
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
Where("schedules.user_id = ? AND schedule_events.start_time <= ? AND schedule_events.end_time >= ?",
userID, nowTime, nowTime).
Or("schedules.user_id = ? AND schedule_events.start_time > ?",
userID, nowTime).
Order("schedule_events.start_time ASC"). // 命中索引
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) RevocateSchedulesByEventID(ctx context.Context, eventID int) error {
// 将 schedules 表中指定 event_id 的 embedded_task_id 字段置空(用于撤销嵌入关系)
res := d.db.WithContext(ctx).
Table("schedules").
Where("event_id = ?", eventID).
Update("status", "interrupted")
if res.RowsAffected == 0 {
return respond.WrongScheduleEventID
}
return res.Error
}
func (d *ScheduleDAO) GetRelIDByScheduleEventID(ctx context.Context, eventID int) (int, error) {
type row struct {
RelID *int `gorm:"column:rel_id"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedule_events").
Select("rel_id").
Where("id = ?", eventID).
First(&r).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, respond.WrongScheduleEventID
}
return 0, err
}
if r.RelID == nil {
return 0, nil
}
return *r.RelID, nil
}

View File

@@ -76,8 +76,9 @@ func (p *GormCachePlugin) invalidScheduleCache(userID int, week int) {
go func() {
// 这里调用你的 CacheDAO 删缓存
_ = p.cacheDAO.DeleteUserWeeklyScheduleFromCache(context.Background(), userID, week)
_ = p.cacheDAO.DeleteUserTodayScheduleFromCache(context.Background(), userID) // 同时删当天的缓存,确保数据一致
_ = p.cacheDAO.DeleteUserRecentCompletedSchedulesFromCache(context.Background(), userID) // 同时删最近完成的缓存,确保数据一致
_ = p.cacheDAO.DeleteUserTodayScheduleFromCache(context.Background(), userID) // 同时删当天日程的缓存,确保数据一致
_ = p.cacheDAO.DeleteUserRecentCompletedSchedulesFromCache(context.Background(), userID) // 同时删最近完成日程的缓存,确保数据一致
_ = p.cacheDAO.DeleteUserOngoingScheduleFromCache(context.Background(), userID) // 同时删正在进行日程的缓存,确保数据一致
log.Printf("[GORM-Cache] Invalidated cache for user %d, week %d", userID, week)
}()
}

View File

@@ -107,6 +107,16 @@ type RecentCompletedEventBrief struct {
CompletedTime string `json:"completed_time"`
}
type OngoingSchedule struct {
ID int `json:"id"` // 这个 ID 是 ScheduleEvent 的 ID不是 Schedule 的 ID
Name string `json:"name"`
Location string `json:"location"`
Type string `json:"type"`
TimeStatus string `json:"time_status"` // "upcoming", "ongoing"
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
}
func (ScheduleEvent) TableName() string { return "schedule_events" }
func (Schedule) TableName() string { return "schedules" }

View File

@@ -37,6 +37,10 @@ func DealWithError(c *gin.Context, err error) { //处理错误,返回对应的
return
}
var resp Response
if errors.Is(err, UserTasksEmpty) || errors.Is(err, NoOngoingOrUpcomingSchedule) {
c.JSON(http.StatusOK, err)
return
}
if errors.As(err, &resp) {
c.JSON(http.StatusBadRequest, resp)
return
@@ -62,6 +66,11 @@ var ( //请求相关的响应
Info: "user tasks empty",
}
NoOngoingOrUpcomingSchedule = Response{ //没有正在进行或即将开始的日程
Status: "10002",
Info: "no ongoing or upcoming schedule",
}
WrongName = Response{ //用户名错误
Status: "40001",
Info: "wrong username",

View File

@@ -79,6 +79,8 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk
scheduleGroup.GET("/week", handlers.ScheduleHandler.GetUserWeeklySchedule)
scheduleGroup.DELETE("/delete", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.DeleteScheduleEvent)
scheduleGroup.GET("/recent-completed", handlers.ScheduleHandler.GetUserRecentCompletedSchedules)
scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule)
scheduleGroup.DELETE("/undo-task-item", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule)
}
}
// 初始化Gin引擎

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"log"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
@@ -128,7 +129,7 @@ func (ss *ScheduleService) DeleteScheduleEvent(ctx context.Context, requests []m
}
continue
}
//2.只删课程事件
//2.只删课程/事件
if req.DeleteCourse {
//2.1.检查课程是否有嵌入的任务事件
exists, err := txM.Schedule.IfScheduleEventIDExists(ctx, req.ID)
@@ -204,6 +205,19 @@ func (ss *ScheduleService) DeleteScheduleEvent(ctx context.Context, requests []m
if err != nil {
return err
}
//先通过rel_id找到对应的task_id
taskID, txErr := txM.Schedule.GetRelIDByScheduleEventID(ctx, req.ID)
if txErr != nil {
return err
}
//2.4.如果是任务块转而去清除task_items表中的嵌入时间
if taskID != 0 {
//再将task_items表中对应的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
}
continue
}
//3.只删嵌入的事件
@@ -230,7 +244,7 @@ func (ss *ScheduleService) DeleteScheduleEvent(ctx context.Context, requests []m
return nil
}
func (ss *ScheduleService) GetUserRecentCompletedSchedules(ctx context.Context, userID, index, limit int) (model.UserRecentCompletedScheduleResponse, error) {
func (ss *ScheduleService) GetUserRecentCompletedSchedules(ctx context.Context, userID, index, limit int) (*model.UserRecentCompletedScheduleResponse, error) {
//1.先查缓存
cachedResp, err := ss.cacheDAO.GetUserRecentCompletedSchedulesFromCache(ctx, userID, index, limit)
if err == nil {
@@ -239,7 +253,7 @@ func (ss *ScheduleService) GetUserRecentCompletedSchedules(ctx context.Context,
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return model.UserRecentCompletedScheduleResponse{}, err
return nil, err
}
//2.查询用户最近完成的日程安排
//获取现在的时间
@@ -247,14 +261,117 @@ func (ss *ScheduleService) GetUserRecentCompletedSchedules(ctx context.Context,
nowTime := time.Date(2026, 6, 30, 12, 0, 0, 0, time.Local) //测试数据
schedules, err := ss.scheduleDAO.GetUserRecentCompletedSchedules(ctx, nowTime, userID, index, limit)
if err != nil {
return model.UserRecentCompletedScheduleResponse{}, err
return nil, err
}
//3.转换为前端需要的格式
result := conv.SchedulesToRecentCompletedSchedules(schedules)
//4.将查询结果存入缓存设置过期时间为30分钟根据实际情况调整
err = ss.cacheDAO.SetUserRecentCompletedSchedulesToCache(ctx, userID, index, limit, result)
if err != nil {
return model.UserRecentCompletedScheduleResponse{}, err
return nil, err
}
return result, nil
}
func (ss *ScheduleService) GetUserOngoingSchedule(ctx context.Context, userID int) (*model.OngoingSchedule, error) {
//1.先查缓存
cachedResp, err := ss.cacheDAO.GetUserOngoingScheduleFromCache(ctx, userID)
if err == nil && cachedResp == nil {
// 之前缓存过没有正在进行的日程,直接返回 nil
return nil, respond.NoOngoingOrUpcomingSchedule
}
if err == nil {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//2.查询用户正在进行的日程安排
/*nowTime := time.Now()*/
nowTime := time.Date(2026, 6, 30, 18, 50, 0, 0, time.Local) //测试数据
schedules, err := ss.scheduleDAO.GetUserOngoingSchedule(ctx, userID, nowTime)
if err != nil {
return nil, err
}
//3.转换为前端需要的格式
result := conv.SchedulesToUserOngoingSchedule(schedules)
if result != nil {
if result.StartTime.After(nowTime) {
result.TimeStatus = "upcoming"
} else {
result.TimeStatus = "ongoing"
}
}
//4.将查询结果存入缓存,设置过期时间直到此任务结束(根据实际情况调整)
err = ss.cacheDAO.SetUserOngoingScheduleToCache(ctx, userID, result)
if err != nil {
return nil, err
}
if result == nil {
// 没有正在进行或即将开始的日程,返回特定错误
return nil, respond.NoOngoingOrUpcomingSchedule
}
return result, nil
}
func (ss *ScheduleService) RevocateUserTaskClassItem(ctx context.Context, userID, eventID int) error {
//1.先查库看看这个event是任务事件还是课程事件以及判断它是否属于用户
eventType, err := ss.scheduleDAO.GetScheduleTypeByEventID(ctx, eventID, userID)
if err != nil {
return err
}
//2.根据查询结果进行不同的撤销操作
if eventType == "course" {
//下面开启事务,撤销嵌入事件
err := ss.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error {
//下面先设置schedule表的embedded_task_id字段为null再设置task_items表的embedded_time字段为null实现删除嵌入事件的效果
//3.1.先将schedule表的embedded_task_id字段设置为null
taskID, txErr := txM.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, eventID)
if txErr != nil {
return txErr
}
//3.2.再将task_items表的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
//3.3.最后设置task_items表的status字段为已撤销
txErr = txM.Schedule.RevocateSchedulesByEventID(ctx, eventID)
if txErr != nil {
return txErr
}
return nil
})
if err != nil {
return err
}
} else if eventType == "task" {
//下面开启事务,撤销任务事件
err := ss.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error {
//先通过rel_id找到对应的task_id
taskID, txErr := txM.Schedule.GetRelIDByScheduleEventID(ctx, eventID)
if txErr != nil {
return err
}
//再将task_items表中对应的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
//最后将其从日程表中删除(通过级联删除实现)
err = txM.Schedule.DeleteScheduleEventAndSchedule(ctx, eventID, userID)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
} else {
log.Println("ScheduleService.RevocateUserTaskClassItem: eventType is neither embedded_task nor task, something must be wrong")
}
return nil
}

View File

@@ -219,20 +219,6 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re
// 这里处理最终的错误返回,比如 respond.Error
return err
}
//6.事务提交成功后,清除相关缓存(如果有的话),以保证数据一致性
/*err = sv.cacheRepo.DeleteTaskClassList(ctx, userID)
if err != nil {
// 缓存删除失败,记录日志但不影响正常返回数据
log.Printf("Failed to delete task class list cache for userID %d: %v", userID, err)
}
err = sv.cacheRepo.DeleteUserTodayScheduleFromCache(ctx, userID)
if err != nil {
log.Printf("Failed to delete user today schedule cache for userID %d: %v", userID, err)
}
err = sv.cacheRepo.DeleteUserWeeklyScheduleFromCache(ctx, userID, req.Week)
if err != nil {
log.Printf("Failed to delete user weekly schedule cache for userID %d week %d: %v", userID, req.Week, err)
}*/
return nil
}