2026-04-27 19:12:00 +08:00

554 lines
19 KiB
Go

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
}
}