package yumigiftchallenge import ( "context" "errors" "fmt" "math" "math/big" "net/http" "regexp" "sort" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) var taskCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) // SaveActivity 保存完整配置。活动进入时间窗后仍可调整配置;只有总榜门闩已经冻结时才 // 降级为展示文案更新,防止修改已经确定的总榜获奖人与发奖归属。 func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailResponse, error) { activityID := req.ID.Int64() if activityID > 0 { current, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } if !activityCoreConfigEditable(*current) { return s.saveFrozenActivityMetadata(ctx, *current, req) } } origin, err := s.requireYumiOrigin(req.SysOrigin) if err != nil { return nil, err } location, startAt, endAt, err := validateActivityInput(req) if err != nil { return nil, err } tasks, err := normalizeTaskInputs(req.Tasks) if err != nil { return nil, err } rewards, err := normalizeRankRewardInputs(req.RankRewards, req.DisplayTopN) if err != nil { return nil, err } if activityID <= 0 { activityID, err = utils.NextID() if err != nil { return nil, err } } now := time.Now() proposed := model.YumiGiftChallengeActivity{ ID: activityID, ActivityCode: strings.TrimSpace(req.ActivityCode), ActivityName: strings.TrimSpace(req.ActivityName), ActivityDesc: strings.TrimSpace(req.ActivityDesc), SysOrigin: origin, Timezone: location.String(), StartTime: startAt, EndTime: endAt, DailySettlementDelayMinutes: req.DailySettlementDelayMinutes, OverallSettlementDelayMinutes: req.OverallSettlementDelayMinutes, OverallSettlementTime: endAt.Add(time.Duration(req.OverallSettlementDelayMinutes) * time.Minute), DisplayTopN: req.DisplayTopN, Enabled: req.Enabled, OverallSettlementStatus: StatusNotStarted, CreateTime: now, UpdateTime: now, } for i := range tasks { tasks[i].ActivityID, tasks[i].CreateTime, tasks[i].UpdateTime = activityID, now, now } for i := range rewards { rewards[i].ActivityID, rewards[i].CreateTime, rewards[i].UpdateTime = activityID, now, now } var snapshots []model.YumiGiftChallengeRewardSnapshot var headers []model.YumiGiftChallengePeriodSettlement if req.Enabled { // 所有外部奖励组读取与校验都在事务前完成;任何失败都不会先禁用或删除旧配置。 snapshots, err = s.buildRewardSnapshots(ctx, proposed, tasks, rewards) if err != nil { return nil, err } headers, err = buildPeriodHeaders(proposed) if err != nil { return nil, err } } isNew := req.ID.Int64() <= 0 err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { preserveLiveState := false if isNew { if req.Enabled { enabledTime := time.Now() if !proposed.EndTime.After(enabledTime) { return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must end in the future") } // 创建并立即启用也以事务内的真实启用时刻为统计下界,不能把构建奖励快照期间的消息补进来。 proposed.EnabledTime = &enabledTime if err := ensureNoEnabledOverlap(tx, proposed); err != nil { return err } } if err := tx.Create(&proposed).Error; err != nil { if isDuplicateKey(err) { return NewAppError(http.StatusConflict, "duplicate_activity_code", "activityCode already exists") } return err } } else { if req.Version == nil { return NewAppError(http.StatusBadRequest, "version_required", "version is required when updating") } current, err := lockActivityConfigurationTx(tx, activityID) if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } else if err != nil { return err } if !activityCoreConfigEditable(current) { return NewAppError(http.StatusConflict, "activity_settled", "activity core config is locked after overall settlement starts") } if current.Version != *req.Version { return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving") } txNow := time.Now() enabledTime := current.EnabledTime if !req.Enabled { enabledTime = nil } else if !current.Enabled { // 停用草稿中途开启时从本次提交时刻计分,不使用已经过去的配置开始时间。 enabledTime = &txNow } startedActive := current.Enabled && !txNow.Before(current.StartTime) preserveLiveState = startedActive if !preserveLiveState { preserveLiveState, err = activityHasHistoricalStateTx(tx, activityID) if err != nil { return err } } if startedActive && !req.Enabled { return NewAppError(http.StatusConflict, "activity_status_readonly", "started activity cannot be disabled") } if req.Enabled { if !current.Enabled && !proposed.EndTime.After(txNow) { return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling") } if err := ensureNoEnabledOverlap(tx, proposed); err != nil { return err } } result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", activityID, *req.Version). Updates(map[string]any{ "activity_code": proposed.ActivityCode, "activity_name": proposed.ActivityName, "activity_desc": proposed.ActivityDesc, "sys_origin": proposed.SysOrigin, "time_zone": proposed.Timezone, "start_time": proposed.StartTime, "end_time": proposed.EndTime, "daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes, "overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes, "overall_settlement_time": proposed.OverallSettlementTime, "display_top_n": proposed.DisplayTopN, "enabled": proposed.Enabled, "enabled_time": enabledTime, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": now, }) if result.Error != nil || result.RowsAffected != 1 { if result.Error != nil { return result.Error } return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving") } } if !isNew { // taskCode/type/sort 是 user_task_daily 唯一与进度更新的身份边界; // 任一用户任务已物化后只能改展示、门槛和奖励,不能换槽位身份。 if err := assertMaterializedTaskIdentityUnchangedTx(tx, activityID, tasks); err != nil { return err } } if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil { return err } if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil { return err } if err := tx.Create(&tasks).Error; err != nil { return err } if err := tx.Create(&rewards).Error; err != nil { return err } if req.Enabled { if preserveLiveState { return mergeEditableActivityArtifactsTx(tx, activityID, snapshots, headers) } return replaceEditableActivityArtifactsTx(tx, activityID, snapshots, headers) } // 只有未进入活动时间窗的停用草稿会走到这里:清掉旧预建门闩和 // 奖励快照,避免之后重新启用时夹带过期配置。进行中活动不允许停用。 if !isNew && !preserveLiveState { return deletePreStartArtifacts(tx, activityID) } return nil }) if err != nil { return nil, err } return s.GetAdminDetail(ctx, activityID) } func (s *Service) saveFrozenActivityMetadata(ctx context.Context, current model.YumiGiftChallengeActivity, req SaveRequest) (*DetailResponse, error) { if req.Version == nil { return nil, NewAppError(http.StatusBadRequest, "version_required", "version is required when updating") } name, desc := strings.TrimSpace(req.ActivityName), strings.TrimSpace(req.ActivityDesc) if name == "" || len(name) > 128 || len(desc) > 1000 { return nil, NewAppError(http.StatusBadRequest, "invalid_activity_text", "activityName is required and activity text is too long") } if req.Tasks != nil || req.RankRewards != nil { return nil, NewAppError(http.StatusConflict, "settled_activity_core_readonly", "frozen activity tasks and rank rewards cannot be changed") } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", current.ID).First(&locked).Error; err != nil { return err } if locked.Version != *req.Version { return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving") } if strings.TrimSpace(req.ActivityCode) != locked.ActivityCode || strings.ToUpper(strings.TrimSpace(req.SysOrigin)) != locked.SysOrigin || strings.TrimSpace(req.Timezone) != locked.Timezone || req.StartTime != locked.StartTime.UnixMilli() || req.EndTime != locked.EndTime.UnixMilli() || req.DailySettlementDelayMinutes != locked.DailySettlementDelayMinutes || req.OverallSettlementDelayMinutes != locked.OverallSettlementDelayMinutes || req.DisplayTopN != locked.DisplayTopN || req.Enabled != locked.Enabled { return NewAppError(http.StatusConflict, "settled_activity_core_readonly", "only activityName and activityDesc can be changed after settlement starts") } result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", locked.ID, *req.Version). Updates(map[string]any{"activity_name": name, "activity_desc": desc, "version": gorm.Expr("version + 1"), "update_time": time.Now()}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving") } return nil }) if err != nil { return nil, err } return s.GetAdminDetail(ctx, current.ID) } // activityCoreConfigEditable 只以总榜是否冻结作为最终写门闩。进行中活动的历史积分、 // 已物化任务和发奖项由各自快照承接,因此配置可以对后续事件/用户生效;总榜一旦进入 // PROCESSING/COMPLETED/RECONCILIATION_REQUIRED 后则不能再改变最终获奖语义。 func activityCoreConfigEditable(activity model.YumiGiftChallengeActivity) bool { return activity.OverallSettlementStatus == StatusNotStarted } func validateActivityInput(req SaveRequest) (*time.Location, time.Time, time.Time, error) { if strings.TrimSpace(req.ActivityCode) == "" || len(strings.TrimSpace(req.ActivityCode)) > 64 { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and max length is 64") } if strings.TrimSpace(req.ActivityName) == "" || len(strings.TrimSpace(req.ActivityName)) > 128 { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and max length is 128") } if len(strings.TrimSpace(req.ActivityDesc)) > 1000 { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc max length is 1000") } location, err := resolveLocation(req.Timezone) if err != nil { return nil, time.Time{}, time.Time{}, err } startAt := time.UnixMilli(req.StartTime).In(location) endAt := time.UnixMilli(req.EndTime).In(location) if req.StartTime <= 0 || req.EndTime <= 0 || !endAt.After(startAt) { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_time_range", "endTime must be later than startTime") } if err := validateSettlementDelays(req.DailySettlementDelayMinutes, req.OverallSettlementDelayMinutes); err != nil { return nil, time.Time{}, time.Time{}, err } if req.DisplayTopN < 1 || req.DisplayTopN > maxDisplayTopN { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_display_top_n", "displayTopN must be between 1 and 500") } days := 0 for day := dateOnly(startAt, location); day.Before(endAt); day = day.AddDate(0, 0, 1) { days++ if days > maxActivityDays { return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days") } } return location, startAt, endAt, nil } func validateSettlementDelays(dailyDelay, overallDelay int) error { if dailyDelay < 0 || dailyDelay > 1440 || overallDelay < 0 || overallDelay > 1440 { return NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlement delay must be between 0 and 1440 minutes") } if overallDelay < dailyDelay { // 总榜门闩关闭后事件会整笔拒绝;若总榜比最终日榜更早到期,两个延迟之间的 // 合法迟到礼物将无法计入仍开放的日榜,因此配置层必须保证总榜最后关门。 return NewAppError(http.StatusBadRequest, "invalid_settlement_delay_order", "overallSettlementDelayMinutes must be greater than or equal to dailySettlementDelayMinutes") } return nil } func normalizeTaskInputs(inputs []TaskInput) ([]model.YumiGiftChallengeTaskConfig, error) { if len(inputs) != 3 { return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "exactly three tasks are required") } rows := make([]model.YumiGiftChallengeTaskConfig, 0, 3) codes := map[string]struct{}{} enterCount, giftCount := 0, 0 for _, input := range inputs { code := strings.TrimSpace(input.TaskCode) taskType := strings.ToUpper(strings.TrimSpace(input.TaskType)) if !taskCodePattern.MatchString(code) { return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "taskCode may contain only letters, digits, underscores, and hyphens") } if _, exists := codes[code]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_task_code", "taskCode must be unique") } codes[code] = struct{}{} if strings.TrimSpace(input.TaskTitle) == "" || len(strings.TrimSpace(input.TaskTitle)) > 128 || len(strings.TrimSpace(input.TaskDesc)) > 500 { return nil, NewAppError(http.StatusBadRequest, "invalid_task_text", "task title is required and task text is too long") } if input.SortOrder < 1 || input.SortOrder > 3 || !input.TargetValue.Positive() || input.ResourceGroupID == nil || input.ResourceGroupID.Int64() <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_task", "sortOrder, targetValue and resourceGroupId are required") } if taskType == TaskEnterPage { enterCount++ if input.TargetValue.Compare(model.Decimal24_2("1.00")) != 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_enter_task", "ENTER_PAGE targetValue must be 1") } } else if taskType == TaskSendGiftGold { giftCount++ } else { return nil, NewAppError(http.StatusBadRequest, "invalid_task_type", "taskType must be ENTER_PAGE or SEND_GIFT_GOLD") } id := input.ID.Int64() if id <= 0 { var err error id, err = utils.NextID() if err != nil { return nil, err } } enabled := true if input.Enabled != nil { enabled = *input.Enabled } if !enabled { return nil, NewAppError(http.StatusBadRequest, "disabled_task", "all three fixed tasks must be enabled") } groupID := input.ResourceGroupID.Int64() rows = append(rows, model.YumiGiftChallengeTaskConfig{ ID: id, TaskCode: code, TaskType: taskType, TaskTitle: strings.TrimSpace(input.TaskTitle), TaskDesc: strings.TrimSpace(input.TaskDesc), TargetValue: input.TargetValue, ResourceGroupID: &groupID, Enabled: enabled, SortOrder: input.SortOrder, }) } if enterCount != 1 || giftCount != 2 { return nil, NewAppError(http.StatusBadRequest, "invalid_task_slots", "tasks must contain one ENTER_PAGE and two SEND_GIFT_GOLD") } sortTaskRows(rows) if rows[0].SortOrder != 1 || rows[1].SortOrder != 2 || rows[2].SortOrder != 3 { return nil, NewAppError(http.StatusBadRequest, "invalid_task_sort", "task sortOrder must be exactly 1, 2, and 3") } thresholds := make([]model.Decimal24_2, 0, 2) for _, row := range rows { if row.TaskType == TaskSendGiftGold { thresholds = append(thresholds, row.TargetValue) } } if thresholds[0].Compare(thresholds[1]) >= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_task_thresholds", "SEND_GIFT_GOLD thresholds must be strictly increasing by sortOrder") } return rows, nil } func normalizeRankRewardInputs(inputs []RankRewardInput, displayTopN int) ([]model.YumiGiftChallengeRankReward, error) { if len(inputs) == 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_rewards", "DAILY and OVERALL rank rewards are required") } rows := make([]model.YumiGiftChallengeRankReward, 0, len(inputs)) for _, input := range inputs { period := strings.ToUpper(strings.TrimSpace(input.PeriodType)) if period != PeriodDaily && period != PeriodOverall { return nil, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL") } if input.StartRank < 1 || input.EndRank < input.StartRank || input.EndRank > displayTopN || input.ResourceGroupID.Int64() <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward", "rank range and resourceGroupId are invalid") } id := input.ID.Int64() if id <= 0 { var err error id, err = utils.NextID() if err != nil { return nil, err } } rows = append(rows, model.YumiGiftChallengeRankReward{ ID: id, PeriodType: period, StartRank: input.StartRank, EndRank: input.EndRank, ResourceGroupID: input.ResourceGroupID.Int64(), RewardName: strings.TrimSpace(input.RewardName), }) } sort.Slice(rows, func(i, j int) bool { if rows[i].PeriodType == rows[j].PeriodType { return rows[i].StartRank < rows[j].StartRank } return rows[i].PeriodType < rows[j].PeriodType }) periodCounts := map[string]int{} lastEnd := map[string]int{} for _, row := range rows { periodCounts[row.PeriodType]++ if row.StartRank <= lastEnd[row.PeriodType] { return nil, NewAppError(http.StatusBadRequest, "overlapping_rank_rewards", "rank reward ranges must not overlap") } lastEnd[row.PeriodType] = row.EndRank } if periodCounts[PeriodDaily] == 0 || periodCounts[PeriodOverall] == 0 { return nil, NewAppError(http.StatusBadRequest, "missing_period_reward", "both DAILY and OVERALL rewards are required") } return rows, nil } // SetEnabled 启停活动;启用在同一事务冻结奖励模板并预建最多 366 个日榜门闩。 func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool) error { activity, err := s.loadActivity(ctx, activityID) if err != nil { return err } if activity.OverallSettlementStatus != StatusNotStarted { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled or disabled") } now := time.Now() if enabled && !activity.EndTime.After(now) { return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling") } if enabled && activity.Enabled { return nil } if !enabled && activity.Enabled && !now.Before(activity.StartTime) { return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled") } if !enabled { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockActivityConfigurationTx(tx, activityID) if err != nil { return err } if !activityCoreConfigEditable(locked) { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be disabled") } if !locked.Enabled { return nil } if locked.Enabled && !time.Now().Before(locked.StartTime) { return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled") } hasHistory, err := activityHasHistoricalStateTx(tx, activityID) if err != nil { return err } // startTime 可被移到未来,不能因此把真实开始过的活动当成草稿。 // 只要有流水、用户 parent 或冻结门闩,停用就会使历史奖励失去入口。 if hasHistory { return NewAppError(http.StatusConflict, "activity_status_readonly", "activity with historical state cannot be disabled") } if err := deletePreStartArtifacts(tx, activityID); err != nil { return err } return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{ "enabled": false, "enabled_time": nil, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now(), }).Error }) } tasks, rewards, err := s.loadActivityChildren(ctx, activityID) if err != nil { return err } if err := validateStoredConfig(tasks, rewards, activity.DisplayTopN); err != nil { return err } // 不信任历史/人工写入的数据;即使绕过 Save,Enable 也不能生成总榜早于最终日榜的门闩。 if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil { return err } snapshots, err := s.buildRewardSnapshots(ctx, *activity, tasks, rewards) if err != nil { return err } headers, err := buildPeriodHeaders(*activity) if err != nil { return err } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockActivityConfigurationTx(tx, activityID) if err != nil { return err } now := time.Now() if !activityCoreConfigEditable(locked) { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be enabled") } if !locked.EndTime.After(now) { return NewAppError(http.StatusConflict, "invalid_enable_window", "disabled activity must end in the future before enabling") } if locked.Version != activity.Version { return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading") } if err := validateSettlementDelays(locked.DailySettlementDelayMinutes, locked.OverallSettlementDelayMinutes); err != nil { return err } if err := ensureNoEnabledOverlap(tx, locked); err != nil { return err } hasHistory, err := activityHasHistoricalStateTx(tx, activityID) if err != nil { return err } if hasHistory { err = mergeEditableActivityArtifactsTx(tx, activityID, snapshots, headers) } else { err = replaceEditableActivityArtifactsTx(tx, activityID, snapshots, headers) } if err != nil { return err } return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{ "enabled": true, "enabled_time": now, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": now, }).Error }) } func validateStoredConfig(tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward, displayTopN int) error { taskInputs := make([]TaskInput, 0, len(tasks)) for _, row := range tasks { var group *FlexibleInt64 if row.ResourceGroupID != nil { value := FlexibleInt64(*row.ResourceGroupID) group = &value } enabled := row.Enabled taskInputs = append(taskInputs, TaskInput{ ID: FlexibleInt64(row.ID), TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue, ResourceGroupID: group, Enabled: &enabled, SortOrder: row.SortOrder, }) } if _, err := normalizeTaskInputs(taskInputs); err != nil { return err } rewardInputs := make([]RankRewardInput, 0, len(rewards)) for _, row := range rewards { rewardInputs = append(rewardInputs, RankRewardInput{ ID: FlexibleInt64(row.ID), PeriodType: row.PeriodType, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName, }) } _, err := normalizeRankRewardInputs(rewardInputs, displayTopN) return err } func (s *Service) buildRewardSnapshots(ctx context.Context, activity model.YumiGiftChallengeActivity, tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward) ([]model.YumiGiftChallengeRewardSnapshot, error) { groupIDs := map[int64]struct{}{} for _, task := range tasks { if task.ResourceGroupID != nil { groupIDs[*task.ResourceGroupID] = struct{}{} } } for _, reward := range rewards { groupIDs[reward.ResourceGroupID] = struct{}{} } ids := make([]int64, 0, len(groupIDs)) for id := range groupIDs { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) now := time.Now() rows := make([]model.YumiGiftChallengeRewardSnapshot, 0) seen := map[string]struct{}{} for _, groupID := range ids { detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID) if err != nil { return nil, NewAppError(http.StatusBadGateway, "reward_group_unavailable", fmt.Sprintf("load reward group %d: %v", groupID, err)) } if int64(detail.ID) != groupID || strings.ToUpper(strings.TrimSpace(detail.SysOrigin)) != activity.SysOrigin { return nil, NewAppError(http.StatusBadRequest, "reward_group_tenant_mismatch", fmt.Sprintf("reward group %d does not belong to %s", groupID, activity.SysOrigin)) } if detail.ShelfStatus == nil || !*detail.ShelfStatus { return nil, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", fmt.Sprintf("reward group %d is not enabled", groupID)) } if len(detail.RewardConfigList) == 0 { return nil, NewAppError(http.StatusBadRequest, "empty_reward_group", fmt.Sprintf("reward group %d has no reward items", groupID)) } sort.Slice(detail.RewardConfigList, func(i, j int) bool { if detail.RewardConfigList[i].Sort == detail.RewardConfigList[j].Sort { return int64(detail.RewardConfigList[i].ID) < int64(detail.RewardConfigList[j].ID) } return detail.RewardConfigList[i].Sort < detail.RewardConfigList[j].Sort }) for index, item := range detail.RewardConfigList { rewardConfigID := int64(item.ID) normalizedItem, normalizeErr := normalizeFrozenRewardItem(groupID, index, item) if normalizeErr != nil { return nil, normalizeErr } key := fmt.Sprintf("%d:%d", groupID, rewardConfigID) if _, exists := seen[key]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_item", "reward group contains duplicate reward item") } seen[key] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } sortOrder := item.Sort if sortOrder <= 0 { sortOrder = index + 1 } displayName := truncate(firstNonBlank(item.Name, item.BadgeName, item.Remark), 500) rows = append(rows, model.YumiGiftChallengeRewardSnapshot{ ID: id, ActivityID: activity.ID, ResourceGroupID: groupID, RewardConfigID: rewardConfigID, RewardType: normalizedItem.rewardType, DetailType: normalizedItem.detailType, Content: normalizedItem.content, Quantity: normalizedItem.quantity, SortOrder: sortOrder, Remark: strings.TrimSpace(item.Remark), Cover: strings.TrimSpace(item.Cover), SourceURL: strings.TrimSpace(item.SourceURL), DisplayName: displayName, CreateTime: now, }) if len(rows) > maxSnapshotItems { return nil, NewAppError(http.StatusBadRequest, "too_many_reward_items", "activity reward snapshot may contain at most 5000 items") } } } return rows, nil } type frozenRewardItem struct { rewardType string detailType string content string quantity int64 } func normalizeFrozenRewardItem(groupID int64, index int, item integration.RewardGroupItem) (frozenRewardItem, error) { result := frozenRewardItem{ rewardType: strings.ToUpper(strings.TrimSpace(item.Type)), detailType: strings.TrimSpace(item.DetailType), content: strings.TrimSpace(item.Content), quantity: int64(item.Quantity), } itemLabel := fmt.Sprintf("reward group %d item %d (id=%d, type=%s, quantity=%d)", groupID, index+1, int64(item.ID), result.rewardType, result.quantity) if int64(item.ID) <= 0 { return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", itemLabel+" has an invalid id") } if result.rewardType == "" { return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", itemLabel+" has an empty type") } // 奖励组详情为了展示会把原始 PROPS 改写成 AVATAR_FRAME/RIDE 等子类型, // 但冻结发奖接口只按基础类型分流;这里还原 PROPS,同时保留子类型用于展示和通知。 switch result.rewardType { case "AVATAR_FRAME", "RIDE", "NOBLE_VIP", "THEME", "LAYOUT", "CHAT_BUBBLE", "FLOAT_PICTURE", "DATA_CARD", "VIP_EFFECT_IMAGE", "RED_PACKET": if result.detailType == "" || strings.EqualFold(result.detailType, "PROPS") { result.detailType = result.rewardType } result.rewardType = "PROPS" case "ROOM_BADGE", "HONOR_ACTIVITY": if result.detailType == "" || strings.EqualFold(result.detailType, "BADGE") { result.detailType = result.rewardType } result.rewardType = "BADGE" } if result.quantity < 0 || result.quantity > math.MaxInt32 { return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", itemLabel+" has an out-of-range quantity") } // 所有当前可发奖类型的 content 都是正整数资源 ID 或整数币值。启用时先拒绝空值、 // 非数字和零值,避免活动完成后才在 Java 发奖链路留下 UNKNOWN 记录。 contentNumber, contentOK := new(big.Int).SetString(result.content, 10) if !contentOK || contentNumber.Sign() <= 0 { return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", itemLabel+" has invalid content") } switch result.rewardType { case "GOLD", "DIAMOND": // 币值由 content 承载,旧奖励组按契约把 quantity 保存为 0;不能把币值复制到 // quantity,否则既破坏冻结事实,也会与 Java 实际按 content 发币的逻辑不一致。 case "BADGE": // quantity=0 是 Java 的永久徽章语义,正数才表示临时徽章天数。 case "PROPS", "GIFT", "EMOJI", "FRAGMENTS", "CUSTOMIZE", "PROP_COUPON": if result.quantity == 0 { return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "invalid_reward_item", itemLabel+" requires a positive quantity") } default: return frozenRewardItem{}, NewAppError(http.StatusBadRequest, "unsupported_reward_item", itemLabel+" is not supported by the reward delivery service") } return result, nil } func firstNonBlank(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" } func buildPeriodHeaders(activity model.YumiGiftChallengeActivity) ([]model.YumiGiftChallengePeriodSettlement, error) { // 所有 Save/Enable/任务与榜奖重建路径最终都经过这里;在门闩生成入口再校验一次, // 防止历史脏数据或后续新增调用方绕过配置层,生成总榜早于最终日榜的关闭时间。 if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil { return nil, err } location, err := resolveLocation(activity.Timezone) if err != nil { return nil, err } now := time.Now() rows := make([]model.YumiGiftChallengePeriodSettlement, 0, maxActivityDays+1) for day := dateOnly(activity.StartTime, location); day.Before(activity.EndTime); day = day.AddDate(0, 0, 1) { periodEnd := day.AddDate(0, 0, 1) if periodEnd.After(activity.EndTime) { periodEnd = activity.EndTime } id, idErr := utils.NextID() if idErr != nil { return nil, idErr } storedDate := dateKey(day, location) rows = append(rows, model.YumiGiftChallengePeriodSettlement{ ID: id, ActivityID: activity.ID, PeriodType: PeriodDaily, PeriodKey: dateKey(day, location), StatDate: &storedDate, SnapshotDueTime: periodEnd.Add(time.Duration(activity.DailySettlementDelayMinutes) * time.Minute), Status: StatusNotStarted, CreateTime: now, UpdateTime: now, }) if len(rows) > maxActivityDays { return nil, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days") } } id, err := utils.NextID() if err != nil { return nil, err } rows = append(rows, model.YumiGiftChallengePeriodSettlement{ ID: id, ActivityID: activity.ID, PeriodType: PeriodOverall, PeriodKey: PeriodOverall, SnapshotDueTime: activity.OverallSettlementTime, Status: StatusNotStarted, CreateTime: now, UpdateTime: now, }) return rows, nil } func ensureNoEnabledOverlap(tx *gorm.DB, activity model.YumiGiftChallengeActivity) error { var overlapIDs []int64 // 该查询命中 (sys_origin, enabled, start_time, end_time) 索引,只返回 id; // FOR UPDATE 同时锁住命中行/范围,两个运营并发移动时间窗不能都通过校验。 err := tx.Model(&model.YumiGiftChallengeActivity{}).Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", activity.SysOrigin, true, activity.ID, activity.EndTime, activity.StartTime). Order("start_time ASC, id ASC").Pluck("id", &overlapIDs).Error if err != nil { return err } if len(overlapIDs) > 0 { return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activity time windows must not overlap") } return nil } // lockActivityConfigurationTx 按“活动 -> 日榜门闩 -> 总榜门闩”的固定顺序抢锁。 // 送礼、任务和结算事务都遵循相同顺序,使活动时间/时区与其门闩成为同一个版本边界, // 避免在线编辑与总榜结算分别持有 activity/header 后形成死锁环路。 func lockActivityConfigurationTx(tx *gorm.DB, activityID int64) (model.YumiGiftChallengeActivity, error) { var activity model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&activity).Error; err != nil { return model.YumiGiftChallengeActivity{}, err } var dailyHeaders []model.YumiGiftChallengePeriodSettlement if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND period_type = ?", activityID, PeriodDaily). Order("id ASC").Find(&dailyHeaders).Error; err != nil { return model.YumiGiftChallengeActivity{}, err } var overallHeader model.YumiGiftChallengePeriodSettlement err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, PeriodOverall, PeriodOverall). First(&overallHeader).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return model.YumiGiftChallengeActivity{}, err } return activity, nil } // activityHasHistoricalStateTx 不使用可被运营改写的 startTime 判断历史。任一用户 // parent/流水或已冻结周期存在,就必须走增量合并,永不再整体删除快照。 func activityHasHistoricalStateTx(tx *gorm.DB, activityID int64) (bool, error) { queries := []struct { model any where string args []any }{ {model: &model.YumiGiftChallengeGiftLedger{}, where: "activity_id = ?", args: []any{activityID}}, {model: &model.YumiGiftChallengeUserTaskDaily{}, where: "activity_id = ?", args: []any{activityID}}, {model: &model.YumiGiftChallengeSettlement{}, where: "activity_id = ?", args: []any{activityID}}, {model: &model.YumiGiftChallengePeriodSettlement{}, where: "activity_id = ? AND status <> ?", args: []any{activityID, StatusNotStarted}}, } for _, query := range queries { var marker struct { ActivityID int64 `gorm:"column:activity_id"` } err := tx.Model(query.model).Select("activity_id").Where(query.where, query.args...).Limit(1).Take(&marker).Error if err == nil { return true, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } } return false, nil } // assertMaterializedTaskIdentityUnchangedTx 保护已生成用户任务的唯一键语义。如果 // 允许改 taskCode/type/sort,同一用户当天会再物化一份任务并重复领奖。 func assertMaterializedTaskIdentityUnchangedTx(tx *gorm.DB, activityID int64, proposed []model.YumiGiftChallengeTaskConfig) error { var marker model.YumiGiftChallengeUserTaskDaily err := tx.Select("id").Where("activity_id = ?", activityID).Limit(1).Take(&marker).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } var current []model.YumiGiftChallengeTaskConfig if err := tx.Where("activity_id = ?", activityID).Order("sort_order ASC, id ASC").Find(¤t).Error; err != nil { return err } sortTaskRows(proposed) if len(current) != len(proposed) { return NewAppError(http.StatusConflict, "materialized_task_identity_readonly", "taskCode, taskType and sortOrder cannot change after user tasks exist") } for index := range current { if current[index].TaskCode != proposed[index].TaskCode || current[index].TaskType != proposed[index].TaskType || current[index].SortOrder != proposed[index].SortOrder { return NewAppError(http.StatusConflict, "materialized_task_identity_readonly", "taskCode, taskType and sortOrder cannot change after user tasks exist") } } return nil } func replaceEditableActivityArtifactsTx(tx *gorm.DB, activityID int64, snapshots []model.YumiGiftChallengeRewardSnapshot, headers []model.YumiGiftChallengePeriodSettlement) error { if err := deletePreStartArtifacts(tx, activityID); err != nil { return err } if len(snapshots) > 0 { if err := tx.CreateInBatches(snapshots, 500).Error; err != nil { return err } } if len(headers) > 0 { return tx.CreateInBatches(headers, 100).Error } return nil } // mergeEditableActivityArtifactsTx 只增量应用进行中活动的新配置:已经落到用户任务或 // 榜奖 parent 里的旧资源组快照绝不删除;未被历史状态引用的当前资源组允许刷新。 func mergeEditableActivityArtifactsTx(tx *gorm.DB, activityID int64, snapshots []model.YumiGiftChallengeRewardSnapshot, headers []model.YumiGiftChallengePeriodSettlement) error { // 先在已持有 activity/header 排他锁的事务内完成门闩重建,关闭新的任务物化入口; // 随后扫描 task/settlement parent,得到的受保护资源组集合才是稳定快照。 if err := syncEditablePeriodHeadersTx(tx, activityID, headers); err != nil { return err } protectedGroups, err := protectedRewardGroupIDsTx(tx, activityID) if err != nil { return err } deleteQuery := tx.Where("activity_id = ?", activityID) if len(protectedGroups) > 0 { deleteQuery = deleteQuery.Where("resource_group_id NOT IN ?", protectedGroups) } if err := deleteQuery.Delete(&model.YumiGiftChallengeRewardSnapshot{}).Error; err != nil { return err } frozenProtectedGroups := map[int64]struct{}{} if len(protectedGroups) > 0 { var ids []int64 if err := tx.Model(&model.YumiGiftChallengeRewardSnapshot{}). Distinct("resource_group_id").Where("activity_id = ? AND resource_group_id IN ?", activityID, protectedGroups). Pluck("resource_group_id", &ids).Error; err != nil { return err } for _, id := range ids { frozenProtectedGroups[id] = struct{}{} } } insertSnapshots := make([]model.YumiGiftChallengeRewardSnapshot, 0, len(snapshots)) for _, snapshot := range snapshots { if _, frozen := frozenProtectedGroups[snapshot.ResourceGroupID]; !frozen { insertSnapshots = append(insertSnapshots, snapshot) } } if len(insertSnapshots) > 0 { // 只要 protected group 已存在任一冻结项,就整组保持原样,不能把资源中心后来新增 // 的项混入旧奖励;运营若要改变奖励必须选择新资源组。缺失整组时允许补建以修复脏数据。 if err := tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(insertSnapshots, 500).Error; err != nil { return err } } var snapshotCount int64 if err := tx.Model(&model.YumiGiftChallengeRewardSnapshot{}).Where("activity_id = ?", activityID).Count(&snapshotCount).Error; err != nil { return err } if snapshotCount > maxSnapshotItems { return NewAppError(http.StatusConflict, "too_many_reward_snapshots", "historical and current reward snapshots may contain at most 5000 items") } return nil } func protectedRewardGroupIDsTx(tx *gorm.DB, activityID int64) ([]int64, error) { groups := map[int64]struct{}{} var taskGroups []int64 if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}). Distinct("resource_group_id").Where("activity_id = ? AND resource_group_id IS NOT NULL", activityID). Pluck("resource_group_id", &taskGroups).Error; err != nil { return nil, err } var settlementGroups []int64 if err := tx.Model(&model.YumiGiftChallengeSettlement{}). Distinct("resource_group_id").Where("activity_id = ?", activityID). Pluck("resource_group_id", &settlementGroups).Error; err != nil { return nil, err } for _, id := range append(taskGroups, settlementGroups...) { if id > 0 { groups[id] = struct{}{} } } result := make([]int64, 0, len(groups)) for id := range groups { result = append(result, id) } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result, nil } func syncEditablePeriodHeadersTx(tx *gorm.DB, activityID int64, desired []model.YumiGiftChallengePeriodSettlement) error { var existing []model.YumiGiftChallengePeriodSettlement if err := tx.Where("activity_id = ?", activityID).Find(&existing).Error; err != nil { return err } desiredByKey := make(map[string]model.YumiGiftChallengePeriodSettlement, len(desired)) for _, row := range desired { desiredByKey[row.PeriodType+"\x00"+row.PeriodKey] = row } existingByKey := make(map[string]model.YumiGiftChallengePeriodSettlement, len(existing)) for _, row := range existing { existingByKey[row.PeriodType+"\x00"+row.PeriodKey] = row } protectedDailyKeys, err := materializedDailyPeriodKeysTx(tx, activityID) if err != nil { return err } // 所有门闩已在 lockActivityConfigurationTx 中按固定顺序锁定。此处删除/重建仅影响 // NOT_STARTED 行,其他事务在提交前看不到中间状态;已冻结周期原行完全不触碰。 if err := tx.Where("activity_id = ? AND status = ?", activityID, StatusNotStarted). Delete(&model.YumiGiftChallengePeriodSettlement{}).Error; err != nil { return err } insertRows := make([]model.YumiGiftChallengePeriodSettlement, 0, len(desired)+len(protectedDailyKeys)) for _, row := range desired { key := row.PeriodType + "\x00" + row.PeriodKey old, existed := existingByKey[key] _, materialized := protectedDailyKeys[row.PeriodKey] if row.PeriodType == PeriodDaily && materialized && existed && old.Status == StatusNotStarted { // 日期仍在新时间窗内也不代表可以重算截止时间:只要已有流水、 // 积分或用户/settlement parent,原 header 和 due 就是该日历史边界。 insertRows = append(insertRows, old) continue } insertRows = append(insertRows, row) } for _, row := range existing { if row.Status != StatusNotStarted || row.PeriodType != PeriodDaily { continue } key := row.PeriodType + "\x00" + row.PeriodKey if _, stillConfigured := desiredByKey[key]; stillConfigured { continue } if _, materialized := protectedDailyKeys[row.PeriodKey]; materialized { // 时间窗缩短时,已经有积分/任务/结算 parent 的旧日期仍需保留原截止时间, // 否则历史数据将失去可结算门闩。 insertRows = append(insertRows, row) } } if len(insertRows) > 0 { return tx.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(insertRows, 100).Error } return nil } func materializedDailyPeriodKeysTx(tx *gorm.DB, activityID int64) (map[string]struct{}, error) { result := map[string]struct{}{} queries := []struct { model any column string where string }{ {model: &model.YumiGiftChallengeGiftLedger{}, column: "stat_date", where: "activity_id = ?"}, {model: &model.YumiGiftChallengeUserDailyScore{}, column: "stat_date", where: "activity_id = ?"}, {model: &model.YumiGiftChallengeUserTaskDaily{}, column: "stat_date", where: "activity_id = ?"}, {model: &model.YumiGiftChallengeSettlement{}, column: "period_key", where: "activity_id = ? AND period_type = 'DAILY'"}, } for _, query := range queries { var keys []string if err := tx.Model(query.model).Distinct(query.column).Where(query.where, activityID). Pluck(query.column, &keys).Error; err != nil { return nil, err } for _, key := range keys { if key = strings.TrimSpace(key); key != "" { result[key] = struct{}{} } } } return result, nil } func deletePreStartArtifacts(tx *gorm.DB, activityID int64) error { if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRewardSnapshot{}).Error; err != nil { return err } return tx.Where("activity_id = ? AND status = ?", activityID, StatusNotStarted). Delete(&model.YumiGiftChallengePeriodSettlement{}).Error } func isDuplicateKey(err error) bool { if err == nil { return false } message := strings.ToLower(err.Error()) return strings.Contains(message, "duplicate entry") || strings.Contains(message, "unique constraint") } // SaveTasks 单独保存三个任务;若活动已启用则在同一次后台操作里重建完整冻结产物。 func (s *Service) SaveTasks(ctx context.Context, activityID int64, req TaskSaveRequest) (*DetailResponse, error) { if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID { return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match") } rows, err := normalizeTaskInputs(req.Tasks) if err != nil { return nil, err } activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } if !activityCoreConfigEditable(*activity) { return nil, NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be edited") } _, rewards, err := s.loadActivityChildren(ctx, activityID) if err != nil { return nil, err } now := time.Now() for i := range rows { rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now } var snapshots []model.YumiGiftChallengeRewardSnapshot var headers []model.YumiGiftChallengePeriodSettlement if activity.Enabled { if err := validateStoredConfig(rows, rewards, activity.DisplayTopN); err != nil { return nil, err } snapshots, err = s.buildRewardSnapshots(ctx, *activity, rows, rewards) if err != nil { return nil, err } headers, err = buildPeriodHeaders(*activity) if err != nil { return nil, err } } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockActivityConfigurationTx(tx, activityID) if err != nil { return err } now := time.Now() if !activityCoreConfigEditable(locked) { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be edited") } if locked.Version != activity.Version || locked.Enabled != activity.Enabled { return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading") } preserveLiveState := locked.Enabled && !now.Before(locked.StartTime) if !preserveLiveState { preserveLiveState, err = activityHasHistoricalStateTx(tx, activityID) if err != nil { return err } } if err := assertMaterializedTaskIdentityUnchangedTx(tx, activityID, rows); err != nil { return err } if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil { return err } if err := tx.Create(&rows).Error; err != nil { return err } if activity.Enabled { if preserveLiveState { if err := mergeEditableActivityArtifactsTx(tx, activityID, snapshots, headers); err != nil { return err } } else if err := replaceEditableActivityArtifactsTx(tx, activityID, snapshots, headers); err != nil { return err } } else if !preserveLiveState { if err := deletePreStartArtifacts(tx, activityID); err != nil { return err } } return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error }) if err != nil { return nil, err } return s.GetAdminDetail(ctx, activityID) } // SaveRankRewards 单独保存日榜/总榜奖励区间,并按启用状态重建活动级奖励快照。 func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req RankRewardSaveRequest) (*DetailResponse, error) { if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID { return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match") } activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } rows, err := normalizeRankRewardInputs(req.RankRewards, activity.DisplayTopN) if err != nil { return nil, err } if !activityCoreConfigEditable(*activity) { return nil, NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be edited") } tasks, _, err := s.loadActivityChildren(ctx, activityID) if err != nil { return nil, err } now := time.Now() for i := range rows { rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now } var snapshots []model.YumiGiftChallengeRewardSnapshot var headers []model.YumiGiftChallengePeriodSettlement if activity.Enabled { if err := validateStoredConfig(tasks, rows, activity.DisplayTopN); err != nil { return nil, err } snapshots, err = s.buildRewardSnapshots(ctx, *activity, tasks, rows) if err != nil { return nil, err } headers, err = buildPeriodHeaders(*activity) if err != nil { return nil, err } } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockActivityConfigurationTx(tx, activityID) if err != nil { return err } now := time.Now() if !activityCoreConfigEditable(locked) { return NewAppError(http.StatusConflict, "activity_readonly", "settled activity cannot be edited") } if locked.Version != activity.Version || locked.Enabled != activity.Enabled { return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading") } preserveLiveState := locked.Enabled && !now.Before(locked.StartTime) if !preserveLiveState { preserveLiveState, err = activityHasHistoricalStateTx(tx, activityID) if err != nil { return err } } if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil { return err } if err := tx.Create(&rows).Error; err != nil { return err } if activity.Enabled { if preserveLiveState { if err := mergeEditableActivityArtifactsTx(tx, activityID, snapshots, headers); err != nil { return err } } else if err := replaceEditableActivityArtifactsTx(tx, activityID, snapshots, headers); err != nil { return err } } else if !preserveLiveState { if err := deletePreStartArtifacts(tx, activityID); err != nil { return err } } return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error }) if err != nil { return nil, err } return s.GetAdminDetail(ctx, activityID) }