package yumigiftchallenge import ( "context" "errors" "net/http" "sort" "strings" "time" "chatapp3-golang/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (s *Service) normalizeSysOrigin(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" { value = strings.ToUpper(strings.TrimSpace(s.cfg.YumiGiftChallenge.DefaultSysOrigin)) } if value == "" { value = defaultSysOrigin } return value } func (s *Service) requireYumiOrigin(value string) (string, error) { origin := s.normalizeSysOrigin(value) // Yumi 是产品品牌,真实 Java SysOriginPlatformEnum 为 LIKEI。显式拒绝其他来源, // 防止后台误建一个永远收不到 Yumi 送礼事件的孤立活动。 if origin != defaultSysOrigin { return "", NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi gift challenge sysOrigin must be LIKEI") } return origin, nil } func resolveLocation(value string) (*time.Location, error) { value = strings.TrimSpace(value) if value == "" { value = defaultTimezone } location, err := time.LoadLocation(value) if err != nil { return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) } return location, nil } func dateOnly(value time.Time, location *time.Location) time.Time { local := value.In(location) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) } func dateKey(value time.Time, location *time.Location) string { return value.In(location).Format("2006-01-02") } func parseDateKey(value string, location *time.Location) (time.Time, error) { parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), location) if err != nil { return time.Time{}, NewAppError(http.StatusBadRequest, "invalid_stat_date", "statDate must be yyyy-MM-dd") } return parsed, nil } func activityStatus(activity model.YumiGiftChallengeActivity, now time.Time) string { if !activity.Enabled { return "DISABLED" } if now.Before(activity.StartTime) { return "NOT_STARTED" } if !now.Before(activity.EndTime) { return "ENDED" } return "ONGOING" } func (s *Service) loadActivity(ctx context.Context, id int64) (*model.YumiGiftChallengeActivity, error) { if id <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required") } var row model.YumiGiftChallengeActivity err := s.db.WithContext(ctx).Where("id = ?", id).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } if err != nil { return nil, err } return &row, nil } func (s *Service) selectActivity(ctx context.Context, origin string, id int64, enabledOnly bool) (*model.YumiGiftChallengeActivity, error) { if id > 0 { row, err := s.loadActivity(ctx, id) if err != nil { return nil, err } if row.SysOrigin != origin || (enabledOnly && !row.Enabled) { return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } return row, nil } now := time.Now() query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}). Where("sys_origin = ?", origin) if enabledOnly { query = query.Where("enabled = ?", true) } // 进行中优先,其次最近待开始,最后最近结束;CASE 只在单一来源的少量活动配置上执行, // 热榜和送礼链路不会走该排序。 var row model.YumiGiftChallengeActivity err := query.Order(clause.Expr{SQL: "CASE WHEN start_time <= ? AND end_time > ? THEN 0 WHEN start_time > ? THEN 1 ELSE 2 END", Vars: []any{now, now, now}}). Order(clause.Expr{SQL: "CASE WHEN start_time > ? THEN start_time END ASC", Vars: []any{now}}). Order("end_time DESC").First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found") } if err != nil { return nil, err } return &row, nil } func (s *Service) loadActivityChildren(ctx context.Context, activityID int64) ([]model.YumiGiftChallengeTaskConfig, []model.YumiGiftChallengeRankReward, error) { var tasks []model.YumiGiftChallengeTaskConfig if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil { return nil, nil, err } var rewards []model.YumiGiftChallengeRankReward if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID). Order("period_type ASC, start_rank ASC, id ASC").Find(&rewards).Error; err != nil { return nil, nil, err } return tasks, rewards, nil } func (s *Service) loadRewardSnapshots(ctx context.Context, activityID int64) (map[int64][]model.YumiGiftChallengeRewardSnapshot, error) { var rows []model.YumiGiftChallengeRewardSnapshot if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID). Order("resource_group_id ASC, sort_order ASC, reward_config_id ASC").Find(&rows).Error; err != nil { return nil, err } result := make(map[int64][]model.YumiGiftChallengeRewardSnapshot) for _, row := range rows { result[row.ResourceGroupID] = append(result[row.ResourceGroupID], row) } return result, nil } func rewardItemViews(rows []model.YumiGiftChallengeRewardSnapshot) []RewardItemView { items := make([]RewardItemView, 0, len(rows)) for _, row := range rows { items = append(items, RewardItemView{ ID: row.RewardConfigID, Type: row.RewardType, DetailType: row.DetailType, Content: row.Content, Quantity: row.Quantity, Cover: row.Cover, SourceURL: row.SourceURL, Name: row.DisplayName, Sort: row.SortOrder, }) } return items } func buildActivityView(row model.YumiGiftChallengeActivity) ActivityView { return ActivityView{ ID: row.ID, ActivityCode: row.ActivityCode, ActivityName: row.ActivityName, ActivityDesc: row.ActivityDesc, SysOrigin: row.SysOrigin, Timezone: row.Timezone, StartTime: row.StartTime.UnixMilli(), EndTime: row.EndTime.UnixMilli(), DailySettlementDelayMinutes: row.DailySettlementDelayMinutes, OverallSettlementDelayMinutes: row.OverallSettlementDelayMinutes, OverallSettlementTime: row.OverallSettlementTime.UnixMilli(), DisplayTopN: row.DisplayTopN, Enabled: row.Enabled, OverallSettlementStatus: row.OverallSettlementStatus, Version: row.Version, CreateTime: row.CreateTime.UnixMilli(), UpdateTime: row.UpdateTime.UnixMilli(), } } func (s *Service) buildDetail(ctx context.Context, activity model.YumiGiftChallengeActivity) (*DetailResponse, error) { tasks, rewards, err := s.loadActivityChildren(ctx, activity.ID) if err != nil { return nil, err } snapshots, err := s.loadRewardSnapshots(ctx, activity.ID) if err != nil { return nil, err } taskViews := make([]TaskView, 0, len(tasks)) for _, row := range tasks { view := TaskView{ ID: row.ID, ActivityID: row.ActivityID, TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, TaskDesc: row.TaskDesc, TargetValue: row.TargetValue, ResourceGroupID: row.ResourceGroupID, Enabled: row.Enabled, SortOrder: row.SortOrder, RewardItems: []RewardItemView{}, } if row.ResourceGroupID != nil { view.RewardItems = rewardItemViews(snapshots[*row.ResourceGroupID]) } taskViews = append(taskViews, view) } rewardViews := make([]RankRewardView, 0, len(rewards)) for _, row := range rewards { rewardViews = append(rewardViews, RankRewardView{ ID: row.ID, ActivityID: row.ActivityID, PeriodType: row.PeriodType, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID, RewardName: row.RewardName, RewardItems: rewardItemViews(snapshots[row.ResourceGroupID]), }) } return &DetailResponse{ Activity: buildActivityView(activity), Tasks: taskViews, RankRewards: rewardViews, SupportedPeriods: []string{PeriodDaily, PeriodOverall}, SupportedTaskTypes: []string{TaskEnterPage, TaskSendGiftGold}, ActivityStatus: activityStatus(activity, time.Now()), ServerTime: time.Now().UnixMilli(), }, nil } // GetDetail 返回 Yumi H5 活动详情,用户身份只用于来源隔离,不接受客户端伪造 userId。 func (s *Service) GetDetail(ctx context.Context, user AuthUser, activityID int64) (*DetailResponse, error) { origin, err := s.requireYumiOrigin(user.SysOrigin) if err != nil { return nil, err } activity, err := s.selectActivity(ctx, origin, activityID, true) if err != nil { return nil, err } return s.buildDetail(ctx, *activity) } // GetAdminDetail 返回后台完整配置详情。 func (s *Service) GetAdminDetail(ctx context.Context, activityID int64) (*DetailResponse, error) { activity, err := s.loadActivity(ctx, activityID) if err != nil { return nil, err } return s.buildDetail(ctx, *activity) } // ListActivities 分页读取配置;查询始终命中 sys_origin+时间索引,不扫描送礼明细。 func (s *Service) ListActivities(ctx context.Context, origin string, page, size int) (map[string]any, error) { origin, err := s.requireYumiOrigin(origin) if err != nil { return nil, err } if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } query := s.db.WithContext(ctx).Model(&model.YumiGiftChallengeActivity{}).Where("sys_origin = ?", origin) var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.YumiGiftChallengeActivity if err := query.Order("start_time DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { return nil, err } records := make([]ActivityView, 0, len(rows)) for _, row := range rows { records = append(records, buildActivityView(row)) } return map[string]any{"records": records, "total": total, "current": page, "size": size}, nil } func sortTaskRows(rows []model.YumiGiftChallengeTaskConfig) { sort.Slice(rows, func(i, j int) bool { if rows[i].SortOrder == rows[j].SortOrder { return rows[i].ID < rows[j].ID } return rows[i].SortOrder < rows[j].SortOrder }) }