This commit is contained in:
zhx 2026-06-10 11:58:49 +08:00
parent 49381e12c0
commit f5842f8702
4 changed files with 297 additions and 28 deletions

View File

@ -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='用户资源装备表';

View File

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

View File

@ -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 {

View File

@ -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 {