duidui_fiber/internal/camp/service/camp_service.go
2026-03-27 10:34:03 +08:00

1291 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}