vip素材
This commit is contained in:
parent
b0301a5dd0
commit
3dd58b2f9c
@ -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" +
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user