package yumigiftchallenge import ( "context" "errors" "fmt" "net/http" "regexp" "sort" "strings" "time" "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 !time.Now().Before(current.StartTime) { return s.saveStartedActivityMetadata(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 } } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if req.ID.Int64() <= 0 { if req.Enabled { if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) { return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future") } 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") } var current model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(¤t).Error; errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } else if err != nil { return err } if !time.Now().Before(current.StartTime) { return NewAppError(http.StatusConflict, "activity_started", "activity started while saving; retry metadata-only update") } if current.Version != *req.Version { return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving") } if req.Enabled { if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) || current.OverallSettlementStatus != StatusNotStarted { return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future and remain unsettled") } 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, "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 err := deletePreStartArtifacts(tx, activityID); 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 err := tx.CreateInBatches(snapshots, 500).Error; err != nil { return err } if err := tx.CreateInBatches(headers, 100).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return s.GetAdminDetail(ctx, activityID) } func (s *Service) saveStartedActivityMetadata(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, "started_activity_core_readonly", "started 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, "started_activity_core_readonly", "only activityName and activityDesc can be changed after start") } 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) } 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 !time.Now().Before(activity.StartTime) { return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be enabled or disabled") } if !enabled { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil { return err } if !time.Now().Before(locked.StartTime) { return NewAppError(http.StatusConflict, "activity_readonly", "started activity 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, "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 { var locked model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil { return err } now := time.Now() if !locked.StartTime.After(now) || !locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted { return NewAppError(http.StatusConflict, "activity_readonly", "activity started while 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 } if err := deletePreStartArtifacts(tx, activityID); err != nil { return err } if err := tx.CreateInBatches(snapshots, 500).Error; err != nil { return err } if err := tx.CreateInBatches(headers, 100).Error; err != nil { return err } return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID). Updates(map[string]any{"enabled": true, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": 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) if rewardConfigID <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_item", fmt.Sprintf("reward group %d contains an invalid item", groupID)) } 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: strings.ToUpper(strings.TrimSpace(item.Type)), DetailType: strings.TrimSpace(item.DetailType), Content: strings.TrimSpace(item.Content), Quantity: int64(item.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 } 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 count int64 err := tx.Model(&model.YumiGiftChallengeActivity{}). Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?", activity.SysOrigin, true, activity.ID, activity.EndTime, activity.StartTime). Count(&count).Error if err != nil { return err } if count > 0 { return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activity time windows must not overlap") } return 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 !time.Now().Before(activity.StartTime) { return nil, NewAppError(http.StatusConflict, "activity_readonly", "started 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 { var locked model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil { return err } now := time.Now() if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) { return NewAppError(http.StatusConflict, "activity_readonly", "started 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") } if err := deletePreStartArtifacts(tx, activityID); 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 err := tx.CreateInBatches(snapshots, 500).Error; err != nil { return err } if err := tx.CreateInBatches(headers, 100).Error; 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 !time.Now().Before(activity.StartTime) { return nil, NewAppError(http.StatusConflict, "activity_readonly", "started 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 { var locked model.YumiGiftChallengeActivity if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil { return err } now := time.Now() if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) { return NewAppError(http.StatusConflict, "activity_readonly", "started 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") } if err := deletePreStartArtifacts(tx, activityID); 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 err := tx.CreateInBatches(snapshots, 500).Error; err != nil { return err } if err := tx.CreateInBatches(headers, 100).Error; 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) }