diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index cfeadd74..80f4fd62 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -6175,8 +6175,11 @@ type UserResourceEntitlement struct { // pinned_gift_configs 仅在 source_snapshot_id 非空且资源类型为 gift 时返回。内容已由 wallet // 校验 hash/resource/app 绑定,App 背包必须优先使用它,legacy entitlement 才回退当前目录。 PinnedGiftConfigs []*GiftConfig `protobuf:"bytes,17,rep,name=pinned_gift_configs,json=pinnedGiftConfigs,proto3" json:"pinned_gift_configs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // vip_benefit_codes 是该资源在当前用户有效 VIP 等级下命中的真实权益编码。 + // 展示层用它区分同为 badge 的 VIP 身份标识、勋章和普通徽章,禁止按 URL 或名称猜测。 + VipBenefitCodes []string `protobuf:"bytes,18,rep,name=vip_benefit_codes,json=vipBenefitCodes,proto3" json:"vip_benefit_codes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UserResourceEntitlement) Reset() { @@ -6328,6 +6331,13 @@ func (x *UserResourceEntitlement) GetPinnedGiftConfigs() []*GiftConfig { return nil } +func (x *UserResourceEntitlement) GetVipBenefitCodes() []string { + if x != nil { + return x.VipBenefitCodes + } + return nil +} + type ResourceGrantItem struct { state protoimpl.MessageState `protogen:"open.v1"` GrantItemId int64 `protobuf:"varint,1,opt,name=grant_item_id,json=grantItemId,proto3" json:"grant_item_id,omitempty"` @@ -27854,7 +27864,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x12created_by_user_id\x18\f \x01(\x03R\x0fcreatedByUserId\x12+\n" + "\x12updated_by_user_id\x18\r \x01(\x03R\x0fupdatedByUserId\x12\"\n" + "\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\x0f \x01(\x03R\vupdatedAtMs\"\xb8\x05\n" + + "\rupdated_at_ms\x18\x0f \x01(\x03R\vupdatedAtMs\"\xe4\x05\n" + "\x17UserResourceEntitlement\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12%\n" + "\x0eentitlement_id\x18\x02 \x01(\tR\rentitlementId\x12\x17\n" + @@ -27874,7 +27884,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\bequipped\x18\x0e \x01(\bR\bequipped\x12,\n" + "\x12source_snapshot_id\x18\x0f \x01(\tR\x10sourceSnapshotId\x124\n" + "\x16resource_snapshot_hash\x18\x10 \x01(\tR\x14resourceSnapshotHash\x12K\n" + - "\x13pinned_gift_configs\x18\x11 \x03(\v2\x1b.hyapp.wallet.v1.GiftConfigR\x11pinnedGiftConfigs\"\x86\x03\n" + + "\x13pinned_gift_configs\x18\x11 \x03(\v2\x1b.hyapp.wallet.v1.GiftConfigR\x11pinnedGiftConfigs\x12*\n" + + "\x11vip_benefit_codes\x18\x12 \x03(\tR\x0fvipBenefitCodes\"\x86\x03\n" + "\x11ResourceGrantItem\x12\"\n" + "\rgrant_item_id\x18\x01 \x01(\x03R\vgrantItemId\x12\x19\n" + "\bgrant_id\x18\x02 \x01(\tR\agrantId\x12\x1f\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 6df2b9a3..9fd15755 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -779,6 +779,9 @@ message UserResourceEntitlement { // pinned_gift_configs 仅在 source_snapshot_id 非空且资源类型为 gift 时返回。内容已由 wallet // 校验 hash/resource/app 绑定,App 背包必须优先使用它,legacy entitlement 才回退当前目录。 repeated GiftConfig pinned_gift_configs = 17; + // vip_benefit_codes 是该资源在当前用户有效 VIP 等级下命中的真实权益编码。 + // 展示层用它区分同为 badge 的 VIP 身份标识、勋章和普通徽章,禁止按 URL 或名称猜测。 + repeated string vip_benefit_codes = 18; } message ResourceGrantItem { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 961145a3..7e58a711 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -3685,12 +3685,13 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) ExpiresAtMs: 1999999999999, }, { - UserId: 42, - EntitlementId: "ent-badge-tile", - ResourceId: 8105, - Resource: shortBadgeResource, - Equipped: true, - ExpiresAtMs: 1999999999999, + UserId: 42, + EntitlementId: "ent-badge-tile", + ResourceId: 8105, + Resource: shortBadgeResource, + VipBenefitCodes: []string{"vip_badge_identity"}, + Equipped: true, + ExpiresAtMs: 1999999999999, }, { UserId: 42, @@ -3816,7 +3817,9 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) } badge := badges[0].(map[string]any) badgeResource := badge["resource"].(map[string]any) - if badge["badge_form"] != "tile" || badgeResource["preview_url"] != "https://cdn.example/badge-tile.png" { + vipBenefitCodes, vipBenefitCodesOK := badgeResource["vip_benefit_codes"].([]any) + if badge["badge_form"] != "tile" || badgeResource["preview_url"] != "https://cdn.example/badge-tile.png" || + !vipBenefitCodesOK || len(vipBenefitCodes) != 1 || vipBenefitCodes[0] != "vip_badge_identity" { t.Fatalf("join profile must include equipped tile badge resource: %+v", badge) } } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 0fe1625c..3bbe4804 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -2989,7 +2989,13 @@ func roomAppearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlemen if item == nil { return map[string]any{} } - return roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) + resource := roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) + if len(resource) > 0 && len(item.GetVipBenefitCodes()) > 0 { + // VIP 身份标识、VIP 勋章和普通徽章共用 badge 类型;必须透传 wallet 已校验的 + // 资源级 benefit code,Flutter 才能在不猜素材名称的前提下选择正确展示层。 + resource["vip_benefit_codes"] = append([]string(nil), item.GetVipBenefitCodes()...) + } + return resource } func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any { diff --git a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go index 5226b303..066827cb 100644 --- a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go @@ -371,7 +371,12 @@ func appearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlement) m return map[string]any{} } // entitlement_id/expires_at_ms 是佩戴态字段,resource 本身不带这两个用户维度。 - return appearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) + resource := appearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) + if len(resource) > 0 && len(item.GetVipBenefitCodes()) > 0 { + // 与房间资料保持同一资源级分类契约,完整个人页不得再次按 badge 图片或名称猜 VIP 身份。 + resource["vip_benefit_codes"] = append([]string(nil), item.GetVipBenefitCodes()...) + } + return resource } func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any { diff --git a/services/wallet-service/internal/domain/resource/entitlement.go b/services/wallet-service/internal/domain/resource/entitlement.go index f25049e8..0c1f98a9 100644 --- a/services/wallet-service/internal/domain/resource/entitlement.go +++ b/services/wallet-service/internal/domain/resource/entitlement.go @@ -18,9 +18,11 @@ type UserResourceEntitlement struct { GiftSnapshotHash string GiftSnapshotJSON string PinnedGiftConfigs []GiftConfig - CreatedAtMS int64 - UpdatedAtMS int64 - Equipped bool + // VIPBenefitCodes 由当前 VIP program、有效等级和 trial 规则共同计算,只描述本资源命中的权益。 + VIPBenefitCodes []string + CreatedAtMS int64 + UpdatedAtMS int64 + Equipped bool } type ListUserResourcesQuery struct { diff --git a/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go b/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go index 358af05f..c545f9b1 100644 --- a/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go +++ b/services/wallet-service/internal/service/wallet/vip_user_settings_integration_test.go @@ -174,6 +174,54 @@ func TestVipUserSettingsDefaultPartialUpdateAndAppScope(t *testing.T) { } } +// TestVipEquippedResourceProjectsBenefitCode 锁定同为 badge 类型的资源仍携带精确 VIP 权益编码, +// gateway/Flutter 才能把 vip_badge_identity 放进身份行而不是普通长徽章行。 +func TestVipEquippedResourceProjectsBenefitCode(t *testing.T) { + repository := mysqltest.NewRepository(t) + service := walletservice.New(repository) + ctx := appcode.WithContext(context.Background(), "fami") + const userID int64 = 940101 + + badge, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "vip1_identity_projection", + ResourceType: resourcedomain.TypeBadge, + Name: "VIP1 Identity Projection", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + AssetURL: "https://cdn.example/vip1-identity.png", + MetadataJSON: `{"badge_form":"strip","badge_kind":"normal"}`, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create VIP identity badge failed: %v", err) + } + repository.BindVIPBenefitResourceForApp( + "fami", 1, ledger.VipBenefitCodeBadgeIdentity, + badge.ResourceID, resourcedomain.TypeBadge, true, + ) + repository.SetVIPLevelStatusForApp("fami", 1, ledger.VipStatusActive, 100) + repository.SetBalanceForApp("fami", userID, 1000) + if _, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{ + CommandID: "vip-identity-projection", UserID: userID, Level: 1, + }); err != nil { + t.Fatalf("purchase VIP1 failed: %v", err) + } + + items, err := service.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ + AppCode: "fami", UserIDs: []int64{userID}, ResourceTypes: []string{resourcedomain.TypeBadge}, + }) + if err != nil { + t.Fatalf("batch get VIP identity equipment failed: %v", err) + } + if len(items) != 1 || len(items[0].Resources) != 1 || + len(items[0].Resources[0].VIPBenefitCodes) != 1 || + items[0].Resources[0].VIPBenefitCodes[0] != ledger.VipBenefitCodeBadgeIdentity { + t.Fatalf("VIP identity resource benefit projection mismatch: %+v", items) + } +} + func containsVipBenefit(items []ledger.VipBenefit, code string) bool { for _, item := range items { if item.BenefitCode == code { diff --git a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go index 4c054169..c1992b71 100644 --- a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go @@ -621,9 +621,9 @@ type vipResourceBindingKey struct { } type currentVipResourceBinding struct { - ResourceType string - PaidAllowed bool - TrialAllowed bool + ResourceType string + PaidBenefitCodes []string + TrialBenefitCodes []string } type vipBatchEffectiveLevels struct { @@ -667,13 +667,13 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx)) mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...) rows, err := tx.QueryContext(ctx, ` - SELECT history.resource_id, 0 AS level, '' AS resource_type, + SELECT history.resource_id, 0 AS level, '' AS resource_type, '' AS benefit_code, FALSE AS trial_enabled, FALSE AS current_allowed FROM vip_benefit_resource_binding_history AS history WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`) AND history.program_type = ? UNION ALL - SELECT benefit.resource_id, benefit.level, benefit.resource_type, + SELECT benefit.resource_id, benefit.level, benefit.resource_type, benefit.benefit_code, (benefit.trial_enabled AND benefit.benefit_code <> 'daily_coin_rebate') AS trial_enabled, (benefit.status = 'active' AND resource.resource_id IS NOT NULL) AS current_allowed FROM vip_level_benefits AS benefit @@ -693,9 +693,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs var resourceID int64 var level int32 var resourceType string + var benefitCode string var trialEnabled bool var currentAllowed bool - if err := rows.Scan(&resourceID, &level, &resourceType, &trialEnabled, ¤tAllowed); err != nil { + if err := rows.Scan(&resourceID, &level, &resourceType, &benefitCode, &trialEnabled, ¤tAllowed); err != nil { _ = rows.Close() return err } @@ -706,8 +707,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs key := vipResourceBindingKey{Level: level, ResourceID: resourceID} binding := bindings[key] binding.ResourceType = resourcedomain.NormalizeResourceType(resourceType) - binding.PaidAllowed = true - binding.TrialAllowed = binding.TrialAllowed || trialEnabled + binding.PaidBenefitCodes = appendUniqueNormalizedVIPCode(binding.PaidBenefitCodes, benefitCode) + if trialEnabled { + binding.TrialBenefitCodes = appendUniqueNormalizedVIPCode(binding.TrialBenefitCodes, benefitCode) + } bindings[key] = binding } if err := rows.Err(); err != nil { @@ -748,31 +751,50 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE filtered = append(filtered, entitlement) continue } - if !hasCurrentVipResourceBindingAtOrBelow(bindings, effectiveLevel, effectiveSource, - entitlement.ResourceID, entitlement.Resource.ResourceType) { + benefitCodes := currentVipBenefitCodesAtOrBelow(bindings, effectiveLevel, effectiveSource, + entitlement.ResourceID, entitlement.Resource.ResourceType) + if len(benefitCodes) == 0 { continue } + // 同为 badge 的 VIP 身份标识、勋章和普通徽章不能靠素材名称区分;将 wallet + // 已完成等级、trial 和改绑校验后的资源级权益事实随 entitlement 一并投影。 + entitlement.VIPBenefitCodes = benefitCodes filtered = append(filtered, entitlement) } grouped[userID] = filtered } } -// hasCurrentVipResourceBindingAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入 -// 数据库请求或按用户增长的复杂度;倒序保证同一资源重复绑定时优先采用最高可用等级的试用策略。 -func hasCurrentVipResourceBindingAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) bool { +// currentVipBenefitCodesAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入 +// 数据库请求或按用户增长的复杂度;同一资源跨等级复用时合并所有当前可用权益编码,供展示层精确分流。 +func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) []string { resourceType = resourcedomain.NormalizeResourceType(resourceType) + result := make([]string, 0, 2) for level := effectiveLevel; level > 0; level-- { binding, exists := bindings[vipResourceBindingKey{Level: level, ResourceID: resourceID}] if !exists || binding.ResourceType != resourceType { continue } - if effectiveSource == ledger.VipEffectiveSourceTrial && binding.TrialAllowed { - return true + codes := binding.PaidBenefitCodes + if effectiveSource == ledger.VipEffectiveSourceTrial { + codes = binding.TrialBenefitCodes } - if effectiveSource == ledger.VipEffectiveSourcePaid && binding.PaidAllowed { - return true + for _, code := range codes { + result = appendUniqueNormalizedVIPCode(result, code) } } - return false + return result +} + +func appendUniqueNormalizedVIPCode(values []string, value string) []string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return values + } + for _, existing := range values { + if existing == normalized { + return values + } + } + return append(values, normalized) } diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 4e2a9ce6..9bc9b3a0 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -850,6 +850,21 @@ func (r *Repository) SetVIPLevelStatusForApp(appCode string, level int32, status } } +// BindVIPBenefitResourceForApp 将既有等级权益绑定到测试资源,覆盖购买、自动佩戴和展示投影的真实链路。 +func (r *Repository) BindVIPBenefitResourceForApp(appCode string, level int32, benefitCode string, resourceID int64, resourceType string, trialEnabled bool) { + r.t.Helper() + if _, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE vip_level_benefits + SET resource_id = ?, resource_type = ?, trial_enabled = ?, auto_equip = TRUE, + status = 'active', updated_at_ms = ? + WHERE app_code = ? AND level = ? AND benefit_code = ?`, + resourceID, resourceType, trialEnabled, time.Now().UTC().UnixMilli(), + appCode, level, benefitCode, + ); err != nil { + r.t.Fatalf("bind vip benefit resource failed: %v", err) + } +} + // SetVIPDailyCoinRebateAmount 写入测试专用等级金额并递增 program 版本,模拟后台完整配置发布。 // amount<=0 时仍写入,供服务端校验/运行防线测试构造异常历史数据。 func (r *Repository) SetVIPDailyCoinRebateAmount(appCode string, level int32, amount int64, status string) { diff --git a/services/wallet-service/internal/transport/grpc/resource_mapper.go b/services/wallet-service/internal/transport/grpc/resource_mapper.go index 381e6005..5c2693c6 100644 --- a/services/wallet-service/internal/transport/grpc/resource_mapper.go +++ b/services/wallet-service/internal/transport/grpc/resource_mapper.go @@ -200,6 +200,7 @@ func userResourceToProto(item resourcedomain.UserResourceEntitlement) *walletv1. Equipped: item.Equipped, SourceSnapshotId: item.SourceSnapshotID, ResourceSnapshotHash: item.ResourceSnapshotHash, + VipBenefitCodes: append([]string(nil), item.VIPBenefitCodes...), } for _, gift := range item.PinnedGiftConfigs { out.PinnedGiftConfigs = append(out.PinnedGiftConfigs, giftConfigToProto(gift))