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