vip fixed

This commit is contained in:
hy001 2026-04-29 20:14:53 +08:00
parent bf07c84dd0
commit f8a8d4d65f
6 changed files with 286 additions and 41 deletions

View File

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

View File

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

View File

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

View File

@ -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"}`)

View File

@ -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,
)
}

View File

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