收敛转盘历史礼物补偿边界

This commit is contained in:
zhx 2026-07-13 23:04:53 +08:00
parent fe468205e5
commit 93f44ecf52
3 changed files with 370 additions and 9 deletions

View File

@ -14,7 +14,7 @@ const (
TypeEmojiPack = "emoji_pack"
// TypeVIPTrialCard 是可在背包中独立计时、单选佩戴的 VIP 体验卡。
// VIP 等级和有效期事实仍由 user_vip_trial_cards 保存,资源目录只负责素材和通用背包展示。
TypeVIPTrialCard = "vip_trial_card"
TypeVIPTrialCard = "vip_trial_card"
StatusActive = "active"
StatusDisabled = "disabled"
@ -35,10 +35,13 @@ const (
GrantSourceAchievement = "achievement"
GrantSourceResourceShop = "resource_shop"
GrantSourceGameRobotInit = "game_robot_init"
GrantStatusDone = "succeeded"
GrantStatusRevoked = "revoked"
ResultWalletCredit = "wallet_credit"
ResultEntitlement = "entitlement"
// GrantSourceWheelReward 标识 activity-service 从转盘不可变结算快照发起的奖品发放。
// 历史兼容还必须满足固定 reason/command namespace、本人操作和 disabled grantable gift 等条件source 本身不是授权开关。
GrantSourceWheelReward = "wheel_reward"
GrantStatusDone = "succeeded"
GrantStatusRevoked = "revoked"
ResultWalletCredit = "wallet_credit"
ResultEntitlement = "entitlement"
GroupItemTypeResource = "resource"
GroupItemTypeWalletAsset = "wallet_asset"

View File

@ -5803,6 +5803,296 @@ func TestGameRobotInitCanGrantActiveNonGrantableResource(t *testing.T) {
}
}
// TestWheelRewardCompensationCanGrantOnlyDisabledGrantableResource 锁定历史转盘补偿的最窄豁免边界:
// 协议标记匹配后仍只允许本人补偿 disabled grantable gift且重放必须直接返回首次 grant不能重复增加权益或 outbox。
func TestWheelRewardCompensationCanGrantOnlyDisabledGrantableResource(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
disabledGrantable, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_disabled_grantable_gift",
ResourceType: resourcedomain.TypeGift,
Name: "Disabled Historical Wheel Gift",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"wheel_reward"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create disabled wheel resource failed: %v", err)
}
const baseCommandID = "wheel_resource:wheel_draw_1720000000000_0123456789abcdef"
command := resourcedomain.GrantResourceCommand{
CommandID: baseCommandID,
TargetUserID: 43101,
ResourceID: disabledGrantable.ResourceID,
Quantity: 2,
Reason: resourcedomain.GrantSourceWheelReward,
OperatorUserID: 43101,
GrantSource: resourcedomain.GrantSourceWheelReward,
}
first, err := svc.GrantResource(ctx, command)
if err != nil {
t.Fatalf("historical wheel compensation should grant disabled grantable resource: %v", err)
}
replayed, err := svc.GrantResource(ctx, command)
if err != nil {
t.Fatalf("historical wheel compensation replay failed: %v", err)
}
if first.GrantID == "" || replayed.GrantID != first.GrantID || len(first.Items) != 1 || len(replayed.Items) != 1 || replayed.Items[0].GrantItemID != first.Items[0].GrantItemID {
t.Fatalf("wheel compensation idempotency mismatch: first=%+v replayed=%+v", first, replayed)
}
_, quantity, remaining := repository.ActiveResourceEntitlement(command.TargetUserID, disabledGrantable.ResourceID)
if quantity != command.Quantity || remaining != command.Quantity {
t.Fatalf("wheel compensation replay must not double entitlement: quantity=%d remaining=%d command=%+v", quantity, remaining, command)
}
if got := repository.CountRows("resource_grants", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("wheel compensation replay must keep one grant, got %d", got)
}
if got := repository.CountRows("resource_grant_items", "grant_id = ?", first.GrantID); got != 1 {
t.Fatalf("wheel compensation replay must keep one grant item, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", command.CommandID, "ResourceGranted"); got != 1 {
t.Fatalf("wheel compensation replay must publish ResourceGranted once, got %d", got)
}
// 后台即使复用 wheel command/reason 也不能发 disabled 资源admin source 必须继续沿用原有 active-only 语义。
adminCommand := command
adminCommand.CommandID = baseCommandID + ":2"
adminCommand.TargetUserID = 43102
adminCommand.GrantSource = resourcedomain.GrantSourceAdmin
if _, err := svc.GrantResource(ctx, adminCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("admin must not grant disabled wheel resource, got %v", err)
}
deletedResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_deleted_grantable_gift",
ResourceType: resourcedomain.TypeGift,
Name: "Deleted Historical Wheel Gift",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"wheel_reward"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create deletable wheel resource failed: %v", err)
}
if _, err := svc.DeleteResource(ctx, resourcedomain.StatusCommand{ID: deletedResource.ResourceID, OperatorUserID: 90001}); err != nil {
t.Fatalf("delete wheel resource failed: %v", err)
}
deletedCommand := command
deletedCommand.CommandID = baseCommandID + ":3"
deletedCommand.TargetUserID = 43103
deletedCommand.OperatorUserID = deletedCommand.TargetUserID
deletedCommand.ResourceID = deletedResource.ResourceID
if _, err := svc.GrantResource(ctx, deletedCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("deleted resource must remain rejected for wheel compensation, got %v", err)
}
nonGrantable, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_disabled_non_grantable_gift",
ResourceType: resourcedomain.TypeGift,
Name: "Non Grantable Historical Wheel Gift",
Status: resourcedomain.StatusDisabled,
Grantable: false,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"wheel_reward"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create non-grantable wheel resource failed: %v", err)
}
nonGrantableCommand := command
nonGrantableCommand.CommandID = baseCommandID + ":4"
nonGrantableCommand.TargetUserID = 43104
nonGrantableCommand.OperatorUserID = nonGrantableCommand.TargetUserID
nonGrantableCommand.ResourceID = nonGrantable.ResourceID
if _, err := svc.GrantResource(ctx, nonGrantableCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("non-grantable resource must remain rejected for wheel compensation, got %v", err)
}
// 只伪造 wheel_reward source 不足以进入兼容分支reason 或命名空间任一不匹配都应在读取资源前作为畸形 Wheel 命令拒绝。
for _, forged := range []resourcedomain.GrantResourceCommand{
{
CommandID: baseCommandID + ":5", TargetUserID: 43105, ResourceID: disabledGrantable.ResourceID, Quantity: 1,
Reason: "manual repair", OperatorUserID: 43105, GrantSource: resourcedomain.GrantSourceWheelReward,
},
{
CommandID: "manual_resource:forged-command", TargetUserID: 43106, ResourceID: disabledGrantable.ResourceID, Quantity: 1,
Reason: resourcedomain.GrantSourceWheelReward, OperatorUserID: 43106, GrantSource: resourcedomain.GrantSourceWheelReward,
},
} {
if _, err := svc.GrantResource(ctx, forged); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("forged wheel identity must not grant disabled resource: command=%+v err=%v", forged, err)
}
}
operatorMismatch := command
operatorMismatch.CommandID = baseCommandID + ":6"
operatorMismatch.TargetUserID = 43107
operatorMismatch.OperatorUserID = 90001
if _, err := svc.GrantResource(ctx, operatorMismatch); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("wheel compensation must not grant for a different operator, got %v", err)
}
disabledBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_disabled_badge",
ResourceType: resourcedomain.TypeBadge,
Name: "Disabled Wheel Badge",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create disabled non-gift resource failed: %v", err)
}
nonGiftCommand := command
nonGiftCommand.CommandID = baseCommandID + ":7"
nonGiftCommand.TargetUserID = 43108
nonGiftCommand.OperatorUserID = nonGiftCommand.TargetUserID
nonGiftCommand.ResourceID = disabledBadge.ResourceID
if _, err := svc.GrantResource(ctx, nonGiftCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("disabled non-gift resource must remain rejected for wheel compensation, got %v", err)
}
disabledCoin, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_disabled_coin",
ResourceType: resourcedomain.TypeCoin,
Name: "Disabled Wheel Coin",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyWalletCredit,
WalletAssetType: ledger.AssetCoin,
WalletAssetAmount: 100,
UsageScopes: []string{"wallet"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create disabled coin resource failed: %v", err)
}
coinCommand := command
coinCommand.CommandID = baseCommandID + ":8"
coinCommand.TargetUserID = 43109
coinCommand.OperatorUserID = coinCommand.TargetUserID
coinCommand.ResourceID = disabledCoin.ResourceID
if _, err := svc.GrantResource(ctx, coinCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("disabled wallet credit/coin resource must remain rejected for wheel compensation, got %v", err)
}
disabledWrongStrategy, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "wheel_disabled_wrong_strategy_gift",
ResourceType: resourcedomain.TypeGift,
Name: "Disabled Wheel Set Active Gift",
Status: resourcedomain.StatusDisabled,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"wheel_reward"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create disabled wrong-strategy gift failed: %v", err)
}
wrongStrategyCommand := command
wrongStrategyCommand.CommandID = baseCommandID + ":9"
wrongStrategyCommand.TargetUserID = 43110
wrongStrategyCommand.OperatorUserID = wrongStrategyCommand.TargetUserID
wrongStrategyCommand.ResourceID = disabledWrongStrategy.ResourceID
if _, err := svc.GrantResource(ctx, wrongStrategyCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("disabled gift with non-quantity strategy must remain rejected, got %v", err)
}
durationCommand := command
durationCommand.CommandID = baseCommandID + ":10"
durationCommand.TargetUserID = 43111
durationCommand.OperatorUserID = durationCommand.TargetUserID
durationCommand.DurationMS = int64(time.Hour / time.Millisecond)
if _, err := svc.GrantResource(ctx, durationCommand); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("disabled wheel gift with duration must remain rejected, got %v", err)
}
}
// TestWheelRewardGrantRejectsMalformedCommandsForActiveResource 验证严格 Wheel 协议校验不依赖 disabled 分支;
// active 资源也不能接受被 trim 修复、非 canonical 数字、大小写漂移或多段 suffix 的 command_id。
func TestWheelRewardGrantRejectsMalformedCommandsForActiveResource(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "active_wheel_command_validation_gift",
ResourceType: resourcedomain.TypeGift,
Name: "Active Wheel Command Validation Gift",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"wheel_reward"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create active wheel command validation resource failed: %v", err)
}
overlong := "wheel_resource:wheel_draw_1720000000000_" + strings.Repeat("a", 100)
if len(overlong) <= 128 {
t.Fatalf("test setup must exceed command_id limit: len=%d", len(overlong))
}
invalidCommandIDs := []string{
"",
"wheel_resource:wheel_draw__0123456789abcdef",
"wheel_resource:wheel_draw_0_0123456789abcdef",
"wheel_resource:wheel_draw_-1_0123456789abcdef",
"wheel_resource:wheel_draw_now_0123456789abcdef",
"wheel_resource:wheel_draw_01720000000000_0123456789abcdef",
"wheel_resource:wheel_draw_1720000000000_",
"wheel_resource:wheel_draw_1720000000000_abcdef",
"wheel_resource:wheel_draw_1720000000000_0123456789ABCDEF",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:0",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:-1",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:item",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:01",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef:1:2",
" wheel_resource:wheel_draw_1720000000000_0123456789abcdef",
"wheel_resource:wheel_draw_1720000000000_0123456789abcdef ",
overlong,
}
for index, commandID := range invalidCommandIDs {
_, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: commandID,
TargetUserID: 43200 + int64(index),
ResourceID: resource.ResourceID,
Quantity: 1,
Reason: resourcedomain.GrantSourceWheelReward,
OperatorUserID: 43200 + int64(index),
GrantSource: resourcedomain.GrantSourceWheelReward,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("active resource accepted malformed wheel command[%d]=%q: %v", index, commandID, err)
}
}
validCommandID := "wheel_resource:wheel_draw_1720000000000_0123456789abcdef:1"
for _, malformedIdentity := range []resourcedomain.GrantResourceCommand{
{
CommandID: validCommandID, TargetUserID: 43250, ResourceID: resource.ResourceID, Quantity: 1,
Reason: "manual repair", OperatorUserID: 43250, GrantSource: resourcedomain.GrantSourceWheelReward,
},
{
CommandID: validCommandID, TargetUserID: 43251, ResourceID: resource.ResourceID, Quantity: 1,
Reason: resourcedomain.GrantSourceWheelReward, OperatorUserID: 90001, GrantSource: resourcedomain.GrantSourceWheelReward,
},
} {
if _, err := svc.GrantResource(ctx, malformedIdentity); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("active resource accepted malformed wheel identity: command=%+v err=%v", malformedIdentity, err)
}
}
if got := repository.CountRows("resource_grants", "resource_id = ?", resource.ResourceID); got != 0 {
t.Fatalf("malformed active wheel commands must not persist grants, got %d", got)
}
}
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)

