From 3dd58b2f9cbae375146c0c64643bd4b83b4cf84e Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 20:18:08 +0800 Subject: [PATCH] =?UTF-8?q?vip=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/wallet/v1/wallet.pb.go | 42 +++++++--- api/proto/wallet/v1/wallet.proto | 11 ++- .../internal/room/service/decoration.go | 6 +- .../service/wallet/ports/repository.go | 3 + .../internal/service/wallet/resource.go | 8 +- .../internal/service/wallet/vip.go | 16 ++++ .../mysql/vip_benefit_resource_repository.go | 80 ++++++++++++++----- .../storage/mysql/vip_program_repository.go | 67 ++++++++++++++++ .../internal/transport/grpc/vip.go | 10 ++- 9 files changed, 203 insertions(+), 40 deletions(-) diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 07826757..cfeadd74 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -17961,7 +17961,8 @@ type VipProgramConfig struct { UpgradeExpiryPolicy string `protobuf:"bytes,5,opt,name=upgrade_expiry_policy,json=upgradeExpiryPolicy,proto3" json:"upgrade_expiry_policy,omitempty"` // downgrade_purchase_policy 首版固定 reject,保留字段用于后台完整展示当前规则。 DowngradePurchasePolicy string `protobuf:"bytes,6,opt,name=downgrade_purchase_policy,json=downgradePurchasePolicy,proto3" json:"downgrade_purchase_policy,omitempty"` - // benefit_inheritance_policy 当前固定 target_only;每级提交最终完整集合,不做自动继承。 + // benefit_inheritance_policy 当前固定 target_only;每级提交功能权益的最终完整集合。 + // 带 resource_id 的装扮素材由 CheckVipBenefit 按具体资源执行 level <= effective level 校验。 BenefitInheritancePolicy string `protobuf:"bytes,7,opt,name=benefit_inheritance_policy,json=benefitInheritancePolicy,proto3" json:"benefit_inheritance_policy,omitempty"` // grant_mode: direct_membership/trial_card,决定后台“赠送 VIP”的真实账务对象。 GrantMode string `protobuf:"bytes,8,opt,name=grant_mode,json=grantMode,proto3" json:"grant_mode,omitempty"` @@ -21454,14 +21455,18 @@ func (x *UnequipVipTrialCardResponse) GetServerTimeMs() int64 { return 0 } -// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能只比较 VIP level, -// 因为体验卡排除项和 App 配置会改变最终可执行权益集合。 +// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能自行比较 VIP level, +// 因为体验卡排除项、资源目录状态和 App 配置会改变最终可执行权益集合。 type CheckVipBenefitRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - BenefitCode string `protobuf:"bytes,4,opt,name=benefit_code,json=benefitCode,proto3" json:"benefit_code,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + BenefitCode string `protobuf:"bytes,4,opt,name=benefit_code,json=benefitCode,proto3" json:"benefit_code,omitempty"` + // resource_id/resource_type 同时传入时校验具体装扮资源。资源型权益允许使用当前 + // effective VIP 等级及以下仍处于 active 目录的绑定;不传时保持功能权益的精确等级语义。 + ResourceId int64 `protobuf:"varint,5,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + ResourceType string `protobuf:"bytes,6,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -21524,6 +21529,20 @@ func (x *CheckVipBenefitRequest) GetBenefitCode() string { return "" } +func (x *CheckVipBenefitRequest) GetResourceId() int64 { + if x != nil { + return x.ResourceId + } + return 0 +} + +func (x *CheckVipBenefitRequest) GetResourceType() string { + if x != nil { + return x.ResourceType + } + return "" +} + type CheckVipBenefitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` @@ -29384,13 +29403,16 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "unequipped\x18\x01 \x01(\bR\n" + "unequipped\x12/\n" + "\x05state\x18\x02 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\x12$\n" + - "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\x8e\x01\n" + + "\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xd4\x01\n" + "\x16CheckVipBenefitRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" + - "\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\"\xc7\x02\n" + + "\fbenefit_code\x18\x04 \x01(\tR\vbenefitCode\x12\x1f\n" + + "\vresource_id\x18\x05 \x01(\x03R\n" + + "resourceId\x12#\n" + + "\rresource_type\x18\x06 \x01(\tR\fresourceType\"\xc7\x02\n" + "\x17CheckVipBenefitResponse\x12\x18\n" + "\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" + "\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 93cb4bfc..6df2b9a3 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -2092,7 +2092,8 @@ message VipProgramConfig { string upgrade_expiry_policy = 5; // downgrade_purchase_policy 首版固定 reject,保留字段用于后台完整展示当前规则。 string downgrade_purchase_policy = 6; - // benefit_inheritance_policy 当前固定 target_only;每级提交最终完整集合,不做自动继承。 + // benefit_inheritance_policy 当前固定 target_only;每级提交功能权益的最终完整集合。 + // 带 resource_id 的装扮素材由 CheckVipBenefit 按具体资源执行 level <= effective level 校验。 string benefit_inheritance_policy = 7; // grant_mode: direct_membership/trial_card,决定后台“赠送 VIP”的真实账务对象。 string grant_mode = 8; @@ -2490,13 +2491,17 @@ message UnequipVipTrialCardResponse { int64 server_time_ms = 3; } -// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能只比较 VIP level, -// 因为体验卡排除项和 App 配置会改变最终可执行权益集合。 +// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能自行比较 VIP level, +// 因为体验卡排除项、资源目录状态和 App 配置会改变最终可执行权益集合。 message CheckVipBenefitRequest { string request_id = 1; string app_code = 2; int64 user_id = 3; string benefit_code = 4; + // resource_id/resource_type 同时传入时校验具体装扮资源。资源型权益允许使用当前 + // effective VIP 等级及以下仍处于 active 目录的绑定;不传时保持功能权益的精确等级语义。 + int64 resource_id = 5; + string resource_type = 6; } message CheckVipBenefitResponse { diff --git a/services/room-service/internal/room/service/decoration.go b/services/room-service/internal/room/service/decoration.go index 1e21b7ef..691eba72 100644 --- a/services/room-service/internal/room/service/decoration.go +++ b/services/room-service/internal/room/service/decoration.go @@ -160,6 +160,9 @@ func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, u defer cancel() decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{ RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode, + // 具体资源坐标让 wallet owner 判定“资源等级 <= 当前有效 VIP 等级”;room-service + // 只核对返回快照,不复制付费/体验卡、目录状态或继承算法。 + ResourceId: resourceID, ResourceType: resourceType, }) if err != nil { return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "check room decoration vip benefit failed") @@ -207,7 +210,8 @@ func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, u continue } // entitlement 只证明该用户仍有权使用 resource_id;展示内容必须取 CheckVipBenefit 返回的 - // 当前 active 目录资源。这样后台更新素材后会刷新房间,且不会继续渲染 entitlement 的历史快照。 + // 当前 active 目录资源(可能来自任一不高于当前 VIP 的等级)。后台更新素材后会刷新房间, + // 且不会继续渲染 entitlement 的历史快照。 metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson()) if metadataErr != nil { return state.RoomDecoration{}, metadataErr diff --git a/services/wallet-service/internal/service/wallet/ports/repository.go b/services/wallet-service/internal/service/wallet/ports/repository.go index 77e7b8d8..96f55831 100644 --- a/services/wallet-service/internal/service/wallet/ports/repository.go +++ b/services/wallet-service/internal/service/wallet/ports/repository.go @@ -153,6 +153,9 @@ type VIPStore interface { // GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。 GetVipState(ctx context.Context, userID int64) (ledger.VipState, error) CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, error) + // CheckVipResourceBenefit 校验具体资源是否仍绑定在当前 effective VIP 等级或其以下, + // 功能型权益继续由 CheckVipBenefit 按当前等级完整矩阵判定。 + CheckVipResourceBenefit(ctx context.Context, userID int64, benefitCode string, resourceID int64, resourceType string) (ledger.CheckVipBenefitResult, error) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) GrantVipTrialCard(ctx context.Context, command ledger.GrantVipTrialCardCommand) (ledger.GrantVipTrialCardReceipt, error) diff --git a/services/wallet-service/internal/service/wallet/resource.go b/services/wallet-service/internal/service/wallet/resource.go index 77ec67ba..7704ebc6 100644 --- a/services/wallet-service/internal/service/wallet/resource.go +++ b/services/wallet-service/internal/service/wallet/resource.go @@ -367,7 +367,7 @@ func (s *Service) EquipUserResource(ctx context.Context, command resourcedomain. // requireVipBenefitForResourceEquip 对 tiered VIP 绑定资源执行服务端二次门禁。 // repository 的绑定查询已联结 program_type,因此 Lalu legacy_timed 资源不会进入此分支; -// Fami 用户即使持有历史 entitlement,也必须仍有对应 effective benefit 才能重新装备。 +// 资源型权益允许当前 effective 等级向下继承,但历史 entitlement 仍不能绕过当前 active 目录绑定。 func (s *Service) requireVipBenefitForResourceEquip(ctx context.Context, userID int64, resourceID int64, resourceType string) error { requirements, err := s.repository.ListVipBenefitRequirementsForResource(ctx, resourceID) if err != nil || len(requirements) == 0 { @@ -375,13 +375,13 @@ func (s *Service) requireVipBenefitForResourceEquip(ctx context.Context, userID } var denied ledger.CheckVipBenefitResult for _, requirement := range requirements { - result, checkErr := s.repository.CheckVipBenefit(ctx, userID, requirement.BenefitCode) + result, checkErr := s.repository.CheckVipResourceBenefit(ctx, userID, requirement.BenefitCode, resourceID, resourceType) if checkErr != nil { // 权益 owner 读取失败必须失败关闭,不能因为用户已有 entitlement 就跳过付费门禁。 return checkErr } - // 权益 code 相同仍不代表样式相同:VIP8 的旧 entitlement 在切到 VIP4 后可能尚未到期。 - // 只有 wallet 当前 effective benefit 精确指向本 resource_id/type 才允许重新装备。 + // resource_id/type 已由 owner 查询限定;这里再次核对返回投影,避免未来仓储实现 + // 只按 benefit_code 放行而把同类其他等级素材错误装备。 if result.Allowed && result.Benefit.ResourceID == resourceID && resourcedomain.NormalizeResourceType(result.Benefit.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) && resourcedomain.NormalizeResourceType(requirement.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) { diff --git a/services/wallet-service/internal/service/wallet/vip.go b/services/wallet-service/internal/service/wallet/vip.go index cf594051..8a864674 100644 --- a/services/wallet-service/internal/service/wallet/vip.go +++ b/services/wallet-service/internal/service/wallet/vip.go @@ -5,6 +5,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" + resourcedomain "hyapp/services/wallet-service/internal/domain/resource" "strings" ) @@ -67,6 +68,21 @@ func (s *Service) CheckVipBenefit(ctx context.Context, userID int64, benefitCode return s.repository.CheckVipBenefit(ctx, userID, benefitCode) } +// CheckVipResourceBenefit 是装扮资源的 owner 判定入口。它把具体 resource_id 纳入校验, +// 因此 room-service 和客户端都无需复制 VIP 等级继承、体验卡排除或资源目录有效性规则。 +func (s *Service) CheckVipResourceBenefit(ctx context.Context, userID int64, benefitCode string, resourceID int64, resourceType string) (ledger.CheckVipBenefitResult, error) { + benefitCode = strings.ToLower(strings.TrimSpace(benefitCode)) + resourceType = resourcedomain.NormalizeResourceType(resourceType) + if userID <= 0 || benefitCode == "" || resourceID <= 0 || resourceType == "" { + return ledger.CheckVipBenefitResult{}, xerr.New(xerr.InvalidArgument, "vip resource benefit request is incomplete") + } + if s.repository == nil { + return ledger.CheckVipBenefitResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") + } + ctx = appcode.WithContext(ctx, appcode.FromContext(ctx)) + return s.repository.CheckVipResourceBenefit(ctx, userID, benefitCode, resourceID, resourceType) +} + // PurchaseVip 执行 VIP 购买、升级或续期;降级由 repository 在锁内按当前会员状态拒绝。 func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { command.CommandID = strings.TrimSpace(command.CommandID) 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 0d9f2356..4c054169 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 @@ -21,8 +21,8 @@ type activeVipBenefitResource struct { Resource resourcedomain.Resource } -// listActiveVipBenefitResourcesTx 只返回当前最终等级真正可用的资源。权益和自动佩戴仍遵守 -// target_only,不能因为付费购买会发放低等级历史物品,就把低等级样式当成当前有效权益。 +// listActiveVipBenefitResourcesTx 只返回当前最终等级的自动佩戴候选。用户可以手动选择低等级 +// 装扮,但 VIP 升级时不能依次自动佩戴所有历史样式覆盖当前等级的默认展示。 func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, level int32, effectiveSource string) ([]activeVipBenefitResource, error) { return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false) } @@ -101,9 +101,10 @@ func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, } // grantVipTrialBenefitResourcesTx 按 config_version 为一张体验卡创建来源独立的装扮 entitlement。 -// 改绑会生成新版本 grant;未变化资源复用同一卡已有 entitlement,既拿到新资源又不会重复旧资源。 +// 体验卡与付费 VIP 使用相同的向下资源继承;改绑会生成新版本 grant,未变化资源复用同一卡 +// 已有 entitlement,既拿到新资源又不会重复旧资源。 func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sql.Tx, card ledger.VipTrialCard, configVersion int64, nowMS int64) error { - items, err := r.listActiveVipBenefitResourcesTx(ctx, tx, card.Level, ledger.VipEffectiveSourceTrial) + items, err := r.listVipBenefitResourcesTx(ctx, tx, card.Level, ledger.VipEffectiveSourceTrial, true) if err != nil || len(items) == 0 { return err } @@ -122,7 +123,8 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq if err != nil { return err } - grantCommandID := "vip_trial_benefit:" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.FromContext(ctx), card.TrialCardID, configVersion)) + // v2 命令空间使已经按旧 target-only 规则补齐过的有效体验卡也会幂等补发低等级素材。 + grantCommandID := "vip_trial_benefit_v2:" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.FromContext(ctx), card.TrialCardID, configVersion)) requestHash := stableHash(fmt.Sprintf("%s|%d|%s", appcode.FromContext(ctx), card.UserID, snapshotJSON)) if _, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil || exists { return err @@ -174,7 +176,7 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq // grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement,彻底去掉 // “运营还要同步 reward group”的隐含前置条件。直购高等级时要把 1..level 各级物品都发入背包, -// 但同一 resource_id 只发一次;grant subject 仍绑定本次会员有效窗,不扩大当前权益和佩戴集合。 +// 但同一 resource_id 只发一次;grant subject 仍绑定本次会员有效窗,功能权限仍不做向下合并。 func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, vip ledger.UserVip, nowMS int64) ([]ledger.VipRewardItem, string, error) { if !vip.Active || vip.Level <= 0 || vip.ExpiresAtMS <= nowMS { return nil, "", nil @@ -288,8 +290,8 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx return entitlementID, true, nil } -// reconcileTieredVipResourceEquipmentTx 删除不属于最终 effective source+level 的全部历史 VIP 装备, -// 再只按当前 benefit.auto_equip 恢复对应来源的 entitlement。paid/trial 即使绑定同一 resource_id, +// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level 或已失效目录的 VIP 装备, +// 再只按当前等级 benefit.auto_equip 恢复默认 entitlement。paid/trial 即使绑定同一 resource_id, // 也会切换到各自 source grant,避免任一来源到期影响另一来源。 func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64) error { trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial @@ -328,7 +330,7 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, AND current_resource.status = 'active' AND current_resource.resource_type = current_benefit.resource_type WHERE current_benefit.app_code = equipment.app_code - AND current_benefit.level = ? + AND current_benefit.level <= ? AND current_benefit.status = 'active' AND current_benefit.resource_id = equipment.resource_id AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?)) @@ -465,6 +467,7 @@ func (r *Repository) isTieredVipBenefitResourceTx(ctx context.Context, tx *sql.T } // requireCurrentVipResourceBindingForUserTx 是装备提交事务内的最后门禁。它先共享锁 program, +// 再通过 idx_vip_benefit_resource 对单 resource_id 做 level <= effective level 的有界检查; // 因而 admin 的 DELETE+INSERT 改绑不可能夹在校验与 equipment 写入之间。 func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error { bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID) @@ -489,10 +492,32 @@ func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Conte if err != nil { return err } - for _, benefit := range state.EffectiveBenefits { - if benefit.ResourceID == resourceID && benefit.Resource != nil && benefit.Resource.ResourceID == resourceID { - return nil - } + if !state.EffectiveVip.Active || state.EffectiveVip.Level <= 0 { + return vipBenefitRequiredForResourceEquip(requirement, resourceID, state.EffectiveVip.Level) + } + trialOnly := state.EffectiveSource == ledger.VipEffectiveSourceTrial + if state.EffectiveSource != ledger.VipEffectiveSourcePaid && !trialOnly { + return vipBenefitRequiredForResourceEquip(requirement, resourceID, state.EffectiveVip.Level) + } + var allowed bool + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM vip_level_benefits AS benefit + JOIN resources AS resource ON resource.app_code = benefit.app_code + AND resource.resource_id = benefit.resource_id + AND resource.status = 'active' + AND resource.resource_type = benefit.resource_type + WHERE benefit.app_code = ? AND benefit.resource_id = ? + AND benefit.status = 'active' AND benefit.level <= ? + AND (? = FALSE OR (benefit.trial_enabled = TRUE AND benefit.benefit_code <> ?)) + )`, appcode.FromContext(ctx), resourceID, state.EffectiveVip.Level, + trialOnly, ledger.VipBenefitCodeDailyCoinRebate, + ).Scan(&allowed); err != nil { + return err + } + if allowed { + return nil } return vipBenefitRequiredForResourceEquip(requirement, resourceID, state.EffectiveVip.Level) } @@ -723,14 +748,8 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE filtered = append(filtered, entitlement) continue } - binding, exact := bindings[vipResourceBindingKey{Level: effectiveLevel, ResourceID: entitlement.ResourceID}] - if !exact || binding.ResourceType != resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) { - continue - } - if effectiveSource == ledger.VipEffectiveSourceTrial && !binding.TrialAllowed { - continue - } - if effectiveSource == ledger.VipEffectiveSourcePaid && !binding.PaidAllowed { + if !hasCurrentVipResourceBindingAtOrBelow(bindings, effectiveLevel, effectiveSource, + entitlement.ResourceID, entitlement.Resource.ResourceType) { continue } filtered = append(filtered, entitlement) @@ -738,3 +757,22 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE grouped[userID] = filtered } } + +// hasCurrentVipResourceBindingAtOrBelow 只在后台配置的有限 VIP 等级内倒序查找,因此不会引入 +// 数据库请求或按用户增长的复杂度;倒序保证同一资源重复绑定时优先采用最高可用等级的试用策略。 +func hasCurrentVipResourceBindingAtOrBelow(bindings map[vipResourceBindingKey]currentVipResourceBinding, effectiveLevel int32, effectiveSource string, resourceID int64, resourceType string) bool { + resourceType = resourcedomain.NormalizeResourceType(resourceType) + 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 + } + if effectiveSource == ledger.VipEffectiveSourcePaid && binding.PaidAllowed { + return true + } + } + return false +} diff --git a/services/wallet-service/internal/storage/mysql/vip_program_repository.go b/services/wallet-service/internal/storage/mysql/vip_program_repository.go index 5bd420a5..a92aece1 100644 --- a/services/wallet-service/internal/storage/mysql/vip_program_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_program_repository.go @@ -402,6 +402,73 @@ func (r *Repository) CheckVipBenefit(ctx context.Context, userID int64, benefitC return result, nil } +// CheckVipResourceBenefit 校验具体装扮资源是否属于当前 effective VIP 等级或任一更低等级。 +// 查询先由 idx_vip_benefit_resource 把范围收敛到单 App、单 resource_id,再受最多 level_count +// 个等级约束;它不会扫描用户背包,也不会改变 target_only 功能权益的执行语义。 +func (r *Repository) CheckVipResourceBenefit(ctx context.Context, userID int64, benefitCode string, resourceID int64, resourceType string) (ledger.CheckVipBenefitResult, error) { + state, err := r.GetVipState(ctx, userID) + if err != nil { + return ledger.CheckVipBenefitResult{}, err + } + code := strings.ToLower(strings.TrimSpace(benefitCode)) + resourceType = resourcedomain.NormalizeResourceType(resourceType) + var requiredLevel int32 + if err := r.db.QueryRowContext(ctx, ` + SELECT COALESCE(MIN(b.level), 0) + FROM vip_level_benefits AS b + JOIN resources AS resource ON resource.app_code = b.app_code + AND resource.resource_id = b.resource_id + AND resource.status = 'active' + AND resource.resource_type = b.resource_type + WHERE b.app_code = ? AND b.resource_id = ? AND b.status = ? + AND b.benefit_code = ? AND b.resource_type = ?`, + appcode.FromContext(ctx), resourceID, ledger.VipStatusActive, code, resourceType, + ).Scan(&requiredLevel); err != nil { + return ledger.CheckVipBenefitResult{}, err + } + result := ledger.CheckVipBenefitResult{ + State: state, DenialReason: "resource_benefit_not_enabled", EvaluatedAtMS: state.EvaluatedAtMS, + RequiredLevel: requiredLevel, CurrentEffectiveLevel: state.EffectiveVip.Level, + } + if !state.EffectiveVip.Active { + result.DenialReason = "vip_inactive" + return result, nil + } + trialOnly := state.EffectiveSource == ledger.VipEffectiveSourceTrial + if state.EffectiveSource != ledger.VipEffectiveSourcePaid && !trialOnly { + return result, nil + } + row := r.db.QueryRowContext(ctx, ` + SELECT b.benefit_code, b.name, b.benefit_type, b.unlock_level, b.status, b.trial_enabled, + b.resource_id, b.resource_type, b.execution_scope, b.auto_equip, b.sort_order, + b.metadata_json, b.created_at_ms, b.updated_at_ms, + `+nullableResourceColumnsWithAlias("resource")+` + FROM vip_level_benefits AS b + JOIN resources AS resource ON resource.app_code = b.app_code + AND resource.resource_id = b.resource_id + AND resource.status = 'active' + AND resource.resource_type = b.resource_type + WHERE b.app_code = ? AND b.resource_id = ? AND b.status = ? + AND b.benefit_code = ? AND b.resource_type = ? AND b.level <= ? + AND (? = FALSE OR (b.trial_enabled = TRUE AND b.benefit_code <> ?)) + ORDER BY b.level DESC + LIMIT 1`, + appcode.FromContext(ctx), resourceID, ledger.VipStatusActive, code, resourceType, + state.EffectiveVip.Level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate, + ) + benefit, err := scanVipBenefit(row) + if errors.Is(err, sql.ErrNoRows) { + return result, nil + } + if err != nil { + return ledger.CheckVipBenefitResult{}, err + } + result.Allowed = true + result.Benefit = benefit + result.DenialReason = "" + return result, nil +} + func vipProgramSelectSQL() string { return ` SELECT app_code, program_type, level_count, same_level_expiry_policy, diff --git a/services/wallet-service/internal/transport/grpc/vip.go b/services/wallet-service/internal/transport/grpc/vip.go index 88c4c610..fc773963 100644 --- a/services/wallet-service/internal/transport/grpc/vip.go +++ b/services/wallet-service/internal/transport/grpc/vip.go @@ -57,7 +57,15 @@ func (s *Server) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipPr // CheckVipBenefit 供权益 owner 精确判断单项能力,不允许调用方退化成等级比较。 func (s *Server) CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) - result, err := s.svc.CheckVipBenefit(ctx, req.GetUserId(), req.GetBenefitCode()) + var result ledger.CheckVipBenefitResult + var err error + if req.GetResourceId() > 0 || req.GetResourceType() != "" { + // 带资源坐标的调用走向下继承判定;不带资源的通知、隐私、返币等功能权益 + // 继续使用当前等级完整矩阵,不能因为装扮继承而扩大功能权限。 + result, err = s.svc.CheckVipResourceBenefit(ctx, req.GetUserId(), req.GetBenefitCode(), req.GetResourceId(), req.GetResourceType()) + } else { + result, err = s.svc.CheckVipBenefit(ctx, req.GetUserId(), req.GetBenefitCode()) + } if err != nil { return nil, xerr.ToGRPCError(err) }