801 lines
33 KiB
Go
801 lines
33 KiB
Go
package yumigiftchallenge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"chatapp3-golang/internal/model"
|
||
"chatapp3-golang/internal/utils"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
var taskCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||
|
||
// SaveActivity 保存完整配置;活动开始后只允许修改名称和描述,不触碰已生效统计口径。
|
||
func (s *Service) SaveActivity(ctx context.Context, req SaveRequest) (*DetailResponse, error) {
|
||
activityID := req.ID.Int64()
|
||
if activityID > 0 {
|
||
current, err := s.loadActivity(ctx, activityID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !time.Now().Before(current.StartTime) {
|
||
return s.saveStartedActivityMetadata(ctx, *current, req)
|
||
}
|
||
}
|
||
origin, err := s.requireYumiOrigin(req.SysOrigin)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
location, startAt, endAt, err := validateActivityInput(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tasks, err := normalizeTaskInputs(req.Tasks)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rewards, err := normalizeRankRewardInputs(req.RankRewards, req.DisplayTopN)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if activityID <= 0 {
|
||
activityID, err = utils.NextID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
now := time.Now()
|
||
proposed := model.YumiGiftChallengeActivity{
|
||
ID: activityID, ActivityCode: strings.TrimSpace(req.ActivityCode), ActivityName: strings.TrimSpace(req.ActivityName),
|
||
ActivityDesc: strings.TrimSpace(req.ActivityDesc), SysOrigin: origin, Timezone: location.String(),
|
||
StartTime: startAt, EndTime: endAt,
|
||
DailySettlementDelayMinutes: req.DailySettlementDelayMinutes,
|
||
OverallSettlementDelayMinutes: req.OverallSettlementDelayMinutes,
|
||
OverallSettlementTime: endAt.Add(time.Duration(req.OverallSettlementDelayMinutes) * time.Minute),
|
||
DisplayTopN: req.DisplayTopN, Enabled: req.Enabled, OverallSettlementStatus: StatusNotStarted,
|
||
CreateTime: now, UpdateTime: now,
|
||
}
|
||
for i := range tasks {
|
||
tasks[i].ActivityID, tasks[i].CreateTime, tasks[i].UpdateTime = activityID, now, now
|
||
}
|
||
for i := range rewards {
|
||
rewards[i].ActivityID, rewards[i].CreateTime, rewards[i].UpdateTime = activityID, now, now
|
||
}
|
||
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
||
var headers []model.YumiGiftChallengePeriodSettlement
|
||
if req.Enabled {
|
||
// 所有外部奖励组读取与校验都在事务前完成;任何失败都不会先禁用或删除旧配置。
|
||
snapshots, err = s.buildRewardSnapshots(ctx, proposed, tasks, rewards)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err = buildPeriodHeaders(proposed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if req.ID.Int64() <= 0 {
|
||
if req.Enabled {
|
||
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) {
|
||
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future")
|
||
}
|
||
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := tx.Create(&proposed).Error; err != nil {
|
||
if isDuplicateKey(err) {
|
||
return NewAppError(http.StatusConflict, "duplicate_activity_code", "activityCode already exists")
|
||
}
|
||
return err
|
||
}
|
||
} else {
|
||
if req.Version == nil {
|
||
return NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
|
||
}
|
||
var current model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(¤t).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||
} else if err != nil {
|
||
return err
|
||
}
|
||
if !time.Now().Before(current.StartTime) {
|
||
return NewAppError(http.StatusConflict, "activity_started", "activity started while saving; retry metadata-only update")
|
||
}
|
||
if current.Version != *req.Version {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
||
}
|
||
if req.Enabled {
|
||
if !proposed.StartTime.After(time.Now()) || !proposed.EndTime.After(time.Now()) || current.OverallSettlementStatus != StatusNotStarted {
|
||
return NewAppError(http.StatusConflict, "invalid_enable_window", "enabled activity must start in the future and remain unsettled")
|
||
}
|
||
if err := ensureNoEnabledOverlap(tx, proposed); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", activityID, *req.Version).
|
||
Updates(map[string]any{
|
||
"activity_code": proposed.ActivityCode, "activity_name": proposed.ActivityName,
|
||
"activity_desc": proposed.ActivityDesc, "sys_origin": proposed.SysOrigin,
|
||
"time_zone": proposed.Timezone, "start_time": proposed.StartTime, "end_time": proposed.EndTime,
|
||
"daily_settlement_delay_minutes": proposed.DailySettlementDelayMinutes,
|
||
"overall_settlement_delay_minutes": proposed.OverallSettlementDelayMinutes,
|
||
"overall_settlement_time": proposed.OverallSettlementTime, "display_top_n": proposed.DisplayTopN,
|
||
"enabled": proposed.Enabled, "overall_settlement_status": StatusNotStarted,
|
||
"version": gorm.Expr("version + 1"), "update_time": now,
|
||
})
|
||
if result.Error != nil || result.RowsAffected != 1 {
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
||
}
|
||
if err := deletePreStartArtifacts(tx, activityID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Create(&tasks).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Create(&rewards).Error; err != nil {
|
||
return err
|
||
}
|
||
if req.Enabled {
|
||
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetAdminDetail(ctx, activityID)
|
||
}
|
||
|
||
func (s *Service) saveStartedActivityMetadata(ctx context.Context, current model.YumiGiftChallengeActivity, req SaveRequest) (*DetailResponse, error) {
|
||
if req.Version == nil {
|
||
return nil, NewAppError(http.StatusBadRequest, "version_required", "version is required when updating")
|
||
}
|
||
name, desc := strings.TrimSpace(req.ActivityName), strings.TrimSpace(req.ActivityDesc)
|
||
if name == "" || len(name) > 128 || len(desc) > 1000 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_activity_text", "activityName is required and activity text is too long")
|
||
}
|
||
if req.Tasks != nil || req.RankRewards != nil {
|
||
return nil, NewAppError(http.StatusConflict, "started_activity_core_readonly", "started activity tasks and rank rewards cannot be changed")
|
||
}
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var locked model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", current.ID).First(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
if locked.Version != *req.Version {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
||
}
|
||
if strings.TrimSpace(req.ActivityCode) != locked.ActivityCode ||
|
||
strings.ToUpper(strings.TrimSpace(req.SysOrigin)) != locked.SysOrigin || strings.TrimSpace(req.Timezone) != locked.Timezone ||
|
||
req.StartTime != locked.StartTime.UnixMilli() || req.EndTime != locked.EndTime.UnixMilli() ||
|
||
req.DailySettlementDelayMinutes != locked.DailySettlementDelayMinutes ||
|
||
req.OverallSettlementDelayMinutes != locked.OverallSettlementDelayMinutes ||
|
||
req.DisplayTopN != locked.DisplayTopN || req.Enabled != locked.Enabled {
|
||
return NewAppError(http.StatusConflict, "started_activity_core_readonly", "only activityName and activityDesc can be changed after start")
|
||
}
|
||
result := tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ? AND version = ?", locked.ID, *req.Version).
|
||
Updates(map[string]any{"activity_name": name, "activity_desc": desc, "version": gorm.Expr("version + 1"), "update_time": time.Now()})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected != 1 {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity has been changed; reload before saving")
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetAdminDetail(ctx, current.ID)
|
||
}
|
||
|
||
func validateActivityInput(req SaveRequest) (*time.Location, time.Time, time.Time, error) {
|
||
if strings.TrimSpace(req.ActivityCode) == "" || len(strings.TrimSpace(req.ActivityCode)) > 64 {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and max length is 64")
|
||
}
|
||
if strings.TrimSpace(req.ActivityName) == "" || len(strings.TrimSpace(req.ActivityName)) > 128 {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and max length is 128")
|
||
}
|
||
if len(strings.TrimSpace(req.ActivityDesc)) > 1000 {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc max length is 1000")
|
||
}
|
||
location, err := resolveLocation(req.Timezone)
|
||
if err != nil {
|
||
return nil, time.Time{}, time.Time{}, err
|
||
}
|
||
startAt := time.UnixMilli(req.StartTime).In(location)
|
||
endAt := time.UnixMilli(req.EndTime).In(location)
|
||
if req.StartTime <= 0 || req.EndTime <= 0 || !endAt.After(startAt) {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_time_range", "endTime must be later than startTime")
|
||
}
|
||
if err := validateSettlementDelays(req.DailySettlementDelayMinutes, req.OverallSettlementDelayMinutes); err != nil {
|
||
return nil, time.Time{}, time.Time{}, err
|
||
}
|
||
if req.DisplayTopN < 1 || req.DisplayTopN > maxDisplayTopN {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "invalid_display_top_n", "displayTopN must be between 1 and 500")
|
||
}
|
||
days := 0
|
||
for day := dateOnly(startAt, location); day.Before(endAt); day = day.AddDate(0, 0, 1) {
|
||
days++
|
||
if days > maxActivityDays {
|
||
return nil, time.Time{}, time.Time{}, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days")
|
||
}
|
||
}
|
||
return location, startAt, endAt, nil
|
||
}
|
||
|
||
func validateSettlementDelays(dailyDelay, overallDelay int) error {
|
||
if dailyDelay < 0 || dailyDelay > 1440 || overallDelay < 0 || overallDelay > 1440 {
|
||
return NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlement delay must be between 0 and 1440 minutes")
|
||
}
|
||
if overallDelay < dailyDelay {
|
||
// 总榜门闩关闭后事件会整笔拒绝;若总榜比最终日榜更早到期,两个延迟之间的
|
||
// 合法迟到礼物将无法计入仍开放的日榜,因此配置层必须保证总榜最后关门。
|
||
return NewAppError(http.StatusBadRequest, "invalid_settlement_delay_order", "overallSettlementDelayMinutes must be greater than or equal to dailySettlementDelayMinutes")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeTaskInputs(inputs []TaskInput) ([]model.YumiGiftChallengeTaskConfig, error) {
|
||
if len(inputs) != 3 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_tasks", "exactly three tasks are required")
|
||
}
|
||
rows := make([]model.YumiGiftChallengeTaskConfig, 0, 3)
|
||
codes := map[string]struct{}{}
|
||
enterCount, giftCount := 0, 0
|
||
for _, input := range inputs {
|
||
code := strings.TrimSpace(input.TaskCode)
|
||
taskType := strings.ToUpper(strings.TrimSpace(input.TaskType))
|
||
if !taskCodePattern.MatchString(code) {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "taskCode may contain only letters, digits, underscores, and hyphens")
|
||
}
|
||
if _, exists := codes[code]; exists {
|
||
return nil, NewAppError(http.StatusBadRequest, "duplicate_task_code", "taskCode must be unique")
|
||
}
|
||
codes[code] = struct{}{}
|
||
if strings.TrimSpace(input.TaskTitle) == "" || len(strings.TrimSpace(input.TaskTitle)) > 128 || len(strings.TrimSpace(input.TaskDesc)) > 500 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_text", "task title is required and task text is too long")
|
||
}
|
||
if input.SortOrder < 1 || input.SortOrder > 3 || !input.TargetValue.Positive() || input.ResourceGroupID == nil || input.ResourceGroupID.Int64() <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task", "sortOrder, targetValue and resourceGroupId are required")
|
||
}
|
||
if taskType == TaskEnterPage {
|
||
enterCount++
|
||
if input.TargetValue.Compare(model.Decimal24_2("1.00")) != 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_enter_task", "ENTER_PAGE targetValue must be 1")
|
||
}
|
||
} else if taskType == TaskSendGiftGold {
|
||
giftCount++
|
||
} else {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_type", "taskType must be ENTER_PAGE or SEND_GIFT_GOLD")
|
||
}
|
||
id := input.ID.Int64()
|
||
if id <= 0 {
|
||
var err error
|
||
id, err = utils.NextID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
enabled := true
|
||
if input.Enabled != nil {
|
||
enabled = *input.Enabled
|
||
}
|
||
if !enabled {
|
||
return nil, NewAppError(http.StatusBadRequest, "disabled_task", "all three fixed tasks must be enabled")
|
||
}
|
||
groupID := input.ResourceGroupID.Int64()
|
||
rows = append(rows, model.YumiGiftChallengeTaskConfig{
|
||
ID: id, TaskCode: code, TaskType: taskType, TaskTitle: strings.TrimSpace(input.TaskTitle),
|
||
TaskDesc: strings.TrimSpace(input.TaskDesc), TargetValue: input.TargetValue,
|
||
ResourceGroupID: &groupID, Enabled: enabled, SortOrder: input.SortOrder,
|
||
})
|
||
}
|
||
if enterCount != 1 || giftCount != 2 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_slots", "tasks must contain one ENTER_PAGE and two SEND_GIFT_GOLD")
|
||
}
|
||
sortTaskRows(rows)
|
||
if rows[0].SortOrder != 1 || rows[1].SortOrder != 2 || rows[2].SortOrder != 3 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_sort", "task sortOrder must be exactly 1, 2, and 3")
|
||
}
|
||
thresholds := make([]model.Decimal24_2, 0, 2)
|
||
for _, row := range rows {
|
||
if row.TaskType == TaskSendGiftGold {
|
||
thresholds = append(thresholds, row.TargetValue)
|
||
}
|
||
}
|
||
if thresholds[0].Compare(thresholds[1]) >= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_task_thresholds", "SEND_GIFT_GOLD thresholds must be strictly increasing by sortOrder")
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func normalizeRankRewardInputs(inputs []RankRewardInput, displayTopN int) ([]model.YumiGiftChallengeRankReward, error) {
|
||
if len(inputs) == 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_rewards", "DAILY and OVERALL rank rewards are required")
|
||
}
|
||
rows := make([]model.YumiGiftChallengeRankReward, 0, len(inputs))
|
||
for _, input := range inputs {
|
||
period := strings.ToUpper(strings.TrimSpace(input.PeriodType))
|
||
if period != PeriodDaily && period != PeriodOverall {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_period", "periodType must be DAILY or OVERALL")
|
||
}
|
||
if input.StartRank < 1 || input.EndRank < input.StartRank || input.EndRank > displayTopN || input.ResourceGroupID.Int64() <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward", "rank range and resourceGroupId are invalid")
|
||
}
|
||
id := input.ID.Int64()
|
||
if id <= 0 {
|
||
var err error
|
||
id, err = utils.NextID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
rows = append(rows, model.YumiGiftChallengeRankReward{
|
||
ID: id, PeriodType: period, StartRank: input.StartRank, EndRank: input.EndRank,
|
||
ResourceGroupID: input.ResourceGroupID.Int64(), RewardName: strings.TrimSpace(input.RewardName),
|
||
})
|
||
}
|
||
sort.Slice(rows, func(i, j int) bool {
|
||
if rows[i].PeriodType == rows[j].PeriodType {
|
||
return rows[i].StartRank < rows[j].StartRank
|
||
}
|
||
return rows[i].PeriodType < rows[j].PeriodType
|
||
})
|
||
periodCounts := map[string]int{}
|
||
lastEnd := map[string]int{}
|
||
for _, row := range rows {
|
||
periodCounts[row.PeriodType]++
|
||
if row.StartRank <= lastEnd[row.PeriodType] {
|
||
return nil, NewAppError(http.StatusBadRequest, "overlapping_rank_rewards", "rank reward ranges must not overlap")
|
||
}
|
||
lastEnd[row.PeriodType] = row.EndRank
|
||
}
|
||
if periodCounts[PeriodDaily] == 0 || periodCounts[PeriodOverall] == 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "missing_period_reward", "both DAILY and OVERALL rewards are required")
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// SetEnabled 启停活动;启用在同一事务冻结奖励模板并预建最多 366 个日榜门闩。
|
||
func (s *Service) SetEnabled(ctx context.Context, activityID int64, enabled bool) error {
|
||
activity, err := s.loadActivity(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !time.Now().Before(activity.StartTime) {
|
||
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be enabled or disabled")
|
||
}
|
||
if !enabled {
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var locked model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
if !time.Now().Before(locked.StartTime) {
|
||
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be disabled")
|
||
}
|
||
if err := deletePreStartArtifacts(tx, activityID); err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||
Updates(map[string]any{"enabled": false, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
|
||
})
|
||
}
|
||
|
||
tasks, rewards, err := s.loadActivityChildren(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateStoredConfig(tasks, rewards, activity.DisplayTopN); err != nil {
|
||
return err
|
||
}
|
||
// 不信任历史/人工写入的数据;即使绕过 Save,Enable 也不能生成总榜早于最终日榜的门闩。
|
||
if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil {
|
||
return err
|
||
}
|
||
snapshots, err := s.buildRewardSnapshots(ctx, *activity, tasks, rewards)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
headers, err := buildPeriodHeaders(*activity)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var locked model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
now := time.Now()
|
||
if !locked.StartTime.After(now) || !locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted {
|
||
return NewAppError(http.StatusConflict, "activity_readonly", "activity started while enabling")
|
||
}
|
||
if locked.Version != activity.Version {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
|
||
}
|
||
if err := validateSettlementDelays(locked.DailySettlementDelayMinutes, locked.OverallSettlementDelayMinutes); err != nil {
|
||
return err
|
||
}
|
||
if err := ensureNoEnabledOverlap(tx, locked); err != nil {
|
||
return err
|
||
}
|
||
if err := deletePreStartArtifacts(tx, activityID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||
Updates(map[string]any{"enabled": true, "overall_settlement_status": StatusNotStarted, "version": gorm.Expr("version + 1"), "update_time": time.Now()}).Error
|
||
})
|
||
}
|
||
|
||
func validateStoredConfig(tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward, displayTopN int) error {
|
||
taskInputs := make([]TaskInput, 0, len(tasks))
|
||
for _, row := range tasks {
|
||
var group *FlexibleInt64
|
||
if row.ResourceGroupID != nil {
|
||
value := FlexibleInt64(*row.ResourceGroupID)
|
||
group = &value
|
||
}
|
||
enabled := row.Enabled
|
||
taskInputs = append(taskInputs, TaskInput{
|
||
ID: FlexibleInt64(row.ID), TaskCode: row.TaskCode, TaskType: row.TaskType,
|
||
TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue,
|
||
ResourceGroupID: group, Enabled: &enabled, SortOrder: row.SortOrder,
|
||
})
|
||
}
|
||
if _, err := normalizeTaskInputs(taskInputs); err != nil {
|
||
return err
|
||
}
|
||
rewardInputs := make([]RankRewardInput, 0, len(rewards))
|
||
for _, row := range rewards {
|
||
rewardInputs = append(rewardInputs, RankRewardInput{
|
||
ID: FlexibleInt64(row.ID), PeriodType: row.PeriodType, StartRank: row.StartRank,
|
||
EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName,
|
||
})
|
||
}
|
||
_, err := normalizeRankRewardInputs(rewardInputs, displayTopN)
|
||
return err
|
||
}
|
||
|
||
func (s *Service) buildRewardSnapshots(ctx context.Context, activity model.YumiGiftChallengeActivity, tasks []model.YumiGiftChallengeTaskConfig, rewards []model.YumiGiftChallengeRankReward) ([]model.YumiGiftChallengeRewardSnapshot, error) {
|
||
groupIDs := map[int64]struct{}{}
|
||
for _, task := range tasks {
|
||
if task.ResourceGroupID != nil {
|
||
groupIDs[*task.ResourceGroupID] = struct{}{}
|
||
}
|
||
}
|
||
for _, reward := range rewards {
|
||
groupIDs[reward.ResourceGroupID] = struct{}{}
|
||
}
|
||
ids := make([]int64, 0, len(groupIDs))
|
||
for id := range groupIDs {
|
||
ids = append(ids, id)
|
||
}
|
||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||
now := time.Now()
|
||
rows := make([]model.YumiGiftChallengeRewardSnapshot, 0)
|
||
seen := map[string]struct{}{}
|
||
for _, groupID := range ids {
|
||
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
|
||
if err != nil {
|
||
return nil, NewAppError(http.StatusBadGateway, "reward_group_unavailable", fmt.Sprintf("load reward group %d: %v", groupID, err))
|
||
}
|
||
if int64(detail.ID) != groupID || strings.ToUpper(strings.TrimSpace(detail.SysOrigin)) != activity.SysOrigin {
|
||
return nil, NewAppError(http.StatusBadRequest, "reward_group_tenant_mismatch", fmt.Sprintf("reward group %d does not belong to %s", groupID, activity.SysOrigin))
|
||
}
|
||
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
|
||
return nil, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", fmt.Sprintf("reward group %d is not enabled", groupID))
|
||
}
|
||
if len(detail.RewardConfigList) == 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "empty_reward_group", fmt.Sprintf("reward group %d has no reward items", groupID))
|
||
}
|
||
sort.Slice(detail.RewardConfigList, func(i, j int) bool {
|
||
if detail.RewardConfigList[i].Sort == detail.RewardConfigList[j].Sort {
|
||
return int64(detail.RewardConfigList[i].ID) < int64(detail.RewardConfigList[j].ID)
|
||
}
|
||
return detail.RewardConfigList[i].Sort < detail.RewardConfigList[j].Sort
|
||
})
|
||
for index, item := range detail.RewardConfigList {
|
||
rewardConfigID := int64(item.ID)
|
||
if rewardConfigID <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_reward_item", fmt.Sprintf("reward group %d contains an invalid item", groupID))
|
||
}
|
||
key := fmt.Sprintf("%d:%d", groupID, rewardConfigID)
|
||
if _, exists := seen[key]; exists {
|
||
return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_item", "reward group contains duplicate reward item")
|
||
}
|
||
seen[key] = struct{}{}
|
||
id, idErr := utils.NextID()
|
||
if idErr != nil {
|
||
return nil, idErr
|
||
}
|
||
sortOrder := item.Sort
|
||
if sortOrder <= 0 {
|
||
sortOrder = index + 1
|
||
}
|
||
displayName := truncate(firstNonBlank(item.Name, item.BadgeName, item.Remark), 500)
|
||
rows = append(rows, model.YumiGiftChallengeRewardSnapshot{
|
||
ID: id, ActivityID: activity.ID, ResourceGroupID: groupID, RewardConfigID: rewardConfigID,
|
||
RewardType: strings.ToUpper(strings.TrimSpace(item.Type)), DetailType: strings.TrimSpace(item.DetailType),
|
||
Content: strings.TrimSpace(item.Content), Quantity: int64(item.Quantity), SortOrder: sortOrder,
|
||
Remark: strings.TrimSpace(item.Remark), Cover: strings.TrimSpace(item.Cover),
|
||
SourceURL: strings.TrimSpace(item.SourceURL), DisplayName: displayName, CreateTime: now,
|
||
})
|
||
if len(rows) > maxSnapshotItems {
|
||
return nil, NewAppError(http.StatusBadRequest, "too_many_reward_items", "activity reward snapshot may contain at most 5000 items")
|
||
}
|
||
}
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func firstNonBlank(values ...string) string {
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func buildPeriodHeaders(activity model.YumiGiftChallengeActivity) ([]model.YumiGiftChallengePeriodSettlement, error) {
|
||
// 所有 Save/Enable/任务与榜奖重建路径最终都经过这里;在门闩生成入口再校验一次,
|
||
// 防止历史脏数据或后续新增调用方绕过配置层,生成总榜早于最终日榜的关闭时间。
|
||
if err := validateSettlementDelays(activity.DailySettlementDelayMinutes, activity.OverallSettlementDelayMinutes); err != nil {
|
||
return nil, err
|
||
}
|
||
location, err := resolveLocation(activity.Timezone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := time.Now()
|
||
rows := make([]model.YumiGiftChallengePeriodSettlement, 0, maxActivityDays+1)
|
||
for day := dateOnly(activity.StartTime, location); day.Before(activity.EndTime); day = day.AddDate(0, 0, 1) {
|
||
periodEnd := day.AddDate(0, 0, 1)
|
||
if periodEnd.After(activity.EndTime) {
|
||
periodEnd = activity.EndTime
|
||
}
|
||
id, idErr := utils.NextID()
|
||
if idErr != nil {
|
||
return nil, idErr
|
||
}
|
||
storedDate := dateKey(day, location)
|
||
rows = append(rows, model.YumiGiftChallengePeriodSettlement{
|
||
ID: id, ActivityID: activity.ID, PeriodType: PeriodDaily, PeriodKey: dateKey(day, location),
|
||
StatDate: &storedDate, SnapshotDueTime: periodEnd.Add(time.Duration(activity.DailySettlementDelayMinutes) * time.Minute),
|
||
Status: StatusNotStarted, CreateTime: now, UpdateTime: now,
|
||
})
|
||
if len(rows) > maxActivityDays {
|
||
return nil, NewAppError(http.StatusBadRequest, "activity_too_long", "activity may cover at most 366 activity days")
|
||
}
|
||
}
|
||
id, err := utils.NextID()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows = append(rows, model.YumiGiftChallengePeriodSettlement{
|
||
ID: id, ActivityID: activity.ID, PeriodType: PeriodOverall, PeriodKey: PeriodOverall,
|
||
SnapshotDueTime: activity.OverallSettlementTime, Status: StatusNotStarted, CreateTime: now, UpdateTime: now,
|
||
})
|
||
return rows, nil
|
||
}
|
||
|
||
func ensureNoEnabledOverlap(tx *gorm.DB, activity model.YumiGiftChallengeActivity) error {
|
||
var count int64
|
||
err := tx.Model(&model.YumiGiftChallengeActivity{}).
|
||
Where("sys_origin = ? AND enabled = ? AND id <> ? AND start_time < ? AND end_time > ?",
|
||
activity.SysOrigin, true, activity.ID, activity.EndTime, activity.StartTime).
|
||
Count(&count).Error
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return NewAppError(http.StatusConflict, "activity_time_overlap", "enabled activity time windows must not overlap")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func deletePreStartArtifacts(tx *gorm.DB, activityID int64) error {
|
||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRewardSnapshot{}).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Where("activity_id = ? AND status = ?", activityID, StatusNotStarted).
|
||
Delete(&model.YumiGiftChallengePeriodSettlement{}).Error
|
||
}
|
||
|
||
func isDuplicateKey(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
message := strings.ToLower(err.Error())
|
||
return strings.Contains(message, "duplicate entry") || strings.Contains(message, "unique constraint")
|
||
}
|
||
|
||
// SaveTasks 单独保存三个任务;若活动已启用则在同一次后台操作里重建完整冻结产物。
|
||
func (s *Service) SaveTasks(ctx context.Context, activityID int64, req TaskSaveRequest) (*DetailResponse, error) {
|
||
if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID {
|
||
return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match")
|
||
}
|
||
rows, err := normalizeTaskInputs(req.Tasks)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
activity, err := s.loadActivity(ctx, activityID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !time.Now().Before(activity.StartTime) {
|
||
return nil, NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
|
||
}
|
||
_, rewards, err := s.loadActivityChildren(ctx, activityID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := time.Now()
|
||
for i := range rows {
|
||
rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now
|
||
}
|
||
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
||
var headers []model.YumiGiftChallengePeriodSettlement
|
||
if activity.Enabled {
|
||
if err := validateStoredConfig(rows, rewards, activity.DisplayTopN); err != nil {
|
||
return nil, err
|
||
}
|
||
snapshots, err = s.buildRewardSnapshots(ctx, *activity, rows, rewards)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err = buildPeriodHeaders(*activity)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var locked model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
now := time.Now()
|
||
if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) {
|
||
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
|
||
}
|
||
if locked.Version != activity.Version || locked.Enabled != activity.Enabled {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
|
||
}
|
||
if err := deletePreStartArtifacts(tx, activityID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeTaskConfig{}).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Create(&rows).Error; err != nil {
|
||
return err
|
||
}
|
||
if activity.Enabled {
|
||
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||
Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetAdminDetail(ctx, activityID)
|
||
}
|
||
|
||
// SaveRankRewards 单独保存日榜/总榜奖励区间,并按启用状态重建活动级奖励快照。
|
||
func (s *Service) SaveRankRewards(ctx context.Context, activityID int64, req RankRewardSaveRequest) (*DetailResponse, error) {
|
||
if req.ActivityID.Int64() > 0 && req.ActivityID.Int64() != activityID {
|
||
return nil, NewAppError(http.StatusBadRequest, "activity_id_mismatch", "path id and activityId must match")
|
||
}
|
||
activity, err := s.loadActivity(ctx, activityID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows, err := normalizeRankRewardInputs(req.RankRewards, activity.DisplayTopN)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !time.Now().Before(activity.StartTime) {
|
||
return nil, NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
|
||
}
|
||
tasks, _, err := s.loadActivityChildren(ctx, activityID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := time.Now()
|
||
for i := range rows {
|
||
rows[i].ActivityID, rows[i].CreateTime, rows[i].UpdateTime = activityID, now, now
|
||
}
|
||
var snapshots []model.YumiGiftChallengeRewardSnapshot
|
||
var headers []model.YumiGiftChallengePeriodSettlement
|
||
if activity.Enabled {
|
||
if err := validateStoredConfig(tasks, rows, activity.DisplayTopN); err != nil {
|
||
return nil, err
|
||
}
|
||
snapshots, err = s.buildRewardSnapshots(ctx, *activity, tasks, rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers, err = buildPeriodHeaders(*activity)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var locked model.YumiGiftChallengeActivity
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", activityID).First(&locked).Error; err != nil {
|
||
return err
|
||
}
|
||
now := time.Now()
|
||
if !locked.StartTime.After(now) || (locked.Enabled && (!locked.EndTime.After(now) || locked.OverallSettlementStatus != StatusNotStarted)) {
|
||
return NewAppError(http.StatusConflict, "activity_readonly", "started activity cannot be edited")
|
||
}
|
||
if locked.Version != activity.Version || locked.Enabled != activity.Enabled {
|
||
return NewAppError(http.StatusConflict, "version_conflict", "activity changed while reward snapshot was loading")
|
||
}
|
||
if err := deletePreStartArtifacts(tx, activityID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Where("activity_id = ?", activityID).Delete(&model.YumiGiftChallengeRankReward{}).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Create(&rows).Error; err != nil {
|
||
return err
|
||
}
|
||
if activity.Enabled {
|
||
if err := tx.CreateInBatches(snapshots, 500).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.CreateInBatches(headers, 100).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return tx.Model(&model.YumiGiftChallengeActivity{}).Where("id = ?", activityID).
|
||
Updates(map[string]any{"version": gorm.Expr("version + 1"), "update_time": now}).Error
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetAdminDetail(ctx, activityID)
|
||
}
|