新增相关属性

This commit is contained in:
zhx 2026-07-22 22:05:31 +08:00
parent aa29614b28
commit 6d52cffc44
10 changed files with 150 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@ -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 codeFlutter 才能在不猜素材名称的前提下选择正确展示层。
resource["vip_benefit_codes"] = append([]string(nil), item.GetVipBenefitCodes()...)
}
return resource
}
func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any {

View File

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

View File

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

View File

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

View File

@ -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, &currentAllowed); err != nil {
if err := rows.Scan(&resourceID, &level, &resourceType, &benefitCode, &trialEnabled, &currentAllowed); 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)
}

View File

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

View File

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