512 lines
17 KiB
Go
512 lines
17 KiB
Go
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
|
||
}
|
||
|
||
|