vip素材

This commit is contained in:
zhx 2026-07-22 20:18:08 +08:00
parent b0301a5dd0
commit 3dd58b2f9c
9 changed files with 203 additions and 40 deletions

View File

@ -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"` UpgradeExpiryPolicy string `protobuf:"bytes,5,opt,name=upgrade_expiry_policy,json=upgradeExpiryPolicy,proto3" json:"upgrade_expiry_policy,omitempty"`
// downgrade_purchase_policy 首版固定 reject保留字段用于后台完整展示当前规则。 // downgrade_purchase_policy 首版固定 reject保留字段用于后台完整展示当前规则。
DowngradePurchasePolicy string `protobuf:"bytes,6,opt,name=downgrade_purchase_policy,json=downgradePurchasePolicy,proto3" json:"downgrade_purchase_policy,omitempty"` 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"` 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”的真实账务对象。 // grant_mode: direct_membership/trial_card决定后台“赠送 VIP”的真实账务对象。
GrantMode string `protobuf:"bytes,8,opt,name=grant_mode,json=grantMode,proto3" json:"grant_mode,omitempty"` 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 return 0
} }
// CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能比较 VIP level // CheckVipBenefit 供 room/user 等权益执行方校验单个特权。调用方不能自行比较 VIP level
// 因为体验卡排除项和 App 配置会改变最终可执行权益集合。 // 因为体验卡排除项、资源目录状态和 App 配置会改变最终可执行权益集合。
type CheckVipBenefitRequest struct { type CheckVipBenefitRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -21524,6 +21529,20 @@ func (x *CheckVipBenefitRequest) GetBenefitCode() string {
return "" 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 { type CheckVipBenefitResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` 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\x18\x01 \x01(\bR\n" +
"unequipped\x12/\n" + "unequipped\x12/\n" +
"\x05state\x18\x02 \x01(\v2\x19.hyapp.wallet.v1.VipStateR\x05state\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" + "\x16CheckVipBenefitRequest\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
"\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\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" + "\x17CheckVipBenefitResponse\x12\x18\n" +
"\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" + "\aallowed\x18\x01 \x01(\bR\aallowed\x125\n" +
"\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" + "\abenefit\x18\x02 \x01(\v2\x1b.hyapp.wallet.v1.VipBenefitR\abenefit\x12/\n" +

View File

@ -2092,7 +2092,8 @@ message VipProgramConfig {
string upgrade_expiry_policy = 5; string upgrade_expiry_policy = 5;
// downgrade_purchase_policy reject // downgrade_purchase_policy reject
string downgrade_purchase_policy = 6; 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; string benefit_inheritance_policy = 7;
// grant_mode: direct_membership/trial_card VIP // grant_mode: direct_membership/trial_card VIP
string grant_mode = 8; string grant_mode = 8;
@ -2490,13 +2491,17 @@ message UnequipVipTrialCardResponse {
int64 server_time_ms = 3; int64 server_time_ms = 3;
} }
// CheckVipBenefit room/user VIP level // CheckVipBenefit room/user VIP level
// App // App
message CheckVipBenefitRequest { message CheckVipBenefitRequest {
string request_id = 1; string request_id = 1;
string app_code = 2; string app_code = 2;
int64 user_id = 3; int64 user_id = 3;
string benefit_code = 4; string benefit_code = 4;
// resource_id/resource_type 使
// effective VIP active
int64 resource_id = 5;
string resource_type = 6;
} }
message CheckVipBenefitResponse { message CheckVipBenefitResponse {

View File

@ -160,6 +160,9 @@ func (s *Service) resolveRoomDecoration(ctx context.Context, requestID string, u
defer cancel() defer cancel()
decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{ decision, err := checker.CheckVipBenefit(lookupCtx, &walletv1.CheckVipBenefitRequest{
RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode, RequestId: strings.TrimSpace(requestID), AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: benefitCode,
// 具体资源坐标让 wallet owner 判定“资源等级 <= 当前有效 VIP 等级”room-service
// 只核对返回快照,不复制付费/体验卡、目录状态或继承算法。
ResourceId: resourceID, ResourceType: resourceType,
}) })
if err != nil { if err != nil {
return state.RoomDecoration{}, xerr.New(xerr.Unavailable, "check room decoration vip benefit failed") 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 continue
} }
// entitlement 只证明该用户仍有权使用 resource_id展示内容必须取 CheckVipBenefit 返回的 // entitlement 只证明该用户仍有权使用 resource_id展示内容必须取 CheckVipBenefit 返回的
// 当前 active 目录资源。这样后台更新素材后会刷新房间,且不会继续渲染 entitlement 的历史快照。 // 当前 active 目录资源(可能来自任一不高于当前 VIP 的等级)。后台更新素材后会刷新房间,
// 且不会继续渲染 entitlement 的历史快照。
metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson()) metadata, metadataErr := parseRoomDecorationMetadata(resourceType, catalogResource.GetMetadataJson())
if metadataErr != nil { if metadataErr != nil {
return state.RoomDecoration{}, metadataErr return state.RoomDecoration{}, metadataErr

View File

@ -153,6 +153,9 @@ type VIPStore interface {
// GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。 // GetVipState 是 paid membership、当前佩戴体验卡和最终权益的唯一合并读模型。
GetVipState(ctx context.Context, userID int64) (ledger.VipState, error) GetVipState(ctx context.Context, userID int64) (ledger.VipState, error)
CheckVipBenefit(ctx context.Context, userID int64, benefitCode string) (ledger.CheckVipBenefitResult, 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) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error)
GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error)
GrantVipTrialCard(ctx context.Context, command ledger.GrantVipTrialCardCommand) (ledger.GrantVipTrialCardReceipt, error) GrantVipTrialCard(ctx context.Context, command ledger.GrantVipTrialCardCommand) (ledger.GrantVipTrialCardReceipt, error)

View File

@ -367,7 +367,7 @@ func (s *Service) EquipUserResource(ctx context.Context, command resourcedomain.
// requireVipBenefitForResourceEquip 对 tiered VIP 绑定资源执行服务端二次门禁。 // requireVipBenefitForResourceEquip 对 tiered VIP 绑定资源执行服务端二次门禁。
// repository 的绑定查询已联结 program_type因此 Lalu legacy_timed 资源不会进入此分支; // 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 { func (s *Service) requireVipBenefitForResourceEquip(ctx context.Context, userID int64, resourceID int64, resourceType string) error {
requirements, err := s.repository.ListVipBenefitRequirementsForResource(ctx, resourceID) requirements, err := s.repository.ListVipBenefitRequirementsForResource(ctx, resourceID)
if err != nil || len(requirements) == 0 { if err != nil || len(requirements) == 0 {
@ -375,13 +375,13 @@ func (s *Service) requireVipBenefitForResourceEquip(ctx context.Context, userID
} }
var denied ledger.CheckVipBenefitResult var denied ledger.CheckVipBenefitResult
for _, requirement := range requirements { 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 { if checkErr != nil {
// 权益 owner 读取失败必须失败关闭,不能因为用户已有 entitlement 就跳过付费门禁。 // 权益 owner 读取失败必须失败关闭,不能因为用户已有 entitlement 就跳过付费门禁。
return checkErr return checkErr
} }
// 权益 code 相同仍不代表样式相同VIP8 的旧 entitlement 在切到 VIP4 后可能尚未到期。 // resource_id/type 已由 owner 查询限定;这里再次核对返回投影,避免未来仓储实现
// 只有 wallet 当前 effective benefit 精确指向本 resource_id/type 才允许重新装备。 // 只按 benefit_code 放行而把同类其他等级素材错误装备。
if result.Allowed && result.Benefit.ResourceID == resourceID && if result.Allowed && result.Benefit.ResourceID == resourceID &&
resourcedomain.NormalizeResourceType(result.Benefit.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) && resourcedomain.NormalizeResourceType(result.Benefit.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) &&
resourcedomain.NormalizeResourceType(requirement.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) { resourcedomain.NormalizeResourceType(requirement.ResourceType) == resourcedomain.NormalizeResourceType(resourceType) {

View File

@ -5,6 +5,7 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
"strings" "strings"
) )
@ -67,6 +68,21 @@ func (s *Service) CheckVipBenefit(ctx context.Context, userID int64, benefitCode
return s.repository.CheckVipBenefit(ctx, userID, 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 在锁内按当前会员状态拒绝。 // PurchaseVip 执行 VIP 购买、升级或续期;降级由 repository 在锁内按当前会员状态拒绝。
func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) { func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) {
command.CommandID = strings.TrimSpace(command.CommandID) command.CommandID = strings.TrimSpace(command.CommandID)

View File

@ -21,8 +21,8 @@ type activeVipBenefitResource struct {
Resource resourcedomain.Resource Resource resourcedomain.Resource
} }
// listActiveVipBenefitResourcesTx 只返回当前最终等级真正可用的资源。权益和自动佩戴仍遵守 // listActiveVipBenefitResourcesTx 只返回当前最终等级的自动佩戴候选。用户可以手动选择低等级
// target_only不能因为付费购买会发放低等级历史物品就把低等级样式当成当前有效权益 // 装扮,但 VIP 升级时不能依次自动佩戴所有历史样式覆盖当前等级的默认展示
func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, level int32, effectiveSource string) ([]activeVipBenefitResource, error) { func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, level int32, effectiveSource string) ([]activeVipBenefitResource, error) {
return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false) 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。 // 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 { 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 { if err != nil || len(items) == 0 {
return err return err
} }
@ -122,7 +123,8 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
if err != nil { if err != nil {
return err 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)) 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 { if _, exists, err := r.lookupResourceGrant(ctx, tx, grantCommandID, requestHash); err != nil || exists {
return err return err
@ -174,7 +176,7 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
// grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement彻底去掉 // grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement彻底去掉
// “运营还要同步 reward group”的隐含前置条件。直购高等级时要把 1..level 各级物品都发入背包, // “运营还要同步 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) { 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 { if !vip.Active || vip.Level <= 0 || vip.ExpiresAtMS <= nowMS {
return nil, "", nil return nil, "", nil
@ -288,8 +290,8 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx
return entitlementID, true, nil return entitlementID, true, nil
} }
// reconcileTieredVipResourceEquipmentTx 删除不属于最终 effective source+level 的全部历史 VIP 装备, // reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level 或已失效目录的 VIP 装备,
// 再只按当前 benefit.auto_equip 恢复对应来源的 entitlement。paid/trial 即使绑定同一 resource_id // 再只按当前等级 benefit.auto_equip 恢复默认 entitlement。paid/trial 即使绑定同一 resource_id
// 也会切换到各自 source grant避免任一来源到期影响另一来源。 // 也会切换到各自 source grant避免任一来源到期影响另一来源。
func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64) error { 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 trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial
@ -328,7 +330,7 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
AND current_resource.status = 'active' AND current_resource.status = 'active'
AND current_resource.resource_type = current_benefit.resource_type AND current_resource.resource_type = current_benefit.resource_type
WHERE current_benefit.app_code = equipment.app_code WHERE current_benefit.app_code = equipment.app_code
AND current_benefit.level = ? AND current_benefit.level <= ?
AND current_benefit.status = 'active' AND current_benefit.status = 'active'
AND current_benefit.resource_id = equipment.resource_id AND current_benefit.resource_id = equipment.resource_id
AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?)) 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 // requireCurrentVipResourceBindingForUserTx 是装备提交事务内的最后门禁。它先共享锁 program
// 再通过 idx_vip_benefit_resource 对单 resource_id 做 level <= effective level 的有界检查;
// 因而 admin 的 DELETE+INSERT 改绑不可能夹在校验与 equipment 写入之间。 // 因而 admin 的 DELETE+INSERT 改绑不可能夹在校验与 equipment 写入之间。
func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error { func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMS int64) error {
bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID) bound, err := r.isTieredVipBenefitResourceTx(ctx, tx, resourceID)
@ -489,10 +492,32 @@ func (r *Repository) requireCurrentVipResourceBindingForUserTx(ctx context.Conte
if err != nil { if err != nil {
return err return err
} }
for _, benefit := range state.EffectiveBenefits { if !state.EffectiveVip.Active || state.EffectiveVip.Level <= 0 {
if benefit.ResourceID == resourceID && benefit.Resource != nil && benefit.Resource.ResourceID == resourceID { return vipBenefitRequiredForResourceEquip(requirement, resourceID, state.EffectiveVip.Level)
return nil
} }
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) return vipBenefitRequiredForResourceEquip(requirement, resourceID, state.EffectiveVip.Level)
} }
@ -723,14 +748,8 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
filtered = append(filtered, entitlement) filtered = append(filtered, entitlement)
continue continue
} }
binding, exact := bindings[vipResourceBindingKey{Level: effectiveLevel, ResourceID: entitlement.ResourceID}] if !hasCurrentVipResourceBindingAtOrBelow(bindings, effectiveLevel, effectiveSource,
if !exact || binding.ResourceType != resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) { entitlement.ResourceID, entitlement.Resource.ResourceType) {
continue
}
if effectiveSource == ledger.VipEffectiveSourceTrial && !binding.TrialAllowed {
continue
}
if effectiveSource == ledger.VipEffectiveSourcePaid && !binding.PaidAllowed {
continue continue
} }
filtered = append(filtered, entitlement) filtered = append(filtered, entitlement)
@ -738,3 +757,22 @@ func filterVipEquipmentByAccess(grouped map[int64][]resourcedomain.UserResourceE
grouped[userID] = filtered 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
}

View File

@ -402,6 +402,73 @@ func (r *Repository) CheckVipBenefit(ctx context.Context, userID int64, benefitC
return result, nil 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 { func vipProgramSelectSQL() string {
return ` return `
SELECT app_code, program_type, level_count, same_level_expiry_policy, SELECT app_code, program_type, level_count, same_level_expiry_policy,

View File

@ -57,7 +57,15 @@ func (s *Server) GetVipProgramConfig(ctx context.Context, req *walletv1.GetVipPr
// CheckVipBenefit 供权益 owner 精确判断单项能力,不允许调用方退化成等级比较。 // CheckVipBenefit 供权益 owner 精确判断单项能力,不允许调用方退化成等级比较。
func (s *Server) CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) { func (s *Server) CheckVipBenefit(ctx context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode()) 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 { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
} }