package dao import ( "database/sql" "encoding/json" "fmt" "strconv" "strings" "time" "dd_fiber_api/internal/camp" "dd_fiber_api/pkg/database" "dd_fiber_api/pkg/utils" "github.com/didi/gendry/builder" ) // ProgressDAO 用户进度数据访问对象 type ProgressDAO struct { client *database.MySQLClient } // NewProgressDAO 创建进度DAO实例 func NewProgressDAO(client *database.MySQLClient) *ProgressDAO { return &ProgressDAO{ client: client, } } // marshalAnswerImages 将进度答案序列化为 answer_images JSON:申论题为 [][]string,否则 []string func marshalAnswerImages(progress *camp.UserProgress) ([]byte, error) { if len(progress.EssayAnswerImages) > 0 { return json.Marshal(progress.EssayAnswerImages) } return json.Marshal(progress.AnswerImages) } // unmarshalAnswerImages 从 answer_images JSON 反序列化:若为二维数组则填 EssayAnswerImages,否则填 AnswerImages func unmarshalAnswerImages(raw string, progress *camp.UserProgress) { if raw == "" { return } // 先尝试按申论格式 [["url"],["url"]] 解析 var essay [][]string if err := json.Unmarshal([]byte(raw), &essay); err == nil && len(essay) > 0 { progress.EssayAnswerImages = essay return } // 再按扁平格式 ["url","url"] 解析 var flat []string if err := json.Unmarshal([]byte(raw), &flat); err == nil { for _, img := range flat { if strings.TrimSpace(img) != "" { progress.AnswerImages = append(progress.AnswerImages, img) } } } } // marshalEssayReviewStatuses 将申论每题审核状态序列化为 JSON 数组字符串 func marshalEssayReviewStatuses(st []string) (string, error) { if len(st) == 0 { return "", nil } b, err := json.Marshal(st) if err != nil { return "", err } return string(b), nil } // unmarshalEssayReviewStatuses 从 essay_review_statuses JSON 反序列化 func unmarshalEssayReviewStatuses(raw string) []string { if raw == "" { return nil } var st []string if err := json.Unmarshal([]byte(raw), &st); err != nil { return nil } return st } // Create 创建用户进度 func (d *ProgressDAO) Create(progress *camp.UserProgress) error { table := "camp_user_progress" // 通过 task_id 查询 camp_id、section_id campID, sectionID, needReview, err := d.getTaskInfo(progress.TaskID) if err != nil { return fmt.Errorf("获取任务信息失败: %v", err) } progress.CampID = campID progress.NeedReview = needReview // 序列化审核图片数组 reviewImagesJSON, err := json.Marshal(progress.ReviewImages) if err != nil { return fmt.Errorf("序列化审核图片失败: %v", err) } answerImagesJSON, err := marshalAnswerImages(progress) if err != nil { return fmt.Errorf("序列化答案图片失败: %v", err) } essayReviewStatusesJSON, _ := marshalEssayReviewStatuses(progress.EssayReviewStatuses) data := []map[string]any{ { "id": progress.ID, "user_id": progress.UserID, "task_id": progress.TaskID, "camp_id": campID, "section_id": sectionID, "is_completed": progress.IsCompleted, "completed_at": normalizeCompletedAt(progress.CompletedAt), "review_status": convertReviewStatus(progress.ReviewStatus), "review_comment": progress.ReviewComment, "review_images": string(reviewImagesJSON), "answer_images": string(answerImagesJSON), "essay_review_statuses": essayReviewStatusesJSON, "objective_best_correct_count": progress.ObjectiveBestCorrectCount, "objective_best_total_count": progress.ObjectiveBestTotalCount, }, } cond, vals, err := builder.BuildInsert(table, data) if err != nil { return fmt.Errorf("构建插入语句失败: %v", err) } _, err = d.client.DB.Exec(cond, vals...) if err != nil { return fmt.Errorf("创建用户进度失败: %v", err) } return nil } // Update 更新用户进度(使用 UPSERT 逻辑) func (d *ProgressDAO) Update(progress *camp.UserProgress) error { table := "camp_user_progress" // 先检查是否存在 checkWhere := map[string]any{ "user_id": progress.UserID, "task_id": progress.TaskID, } checkCond, checkVals, err := builder.BuildSelect(table, checkWhere, []string{"id"}) if err != nil { return fmt.Errorf("构建查询语句失败: %v", err) } var existingID string checkErr := d.client.DB.QueryRow(checkCond, checkVals...).Scan(&existingID) // 通过 task_id 查询 camp_id、section_id campID, sectionID, needReview, err := d.getTaskInfo(progress.TaskID) if err != nil { return fmt.Errorf("获取任务信息失败: %v", err) } progress.CampID = campID progress.NeedReview = needReview // 序列化审核图片数组 reviewImagesJSON, err := json.Marshal(progress.ReviewImages) if err != nil { return fmt.Errorf("序列化审核图片失败: %v", err) } answerImagesJSON, err := marshalAnswerImages(progress) if err != nil { return fmt.Errorf("序列化答案图片失败: %v", err) } essayReviewStatusesJSON, _ := marshalEssayReviewStatuses(progress.EssayReviewStatuses) if checkErr == sql.ErrNoRows { // 不存在,执行插入 return d.Create(progress) } else if checkErr != nil { // 查询出错 return fmt.Errorf("检查用户进度是否存在失败: %v", checkErr) } else { // 已存在,执行更新 where := map[string]any{ "user_id": progress.UserID, "task_id": progress.TaskID, } data := map[string]any{ "is_completed": progress.IsCompleted, "completed_at": normalizeCompletedAt(progress.CompletedAt), "review_status": convertReviewStatus(progress.ReviewStatus), "review_comment": progress.ReviewComment, "review_images": string(reviewImagesJSON), "answer_images": string(answerImagesJSON), "essay_review_statuses": essayReviewStatusesJSON, "camp_id": campID, "section_id": sectionID, "objective_best_correct_count": progress.ObjectiveBestCorrectCount, "objective_best_total_count": progress.ObjectiveBestTotalCount, } cond, vals, err := builder.BuildUpdate(table, where, data) if err != nil { return fmt.Errorf("构建更新语句失败: %v", err) } _, err = d.client.DB.Exec(cond, vals...) if err != nil { return fmt.Errorf("更新用户进度失败: %v", err) } } return nil } // GetByUserAndTask 根据用户ID和任务ID获取进度 func (d *ProgressDAO) GetByUserAndTask(userID, taskID string) (*camp.UserProgress, error) { table := "camp_user_progress" where := map[string]any{ "user_id": userID, "task_id": taskID, } selectFields := []string{"id", "user_id", "task_id", "camp_id", "is_completed", "completed_at", "review_status", "review_comment", "review_images", "answer_images", "essay_review_statuses", "objective_best_correct_count", "objective_best_total_count"} cond, vals, err := builder.BuildSelect(table, where, selectFields) if err != nil { return nil, fmt.Errorf("构建查询失败: %v", err) } var ( id string userIDResult string taskIDResult string campIDResult string isCompleted bool completedAtTs sql.NullTime reviewStatusStr string reviewComment sql.NullString reviewImagesJSON sql.NullString answerImagesJSON sql.NullString essayReviewStatusesJSON sql.NullString objectiveBestCorrectCount sql.NullInt64 objectiveBestTotalCount sql.NullInt64 ) err = d.client.DB.QueryRow(cond, vals...).Scan( &id, &userIDResult, &taskIDResult, &campIDResult, &isCompleted, &completedAtTs, &reviewStatusStr, &reviewComment, &reviewImagesJSON, &answerImagesJSON, &essayReviewStatusesJSON, &objectiveBestCorrectCount, &objectiveBestTotalCount, ) if err == sql.ErrNoRows { // 没有进度记录是正常情况,返回 nil, nil 而不是错误 return nil, nil } if err != nil { return nil, fmt.Errorf("查询用户进度失败: %v", err) } progress := &camp.UserProgress{ ID: id, UserID: userIDResult, TaskID: taskIDResult, CampID: campIDResult, IsCompleted: isCompleted, CompletedAt: utils.FormatNullTimeToStd(completedAtTs), ReviewStatus: parseReviewStatus(reviewStatusStr), ReviewComment: reviewComment.String, ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), } // 反序列化审核图片数组(过滤空字符串) if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { var reviewImages []string if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { filtered := make([]string, 0, len(reviewImages)) for _, img := range reviewImages { if strings.TrimSpace(img) != "" { filtered = append(filtered, img) } } progress.ReviewImages = filtered } } unmarshalAnswerImages(answerImagesJSON.String, progress) if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) } if _, _, needReview, err := d.getTaskInfo(taskIDResult); err == nil { progress.NeedReview = needReview } return progress, nil } // List 列出用户进度(支持按用户ID、用户关键词、任务ID筛选) // userKeyword 同时支持:用户ID 模糊匹配(user_id LIKE)、手机号匹配(从 users 表解析 phone/mobile 再筛进度) func (d *ProgressDAO) List(userID, userKeyword, taskID, sectionID, campID, reviewStatus string, page, pageSize int) ([]*camp.UserProgress, int, error) { table := "camp_user_progress" // 构建查询条件 where := map[string]any{} if userKeyword != "" { // 用户关键词:尝试从 users 表按手机号解析出 user_id 列表(表不存在则忽略) phoneUserIDs, _ := d.findUserIDsByPhoneKeyword(userKeyword) if len(phoneUserIDs) > 0 { return d.listWithUserKeywordOrPhone(table, userKeyword, phoneUserIDs, taskID, sectionID, campID, reviewStatus, page, pageSize) } where["user_id like"] = "%" + userKeyword + "%" } else if userID != "" { where["user_id"] = userID } if taskID != "" { where["task_id"] = taskID } if sectionID != "" { where["section_id"] = sectionID } if campID != "" { where["camp_id"] = campID } if reviewStatus != "" { where["review_status"] = reviewStatus } // 查询总数 countFields := []string{"COUNT(*) as total"} countCond, countVals, err := builder.BuildSelect(table, where, countFields) if err != nil { return nil, 0, fmt.Errorf("构建统计查询失败: %v", err) } var total int err = d.client.DB.QueryRow(countCond, countVals...).Scan(&total) if err != nil { return nil, 0, fmt.Errorf("查询用户进度总数失败: %v", err) } // 查询数据(含 section_id 供管理端展示所属小节) selectFields := []string{"id", "user_id", "task_id", "camp_id", "section_id", "is_completed", "completed_at", "review_status", "review_comment", "review_images", "answer_images", "essay_review_statuses", "objective_best_correct_count", "objective_best_total_count"} cond, vals, err := builder.BuildSelect(table, where, selectFields) if err != nil { return nil, 0, fmt.Errorf("构建查询失败: %v", err) } // 按时间顺序排序(完成时间或创建时间倒序) offset := (page - 1) * pageSize cond += " ORDER BY COALESCE(completed_at, created_at) DESC LIMIT ? OFFSET ?" vals = append(vals, pageSize, offset) rows, err := d.client.DB.Query(cond, vals...) if err != nil { return nil, 0, fmt.Errorf("查询用户进度列表失败: %v", err) } defer rows.Close() progressList := make([]*camp.UserProgress, 0) taskNeedReviewCache := make(map[string]bool) taskIDs := make(map[string]struct{}) for rows.Next() { var ( id string userIDResult string taskIDResult string campIDResult string sectionIDResult string isCompleted bool completedAtTs sql.NullTime reviewStatusStr string reviewComment sql.NullString reviewImagesJSON sql.NullString answerImagesJSON sql.NullString essayReviewStatusesJSON sql.NullString objectiveBestCorrectCount sql.NullInt64 objectiveBestTotalCount sql.NullInt64 ) err := rows.Scan( &id, &userIDResult, &taskIDResult, &campIDResult, §ionIDResult, &isCompleted, &completedAtTs, &reviewStatusStr, &reviewComment, &reviewImagesJSON, &answerImagesJSON, &essayReviewStatusesJSON, &objectiveBestCorrectCount, &objectiveBestTotalCount, ) if err != nil { continue } progress := &camp.UserProgress{ ID: id, UserID: userIDResult, TaskID: taskIDResult, CampID: campIDResult, SectionID: sectionIDResult, IsCompleted: isCompleted, CompletedAt: utils.FormatNullTimeToStd(completedAtTs), ReviewStatus: parseReviewStatus(reviewStatusStr), ReviewComment: reviewComment.String, ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), } taskIDs[taskIDResult] = struct{}{} // 反序列化审核图片数组(过滤空字符串) if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { var reviewImages []string if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { filtered := make([]string, 0, len(reviewImages)) for _, img := range reviewImages { if strings.TrimSpace(img) != "" { filtered = append(filtered, img) } } progress.ReviewImages = filtered } } unmarshalAnswerImages(answerImagesJSON.String, progress) if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) } progressList = append(progressList, progress) } if err = rows.Err(); err != nil { return nil, 0, fmt.Errorf("遍历用户进度数据失败: %v", err) } // 批量查询任务是否需要审核 for taskID := range taskIDs { _, _, needReview, err := d.getTaskInfo(taskID) if err == nil { taskNeedReviewCache[taskID] = needReview } } for _, progress := range progressList { if val, ok := taskNeedReviewCache[progress.TaskID]; ok { progress.NeedReview = val } } return progressList, total, nil } // ListByUserIDsAndCamp 按用户 ID 列表与打卡营查询进度(不分页,用于管理端矩阵:一页用户的所有进度) func (d *ProgressDAO) ListByUserIDsAndCamp(userIDs []string, campID, sectionID, taskID, reviewStatus string) ([]*camp.UserProgress, error) { if len(userIDs) == 0 { return nil, nil } table := "camp_user_progress" placeholders := strings.Repeat("?,", len(userIDs)) placeholders = placeholders[:len(placeholders)-1] query := `SELECT id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count FROM ` + table + ` WHERE user_id IN (` + placeholders + `) AND camp_id = ?` args := make([]any, 0, len(userIDs)+4) for _, u := range userIDs { args = append(args, u) } args = append(args, campID) if sectionID != "" { query += ` AND section_id = ?` args = append(args, sectionID) } if taskID != "" { query += ` AND task_id = ?` args = append(args, taskID) } if reviewStatus != "" { query += ` AND review_status = ?` args = append(args, reviewStatus) } query += ` ORDER BY section_id, task_id` rows, err := d.client.DB.Query(query, args...) if err != nil { return nil, fmt.Errorf("查询进度失败: %v", err) } defer rows.Close() progressList, taskNeedReviewCache, _, err := d.scanProgressRows(rows) if err != nil { return nil, err } for _, progress := range progressList { if val, ok := taskNeedReviewCache[progress.TaskID]; ok { progress.NeedReview = val } } return progressList, nil } // findUserIDsByPhoneKeyword 根据手机号关键词从 users 表查询 user_id 列表(表需含 id、phone 或 mobile 列) // 若 users 表不存在或查询失败,返回 nil, nil,调用方仅用 user_id LIKE 即可 func (d *ProgressDAO) findUserIDsByPhoneKeyword(keyword string) ([]string, error) { keyword = strings.TrimSpace(keyword) if keyword == "" { return nil, nil } // 兼容表名为 users,列名为 phone 或 mobile(常见 C 端用户表) query := `SELECT id FROM users WHERE (phone LIKE ? OR mobile LIKE ?) LIMIT 500` rows, err := d.client.DB.Query(query, "%"+keyword+"%", "%"+keyword+"%") if err != nil { return nil, nil // 表不存在或列名不同时不报错,让上层只用 user_id LIKE } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { continue } if id != "" { ids = append(ids, id) } } return ids, nil } // listWithUserKeywordOrPhone 使用 (user_id LIKE ? OR user_id IN (...)) 条件查询进度列表与总数 func (d *ProgressDAO) listWithUserKeywordOrPhone(table, userKeyword string, phoneUserIDs []string, taskID, sectionID, campID, reviewStatus string, page, pageSize int) ([]*camp.UserProgress, int, error) { inPlaceholders := strings.Repeat("?,", len(phoneUserIDs)) if len(inPlaceholders) > 0 { inPlaceholders = inPlaceholders[:len(inPlaceholders)-1] } userCond := "(user_id LIKE ? OR user_id IN (" + inPlaceholders + "))" var conditions []string var vals []interface{} vals = append(vals, "%"+userKeyword+"%") for _, id := range phoneUserIDs { vals = append(vals, id) } conditions = append(conditions, userCond) if taskID != "" { conditions = append(conditions, "task_id = ?") vals = append(vals, taskID) } if sectionID != "" { conditions = append(conditions, "section_id = ?") vals = append(vals, sectionID) } if campID != "" { conditions = append(conditions, "camp_id = ?") vals = append(vals, campID) } if reviewStatus != "" { conditions = append(conditions, "review_status = ?") vals = append(vals, reviewStatus) } whereSQL := strings.Join(conditions, " AND ") countQuery := "SELECT COUNT(*) as total FROM " + table + " WHERE " + whereSQL var total int if err := d.client.DB.QueryRow(countQuery, vals...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("查询用户进度总数失败: %v", err) } selectFields := "id, user_id, task_id, camp_id, section_id, is_completed, completed_at, review_status, review_comment, review_images, answer_images, essay_review_statuses, objective_best_correct_count, objective_best_total_count" offset := (page - 1) * pageSize listQuery := "SELECT " + selectFields + " FROM " + table + " WHERE " + whereSQL + " ORDER BY COALESCE(completed_at, created_at) DESC LIMIT ? OFFSET ?" listVals := append(vals, pageSize, offset) rows, err := d.client.DB.Query(listQuery, listVals...) if err != nil { return nil, 0, fmt.Errorf("查询用户进度列表失败: %v", err) } defer rows.Close() progressList, taskNeedReviewCache, taskIDs, err := d.scanProgressRows(rows) if err != nil { return nil, 0, err } for taskID := range taskIDs { _, _, needReview, err := d.getTaskInfo(taskID) if err != nil { continue } taskNeedReviewCache[taskID] = needReview } for _, progress := range progressList { if val, ok := taskNeedReviewCache[progress.TaskID]; ok { progress.NeedReview = val } } return progressList, total, nil } // scanProgressRows 将 progress 查询结果扫描为列表,并返回 taskNeedReviewCache、taskIDs func (d *ProgressDAO) scanProgressRows(rows *sql.Rows) ([]*camp.UserProgress, map[string]bool, map[string]struct{}, error) { progressList := make([]*camp.UserProgress, 0) taskNeedReviewCache := make(map[string]bool) taskIDs := make(map[string]struct{}) for rows.Next() { var ( id string userIDResult string taskIDResult string campIDResult string sectionIDResult string isCompleted bool completedAtTs sql.NullTime reviewStatusStr string reviewComment sql.NullString reviewImagesJSON sql.NullString answerImagesJSON sql.NullString essayReviewStatusesJSON sql.NullString objectiveBestCorrectCount sql.NullInt64 objectiveBestTotalCount sql.NullInt64 ) err := rows.Scan( &id, &userIDResult, &taskIDResult, &campIDResult, §ionIDResult, &isCompleted, &completedAtTs, &reviewStatusStr, &reviewComment, &reviewImagesJSON, &answerImagesJSON, &essayReviewStatusesJSON, &objectiveBestCorrectCount, &objectiveBestTotalCount, ) if err != nil { continue } progress := &camp.UserProgress{ ID: id, UserID: userIDResult, TaskID: taskIDResult, CampID: campIDResult, SectionID: sectionIDResult, IsCompleted: isCompleted, CompletedAt: utils.FormatNullTimeToStd(completedAtTs), ReviewStatus: parseReviewStatus(reviewStatusStr), ReviewComment: reviewComment.String, ObjectiveBestCorrectCount: int(objectiveBestCorrectCount.Int64), ObjectiveBestTotalCount: int(objectiveBestTotalCount.Int64), } taskIDs[taskIDResult] = struct{}{} if reviewImagesJSON.Valid && reviewImagesJSON.String != "" { var reviewImages []string if err := json.Unmarshal([]byte(reviewImagesJSON.String), &reviewImages); err == nil { filtered := make([]string, 0, len(reviewImages)) for _, img := range reviewImages { if strings.TrimSpace(img) != "" { filtered = append(filtered, img) } } progress.ReviewImages = filtered } } unmarshalAnswerImages(answerImagesJSON.String, progress) if essayReviewStatusesJSON.Valid && essayReviewStatusesJSON.String != "" { progress.EssayReviewStatuses = unmarshalEssayReviewStatuses(essayReviewStatusesJSON.String) } progressList = append(progressList, progress) } if err := rows.Err(); err != nil { return nil, nil, nil, fmt.Errorf("遍历用户进度数据失败: %v", err) } return progressList, taskNeedReviewCache, taskIDs, nil } // DeleteByUserAndCamp 删除用户在某打卡营下的所有进度 func (d *ProgressDAO) DeleteByUserAndCamp(userID, campID string) (int64, error) { query := `DELETE FROM camp_user_progress WHERE user_id = ? AND camp_id = ?` res, err := d.client.DB.Exec(query, userID, campID) if err != nil { return 0, fmt.Errorf("删除用户营内进度失败: %v", err) } rows, _ := res.RowsAffected() return rows, nil } // DeleteByUserAndTask 删除用户在某任务下的进度记录 func (d *ProgressDAO) DeleteByUserAndTask(userID, taskID string) error { query := `DELETE FROM camp_user_progress WHERE user_id = ? AND task_id = ?` _, err := d.client.DB.Exec(query, userID, taskID) if err != nil { return fmt.Errorf("删除用户任务进度失败: %v", err) } return nil } // HasProgressForTask 判断任务是否已有用户进度 func (d *ProgressDAO) HasProgressForTask(taskID string) (bool, error) { query := "SELECT 1 FROM camp_user_progress WHERE task_id = ? LIMIT 1" var dummy int err := d.client.DB.QueryRow(query, taskID).Scan(&dummy) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, fmt.Errorf("查询任务进度失败: %v", err) } return true, nil } // HasProgressForSection 判断小节是否已有用户进度 func (d *ProgressDAO) HasProgressForSection(sectionID string) (bool, error) { query := "SELECT 1 FROM camp_user_progress WHERE section_id = ? LIMIT 1" var dummy int err := d.client.DB.QueryRow(query, sectionID).Scan(&dummy) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, fmt.Errorf("查询小节进度失败: %v", err) } return true, nil } // HasProgressForCamp 判断打卡营是否已有用户进度 func (d *ProgressDAO) HasProgressForCamp(campID string) (bool, error) { query := `SELECT 1 FROM camp_user_progress WHERE camp_id = ? LIMIT 1` var dummy int err := d.client.DB.QueryRow(query, campID).Scan(&dummy) if err == sql.ErrNoRows { return false, nil } if err != nil { return false, fmt.Errorf("查询打卡营进度失败: %v", err) } return true, nil } // CountCompletedBySection 统计用户在小节下已完成的任务数 func (d *ProgressDAO) CountCompletedBySection(userID, sectionID string) (int, error) { query := `SELECT COUNT(*) FROM camp_user_progress WHERE user_id = ? AND section_id = ? AND is_completed = 1` var count int err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&count) if err != nil { return 0, fmt.Errorf("统计用户小节完成任务数失败: %v", err) } return count, nil } // GetUserSectionCompletedAt 获取用户在某小节「全部任务完成」的时间(该小节下已完成任务的 completed_at 最大值) // 用于时间间隔解锁的起点:间隔从「上一小节完成时刻」开始计算 func (d *ProgressDAO) GetUserSectionCompletedAt(userID, sectionID string) (*time.Time, error) { query := `SELECT MAX(completed_at) FROM camp_user_progress WHERE user_id = ? AND section_id = ? AND is_completed = 1 AND completed_at IS NOT NULL` var completedAt sql.NullTime err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&completedAt) if err != nil { return nil, fmt.Errorf("获取小节完成时间失败: %v", err) } if !completedAt.Valid { return nil, nil } t := completedAt.Time return &t, nil } // GetUserSectionStartedAt 获取用户在某小节「首次产生进度」的时间(该小节下任意进度记录的 created_at 最小值) // 用于时间间隔解锁的起点:间隔从「上一小节开启时」开始计算(用户第一次进入/开始该小节的时间) func (d *ProgressDAO) GetUserSectionStartedAt(userID, sectionID string) (*time.Time, error) { query := `SELECT MIN(created_at) FROM camp_user_progress WHERE user_id = ? AND section_id = ?` var createdAt sql.NullTime err := d.client.DB.QueryRow(query, userID, sectionID).Scan(&createdAt) if err != nil { return nil, fmt.Errorf("获取小节开启时间失败: %v", err) } if !createdAt.Valid { return nil, nil } t := createdAt.Time return &t, nil } // CountTasksAndCompletedByCamp 统计打卡营下的总任务数和用户已完成的任务数 // 用于轻量计算打卡营整体完成状态,避免加载全部小节/任务数据 func (d *ProgressDAO) CountTasksAndCompletedByCamp(userID, campID string) (totalTasks int, completedTasks int, err error) { // 1. 查询打卡营下的总任务数(关联 camp_sections 确保只统计未删除的小节下的任务) totalQuery := `SELECT COUNT(*) FROM camp_tasks t INNER JOIN camp_sections s ON t.section_id = s.id AND (s.deleted_at IS NULL OR s.deleted_at = '0001-01-01 00:00:00') WHERE t.camp_id = ? AND (t.deleted_at IS NULL OR t.deleted_at = '0001-01-01 00:00:00')` err = d.client.DB.QueryRow(totalQuery, campID).Scan(&totalTasks) if err != nil { return 0, 0, fmt.Errorf("统计打卡营总任务数失败: %v", err) } // 2. 查询用户在该打卡营下已完成的任务数 completedQuery := `SELECT COUNT(*) FROM camp_user_progress WHERE user_id = ? AND camp_id = ? AND is_completed = 1` err = d.client.DB.QueryRow(completedQuery, userID, campID).Scan(&completedTasks) if err != nil { return totalTasks, 0, fmt.Errorf("统计用户已完成任务数失败: %v", err) } return totalTasks, completedTasks, nil } // ========== 辅助函数 ========== // convertReviewStatus 将 ReviewStatus 转换为数据库 ENUM 字符串 func convertReviewStatus(status camp.ReviewStatus) string { switch status { case camp.ReviewStatusApproved: return "APPROVED" case camp.ReviewStatusRejected: return "REJECTED" default: return "PENDING" } } // parseReviewStatus 将数据库 ENUM 字符串转换为 ReviewStatus func parseReviewStatus(statusStr string) camp.ReviewStatus { switch statusStr { case "APPROVED": return camp.ReviewStatusApproved case "REJECTED": return camp.ReviewStatusRejected default: return camp.ReviewStatusPending } } // getTaskInfo 通过任务ID获取打卡营ID、小节ID与审核标记 func (d *ProgressDAO) getTaskInfo(taskID string) (string, string, bool, error) { var ( campID string sectionID string taskTypeStr string conditionJSON sql.NullString ) query := "SELECT camp_id, section_id, task_type, `condition` FROM camp_tasks WHERE id = ? AND deleted_at IS NULL" err := d.client.DB.QueryRow(query, taskID).Scan(&campID, §ionID, &taskTypeStr, &conditionJSON) if err == sql.ErrNoRows { return "", "", false, fmt.Errorf("任务不存在: %s", taskID) } if err != nil { return "", "", false, err } needReview := false if conditionJSON.Valid && conditionJSON.String != "" { var raw map[string]any if err := json.Unmarshal([]byte(conditionJSON.String), &raw); err == nil { if val, ok := raw["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } else if subjectiveRaw, ok := raw["subjective"].(map[string]any); ok { if val, ok := subjectiveRaw["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } } else if essayRaw, ok := raw["essay"].(map[string]any); ok { if val, ok := essayRaw["need_review"]; ok { if boolVal, ok := val.(bool); ok { needReview = boolVal } } } } } return campID, sectionID, needReview, nil } // normalizeCompletedAt 将 completed_at 字符串统一转换为数据库可接受的时间值 func normalizeCompletedAt(s string) any { if s == "" { return nil } // 尝试 Unix 秒 if sec, err := strconv.ParseInt(s, 10, 64); err == nil { return time.Unix(sec, 0).In(time.Local).Format("2006-01-02 15:04:05") } // 尝试 RFC3339 / RFC3339Nano if t, err := time.Parse(time.RFC3339, s); err == nil { return t.In(time.Local).Format("2006-01-02 15:04:05") } if t, err := time.Parse(time.RFC3339Nano, s); err == nil { return t.In(time.Local).Format("2006-01-02 15:04:05") } // 尝试已是标准格式 if _, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { return s } // 无法解析,回退为 NULL return nil }