徽章
This commit is contained in:
parent
49381e12c0
commit
f5842f8702
@ -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='用户资源装备表';
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user