From f8a8d4d65ff2dd056810fcadce8f37d81bc0b9f3 Mon Sep 17 00:00:00 2001 From: hy001 Date: Wed, 29 Apr 2026 20:14:53 +0800 Subject: [PATCH] vip fixed --- internal/service/vip/admin.go | 10 ++- internal/service/vip/app.go | 56 +++++++++++----- internal/service/vip/config.go | 32 +++++---- internal/service/vip/purchase_test.go | 88 ++++++++++++++++++++++++- internal/service/vip/resources.go | 95 +++++++++++++++++++++++++++ internal/service/vip/types.go | 46 +++++++++---- 6 files changed, 286 insertions(+), 41 deletions(-) create mode 100644 internal/service/vip/resources.go diff --git a/internal/service/vip/admin.go b/internal/service/vip/admin.go index 3c07478..13f04ab 100644 --- a/internal/service/vip/admin.go +++ b/internal/service/vip/admin.go @@ -105,9 +105,17 @@ func (s *Service) PageUserStates( Find(&rows).Error; err != nil { return nil, err } + resourceIDs := make([]int64, 0, len(rows)*5) + for idx := range rows { + resourceIDs = appendResourceIDsFromState(resourceIDs, &rows[idx]) + } + resources, err := s.loadResourceLookup(ctx, resourceIDs) + if err != nil { + return nil, err + } records = make([]UserVipStatePayload, 0, len(rows)) for idx := range rows { - records = append(records, statePayloadFromModel(&rows[idx], now)) + records = append(records, statePayloadFromModelWithResources(&rows[idx], now, resources)) } } diff --git a/internal/service/vip/app.go b/internal/service/vip/app.go index db3eafb..2e17331 100644 --- a/internal/service/vip/app.go +++ b/internal/service/vip/app.go @@ -36,13 +36,19 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, er return nil, err } activeState := activeStateOrNil(state, now) + resourceIDs := resourceIDsFromConfigMap(configs) + resourceIDs = appendResourceIDsFromState(resourceIDs, state) + resources, err := s.loadResourceLookup(ctx, resourceIDs) + if err != nil { + return nil, err + } levels := make([]VipLevelHomePayload, 0, levelCount) for level := 1; level <= levelCount; level++ { payload := defaultLevelPayload(level) config, exists := configs[level] if exists { - payload = payloadFromConfig(config) + payload = payloadFromConfigWithResources(config, resources) } item := VipLevelHomePayload{VipLevelPayload: payload} if exists && config.Enabled { @@ -55,7 +61,7 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, er levels = append(levels, item) } - statePayload := statePayloadFromModel(state, now) + statePayload := statePayloadFromModelWithResources(state, now, resources) statePayload.UserID = user.UserID statePayload.SysOrigin = sysOrigin return &HomeResponse{ @@ -78,7 +84,11 @@ func (s *Service) GetStatus(ctx context.Context, user AuthUser) (*UserVipStatePa if err != nil { return nil, err } - payload := statePayloadFromModel(state, now) + resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) + if err != nil { + return nil, err + } + payload := statePayloadFromModelWithResources(state, now, resources) payload.UserID = user.UserID payload.SysOrigin = sysOrigin return &payload, nil @@ -112,6 +122,10 @@ func (s *Service) PreviewPurchase(ctx context.Context, user AuthUser, req Purcha if err != nil { return nil, err } + resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromConfig(nil, plan.TargetConfig)) + if err != nil { + return nil, err + } return &PurchasePreviewResponse{ UserID: user.UserID, @@ -124,7 +138,7 @@ func (s *Service) PreviewPurchase(ctx context.Context, user AuthUser, req Purcha PayableGold: plan.PayableGold, StartAt: formatTime(plan.StartAt), ExpireAt: formatTime(plan.ExpireAt), - TargetConfig: payloadFromConfig(plan.TargetConfig), + TargetConfig: payloadFromConfigWithResources(plan.TargetConfig, resources), }, nil } @@ -159,7 +173,11 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil { return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) } - statePayload := statePayloadFromModel(state, now) + resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) + if resourceErr != nil { + return nil, resourceErr + } + statePayload := statePayloadFromModelWithResources(state, now, resources) statePayload.UserID = user.UserID statePayload.SysOrigin = sysOrigin return &PurchaseResponse{ @@ -275,7 +293,13 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) } - payload := statePayloadFromModel(state, now) + resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) + if err != nil { + return nil, err + } + payload := statePayloadFromModelWithResources(state, now, resources) + payload.UserID = user.UserID + payload.SysOrigin = sysOrigin return &PurchaseResponse{ Success: true, Order: orderPayloadFromModel(order), @@ -451,20 +475,20 @@ func applyPlanToState(state *model.UserVipState, plan purchasePlan, now time.Tim state.StartAt = plan.StartAt state.ExpireAt = plan.ExpireAt state.BadgeResourceID = plan.TargetConfig.BadgeResourceID - state.BadgeName = plan.TargetConfig.BadgeName - state.BadgeURL = plan.TargetConfig.BadgeURL + state.BadgeName = "" + state.BadgeURL = "" state.AvatarFrameResourceID = plan.TargetConfig.AvatarFrameResourceID - state.AvatarFrameName = plan.TargetConfig.AvatarFrameName - state.AvatarFrameURL = plan.TargetConfig.AvatarFrameURL + state.AvatarFrameName = "" + state.AvatarFrameURL = "" state.EntryEffectResourceID = plan.TargetConfig.EntryEffectResourceID - state.EntryEffectName = plan.TargetConfig.EntryEffectName - state.EntryEffectURL = plan.TargetConfig.EntryEffectURL + state.EntryEffectName = "" + state.EntryEffectURL = "" state.ChatBubbleResourceID = plan.TargetConfig.ChatBubbleResourceID - state.ChatBubbleName = plan.TargetConfig.ChatBubbleName - state.ChatBubbleURL = plan.TargetConfig.ChatBubbleURL + state.ChatBubbleName = "" + state.ChatBubbleURL = "" state.FloatPictureResourceID = plan.TargetConfig.FloatPictureResourceID - state.FloatPictureName = plan.TargetConfig.FloatPictureName - state.FloatPictureURL = plan.TargetConfig.FloatPictureURL + state.FloatPictureName = "" + state.FloatPictureURL = "" state.UpdateTime = now if state.CreateTime.IsZero() { state.CreateTime = now diff --git a/internal/service/vip/config.go b/internal/service/vip/config.go index 0fb9e99..1bffd46 100644 --- a/internal/service/vip/config.go +++ b/internal/service/vip/config.go @@ -25,6 +25,10 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo if err != nil { return nil, err } + resources, err := s.loadResourceLookup(ctx, resourceIDsFromConfigRows(rows)) + if err != nil { + return nil, err + } levels := buildDefaultLevelPayloads() configured := len(rows) > 0 @@ -33,7 +37,7 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo if row.Level < 1 || row.Level > levelCount { continue } - levels[row.Level-1] = payloadFromConfig(row) + levels[row.Level-1] = payloadFromConfigWithResources(row, resources) if row.UpdateTime.After(updateTime) { updateTime = row.UpdateTime } @@ -138,14 +142,20 @@ func normalizeResource(resource VipResourcePayload) VipResourcePayload { resource.URL = strings.TrimSpace(resource.URL) resource.SourceURL = strings.TrimSpace(resource.SourceURL) resource.ResourceURL = strings.TrimSpace(resource.ResourceURL) + resource.Cover = strings.TrimSpace(resource.Cover) + resource.CoverURL = strings.TrimSpace(resource.CoverURL) if resource.URL == "" { resource.URL = resource.SourceURL } if resource.URL == "" { resource.URL = resource.ResourceURL } + if resource.Cover == "" { + resource.Cover = resource.CoverURL + } resource.SourceURL = resource.URL resource.ResourceURL = resource.URL + resource.CoverURL = resource.Cover return resource } @@ -157,20 +167,20 @@ func applyLevelPayloadToConfig(row *model.VipLevelConfig, item VipLevelPayload, row.DurationDays = vipDurationDay row.PriceGold = item.PriceGold row.BadgeResourceID = item.Badge.ResourceID.Int64() - row.BadgeName = item.Badge.Name - row.BadgeURL = item.Badge.URL + row.BadgeName = "" + row.BadgeURL = "" row.AvatarFrameResourceID = item.AvatarFrame.ResourceID.Int64() - row.AvatarFrameName = item.AvatarFrame.Name - row.AvatarFrameURL = item.AvatarFrame.URL + row.AvatarFrameName = "" + row.AvatarFrameURL = "" row.EntryEffectResourceID = item.EntryEffect.ResourceID.Int64() - row.EntryEffectName = item.EntryEffect.Name - row.EntryEffectURL = item.EntryEffect.URL + row.EntryEffectName = "" + row.EntryEffectURL = "" row.ChatBubbleResourceID = item.ChatBubble.ResourceID.Int64() - row.ChatBubbleName = item.ChatBubble.Name - row.ChatBubbleURL = item.ChatBubble.URL + row.ChatBubbleName = "" + row.ChatBubbleURL = "" row.FloatPictureResourceID = item.FloatPicture.ResourceID.Int64() - row.FloatPictureName = item.FloatPicture.Name - row.FloatPictureURL = item.FloatPicture.URL + row.FloatPictureName = "" + row.FloatPictureURL = "" row.UpdateTime = now if row.CreateTime.IsZero() { row.CreateTime = now diff --git a/internal/service/vip/purchase_test.go b/internal/service/vip/purchase_test.go index c89a864..1a25e41 100644 --- a/internal/service/vip/purchase_test.go +++ b/internal/service/vip/purchase_test.go @@ -106,7 +106,7 @@ func TestBuildPurchasePlanRejectsDowngrade(t *testing.T) { func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) { var payload VipResourcePayload - if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","sourceUrl":"https://example.com/a.svga"}`), &payload); err != nil { + if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","sourceUrl":"https://example.com/a.svga","coverUrl":"https://example.com/a.png"}`), &payload); err != nil { t.Fatalf("unmarshal resource payload: %v", err) } payload = normalizeResource(payload) @@ -116,16 +116,100 @@ func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) { if payload.URL != "https://example.com/a.svga" || payload.SourceURL != payload.URL || payload.ResourceURL != payload.URL { t.Fatalf("resource urls = url:%q sourceUrl:%q resourceUrl:%q", payload.URL, payload.SourceURL, payload.ResourceURL) } + if payload.Cover != "https://example.com/a.png" || payload.CoverURL != payload.Cover { + t.Fatalf("resource cover = cover:%q coverUrl:%q", payload.Cover, payload.CoverURL) + } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal resource payload: %v", err) } - if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga","sourceUrl":"https://example.com/a.svga","resourceUrl":"https://example.com/a.svga"}` { + if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga","sourceUrl":"https://example.com/a.svga","resourceUrl":"https://example.com/a.svga","cover":"https://example.com/a.png","coverUrl":"https://example.com/a.png"}` { t.Fatalf("json = %s", got) } } +func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) { + row := model.VipLevelConfig{ + Level: 2, + LevelCode: "VIP2", + DisplayName: "VIP 2", + Enabled: true, + DurationDays: vipDurationDay, + PriceGold: 2000, + AvatarFrameResourceID: 11, + AvatarFrameName: "stale frame", + AvatarFrameURL: "https://example.com/stale.svga", + } + + payload := payloadFromConfigWithResources(row, vipResourceLookup{ + 11: { + ID: 11, + Name: "resource group frame", + Cover: "https://example.com/live.png", + SourceURL: "https://example.com/live.svga", + }, + }) + + if payload.AvatarFrame.Name != "resource group frame" || + payload.AvatarFrame.URL != "https://example.com/live.svga" || + payload.AvatarFrame.Cover != "https://example.com/live.png" { + t.Fatalf("avatar frame was not loaded from resource group: %+v", payload.AvatarFrame) + } +} + +func TestApplyPlanToStateKeepsOnlyResourceIDsAndPayloadUsesLookup(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + plan := purchasePlan{ + TargetLevel: 3, + StartAt: now, + ExpireAt: now.AddDate(0, 0, vipDurationDay), + DurationDays: vipDurationDay, + TargetConfig: model.VipLevelConfig{ + Level: 3, + DisplayName: "VIP 3", + AvatarFrameResourceID: 11, + AvatarFrameName: "frame", + AvatarFrameURL: "https://example.com/frame.svga", + EntryEffectResourceID: 12, + EntryEffectName: "ride", + EntryEffectURL: "https://example.com/ride.svga", + ChatBubbleResourceID: 13, + ChatBubbleName: "bubble", + ChatBubbleURL: "https://example.com/bubble.svga", + FloatPictureResourceID: 14, + FloatPictureName: "float", + FloatPictureURL: "https://example.com/float.svga", + }, + } + + state := &model.UserVipState{UserID: 1001, SysOrigin: "LIKEI"} + applyPlanToState(state, plan, now) + + if state.AvatarFrameResourceID != plan.TargetConfig.AvatarFrameResourceID || + state.EntryEffectResourceID != plan.TargetConfig.EntryEffectResourceID || + state.ChatBubbleResourceID != plan.TargetConfig.ChatBubbleResourceID || + state.FloatPictureResourceID != plan.TargetConfig.FloatPictureResourceID { + t.Fatalf("state resource ids were not copied from target config") + } + if state.AvatarFrameName != "" || state.AvatarFrameURL != "" { + t.Fatalf("state stored resource details: name=%q url=%q", state.AvatarFrameName, state.AvatarFrameURL) + } + + payload := statePayloadFromModelWithResources(state, now, vipResourceLookup{ + 11: {ID: 11, Name: "frame", Cover: "https://example.com/frame.png", SourceURL: "https://example.com/frame.svga"}, + 12: {ID: 12, Name: "ride", Cover: "https://example.com/ride.png", SourceURL: "https://example.com/ride.svga"}, + 13: {ID: 13, Name: "bubble", Cover: "https://example.com/bubble.png", SourceURL: "https://example.com/bubble.svga"}, + 14: {ID: 14, Name: "float", Cover: "https://example.com/float.png", SourceURL: "https://example.com/float.svga"}, + }) + if payload.AvatarFrame.Cover != "https://example.com/frame.png" || + payload.EntryEffect.Cover != "https://example.com/ride.png" || + payload.ChatBubble.Cover != "https://example.com/bubble.png" || + payload.FloatPicture.Cover != "https://example.com/float.png" { + t.Fatalf("state payload covers were not loaded from resource lookup") + } +} + func TestMapWalletPurchaseErrorInsufficientBalance(t *testing.T) { err := errors.New(`java api failed: status=406 body={"errorCode":5000,"errorCodeName":"INSUFFICIENT_BALANCE","errorMsg":"balance not made"}`) diff --git a/internal/service/vip/resources.go b/internal/service/vip/resources.go new file mode 100644 index 0000000..df30729 --- /dev/null +++ b/internal/service/vip/resources.go @@ -0,0 +1,95 @@ +package vip + +import ( + "context" + + "chatapp3-golang/internal/model" +) + +type vipResourceRecord struct { + ID int64 `gorm:"column:id"` + Name string `gorm:"column:name"` + Cover string `gorm:"column:cover"` + SourceURL string `gorm:"column:source_url"` +} + +type vipResourceLookup map[int64]vipResourceRecord + +func (s *Service) loadResourceLookup(ctx context.Context, resourceIDs []int64) (vipResourceLookup, error) { + ids := compactResourceIDs(resourceIDs) + if len(ids) == 0 { + return nil, nil + } + + var rows []vipResourceRecord + if err := s.db.WithContext(ctx). + Table("props_source_record"). + Select("id, name, cover, source_url"). + Where("id IN ?", ids). + Find(&rows).Error; err != nil { + return nil, err + } + + lookup := make(vipResourceLookup, len(rows)) + for _, row := range rows { + lookup[row.ID] = row + } + return lookup, nil +} + +func compactResourceIDs(resourceIDs []int64) []int64 { + seen := make(map[int64]struct{}, len(resourceIDs)) + ids := make([]int64, 0, len(resourceIDs)) + for _, id := range resourceIDs { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} + +func resourceIDsFromConfigRows(rows []model.VipLevelConfig) []int64 { + ids := make([]int64, 0, len(rows)*5) + for _, row := range rows { + ids = appendResourceIDsFromConfig(ids, row) + } + return ids +} + +func resourceIDsFromConfigMap(configs map[int]model.VipLevelConfig) []int64 { + ids := make([]int64, 0, len(configs)*5) + for _, row := range configs { + ids = appendResourceIDsFromConfig(ids, row) + } + return ids +} + +func appendResourceIDsFromConfig(ids []int64, row model.VipLevelConfig) []int64 { + return append( + ids, + row.BadgeResourceID, + row.AvatarFrameResourceID, + row.EntryEffectResourceID, + row.ChatBubbleResourceID, + row.FloatPictureResourceID, + ) +} + +func appendResourceIDsFromState(ids []int64, row *model.UserVipState) []int64 { + if row == nil { + return ids + } + return append( + ids, + row.BadgeResourceID, + row.AvatarFrameResourceID, + row.EntryEffectResourceID, + row.ChatBubbleResourceID, + row.FloatPictureResourceID, + ) +} diff --git a/internal/service/vip/types.go b/internal/service/vip/types.go index 888f66e..265f4f8 100644 --- a/internal/service/vip/types.go +++ b/internal/service/vip/types.go @@ -103,6 +103,8 @@ type VipResourcePayload struct { URL string `json:"url"` SourceURL string `json:"sourceUrl,omitempty"` ResourceURL string `json:"resourceUrl,omitempty"` + Cover string `json:"cover,omitempty"` + CoverURL string `json:"coverUrl,omitempty"` } type ResourceID int64 @@ -311,18 +313,36 @@ func defaultLevelPayload(level int) VipLevelPayload { } } -func resourcePayload(resourceID int64, name string, url string) VipResourcePayload { +func resourcePayload(resourceID int64, name string, url string, cover string) VipResourcePayload { url = strings.TrimSpace(url) + cover = strings.TrimSpace(cover) return VipResourcePayload{ ResourceID: ResourceID(resourceID), Name: strings.TrimSpace(name), URL: url, SourceURL: url, ResourceURL: url, + Cover: cover, + CoverURL: cover, } } +func resourcePayloadWithLookup(resourceID int64, name string, url string, cover string, resources vipResourceLookup) VipResourcePayload { + if resourceID > 0 { + if record, exists := resources[resourceID]; exists { + name = record.Name + url = record.SourceURL + cover = record.Cover + } + } + return resourcePayload(resourceID, name, url, cover) +} + func payloadFromConfig(row model.VipLevelConfig) VipLevelPayload { + return payloadFromConfigWithResources(row, nil) +} + +func payloadFromConfigWithResources(row model.VipLevelConfig, resources vipResourceLookup) VipLevelPayload { duration := row.DurationDays if duration <= 0 { duration = vipDurationDay @@ -343,16 +363,20 @@ func payloadFromConfig(row model.VipLevelConfig) VipLevelPayload { Enabled: row.Enabled, DurationDays: duration, PriceGold: row.PriceGold, - Badge: resourcePayload(row.BadgeResourceID, row.BadgeName, row.BadgeURL), - AvatarFrame: resourcePayload(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL), - EntryEffect: resourcePayload(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL), - ChatBubble: resourcePayload(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL), - FloatPicture: resourcePayload(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL), + Badge: resourcePayloadWithLookup(row.BadgeResourceID, row.BadgeName, row.BadgeURL, "", resources), + AvatarFrame: resourcePayloadWithLookup(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL, "", resources), + EntryEffect: resourcePayloadWithLookup(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL, "", resources), + ChatBubble: resourcePayloadWithLookup(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL, "", resources), + FloatPicture: resourcePayloadWithLookup(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL, "", resources), UpdateTime: formatTime(row.UpdateTime), } } func statePayloadFromModel(row *model.UserVipState, now time.Time) UserVipStatePayload { + return statePayloadFromModelWithResources(row, now, nil) +} + +func statePayloadFromModelWithResources(row *model.UserVipState, now time.Time, resources vipResourceLookup) UserVipStatePayload { if row == nil { return UserVipStatePayload{Active: false, Status: statusExpired} } @@ -377,11 +401,11 @@ func statePayloadFromModel(row *model.UserVipState, now time.Time) UserVipStateP payload.DisplayName = "" return payload } - payload.Badge = resourcePayload(row.BadgeResourceID, row.BadgeName, row.BadgeURL) - payload.AvatarFrame = resourcePayload(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL) - payload.EntryEffect = resourcePayload(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL) - payload.ChatBubble = resourcePayload(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL) - payload.FloatPicture = resourcePayload(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL) + payload.Badge = resourcePayloadWithLookup(row.BadgeResourceID, row.BadgeName, row.BadgeURL, "", resources) + payload.AvatarFrame = resourcePayloadWithLookup(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL, "", resources) + payload.EntryEffect = resourcePayloadWithLookup(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL, "", resources) + payload.ChatBubble = resourcePayloadWithLookup(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL, "", resources) + payload.FloatPicture = resourcePayloadWithLookup(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL, "", resources) return payload }