From acb5225de9108563b9ebd73091020023b9718d79 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 19:51:43 +0800 Subject: [PATCH] fix: allow mic seat animation equipment --- docs/flutter对接/背包接口Flutter对接.md | 9 +- docs/用户装扮Flutter对接文档.md | 3 +- docs/资源组与礼物架构.md | 3 +- .../internal/service/wallet/service_test.go | 93 +++++++++++++++++++ .../storage/mysql/resource_repository.go | 22 +++-- .../storage/mysql/resource_repository_test.go | 16 +++- 6 files changed, 129 insertions(+), 17 deletions(-) diff --git a/docs/flutter对接/背包接口Flutter对接.md b/docs/flutter对接/背包接口Flutter对接.md index 25abf1e0..03802be5 100644 --- a/docs/flutter对接/背包接口Flutter对接.md +++ b/docs/flutter对接/背包接口Flutter对接.md @@ -82,7 +82,7 @@ Query: | `gift` | 礼物权益 | 否 | | `floating_screen` | 飘屏 | 否 | | `mic_seat_icon` | 麦位图标 | 否 | -| `mic_seat_animation` | 麦位动画 | 否 | +| `mic_seat_animation` | 麦位动画 | 是 | | `emoji_pack` | 表情包 | 否 | 接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0`。`expires_at_ms = 0` 表示永久有效。 @@ -219,11 +219,12 @@ Body: | 规则 | 说明 | | --- | --- | -| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`。 | -| 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`mic_seat_animation`、`emoji_pack`。 | +| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`、`mic_seat_animation`。 | +| 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`emoji_pack`。 | | 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 | | 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 | -| 徽章展示 | 背包佩戴的 `badge` 是单装备槽;资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 | +| 发放默认佩戴 | `badge`、`mic_seat_animation` 发放成功后默认佩戴。 | +| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 | 常见错误: diff --git a/docs/用户装扮Flutter对接文档.md b/docs/用户装扮Flutter对接文档.md index 4a5bfc15..1ebe3755 100644 --- a/docs/用户装扮Flutter对接文档.md +++ b/docs/用户装扮Flutter对接文档.md @@ -235,7 +235,8 @@ Body 参数: 说明: -- 可佩戴类型:`avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`。 +- 可佩戴类型:`avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`、`mic_seat_animation`。 +- `badge`、`mic_seat_animation` 发放成功后服务端默认佩戴。 - 过期资源不能佩戴。 - 不传 `entitlement_id` 时,服务端会选择当前有效权益。 diff --git a/docs/资源组与礼物架构.md b/docs/资源组与礼物架构.md index b8fd2ace..02c54310 100644 --- a/docs/资源组与礼物架构.md +++ b/docs/资源组与礼物架构.md @@ -425,8 +425,9 @@ POST /api/v1/users/me/resources/{resource_id}/equip 装备资源的边界: -- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章等可装备资源。 +- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位声波等可装备资源。 - `floating_screen`、`gift` 和 `coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`。 +- `badge`、`mic_seat_animation` 发放成功后默认写入当前佩戴。 - `user-service.users.avatar` 仍然只是头像 URL,不承载头像框。 - 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表,避免污染用户主数据。 - 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index c2348451..72092f7d 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2998,6 +2998,99 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) { } } +// TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。 +func TestGrantMicSeatAnimationAutoEquips(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + ctx := context.Background() + + firstMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "mic_wave_blue", + ResourceType: resourcedomain.TypeMicSeatAnimation, + Name: "Blue Mic Wave", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, + UsageScopes: []string{"mic_seat"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create first mic seat animation resource failed: %v", err) + } + secondMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "mic_wave_gold", + ResourceType: resourcedomain.TypeMicSeatAnimation, + Name: "Gold Mic Wave", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, + UsageScopes: []string{"mic_seat"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create second mic seat animation resource failed: %v", err) + } + + firstGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-grant-mic-wave-blue", + TargetUserID: 42003, + ResourceID: firstMic.ResourceID, + Quantity: 1, + Reason: "test mic auto equip", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + }) + if err != nil { + t.Fatalf("grant first mic seat animation resource failed: %v", err) + } + resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ + UserID: 42003, + ResourceType: resourcedomain.TypeMicSeatAnimation, + ActiveOnly: true, + }) + if err != nil { + t.Fatalf("list mic seat animation resources failed: %v", err) + } + if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != firstGrant.Items[0].EntitlementID { + t.Fatalf("single resource grant should auto equip mic seat animation: resources=%+v grant=%+v", resources, firstGrant) + } + + group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{ + GroupCode: "mic_wave_group", + Name: "Mic Wave Group", + Status: resourcedomain.StatusActive, + Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: secondMic.ResourceID, Quantity: 1, SortOrder: 1}}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create mic seat animation group failed: %v", err) + } + groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{ + CommandID: "cmd-grant-mic-wave-group", + TargetUserID: 42003, + GroupID: group.GroupID, + Reason: "test group mic auto equip", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + }) + if err != nil { + t.Fatalf("grant mic seat animation group failed: %v", err) + } + equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ + UserIDs: []int64{42003}, + ResourceTypes: []string{resourcedomain.TypeMicSeatAnimation}, + }) + if err != nil { + t.Fatalf("batch get mic seat animation equipment failed: %v", err) + } + if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != groupGrant.Items[0].EntitlementID { + t.Fatalf("group grant should auto equip the latest mic seat animation: equipped=%+v group_grant=%+v", equippedResources, groupGrant) + } + if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 { + t.Fatalf("mic seat animation equipment should keep one active row, got %d", got) + } +} + // TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。 func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 515e1ca7..97ad7bda 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -1048,7 +1048,7 @@ func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedo if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) { return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") } - if isAutoEquippedBadgeResourceType(command.ResourceType) { + if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge { // 徽章是“拥有即佩戴”的展示资源,不再支持按类型整类卸下。 // 旧客户端如果仍调用 badge unequip,按幂等无变更返回,避免把用户全部徽章一次删掉。 return resourcedomain.UnequipUserResourceResult{ @@ -1699,7 +1699,7 @@ func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID str } item.ResultType = resourcedomain.ResultEntitlement item.EntitlementID = entitlementID - if err := r.autoEquipGrantedBadgeEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil { + if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err } } @@ -1848,8 +1848,8 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in return entitlementID, nil } -func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error { - if !isAutoEquippedBadgeResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" { +func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error { + if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" { return nil } entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) @@ -1860,7 +1860,8 @@ func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx return err } entitlement.Equipped = true - // 徽章发放来自商店购买、VIP/活动资源组和后台资源发放,统一在 wallet 授权事务内自动佩戴。 + // 徽章和麦位声波都属于“拿到即可默认展示”的装扮资源;写 equipment 必须和授权在同一事务内完成, + // 否则发放成功但佩戴失败会让背包、麦位表现和后续投影读到不一致的状态。 // 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。 eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ @@ -3171,8 +3172,13 @@ func allowsMultipleEquippedResources(resourceType string) bool { return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge } -func isAutoEquippedBadgeResourceType(resourceType string) bool { - return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge +func isAutoEquippedOnGrantResourceType(resourceType string) bool { + switch resourcedomain.NormalizeResourceType(resourceType) { + case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: + return true + default: + return false + } } func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error { @@ -3604,7 +3610,7 @@ func validateActiveResourceGroupResource(resource resourcedomain.Resource) error func isEquipableResourceType(resourceType string) bool { switch resourcedomain.NormalizeResourceType(resourceType) { - case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge: + case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation: return true default: return false diff --git a/services/wallet-service/internal/storage/mysql/resource_repository_test.go b/services/wallet-service/internal/storage/mysql/resource_repository_test.go index 9fcb011c..28c752ad 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository_test.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository_test.go @@ -6,8 +6,18 @@ import ( resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) -func TestProfileCardResourceIsEquipable(t *testing.T) { - if !isEquipableResourceType(resourcedomain.TypeProfileCard) { - t.Fatalf("profile_card resources must use the equipment slot just like other profile decorations") +func TestDecorativeResourceTypesAreEquipable(t *testing.T) { + for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatAnimation} { + if !isEquipableResourceType(resourceType) { + t.Fatalf("%s resources must use the equipment slot just like other profile decorations", resourceType) + } + } +} + +func TestGrantDefaultEquippedResourceTypes(t *testing.T) { + for _, resourceType := range []string{resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation} { + if !isAutoEquippedOnGrantResourceType(resourceType) { + t.Fatalf("%s resources must auto equip inside resource grant transactions", resourceType) + } } }