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") } var effectiveActivity model.YumiGiftChallengeActivity var statDate string err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, lockedDate, err := lockOngoingActivityTx(tx, activity.ID, origin, time.Now()) if err != nil { return err } effectiveActivity, statDate = locked, lockedDate var header model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where( "activity_id = ? AND period_type = ? AND period_key = ?", effectiveActivity.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, effectiveActivity, 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", effectiveActivity.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, effectiveActivity, statDate, user.UserID) } // lockOngoingActivityTx 把活动时间/时区读取放到 activity SHARE 锁内。在线配置编辑会先取 // activity UPDATE 锁再重建周期门闩,因此任务事务不会把旧时区计算出的日期写进新门闩。 func lockOngoingActivityTx(tx *gorm.DB, activityID int64, origin string, now time.Time) (model.YumiGiftChallengeActivity, string, error) { activity, err := lockEnabledActivityTx(tx, activityID, origin) if err != nil { return model.YumiGiftChallengeActivity{}, "", err } if activityStatus(activity, now) != "ONGOING" { return model.YumiGiftChallengeActivity{}, "", NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing") } location, err := resolveLocation(activity.Timezone) if err != nil { return model.YumiGiftChallengeActivity{}, "", err } return activity, dateKey(now, location), nil } // lockEnabledActivityTx 是用户任务链路的统一首锁;历史任务领取不要求当前 // 时间窗仍 ongoing,但仍必须在锁内校验活动归属与启用状态。 func lockEnabledActivityTx(tx *gorm.DB, activityID int64, origin string) (model.YumiGiftChallengeActivity, error) { var activity model.YumiGiftChallengeActivity err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error if errors.Is(err, gorm.ErrRecordNotFound) { return model.YumiGiftChallengeActivity{}, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } if err != nil { return model.YumiGiftChallengeActivity{}, err } if activity.SysOrigin != origin || !activity.Enabled { return model.YumiGiftChallengeActivity{}, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } return activity, nil } 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 } var ownerID int64 var effectiveActivity model.YumiGiftChallengeActivity var statDate string err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockEnabledActivityTx(tx, activity.ID, origin) if err != nil { return err } effectiveActivity = locked location, err := resolveLocation(locked.Timezone) if err != nil { return err } statDate = dateKey(time.Now(), location) task, selected, err := lockClaimRequestOwnerTx(tx, effectiveActivity.ID, user.UserID, taskCode, requestID) if err != nil { return err } if selected { statDate = task.StatDate } if !selected && activityStatus(effectiveActivity, time.Now()) == "ONGOING" { if err := s.ensureTaskSnapshotsTx(tx, effectiveActivity, statDate, user.UserID); err != nil { return err } queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", effectiveActivity.ID, statDate, user.UserID, taskCode). First(&task).Error if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) { return queryErr } processable := task.DeliveryStatus == DeliveryNotClaimed || task.DeliveryStatus == DeliveryPending || task.DeliveryStatus == DeliveryProcessing // SUCCESS 只能由上面的相同 requestId 命中后幂等返回;UNKNOWN 必须 // 排在所有可处理的历史 owner 之后,不能挡住其他日期的正常奖励。 selected = queryErr == nil && task.CompletedTime != nil && task.ProgressValue.Compare(task.TargetValue) >= 0 && processable } if !selected { // 时区或时间窗变更后,已完成的旧日任务仍是独立奖励 owner。 // 先按 requestId 找原 owner 保证重试不会切到另一天,再领取最近的可处理任务。 task, err = lockHistoricalClaimTaskTx(tx, effectiveActivity.ID, user.UserID, taskCode) if err != nil { return err } statDate = task.StatDate } 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, effectiveActivity.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, effectiveActivity, statDate, user.UserID) } // lockClaimRequestOwnerTx 在选“当前日/最近日”前先恢复 requestId 已绑定的 owner。 // 即使请求重试时当前日任务又完成了,也不会用同一 requestId 多领一天。 func lockClaimRequestOwnerTx(tx *gorm.DB, activityID, userID int64, taskCode, requestID string) (model.YumiGiftChallengeUserTaskDaily, bool, error) { var task model.YumiGiftChallengeUserTaskDaily err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND user_id = ? AND task_code = ? AND claim_request_id = ? AND completed_time IS NOT NULL AND progress_value >= target_value", activityID, userID, taskCode, requestID). Order("stat_date DESC, id DESC").First(&task).Error if errors.Is(err, gorm.ErrRecordNotFound) { return model.YumiGiftChallengeUserTaskDaily{}, false, nil } if err != nil { return model.YumiGiftChallengeUserTaskDaily{}, false, err } return task, true, nil } // lockHistoricalClaimTaskTx 只在当前日任务不可领时走历史兜底。每个日任务行 // 是独立 owner;该函数只处理尚未绑定 requestId 的新请求。 func lockHistoricalClaimTaskTx(tx *gorm.DB, activityID, userID int64, taskCode string) (model.YumiGiftChallengeUserTaskDaily, error) { base := func() *gorm.DB { return tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND user_id = ? AND task_code = ? AND completed_time IS NOT NULL AND progress_value >= target_value", activityID, userID, taskCode) } var task model.YumiGiftChallengeUserTaskDaily err := base().Where("delivery_status IN ?", []string{DeliveryNotClaimed, DeliveryPending, DeliveryProcessing}). Order("stat_date DESC, id DESC").First(&task).Error if err == nil { return task, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return model.YumiGiftChallengeUserTaskDaily{}, err } // UNKNOWN 不能自动重发,但也不能被当成“没有奖励”后跳过;后续 switch // 会要求人工核账。SUCCESS 只能通过上面的 requestId owner 查询返回幂等成功。 task = model.YumiGiftChallengeUserTaskDaily{} err = base().Where("delivery_status = ?", DeliveryUnknown). Order("stat_date DESC, id DESC").First(&task).Error if errors.Is(err, gorm.ErrRecordNotFound) { return model.YumiGiftChallengeUserTaskDaily{}, NewAppError(http.StatusConflict, "task_not_completed", "task is not completed") } if err != nil { return model.YumiGiftChallengeUserTaskDaily{}, err } return task, nil } 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 }