package service import ( "context" "encoding/json" "fmt" "strings" "sync" "time" "dd_fiber_api/internal/camp" "dd_fiber_api/internal/camp/dao" "dd_fiber_api/internal/order" order_dao "dd_fiber_api/internal/order/dao" order_service "dd_fiber_api/internal/order/service" question_service "dd_fiber_api/internal/question/service" "dd_fiber_api/pkg/snowflake" "dd_fiber_api/pkg/utils" ) // CampService 打卡营服务 type CampService struct { campDAO *dao.CampDAO sectionDAO *dao.SectionDAO taskDAO *dao.TaskDAO progressDAO *dao.ProgressDAO userCampDAO *dao.UserCampDAO orderDAO *order_dao.OrderDAO orderService *order_service.OrderService answerRecordService *question_service.AnswerRecordService paperService *question_service.PaperService } // NewCampService 创建打卡营服务 func NewCampService(campDAO *dao.CampDAO) *CampService { return &CampService{ campDAO: campDAO, } } // SetDependencies 设置依赖(用于聚合接口) func (s *CampService) SetDependencies(sectionDAO *dao.SectionDAO, taskDAO *dao.TaskDAO, progressDAO *dao.ProgressDAO, userCampDAO *dao.UserCampDAO, orderDAO *order_dao.OrderDAO) { s.sectionDAO = sectionDAO s.taskDAO = taskDAO s.progressDAO = progressDAO s.userCampDAO = userCampDAO s.orderDAO = orderDAO } // SetOrderService 设置订单服务(用于任务完成后自动开启下一小节时创建 0 元订单) func (s *CampService) SetOrderService(svc *order_service.OrderService) { s.orderService = svc } // SetAnswerRecordService 设置答题记录服务(用于客观题完成状态校验) func (s *CampService) SetAnswerRecordService(svc *question_service.AnswerRecordService) { s.answerRecordService = svc } // SetPaperService 设置试卷服务(用于客观题完成状态:需答完所有题才算完成) func (s *CampService) SetPaperService(svc *question_service.PaperService) { s.paperService = svc } // CreateCamp 创建打卡营 func (s *CampService) CreateCamp(req *camp.CreateCampRequest) (*camp.CreateCampResponse, error) { campID := snowflake.GetInstance().NextIDString() campObj := &camp.Camp{ ID: campID, Title: req.Title, CoverImage: req.CoverImage, Description: req.Description, IntroType: req.IntroType, IntroContent: req.IntroContent, CategoryID: req.CategoryID, IsRecommended: req.IsRecommended, SectionCount: 0, } err := s.campDAO.Create(campObj) if err != nil { return &camp.CreateCampResponse{ Success: false, Message: fmt.Sprintf("创建打卡营失败: %v", err), }, nil } return &camp.CreateCampResponse{ ID: campID, Success: true, Message: "创建打卡营成功", }, nil } // GetCamp 获取打卡营 func (s *CampService) GetCamp(id string) (*camp.GetCampResponse, error) { campObj, err := s.campDAO.GetByID(id) if err != nil { return &camp.GetCampResponse{ Success: false, Message: fmt.Sprintf("获取打卡营失败: %v", err), }, nil } // 如果提供了 user_id,可以在这里获取用户相关的状态信息 // 例如:是否已加入、当前进度等 // 目前先返回基础信息,后续可以根据需要扩展 return &camp.GetCampResponse{ Camp: campObj, Success: true, Message: "获取打卡营成功", }, nil } // UpdateCamp 更新打卡营 func (s *CampService) UpdateCamp(req *camp.UpdateCampRequest) (*camp.UpdateCampResponse, error) { campObj := &camp.Camp{ ID: req.ID, Title: req.Title, CoverImage: req.CoverImage, Description: req.Description, IntroType: req.IntroType, IntroContent: req.IntroContent, CategoryID: req.CategoryID, IsRecommended: req.IsRecommended, SectionCount: 0, // 不更新 section_count,由系统自动维护 } err := s.campDAO.Update(campObj) if err != nil { return &camp.UpdateCampResponse{ Success: false, Message: fmt.Sprintf("更新打卡营失败: %v", err), }, nil } return &camp.UpdateCampResponse{ Success: true, Message: "更新打卡营成功", }, nil } // DeleteCamp 删除打卡营 func (s *CampService) DeleteCamp(id string) (*camp.DeleteCampResponse, error) { // TODO: 检查是否存在未删除的小节、任务和用户进度 // 暂时先允许删除,后续可以添加检查逻辑 err := s.campDAO.Delete(id) if err != nil { return &camp.DeleteCampResponse{ Success: false, Message: fmt.Sprintf("删除打卡营失败: %v", err), }, nil } return &camp.DeleteCampResponse{ Success: true, Message: "删除打卡营成功", }, nil } // ListCamps 列出打卡营(支持搜索和筛选;joined_only=1 时仅返回用户已加入的营) func (s *CampService) ListCamps(req *camp.ListCampsRequest) (*camp.ListCampsResponse, error) { if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = 10 } if req.PageSize > 100 { req.PageSize = 100 } // 仅已加入:根据 user_id 查用户已加入的营,再补全营详情 if req.JoinedOnly == 1 && req.UserID != "" && s.userCampDAO != nil { joined, total, err := s.userCampDAO.ListUserJoinedCamps(req.UserID, req.Page, req.PageSize) if err != nil { return &camp.ListCampsResponse{ Success: false, Message: fmt.Sprintf("获取已加入打卡营列表失败: %v", err), }, nil } camps := make([]*camp.Camp, 0, len(joined)) trueVal := true for _, uc := range joined { c, err := s.campDAO.GetByID(uc.CampID) if err != nil || c == nil { continue } c.IsJoined = &trueVal camps = append(camps, c) } // 按 camp_sections 实时统计小节数 if len(camps) > 0 && s.sectionDAO != nil { campIDs := make([]string, 0, len(camps)) for _, c := range camps { campIDs = append(campIDs, c.ID) } if countMap, err := s.sectionDAO.CountByCampIDs(campIDs); err == nil { for _, c := range camps { if n, ok := countMap[c.ID]; ok { c.SectionCount = int32(n) } } } } return &camp.ListCampsResponse{ Camps: camps, Total: total, Success: true, Message: "获取打卡营列表成功", }, nil } var isRecommended *bool switch req.RecommendFilter { case camp.RecommendFilterOnlyTrue: trueVal := true isRecommended = &trueVal case camp.RecommendFilterOnlyFalse: falseVal := false isRecommended = &falseVal case camp.RecommendFilterAll: isRecommended = nil } camps, total, err := s.campDAO.Search(req.Keyword, req.CategoryID, isRecommended, req.Page, req.PageSize) if err != nil { return &camp.ListCampsResponse{ Success: false, Message: fmt.Sprintf("获取打卡营列表失败: %v", err), }, nil } // 按 camp_sections 实时统计小节数,覆盖表中的 section_count,保证列表展示正确 if len(camps) > 0 && s.sectionDAO != nil { campIDs := make([]string, 0, len(camps)) for _, c := range camps { campIDs = append(campIDs, c.ID) } if countMap, err := s.sectionDAO.CountByCampIDs(campIDs); err == nil { for _, c := range camps { if n, ok := countMap[c.ID]; ok { c.SectionCount = int32(n) } } } } // 当请求带了 user_id 时,为每个营填充是否已加入 if req.UserID != "" && s.userCampDAO != nil && len(camps) > 0 { for _, c := range camps { isJoined, _, _, _ := s.userCampDAO.CheckUserCampStatus(req.UserID, c.ID) c.IsJoined = &isJoined } } return &camp.ListCampsResponse{ Camps: camps, Total: total, Success: true, Message: "获取打卡营列表成功", }, nil } // GetCampDetailWithStatus 获取打卡营详情及状态(聚合多个数据源) func (s *CampService) GetCampDetailWithStatus(ctx context.Context, req *camp.GetCampDetailWithStatusRequest) (*camp.GetCampDetailWithStatusResponse, error) { if req.CampID == "" || req.UserID == "" { return &camp.GetCampDetailWithStatusResponse{ Success: false, Message: "参数缺失", }, nil } // 检查依赖是否已设置 if s.sectionDAO == nil || s.taskDAO == nil || s.progressDAO == nil || s.userCampDAO == nil || s.orderDAO == nil { return &camp.GetCampDetailWithStatusResponse{ Success: false, Message: "服务依赖未初始化", }, nil } // 使用 WaitGroup 进行并发控制(Go 1.25 语法) var wg sync.WaitGroup var mu sync.Mutex var firstErr error // 定义结果变量 var campObj *camp.Camp var sections []*camp.Section var isJoined bool var joinedAt string var currentSectionID string var purchasedSectionIDs map[string]bool var progressList []*camp.UserProgress // 1. 并发获取基础数据 // 获取打卡营详情 wg.Go(func() { campResp, err := s.GetCamp(req.CampID) mu.Lock() defer mu.Unlock() if err != nil { if firstErr == nil { firstErr = fmt.Errorf("获取打卡营详情失败: %v", err) } return } if !campResp.Success { if firstErr == nil { firstErr = fmt.Errorf("获取打卡营详情失败: %s", campResp.Message) } return } campObj = campResp.Camp }) // 获取小节列表 wg.Go(func() { sectionListReq := &camp.ListSectionsRequest{ CampID: req.CampID, Page: 1, PageSize: 1000, } sectionListResp, err := s.listSections(sectionListReq) mu.Lock() defer mu.Unlock() if err != nil { if firstErr == nil { firstErr = fmt.Errorf("获取小节列表失败: %v", err) } return } if !sectionListResp.Success { if firstErr == nil { firstErr = fmt.Errorf("获取小节列表失败: %s", sectionListResp.Message) } return } sections = sectionListResp.Sections }) // 检查用户状态 wg.Go(func() { statusResp, err := s.checkUserCampStatus(req.UserID, req.CampID) mu.Lock() defer mu.Unlock() if err != nil { if firstErr == nil { firstErr = fmt.Errorf("检查用户状态失败: %v", err) } return } if !statusResp.Success { if firstErr == nil { firstErr = fmt.Errorf("检查用户状态失败: %s", statusResp.Message) } return } isJoined = statusResp.IsJoined joinedAt = statusResp.JoinedAt currentSectionID = statusResp.CurrentSectionID }) // 获取已购买的小节列表(通过订单查询) wg.Go(func() { orders, _, err := s.orderDAO.ListOrders(req.UserID, req.CampID, "", order.OrderStatusPaid, order.PaymentMethodUnknown, 1, 1000) mu.Lock() defer mu.Unlock() if err != nil { if firstErr == nil { firstErr = fmt.Errorf("获取订单列表失败: %v", err) } return } purchasedSectionIDs = make(map[string]bool) for _, ord := range orders { // 新的 Order 结构体使用 Status 字段,类型为 OrderStatus (string) if ord.Status == order.OrderStatusPaid { purchasedSectionIDs[ord.SectionID] = true } } }) // 获取进度列表(不在这里计算小节进度,等 sections 获取完成后再计算) wg.Go(func() { list, _, err := s.progressDAO.List(req.UserID, "", "", "", req.CampID, "", 1, 1000) mu.Lock() defer mu.Unlock() if err != nil { if firstErr == nil { firstErr = fmt.Errorf("获取进度列表失败: %v", err) } return } progressList = list }) // 等待所有基础数据获取完成 wg.Wait() // 检查是否有错误 mu.Lock() err := firstErr mu.Unlock() if err != nil { return &camp.GetCampDetailWithStatusResponse{ Success: false, Message: err.Error(), }, nil } // 检查必要数据是否存在 if campObj == nil || sections == nil { return &camp.GetCampDetailWithStatusResponse{ Success: false, Message: "获取数据不完整", }, nil } // 初始化映射 if purchasedSectionIDs == nil { purchasedSectionIDs = make(map[string]bool) } // 构建进度映射,方便快速查找任务进度 progressMap := make(map[string]*camp.UserProgress) for _, prog := range progressList { progressMap[prog.TaskID] = prog } // 处理每个小节,获取任务列表和进度 aggregatedSections := make([]*camp.AggregatedSectionDetail, 0, len(sections)) for _, section := range sections { sectionID := section.ID // 判断是否已购买 isPurchased := purchasedSectionIDs[sectionID] // 获取任务列表(循环获取所有任务,避免分页限制) // 先获取准确的任务总数 totalCount, err := s.taskDAO.CountActiveBySection(sectionID) if err != nil { totalCount = 0 } allTasks := make([]*camp.Task, 0) page := 1 pageSize := 1000 for { tasks, _, err := s.taskDAO.List("", "", sectionID, camp.TaskTypeUnknown, page, pageSize) if err != nil { // 查询出错,退出循环 break } // 添加任务到列表 allTasks = append(allTasks, tasks...) // 如果已经获取了所有任务,退出循环 if len(allTasks) >= totalCount || len(tasks) < pageSize { break } page++ } tasks := allTasks // 处理任务列表,获取每个任务的进度,同时统计完成情况 aggregatedTasks := make([]*camp.AggregatedTaskDetail, 0, len(tasks)) totalTasks := int32(len(tasks)) completedTasks := int32(0) for _, task := range tasks { // 判断是否需要审核(主观题和申论题需要审核) needReview := task.TaskType == camp.TaskTypeSubjective || task.TaskType == camp.TaskTypeEssay // 获取任务进度(优先从进度映射中获取,避免重复查询) var taskProgress *camp.UserProgress if prog, ok := progressMap[task.ID]; ok { taskProgress = prog } else { taskProgress, _ = s.progressDAO.GetByUserAndTask(req.UserID, task.ID) // 缓存到映射中 if taskProgress != nil { progressMap[task.ID] = taskProgress } } // 确定任务状态 status := "NotStarted" reviewStatus := "" isTaskCompleted := false if taskProgress != nil { if taskProgress.IsCompleted { // 客观题:进度表已按“正确率达标即完成、达标后永久完成”维护,直接信任 is_completed if task.TaskType == camp.TaskTypeObjective { status = "Completed" isTaskCompleted = true } else if needReview { reviewStatus = string(taskProgress.ReviewStatus) switch taskProgress.ReviewStatus { case camp.ReviewStatusApproved: status = "Completed" isTaskCompleted = true case camp.ReviewStatusRejected: status = "Rejected" default: status = "Reviewing" } } else { status = "Completed" isTaskCompleted = true } } else { status = "InProgress" } } // 统计已完成任务数 if isTaskCompleted { completedTasks++ } // 生成任务标题 taskTitle := s.getTaskTitle(task) allowNextWhileReviewing := parseAllowNextWhileReviewing(task.Condition) prereqID := task.PrerequisiteTaskID prerequisites := []string{} if prereqID != "" { prerequisites = []string{prereqID} } aggregatedTask := &camp.AggregatedTaskDetail{ ID: task.ID, TaskType: task.TaskType, Title: taskTitle, Status: status, NeedReview: needReview, AllowNextWhileReviewing: allowNextWhileReviewing, ReviewStatus: reviewStatus, Progress: taskProgress, PrerequisiteTaskID: prereqID, Prerequisites: prerequisites, } aggregatedTasks = append(aggregatedTasks, aggregatedTask) } // 按解锁关系计算 CanStart:仅看后台配置的前置任务;未配置 prerequisite_task_id 时默认都可开始(不按 1→2→3 顺序) taskIDToAgg := make(map[string]*camp.AggregatedTaskDetail) for _, at := range aggregatedTasks { taskIDToAgg[at.ID] = at } for i, at := range aggregatedTasks { task := tasks[i] if task.PrerequisiteTaskID == "" { // 未配置前置任务:默认可直接做,不按列表顺序 at.CanStart = true } else { // 配置了前置任务:必须前置任务完成(且需审核时已通过)才可开始 prereq, ok := taskIDToAgg[task.PrerequisiteTaskID] if !ok { at.CanStart = false continue } if prereq.Status == "Completed" { at.CanStart = true } else if prereq.Status == "Reviewing" && prereq.NeedReview && prereq.AllowNextWhileReviewing { at.CanStart = true } else { at.CanStart = false } } } // 计算小节进度 isCompleted := totalTasks > 0 && completedTasks == totalTasks // 已开始:如果有已完成的任务,或者已购买(表示用户已经开始使用) isStarted := completedTasks > 0 || isPurchased sectionProgress := &camp.UserSectionProgress{ SectionID: sectionID, TotalTasks: totalTasks, CompletedTasks: completedTasks, IsCompleted: isCompleted, } aggregatedSection := &camp.AggregatedSectionDetail{ ID: sectionID, Title: section.Title, SectionNumber: section.SectionNumber, PriceFen: section.PriceFen, IsPurchased: isPurchased, IsStarted: isStarted, IsCompleted: isCompleted, IsCurrent: sectionID == currentSectionID, RequirePreviousSection: section.RequirePreviousSection, TimeIntervalType: section.TimeIntervalType, TimeIntervalValue: section.TimeIntervalValue, Tasks: aggregatedTasks, SectionProgress: sectionProgress, } aggregatedSections = append(aggregatedSections, aggregatedSection) } // 计算打卡营状态 campStatus := camp.CampStatusNotStarted if isJoined { allSectionsCompleted := true hasStartedTask := false hasAnyTask := false for _, section := range aggregatedSections { if section.SectionProgress != nil { totalTasks := section.SectionProgress.TotalTasks completedTasks := section.SectionProgress.CompletedTasks if totalTasks > 0 { hasAnyTask = true if completedTasks > 0 { hasStartedTask = true } if completedTasks != totalTasks { allSectionsCompleted = false } } } } if hasAnyTask { if allSectionsCompleted { campStatus = camp.CampStatusCompleted } else if hasStartedTask { campStatus = camp.CampStatusInProgress } } else { campStatus = camp.CampStatusCompleted } } // 复制 Camp 对象,不返回 deleted_at campCopy := &camp.Camp{ ID: campObj.ID, Title: campObj.Title, CoverImage: campObj.CoverImage, Description: campObj.Description, IntroType: campObj.IntroType, IntroContent: campObj.IntroContent, CategoryID: campObj.CategoryID, IsRecommended: campObj.IsRecommended, SectionCount: campObj.SectionCount, DeletedAt: "", // 不返回 deleted_at } // 构建用户与打卡营的关系 userCamp := &camp.UserCamp{ IsJoined: isJoined, JoinedAt: joinedAt, CurrentSectionID: currentSectionID, CampStatus: campStatus, } // 构建打卡营详情 campDetail := &camp.CampDetail{ Camp: campCopy, UserCamp: userCamp, } return &camp.GetCampDetailWithStatusResponse{ CampDetail: campDetail, Sections: aggregatedSections, Success: true, Message: "获取打卡营详情成功", }, nil } // 辅助方法:获取小节列表(内部使用) func (s *CampService) listSections(req *camp.ListSectionsRequest) (*camp.ListSectionsResponse, error) { if s.sectionDAO == nil { return &camp.ListSectionsResponse{ Success: false, Message: "sectionDAO 未初始化", }, nil } // 设置默认值 if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = 10 } if req.PageSize > 1000 { req.PageSize = 1000 } sections, total, err := s.sectionDAO.List(req.Keyword, req.CampID, req.Page, req.PageSize) if err != nil { return &camp.ListSectionsResponse{ Success: false, Message: fmt.Sprintf("获取小节列表失败: %v", err), }, nil } return &camp.ListSectionsResponse{ Sections: sections, Total: total, Success: true, Message: "获取小节列表成功", }, nil } // 辅助方法:检查用户打卡营状态 func (s *CampService) checkUserCampStatus(userID, campID string) (*camp.CheckUserCampStatusResponse, error) { if s.userCampDAO == nil { return &camp.CheckUserCampStatusResponse{ Success: false, Message: "userCampDAO 未初始化", }, nil } isJoined, joinedAt, currentSectionID, err := s.userCampDAO.CheckUserCampStatus(userID, campID) if err != nil { return &camp.CheckUserCampStatusResponse{ Success: false, Message: fmt.Sprintf("查询失败: %v", err), IsJoined: false, }, nil } // 格式化时间 formattedJoinedAt := utils.FormatNullTimeToStd(joinedAt) var currentSectionIDStr string if currentSectionID.Valid { currentSectionIDStr = currentSectionID.String } return &camp.CheckUserCampStatusResponse{ Success: true, Message: "查询成功", IsJoined: isJoined, JoinedAt: formattedJoinedAt, CurrentSectionID: currentSectionIDStr, }, nil } // CanUnlockSection 检查用户是否可开启指定小节(查询数据库:上一小节是否完成、是否已拥有等) func (s *CampService) CanUnlockSection(req *camp.CanUnlockSectionRequest) (*camp.CanUnlockSectionResponse, error) { if req.CampID == "" || req.SectionID == "" || req.UserID == "" { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "参数不完整", }, nil } if s.sectionDAO == nil { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "服务未就绪", }, nil } section, err := s.sectionDAO.GetByID(req.SectionID) if err != nil || section == nil { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "小节不存在", }, nil } if section.CampID != req.CampID { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "小节不属于该打卡营", }, nil } // 已拥有该小节时,仍须校验时间间隔(未到间隔则不可视为“可进入”,不推进 current_section_id) owned := false if s.orderDAO != nil { owned, _ = s.orderDAO.CheckUserHasSection(req.UserID, req.SectionID) } // 判断是否需要完成上一小节 sections, _, err := s.sectionDAO.List("", req.CampID, 1, 1000) if err != nil || len(sections) == 0 { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: true, Message: "", }, nil } var prevSection *camp.Section for i, sec := range sections { if sec.ID == req.SectionID { if i > 0 { prevSection = sections[i-1] } break } } // 时间间隔限制:只要本节配置了间隔且存在上一节,就必须校验「上一节已完成 + 间隔已过」,与 require_previous_section 无关(收费类型不校验时间,由支付逻辑控制) if section.TimeIntervalType != camp.TimeIntervalTypeNone && section.TimeIntervalType != camp.TimeIntervalTypePaid && section.TimeIntervalValue > 0 && prevSection != nil { totalTasks := 0 if s.taskDAO != nil { totalTasks, _ = s.taskDAO.CountActiveBySection(prevSection.ID) } if totalTasks > 0 { completedTasks := 0 if s.progressDAO != nil { completedTasks, _ = s.progressDAO.CountCompletedBySection(req.UserID, prevSection.ID) } if completedTasks < totalTasks { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "请先完成上一小节", }, nil } } if s.progressDAO == nil { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "无法获取上一小节开启时间,请稍后再试", }, nil } // 时间间隔起点:从上一小节「开启时」算起(首次产生进度的 created_at),若无则用完成时间兜底 intervalStart, _ := s.progressDAO.GetUserSectionStartedAt(req.UserID, prevSection.ID) if intervalStart == nil { intervalStart, _ = s.progressDAO.GetUserSectionCompletedAt(req.UserID, prevSection.ID) } if intervalStart == nil { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "无法获取上一小节开启时间,请稍后再试", }, nil } now := time.Now() switch section.TimeIntervalType { case camp.TimeIntervalTypeHour: unlockAt := intervalStart.Add(time.Duration(section.TimeIntervalValue) * time.Hour) if now.Before(unlockAt) { msg := fmt.Sprintf("需在上一小节开启后 %d 小时才能解锁本小节", section.TimeIntervalValue) hours := int(time.Until(unlockAt).Hours()) mins := int(time.Until(unlockAt).Minutes()) % 60 if hours > 0 || mins > 0 { msg = fmt.Sprintf("%s,请 %d 小时 %d 分钟后再试", msg, hours, mins) } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: msg, UnlockAt: unlockAt.Unix(), }, nil } case camp.TimeIntervalTypeNaturalDay: loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = intervalStart.Location() } prevInLoc := intervalStart.In(loc) prevDay := time.Date(prevInLoc.Year(), prevInLoc.Month(), prevInLoc.Day(), 0, 0, 0, 0, loc) unlockDay := prevDay.AddDate(0, 0, int(section.TimeIntervalValue)) nowInLoc := now.In(loc) if nowInLoc.Before(unlockDay) { msg := fmt.Sprintf("需在上一小节开启后 %d 个自然日才能解锁本小节", section.TimeIntervalValue) if section.TimeIntervalValue == 1 { msg = "需在上一小节开启的次日才能解锁本小节" } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: msg, UnlockAt: unlockDay.Unix(), }, nil } } // 时间间隔已通过,若已拥有则直接返回可解锁 if owned { return &camp.CanUnlockSectionResponse{Success: true, CanUnlock: true, Message: "已拥有"}, nil } } // 无时间间隔或时间间隔已通过:若不需要完成上一小节,则允许解锁 needPrev := section.RequirePreviousSection if !needPrev || prevSection == nil { if owned { return &camp.CanUnlockSectionResponse{Success: true, CanUnlock: true, Message: "已拥有"}, nil } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: true, Message: "", }, nil } // 上一小节任务总数与已完成数 totalTasks := 0 if s.taskDAO != nil { totalTasks, err = s.taskDAO.CountActiveBySection(prevSection.ID) if err != nil { totalTasks = 0 } } if totalTasks == 0 { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: true, Message: "", }, nil } completedTasks := 0 if s.progressDAO != nil { completedTasks, err = s.progressDAO.CountCompletedBySection(req.UserID, prevSection.ID) if err != nil { completedTasks = 0 } } if completedTasks < totalTasks { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "请先完成上一小节", }, nil } // 时间/自然天限制:起点从上一节「开启时」算起(首次产生进度的 created_at),若无则用完成时间兜底(收费类型不校验时间) if section.TimeIntervalType != camp.TimeIntervalTypeNone && section.TimeIntervalType != camp.TimeIntervalTypePaid && section.TimeIntervalValue > 0 { var intervalStart *time.Time if s.progressDAO != nil { intervalStart, _ = s.progressDAO.GetUserSectionStartedAt(req.UserID, prevSection.ID) if intervalStart == nil { intervalStart, _ = s.progressDAO.GetUserSectionCompletedAt(req.UserID, prevSection.ID) } } if intervalStart == nil { return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: "无法获取上一小节开启时间,请稍后再试", }, nil } now := time.Now() switch section.TimeIntervalType { case camp.TimeIntervalTypeHour: unlockAt := intervalStart.Add(time.Duration(section.TimeIntervalValue) * time.Hour) if now.Before(unlockAt) { left := time.Until(unlockAt) hours := int(left.Hours()) mins := int(left.Minutes()) % 60 msg := fmt.Sprintf("需在上一小节开启后 %d 小时才能解锁本小节", section.TimeIntervalValue) if hours > 0 || mins > 0 { msg = fmt.Sprintf("%s,请 %d 小时 %d 分钟后再试", msg, hours, mins) } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: msg, UnlockAt: unlockAt.Unix(), }, nil } case camp.TimeIntervalTypeNaturalDay: // 自然日按中国时区计算,使「次日」为北京时间次日 0 点 loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = intervalStart.Location() } prevOpenInLoc := intervalStart.In(loc) prevDay := time.Date(prevOpenInLoc.Year(), prevOpenInLoc.Month(), prevOpenInLoc.Day(), 0, 0, 0, 0, loc) unlockDay := prevDay.AddDate(0, 0, int(section.TimeIntervalValue)) nowInLoc := now.In(loc) if nowInLoc.Before(unlockDay) { msg := fmt.Sprintf("需在上一小节开启后 %d 个自然日才能解锁本小节", section.TimeIntervalValue) if section.TimeIntervalValue == 1 { msg = "需在上一小节开启的次日才能解锁本小节" } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: false, Message: msg, UnlockAt: unlockDay.Unix(), }, nil } } } return &camp.CanUnlockSectionResponse{ Success: true, CanUnlock: true, Message: "", }, nil } // TryAutoOpenNextSectionIfEligible 在「当前小节全部任务已完成」时,若下一小节无限制且免费则自动开启(创建 0 元订单并更新 current_section_id) // 供 ProgressService 在任务完成时调用,无需前端再请求 can_unlock_section + purchase_section func (s *CampService) TryAutoOpenNextSectionIfEligible(userID, campID, completedSectionID string) { if userID == "" || campID == "" || completedSectionID == "" { return } if s.sectionDAO == nil || s.taskDAO == nil || s.progressDAO == nil || s.orderDAO == nil { return } section, err := s.sectionDAO.GetByID(completedSectionID) if err != nil || section == nil || section.CampID != campID { return } totalTasks, err := s.taskDAO.CountActiveBySection(completedSectionID) if err != nil || totalTasks == 0 { return } completedTasks, err := s.progressDAO.CountCompletedBySection(userID, completedSectionID) if err != nil || completedTasks < totalTasks { return } sections, _, err := s.sectionDAO.List("", campID, 1, 1000) if err != nil || len(sections) == 0 { return } var nextSection *camp.Section for i, sec := range sections { if sec.ID == completedSectionID { if i+1 < len(sections) { nextSection = sections[i+1] } break } } if nextSection == nil { return } // 硬性时间间隔校验:下一节若配置了间隔,必须「上一节开启时间 + 间隔」已过才允许自动开启;收费类型不自动开启,需用户主动购买 if nextSection.TimeIntervalType == camp.TimeIntervalTypePaid { return } if nextSection.TimeIntervalType != camp.TimeIntervalTypeNone && nextSection.TimeIntervalValue > 0 { intervalStart, err := s.progressDAO.GetUserSectionStartedAt(userID, completedSectionID) if err != nil || intervalStart == nil { intervalStart, err = s.progressDAO.GetUserSectionCompletedAt(userID, completedSectionID) } if err != nil || intervalStart == nil { return // 拿不到开启/完成时间则不自动开启 } now := time.Now() switch nextSection.TimeIntervalType { case camp.TimeIntervalTypeHour: unlockAt := intervalStart.Add(time.Duration(nextSection.TimeIntervalValue) * time.Hour) if now.Before(unlockAt) { return // 未到解锁时间,不创建订单、不更新 current_section_id } case camp.TimeIntervalTypeNaturalDay: loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = intervalStart.Location() } prevDay := time.Date(intervalStart.In(loc).Year(), intervalStart.In(loc).Month(), intervalStart.In(loc).Day(), 0, 0, 0, 0, loc) unlockDay := prevDay.AddDate(0, 0, int(nextSection.TimeIntervalValue)) if now.In(loc).Before(unlockDay) { return } default: // 其它类型不自动开启 return } } owned, err := s.orderDAO.CheckUserHasSection(userID, nextSection.ID) if err == nil && owned { // 下一小节已拥有(如重启后):仍须通过 CanUnlockSection 校验(时间/自然日等限制),通过后再推进 current_section_id resp, err := s.CanUnlockSection(&camp.CanUnlockSectionRequest{ UserID: userID, CampID: campID, SectionID: nextSection.ID, }) if err == nil && resp != nil && resp.CanUnlock && s.userCampDAO != nil { _ = s.userCampDAO.UpdateCurrentSection(userID, campID, nextSection.ID) } return } if nextSection.PriceFen > 0 { return } resp, err := s.CanUnlockSection(&camp.CanUnlockSectionRequest{ UserID: userID, CampID: campID, SectionID: nextSection.ID, }) if err != nil || resp == nil || !resp.CanUnlock { return } if s.orderService == nil { return } _, _ = s.orderService.CreateOrder(context.Background(), &order.CreateOrderRequest{ UserID: userID, CampID: campID, SectionID: nextSection.ID, }) } // parseAllowNextWhileReviewing 从任务 condition JSON 中解析 allow_next_while_reviewing(审核中是否允许开启下一任务),默认 true。 // 主观题已从管理端去掉该选项,不再下发该字段,缺省视为 true;申论题仍可配置。 func parseAllowNextWhileReviewing(condition json.RawMessage) bool { if len(condition) == 0 { return true } var raw map[string]any if err := json.Unmarshal(condition, &raw); err != nil { return true } // 根级别 if val, ok := raw["allow_next_while_reviewing"]; ok { if b, ok := val.(bool); ok { return b } } // subjective if sub, ok := raw["subjective"].(map[string]any); ok { if val, ok := sub["allow_next_while_reviewing"]; ok { if b, ok := val.(bool); ok { return b } } } // essay if essay, ok := raw["essay"].(map[string]any); ok { if val, ok := essay["allow_next_while_reviewing"]; ok { if b, ok := val.(bool); ok { return b } } } return true } // getTaskTitle 获取任务标题(优先使用任务标题字段,否则按类型生成) func (s *CampService) getTaskTitle(task *camp.Task) string { if task != nil && strings.TrimSpace(task.Title) != "" { return strings.TrimSpace(task.Title) } if task == nil { return "未知任务" } switch task.TaskType { case camp.TaskTypeImageText: return "图文任务" case camp.TaskTypeVideo: return "视频任务" case camp.TaskTypeSubjective: return "主观题任务" case camp.TaskTypeObjective: return "客观题任务" case camp.TaskTypeEssay: return "申论题任务" default: return "未知任务" } } // CanStartTask 检查用户是否可以开始指定任务(前置任务是否已完成) func (s *CampService) CanStartTask(req *camp.CanStartTaskRequest) (*camp.CanStartTaskResponse, error) { if req.UserID == "" || req.TaskID == "" { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "参数不完整", Message: "参数不完整", }, nil } // 获取目标任务信息 task, err := s.taskDAO.GetByID(req.TaskID) if err != nil || task == nil { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "任务不存在", Message: "任务不存在", }, nil } // 获取同一小节下所有任务(按 id 升序) allTasks, _, err := s.taskDAO.List("", "", task.SectionID, camp.TaskTypeUnknown, 1, 1000) if err != nil { return &camp.CanStartTaskResponse{ Success: false, Message: fmt.Sprintf("获取任务列表失败: %v", err), }, nil } // 找到目标任务在列表中的位置 targetIndex := -1 for i, t := range allTasks { if t.ID == req.TaskID { targetIndex = i break } } if targetIndex == -1 { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "任务不在当前小节中", Message: "任务不在当前小节中", }, nil } // 第一个任务或未设置前置任务时,按“上一任务”顺序检查 if targetIndex == 0 { return &camp.CanStartTaskResponse{ Success: true, CanStart: true, Message: "第一个任务,可以开始", }, nil } // 若配置了前置任务(解锁关系),则必须完成该前置任务后才能开始 if task.PrerequisiteTaskID != "" { prevProgress, _ := s.progressDAO.GetByUserAndTask(req.UserID, task.PrerequisiteTaskID) if prevProgress == nil { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "请先完成前置任务", Message: "请先完成前置任务", }, nil } if !prevProgress.IsCompleted { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "请先完成前置任务", Message: "请先完成前置任务", }, nil } // 若前置任务需要审核,则需审核通过后才算“完成” prevTask, _ := s.taskDAO.GetByID(task.PrerequisiteTaskID) if prevTask != nil && (prevTask.TaskType == camp.TaskTypeSubjective || prevTask.TaskType == camp.TaskTypeEssay) { if prevProgress.ReviewStatus != camp.ReviewStatusApproved && prevProgress.ReviewStatus != camp.ReviewStatusRejected { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "前置任务审核中,请等待审核完成后继续", Message: "前置任务审核中,请等待审核完成后继续", }, nil } if prevProgress.ReviewStatus == camp.ReviewStatusRejected { return &camp.CanStartTaskResponse{ Success: true, CanStart: false, Reason: "请先完成前置任务", Message: "请先完成前置任务", }, nil } } return &camp.CanStartTaskResponse{ Success: true, CanStart: true, Message: "可以开始", }, nil } // 未配置前置任务:默认可直接做,不按列表顺序要求上一任务 if task.PrerequisiteTaskID == "" { return &camp.CanStartTaskResponse{ Success: true, CanStart: true, Message: "可以开始", }, nil } return &camp.CanStartTaskResponse{ Success: true, CanStart: true, Message: "可以开始", }, nil }