package taskcenter import ( "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) // ListTasks 返回 App 任务列表,字段兼容旧 Flutter 模型。 func (s *Service) ListTasks(ctx context.Context, user AuthUser) ([]AppTaskItem, error) { sysOrigin := s.normalizeSysOrigin(user.SysOrigin) bundle, err := s.loadEnabledBundle(ctx, sysOrigin) if err != nil { return nil, err } if bundle == nil { return []AppTaskItem{}, nil } result := make([]AppTaskItem, 0, len(bundle.Tasks)) for _, task := range bundle.Tasks { cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now()) progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey) if err != nil { return nil, err } if task.TaskCategory == TaskCategoryNewbie && exists && progress.RewardCollected { continue } completedValue := int64(0) rewardCollected := 0 if exists { completedValue = progress.CompletedValue if progress.RewardCollected { rewardCollected = 1 } } taskStatus := 0 if completedValue >= task.TargetValue { taskStatus = 1 } result = append(result, AppTaskItem{ TaskType: appTaskType(task.TaskCategory), TaskName: task.TaskName, TaskDesc: task.TaskDesc, JumpPage: resolveJumpPage(task), ConditionType: task.ConditionType, SortOrder: task.SortOrder, TaskID: task.ID, TaskStatus: taskStatus, Cover: task.Cover, TaskIcon: task.TaskIcon, ConditionValue: task.ConditionType, CompletedValue: completedValue, IsRewardCollected: rewardCollected, TargetValue: task.TargetValue, Quantity: task.RewardGold, }) } return result, nil } // ClaimableCount 返回可领取任务数量。 func (s *Service) ClaimableCount(ctx context.Context, user AuthUser) (*ClaimableCountResponse, error) { tasks, err := s.ListTasks(ctx, user) if err != nil { return nil, err } count := 0 for _, task := range tasks { if task.TaskStatus == 1 && task.IsRewardCollected == 0 { count++ } } return &ClaimableCountResponse{Count: count}, nil } // Claim 领取任务奖励。 func (s *Service) Claim(ctx context.Context, user AuthUser, taskID int64) (bool, error) { if taskID <= 0 { return false, NewAppError(http.StatusBadRequest, "invalid_task_id", "taskId is required") } sysOrigin := s.normalizeSysOrigin(user.SysOrigin) bundle, err := s.loadEnabledBundle(ctx, sysOrigin) if err != nil { return false, err } if bundle == nil { return false, NewAppError(http.StatusNotFound, "task_center_not_configured", "task center is not configured") } var task model.TaskCenterTaskConfig found := false for _, candidate := range bundle.Tasks { if candidate.ID == taskID { task = candidate found = true break } } if !found { return false, NewAppError(http.StatusNotFound, "task_not_found", "task not found") } cycleKey := currentCycleKey(task.TaskCategory, bundle.Location, time.Now()) progress, exists, err := s.loadProgress(ctx, sysOrigin, user.UserID, task.ID, cycleKey) if err != nil { return false, err } if !exists || progress.CompletedValue < task.TargetValue { return false, NewAppError(http.StatusBadRequest, "task_not_completed", "task is not completed") } if progress.RewardCollected { return true, nil } walletEventID := buildWalletEventID(sysOrigin, user.UserID, task.TaskCode, cycleKey) if err := s.dispatchClaimGold(ctx, sysOrigin, user.UserID, task, walletEventID); err != nil { _ = s.saveClaimRecord(ctx, task, progress, walletEventID, ClaimStatusFailed, err.Error(), nil) return false, err } now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.saveClaimRecordTx(tx, task, progress, walletEventID, ClaimStatusSuccess, "", &now); err != nil { return err } return tx.Model(&model.TaskCenterUserProgress{}). Where("id = ?", progress.ID). Updates(map[string]any{ "status": TaskStatusClaimed, "reward_collected": true, "claimed_at": now, "update_time": now, }).Error }); err != nil { return false, err } return true, nil } func (s *Service) dispatchClaimGold(ctx context.Context, sysOrigin string, userID int64, task model.TaskCenterTaskConfig, walletEventID string) error { if task.RewardGold <= 0 { return nil } if s.java == nil { return NewAppError(http.StatusBadGateway, "wallet_gateway_missing", "wallet gateway is not configured") } exists, err := s.java.ExistsGoldEvent(ctx, walletEventID) if err != nil { return NewAppError(http.StatusBadGateway, "wallet_event_check_failed", err.Error()) } if exists { return nil } if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: "INCOME", UserID: userID, SysOrigin: sysOrigin, EventID: walletEventID, Remark: fmt.Sprintf("taskCode=%s cycleKey=%s", task.TaskCode, currentCycleKey(task.TaskCategory, s.location, time.Now())), Amount: integration.NewPennyAmountPayloadFromDollar(task.RewardGold), CloseDelayAsset: false, OpUserType: "APP", Origin: "DAILY_TASK", }); err != nil { return NewAppError(http.StatusBadGateway, "wallet_change_failed", err.Error()) } return nil } func (s *Service) saveClaimRecord(ctx context.Context, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.saveClaimRecordTx(tx, task, progress, walletEventID, status, failureReason, claimedAt) }) } func (s *Service) saveClaimRecordTx(tx *gorm.DB, task model.TaskCenterTaskConfig, progress model.TaskCenterUserProgress, walletEventID string, status string, failureReason string, claimedAt *time.Time) error { now := time.Now() var row model.TaskCenterClaimRecord err := tx.Where("wallet_event_id = ?", walletEventID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { id, idErr := utils.NextID() if idErr != nil { return idErr } row = model.TaskCenterClaimRecord{ ID: id, SysOrigin: progress.SysOrigin, UserID: progress.UserID, TaskID: task.ID, TaskCode: task.TaskCode, TaskName: task.TaskName, TaskCategory: task.TaskCategory, CycleKey: progress.CycleKey, RewardGold: task.RewardGold, WalletEventID: walletEventID, CreateTime: now, } } else if err != nil { return err } row.Status = status row.FailureReason = strings.TrimSpace(failureReason) row.ClaimedAt = claimedAt row.UpdateTime = now return tx.Save(&row).Error } type taskCenterBundle struct { Config model.TaskCenterConfig Tasks []model.TaskCenterTaskConfig Location *time.Location } func (s *Service) loadEnabledBundle(ctx context.Context, sysOrigin string) (*taskCenterBundle, error) { var configRow model.TaskCenterConfig err := s.db.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if !configRow.Enabled { return nil, nil } location := s.location if parsed, parseErr := time.LoadLocation(normalizeTimezone(configRow.Timezone, s.defaultTimezone())); parseErr == nil { location = parsed } var tasks []model.TaskCenterTaskConfig if err := s.db.WithContext(ctx). Where("config_id = ? AND enabled = ?", configRow.ID, true). Order("sort_order asc, id asc"). Find(&tasks).Error; err != nil { return nil, err } return &taskCenterBundle{Config: configRow, Tasks: tasks, Location: location}, nil } func (s *Service) loadProgress(ctx context.Context, sysOrigin string, userID int64, taskID int64, cycleKey string) (model.TaskCenterUserProgress, bool, error) { var row model.TaskCenterUserProgress err := s.db.WithContext(ctx). Where("sys_origin = ? AND user_id = ? AND task_id = ? AND cycle_key = ?", sysOrigin, userID, taskID, cycleKey). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return model.TaskCenterUserProgress{}, false, nil } if err != nil { return model.TaskCenterUserProgress{}, false, err } return row, true, nil } func appTaskType(category string) int { if normalizeCategory(category) == TaskCategoryDaily { return 0 } return 1 } func resolveJumpPage(task model.TaskCenterTaskConfig) string { jumpPage := strings.TrimSpace(task.JumpPage) if jumpPage != "" { return jumpPage } if isRechargeTask(task) { return defaultRechargeJumpPage } switch normalizeJumpType(task.JumpType) { case JumpTypeRechargeInApp: return defaultRechargeJumpPage case "ROOM_RANDOM", "ROOM_RANDOM_WITH_MIC": return "/room" case "NONE": return "" default: return jumpPage } } func isRechargeTask(task model.TaskCenterTaskConfig) bool { return strings.EqualFold(strings.TrimSpace(task.ConditionType), EventTypeRechargeGold) } func buildWalletEventID(sysOrigin string, userID int64, taskCode string, cycleKey string) string { normalizedSysOrigin := compactWalletEventPart(sysOrigin, 8) if normalizedSysOrigin == "" { normalizedSysOrigin = "SYS" } raw := strings.Join([]string{ strings.ToUpper(strings.TrimSpace(sysOrigin)), strconv.FormatInt(userID, 10), strings.ToUpper(strings.TrimSpace(taskCode)), strings.TrimSpace(cycleKey), }, "|") sum := sha1.Sum([]byte(raw)) return fmt.Sprintf("TC:%s:%s:%s", normalizedSysOrigin, strconv.FormatInt(userID, 36), hex.EncodeToString(sum[:])[:16]) } func compactWalletEventPart(value string, maxLen int) string { value = strings.ToUpper(strings.TrimSpace(value)) if maxLen <= 0 { return "" } var builder strings.Builder for _, char := range value { if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { builder.WriteRune(char) if builder.Len() >= maxLen { break } } } return builder.String() }