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` | 礼物权益 | 否 |
| `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`。 |
常见错误:

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

View File

@ -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` 表,避免污染用户主数据。
- 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。

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 锁定道具商店购买的原子语义。
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.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) {
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

View File

@ -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)
}
}
}