918 lines
30 KiB
Go
918 lines
30 KiB
Go
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
|
||
}
|
||
|