Version: 0.9.77.dev.260505
后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
This commit is contained in:
52
backend/services/runtime/conv/agent.go
Normal file
52
backend/services/runtime/conv/agent.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// ToEinoMessages 将数据库模型转换为 Eino 模型
|
||||
func ToEinoMessages(dbMsgs []model.ChatHistory) []*schema.Message {
|
||||
res := make([]*schema.Message, 0)
|
||||
for _, m := range dbMsgs {
|
||||
var role schema.RoleType
|
||||
switch safeChatHistoryRole(m.Role) {
|
||||
case "user":
|
||||
role = schema.User
|
||||
case "assistant":
|
||||
role = schema.Assistant
|
||||
default:
|
||||
role = schema.System
|
||||
}
|
||||
msg := &schema.Message{
|
||||
Role: role,
|
||||
Content: safeChatHistoryText(m.MessageContent),
|
||||
ReasoningContent: safeChatHistoryText(m.ReasoningContent),
|
||||
}
|
||||
// retry 机制已整体下线:历史数据里的 retry_* 列不再回灌到运行期上下文。
|
||||
extra := make(map[string]any)
|
||||
extra["history_id"] = m.ID
|
||||
if m.ReasoningDurationSeconds > 0 {
|
||||
extra["reasoning_duration_seconds"] = m.ReasoningDurationSeconds
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
msg.Extra = extra
|
||||
}
|
||||
res = append(res, msg)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func safeChatHistoryRole(role *string) string {
|
||||
if role == nil {
|
||||
return ""
|
||||
}
|
||||
return *role
|
||||
}
|
||||
|
||||
func safeChatHistoryText(text *string) string {
|
||||
if text == nil {
|
||||
return ""
|
||||
}
|
||||
return *text
|
||||
}
|
||||
481
backend/services/runtime/conv/schedule.go
Normal file
481
backend/services/runtime/conv/schedule.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||
)
|
||||
|
||||
import "sort"
|
||||
|
||||
func SchedulesToScheduleConflictDetail(schedules []model.Schedule) []model.ScheduleConflictDetail {
|
||||
if len(schedules) == 0 {
|
||||
return []model.ScheduleConflictDetail{}
|
||||
}
|
||||
// 1. 使用 Map 进行逻辑分组
|
||||
// Key 格式: EventID-Week-Day (防止同一事件在不同天出现时被混为一谈)
|
||||
groups := make(map[string]*model.ScheduleConflictDetail)
|
||||
for _, s := range schedules {
|
||||
key := fmt.Sprintf("%d-%d-%d", s.EventID, s.Week, s.DayOfWeek)
|
||||
|
||||
if _, ok := groups[key]; !ok {
|
||||
// 初始化该分组
|
||||
groups[key] = &model.ScheduleConflictDetail{
|
||||
EventID: s.EventID,
|
||||
Name: s.Event.Name,
|
||||
Location: *s.Event.Location, // 假设字段是 *string
|
||||
Type: s.Event.Type,
|
||||
Week: s.Week,
|
||||
DayOfWeek: s.DayOfWeek,
|
||||
}
|
||||
}
|
||||
// 将当前节次加入数组
|
||||
groups[key].Sections = append(groups[key].Sections, s.Section)
|
||||
}
|
||||
|
||||
// 2. 处理每个分组的区间逻辑
|
||||
res := make([]model.ScheduleConflictDetail, 0, len(groups))
|
||||
for _, detail := range groups {
|
||||
// 排序节次,例如把 [3, 1, 2] 变成 [1, 2, 3]
|
||||
sort.Ints(detail.Sections)
|
||||
|
||||
// 最小值即起始,最大值即结束
|
||||
detail.StartSection = detail.Sections[0]
|
||||
detail.EndSection = detail.Sections[len(detail.Sections)-1]
|
||||
|
||||
res = append(res, *detail)
|
||||
}
|
||||
|
||||
// 3. 可选:对结果集按时间排序,让前端收到的 DTO 也是有序的
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
if res[i].Week != res[j].Week {
|
||||
return res[i].Week < res[j].Week
|
||||
}
|
||||
if res[i].DayOfWeek != res[j].DayOfWeek {
|
||||
return res[i].DayOfWeek < res[j].DayOfWeek
|
||||
}
|
||||
return res[i].StartSection < res[j].StartSection
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// SectionToTime 映射表:将原子节次转为起始/结束时间点
|
||||
// 此处以重邮为例
|
||||
var sectionTimeMap = map[int][2]string{
|
||||
1: {"08:00", "08:45"}, 2: {"08:55", "09:40"},
|
||||
3: {"10:15", "11:00"}, 4: {"11:10", "11:55"},
|
||||
5: {"14:00", "14:45"}, 6: {"14:55", "15:40"},
|
||||
7: {"16:15", "17:00"}, 8: {"17:10", "17:55"},
|
||||
9: {"19:00", "19:45"}, 10: {"19:55", "20:40"},
|
||||
11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
|
||||
}
|
||||
|
||||
func SchedulesToUserTodaySchedule(schedules []model.Schedule) []model.UserTodaySchedule {
|
||||
if len(schedules) == 0 {
|
||||
return []model.UserTodaySchedule{}
|
||||
}
|
||||
// 1. 数据预处理:按 Week-Day 分组
|
||||
dayGroups := make(map[string][]model.Schedule)
|
||||
for _, s := range schedules {
|
||||
dayKey := fmt.Sprintf("%d-%d", s.Week, s.DayOfWeek)
|
||||
dayGroups[dayKey] = append(dayGroups[dayKey], s)
|
||||
}
|
||||
var result []model.UserTodaySchedule
|
||||
for _, daySchedules := range dayGroups {
|
||||
todayDTO := model.UserTodaySchedule{
|
||||
Week: daySchedules[0].Week,
|
||||
DayOfWeek: daySchedules[0].DayOfWeek,
|
||||
Events: []model.EventBrief{},
|
||||
}
|
||||
// 💡 关键点:建立一个 Section 查找表,方便 O(1) 确定某节课是什么
|
||||
sectionMap := make(map[int]model.Schedule)
|
||||
for _, s := range daySchedules {
|
||||
sectionMap[s.Section] = s
|
||||
}
|
||||
order := 1
|
||||
// 💡 线性扫描:从第 1 节巡检到第 12 节
|
||||
for curr := 1; curr <= 12; {
|
||||
if slot, ok := sectionMap[curr]; ok {
|
||||
// === A 场景:当前节次有课 ===
|
||||
// 1. 寻找该事件的连续范围(比如 9-12 节连上)
|
||||
// 我们向后探测,直到 EventID 变化或节次断开
|
||||
end := curr
|
||||
for next := curr + 1; next <= 12; next++ {
|
||||
if nextSlot, exist := sectionMap[next]; exist && nextSlot.EventID == slot.EventID {
|
||||
end = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 2. 封装 EventBrief
|
||||
brief := model.EventBrief{
|
||||
ID: slot.EventID,
|
||||
Order: order,
|
||||
Name: slot.Event.Name,
|
||||
Location: *slot.Event.Location,
|
||||
Type: slot.Event.Type,
|
||||
StartTime: sectionTimeMap[curr][0],
|
||||
EndTime: sectionTimeMap[end][1],
|
||||
Span: end - curr + 1,
|
||||
}
|
||||
// 3. 处理嵌入任务
|
||||
// 只要这几个连续节次里有一个有任务,就带上
|
||||
for i := curr; i <= end; i++ {
|
||||
if s, exist := sectionMap[i]; exist && s.EmbeddedTask != nil {
|
||||
brief.EmbeddedTaskInfo = model.TaskBrief{
|
||||
ID: s.EmbeddedTask.ID,
|
||||
Name: *s.EmbeddedTask.Content,
|
||||
Type: "task",
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
todayDTO.Events = append(todayDTO.Events, brief)
|
||||
// 💡 指针跳跃:直接跳过已处理的节次
|
||||
curr = end + 1
|
||||
order++
|
||||
} else {
|
||||
// === B 场景:当前节次没课(Type = "empty") ===
|
||||
// 逻辑:按照学校标准大节(1-2, 3-4...)进行空位合并
|
||||
// 如果当前是奇数节(1, 3, 5...)且下一节也没课,就合并成一个空块
|
||||
emptyEnd := curr
|
||||
if curr%2 != 0 && curr < 12 {
|
||||
if _, nextHasClass := sectionMap[curr+1]; !nextHasClass {
|
||||
emptyEnd = curr + 1
|
||||
}
|
||||
}
|
||||
todayDTO.Events = append(todayDTO.Events, model.EventBrief{
|
||||
ID: 0, // 空课 ID 为 0
|
||||
Order: order,
|
||||
Name: "无课",
|
||||
Type: "empty",
|
||||
StartTime: sectionTimeMap[curr][0],
|
||||
EndTime: sectionTimeMap[emptyEnd][1],
|
||||
Location: "休息时间",
|
||||
})
|
||||
curr = emptyEnd + 1
|
||||
order++
|
||||
}
|
||||
}
|
||||
result = append(result, todayDTO)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSchedule {
|
||||
if len(schedules) == 0 {
|
||||
return &model.UserWeekSchedule{
|
||||
Week: 0,
|
||||
Events: []model.WeeklyEventBrief{},
|
||||
}
|
||||
}
|
||||
// 1. 初始化返回结构 (默认取第一条数据的周次)
|
||||
weekDTO := &model.UserWeekSchedule{
|
||||
Week: schedules[0].Week,
|
||||
Events: []model.WeeklyEventBrief{},
|
||||
}
|
||||
|
||||
// 2. 建立 [天][节次] 的快速索引地图
|
||||
// indexMap[day][section] -> model.Schedule
|
||||
indexMap := make(map[int]map[int]model.Schedule)
|
||||
for d := 1; d <= 7; d++ {
|
||||
indexMap[d] = make(map[int]model.Schedule)
|
||||
}
|
||||
for _, s := range schedules {
|
||||
indexMap[s.DayOfWeek][s.Section] = s
|
||||
}
|
||||
|
||||
// 3. 线性扫描 1-7 天
|
||||
for day := 1; day <= 7; day++ {
|
||||
order := 1 // 每一天开始时,内部显示顺序重置
|
||||
|
||||
// 4. 线性扫描 1-12 节
|
||||
for curr := 1; curr <= 12; {
|
||||
// 场景 A:当前槽位有课/有任务
|
||||
if slot, hasClass := indexMap[day][curr]; hasClass {
|
||||
end := curr
|
||||
// 探测逻辑:合并相同 EventID 的连续节次 (Span 计算)
|
||||
for next := curr + 1; next <= 12; next++ {
|
||||
if nextSlot, exist := indexMap[day][next]; exist && nextSlot.EventID == slot.EventID {
|
||||
end = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
span := end - curr + 1
|
||||
brief := model.WeeklyEventBrief{
|
||||
ID: slot.EventID,
|
||||
Order: order,
|
||||
DayOfWeek: day,
|
||||
Name: slot.Event.Name,
|
||||
Location: *slot.Event.Location,
|
||||
Type: slot.Event.Type,
|
||||
StartTime: sectionTimeMap[curr][0], // 使用你定义的映射表
|
||||
EndTime: sectionTimeMap[end][1],
|
||||
Span: span,
|
||||
}
|
||||
|
||||
// 提取嵌入任务信息 (逻辑同前,探测整个 Span)
|
||||
for i := curr; i <= end; i++ {
|
||||
if s, exist := indexMap[day][i]; exist && s.EmbeddedTask != nil {
|
||||
brief.EmbeddedTaskInfo = model.TaskBrief{
|
||||
ID: s.EmbeddedTask.ID,
|
||||
Name: *s.EmbeddedTask.Content,
|
||||
Type: "task",
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
weekDTO.Events = append(weekDTO.Events, brief)
|
||||
curr = end + 1 // 指针跳跃到下一块
|
||||
order++
|
||||
} else {
|
||||
// 场景 B:无课 (Type="empty"),进行逻辑合并
|
||||
emptyEnd := curr
|
||||
// 奇数节起步且下一节也空,则合并为大节 (1-2, 3-4...)
|
||||
if curr%2 != 0 && curr < 12 {
|
||||
if _, nextHasClass := indexMap[day][curr+1]; !nextHasClass {
|
||||
emptyEnd = curr + 1
|
||||
}
|
||||
}
|
||||
|
||||
weekDTO.Events = append(weekDTO.Events, model.WeeklyEventBrief{
|
||||
ID: 0,
|
||||
Order: order,
|
||||
DayOfWeek: day,
|
||||
Name: "无课",
|
||||
Type: "empty",
|
||||
StartTime: sectionTimeMap[curr][0],
|
||||
EndTime: sectionTimeMap[emptyEnd][1],
|
||||
Span: emptyEnd - curr + 1,
|
||||
Location: "",
|
||||
})
|
||||
curr = emptyEnd + 1
|
||||
order++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return weekDTO
|
||||
}
|
||||
|
||||
func SchedulesToRecentCompletedSchedules(schedules []model.Schedule) *model.UserRecentCompletedScheduleResponse {
|
||||
// 1. 初始化结果集,确保即使为空也返回空数组而非 nil
|
||||
result := &model.UserRecentCompletedScheduleResponse{
|
||||
Events: make([]model.RecentCompletedEventBrief, 0),
|
||||
}
|
||||
|
||||
if len(schedules) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// 💡 核心:去重地图,key 是 EventID
|
||||
seen := make(map[int]bool)
|
||||
|
||||
for _, s := range schedules {
|
||||
// 2. 检查这个逻辑事件(课程或任务块)是否已经处理过
|
||||
if seen[s.EventID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// 5. 构造 Brief
|
||||
temp := model.RecentCompletedEventBrief{
|
||||
// ID 统一使用 EventID,确保唯一性且方便前端追踪逻辑块
|
||||
ID: s.EventID,
|
||||
Name: displayName,
|
||||
Type: displayType,
|
||||
CompletedTime: strTime,
|
||||
}
|
||||
|
||||
result.Events = append(result.Events, temp)
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 这里我们使用一个临时的内部结构来兼容“实日程”和“虚计划”
|
||||
type slotInfo struct {
|
||||
schedule *model.Schedule
|
||||
plan *model.TaskClassItem
|
||||
}
|
||||
|
||||
func PlanningResultToUserWeekSchedules(userSchedule []model.Schedule, plans []model.TaskClassItem) []model.UserWeekSchedule {
|
||||
// 1. 周次范围探测与数据分桶 (保持高效的 O(N) 复杂度)
|
||||
minW, maxW := 25, 1
|
||||
weekMap := make(map[int][]model.Schedule)
|
||||
for _, s := range userSchedule {
|
||||
if s.Week < minW {
|
||||
minW = s.Week
|
||||
}
|
||||
if s.Week > maxW {
|
||||
maxW = s.Week
|
||||
}
|
||||
weekMap[s.Week] = append(weekMap[s.Week], s)
|
||||
}
|
||||
|
||||
planMap := make(map[int][]model.TaskClassItem)
|
||||
for _, p := range plans {
|
||||
if p.EmbeddedTime == nil {
|
||||
continue
|
||||
}
|
||||
w := p.EmbeddedTime.Week
|
||||
if w < minW {
|
||||
minW = w
|
||||
}
|
||||
if w > maxW {
|
||||
maxW = w
|
||||
}
|
||||
planMap[w] = append(planMap[w], p)
|
||||
}
|
||||
|
||||
var results []model.UserWeekSchedule
|
||||
|
||||
for w := minW; w <= maxW; w++ {
|
||||
// 构建当前周的逻辑网格
|
||||
indexMap := make(map[int]map[int]slotInfo)
|
||||
for d := 1; d <= 7; d++ {
|
||||
indexMap[d] = make(map[int]slotInfo)
|
||||
}
|
||||
|
||||
for _, s := range weekMap[w] {
|
||||
indexMap[s.DayOfWeek][s.Section] = slotInfo{schedule: &s}
|
||||
}
|
||||
for _, p := range planMap[w] {
|
||||
for sec := p.EmbeddedTime.SectionFrom; sec <= p.EmbeddedTime.SectionTo; sec++ {
|
||||
info := indexMap[p.EmbeddedTime.DayOfWeek][sec]
|
||||
info.plan = &p
|
||||
indexMap[p.EmbeddedTime.DayOfWeek][sec] = info
|
||||
}
|
||||
}
|
||||
|
||||
weekDTO := &model.UserWeekSchedule{Week: w, Events: []model.WeeklyEventBrief{}}
|
||||
|
||||
for day := 1; day <= 7; day++ {
|
||||
order := 1
|
||||
for curr := 1; curr <= 12; {
|
||||
slot := indexMap[day][curr]
|
||||
|
||||
if slot.schedule != nil || slot.plan != nil {
|
||||
end := curr
|
||||
// 🚀 修复逻辑 A:精准探测合并边界
|
||||
for next := curr + 1; next <= 12; next++ {
|
||||
nextSlot := indexMap[day][next]
|
||||
isSame := false
|
||||
|
||||
if slot.schedule != nil && nextSlot.schedule != nil {
|
||||
// 场景:都是课,且是同一门课
|
||||
isSame = slot.schedule.EventID == nextSlot.schedule.EventID
|
||||
} else if slot.schedule == nil && nextSlot.schedule == nil && slot.plan != nil && nextSlot.plan != nil {
|
||||
// 场景:都是新排任务,且是同一个 TaskItem (修复了之前会合并不同任务的 Bug)
|
||||
isSame = slot.plan.ID == nextSlot.plan.ID
|
||||
}
|
||||
|
||||
if isSame {
|
||||
end = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 修复逻辑 B:直接计算 span 并传值,消除重复计算
|
||||
span := end - curr + 1
|
||||
brief := buildBrief(slot, day, curr, end, span, order)
|
||||
|
||||
weekDTO.Events = append(weekDTO.Events, brief)
|
||||
curr = end + 1
|
||||
order++
|
||||
} else {
|
||||
// 场景 B:留空处理 (逻辑保持原子化)
|
||||
emptyEnd := curr
|
||||
if curr%2 != 0 && curr < 12 {
|
||||
if next := indexMap[day][curr+1]; next.schedule == nil && next.plan == nil {
|
||||
emptyEnd = curr + 1
|
||||
}
|
||||
}
|
||||
weekDTO.Events = append(weekDTO.Events, model.WeeklyEventBrief{
|
||||
Name: "无课", Type: "empty", DayOfWeek: day, Order: order,
|
||||
StartTime: sectionTimeMap[curr][0], EndTime: sectionTimeMap[emptyEnd][1],
|
||||
Span: emptyEnd - curr + 1,
|
||||
})
|
||||
curr = emptyEnd + 1
|
||||
order++
|
||||
}
|
||||
}
|
||||
}
|
||||
results = append(results, *weekDTO)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func buildBrief(slot slotInfo, day, start, end, span, order int) model.WeeklyEventBrief {
|
||||
brief := model.WeeklyEventBrief{
|
||||
DayOfWeek: day,
|
||||
Order: order,
|
||||
StartTime: sectionTimeMap[start][0],
|
||||
EndTime: sectionTimeMap[end][1],
|
||||
Span: span,
|
||||
Status: "normal", // 默认设为正常状态
|
||||
}
|
||||
|
||||
if slot.schedule != nil {
|
||||
// 场景 A:它是数据库里原有的课 (实日程)
|
||||
brief.ID = slot.schedule.EventID
|
||||
brief.Name = slot.schedule.Event.Name
|
||||
brief.Location = *slot.schedule.Event.Location
|
||||
brief.Type = slot.schedule.Event.Type
|
||||
|
||||
// 如果这节课里被算法“塞”进了一个计划任务
|
||||
if slot.plan != nil {
|
||||
brief.Status = "suggested" // 标记为建议状态,前端据此高亮整块
|
||||
brief.EmbeddedTaskInfo = model.TaskBrief{
|
||||
ID: slot.plan.ID,
|
||||
Name: *slot.plan.Content,
|
||||
Type: "task",
|
||||
}
|
||||
}
|
||||
} else if slot.plan != nil {
|
||||
// 场景 B:它是算法在空地新建的任务块 (虚日程)
|
||||
brief.Name = *slot.plan.Content
|
||||
brief.Type = "task"
|
||||
brief.Status = "suggested" // 标记为建议状态
|
||||
brief.ID = slot.plan.ID // 虚日程的 ID 直接使用 TaskClassItem 的 ID,方便前端追踪和操作
|
||||
}
|
||||
|
||||
return brief
|
||||
}
|
||||
218
backend/services/runtime/conv/task-class.go
Normal file
218
backend/services/runtime/conv/task-class.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/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,
|
||||
SubjectType: stringPtrOrNil(req.SubjectType),
|
||||
DifficultyLevel: stringPtrOrNil(req.DifficultyLevel),
|
||||
CognitiveIntensity: stringPtrOrNil(req.CognitiveIntensity),
|
||||
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
|
||||
}*/
|
||||
taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
|
||||
taskClass.ExcludedDaysOfWeek = req.Config.ExcludedDaysOfWeek
|
||||
//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,
|
||||
SubjectType: safeStr(tc.SubjectType),
|
||||
DifficultyLevel: safeStr(tc.DifficultyLevel),
|
||||
CognitiveIntensity: safeStr(tc.CognitiveIntensity),
|
||||
}
|
||||
resp.TaskClasses = append(resp.TaskClasses, tcResp)
|
||||
}
|
||||
return &resp
|
||||
}
|
||||
|
||||
func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.UserAddTaskClassRequest, error) {
|
||||
if taskClass == nil {
|
||||
return nil, errors.New("源数据对象不可为空")
|
||||
}
|
||||
// 1. 映射基础信息 (处理指针解引用)
|
||||
req := &model.UserAddTaskClassRequest{
|
||||
Name: safeStr(taskClass.Name),
|
||||
Mode: safeStr(taskClass.Mode),
|
||||
StartDate: formatTime(taskClass.StartDate),
|
||||
EndDate: formatTime(taskClass.EndDate),
|
||||
SubjectType: safeStr(taskClass.SubjectType),
|
||||
DifficultyLevel: safeStr(taskClass.DifficultyLevel),
|
||||
CognitiveIntensity: safeStr(taskClass.CognitiveIntensity),
|
||||
}
|
||||
// 2. 映射配置信息 (Config Section)
|
||||
req.Config = model.UserAddTaskClassConfig{
|
||||
TotalSlots: safeInt(taskClass.TotalSlots),
|
||||
AllowFillerCourse: safeBool(taskClass.AllowFillerCourse),
|
||||
Strategy: safeStr(taskClass.Strategy),
|
||||
}
|
||||
/*// 3. 处理 ExcludedSlots JSON 字符串 -> []int
|
||||
if taskClass.ExcludedSlots != nil && *taskClass.ExcludedSlots != "" {
|
||||
var excluded []int
|
||||
// 直接使用标准反序列化,比手动处理 rune 字符要健壮得多
|
||||
if err := json.Unmarshal([]byte(*taskClass.ExcludedSlots), &excluded); err == nil {
|
||||
req.Config.ExcludedSlots = excluded
|
||||
}
|
||||
}*/
|
||||
req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
|
||||
req.Config.ExcludedDaysOfWeek = taskClass.ExcludedDaysOfWeek
|
||||
// 4. 映射子项信息 (Items Section)
|
||||
// 此时 items 已经通过 Preload 加载到了 taskClass.Items 中
|
||||
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))
|
||||
for _, item := range taskClass.Items {
|
||||
itemReq := model.UserAddTaskClassItemRequest{
|
||||
ID: item.ID, // 填充数据库主键 ID,前端拖拽编排依赖此字段
|
||||
Order: safeInt(item.Order),
|
||||
Content: safeStr(item.Content),
|
||||
EmbeddedTime: item.EmbeddedTime, // 结构体指针直接复用
|
||||
}
|
||||
req.Items = append(req.Items, itemReq)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// UserInsertTaskItemRequestToModel 用于将填入空闲时段日程的请求转换为 Schedule 模型
|
||||
func UserInsertTaskItemRequestToModel(req *model.UserInsertTaskClassItemToScheduleRequest, item *model.TaskClassItem, taskID *int, userID, startSection, endSection int) ([]model.Schedule, *model.ScheduleEvent, error) {
|
||||
var schedules []model.Schedule
|
||||
for section := startSection; section <= endSection; section++ {
|
||||
req1 := &model.Schedule{
|
||||
UserID: userID,
|
||||
EmbeddedTaskID: taskID,
|
||||
Week: req.Week,
|
||||
DayOfWeek: req.DayOfWeek,
|
||||
Section: section,
|
||||
Status: "normal",
|
||||
}
|
||||
schedules = append(schedules, *req1)
|
||||
}
|
||||
startTime, endTime, err := RelativeTimeToRealTime(req.Week, req.DayOfWeek, startSection, endSection)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req2 := &model.ScheduleEvent{
|
||||
UserID: userID, // 由调用方填充
|
||||
Name: safeStr(item.Content), // 任务内容作为事件名称
|
||||
Type: "task",
|
||||
RelID: &item.ID, // 关联到 TaskClassItem 的 ID
|
||||
CanBeEmbedded: false, // 任务事件允许嵌入其他任务(如果需要的话)
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
return schedules, req2, nil
|
||||
}
|
||||
|
||||
// --- 🛡️ 辅助工具函数:保持代码清爽并防止 Panic ---
|
||||
|
||||
func safeStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func safeInt(i *int) int {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func safeBool(b *bool) bool {
|
||||
if b == nil {
|
||||
return true
|
||||
}
|
||||
return *b
|
||||
}
|
||||
|
||||
func formatTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
// 务必使用 2006-01-02 格式以匹配前端校验
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
94
backend/services/runtime/conv/task.go
Normal file
94
backend/services/runtime/conv/task.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||
)
|
||||
|
||||
func UserAddTaskRequestToModel(request *model.UserAddTaskRequest, userID int) *model.Task {
|
||||
return &model.Task{
|
||||
Title: request.Title,
|
||||
Priority: request.PriorityGroup,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&request.EstimatedSections),
|
||||
DeadlineAt: request.DeadlineAt,
|
||||
UrgencyThresholdAt: request.UrgencyThresholdAt,
|
||||
UserID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
func ModelToUserAddTaskResponse(task *model.Task) *model.UserAddTaskResponse {
|
||||
status := "incomplete"
|
||||
if task.IsCompleted {
|
||||
status = "completed"
|
||||
}
|
||||
return &model.UserAddTaskResponse{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||
DeadlineAt: task.DeadlineAt,
|
||||
Status: status,
|
||||
CreatedAt: time.Now(), // 创建时间使用当前服务时间,保持既有响应语义。
|
||||
}
|
||||
}
|
||||
|
||||
func ModelToGetUserTasksResp(tasks []model.Task) []model.GetUserTaskResp {
|
||||
var resp []model.GetUserTaskResp
|
||||
for _, task := range tasks {
|
||||
status := "incomplete"
|
||||
if task.IsCompleted {
|
||||
status = "completed"
|
||||
}
|
||||
|
||||
deadline := ""
|
||||
if task.DeadlineAt != nil {
|
||||
deadline = task.DeadlineAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
urgencyThreshold := ""
|
||||
if task.UrgencyThresholdAt != nil {
|
||||
urgencyThreshold = task.UrgencyThresholdAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
resp = append(resp, model.GetUserTaskResp{
|
||||
ID: task.ID,
|
||||
UserID: task.UserID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||
Status: status,
|
||||
Deadline: deadline,
|
||||
IsCompleted: task.IsCompleted,
|
||||
UrgencyThresholdAt: urgencyThreshold,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ModelToGetUserTaskResp 将单个 Task 模型转换为 GetUserTaskResp。
|
||||
func ModelToGetUserTaskResp(task *model.Task) model.GetUserTaskResp {
|
||||
status := "incomplete"
|
||||
if task.IsCompleted {
|
||||
status = "completed"
|
||||
}
|
||||
deadline := ""
|
||||
if task.DeadlineAt != nil {
|
||||
deadline = task.DeadlineAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
urgencyThreshold := ""
|
||||
if task.UrgencyThresholdAt != nil {
|
||||
urgencyThreshold = task.UrgencyThresholdAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return model.GetUserTaskResp{
|
||||
ID: task.ID,
|
||||
UserID: task.UserID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
EstimatedSections: model.NormalizeEstimatedSections(&task.EstimatedSections),
|
||||
Status: status,
|
||||
Deadline: deadline,
|
||||
IsCompleted: task.IsCompleted,
|
||||
UrgencyThresholdAt: urgencyThreshold,
|
||||
}
|
||||
}
|
||||
149
backend/services/runtime/conv/time.go
Normal file
149
backend/services/runtime/conv/time.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package conv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// DateFormat 此处定义一个全局常量,确保在整个代码中使用统一的日期格式解析和格式化
|
||||
const DateFormat = "2006-01-02"
|
||||
|
||||
// RealDateToRelativeDate 将绝对日期转换为相对日期(格式: "week-day")
|
||||
func RealDateToRelativeDate(realDate string) (int, int, error) {
|
||||
SemesterStartDate := viper.GetString("time.semesterStartDate") // 从配置文件中读取学期开始日期
|
||||
SemesterEndDate := viper.GetString("time.semesterEndDate") // 从配置文件中读取学期结束日期
|
||||
t, err := time.Parse(DateFormat, realDate)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
start, err := time.Parse(DateFormat, SemesterStartDate)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
end, err := time.Parse(DateFormat, SemesterEndDate)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
// 边界校验:日期必须在学期范围内
|
||||
if t.Before(start) || t.After(end) {
|
||||
return 0, 0, errors.New("日期超出学期范围")
|
||||
}
|
||||
// 计算天数差值(注意:24小时为一个基准天)
|
||||
days := int(t.Sub(start).Hours() / 24)
|
||||
// 计算周数和星期
|
||||
// 假设 SemesterStartDate 对应第 1 周,周 1
|
||||
week := (days / 7) + 1
|
||||
dayOfWeek := (days % 7) + 1
|
||||
return week, dayOfWeek, nil
|
||||
}
|
||||
|
||||
// RelativeDateToRealDate 将相对日期转换为绝对日期(输入格式: "week-day")
|
||||
func RelativeDateToRealDate(week, dayOfWeek int) (string, error) {
|
||||
SemesterStartDate := viper.GetString("time.semesterStartDate") // 从配置文件中读取学期开始日期
|
||||
SemesterEndDate := viper.GetString("time.semesterEndDate") // 从配置文件中读取学期结束日期
|
||||
start, _ := time.Parse(DateFormat, SemesterStartDate)
|
||||
// 核心转换逻辑:(周-1)*7 + (天-1)
|
||||
offsetDays := (week-1)*7 + (dayOfWeek - 1)
|
||||
targetDate := start.AddDate(0, 0, offsetDays)
|
||||
// 校验计算出的日期是否超出学期结束日期
|
||||
end, _ := time.Parse(DateFormat, SemesterEndDate)
|
||||
if targetDate.After(end) {
|
||||
return "", respond.TimeOutOfRangeOfThisSemester
|
||||
}
|
||||
return targetDate.Format(DateFormat), nil
|
||||
}
|
||||
|
||||
type SectionTime struct {
|
||||
Start string // 第一个开始
|
||||
End string // 第一个结束
|
||||
}
|
||||
|
||||
var SectionTimeMap2 = map[int]SectionTime{
|
||||
1: {Start: "08:00", End: "08:45"},
|
||||
2: {Start: "08:55", End: "09:40"},
|
||||
3: {Start: "10:15", End: "11:00"},
|
||||
4: {Start: "11:10", End: "11:55"},
|
||||
5: {Start: "14:00", End: "14:45"},
|
||||
6: {Start: "14:55", End: "15:40"},
|
||||
7: {Start: "16:15", End: "17:00"},
|
||||
8: {Start: "17:10", End: "17:55"},
|
||||
9: {Start: "19:00", End: "19:45"},
|
||||
10: {Start: "19:55", End: "20:40"},
|
||||
11: {Start: "20:50", End: "21:35"},
|
||||
12: {Start: "21:45", End: "22:30"},
|
||||
}
|
||||
|
||||
func RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection int) (time.Time, time.Time, error) {
|
||||
// 1. 安全校验
|
||||
if startSection > endSection {
|
||||
return time.Time{}, time.Time{}, respond.InvalidSectionRange
|
||||
}
|
||||
|
||||
startTimeInfo, okStart := SectionTimeMap2[startSection]
|
||||
endTimeInfo, okEnd := SectionTimeMap2[endSection]
|
||||
if !okStart || !okEnd {
|
||||
return time.Time{}, time.Time{}, respond.InvalidSectionNumber
|
||||
}
|
||||
|
||||
if week < 1 || dayOfWeek < 1 || dayOfWeek > 7 {
|
||||
return time.Time{}, time.Time{}, respond.InvalidWeekOrDayOfWeek
|
||||
}
|
||||
|
||||
// 2. 计算目标日期
|
||||
// 偏移天数 = (周数-1)*7 + (周几-1)
|
||||
daysOffset := (week-1)*7 + (dayOfWeek - 1)
|
||||
TermStartDate := viper.GetString("time.semesterStartDate") // 从配置文件中读取学期开始日期
|
||||
baseDate, _ := time.Parse("2006-01-02", TermStartDate)
|
||||
targetDate := baseDate.AddDate(0, 0, daysOffset)
|
||||
dateStr := targetDate.Format("2006-01-02")
|
||||
|
||||
// 3. 锁定时区 (Asia/Shanghai)
|
||||
timeZone := viper.GetString("time.zone") // 从配置文件中读取时区
|
||||
loc, _ := time.LoadLocation(timeZone)
|
||||
|
||||
// 拼接:起始节次的 Start 和 结束节次的 End
|
||||
startFullStr := fmt.Sprintf("%s %s", dateStr, startTimeInfo.Start)
|
||||
endFullStr := fmt.Sprintf("%s %s", dateStr, endTimeInfo.End)
|
||||
|
||||
startTime, err := time.ParseInLocation("2006-01-02 15:04", startFullStr, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
|
||||
endTime, err := time.ParseInLocation("2006-01-02 15:04", endFullStr, loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func CalculateFirstDayOfWeek(date time.Time) time.Time {
|
||||
// 计算当前日期是周几(0-6,0表示周日)
|
||||
weekday := int(date.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7 // 将周日调整为7,方便计算
|
||||
}
|
||||
// 计算距离周一的天数偏移
|
||||
offset := weekday - 1
|
||||
// 计算本周一的日期
|
||||
firstDayOfWeek := date.AddDate(0, 0, -offset)
|
||||
return firstDayOfWeek
|
||||
}
|
||||
|
||||
func CalculateLastDayOfWeek(date time.Time) time.Time {
|
||||
// 计算当前日期是周几(0-6,0表示周日)
|
||||
weekday := int(date.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7 // 将周日调整为7,方便计算
|
||||
}
|
||||
// 计算距离周日的天数偏移
|
||||
offset := 7 - weekday
|
||||
// 计算本周日的日期
|
||||
lastDayOfWeek := date.AddDate(0, 0, offset)
|
||||
return lastDayOfWeek
|
||||
}
|
||||
Reference in New Issue
Block a user