View File

@ -48,7 +48,7 @@ func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.G
if resourcedomain.NormalizeResourceType(resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "vip_trial_card must use GrantVipTrialCard")
}
if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil {
if err := validateGrantableResourceForCommand(resource, command); err != nil {
return resourcedomain.ResourceGrant{}, err
}
grantID := resourceGrantID(command.AppCode, command.CommandID)
@ -786,9 +786,12 @@ func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery)
}
func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand {
command.CommandID = strings.TrimSpace(command.CommandID)
command.Reason = strings.TrimSpace(command.Reason)
command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
// 普通历史调用继续保留 trim 兼容Wheel command 是结算事实标识,必须保留原始字节交给严格格式校验,不能把带空白的畸形输入静默修正成合法命令。
if command.GrantSource != resourcedomain.GrantSourceWheelReward {
command.CommandID = strings.TrimSpace(command.CommandID)
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Quantity == 0 {
command.Quantity = 1
}
@ -802,9 +805,53 @@ func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) e
if command.DurationMS < 0 {
return xerr.New(xerr.InvalidArgument, "duration_ms is invalid")
}
if command.GrantSource == resourcedomain.GrantSourceWheelReward {
// Wheel source 由 activity owner 使用:即便资源当前 active也必须先固定命令形状和本人操作语义不能把可伪造的协议字符串当成 disabled 例外的授权依据。
if command.Reason != resourcedomain.GrantSourceWheelReward || command.OperatorUserID != command.TargetUserID || !validWheelResourceGrantCommandID(command.CommandID) {
return xerr.New(xerr.InvalidArgument, "wheel reward resource grant command is invalid")
}
}
return nil
}
func validWheelResourceGrantCommandID(commandID string) bool {
const (
commandPrefix = "wheel_resource"
drawPrefix = "wheel_draw_"
entropyLength = 16 // pkg/idgen.New 固定使用 8 bytes entropy编码后必须是 16 位小写 hex。
maxLength = 128
)
if commandID == "" || len(commandID) > maxLength || strings.TrimSpace(commandID) != commandID {
return false
}
parts := strings.Split(commandID, ":")
if len(parts) != 2 && len(parts) != 3 {
return false
}
if parts[0] != commandPrefix || !strings.HasPrefix(parts[1], drawPrefix) {
return false
}
drawParts := strings.Split(strings.TrimPrefix(parts[1], drawPrefix), "_")
if len(drawParts) != 2 || !validCanonicalPositiveDecimal(drawParts[0]) || len(drawParts[1]) != entropyLength || !isLowerHex(drawParts[1]) {
return false
}
return len(parts) == 2 || validCanonicalPositiveDecimal(parts[2])
}
func validCanonicalPositiveDecimal(value string) bool {
parsed, err := strconv.ParseUint(value, 10, 64)
return err == nil && parsed > 0 && strconv.FormatUint(parsed, 10) == value
}
func isLowerHex(value string) bool {
for _, char := range value {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return value != ""
}
func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand {
command.CommandID = strings.TrimSpace(command.CommandID)
command.Reason = strings.TrimSpace(command.Reason)
@ -837,7 +884,6 @@ func validateRevokeResourceGrantCommand(command resourcedomain.RevokeResourceGra
}
func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {
if grantSource == resourcedomain.GrantSourceGameRobotInit {
if resource.Status != resourcedomain.StatusActive {
return xerr.New(xerr.Conflict, "resource is disabled")
@ -856,6 +902,28 @@ func validateGrantableResourceForSource(resource resourcedomain.Resource, grantS
return nil
}
func validateGrantableResourceForCommand(resource resourcedomain.Resource, command resourcedomain.GrantResourceCommand) error {
// 历史转盘结算可能在 outbox 堆积期间遇到奖品下架disabled 仅表示停止新配置/展示,不应抹掉抽奖时已经固化的中奖事实。
// source/reason/command 前缀只用于识别协议,不能独立充当授权;本人操作和 gift-only 约束共同阻断后台借道、加币与自动装备副作用。
if resource.Status == resourcedomain.StatusDisabled && command.GrantSource == resourcedomain.GrantSourceWheelReward {
// deleted 表示资源事实已回收,且只会走下方原有校验;这里仍要求 grantable确保运营明确关闭发放能力时补偿也会停住。
if !resource.Grantable {
return xerr.New(xerr.Conflict, "resource is not grantable")
}
if command.OperatorUserID != command.TargetUserID {
return xerr.New(xerr.PermissionDenied, "wheel reward compensation must be performed for the same user")
}
if resourcedomain.NormalizeResourceType(resource.ResourceType) != resourcedomain.TypeGift || resourcedomain.IsCoinResource(resource) || resourcedomain.IsWalletCredit(resource) {
return xerr.New(xerr.Conflict, "disabled wheel reward compensation only supports non-wallet gift resources")
}
if resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) != resourcedomain.GrantStrategyIncreaseQuantity || command.DurationMS != 0 {
return xerr.New(xerr.Conflict, "disabled wheel reward compensation requires quantity-only grant semantics")
}
return nil
}
return validateGrantableResourceForSource(resource, command.GrantSource)
}
func validateGrantableResource(resource resourcedomain.Resource) error {
if resource.Status != resourcedomain.StatusActive {
return xerr.New(xerr.Conflict, "resource is disabled")