fix: allow mic seat animation equipment

This commit is contained in:
zhx 2026-06-12 19:51:43 +08:00
parent 88551cd96d
commit acb5225de9
6 changed files with 129 additions and 17 deletions

View File

@ -82,7 +82,7 @@ Query
| `gift` | 礼物权益 | 否 | | `gift` | 礼物权益 | 否 |
| `floating_screen` | 飘屏 | 否 | | `floating_screen` | 飘屏 | 否 |
| `mic_seat_icon` | 麦位图标 | 否 | | `mic_seat_icon` | 麦位图标 | 否 |
| `mic_seat_animation` | 麦位动画 | | | `mic_seat_animation` | 麦位动画 | |
| `emoji_pack` | 表情包 | 否 | | `emoji_pack` | 表情包 | 否 |
接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0``expires_at_ms = 0` 表示永久有效。 接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0``expires_at_ms = 0` 表示永久有效。
@ -219,11 +219,12 @@ Body
| 规则 | 说明 | | 规则 | 说明 |
| --- | --- | | --- | --- |
| 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge`。 | | 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_animation`。 |
| 不可佩戴类型 | `gift``floating_screen``mic_seat_icon``mic_seat_animation`、`emoji_pack`。 | | 不可佩戴类型 | `gift``floating_screen``mic_seat_icon``emoji_pack`。 |
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 | | 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 | | 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
| 徽章展示 | 背包佩戴的 `badge` 是单装备槽;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 | | 发放默认佩戴 | `badge``mic_seat_animation` 发放成功后默认佩戴。 |
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 |
常见错误: 常见错误:

View File

@ -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` 时,服务端会选择当前有效权益。 - 不传 `entitlement_id` 时,服务端会选择当前有效权益。

View File

@ -425,8 +425,9 @@ POST /api/v1/users/me/resources/{resource_id}/equip
装备资源的边界: 装备资源的边界:
- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章等可装备资源。 - `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位声波等可装备资源。
- `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity` - `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`
- `badge``mic_seat_animation` 发放成功后默认写入当前佩戴。
- `user-service.users.avatar` 仍然只是头像 URL不承载头像框。 - `user-service.users.avatar` 仍然只是头像 URL不承载头像框。
- 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表,避免污染用户主数据。 - 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表,避免污染用户主数据。
- 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。 - 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。

View File

@ -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 锁定道具商店购买的原子语义。 // TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -1048,7 +1048,7 @@ func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedo
if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) { if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) {
return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") 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按幂等无变更返回避免把用户全部徽章一次删掉。 // 旧客户端如果仍调用 badge unequip按幂等无变更返回避免把用户全部徽章一次删掉。
return resourcedomain.UnequipUserResourceResult{ return resourcedomain.UnequipUserResourceResult{
@ -1699,7 +1699,7 @@ func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID str
} }
item.ResultType = resourcedomain.ResultEntitlement item.ResultType = resourcedomain.ResultEntitlement
item.EntitlementID = entitlementID 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 return resourcedomain.ResourceGrantItem{}, err
} }
} }
@ -1848,8 +1848,8 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in
return entitlementID, nil return entitlementID, nil
} }
func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error { func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
if !isAutoEquippedBadgeResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" { if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
return nil return nil
} }
entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
@ -1860,7 +1860,8 @@ func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx
return err return err
} }
entitlement.Equipped = true entitlement.Equipped = true
// 徽章发放来自商店购买、VIP/活动资源组和后台资源发放,统一在 wallet 授权事务内自动佩戴。 // 徽章和麦位声波都属于“拿到即可默认展示”的装扮资源;写 equipment 必须和授权在同一事务内完成,
// 否则发放成功但佩戴失败会让背包、麦位表现和后续投影读到不一致的状态。
// 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。 // 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。
eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
@ -3171,8 +3172,13 @@ func allowsMultipleEquippedResources(resourceType string) bool {
return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge
} }
func isAutoEquippedBadgeResourceType(resourceType string) bool { func isAutoEquippedOnGrantResourceType(resourceType string) bool {
return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge 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 { 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 { func isEquipableResourceType(resourceType string) bool {
switch resourcedomain.NormalizeResourceType(resourceType) { 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 return true
default: default:
return false return false

View File

@ -6,8 +6,18 @@ import (
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
) )
func TestProfileCardResourceIsEquipable(t *testing.T) { func TestDecorativeResourceTypesAreEquipable(t *testing.T) {
if !isEquipableResourceType(resourcedomain.TypeProfileCard) { for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatAnimation} {
t.Fatalf("profile_card resources must use the equipment slot just like other profile decorations") 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)
}
} }
} }