package yumigiftchallenge import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Enter 在活动时区创建当天任务快照,并幂等完成进入页面任务。 func (s *Service) Enter(ctx context.Context, user AuthUser, req EnterRequest) (*UserStateResponse, error) { origin, err := s.requireYumiOrigin(user.SysOrigin) if err != nil { return nil, err } if user.UserID <= 0 { return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") } activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true) if err != nil { return nil, err } if activityStatus(*activity, time.Now()) != "ONGOING" { return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing") } location, err := resolveLocation(activity.Timezone) if err != nil { return nil, err } statDate := dateKey(time.Now(), location) err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var header model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, statDate, ).First(&header).Error; err != nil { return err } if header.Status != StatusNotStarted { return NewAppError(http.StatusConflict, "daily_period_closed", "daily task period is already frozen") } if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil { return err } now := time.Now() return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}). Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ? AND completed_time IS NULL", activity.ID, statDate, user.UserID, TaskEnterPage). Updates(map[string]any{"progress_value": model.Decimal24_2("1.00"), "completed_time": now, "update_time": now}).Error }) if err != nil { return nil, err } return s.buildUserState(ctx, *activity, statDate, user.UserID) } func (s *Service) ensureTaskSnapshotsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, statDate string, userID int64) error { var configs []model.YumiGiftChallengeTaskConfig if err := tx.Where("activity_id = ? AND enabled = ?", activity.ID, true). Order("sort_order ASC, id ASC").Find(&configs).Error; err != nil { return err } if len(configs) != 3 { return NewAppError(http.StatusConflict, "invalid_activity_tasks", "enabled activity must contain exactly three tasks") } now := time.Now() for _, config := range configs { id, err := utils.NextID() if err != nil { return err } row := model.YumiGiftChallengeUserTaskDaily{ ID: id, ActivityID: activity.ID, StatDate: statDate, UserID: userID, TaskConfigID: config.ID, TaskCode: config.TaskCode, TaskType: config.TaskType, TaskTitle: config.TaskTitle, SortOrder: config.SortOrder, TargetValue: config.TargetValue, ResourceGroupID: config.ResourceGroupID, BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:TASK:%d:%s:%d:%s", activity.ID, statDate, userID, config.TaskCode), DeliveryStatus: DeliveryNotClaimed, CreateTime: now, UpdateTime: now, } // 活动、统计日、用户和 taskCode 唯一键同时承接页面重进和送礼并发,只有第一份任务配置成为当天快照。 if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil { return err } } return nil } func mustLocation(value string) *time.Location { location, err := time.LoadLocation(strings.TrimSpace(value)) if err != nil { return time.UTC } return location } // ClaimTask 先在本地事务抢占任务 parent 并物化 delivery item,再逐项调用外部发奖。 func (s *Service) ClaimTask(ctx context.Context, user AuthUser, taskCode string, req ClaimRequest) (*UserStateResponse, error) { origin, err := s.requireYumiOrigin(user.SysOrigin) if err != nil { return nil, err } taskCode = strings.TrimSpace(taskCode) if !taskCodePattern.MatchString(taskCode) { return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "invalid taskCode") } requestID := strings.TrimSpace(req.RequestID) if requestID == "" || len(requestID) > 128 { return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and max length is 128") } activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true) if err != nil { return nil, err } if activityStatus(*activity, time.Now()) != "ONGOING" { return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing") } location, err := resolveLocation(activity.Timezone) if err != nil { return nil, err } statDate := dateKey(time.Now(), location) var ownerID int64 err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil { return err } var task model.YumiGiftChallengeUserTaskDaily queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", activity.ID, statDate, user.UserID, taskCode). First(&task).Error if errors.Is(queryErr, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "task_not_found", "task not found") } if queryErr != nil { return queryErr } ownerID = task.ID if task.CompletedTime == nil || task.ProgressValue.Compare(task.TargetValue) < 0 { return NewAppError(http.StatusConflict, "task_not_completed", "task is not completed") } switch task.DeliveryStatus { case DeliverySuccess: return nil case DeliveryUnknown: return NewAppError(http.StatusConflict, "delivery_unknown", "reward result is unknown; contact support for reconciliation") case DeliveryProcessing, DeliveryPending: // 已经抢占的领取由同一 owner/item 幂等边界继续处理,重复 requestId 不会创建第二份奖励。 case DeliveryNotClaimed: now := time.Now() if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", task.ID). Updates(map[string]any{"delivery_status": DeliveryPending, "claim_request_id": requestID, "claimed_time": now, "update_time": now}).Error; err != nil { return err } default: return NewAppError(http.StatusConflict, "invalid_delivery_status", "task reward state is invalid") } return s.createDeliveryItemsTx(tx, OwnerTask, task.ID, activity.ID, user.UserID, task.ResourceGroupID) }) if err != nil { return nil, err } if ownerID > 0 { if err := s.deliverOwnerItems(ctx, OwnerTask, ownerID); err != nil { return nil, err } } return s.buildUserState(ctx, *activity, statDate, user.UserID) } func (s *Service) buildUserState(ctx context.Context, activity model.YumiGiftChallengeActivity, statDate string, userID int64) (*UserStateResponse, error) { var total model.YumiGiftChallengeUserScore if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } var daily model.YumiGiftChallengeUserDailyScore if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&daily).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } overallRank, err := s.currentUserRank(ctx, activity.ID, nil, userID, total.Score, total.ScoreReachedTime) if err != nil { return nil, err } dailyRank, err := s.currentUserRank(ctx, activity.ID, &statDate, userID, daily.Score, daily.ScoreReachedTime) if err != nil { return nil, err } var tasks []model.YumiGiftChallengeUserTaskDaily if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID). Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil { return nil, err } snapshots, err := s.loadRewardSnapshots(ctx, activity.ID) if err != nil { return nil, err } views := make([]UserTaskView, 0, len(tasks)) for _, row := range tasks { items := []RewardItemView{} if row.ResourceGroupID != nil { items = rewardItemViews(snapshots[*row.ResourceGroupID]) } views = append(views, UserTaskView{ ID: row.ID, TaskConfigID: row.TaskConfigID, StatDate: row.StatDate, TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, SortOrder: row.SortOrder, TargetValue: row.TargetValue, ProgressValue: row.ProgressValue, Completed: row.CompletedTime != nil, CompletedTime: millis(row.CompletedTime), ResourceGroupID: row.ResourceGroupID, DeliveryStatus: row.DeliveryStatus, ClaimedTime: millis(row.ClaimedTime), DeliverTime: millis(row.DeliverTime), RewardItems: items, }) } return &UserStateResponse{ ActivityID: activity.ID, StatDate: statDate, TotalScore: total.Score, DailyScore: daily.Score, OverallRank: overallRank, DailyRank: dailyRank, Tasks: views, }, nil } func (s *Service) currentUserRank(ctx context.Context, activityID int64, statDate *string, userID int64, score model.Decimal24_2, reached time.Time) (*int, error) { if !score.Positive() || reached.IsZero() { return nil, nil } var count int64 query := s.db.WithContext(ctx) if statDate == nil { query = query.Model(&model.YumiGiftChallengeUserScore{}).Where("activity_id = ?", activityID) } else { query = query.Model(&model.YumiGiftChallengeUserDailyScore{}).Where("activity_id = ? AND stat_date = ?", activityID, *statDate) } err := query.Where("score > ? OR (score = ? AND score_reached_time < ?) OR (score = ? AND score_reached_time = ? AND user_id < ?)", score, score, reached, score, reached, userID).Count(&count).Error if err != nil { return nil, err } rank := int(count) + 1 return &rank, nil }