fix: allow mic seat animation equipment
This commit is contained in:
parent
88551cd96d
commit
acb5225de9
@ -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`。 |
|
||||||
|
|
||||||
常见错误:
|
常见错误:
|
||||||
|
|
||||||
|
|||||||
@ -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` 时,服务端会选择当前有效权益。
|
||||||
|
|
||||||
|
|||||||
@ -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` 表,避免污染用户主数据。
|
||||||
- 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。
|
- 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user