增加VIP资源返回

This commit is contained in:
hy001 2026-04-29 19:49:18 +08:00
parent 65b0558e03
commit bf07c84dd0
5 changed files with 316 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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