package weekstar import ( "context" "errors" "net/http" "sort" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) // GetConfig 返回指定配置详情;未传 ID 时默认返回当前生效配置,若当前周不存在则返回最近未来周配置。 func (s *WeekStarService) GetConfig(ctx context.Context, sysOrigin string, id int64) (*WeekStarConfigResponse, error) { var ( configRow *model.WeekStarActivityConfig giftRows []model.WeekStarActivityGiftConfig rewardRows []model.WeekStarActivityRewardConfig err error ) // 显式传了配置 ID 时按 ID 查;否则优先取当前生效配置,再回退未来周配置。 if id > 0 { configRow, giftRows, rewardRows, err = s.loadConfigBundleByID(ctx, id, sysOrigin) } else { configRow, giftRows, rewardRows, err = s.loadPreferredAdminConfigBundle(ctx, sysOrigin, time.Now().In(s.location)) } if err != nil { return nil, err } if configRow == nil { return &WeekStarConfigResponse{ Configured: false, SysOrigin: s.normalizeSysOrigin(sysOrigin), Timezone: s.location.String(), GiftConfigs: []WeekStarGiftConfigPayload{}, RewardConfigs: []WeekStarRewardConfigPayload{}, }, nil } return s.buildConfigResponse(ctx, configRow, giftRows, rewardRows, true) } // PageConfigs 按系统分页返回多周活动配置列表,供后台查看和选择未来周配置。 func (s *WeekStarService) PageConfigs(ctx context.Context, sysOrigin string, cursor, limit int) (map[string]any, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) page, size := normalizePage(cursor, limit) query := s.repo.DB.WithContext(ctx). Model(&model.WeekStarActivityConfig{}). Where("sys_origin = ?", sysOrigin) var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.WeekStarActivityConfig if total > 0 { if err := query. Order("start_at desc"). Order("id desc"). Offset((page - 1) * size). Limit(size). Find(&rows).Error; err != nil { return nil, err } } records := make([]WeekStarConfigResponse, 0, len(rows)) for index := range rows { gifts, rewards, err := s.loadConfigChildren(ctx, rows[index].ID) if err != nil { return nil, err } record, err := s.buildConfigResponse(ctx, &rows[index], gifts, rewards, false) if err != nil { return nil, err } if record != nil { records = append(records, *record) } } return map[string]any{ "records": records, "total": total, }, nil } // SaveConfig 创建或更新未来周配置,并校验礼物、奖励和启用配置时间窗不重叠。 func (s *WeekStarService) SaveConfig(ctx context.Context, req SaveWeekStarConfigRequest) (*WeekStarConfigResponse, error) { requestID := req.ID.Int64() sysOrigin := s.normalizeSysOrigin(req.SysOrigin) timezone := strings.TrimSpace(req.Timezone) if timezone == "" { timezone = s.cfg.WeekStar.Timezone } location, err := time.LoadLocation(timezone) if err != nil { return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) } if len(req.GiftConfigs) != 3 { return nil, NewAppError(http.StatusBadRequest, "invalid_gift_configs", "giftConfigs must contain exactly 3 gifts") } if len(req.RewardConfigs) != 3 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_configs", "rewardConfigs must contain exactly 3 rewards") } startAt, err := parseDateTimeInLocation(req.StartAt, location) if err != nil { return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", err.Error()) } endAt, err := parseDateTimeInLocation(req.EndAt, location) if err != nil { return nil, NewAppError(http.StatusBadRequest, "invalid_end_at", err.Error()) } if !endAt.After(startAt) { return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "endAt must be later than startAt") } now := time.Now().In(location) if requestID > 0 { if !startAt.After(now) { return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "only future-week configs can be updated") } } else if !startAt.After(now) { currentCycleStart, _ := s.cycleBounds(now.In(s.location)) if startAt.In(s.location).Before(currentCycleStart) { return nil, NewAppError(http.StatusBadRequest, "invalid_start_at", "current-week config cannot start before this week") } if !endAt.After(now) { return nil, NewAppError(http.StatusBadRequest, "invalid_time_range", "current-week config must end in the future") } } giftIDs := make(map[int64]struct{}, len(req.GiftConfigs)) // 先把礼物和奖励配置标准化,避免后续事务里还掺杂输入清洗逻辑。 normalizedGifts := make([]model.WeekStarActivityGiftConfig, 0, len(req.GiftConfigs)) for index, item := range req.GiftConfigs { giftID := item.GiftID.Int64() if giftID <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_gift_id", "giftId is required") } if _, exists := giftIDs[giftID]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_gift_id", "giftConfigs must not contain duplicate gifts") } giftIDs[giftID] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } normalizedGifts = append(normalizedGifts, model.WeekStarActivityGiftConfig{ ID: id, GiftID: giftID, GiftName: strings.TrimSpace(item.GiftName), GiftPhoto: strings.TrimSpace(item.GiftPhoto), GiftCandy: item.GiftCandy, Sort: index + 1, }) } rewardRanks := map[int]struct{}{1: {}, 2: {}, 3: {}} seenRanks := make(map[int]struct{}, len(req.RewardConfigs)) normalizedRewards := make([]model.WeekStarActivityRewardConfig, 0, len(req.RewardConfigs)) for _, item := range req.RewardConfigs { rewardGroupID := item.RewardGroupID.Int64() if _, ok := rewardRanks[item.Rank]; !ok { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_rank", "reward rank must be 1, 2, or 3") } if rewardGroupID <= 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "rewardGroupId is required") } if _, exists := seenRanks[item.Rank]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_reward_rank", "rewardConfigs must not contain duplicate ranks") } rewardItems, rewardGroupName, err := s.loadRewardPreviewItems(ctx, rewardGroupID) if err != nil || len(rewardItems) == 0 { return nil, NewAppError(http.StatusBadRequest, "invalid_reward_group_id", "reward group detail is empty or unavailable") } seenRanks[item.Rank] = struct{}{} id, idErr := utils.NextID() if idErr != nil { return nil, idErr } if strings.TrimSpace(rewardGroupName) == "" { rewardGroupName = item.RewardGroupName } normalizedRewards = append(normalizedRewards, model.WeekStarActivityRewardConfig{ ID: id, Rank: item.Rank, RewardGroupID: rewardGroupID, RewardGroupName: strings.TrimSpace(rewardGroupName), }) } sort.Slice(normalizedRewards, func(i, j int) bool { return normalizedRewards[i].Rank < normalizedRewards[j].Rank }) var savedConfigID int64 // 主配置与子配置在同一个事务中写入,避免出现半保存状态。 if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() storedStartAt := s.toStorageWallClock(startAt) storedEndAt := s.toStorageWallClock(endAt) var configRow model.WeekStarActivityConfig if requestID > 0 { if err := tx.Where("id = ?", requestID).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "week_star_config_not_found", "config not found") } else if err != nil { return err } if !s.isConfigEditable(&configRow, time.Now().In(s.location)) { return NewAppError(http.StatusBadRequest, "week_star_config_readonly", "only future-week configs can be edited") } if strings.TrimSpace(req.SysOrigin) == "" { sysOrigin = configRow.SysOrigin } } else { configID, idErr := utils.NextID() if idErr != nil { return idErr } configRow = model.WeekStarActivityConfig{ ID: configID, SysOrigin: sysOrigin, Enabled: req.Enabled, StartAt: storedStartAt, EndAt: storedEndAt, Timezone: timezone, CreateTime: now, UpdateTime: now, } } if req.Enabled { if err := s.ensureNoEnabledOverlap(ctx, tx, sysOrigin, configRow.ID, storedStartAt, storedEndAt); err != nil { return err } } configRow.SysOrigin = sysOrigin configRow.Enabled = req.Enabled configRow.StartAt = storedStartAt configRow.EndAt = storedEndAt configRow.Timezone = timezone configRow.UpdateTime = now if requestID > 0 { if err := tx.Model(&model.WeekStarActivityConfig{}). Where("id = ?", configRow.ID). Updates(map[string]any{ "sys_origin": configRow.SysOrigin, "enabled": configRow.Enabled, "start_at": configRow.StartAt, "end_at": configRow.EndAt, "timezone": configRow.Timezone, "update_time": configRow.UpdateTime, }).Error; err != nil { return err } } else if err := tx.Create(&configRow).Error; err != nil { return err } if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WeekStarActivityGiftConfig{}).Error; err != nil { return err } if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.WeekStarActivityRewardConfig{}).Error; err != nil { return err } for index := range normalizedGifts { normalizedGifts[index].ConfigID = configRow.ID normalizedGifts[index].CreateTime = now normalizedGifts[index].UpdateTime = now } for index := range normalizedRewards { normalizedRewards[index].ConfigID = configRow.ID normalizedRewards[index].CreateTime = now normalizedRewards[index].UpdateTime = now } if err := tx.Create(&normalizedGifts).Error; err != nil { return err } if err := tx.Create(&normalizedRewards).Error; err != nil { return err } savedConfigID = configRow.ID return nil }); err != nil { return nil, err } return s.GetConfig(ctx, sysOrigin, savedConfigID) } // buildConfigResponse 把配置主表和子表组装成后台接口需要的展示结构。 func (s *WeekStarService) buildConfigResponse(ctx context.Context, configRow *model.WeekStarActivityConfig, giftRows []model.WeekStarActivityGiftConfig, rewardRows []model.WeekStarActivityRewardConfig, includeItems bool) (*WeekStarConfigResponse, error) { rewards := make([]WeekStarRewardConfigPayload, 0, len(rewardRows)) sort.Slice(rewardRows, func(i, j int) bool { return rewardRows[i].Rank < rewardRows[j].Rank }) for _, row := range rewardRows { payload := WeekStarRewardConfigPayload{ Rank: row.Rank, RewardGroupID: row.RewardGroupID, RewardGroupName: row.RewardGroupName, } if includeItems && row.RewardGroupID > 0 { items, groupName, err := s.loadRewardPreviewItems(ctx, row.RewardGroupID) if err == nil { payload.Items = items if strings.TrimSpace(groupName) != "" { payload.RewardGroupName = groupName } } } rewards = append(rewards, payload) } now := time.Now().In(s.location) return &WeekStarConfigResponse{ Configured: true, ID: configRow.ID, SysOrigin: configRow.SysOrigin, Enabled: configRow.Enabled, Status: s.resolveActivityStatus(configRow, now), Editable: s.isConfigEditable(configRow, now), StartAt: formatDateTime(s.fromStorageWallClock(configRow.StartAt)), EndAt: formatDateTime(s.fromStorageWallClock(configRow.EndAt)), Timezone: configRow.Timezone, UpdateTime: formatDateTime(configRow.UpdateTime), GiftConfigs: buildGiftPayloads(giftRows), RewardConfigs: rewards, }, nil } // buildRewardPreview 把奖励配置组装成 H5 可直接渲染的奖励预览数据。 func (s *WeekStarService) buildRewardPreview(ctx context.Context, rewardRows []model.WeekStarActivityRewardConfig) ([]WeekStarRewardConfigPayload, error) { sort.Slice(rewardRows, func(i, j int) bool { return rewardRows[i].Rank < rewardRows[j].Rank }) result := make([]WeekStarRewardConfigPayload, 0, len(rewardRows)) for _, row := range rewardRows { payload := WeekStarRewardConfigPayload{ Rank: row.Rank, RewardGroupID: row.RewardGroupID, RewardGroupName: row.RewardGroupName, } if row.RewardGroupID > 0 { items, groupName, err := s.loadRewardPreviewItems(ctx, row.RewardGroupID) if err == nil { payload.Items = items if strings.TrimSpace(groupName) != "" { payload.RewardGroupName = groupName } } } result = append(result, payload) } return result, nil } // loadRewardPreviewItems 从 Java 奖励组详情接口拉取预览物品列表。 func (s *WeekStarService) loadRewardPreviewItems(ctx context.Context, rewardGroupID int64) ([]WeekStarRewardPreviewItem, string, error) { detail, err := s.java.GetRewardGroupDetail(ctx, rewardGroupID) if err != nil { return nil, "", err } items := make([]WeekStarRewardPreviewItem, 0, len(detail.RewardConfigList)) for _, item := range detail.RewardConfigList { items = append(items, WeekStarRewardPreviewItem{ ID: int64(item.ID), Type: item.Type, Name: item.Name, Content: item.Content, Quantity: int64(item.Quantity), Cover: item.Cover, Remark: item.Remark, }) } sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) return items, detail.Name, nil } // loadConfigBundleByID 按配置 ID 读取完整配置,并校验它是否属于目标系统。 func (s *WeekStarService) loadConfigBundleByID(ctx context.Context, id int64, sysOrigin string) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { if id <= 0 { return nil, nil, nil, nil } var configRow model.WeekStarActivityConfig err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil } if err != nil { return nil, nil, nil, err } if strings.TrimSpace(sysOrigin) != "" && configRow.SysOrigin != s.normalizeSysOrigin(sysOrigin) { return nil, nil, nil, nil } gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) if err != nil { return nil, nil, nil, err } return &configRow, gifts, rewards, nil } // loadPreferredAdminConfigBundle 优先选择当前周配置,其次选择最近未来周配置,再回退到最新一条配置。 func (s *WeekStarService) loadPreferredAdminConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { if configRow, gifts, rewards, err := s.loadActiveConfigBundle(ctx, sysOrigin, at); err != nil || configRow != nil { return configRow, gifts, rewards, err } if configRow, gifts, rewards, err := s.loadUpcomingConfigBundle(ctx, sysOrigin, at); err != nil || configRow != nil { return configRow, gifts, rewards, err } sysOrigin = s.normalizeSysOrigin(sysOrigin) var configRow model.WeekStarActivityConfig err := s.repo.DB.WithContext(ctx). Where("sys_origin = ?", sysOrigin). Order("start_at desc"). Order("id desc"). First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil } if err != nil { return nil, nil, nil, err } gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) if err != nil { return nil, nil, nil, err } return &configRow, gifts, rewards, nil } // loadActiveConfigBundle 根据事件时间或当前时间查找正在生效的启用配置,时间窗语义为 [startAt, endAt)。 func (s *WeekStarService) loadActiveConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) storedAt := s.toStorageWallClock(at) var configRow model.WeekStarActivityConfig err := s.repo.DB.WithContext(ctx). Where( "sys_origin = ? AND enabled = ? AND start_at <= ? AND end_at > ?", sysOrigin, true, storedAt, storedAt, ). Order("start_at asc"). Order("id asc"). First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil } if err != nil { return nil, nil, nil, err } gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) if err != nil { return nil, nil, nil, err } return &configRow, gifts, rewards, nil } // loadUpcomingConfigBundle 查找最近的未来周启用配置,用于当前周结束后的自动切换展示。 func (s *WeekStarService) loadUpcomingConfigBundle(ctx context.Context, sysOrigin string, at time.Time) (*model.WeekStarActivityConfig, []model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { sysOrigin = s.normalizeSysOrigin(sysOrigin) var configRow model.WeekStarActivityConfig err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND enabled = ? AND start_at > ?", sysOrigin, true, s.toStorageWallClock(at)). Order("start_at asc"). Order("id asc"). First(&configRow).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil } if err != nil { return nil, nil, nil, err } gifts, rewards, err := s.loadConfigChildren(ctx, configRow.ID) if err != nil { return nil, nil, nil, err } return &configRow, gifts, rewards, nil } // ensureNoEnabledOverlap 校验同一系统下启用状态的配置时间窗不重叠。 func (s *WeekStarService) ensureNoEnabledOverlap(ctx context.Context, tx *gorm.DB, sysOrigin string, excludeID int64, startAt, endAt time.Time) error { query := tx.WithContext(ctx). Model(&model.WeekStarActivityConfig{}). Where("sys_origin = ? AND enabled = ?", sysOrigin, true). Where("start_at < ? AND end_at > ?", endAt, startAt) if excludeID > 0 { query = query.Where("id <> ?", excludeID) } var count int64 if err := query.Count(&count).Error; err != nil { return err } if count > 0 { return NewAppError(http.StatusBadRequest, "week_star_config_overlap", "enabled configs must not overlap in time range") } return nil } // isConfigEditable 判断配置是否仍处于未开始状态,只有未来周配置允许后台编辑。 func (s *WeekStarService) isConfigEditable(configRow *model.WeekStarActivityConfig, now time.Time) bool { if configRow == nil { return false } return s.fromStorageWallClock(configRow.StartAt).After(now.In(s.location)) } // loadConfigChildren 读取某个活动配置下的指定礼物和奖励组子表。 func (s *WeekStarService) loadConfigChildren(ctx context.Context, configID int64) ([]model.WeekStarActivityGiftConfig, []model.WeekStarActivityRewardConfig, error) { var gifts []model.WeekStarActivityGiftConfig if err := s.repo.DB.WithContext(ctx). Where("config_id = ?", configID). Order("sort asc"). Find(&gifts).Error; err != nil { return nil, nil, err } var rewards []model.WeekStarActivityRewardConfig if err := s.repo.DB.WithContext(ctx). Where("config_id = ?", configID). Order("`rank` asc"). Find(&rewards).Error; err != nil { return nil, nil, err } return gifts, rewards, nil } // resolveActivityStatus 根据当前时间判断配置处于未开始、进行中、已结束或已关闭状态。 func (s *WeekStarService) resolveActivityStatus(configRow *model.WeekStarActivityConfig, now time.Time) string { if configRow == nil || !configRow.Enabled { return weekStarActivityStatusDisabled } startAt := s.fromStorageWallClock(configRow.StartAt) endAt := s.fromStorageWallClock(configRow.EndAt) switch { case now.Before(startAt): return weekStarActivityStatusNotStarted case !now.Before(endAt): return weekStarActivityStatusEnded default: return weekStarActivityStatusOngoing } }