2026-07-18 15:32:58 +08:00

352 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}