352 lines
13 KiB
Go
352 lines
13 KiB
Go
package gameking
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"chatapp3-golang/internal/integration"
|
||
"chatapp3-golang/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
|
||
|
||
func normalizeSysOrigin(value string) string {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return "LIKEI"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func normalizeTimezone(value string) (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", "timeZone must be a valid IANA timezone")
|
||
}
|
||
return value, location, nil
|
||
}
|
||
|
||
func normalizeRankingType(value string) (string, error) {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
if value == "" {
|
||
value = rankingTypeTycoon
|
||
}
|
||
if value != rankingTypeTycoon {
|
||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON")
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func normalizeRankingPeriod(value string) (string, error) {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
if value == "" {
|
||
value = rankingPeriodOverall
|
||
}
|
||
if value != rankingPeriodOverall {
|
||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_period", "configured rankingPeriod must be OVERALL")
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) {
|
||
req.ActivityCode = strings.TrimSpace(req.ActivityCode)
|
||
req.ActivityName = strings.TrimSpace(req.ActivityName)
|
||
req.ActivityDesc = strings.TrimSpace(req.ActivityDesc)
|
||
if req.ActivityCode == "" || len(req.ActivityCode) > 64 {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_code", "activityCode is required and must not exceed 64 characters")
|
||
}
|
||
if req.ActivityName == "" || len(req.ActivityName) > 128 {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_name", "activityName is required and must not exceed 128 characters")
|
||
}
|
||
if len(req.ActivityDesc) > 1000 {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_desc", "activityDesc must not exceed 1000 characters")
|
||
}
|
||
req.SysOrigin = normalizeSysOrigin(req.SysOrigin)
|
||
if req.SysOrigin != "LIKEI" {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_sys_origin", "Yumi game king only supports LIKEI sysOrigin")
|
||
}
|
||
timezone, _, err := normalizeTimezone(req.Timezone)
|
||
if err != nil {
|
||
return req, err
|
||
}
|
||
req.Timezone = timezone
|
||
if req.StartTime <= 0 || req.EndTime <= req.StartTime {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_activity_time", "startTime and endTime are required and startTime must be before endTime")
|
||
}
|
||
if req.SettlementDelayMinutes == nil {
|
||
value := defaultSettlementDelayMinutes
|
||
req.SettlementDelayMinutes = &value
|
||
}
|
||
if *req.SettlementDelayMinutes < 0 || *req.SettlementDelayMinutes > maxSettlementDelayMinutes {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_settlement_delay", "settlementDelayMinutes must be between 0 and 1440")
|
||
}
|
||
if req.CoinPerDraw <= 0 {
|
||
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero")
|
||
}
|
||
req.RankingType, err = normalizeRankingType(req.RankingType)
|
||
if err != nil {
|
||
return req, err
|
||
}
|
||
req.RankingPeriod, err = normalizeRankingPeriod(req.RankingPeriod)
|
||
return req, err
|
||
}
|
||
|
||
func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values []PrizeInput) ([]normalizedPrize, error) {
|
||
if len(values) != 7 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_count", "exactly seven prizes are required")
|
||
}
|
||
seenSort := make(map[int]struct{}, 7)
|
||
result := make([]normalizedPrize, 0, 7)
|
||
for _, value := range values {
|
||
value.PrizeName = strings.TrimSpace(value.PrizeName)
|
||
value.PrizeImage = strings.TrimSpace(value.PrizeImage)
|
||
if value.PrizeName == "" || len(value.PrizeName) > 128 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_name", "every prizeName is required and must not exceed 128 characters")
|
||
}
|
||
if value.PrizeImage == "" || len(value.PrizeImage) > 500 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_image", "every prizeImage is required and must not exceed 500 characters")
|
||
}
|
||
if !value.Enabled {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_enabled", "all seven prizes must be enabled")
|
||
}
|
||
if value.Weight <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_weight", "every prize weight must be greater than zero")
|
||
}
|
||
if value.Stock < -1 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_stock", "prize stock must be -1 or greater")
|
||
}
|
||
if value.SortOrder < 1 || value.SortOrder > 7 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_sort", "prize sortOrder must cover 1 through 7")
|
||
}
|
||
if _, exists := seenSort[value.SortOrder]; exists {
|
||
return nil, NewAppError(http.StatusBadRequest, "duplicate_prize_sort", "prize sortOrder must be unique")
|
||
}
|
||
seenSort[value.SortOrder] = struct{}{}
|
||
if value.ResourceGroupID.Int64() <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_prize_reward_group", "every prize must reference a reward group")
|
||
}
|
||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, normalizedPrize{Input: value, Snapshot: snapshot})
|
||
}
|
||
sort.Slice(result, func(i, j int) bool { return result[i].Input.SortOrder < result[j].Input.SortOrder })
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
|
||
if len(values) != len(fixedRankRanges) {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required")
|
||
}
|
||
byRange := make(map[string]RankRewardInput, len(values))
|
||
for _, value := range values {
|
||
key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank)
|
||
if _, exists := byRange[key]; exists {
|
||
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
|
||
}
|
||
byRange[key] = value
|
||
}
|
||
result := make([]normalizedRankReward, 0, len(fixedRankRanges))
|
||
for _, rankRange := range fixedRankRanges {
|
||
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1])
|
||
value, exists := byRange[key]
|
||
if !exists {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
|
||
}
|
||
if value.ResourceGroupID.Int64() <= 0 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
||
}
|
||
value.RewardName = strings.TrimSpace(value.RewardName)
|
||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if value.RewardName == "" {
|
||
value.RewardName = snapshot.GroupName
|
||
}
|
||
if len(value.RewardName) > 128 {
|
||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
|
||
}
|
||
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) loadRewardGroupSnapshot(ctx context.Context, sysOrigin string, groupID int64) (rewardGroupSnapshot, error) {
|
||
if s.gateway == nil {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
||
}
|
||
detail, err := s.gateway.GetRewardGroupDetail(ctx, groupID)
|
||
if err != nil {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadGateway, "reward_group_lookup_failed", err.Error())
|
||
}
|
||
if int64(detail.ID) != groupID || len(detail.RewardConfigList) == 0 {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_empty", "reward group must exist and contain at least one reward")
|
||
}
|
||
// 租户信息缺失也必须 fail-closed;否则旧 Java DTO 或异常响应会让跨租户奖励组绕过校验。
|
||
if strings.TrimSpace(detail.SysOrigin) == "" || !strings.EqualFold(strings.TrimSpace(detail.SysOrigin), sysOrigin) {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_sys_origin_mismatch", "reward group sysOrigin does not match activity")
|
||
}
|
||
// 启用和每次发奖前都必须拿到明确的上架状态;缺失状态不能被当成“已上架”。
|
||
if detail.ShelfStatus == nil || !*detail.ShelfStatus {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_not_shelved", "reward group must be shelved")
|
||
}
|
||
items := make([]RewardItemView, 0, len(detail.RewardConfigList))
|
||
for _, item := range detail.RewardConfigList {
|
||
if int64(item.ID) <= 0 || strings.TrimSpace(item.Type) == "" || int64(item.Quantity) <= 0 || int64(item.Quantity) > math.MaxInt32 {
|
||
return rewardGroupSnapshot{}, NewAppError(http.StatusBadRequest, "reward_group_item_invalid", "reward group contains an invalid reward item")
|
||
}
|
||
items = append(items, RewardItemView{
|
||
ID: int64(item.ID),
|
||
Type: strings.TrimSpace(item.Type),
|
||
DetailType: strings.TrimSpace(item.DetailType),
|
||
Content: strings.TrimSpace(item.Content),
|
||
Quantity: int64(item.Quantity),
|
||
Cover: strings.TrimSpace(item.Cover),
|
||
SourceURL: strings.TrimSpace(item.SourceURL),
|
||
Name: strings.TrimSpace(item.Name),
|
||
Remark: strings.TrimSpace(item.Remark),
|
||
})
|
||
}
|
||
// 奖励组接口不承诺返回顺序;按配置 ID 及实际发奖字段规范化后再冻结/比对,
|
||
// 避免单纯数组重排被误判,同时确保 type/detailType/content/quantity 全部参与校验。
|
||
sort.Slice(items, func(i, j int) bool {
|
||
if items[i].ID != items[j].ID {
|
||
return items[i].ID < items[j].ID
|
||
}
|
||
if items[i].Type != items[j].Type {
|
||
return items[i].Type < items[j].Type
|
||
}
|
||
if items[i].DetailType != items[j].DetailType {
|
||
return items[i].DetailType < items[j].DetailType
|
||
}
|
||
if items[i].Content != items[j].Content {
|
||
return items[i].Content < items[j].Content
|
||
}
|
||
return items[i].Quantity < items[j].Quantity
|
||
})
|
||
for index := range items {
|
||
items[index].Sort = index + 1
|
||
}
|
||
raw, err := json.Marshal(items)
|
||
if err != nil {
|
||
return rewardGroupSnapshot{}, err
|
||
}
|
||
return rewardGroupSnapshot{GroupID: groupID, GroupName: strings.TrimSpace(detail.Name), ItemsJSON: string(raw), Items: items}, nil
|
||
}
|
||
|
||
func rewardItems(raw string) []RewardItemView {
|
||
items := make([]RewardItemView, 0)
|
||
if strings.TrimSpace(raw) == "" {
|
||
return items
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||
return []RewardItemView{}
|
||
}
|
||
return items
|
||
}
|
||
|
||
func activityView(row model.YumiGameKingActivity) ActivityView {
|
||
return ActivityView{
|
||
ID: row.ID,
|
||
ActivityCode: row.ActivityCode,
|
||
ActivityName: row.ActivityName,
|
||
ActivityDesc: row.ActivityDesc,
|
||
SysOrigin: row.SysOrigin,
|
||
Timezone: row.Timezone,
|
||
StartTime: unixMilli(row.StartTime),
|
||
EndTime: unixMilli(row.EndTime),
|
||
SettlementDelayMinutes: row.SettlementDelayMinutes,
|
||
SettlementTime: unixMilli(row.SettlementTime),
|
||
CoinPerDraw: row.CoinPerDraw,
|
||
EventTypes: []string{"GAME_CONSUME_GOLD"},
|
||
RankingType: row.RankingType,
|
||
RankingPeriod: row.RankingPeriod,
|
||
Enabled: row.Enabled,
|
||
SettlementStatus: row.SettlementStatus,
|
||
CreateTime: unixMilli(row.CreateTime),
|
||
UpdateTime: unixMilli(row.UpdateTime),
|
||
}
|
||
}
|
||
|
||
func prizeView(row model.YumiGameKingPrize) PrizeView {
|
||
return PrizeView{
|
||
ID: row.ID,
|
||
ActivityID: row.ActivityID,
|
||
PrizeName: row.PrizeName,
|
||
PrizeImage: row.PrizeImage,
|
||
Weight: row.Weight,
|
||
Stock: row.Stock,
|
||
ResourceGroupID: row.ResourceGroupID,
|
||
RewardGroupName: row.RewardGroupName,
|
||
Enabled: row.Enabled,
|
||
SortOrder: row.SortOrder,
|
||
RewardItems: rewardItems(row.RewardItemsJSON),
|
||
}
|
||
}
|
||
|
||
func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
|
||
return RankRewardView{
|
||
ID: row.ID,
|
||
ActivityID: row.ActivityID,
|
||
StartRank: row.StartRank,
|
||
EndRank: row.EndRank,
|
||
ResourceGroupID: row.ResourceGroupID,
|
||
RewardName: row.RewardName,
|
||
RewardItems: rewardItems(row.RewardItemsJSON),
|
||
}
|
||
}
|
||
|
||
func activityStatus(row model.YumiGameKingActivity, now time.Time) string {
|
||
if !row.Enabled {
|
||
return activityDisabled
|
||
}
|
||
if now.Before(row.StartTime) {
|
||
return activityUpcoming
|
||
}
|
||
if now.Before(row.EndTime) {
|
||
return activityActive
|
||
}
|
||
return activityEnded
|
||
}
|
||
|
||
func dateKey(at time.Time, location *time.Location) time.Time {
|
||
local := at.In(location)
|
||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC)
|
||
}
|
||
|
||
func withWriteLock(db *gorm.DB) *gorm.DB {
|
||
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
||
}
|
||
|
||
func isRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
|
||
|
||
func truncateFailure(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if len(value) <= 500 {
|
||
return value
|
||
}
|
||
return value[:500]
|
||
}
|
||
|
||
func integrationProfile(entry *RankEntryView, profile integration.UserProfile) {
|
||
entry.Account = profile.Account
|
||
entry.Nickname = profile.UserNickname
|
||
entry.Avatar = profile.UserAvatar
|
||
}
|