package service import ( "encoding/json" "fmt" "strings" "dd_fiber_api/internal/camp" "dd_fiber_api/internal/camp/dao" question_service "dd_fiber_api/internal/question/service" "dd_fiber_api/pkg/snowflake" ) // ProgressService 用户进度服务 type ProgressService struct { progressDAO *dao.ProgressDAO taskDAO *dao.TaskDAO userCampDAO *dao.UserCampDAO answerRecordService *question_service.AnswerRecordService campService *CampService } // NewProgressService 创建用户进度服务 func NewProgressService(progressDAO *dao.ProgressDAO, taskDAO *dao.TaskDAO, userCampDAO *dao.UserCampDAO, answerRecordService *question_service.AnswerRecordService, campService *CampService) *ProgressService { return &ProgressService{ progressDAO: progressDAO, taskDAO: taskDAO, userCampDAO: userCampDAO, answerRecordService: answerRecordService, campService: campService, } } // UpdateUserProgress 更新用户进度 func (s *ProgressService) UpdateUserProgress(req *camp.UpdateUserProgressRequest) (*camp.UpdateUserProgressResponse, error) { // 读取现有记录用于字段合并,避免未提供字段被清空 var existing *camp.UserProgress if prev, err := s.progressDAO.GetByUserAndTask(req.UserID, req.TaskID); err == nil { existing = prev } // 获取任务信息(用于后续判断完成状态) task, err := s.taskDAO.GetByID(req.TaskID) if err != nil { return &camp.UpdateUserProgressResponse{ Success: false, Message: fmt.Sprintf("获取任务信息失败: %v", err), }, nil } // 申论题:支持「暂存」——允许部分题目提交,与已有进度合并;仅当全部题目都有答案时才允许完成 var essayMerged [][]string var essayAllFilled bool if task.TaskType == camp.TaskTypeEssay && len(req.EssayAnswerImages) > 0 { expectCount := req.EssayQuestionCount if expectCount <= 0 { expectCount = len(req.EssayAnswerImages) } if expectCount <= 0 && existing != nil && len(existing.EssayAnswerImages) > 0 { expectCount = len(existing.EssayAnswerImages) } if expectCount <= 0 { expectCount = len(req.EssayAnswerImages) } merged := make([][]string, expectCount) for i := 0; i < expectCount; i++ { if i < len(req.EssayAnswerImages) && len(req.EssayAnswerImages[i]) > 0 { merged[i] = req.EssayAnswerImages[i] } else if existing != nil && i < len(existing.EssayAnswerImages) && len(existing.EssayAnswerImages[i]) > 0 { merged[i] = existing.EssayAnswerImages[i] } else { merged[i] = nil } } essayMerged = merged essayAllFilled = true for _, imgs := range merged { if len(imgs) == 0 { essayAllFilled = false break } } } // 判断是否完成 isCompleted := req.IsCompleted reviewStatus := req.ReviewStatus var objectiveBestCorrectCount, objectiveBestTotalCount int if existing != nil { objectiveBestCorrectCount = existing.ObjectiveBestCorrectCount objectiveBestTotalCount = existing.ObjectiveBestTotalCount } // 申论题:若传了每题审核状态 essay_review_statuses,则据此计算整体 review_status(任务最终状态由所有题目状态决定) if task.TaskType == camp.TaskTypeEssay && len(req.EssayReviewStatuses) > 0 { reviewStatus = computeEssayOverallReviewStatus(req.EssayReviewStatuses) } // 根据任务类型判断完成状态 switch task.TaskType { case camp.TaskTypeSubjective, camp.TaskTypeEssay: // 主观题和申论题:需要检查是否需要审核 needReview := false if len(task.Condition) > 0 { var conditionMap map[string]any if err := json.Unmarshal(task.Condition, &conditionMap); err == nil { // 检查 need_review 字段(可能在根级别或 subjective/essay 子对象中) if val, ok := conditionMap["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } else if task.TaskType == camp.TaskTypeSubjective { if subjectiveRaw, ok := conditionMap["subjective"].(map[string]any); ok { if val, ok := subjectiveRaw["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } } } else if task.TaskType == camp.TaskTypeEssay { if essayRaw, ok := conditionMap["essay"].(map[string]any); ok { if val, ok := essayRaw["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } } } } } if needReview { // 需要审核:只有审核通过才算完成 isCompleted = (reviewStatus == camp.ReviewStatusApproved) if task.TaskType == camp.TaskTypeEssay && len(essayMerged) > 0 { isCompleted = essayAllFilled && isCompleted } } else { // 不需要审核:提交后直接完成,自动设置为审核通过 hasSubmission := len(req.AnswerImages) > 0 || len(req.EssayAnswerImages) > 0 || req.IsCompleted if hasSubmission { if task.TaskType == camp.TaskTypeEssay && len(essayMerged) > 0 { isCompleted = essayAllFilled } else { isCompleted = true } if isCompleted { reviewStatus = camp.ReviewStatusApproved } } } case camp.TaskTypeImageText, camp.TaskTypeVideo: // 图文和视频任务:直接使用 req.IsCompleted isCompleted = req.IsCompleted case camp.TaskTypeObjective: // 客观题:按正确率(正确数/总题数)达标即完成;达标后只保留最高正确率,后续未达标不覆盖已完成状态 if req.ObjectiveTotalCount > 0 { correct := req.ObjectiveCorrectCount total := req.ObjectiveTotalCount if correct < 0 { correct = 0 } passRate := getObjectivePassRateFromCondition(task.Condition) // 默认 60 // 正确率 = 正确数/总题数,用整数比较:correct*100/total >= passRate qualified := total > 0 && (correct*100)/total >= passRate if existing != nil && existing.IsCompleted { // 已达标过:永不再改为未完成;仅当本次正确率更高时更新最佳 isCompleted = true bestCorrect, bestTotal := existing.ObjectiveBestCorrectCount, existing.ObjectiveBestTotalCount if bestTotal <= 0 { bestCorrect, bestTotal = correct, total } else if total > 0 && (correct*bestTotal > bestCorrect*total) { bestCorrect, bestTotal = correct, total } objectiveBestCorrectCount, objectiveBestTotalCount = bestCorrect, bestTotal } else if existing != nil { // 此前未达标 isCompleted = qualified bestCorrect, bestTotal := existing.ObjectiveBestCorrectCount, existing.ObjectiveBestTotalCount if bestTotal <= 0 { bestCorrect, bestTotal = correct, total } else if qualified && total > 0 && (correct*bestTotal > bestCorrect*total) { bestCorrect, bestTotal = correct, total } else if qualified { bestCorrect, bestTotal = correct, total } objectiveBestCorrectCount, objectiveBestTotalCount = bestCorrect, bestTotal } else { // 无历史进度 isCompleted = qualified objectiveBestCorrectCount, objectiveBestTotalCount = correct, total } } else { // 未传本次客观题数据(如 0 题提交):若已有达标记录则保持完成,否则按请求 if existing != nil && existing.IsCompleted { isCompleted = true } else { isCompleted = req.IsCompleted } if existing != nil { objectiveBestCorrectCount = existing.ObjectiveBestCorrectCount objectiveBestTotalCount = existing.ObjectiveBestTotalCount } } default: // 其他类型:直接使用 req.IsCompleted isCompleted = req.IsCompleted } // 合并逻辑:仅当请求未提供字段(nil / 空字符串)时才保留原值 // 注意:对于切片字段,区分 nil(字段未提供)和 [](显式清空) // JSON 反序列化:字段不存在 → nil,"review_images": [] → 非nil空切片 reviewComment := req.ReviewComment if reviewComment == "" && existing != nil { reviewComment = existing.ReviewComment } reviewImages := req.ReviewImages if reviewImages == nil && existing != nil { // 字段未提供,保留原值 reviewImages = existing.ReviewImages } // 如果 reviewImages 非 nil 但 len==0(显式传了空数组),则清空图片 answerImages := req.AnswerImages essayAnswerImages := req.EssayAnswerImages if len(essayMerged) > 0 { // 申论题:使用合并后的数据(支持暂存时与已有进度合并) essayAnswerImages = essayMerged var flat []string for _, imgs := range essayMerged { flat = append(flat, imgs...) } answerImages = flat } else if len(req.EssayAnswerImages) > 0 { // 兼容:未走 merge 时仍用请求数据 essayAnswerImages = req.EssayAnswerImages var flat []string for _, imgs := range req.EssayAnswerImages { flat = append(flat, imgs...) } answerImages = flat } else { if answerImages == nil && existing != nil { answerImages = existing.AnswerImages } if essayAnswerImages == nil && existing != nil { essayAnswerImages = existing.EssayAnswerImages } } campID := req.CampID if campID == "" && existing != nil { campID = existing.CampID } completedAt := req.CompletedAt if completedAt == "" && existing != nil { completedAt = existing.CompletedAt } progress := &camp.UserProgress{ ID: snowflake.GetInstance().NextIDString(), UserID: req.UserID, TaskID: req.TaskID, CampID: campID, IsCompleted: isCompleted, CompletedAt: completedAt, ReviewStatus: reviewStatus, // 使用处理后的 reviewStatus(不需要审核时自动设置为 approved) ReviewComment: reviewComment, ReviewImages: reviewImages, AnswerImages: answerImages, EssayAnswerImages: essayAnswerImages, ObjectiveBestCorrectCount: objectiveBestCorrectCount, ObjectiveBestTotalCount: objectiveBestTotalCount, } // 申论题:若请求带了每题审核状态,则写入 if task.TaskType == camp.TaskTypeEssay && len(req.EssayReviewStatuses) > 0 { progress.EssayReviewStatuses = normalizeEssayReviewStatuses(req.EssayReviewStatuses) } else if existing != nil && len(existing.EssayReviewStatuses) > 0 { progress.EssayReviewStatuses = existing.EssayReviewStatuses } // 客观题:写入前再次确认,若库中已是完成则绝不改为未完成(防并发或其它路径覆盖) if task.TaskType == camp.TaskTypeObjective && !progress.IsCompleted { recheck, _ := s.progressDAO.GetByUserAndTask(req.UserID, req.TaskID) if recheck != nil && recheck.IsCompleted { progress.IsCompleted = true if progress.CompletedAt == "" && recheck.CompletedAt != "" { progress.CompletedAt = recheck.CompletedAt } // 保留库中已有的最佳成绩,避免被本次未达标数据覆盖 if recheck.ObjectiveBestTotalCount > 0 { progress.ObjectiveBestCorrectCount = recheck.ObjectiveBestCorrectCount progress.ObjectiveBestTotalCount = recheck.ObjectiveBestTotalCount } } } if err = s.progressDAO.Update(progress); err != nil { return &camp.UpdateUserProgressResponse{ Success: false, Message: fmt.Sprintf("更新用户进度失败: %v", err), }, nil } if isCompleted && task.CampID != "" && task.SectionID != "" && s.campService != nil { s.campService.TryAutoOpenNextSectionIfEligible(req.UserID, task.CampID, task.SectionID) } return &camp.UpdateUserProgressResponse{ Success: true, Message: "更新用户进度成功", }, nil } // GetUserProgress 获取用户进度 func (s *ProgressService) GetUserProgress(userID, taskID string) (*camp.GetUserProgressResponse, error) { progress, err := s.progressDAO.GetByUserAndTask(userID, taskID) if err != nil { return &camp.GetUserProgressResponse{ Success: false, Message: fmt.Sprintf("获取用户进度失败: %v", err), }, nil } // 如果没有进度记录,返回 success=false 但不报错,这是正常情况 if progress == nil { return &camp.GetUserProgressResponse{ Progress: nil, Success: false, Message: "暂无进度", }, nil } return &camp.GetUserProgressResponse{ Progress: progress, Success: true, Message: "获取用户进度成功", }, nil } // ListUserProgress 列出用户进度 func (s *ProgressService) ListUserProgress(req *camp.ListUserProgressRequest) (*camp.ListUserProgressResponse, error) { // 设置默认值 if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = 10 } if req.PageSize > 100 { req.PageSize = 100 } reviewStatus := strings.TrimSpace(req.ReviewStatus) userKeyword := strings.TrimSpace(req.UserKeyword) // 当指定了 camp_id 时:按「已加入该营的用户」分页,展示所有开启过该营的用户(含无进度的) if req.CampID != "" && s.userCampDAO != nil { userIDs, total, err := s.userCampDAO.ListUserIDsByCamp(req.CampID, userKeyword, req.Page, req.PageSize) if err != nil { return &camp.ListUserProgressResponse{ Success: false, Message: fmt.Sprintf("获取打卡营用户列表失败: %v", err), }, nil } var progressList []*camp.UserProgress if len(userIDs) > 0 { progressList, err = s.progressDAO.ListByUserIDsAndCamp(userIDs, req.CampID, req.SectionID, req.TaskID, reviewStatus) if err != nil { return &camp.ListUserProgressResponse{ Success: false, Message: fmt.Sprintf("获取用户进度列表失败: %v", err), }, nil } } return &camp.ListUserProgressResponse{ ProgressList: progressList, UserIDs: userIDs, Total: total, Success: true, Message: "获取用户进度列表成功", }, nil } // 未指定 camp_id 或无 userCampDAO:沿用原逻辑(按进度记录分页) progressList, total, err := s.progressDAO.List(req.UserID, userKeyword, req.TaskID, req.SectionID, req.CampID, reviewStatus, req.Page, req.PageSize) if err != nil { return &camp.ListUserProgressResponse{ Success: false, Message: fmt.Sprintf("获取用户进度列表失败: %v", err), }, nil } return &camp.ListUserProgressResponse{ ProgressList: progressList, Total: total, Success: true, Message: "获取用户进度列表成功", }, nil } // ResetTaskProgress 重新答题(删除指定任务下的用户进度记录和答题记录) // 客观题:只清空答题记录,不删除进度,完成状态永久保留 func (s *ProgressService) ResetTaskProgress(userID, taskID string) (*camp.ResetTaskProgressResponse, error) { if userID == "" || taskID == "" { return &camp.ResetTaskProgressResponse{ Success: false, Message: "参数缺失:user_id 和 task_id 不能为空", }, nil } // 获取任务信息,用于判断任务类型和获取试卷ID task, err := s.taskDAO.GetByID(taskID) if err != nil { return &camp.ResetTaskProgressResponse{ Success: false, Message: fmt.Sprintf("获取任务信息失败: %v", err), }, nil } // 客观题:不删除进度记录,只清空答题记录,便于再次作答且不影响完成状态 if task.TaskType == camp.TaskTypeObjective { paperID := ExtractPaperIDFromTaskContent(task.Content) if paperID != "" && s.answerRecordService != nil { deletedCount, err := s.answerRecordService.DeleteAnswerRecordByUserAndPaper(userID, paperID, taskID) if err != nil { fmt.Printf("[ResetTaskProgress] 客观题删除答题记录失败 user_id=%s paper_id=%s err=%v\n", userID, paperID, err) } else { fmt.Printf("[ResetTaskProgress] 客观题已删除 %d 条答题记录 user_id=%s paper_id=%s(进度保留)\n", deletedCount, userID, paperID) } } return &camp.ResetTaskProgressResponse{ Success: true, Message: "已清空答题记录,可重新作答;完成状态已保留", }, nil } // 非客观题:删除用户任务进度记录 err = s.progressDAO.DeleteByUserAndTask(userID, taskID) if err != nil { return &camp.ResetTaskProgressResponse{ Success: false, Message: fmt.Sprintf("删除用户进度失败: %v", err), }, nil } return &camp.ResetTaskProgressResponse{ Success: true, Message: "重新答题成功,进度记录和答题记录已删除", }, nil } // getObjectivePassRateFromCondition 从任务 condition 中解析客观题达标正确率(百分比),默认 60 func getObjectivePassRateFromCondition(condition json.RawMessage) int { if len(condition) == 0 { return 60 } var raw map[string]any if err := json.Unmarshal(condition, &raw); err != nil { return 60 } obj, ok := raw["objective"].(map[string]any) if !ok { return 60 } switch v := obj["pass_rate"].(type) { case float64: if v >= 0 && v <= 100 { return int(v) } case int: if v >= 0 && v <= 100 { return v } } return 60 } // computeEssayOverallReviewStatus 根据申论题每题审核状态计算任务整体状态:所有题目通过才是通过,有一道题未审核/待审核/驳回则最终为驳回 func computeEssayOverallReviewStatus(perQuestion []string) camp.ReviewStatus { if len(perQuestion) == 0 { return camp.ReviewStatusRejected } for _, s := range perQuestion { upper := strings.ToUpper(strings.TrimSpace(s)) if upper != "APPROVED" { return camp.ReviewStatusRejected } } return camp.ReviewStatusApproved } // normalizeEssayReviewStatuses 将前端传来的 pending/approved/rejected 规范为大写 PENDING/APPROVED/REJECTED 存储 func normalizeEssayReviewStatuses(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { upper := strings.ToUpper(strings.TrimSpace(s)) switch upper { case "APPROVED", "REJECTED": out = append(out, upper) default: out = append(out, "PENDING") } } return out }