diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 061b804..d5065b9 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -89,6 +89,21 @@ func (g *Gateways) GrantProps(ctx context.Context, req GrantPropsRequest) error return g.RewardDispatch.GrantProps(ctx, req) } +// GivePropsBackpack 透传到道具背包发放接口。 +func (g *Gateways) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error { + return g.RewardDispatch.GivePropsBackpack(ctx, req) +} + +// SwitchUseProps 透传到道具使用切换接口。 +func (g *Gateways) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { + return g.RewardDispatch.SwitchUseProps(ctx, userID, propsID) +} + +// ActivateTemporaryBadge 透传到临时徽章激活接口。 +func (g *Gateways) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { + return g.RewardDispatch.ActivateTemporaryBadge(ctx, userID, badgeID, days) +} + // SendActivityReward 透传到奖励发放网关。 func (g *Gateways) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { return g.RewardDispatch.SendActivityReward(ctx, req) @@ -124,6 +139,11 @@ func (g *Gateways) GetUserLanguage(ctx context.Context, userID int64) (string, e return g.Profile.GetUserLanguage(ctx, userID) } +// RemoveUserProfileCacheAll 透传到用户资料缓存清理接口。 +func (g *Gateways) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error { + return g.Profile.RemoveUserProfileCacheAll(ctx, userID) +} + // ListCurrentWeekRoomContribution 透传到房间流水网关。 func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { return g.Room.ListCurrentWeekRoomContribution(ctx, limit) @@ -244,6 +264,21 @@ func (g *RewardDispatchGateway) GrantProps(ctx context.Context, req GrantPropsRe return g.client.GrantProps(ctx, req) } +// GivePropsBackpack 发放单个道具到用户背包。 +func (g *RewardDispatchGateway) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error { + return g.client.GivePropsBackpack(ctx, req) +} + +// SwitchUseProps 切换用户当前使用道具。 +func (g *RewardDispatchGateway) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { + return g.client.SwitchUseProps(ctx, userID, propsID) +} + +// ActivateTemporaryBadge 激活临时徽章并佩戴。 +func (g *RewardDispatchGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { + return g.client.ActivateTemporaryBadge(ctx, userID, badgeID, days) +} + // SendActivityReward 发放活动奖励组。 func (g *RewardDispatchGateway) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { return g.client.SendActivityReward(ctx, req) @@ -289,6 +324,11 @@ func (g *ProfileGateway) GetUserLanguage(ctx context.Context, userID int64) (str return g.client.GetUserLanguage(ctx, userID) } +// RemoveUserProfileCacheAll 清理用户资料相关缓存。 +func (g *ProfileGateway) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error { + return g.client.RemoveUserProfileCacheAll(ctx, userID) +} + // RoomGateway 负责房间资料和房间流水相关接口。 type RoomGateway struct { client *Client diff --git a/internal/integration/java.go b/internal/integration/java.go index 8c0a19f..457eb9d 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -126,6 +126,18 @@ type GrantPropsRequest struct { SourceGroupID int64 `json:"sourceGroupId"` } +type GivePropsBackpackRequest struct { + AcceptUserID int64 `json:"acceptUserId"` + PropsID int64 `json:"propsId"` + Type string `json:"type"` + Origin string `json:"origin"` + OriginDesc string `json:"originDesc"` + Days int `json:"days"` + UseProps bool `json:"useProps"` + AllowGive bool `json:"allowGive"` + OpUser int64 `json:"opUser,omitempty"` +} + type SendActivityRewardRequest struct { TrackID int64 `json:"trackId"` Origin string `json:"origin"` @@ -486,6 +498,32 @@ func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardR return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil) } +func (c *Client) GivePropsBackpack(ctx context.Context, req GivePropsBackpackRequest) error { + return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props/client/giveProps", req, nil, nil) +} + +func (c *Client) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error { + endpoint := c.cfg.Java.OtherBaseURL + "/props/client/switch/use-props?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + + "&propsId=" + url.QueryEscape(strconv.FormatInt(propsID, 10)) + return c.getJSON(ctx, endpoint, nil, nil) +} + +func (c *Client) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error { + endpoint := c.cfg.Java.OtherBaseURL + "/user-badge/client/activationTemporaryAndUse?userId=" + + url.QueryEscape(strconv.FormatInt(userID, 10)) + + "&badgeId=" + url.QueryEscape(strconv.FormatInt(badgeID, 10)) + + "&days=" + url.QueryEscape(strconv.Itoa(days)) + var resp resultResponse[bool] + if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { + return err + } + if !resp.Body { + return fmt.Errorf("activate temporary badge returned false") + } + return nil +} + // SendOfficialNoticeCustomize 调用 external 服务发送自定义官方通知。 func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNoticeCustomizeRequest) error { return c.postJSON(ctx, c.cfg.Java.ExternalBaseURL+"/official-notice/client/send/customize/template", req, nil, nil) @@ -685,6 +723,11 @@ func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, err return strings.TrimSpace(resp.Body), nil } +func (c *Client) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error { + endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/removeCacheAll?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) + return c.getJSON(ctx, endpoint, nil, nil) +} + func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance" var resp resultResponse[map[string]int64] diff --git a/internal/service/vip/app.go b/internal/service/vip/app.go index 4e65811..db3eafb 100644 --- a/internal/service/vip/app.go +++ b/internal/service/vip/app.go @@ -156,6 +156,9 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque if stateErr != nil { return nil, stateErr } + 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) statePayload.UserID = user.UserID statePayload.SysOrigin = sysOrigin @@ -268,6 +271,10 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque } committed = true + if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil { + return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) + } + payload := statePayloadFromModel(state, now) return &PurchaseResponse{ Success: true, @@ -487,6 +494,176 @@ func expireState(state *model.UserVipState, now time.Time) { state.UpdateTime = now } +func (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error { + return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now) +} + +func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error { + if !isActiveState(state, now) { + return nil + } + return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now) +} + +func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error { + if len(resources) == 0 { + return nil + } + if s.java == nil { + return fmt.Errorf("java gateway is unavailable") + } + if days <= 0 { + days = 1 + } + + for _, resource := range resources { + if resource.ResourceID <= 0 { + continue + } + exists, err := s.vipResourceExists(ctx, userID, resource, expireAt) + if err != nil { + return err + } + if !exists { + if err := s.grantVIPResource(ctx, userID, resource, days); err != nil { + return err + } + } + if !resource.Badge { + if err := s.java.SwitchUseProps(ctx, userID, resource.ResourceID); err != nil { + return fmt.Errorf("switch vip resource %s:%d: %w", resource.PropsType, resource.ResourceID, err) + } + } + if err := s.alignVIPResourceExpireAt(ctx, userID, resource, expireAt, now); err != nil { + return err + } + } + + if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil { + return fmt.Errorf("remove vip user profile cache: %w", err) + } + return nil +} + +func (s *Service) grantVIPResource(ctx context.Context, userID int64, resource vipGrantResource, days int) error { + if resource.Badge { + if err := s.java.ActivateTemporaryBadge(ctx, userID, resource.ResourceID, days); err != nil { + return fmt.Errorf("grant vip badge %d: %w", resource.ResourceID, err) + } + return nil + } + req := integration.GivePropsBackpackRequest{ + AcceptUserID: userID, + PropsID: resource.ResourceID, + Type: resource.PropsType, + Origin: walletOrigin, + OriginDesc: walletOriginDesc, + Days: days, + UseProps: true, + AllowGive: false, + } + if err := s.java.GivePropsBackpack(ctx, req); err != nil { + return fmt.Errorf("grant vip resource %s:%d: %w", resource.PropsType, resource.ResourceID, err) + } + return nil +} + +func (s *Service) vipResourceExists(ctx context.Context, userID int64, resource vipGrantResource, expireAt time.Time) (bool, error) { + var count int64 + threshold := expireAt.Add(-time.Second) + query := s.db.WithContext(ctx) + if resource.Badge { + query = query.Table("user_badge_backpack"). + Where("user_id = ? AND badge_id = ? AND expire_time >= ?", userID, resource.ResourceID, threshold) + } else { + query = query.Table("user_props_backpack"). + Where("user_id = ? AND props_id = ? AND type = ? AND expire_time >= ?", userID, resource.ResourceID, resource.PropsType, threshold) + } + if err := query.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (s *Service) alignVIPResourceExpireAt(ctx context.Context, userID int64, resource vipGrantResource, expireAt time.Time, now time.Time) error { + var result *gorm.DB + if resource.Badge { + result = s.db.WithContext(ctx).Exec( + "UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id = ?", + "TEMPORARY", expireAt, true, now, userID, resource.ResourceID, + ) + } else { + result = s.db.WithContext(ctx).Exec( + "UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id = ? AND type = ?", + expireAt, true, false, now, userID, resource.ResourceID, resource.PropsType, + ) + } + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + exists, err := s.vipResourceExists(ctx, userID, resource, expireAt) + if err != nil { + return err + } + if !exists { + if resource.Badge { + return fmt.Errorf("vip badge backpack row not found after grant: %d", resource.ResourceID) + } + return fmt.Errorf("vip props backpack row not found after grant: %s:%d", resource.PropsType, resource.ResourceID) + } + } + return nil +} + +func vipGrantResourcesFromConfig(config model.VipLevelConfig) []vipGrantResource { + return compactVIPGrantResources([]vipGrantResource{ + {ResourceID: config.BadgeResourceID, Badge: true}, + {ResourceID: config.AvatarFrameResourceID, PropsType: vipPropsTypeAvatarFrame}, + {ResourceID: config.EntryEffectResourceID, PropsType: vipPropsTypeRide}, + {ResourceID: config.ChatBubbleResourceID, PropsType: vipPropsTypeChatBubble}, + {ResourceID: config.FloatPictureResourceID, PropsType: vipPropsTypeFloatPicture}, + }) +} + +func vipGrantResourcesFromState(state *model.UserVipState) []vipGrantResource { + if state == nil { + return nil + } + return compactVIPGrantResources([]vipGrantResource{ + {ResourceID: state.BadgeResourceID, Badge: true}, + {ResourceID: state.AvatarFrameResourceID, PropsType: vipPropsTypeAvatarFrame}, + {ResourceID: state.EntryEffectResourceID, PropsType: vipPropsTypeRide}, + {ResourceID: state.ChatBubbleResourceID, PropsType: vipPropsTypeChatBubble}, + {ResourceID: state.FloatPictureResourceID, PropsType: vipPropsTypeFloatPicture}, + }) +} + +func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource { + compacted := make([]vipGrantResource, 0, len(resources)) + for _, resource := range resources { + if resource.ResourceID > 0 { + compacted = append(compacted, resource) + } + } + return compacted +} + +func vipGrantDaysUntil(now time.Time, expireAt time.Time) int { + if !expireAt.After(now) { + return 1 + } + duration := expireAt.Sub(now) + days := int(duration / (24 * time.Hour)) + if duration%(24*time.Hour) != 0 { + days++ + } + if days < 1 { + return 1 + } + return days +} + func mapWalletPurchaseError(err error) *AppError { message := err.Error() normalized := strings.ToLower(message) @@ -509,7 +686,7 @@ func buildWalletCommand(userID int64, sysOrigin string, order model.VipOrderReco CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOrigin, - CustomizeOriginDesc: "VIP purchase", + CustomizeOriginDesc: walletOriginDesc, } } diff --git a/internal/service/vip/purchase_test.go b/internal/service/vip/purchase_test.go index 91b3370..c89a864 100644 --- a/internal/service/vip/purchase_test.go +++ b/internal/service/vip/purchase_test.go @@ -153,6 +153,45 @@ func TestMapWalletPurchaseErrorGatewayFailure(t *testing.T) { } } +func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) { + config := model.VipLevelConfig{ + BadgeResourceID: 10, + AvatarFrameResourceID: 11, + EntryEffectResourceID: 12, + ChatBubbleResourceID: 13, + FloatPictureResourceID: 14, + } + + resources := vipGrantResourcesFromConfig(config) + if len(resources) != 5 { + t.Fatalf("resources len = %d, want 5", len(resources)) + } + assertGrant := func(index int, resourceID int64, propsType string, badge bool) { + t.Helper() + got := resources[index] + if got.ResourceID != resourceID || got.PropsType != propsType || got.Badge != badge { + t.Fatalf("resource[%d] = %+v, want id=%d type=%s badge=%v", index, got, resourceID, propsType, badge) + } + } + assertGrant(0, 10, "", true) + assertGrant(1, 11, vipPropsTypeAvatarFrame, false) + assertGrant(2, 12, vipPropsTypeRide, false) + assertGrant(3, 13, vipPropsTypeChatBubble, false) + assertGrant(4, 14, vipPropsTypeFloatPicture, false) +} + +func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) { + now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) + expireAt := now.Add((24 * time.Hour) + time.Second) + + if got := vipGrantDaysUntil(now, expireAt); got != 2 { + t.Fatalf("days = %d, want 2", got) + } + if got := vipGrantDaysUntil(now, now); got != 1 { + t.Fatalf("expired days = %d, want 1", got) + } +} + func vipTestConfigs() map[int]model.VipLevelConfig { configs := make(map[int]model.VipLevelConfig, levelCount) for level := 1; level <= levelCount; level++ { diff --git a/internal/service/vip/types.go b/internal/service/vip/types.go index 13f769c..888f66e 100644 --- a/internal/service/vip/types.go +++ b/internal/service/vip/types.go @@ -34,6 +34,12 @@ const ( walletReceiptIncome = "INCOME" walletReceiptExpenditure = "EXPENDITURE" walletOrigin = "VIP_PURCHASE" + walletOriginDesc = "VIP purchase" + + vipPropsTypeAvatarFrame = "AVATAR_FRAME" + vipPropsTypeRide = "RIDE" + vipPropsTypeChatBubble = "CHAT_BUBBLE" + vipPropsTypeFloatPicture = "FLOAT_PICTURE" ) type AppError = common.AppError @@ -47,6 +53,10 @@ type vipDB interface { type vipGateway interface { ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error + SwitchUseProps(ctx context.Context, userID int64, propsID int64) error + ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error + RemoveUserProfileCacheAll(ctx context.Context, userID int64) error } type Service struct { @@ -229,6 +239,12 @@ type purchasePlan struct { TargetConfig model.VipLevelConfig } +type vipGrantResource struct { + ResourceID int64 + PropsType string + Badge bool +} + func normalizeSysOrigin(sysOrigin string) string { return strings.ToUpper(strings.TrimSpace(sysOrigin)) }