package taskcenter import ( "context" "errors" "net/http" "sort" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) // GetConfig 返回后台任务配置。 func (s *Service) GetConfig(ctx context.Context, sysOrigin string, taskCategory string) (*ConfigResponse, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) category := normalizeCategory(taskCategory) if err := validateCategory(category); err != nil { return nil, err } var configRow model.TaskCenterConfig err := s.db.WithContext(ctx). Where("sys_origin = ?", sysOrigin). First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &ConfigResponse{ Configured: false, SysOrigin: sysOrigin, Enabled: false, Timezone: s.defaultTimezone(), TaskCategory: category, Tasks: []TaskConfigPayload{}, ConditionJumps: []ConditionJumpConfigPayload{}, }, nil } if err != nil { return nil, err } tasks, err := s.loadTaskConfigs(ctx, configRow.ID, category, false) if err != nil { return nil, err } conditionJumps, err := s.loadConditionJumpConfigs(ctx, configRow.ID, category) if err != nil { return nil, err } return &ConfigResponse{ Configured: true, ID: configRow.ID, SysOrigin: configRow.SysOrigin, Enabled: configRow.Enabled, Timezone: normalizeTimezone(configRow.Timezone, s.defaultTimezone()), TaskCategory: category, UpdateTime: formatDateTime(configRow.UpdateTime), Tasks: buildTaskConfigPayloads(tasks), ConditionJumps: buildConditionJumpConfigPayloads(conditionJumps, tasks), }, nil } // SaveConfig 保存后台任务配置。后台每日任务页支持 DAILY 和一次性的 NEWBIE 专属任务。 func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) { requestID := req.ID.Int64() sysOrigin := s.normalizeSysOrigin(req.SysOrigin) category := normalizeCategory(req.TaskCategory) if err := validateCategory(category); err != nil { return nil, err } timezone := normalizeTimezone(req.Timezone, s.defaultTimezone()) if _, err := time.LoadLocation(timezone); err != nil { return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) } tasks, err := s.normalizeTaskInputs(req.Tasks, category) if err != nil { return nil, err } conditionJumps, saveConditionJumps, err := s.normalizeConditionJumpInputs(req.ConditionJumps, category) if err != nil { return nil, err } replaceConditionJumps := saveConditionJumps if !saveConditionJumps { conditionJumps = buildConditionJumpConfigRowsFromTasks(tasks) saveConditionJumps = len(conditionJumps) > 0 } if saveConditionJumps { applyConditionJumpsToTasks(tasks, conditionJumps) } if req.Enabled && !hasEnabledTask(tasks) { return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "enabled config must contain at least one enabled task") } var savedConfigID int64 if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() var configRow model.TaskCenterConfig if requestID > 0 { err := tx.Where("id = ?", requestID).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "task_center_config_not_found", "config not found") } if err != nil { return err } if strings.TrimSpace(req.SysOrigin) == "" { sysOrigin = configRow.SysOrigin } } else { err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { configID, idErr := utils.NextID() if idErr != nil { return idErr } configRow = model.TaskCenterConfig{ ID: configID, SysOrigin: sysOrigin, CreateTime: now, } } else if err != nil { return err } } configRow.SysOrigin = sysOrigin configRow.Enabled = req.Enabled configRow.Timezone = timezone configRow.UpdateTime = now if configRow.CreateTime.IsZero() { configRow.CreateTime = now } if err := tx.Save(&configRow).Error; err != nil { return err } if saveConditionJumps { if err := s.saveConditionJumpConfigsTx(tx, configRow, category, conditionJumps, now, replaceConditionJumps); err != nil { return err } } keepIDs := make([]int64, 0, len(tasks)) for index := range tasks { if tasks[index].ID > 0 { var existing model.TaskCenterTaskConfig err := tx.Where("id = ? AND config_id = ? AND task_category = ?", tasks[index].ID, configRow.ID, category). First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "task_config_not_found", "task config not found") } if err != nil { return err } tasks[index].CreateTime = existing.CreateTime } else { taskID, idErr := utils.NextID() if idErr != nil { return idErr } tasks[index].ID = taskID tasks[index].CreateTime = now } tasks[index].ConfigID = configRow.ID tasks[index].SysOrigin = sysOrigin tasks[index].TaskCategory = category tasks[index].UpdateTime = now if tasks[index].CreateTime.IsZero() { tasks[index].CreateTime = now } if err := tx.Save(&tasks[index]).Error; err != nil { return err } keepIDs = append(keepIDs, tasks[index].ID) } deleteQuery := tx.Where("config_id = ? AND task_category = ?", configRow.ID, category) if len(keepIDs) > 0 { deleteQuery = deleteQuery.Where("id NOT IN ?", keepIDs) } if err := deleteQuery.Delete(&model.TaskCenterTaskConfig{}).Error; err != nil { return err } savedConfigID = configRow.ID return nil }); err != nil { return nil, err } return s.GetConfig(ctx, taskCenterSysOriginBySavedID(ctx, s.db, savedConfigID, sysOrigin), category) } // PageClaimRecords 分页查询领取记录。 func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, status string, cursor int, limit int) (*PageResponse[ClaimRecordView], error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) category := normalizeCategory(taskCategory) if err := validateCategory(category); err != nil { return nil, err } cursor, limit = NormalizePage(cursor, limit) query := s.db.WithContext(ctx).Model(&model.TaskCenterClaimRecord{}). Where("sys_origin = ? AND task_category = ?", sysOrigin, category) if userID > 0 { query = query.Where("user_id = ?", userID) } if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" { query = query.Where("task_code = ?", code) } if normalizedStatus := strings.ToUpper(strings.TrimSpace(status)); normalizedStatus != "" { query = query.Where("status = ?", normalizedStatus) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.TaskCenterClaimRecord if err := query.Order("create_time desc, id desc"). Offset((cursor - 1) * limit). Limit(limit). Find(&rows).Error; err != nil { return nil, err } records := make([]ClaimRecordView, 0, len(rows)) for _, row := range rows { records = append(records, claimRecordView(row)) } return &PageResponse[ClaimRecordView]{Records: records, Total: total, Current: cursor, Size: limit}, nil } // PageEvents 分页查询事件流水。 func (s *Service) PageEvents(ctx context.Context, sysOrigin string, taskCategory string, userID int64, taskCode string, eventType string, cursor int, limit int) (*PageResponse[EventView], error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) category := normalizeCategory(taskCategory) if err := validateCategory(category); err != nil { return nil, err } cursor, limit = NormalizePage(cursor, limit) query := s.db.WithContext(ctx).Model(&model.TaskCenterEvent{}). Where("sys_origin = ? AND task_category = ?", sysOrigin, category) if userID > 0 { query = query.Where("user_id = ?", userID) } if code := strings.ToUpper(strings.TrimSpace(taskCode)); code != "" { query = query.Where("task_code = ?", code) } if normalizedEventType := strings.ToUpper(strings.TrimSpace(eventType)); normalizedEventType != "" { query = query.Where("event_type = ?", normalizedEventType) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.TaskCenterEvent if err := query.Order("create_time desc, id desc"). Offset((cursor - 1) * limit). Limit(limit). Find(&rows).Error; err != nil { return nil, err } records := make([]EventView, 0, len(rows)) for _, row := range rows { records = append(records, eventView(row)) } return &PageResponse[EventView]{Records: records, Total: total, Current: cursor, Size: limit}, nil } func (s *Service) normalizeTaskInputs(inputs []SaveTaskConfigInput, category string) ([]model.TaskCenterTaskConfig, error) { if len(inputs) == 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "tasks is required") } seenCodes := make(map[string]struct{}, len(inputs)) rows := make([]model.TaskCenterTaskConfig, 0, len(inputs)) for _, item := range inputs { taskCategory := strings.ToUpper(strings.TrimSpace(item.TaskCategory)) if taskCategory == "" { taskCategory = category } else { taskCategory = normalizeCategory(taskCategory) } if taskCategory != category { return nil, NewAppError(http.StatusBadRequest, "invalid_task_category", "taskCategory does not match request category") } taskCode := strings.ToUpper(strings.TrimSpace(item.TaskCode)) if taskCode == "" { return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "taskCode is required") } if _, exists := seenCodes[taskCode]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_task_code", "taskCode must not duplicate") } seenCodes[taskCode] = struct{}{} taskName := strings.TrimSpace(item.TaskName) if taskName == "" { return nil, NewAppError(http.StatusBadRequest, "invalid_task_name", "taskName is required") } conditionType := strings.ToUpper(strings.TrimSpace(item.ConditionType)) if conditionType == "" { return nil, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is required") } if !isAllowedConditionType(conditionType) { return nil, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is not supported") } if item.TargetValue <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_target_value", "targetValue must be greater than 0") } if item.RewardGold <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_gold", "rewardGold must be greater than 0") } rows = append(rows, model.TaskCenterTaskConfig{ ID: item.ID.Int64(), TaskCode: taskCode, TaskCategory: category, Enabled: item.Enabled, TaskName: taskName, TaskDesc: strings.TrimSpace(item.TaskDesc), ConditionType: conditionType, TargetValue: item.TargetValue, TargetUnit: strings.TrimSpace(item.TargetUnit), RewardGold: item.RewardGold, JumpType: normalizeJumpType(item.JumpType), JumpPage: strings.TrimSpace(item.JumpPage), TaskIcon: strings.TrimSpace(item.TaskIcon), Cover: strings.TrimSpace(item.Cover), SortOrder: item.SortOrder, }) } sort.SliceStable(rows, func(i, j int) bool { if rows[i].SortOrder == rows[j].SortOrder { return rows[i].TaskCode < rows[j].TaskCode } return rows[i].SortOrder < rows[j].SortOrder }) return rows, nil } func (s *Service) normalizeConditionJumpInputs(inputs []SaveConditionJumpConfigInput, category string) ([]model.TaskCenterConditionJumpConfig, bool, error) { if inputs == nil { return nil, false, nil } seenConditions := make(map[string]struct{}, len(inputs)) rows := make([]model.TaskCenterConditionJumpConfig, 0, len(inputs)) for _, item := range inputs { taskCategory := strings.ToUpper(strings.TrimSpace(item.TaskCategory)) if taskCategory == "" { taskCategory = category } else { taskCategory = normalizeCategory(taskCategory) } if taskCategory != category { return nil, true, NewAppError(http.StatusBadRequest, "invalid_task_category", "condition jump taskCategory does not match request category") } conditionType := strings.ToUpper(strings.TrimSpace(item.ConditionType)) if conditionType == "" { return nil, true, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is required") } if !isAllowedConditionType(conditionType) { return nil, true, NewAppError(http.StatusBadRequest, "invalid_condition_type", "conditionType is not supported") } if _, exists := seenConditions[conditionType]; exists { return nil, true, NewAppError(http.StatusBadRequest, "duplicate_condition_type", "conditionType must not duplicate") } seenConditions[conditionType] = struct{}{} rows = append(rows, model.TaskCenterConditionJumpConfig{ ID: item.ID.Int64(), TaskCategory: category, ConditionType: conditionType, JumpType: normalizeJumpType(item.JumpType), JumpPage: normalizeConditionJumpPage(item.JumpType, item.JumpPage), SortOrder: item.SortOrder, }) } sort.SliceStable(rows, func(i, j int) bool { if rows[i].SortOrder == rows[j].SortOrder { return rows[i].ConditionType < rows[j].ConditionType } return rows[i].SortOrder < rows[j].SortOrder }) return rows, true, nil } func applyConditionJumpsToTasks(tasks []model.TaskCenterTaskConfig, jumps []model.TaskCenterConditionJumpConfig) { jumpByCondition := make(map[string]model.TaskCenterConditionJumpConfig, len(jumps)) for _, jump := range jumps { jumpByCondition[strings.ToUpper(strings.TrimSpace(jump.ConditionType))] = jump } for index := range tasks { conditionType := strings.ToUpper(strings.TrimSpace(tasks[index].ConditionType)) jump, exists := jumpByCondition[conditionType] if !exists { tasks[index].JumpType, tasks[index].JumpPage = defaultConditionJump(conditionType) continue } tasks[index].JumpType = normalizeJumpType(jump.JumpType) tasks[index].JumpPage = normalizeConditionJumpPage(jump.JumpType, jump.JumpPage) } } func defaultConditionJump(conditionType string) (string, string) { switch strings.ToUpper(strings.TrimSpace(conditionType)) { case EventTypeRechargeGold: return JumpTypeRechargeInApp, "" case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold: return "ROOM_RANDOM_WITH_MIC", "" default: return "NONE", "" } } func (s *Service) saveConditionJumpConfigsTx(tx *gorm.DB, configRow model.TaskCenterConfig, category string, rows []model.TaskCenterConditionJumpConfig, now time.Time, deleteMissing bool) error { var existingRows []model.TaskCenterConditionJumpConfig if err := tx. Where("config_id = ? AND task_category = ?", configRow.ID, category). Find(&existingRows).Error; err != nil { return err } existingByCondition := make(map[string]model.TaskCenterConditionJumpConfig, len(existingRows)) for _, row := range existingRows { existingByCondition[strings.ToUpper(strings.TrimSpace(row.ConditionType))] = row } keepIDs := make([]int64, 0, len(rows)) for index := range rows { conditionType := strings.ToUpper(strings.TrimSpace(rows[index].ConditionType)) if rows[index].ID <= 0 { if existing, exists := existingByCondition[conditionType]; exists { rows[index].ID = existing.ID rows[index].CreateTime = existing.CreateTime } } if rows[index].ID <= 0 { id, idErr := utils.NextID() if idErr != nil { return idErr } rows[index].ID = id rows[index].CreateTime = now } if rows[index].CreateTime.IsZero() { rows[index].CreateTime = now } rows[index].ConfigID = configRow.ID rows[index].SysOrigin = configRow.SysOrigin rows[index].TaskCategory = category rows[index].ConditionType = conditionType rows[index].JumpType = normalizeJumpType(rows[index].JumpType) rows[index].JumpPage = normalizeConditionJumpPage(rows[index].JumpType, rows[index].JumpPage) rows[index].UpdateTime = now if err := tx.Save(&rows[index]).Error; err != nil { return err } keepIDs = append(keepIDs, rows[index].ID) } if !deleteMissing { return nil } deleteQuery := tx.Where("config_id = ? AND task_category = ?", configRow.ID, category) if len(keepIDs) > 0 { deleteQuery = deleteQuery.Where("id NOT IN ?", keepIDs) } return deleteQuery.Delete(&model.TaskCenterConditionJumpConfig{}).Error } func buildConditionJumpConfigRowsFromTasks(tasks []model.TaskCenterTaskConfig) []model.TaskCenterConditionJumpConfig { seenConditions := make(map[string]struct{}, len(tasks)) rows := make([]model.TaskCenterConditionJumpConfig, 0, len(tasks)) for _, task := range tasks { conditionType := strings.ToUpper(strings.TrimSpace(task.ConditionType)) if conditionType == "" { continue } if _, exists := seenConditions[conditionType]; exists { continue } jumpType := normalizeJumpType(task.JumpType) jumpPage := strings.TrimSpace(task.JumpPage) if jumpType == "NONE" && jumpPage == "" { jumpType, jumpPage = defaultConditionJump(conditionType) } rows = append(rows, model.TaskCenterConditionJumpConfig{ TaskCategory: task.TaskCategory, ConditionType: conditionType, JumpType: jumpType, JumpPage: normalizeConditionJumpPage(jumpType, jumpPage), SortOrder: task.SortOrder, }) seenConditions[conditionType] = struct{}{} } sort.SliceStable(rows, func(i, j int) bool { if rows[i].SortOrder == rows[j].SortOrder { return rows[i].ConditionType < rows[j].ConditionType } return rows[i].SortOrder < rows[j].SortOrder }) return rows } func (s *Service) loadTaskConfigs(ctx context.Context, configID int64, category string, enabledOnly bool) ([]model.TaskCenterTaskConfig, error) { if configID <= 0 { return []model.TaskCenterTaskConfig{}, nil } query := s.db.WithContext(ctx). Where("config_id = ? AND task_category = ?", configID, normalizeCategory(category)) if enabledOnly { query = query.Where("enabled = ?", true) } var rows []model.TaskCenterTaskConfig if err := query.Order("sort_order asc, id asc").Find(&rows).Error; err != nil { return nil, err } return rows, nil } func (s *Service) loadConditionJumpConfigs(ctx context.Context, configID int64, category string) ([]model.TaskCenterConditionJumpConfig, error) { if configID <= 0 { return []model.TaskCenterConditionJumpConfig{}, nil } var rows []model.TaskCenterConditionJumpConfig if err := s.db.WithContext(ctx). Where("config_id = ? AND task_category = ?", configID, normalizeCategory(category)). Order("sort_order asc, id asc"). Find(&rows).Error; err != nil { return nil, err } return rows, nil } func buildTaskConfigPayloads(rows []model.TaskCenterTaskConfig) []TaskConfigPayload { result := make([]TaskConfigPayload, 0, len(rows)) for _, row := range rows { result = append(result, TaskConfigPayload{ ID: row.ID, TaskCode: row.TaskCode, TaskCategory: row.TaskCategory, Enabled: row.Enabled, TaskName: row.TaskName, TaskDesc: row.TaskDesc, ConditionType: row.ConditionType, TargetValue: row.TargetValue, TargetUnit: row.TargetUnit, RewardGold: row.RewardGold, JumpType: row.JumpType, JumpPage: row.JumpPage, TaskIcon: row.TaskIcon, Cover: row.Cover, SortOrder: row.SortOrder, }) } return result } func buildConditionJumpConfigPayloads(rows []model.TaskCenterConditionJumpConfig, tasks []model.TaskCenterTaskConfig) []ConditionJumpConfigPayload { result := make([]ConditionJumpConfigPayload, 0, len(rows)+len(tasks)) seenConditions := make(map[string]struct{}, len(rows)+len(tasks)) for _, row := range rows { conditionType := strings.ToUpper(strings.TrimSpace(row.ConditionType)) if conditionType == "" { continue } seenConditions[conditionType] = struct{}{} jumpType := normalizeJumpType(row.JumpType) result = append(result, ConditionJumpConfigPayload{ ID: row.ID, TaskCategory: row.TaskCategory, ConditionType: conditionType, JumpType: jumpType, JumpPage: normalizeConditionJumpPage(jumpType, row.JumpPage), SortOrder: row.SortOrder, }) } for _, task := range tasks { conditionType := strings.ToUpper(strings.TrimSpace(task.ConditionType)) if conditionType == "" { continue } if _, exists := seenConditions[conditionType]; exists { continue } jumpType := normalizeJumpType(task.JumpType) jumpPage := strings.TrimSpace(task.JumpPage) if jumpType == "NONE" && jumpPage == "" { jumpType, jumpPage = defaultConditionJump(conditionType) } jumpPage = normalizeConditionJumpPage(jumpType, jumpPage) result = append(result, ConditionJumpConfigPayload{ ID: 0, TaskCategory: task.TaskCategory, ConditionType: conditionType, JumpType: jumpType, JumpPage: jumpPage, SortOrder: task.SortOrder, }) seenConditions[conditionType] = struct{}{} } sort.SliceStable(result, func(i, j int) bool { if result[i].SortOrder == result[j].SortOrder { return result[i].ConditionType < result[j].ConditionType } return result[i].SortOrder < result[j].SortOrder }) return result } func normalizeConditionJumpPage(jumpType string, jumpPage string) string { return strings.TrimSpace(jumpPage) } func claimRecordView(row model.TaskCenterClaimRecord) ClaimRecordView { return ClaimRecordView{ ID: row.ID, SysOrigin: row.SysOrigin, UserID: row.UserID, TaskID: row.TaskID, TaskCode: row.TaskCode, TaskName: row.TaskName, TaskCategory: row.TaskCategory, CycleKey: row.CycleKey, RewardGold: row.RewardGold, WalletEventID: row.WalletEventID, Status: row.Status, FailureReason: row.FailureReason, ClaimedAt: formatDateTimePtr(row.ClaimedAt), CreateTime: formatDateTime(row.CreateTime), UpdateTime: formatDateTime(row.UpdateTime), } } func eventView(row model.TaskCenterEvent) EventView { return EventView{ ID: row.ID, SysOrigin: row.SysOrigin, EventID: row.EventID, EventType: row.EventType, UserID: row.UserID, TaskID: row.TaskID, TaskCode: row.TaskCode, TaskCategory: row.TaskCategory, CycleKey: row.CycleKey, DeltaValue: row.DeltaValue, CompletedValue: row.CompletedValue, OccurredAt: formatDateTime(row.OccurredAt), CreateTime: formatDateTime(row.CreateTime), UpdateTime: formatDateTime(row.UpdateTime), } } func taskCenterSysOriginBySavedID(ctx context.Context, db taskCenterDB, configID int64, fallback string) string { if configID <= 0 { return fallback } var row model.TaskCenterConfig if err := db.WithContext(ctx).Where("id = ?", configID).First(&row).Error; err == nil && strings.TrimSpace(row.SysOrigin) != "" { return row.SysOrigin } return fallback } func hasEnabledTask(tasks []model.TaskCenterTaskConfig) bool { for _, task := range tasks { if task.Enabled { return true } } return false } func normalizeTimezone(value string, fallback string) string { timezone := strings.TrimSpace(value) if timezone == "" { timezone = strings.TrimSpace(fallback) } if timezone == "" { timezone = defaultTaskCenterTimezone } return timezone } func normalizeJumpType(value string) string { jumpType := strings.ToUpper(strings.TrimSpace(value)) if jumpType == "" { jumpType = "NONE" } return jumpType }