Compare commits

...

1 Commits
test ... main

Author SHA1 Message Date
zhx
6d52cffc44 新增相关属性 2026-07-22 22:05:31 +08:00
10 changed files with 150 additions and 34 deletions

View File

@ -6175,6 +6175,9 @@ type UserResourceEntitlement struct {
// pinned_gift_configs 仅在 source_snapshot_id 非空且资源类型为 gift 时返回。内容已由 wallet // pinned_gift_configs 仅在 source_snapshot_id 非空且资源类型为 gift 时返回。内容已由 wallet
// 校验 hash/resource/app 绑定App 背包必须优先使用它legacy entitlement 才回退当前目录。 // 校验 hash/resource/app 绑定App 背包必须优先使用它legacy entitlement 才回退当前目录。
PinnedGiftConfigs []*GiftConfig `protobuf:"bytes,17,rep,name=pinned_gift_configs,json=pinnedGiftConfigs,proto3" json:"pinned_gift_configs,omitempty"` PinnedGiftConfigs []*GiftConfig `protobuf:"bytes,17,rep,name=pinned_gift_configs,json=pinnedGiftConfigs,proto3" json:"pinned_gift_configs,omitempty"`
// 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -6328,6 +6331,13 @@ func (x *UserResourceEntitlement) GetPinnedGiftConfigs() []*GiftConfig {
return nil return nil
} }
func (x *UserResourceEntitlement) GetVipBenefitCodes() []string {
if x != nil {
return x.VipBenefitCodes
}
return nil
}
type ResourceGrantItem struct { type ResourceGrantItem struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
GrantItemId int64 `protobuf:"varint,1,opt,name=grant_item_id,json=grantItemId,proto3" json:"grant_item_id,omitempty"` 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" + "\x12created_by_user_id\x18\f \x01(\x03R\x0fcreatedByUserId\x12+\n" +
"\x12updated_by_user_id\x18\r \x01(\x03R\x0fupdatedByUserId\x12\"\n" + "\x12updated_by_user_id\x18\r \x01(\x03R\x0fupdatedByUserId\x12\"\n" +
"\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\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" + "\x17UserResourceEntitlement\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12%\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12%\n" +
"\x0eentitlement_id\x18\x02 \x01(\tR\rentitlementId\x12\x17\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" + "\bequipped\x18\x0e \x01(\bR\bequipped\x12,\n" +
"\x12source_snapshot_id\x18\x0f \x01(\tR\x10sourceSnapshotId\x124\n" + "\x12source_snapshot_id\x18\x0f \x01(\tR\x10sourceSnapshotId\x124\n" +
"\x16resource_snapshot_hash\x18\x10 \x01(\tR\x14resourceSnapshotHash\x12K\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" + "\x11ResourceGrantItem\x12\"\n" +
"\rgrant_item_id\x18\x01 \x01(\x03R\vgrantItemId\x12\x19\n" + "\rgrant_item_id\x18\x01 \x01(\x03R\vgrantItemId\x12\x19\n" +
"\bgrant_id\x18\x02 \x01(\tR\agrantId\x12\x1f\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 // pinned_gift_configs source_snapshot_id gift wallet
// hash/resource/app App 使legacy entitlement 退 // hash/resource/app App 使legacy entitlement 退
repeated GiftConfig pinned_gift_configs = 17; repeated GiftConfig pinned_gift_configs = 17;
// vip_benefit_codes VIP
// badge VIP URL
repeated string vip_benefit_codes = 18;
} }
message ResourceGrantItem { message ResourceGrantItem {

View File

@ -3689,6 +3689,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
EntitlementId: "ent-badge-tile", EntitlementId: "ent-badge-tile",
ResourceId: 8105, ResourceId: 8105,
Resource: shortBadgeResource, Resource: shortBadgeResource,
VipBenefitCodes: []string{"vip_badge_identity"},
Equipped: true, Equipped: true,
ExpiresAtMs: 1999999999999, ExpiresAtMs: 1999999999999,
}, },
@ -3816,7 +3817,9 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
} }
badge := badges[0].(map[string]any) badge := badges[0].(map[string]any)
badgeResource := badge["resource"].(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) 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 { if item == nil {
return map[string]any{} 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 { 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{} return map[string]any{}
} }
// entitlement_id/expires_at_ms 是佩戴态字段resource 本身不带这两个用户维度。 // 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 { func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any {

View File

@ -18,6 +18,8 @@ type UserResourceEntitlement struct {
GiftSnapshotHash string GiftSnapshotHash string
GiftSnapshotJSON string GiftSnapshotJSON string
PinnedGiftConfigs []GiftConfig PinnedGiftConfigs []GiftConfig
// VIPBenefitCodes 由当前 VIP program、有效等级和 trial 规则共同计算,只描述本资源命中的权益。
VIPBenefitCodes []string
CreatedAtMS int64 CreatedAtMS int64
UpdatedAtMS int64 UpdatedAtMS int64
Equipped bool Equipped bool

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 { func containsVipBenefit(items []ledger.VipBenefit, code string) bool {
for _, item := range items { for _, item := range items {
if item.BenefitCode == code { if item.BenefitCode == code {

View File

@ -622,8 +622,8 @@ type vipResourceBindingKey struct {
type currentVipResourceBinding struct { type currentVipResourceBinding struct {
ResourceType string ResourceType string
PaidAllowed bool PaidBenefitCodes []string
TrialAllowed bool TrialBenefitCodes []string
} }
type vipBatchEffectiveLevels struct { type vipBatchEffectiveLevels struct {
@ -667,13 +667,13 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx)) ledger.VipProgramTypeTieredPrivilegeV1, appcode.FromContext(ctx))
mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...) mappingArgs = append(mappingArgs, int64AnyArgs(resourceIDs)...)
rows, err := tx.QueryContext(ctx, ` 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 FALSE AS trial_enabled, FALSE AS current_allowed
FROM vip_benefit_resource_binding_history AS history FROM vip_benefit_resource_binding_history AS history
WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`) WHERE history.app_code = ? AND history.resource_id IN (`+placeholders(len(resourceIDs))+`)
AND history.program_type = ? AND history.program_type = ?
UNION ALL 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.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 (benefit.status = 'active' AND resource.resource_id IS NOT NULL) AS current_allowed
FROM vip_level_benefits AS benefit FROM vip_level_benefits AS benefit
@ -693,9 +693,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
var resourceID int64 var resourceID int64
var level int32 var level int32
var resourceType string var resourceType string
var benefitCode string
var trialEnabled bool var trialEnabled bool
var currentAllowed 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() _ = rows.Close()
return err return err
} }
@ -706,8 +707,10 @@ func (r *Repository) filterCurrentVipEquipmentBatch(ctx context.Context, userIDs
key := vipResourceBindingKey{Level: level, ResourceID: resourceID} key := vipResourceBindingKey{Level: level, ResourceID: resourceID}
binding := bindings[key] binding := bindings[key]
binding.ResourceType = resourcedomain.NormalizeResourceType(resourceType) binding.ResourceType = resourcedomain.NormalizeResourceType(resourceType)
binding.PaidAllowed = true binding.PaidBenefitCodes = appendUniqueNormalizedVIPCode(binding.PaidBenefitCodes, benefitCode)
binding.TrialAllowed = binding.TrialAllowed || trialEnabled if trialEnabled {
binding.TrialBenefitCodes = appendUniqueNormalizedVIPCode(binding.TrialBenefitCodes, benefitCode)
}
bindings[key] = binding bindings[key] = binding
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -748,31 +751,50 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
filtered = append(filtered, entitlement) filtered = append(filtered, entitlement)
continue continue
} }
if !hasCurrentVipResourceBindingAtOrBelow(bindings, effectiveLevel, effectiveSource, benefitCodes := currentVipBenefitCodesAtOrBelow(bindings, effectiveLevel, effectiveSource,
entitlement.ResourceID, entitlement.Resource.ResourceType) { entitlement.ResourceID, entitlement.Resource.ResourceType)
if len(benefitCodes) == 0 {
continue continue
} }
// 同为 badge 的 VIP 身份标识、勋章和普通徽章不能靠素材名称区分;将 wallet
// 已完成等级、trial 和改绑校验后的资源级权益事实随 entitlement 一并投影。
entitlement.VIPBenefitCodes = benefitCodes
filtered = append(filtered, entitlement) filtered = append(filtered, entitlement)
} }
grouped[userID] = filtered grouped[userID] = filtered
} }
} }
// hasCurrentVipResourceBindingAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入 // currentVipBenefitCodesAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入
// 数据库请求或按用户增长的复杂度;倒序保证同一资源重复绑定时优先采用最高可用等级的试用策略 // 数据库请求或按用户增长的复杂度;同一资源跨等级复用时合并所有当前可用权益编码,供展示层精确分流
func hasCurrentVipResourceBindingAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) bool { func currentVipBenefitCodesAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) []string {
resourceType = resourcedomain.NormalizeResourceType(resourceType) resourceType = resourcedomain.NormalizeResourceType(resourceType)
result := make([]string, 0, 2)
for level := effectiveLevel; level > 0; level-- { for level := effectiveLevel; level > 0; level-- {
binding, exists := bindings[vipResourceBindingKey{Level: level, ResourceID: resourceID}] binding, exists := bindings[vipResourceBindingKey{Level: level, ResourceID: resourceID}]
if !exists || binding.ResourceType != resourceType { if !exists || binding.ResourceType != resourceType {
continue continue
} }
if effectiveSource == ledger.VipEffectiveSourceTrial && binding.TrialAllowed { codes := binding.PaidBenefitCodes
return true if effectiveSource == ledger.VipEffectiveSourceTrial {
codes = binding.TrialBenefitCodes
} }
if effectiveSource == ledger.VipEffectiveSourcePaid && binding.PaidAllowed { for _, code := range codes {
return true 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 版本,模拟后台完整配置发布。 // SetVIPDailyCoinRebateAmount 写入测试专用等级金额并递增 program 版本,模拟后台完整配置发布。
// amount<=0 时仍写入,供服务端校验/运行防线测试构造异常历史数据。 // amount<=0 时仍写入,供服务端校验/运行防线测试构造异常历史数据。
func (r *Repository) SetVIPDailyCoinRebateAmount(appCode string, level int32, amount int64, status string) { 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, Equipped: item.Equipped,
SourceSnapshotId: item.SourceSnapshotID, SourceSnapshotId: item.SourceSnapshotID,
ResourceSnapshotHash: item.ResourceSnapshotHash, ResourceSnapshotHash: item.ResourceSnapshotHash,
VipBenefitCodes: append([]string(nil), item.VIPBenefitCodes...),
} }
for _, gift := range item.PinnedGiftConfigs { for _, gift := range item.PinnedGiftConfigs {
out.PinnedGiftConfigs = append(out.PinnedGiftConfigs, giftConfigToProto(gift)) out.PinnedGiftConfigs = append(out.PinnedGiftConfigs, giftConfigToProto(gift))