2026-07-18 15:32:58 +08:00

801 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&current).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
}
// 不信任历史/人工写入的数据;即使绕过 SaveEnable 也不能生成总榜早于最终日榜的门闩。
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)
}