diff --git a/services/wallet-service/internal/domain/resource/constants.go b/services/wallet-service/internal/domain/resource/constants.go index 90946e14..36cc7f89 100644 --- a/services/wallet-service/internal/domain/resource/constants.go +++ b/services/wallet-service/internal/domain/resource/constants.go @@ -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,13 +35,10 @@ const ( GrantSourceAchievement = "achievement" GrantSourceResourceShop = "resource_shop" GrantSourceGameRobotInit = "game_robot_init" - // GrantSourceWheelReward 标识 activity-service 从转盘不可变结算快照发起的奖品发放。 - // 历史兼容还必须满足固定 reason/command namespace、本人操作和 disabled grantable gift 等条件;source 本身不是授权开关。 - GrantSourceWheelReward = "wheel_reward" - GrantStatusDone = "succeeded" - GrantStatusRevoked = "revoked" - ResultWalletCredit = "wallet_credit" - ResultEntitlement = "entitlement" + GrantStatusDone = "succeeded" + GrantStatusRevoked = "revoked" + ResultWalletCredit = "wallet_credit" + ResultEntitlement = "entitlement" GroupItemTypeResource = "resource" GroupItemTypeWalletAsset = "wallet_asset" diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 1d4634b1..d2b3ec7b 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -5803,296 +5803,6 @@ 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) diff --git a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go index 9751e58f..9d42a33e 100644 --- a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go @@ -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 := validateGrantableResourceForCommand(resource, command); err != nil { + if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil { return resourcedomain.ResourceGrant{}, err } grantID := resourceGrantID(command.AppCode, command.CommandID) @@ -786,12 +786,9 @@ func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) } func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand { - command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) - // 普通历史调用继续保留 trim 兼容;Wheel command 是结算事实标识,必须保留原始字节交给严格格式校验,不能把带空白的畸形输入静默修正成合法命令。 - if command.GrantSource != resourcedomain.GrantSourceWheelReward { - command.CommandID = strings.TrimSpace(command.CommandID) - } + command.CommandID = strings.TrimSpace(command.CommandID) command.Reason = strings.TrimSpace(command.Reason) + command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) if command.Quantity == 0 { command.Quantity = 1 } @@ -805,53 +802,9 @@ 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) @@ -884,6 +837,7 @@ 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") @@ -902,28 +856,6 @@ 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")