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

512 lines
17 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 (
"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
}