1227 lines
50 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"
"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
}
// 不信任历史/人工写入的数据;即使绕过 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 {
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(&current).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)
}