diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 95e27fc0..505895d1 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -921,7 +921,7 @@ CREATE TABLE IF NOT EXISTS user_resource_equipment ( entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID', resource_id BIGINT NOT NULL COMMENT '资源 ID', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, user_id, resource_type), + PRIMARY KEY (app_code, user_id, resource_type, entitlement_id), KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'; diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 4fb56510..e7daa3e3 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2719,6 +2719,16 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) { if got := repository.CountRows("user_resource_entitlements", "user_id = ?", int64(42001)); got != 1 { t.Fatalf("group grant should write one non-coin entitlement, got %d", got) } + equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ + UserIDs: []int64{42001}, + ResourceTypes: []string{resourcedomain.TypeBadge}, + }) + if err != nil { + t.Fatalf("batch get group-granted badge equipment failed: %v", err) + } + if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != badgeResource.ResourceID { + t.Fatalf("group-granted badge should be auto equipped: %+v", equippedResources) + } if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 { t.Fatalf("group grant should write one coin wallet entry, got %d", got) } @@ -2732,9 +2742,9 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { ctx := context.Background() resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ - ResourceCode: "shop_frame_gold", - ResourceType: resourcedomain.TypeAvatarFrame, - Name: "Gold Frame", + ResourceCode: "shop_badge_gold", + ResourceType: resourcedomain.TypeBadge, + Name: "Gold Badge", Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyExtendExpiry, @@ -2760,7 +2770,7 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { } receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{ - CommandID: "cmd-shop-frame", + CommandID: "cmd-shop-badge", UserID: 53001, ShopItemID: shopItems[0].ShopItemID, }) @@ -2773,9 +2783,12 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() { t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource) } + if !receipt.Resource.Equipped { + t.Fatalf("resource shop badge purchase should auto equip receipt resource: %+v", receipt.Resource) + } again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{ - CommandID: "cmd-shop-frame", + CommandID: "cmd-shop-badge", UserID: 53001, ShopItemID: shopItems[0].ShopItemID, }) @@ -2788,7 +2801,7 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 { t.Fatalf("resource shop order should be persisted once, got %d", got) } - if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-frame", "resource_shop_purchase"); got != 1 { + if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-badge", "resource_shop_purchase"); got != 1 { t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got) } if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 { @@ -2800,6 +2813,9 @@ func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) { if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 { t.Fatalf("resource shop purchase should grant one entitlement, got %d", got) } + if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ? AND resource_id = ?", int64(53001), resourcedomain.TypeBadge, resource.ResourceID); got != 1 { + t.Fatalf("resource shop badge purchase should write one equipment row, got %d", got) + } } func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) { @@ -3141,6 +3157,17 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if err != nil { t.Fatalf("grant badge resource failed: %v", err) } + resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ + UserID: 43001, + ResourceType: resourcedomain.TypeBadge, + ActiveOnly: true, + }) + if err != nil { + t.Fatalf("list auto-equipped badge resources failed: %v", err) + } + if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != grant.Items[0].EntitlementID { + t.Fatalf("badge grant should auto equip entitlement: resources=%+v grant=%+v", resources, grant) + } equipped, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, ResourceID: badgeResource.ResourceID, @@ -3154,7 +3181,32 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if !equipped.Equipped { t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped) } - resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ + secondBadgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "badge_equippable_short", + ResourceType: resourcedomain.TypeBadge, + Name: "Second Equippable Badge", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create second badge resource failed: %v", err) + } + secondGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-grant-equip-badge-second", + TargetUserID: 43001, + ResourceID: secondBadgeResource.ResourceID, + Quantity: 1, + Reason: "test second badge equip", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + }) + if err != nil { + t.Fatalf("grant second badge resource failed: %v", err) + } + resources, err = svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ UserID: 43001, ResourceType: resourcedomain.TypeBadge, ActiveOnly: true, @@ -3162,8 +3214,17 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if err != nil { t.Fatalf("list user resources failed: %v", err) } - if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID { - t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped) + if len(resources) != 2 { + t.Fatalf("badge grants should keep multiple equipped resources: resources=%+v", resources) + } + equippedBadgeIDs := map[string]bool{} + for _, item := range resources { + if item.Equipped { + equippedBadgeIDs[item.EntitlementID] = true + } + } + if !equippedBadgeIDs[equipped.EntitlementID] || !equippedBadgeIDs[secondGrant.Items[0].EntitlementID] { + t.Fatalf("resource list should expose both equipped badges: resources=%+v first=%+v second=%+v", resources, equipped, secondGrant) } repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ UserID: 43001, @@ -3176,8 +3237,8 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if repeated.EntitlementID != equipped.EntitlementID { t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped) } - if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 1 { - t.Fatalf("equipment should keep one active badge, got %d", got) + if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 2 { + t.Fatalf("repeat equip should not duplicate and should keep both active badges, got %d", got) } equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ UserIDs: []int64{43001}, @@ -3186,7 +3247,7 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if err != nil { t.Fatalf("batch get equipped resources failed: %v", err) } - if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != equipped.EntitlementID { + if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 2 { t.Fatalf("batch equipped resources mismatch: %+v", equippedResources) } unequipped, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ @@ -3196,11 +3257,11 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { if err != nil { t.Fatalf("unequip badge resource failed: %v", err) } - if !unequipped.Unequipped || unequipped.ResourceType != resourcedomain.TypeBadge { + if unequipped.Unequipped || unequipped.ResourceType != resourcedomain.TypeBadge { t.Fatalf("unequip result mismatch: %+v", unequipped) } - if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 0 { - t.Fatalf("equipment should be removed after unequip, got %d", got) + if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 2 { + t.Fatalf("badge equipment should remain after no-op unequip, got %d", got) } emptyUnequip, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{ UserID: 43001, @@ -3293,6 +3354,88 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) { } } +// TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes 验证多徽章 schema 不会改变其它装扮类型的单选语义。 +func TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + ctx := context.Background() + + firstFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "frame_single_first", + ResourceType: resourcedomain.TypeAvatarFrame, + Name: "First Frame", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create first frame failed: %v", err) + } + secondFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "frame_single_second", + ResourceType: resourcedomain.TypeAvatarFrame, + Name: "Second Frame", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create second frame failed: %v", err) + } + if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-grant-first-frame", + TargetUserID: 43002, + ResourceID: firstFrame.ResourceID, + Quantity: 1, + Reason: "test first frame", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + }); err != nil { + t.Fatalf("grant first frame failed: %v", err) + } + if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-grant-second-frame", + TargetUserID: 43002, + ResourceID: secondFrame.ResourceID, + Quantity: 1, + Reason: "test second frame", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + }); err != nil { + t.Fatalf("grant second frame failed: %v", err) + } + if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ + UserID: 43002, + ResourceID: firstFrame.ResourceID, + }); err != nil { + t.Fatalf("equip first frame failed: %v", err) + } + if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{ + UserID: 43002, + ResourceID: secondFrame.ResourceID, + }); err != nil { + t.Fatalf("equip second frame failed: %v", err) + } + + if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43002), resourcedomain.TypeAvatarFrame); got != 1 { + t.Fatalf("avatar_frame equipment should keep one active row, got %d", got) + } + equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{ + UserIDs: []int64{43002}, + ResourceTypes: []string{resourcedomain.TypeAvatarFrame}, + }) + if err != nil { + t.Fatalf("batch get equipped frames failed: %v", err) + } + if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != secondFrame.ResourceID { + t.Fatalf("avatar_frame should expose only the last equipped resource: %+v", equippedResources) + } +} + // TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。 func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 3f3cefba..6edf95ca 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -104,6 +104,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) { _ = db.Close() return nil, err } + if err := ensureUserResourceEquipmentSchema(ctx, db); err != nil { + _ = db.Close() + return nil, err + } return &Repository{db: db}, nil } @@ -158,6 +162,61 @@ func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) e return err } +func ensureUserResourceEquipmentSchema(ctx context.Context, db *sql.DB) error { + // 徽章现在是“拥有即佩戴”的多选资源,装备表必须允许同一用户同一类型下存在多条 entitlement。 + // 非徽章仍由写入逻辑先删同类型旧装备再插入新装备,避免把 schema 的多行能力泄漏成其它装扮多穿。 + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS user_resource_equipment ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL COMMENT '用户 ID', + resource_type VARCHAR(32) NOT NULL COMMENT '资源类型', + entitlement_id VARCHAR(96) NOT NULL COMMENT '权益 ID', + resource_id BIGINT NOT NULL COMMENT '资源 ID', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, user_id, resource_type, entitlement_id), + KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资源装备表'`); err != nil { + return err + } + + primaryColumns, err := tablePrimaryKeyColumns(ctx, db, "user_resource_equipment") + if err != nil { + return err + } + if strings.Join(primaryColumns, ",") == "app_code,user_id,resource_type" { + _, err = db.ExecContext(ctx, ` + ALTER TABLE user_resource_equipment + DROP PRIMARY KEY, + ADD PRIMARY KEY (app_code, user_id, resource_type, entitlement_id)`) + return err + } + return nil +} + +func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION ASC`, tableName) + if err != nil { + return nil, err + } + defer rows.Close() + + columns := make([]string, 0, 4) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return nil, err + } + columns = append(columns, column) + } + return columns, rows.Err() +} + // Close 释放 MySQL 连接池。 func (r *Repository) Close() error { if r == nil || r.db == nil { diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 038f5b56..ee22de3f 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -958,18 +958,10 @@ func (r *Repository) EquipUserResource(ctx context.Context, command resourcedoma if !isEquipableResourceType(entitlement.Resource.ResourceType) { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO user_resource_equipment ( - app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - entitlement_id = VALUES(entitlement_id), - resource_id = VALUES(resource_id), - updated_at_ms = VALUES(updated_at_ms)`, - command.AppCode, command.UserID, entitlement.Resource.ResourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs, - ); err != nil { + if err := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil { return resourcedomain.UserResourceEntitlement{}, err } + entitlement.Equipped = true eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{ @@ -988,7 +980,6 @@ func (r *Repository) EquipUserResource(ctx context.Context, command resourcedoma if err := tx.Commit(); err != nil { return resourcedomain.UserResourceEntitlement{}, err } - entitlement.Equipped = true return entitlement, nil } @@ -1005,6 +996,15 @@ 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) { + // 徽章是“拥有即佩戴”的展示资源,不再支持按类型整类卸下。 + // 旧客户端如果仍调用 badge unequip,按幂等无变更返回,避免把用户全部徽章一次删掉。 + return resourcedomain.UnequipUserResourceResult{ + ResourceType: command.ResourceType, + Unequipped: false, + UpdatedAtMS: time.Now().UnixMilli(), + }, nil + } // 取消佩戴要和 outbox 事件在同一事务内提交: // UI 投影可以监听 UserResourceChanged,但不能在 equipment 删除失败时收到“已卸下”的假事件。 @@ -1083,7 +1083,7 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re args = append(args, stringAnyArgs(resourceTypes)...) } // 从 equipment 反查 entitlement/resource,而不是从 entitlement 扫描 equipped 标记: - // 这样每个 user_id + resource_type 只会返回当前佩戴的那一个有效权益。 + // 这样非徽章类型仍由写入侧保证每类一个,徽章则可以按同一个 resource_type 返回多个已佩戴权益。 rows, err := r.db.QueryContext(ctx, ` SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, @@ -1647,6 +1647,9 @@ 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 { + return resourcedomain.ResourceGrantItem{}, err + } } inserted, err := r.insertResourceGrantItem(ctx, tx, item) if err != nil { @@ -1793,6 +1796,62 @@ 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) == "" { + return nil + } + entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) + if err != nil { + return err + } + if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil { + return err + } + entitlement.Equipped = true + // 徽章发放来自商店购买、VIP/活动资源组和后台资源发放,统一在 wallet 授权事务内自动佩戴。 + // 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。 + eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) + return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ + resourceOutboxEvent("UserResourceChanged", eventCommandID, userID, resource.ResourceID, map[string]any{ + "action": "auto_equip", + "app_code": appcode.FromContext(ctx), + "user_id": userID, + "resource_id": resource.ResourceID, + "resource_type": resource.ResourceType, + "entitlement": entitlement, + "updated_at_ms": nowMs, + "event_command": eventCommandID, + }, nowMs), + }) +} + +func (r *Repository) equipUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement, nowMs int64) error { + resourceType := resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType) + if !isEquipableResourceType(resourceType) { + return xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") + } + if !allowsMultipleEquippedResources(resourceType) { + // schema 已经允许同类型多条 equipment;非徽章类型仍要在写入侧清掉旧指针,保持头像框/资料卡/座驾等单选语义。 + if _, err := tx.ExecContext(ctx, ` + DELETE FROM user_resource_equipment + WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id <> ?`, + appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, + ); err != nil { + return err + } + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_resource_equipment ( + app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + resource_id = VALUES(resource_id), + updated_at_ms = VALUES(updated_at_ms)`, + appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs, + ) + return err +} + func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error { if strings.TrimSpace(entitlementID) == "" { return nil @@ -3031,6 +3090,14 @@ func normalizeEquipableResourceTypes(values []string) ([]string, error) { return out, nil } +func allowsMultipleEquippedResources(resourceType string) bool { + return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge +} + +func isAutoEquippedBadgeResourceType(resourceType string) bool { + return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge +} + func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error { userIDs = compactPositiveInt64s(userIDs) if r == nil || r.db == nil || len(userIDs) == 0 {