# Resource And Resource Group Architecture

本文定义资源、资源组、礼物配置和用户资源赠送的后端边界。资源类型固定为头像框、金币、座驾、气泡、徽章、飘屏、礼物；资源组是一组资源和钱包资产项的集合，可以一次性赠送给用户。实现必须以这里的服务归属、数据边界和 App/Admin 接口边界为准。

## Goals

- 建立统一资源库，头像框、金币、座驾、气泡、徽章、飘屏、礼物都从资源库中选择，不允许礼物配置直接填写散落的图片或动画 URL。
- 资源组只表达“打包集合”，赠送资源组时按当时组内容展开成多条资源赠送事实；组内可以直接配置 `COIN` 和 `DIAMOND` 钱包资产数量。
- 资源和资源组都可以通过后台命令赠送给用户，赠送必须可审计、可幂等、可追溯。
- 金币仍由 `wallet-service` 资产账本管理；资源库里的金币资源只是对 `COIN` 入账规则的配置包装。
- 礼物价格、收费资产、礼物热度、主播积分和有效期仍由 `wallet-service` 结算，客户端不能提交价格、收费类型、热度或积分。
- 按 `app_code` 隔离资源、资源组、礼物配置、赠送和用户持有状态。

## Non-Goals

- 首版不新增独立 `resource-service` 微服务。资源 catalog、资源组、礼物绑定、用户资源背包和赠送命令放在 `wallet-service` resource domain 中。
- 首版不做资源交易市场、用户之间转赠、资源回收兑换、资源合成。
- 首版不做资源组嵌套资源组，避免递归、循环引用和赠送展开的不确定性。
- 首版不让客户端直接提交资源 URL、资源组内容、金币数量或资源有效期。
- 首版不把资源事实写入 `hyapp_admin` 库；后台库只保存操作审计。

## Service Ownership

| Service | Owns | Does Not Own |
| --- | --- | --- |
| `wallet-service` | 资源 catalog、资源组、资源组成员、礼物绑定、用户资源持有、资源/资源组赠送、金币资源入账、资源赠送 outbox | 用户资料、房间状态、后台账号权限 |
| `room-service` | 房间礼物命令、房间热度、本地榜、房间礼物事件 | 礼物价格、礼物资源配置、用户资源背包 |
| `gateway-service` | App HTTP envelope、鉴权、把当前用户和 `app_code` 传给内部服务 | 资源配置事实、赠送落账 |
| `server/admin` | 后台鉴权、权限、表单校验、操作审计、调用 wallet-service 管理 RPC | 资源 catalog 权威事实、用户资源余额或背包事实 |
| `user-service` | 用户主数据、区域、账号状态 | 用户资源背包、金币资产、礼物配置 |

后台管理资源或资源组时，`server/admin` 必须先做后台权限判断并写 `admin_operation_logs`，然后调用 `wallet-service` 管理命令。不要把资源配置和赠送结果落到 admin DB，也不要让 `server/admin` 直接修改用户背包表。

## Core Concepts

| Concept | Meaning |
| --- | --- |
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、资料卡、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源；`item_type=wallet_asset` 直接配置 `COIN` 或 `DIAMOND` 数量。 |
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置，并额外拥有礼物类型、有效期、特效、价格、收费资产、热度和主播积分规则。 |
| `user_resource` | 用户持有的非金币资源权益，例如某个头像框 30 天、某个徽章永久。 |
| `wallet_asset_resource` | 会转成钱包资产入账的资源，例如金币。实际余额仍写 `wallet_accounts`。 |
| `grant` | 后台或系统向用户发放资源或资源组的命令事实。 |

资源类型固定为枚举，不允许后台随意输入新字符串：

| Type | Example | Grant Result |
| --- | --- | --- |
| `avatar_frame` | 头像框 | 写入 `user_resource_entitlements` |
| `profile_card` | 资料卡 | 写入 `user_resource_entitlements` |
| `coin` | 金币包 | `wallet_accounts.asset_type=COIN` 入账 |
| `vehicle` | 座驾 | 写入 `user_resource_entitlements` |
| `chat_bubble` | 气泡 | 写入 `user_resource_entitlements` |
| `badge` | 徽章 | 写入 `user_resource_entitlements` |
| `floating_screen` | 飘屏 | 写入 `user_resource_entitlements`，使用时由服务端触发飘屏展示 |
| `gift` | 礼物 | 写入 `user_resource_entitlements`；礼物配置只能绑定这类资源 |

## Gift Boundary

礼物不是独立上传资源。新增礼物必须从 `resources` 中选择一个 `resource_type=gift` 的资源，然后配置礼物类型、有效期、特效、价格、收费资产和结算值。

礼物拆成三层：

| Layer | Owner | Fields |
| --- | --- | --- |
| 礼物配置 | `wallet-service` resource domain | `gift_id`、`resource_id`、展示名、排序、上下架状态、礼物类型、有效期、特效、客户端表现 metadata |
| 礼物区域配置 | `wallet-service` resource domain | `gift_id` 和多个 `region_id` 的映射；`region_id=0` 表示 GLOBAL 兜底区域 |
| 礼物账务价格 | `wallet-service` ledger domain | `gift_id`、`price_version`、`charge_asset_type`、`coin_price`、`gift_point_amount`、`heat_value`、价格生效时间 |

礼物类型 code 固定为：

| Code | Label |
| --- | --- |
| `normal` | 普通礼物 |
| `cp` | CP礼物 |
| `lucky` | 幸运礼物 |
| `super_lucky` | 超级幸运礼物 |
| `exclusive` | 专属礼物 |
| `noble` | 贵族礼物 |
| `flag` | 国旗礼物 |
| `activity` | 活动礼物 |
| `magic` | 魔法礼物 |
| `custom` | 定制礼物 |

礼物收费资产只允许 `COIN` 和 `DIAMOND`。字段名沿用 `coin_price` 表达“价格数值”，实际扣费资产由 `charge_asset_type` 决定。`DebitGiftResponse.coin_spent` 保留兼容旧调用方；新调用方应优先读取 `charge_asset_type` 和 `charge_amount`。

礼物特效 code 固定为：

| Code | Label |
| --- | --- |
| `animation` | 动画 |
| `music` | 音乐 |
| `global_broadcast` | 全局广播 |

`gift_configs.effective_from_ms` 和 `gift_configs.effective_to_ms` 表达礼物配置有效期。任一字段为 `0` 表示该方向不限制；两个字段都为 `0` 表示长期有效。App 礼物列表和 `DebitGift` 送礼扣费都必须过滤未开始或已结束的 active 礼物。后台列表可以展示全部礼物，便于运营提前配置或排查历史配置。

`SendGift` 仍然只接收 `gift_id/gift_count`。room-service 必须把房间 `visible_region_id` 传给 wallet-service，wallet-service 必须验证礼物 active、在有效期内且在该区域可用，再使用服务端价格按 `charge_asset_type` 扣 `COIN` 或 `DIAMOND`，用服务端资源快照发房间消息。客户端不能提交资源 URL、礼物价格、收费资产、热度、主播积分、有效期、特效或礼物区域。

礼物绑定资源时的约束：

- 一个 active 礼物必须绑定一个 active resource。
- 礼物绑定的 resource 必须 `resource_type=gift`。
- 下架 resource 不自动删除历史礼物交易，只阻止新礼物上架或继续发送。
- 修改礼物 resource 后，新发送使用新 resource；历史礼物事件使用发送时快照，不反查当前配置。
- 钱包落账 metadata 必须保存 `gift_id`、`price_version`、`charge_asset_type`、`charge_amount`、`coin_price`、`gift_point_amount`、`heat_value`。房间礼物事件必须保存必要的 `resource_snapshot_json` 供客户端展示和审计。

## Grant Boundary

资源和资源组都可以直接赠送给用户。赠送必须是服务端命令，不允许客户端自助拼装。

赠送资源：

1. `server/admin` 鉴权并透传操作端前端按单次赠送动作生成的 `command_id`。
2. `wallet-service` 锁定资源配置，确认 active、grantable、app_code 匹配。
3. 如果是 `coin`，在同一个钱包事务中写 `wallet_transactions/wallet_entries/wallet_outbox`。
4. 如果是非金币资源，写 `resource_grants/resource_grant_items/user_resource_entitlements/resource_outbox`。
5. 返回 grant 结果和每个资源项结果。

赠送资源组：

1. 锁定 active `resource_groups` 和对应的 `resource_group_items`。
2. 按当时组内容展开资源项，并保存 `group_snapshot_json`。
3. `item_type=resource` 按资源自身 grant 规则处理。
4. `item_type=wallet_asset` 只允许 `COIN` 或 `DIAMOND`，必须写 `wallet_transactions/wallet_entries/wallet_outbox`。
5. 任一资源项失败，整个资源组赠送失败并回滚；不能出现只发了一半的资源组。

幂等规则：

- `resource_grants.command_id` 在同一 `app_code` 下唯一。
- 同一 `command_id` 重试且 payload 完全一致，返回原 grant 结果。
- 同一 `command_id` 携带不同目标用户、资源、资源组、数量或有效期，返回冲突。
- 资源组赠送的幂等 payload 必须包含展开前的 `group_id` 和后台提交参数；组内容快照以第一次成功提交为准。

## Expiration And Stacking

资源有效期不能由客户端提交。后台或系统命令可以选择服务端允许的 `duration_ms`，最终策略由资源配置决定。

建议资源配置 `grant_strategy`：

| Strategy | Meaning |
| --- | --- |
| `wallet_credit` | 转成钱包资产入账，只适用于 `coin` 等资产资源。 |
| `new_entitlement` | 每次发放生成一条独立权益，适合限时体验券。 |
| `extend_expiry` | 同一用户同一资源合并延长有效期，适合头像框、气泡、座驾。 |
| `increase_quantity` | 增加数量，适合礼物、飘屏等消耗型资源。 |
| `set_active_flag` | 发放后永久持有，适合徽章。 |

首版 App 使用资源时，只读取当前有效权益：

- `status=active`
- `effective_at_ms <= now`
- `expires_at_ms IS NULL OR expires_at_ms > now`
- 数量型资源必须 `remaining_quantity > 0`

资源过期不需要物理删除。展示和使用时按时间过滤，后台任务可以异步把过期权益标记为 `expired`。

## Data Model Sketch

字段名称使用 snake_case，所有业务表必须带 `app_code`。

```sql
resources(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  resource_code VARCHAR(96) NOT NULL,
  resource_type VARCHAR(32) NOT NULL,
  name VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  grantable BOOLEAN NOT NULL DEFAULT TRUE,
  manager_grant_enabled BOOLEAN NOT NULL DEFAULT TRUE,
  grant_strategy VARCHAR(32) NOT NULL,
  wallet_asset_type VARCHAR(32) NULL,
  wallet_asset_amount BIGINT NOT NULL DEFAULT 0,
  usage_scope_json JSON NOT NULL,
  asset_url VARCHAR(512) NOT NULL DEFAULT '',
  preview_url VARCHAR(512) NOT NULL DEFAULT '',
  animation_url VARCHAR(512) NOT NULL DEFAULT '',
  metadata_json JSON NULL,
  sort_order INT NOT NULL DEFAULT 0,
  created_by_user_id BIGINT NOT NULL,
  updated_by_user_id BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  UNIQUE KEY uk_resources_code (app_code, resource_code),
  KEY idx_resources_type_status (app_code, resource_type, status, sort_order),
  KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order)
);

resource_groups(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  group_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  group_code VARCHAR(96) NOT NULL,
  name VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  description VARCHAR(512) NOT NULL DEFAULT '',
  sort_order INT NOT NULL DEFAULT 0,
  created_by_user_id BIGINT NOT NULL,
  updated_by_user_id BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  UNIQUE KEY uk_resource_groups_code (app_code, group_code),
  KEY idx_resource_groups_status (app_code, status, sort_order)
);

resource_group_items(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  group_item_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  group_id BIGINT NOT NULL,
  item_type VARCHAR(32) NOT NULL DEFAULT 'resource',
  resource_id BIGINT NOT NULL DEFAULT 0,
  wallet_asset_type VARCHAR(32) NOT NULL DEFAULT '',
  wallet_asset_amount BIGINT NOT NULL DEFAULT 0,
  quantity BIGINT NOT NULL DEFAULT 1,
  duration_ms BIGINT NOT NULL DEFAULT 0,
  sort_order INT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  UNIQUE KEY uk_resource_group_items_member (app_code, group_id, item_type, resource_id, wallet_asset_type),
  KEY idx_resource_group_items_group (app_code, group_id, sort_order)
);

gift_configs(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  gift_id VARCHAR(96) NOT NULL,
  resource_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  name VARCHAR(128) NOT NULL,
  sort_order INT NOT NULL DEFAULT 0,
  presentation_json JSON NULL,
  gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal',
  effective_from_ms BIGINT NOT NULL DEFAULT 0,
  effective_to_ms BIGINT NOT NULL DEFAULT 0,
  effect_types_json JSON NULL,
  created_by_user_id BIGINT NOT NULL,
  updated_by_user_id BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, gift_id),
  KEY idx_gift_configs_resource (app_code, resource_id),
  KEY idx_gift_configs_status_sort (app_code, status, sort_order),
  KEY idx_gift_configs_effective (app_code, status, effective_from_ms, effective_to_ms)
);

wallet_gift_prices(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  gift_id VARCHAR(96) NOT NULL,
  price_version VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN',
  coin_price BIGINT NOT NULL,
  gift_point_amount BIGINT NOT NULL,
  heat_value BIGINT NOT NULL,
  effective_at_ms BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, gift_id, price_version),
  KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms)
);

gift_config_regions(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  gift_id VARCHAR(96) NOT NULL,
  region_id BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, gift_id, region_id),
  KEY idx_gift_config_regions_region (app_code, region_id, gift_id)
);

resource_grants(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  grant_id VARCHAR(96) NOT NULL,
  command_id VARCHAR(128) NOT NULL,
  target_user_id BIGINT NOT NULL,
  grant_source VARCHAR(32) NOT NULL,
  grant_subject_type VARCHAR(32) NOT NULL,
  grant_subject_id VARCHAR(96) NOT NULL,
  status VARCHAR(32) NOT NULL,
  request_hash VARCHAR(128) NOT NULL,
  group_snapshot_json JSON NULL,
  reason VARCHAR(256) NOT NULL,
  operator_user_id BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, grant_id),
  UNIQUE KEY uk_resource_grants_command (app_code, command_id),
  KEY idx_resource_grants_target_time (app_code, target_user_id, created_at_ms)
);

resource_grant_items(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  grant_item_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  grant_id VARCHAR(96) NOT NULL,
  resource_id BIGINT NOT NULL,
  resource_snapshot_json JSON NOT NULL,
  quantity BIGINT NOT NULL DEFAULT 1,
  duration_ms BIGINT NULL,
  result_type VARCHAR(32) NOT NULL,
  wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
  entitlement_id VARCHAR(96) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  KEY idx_resource_grant_items_grant (app_code, grant_id)
);

user_resource_entitlements(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  entitlement_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  resource_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  quantity BIGINT NOT NULL DEFAULT 1,
  remaining_quantity BIGINT NOT NULL DEFAULT 1,
  effective_at_ms BIGINT NOT NULL,
  expires_at_ms BIGINT NULL,
  source_grant_id VARCHAR(96) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, entitlement_id),
  KEY idx_user_resource_active (app_code, user_id, resource_id, status, expires_at_ms),
  KEY idx_user_resource_user_type (app_code, user_id, status, updated_at_ms)
);

user_resource_equipment(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  user_id BIGINT NOT NULL,
  resource_type VARCHAR(32) NOT NULL,
  entitlement_id VARCHAR(96) NOT NULL,
  resource_id BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, user_id, resource_type),
  KEY idx_user_resource_equipment_user (app_code, user_id, updated_at_ms)
);
```

`manager_grant_enabled` 只控制经理中心 H5 是否可赠送该资源。后台仍可按后台权限管理资源；App 普通资源列表不暴露这个字段。`grant_source='manager_center'` 的 `GrantResource` 必须在 wallet-service 事务内重新校验 `status=active`、`grantable=TRUE` 和 `manager_grant_enabled=TRUE`，不能只信 gateway 的列表过滤。

`wallet_gift_prices` 保持账务字段，但必须和 `gift_configs.gift_id` 对齐。后续实现可以在 `wallet_gift_prices` 增加 `gift_config_version` 或在账务 metadata 中保存 `gift_configs` 快照；不要把礼物资源字段塞进账务价格表。

## Admin API Boundary

后台 API 建议：

```http
GET  /api/v1/admin/resources
POST /api/v1/admin/resources
GET  /api/v1/admin/resources/{resource_id}
PUT  /api/v1/admin/resources/{resource_id}
POST /api/v1/admin/resources/{resource_id}/enable
POST /api/v1/admin/resources/{resource_id}/disable

GET  /api/v1/admin/resource-groups
POST /api/v1/admin/resource-groups
PUT  /api/v1/admin/resource-groups/{group_id}
PUT  /api/v1/admin/resource-groups/{group_id}/items
POST /api/v1/admin/resource-groups/{group_id}/enable
POST /api/v1/admin/resource-groups/{group_id}/disable

GET  /api/v1/admin/gifts
POST /api/v1/admin/gifts
PUT  /api/v1/admin/gifts/{gift_id}
POST /api/v1/admin/gifts/{gift_id}/enable
POST /api/v1/admin/gifts/{gift_id}/disable

POST /api/v1/admin/resource-grants/resource
POST /api/v1/admin/resource-grants/group
GET  /api/v1/admin/resource-grants
```

后台资源管理页面边界：

- 资源列表每行支持编辑资源名称、编码、类型、封面 URL、素材 URL、上下架状态和金币数量配置。
- 资源组列表支持新增资源组；资源组成员可以选择资源库资源，也可以直接添加金币或钻石资产项。
- 资源组内 `item_type=resource` 的单位是天，后台前端提交 `durationDays`；`server/admin` 换算为 wallet-service RPC 的 `duration_ms`，数量固定为 1。
- 资源组内 `item_type=wallet_asset` 的单位是数量，后台前端提交 `assetType` 和 `amount`；`server/admin` 换算为 wallet-service RPC 的 `wallet_asset_type`、`wallet_asset_amount`，`duration_ms=0`、`quantity=1`。
- 同一资源组内同一个 `resource_id` 不能重复，同一种钱包资产也不能重复。
- active 资源组保存或启用时，组内资源项必须是 `active`、`grantable=true` 且不能是 `coin` 资源；金币和钻石必须使用 `wallet_asset` 项。
- 礼物新增或编辑时必须选择礼物类型和收费类型；收费类型只允许 `COIN` 或 `DIAMOND`。
- 礼物有效期可以为空；填写时必须满足结束时间晚于开始时间。有效期只控制礼物配置可见和可发送，不重算历史礼物交易。
- 礼物特效是枚举多选，只描述客户端表现能力；服务端不根据特效触发动画、音乐或广播，只在 App 礼物列表和房间礼物消息相关链路中透传必要配置。

权限建议：

| Permission | Kind | Usage |
| --- | --- | --- |
| `resource:view` | `menu` | 查看资源列表和详情 |
| `resource:create` | `button` | 新增资源 |
| `resource:update` | `button` | 编辑资源、启用、禁用 |
| `resource-group:view` | `menu` | 查看资源组列表和详情 |
| `resource-group:create` | `button` | 新增资源组 |
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
| `gift:view` | `menu` | 查看礼物列表 |
| `gift:create` | `button` | 新增礼物并选择资源 |
| `gift:update` | `button` | 编辑礼物和价格 |
| `gift:status` | `button` | 上架、下架 |
| `resource-grant:view` | `menu` | 查看资源赠送记录 |
| `resource-grant:create` | `button` | 给用户赠送资源或资源组 |

后台写命令必须记录：

- `app_code`
- `command_id`
- 操作人
- 目标用户
- 资源或资源组
- 数量、有效期、原因
- 请求 IP、路径、request_id
- wallet-service 返回的 `grant_id` 或错误

## App API Boundary

App 只读资源配置和自己的资源权益，不直接改资源配置。

建议接口：

```http
GET /api/v1/resources?resource_type=avatar_frame
GET /api/v1/resource-groups/{group_id}
GET /api/v1/gifts?region_id=1001
GET /api/v1/users/me/resources
POST /api/v1/users/me/resources/{resource_id}/equip
```

礼物列表按区域和有效期过滤：传入 `region_id=N` 时返回绑定到 `N` 的礼物和 `region_id=0` 的 GLOBAL 兜底礼物；不传 `region_id` 时只返回 GLOBAL 兜底礼物。只返回 `status=active`、资源 active、当前时间落在有效期内的礼物。响应会包含 `gift_type_code`、`charge_asset_type`、`effective_from_ms`、`effective_to_ms` 和 `effect_types`，客户端只能用于展示和选择，不能在送礼时回传这些值作为结算依据。

装备资源的边界：

- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位声波等可装备资源。
- `floating_screen`、`gift` 和 `coin` 不能走 `equip`；金币只落钱包账本，礼物和飘屏后续通过使用命令扣减 `remaining_quantity`。
- `badge`、`mic_seat_animation` 发放成功后默认写入当前佩戴。
- `user-service.users.avatar` 仍然只是头像 URL，不承载头像框。
- 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表，避免污染用户主数据。
- 房间展示用户资料时需要读已装备资源快照，不能让客户端自报头像框、气泡或徽章。

## Validation Rules

| Rule | Error |
| --- | --- |
| resource_code empty or duplicated in same app | `INVALID_ARGUMENT` / `CONFLICT` |
| unknown resource_type | `INVALID_ARGUMENT` |
| `coin` resource missing `wallet_asset_type=COIN` or amount <= 0 | `INVALID_ARGUMENT` |
| non-coin resource uses `wallet_credit` | `INVALID_ARGUMENT` |
| gift config selects resource_type != `gift` | `INVALID_ARGUMENT` |
| gift config has negative region_id | `INVALID_ARGUMENT` |
| SendGift gift not configured for room visible_region_id | `NOT_FOUND` |
| disabled resource selected for new gift or grant | `CONFLICT` |
| resource group has no items | `INVALID_ARGUMENT` |
| resource group resource item missing positive `resource_id` or day duration | `INVALID_ARGUMENT` |
| resource group wallet asset item is not `COIN` or `DIAMOND`, or amount <= 0 | `INVALID_ARGUMENT` |
| resource group contains duplicate resource or duplicate wallet asset | `INVALID_ARGUMENT` |
| resource group contains disabled resource on grant | `CONFLICT` |
| duplicate command_id with different payload | `LEDGER_CONFLICT` or `CONFLICT` |
| target user not found or app_code mismatch | `NOT_FOUND` |
| equip resource without active entitlement, or entitlement expired | `NOT_FOUND` |
| equip non-equipable resource type | `INVALID_ARGUMENT` |

## Event And Outbox

资源相关事件必须和资源赠送或配置事务同库提交：

| Event | When |
| --- | --- |
| `ResourceChanged` | 资源创建、编辑、启用、禁用 |
| `ResourceGroupChanged` | 资源组创建、编辑、替换成员、启用、禁用 |
| `GiftConfigChanged` | 礼物创建、编辑、上下架、资源绑定变更 |
| `ResourceGranted` | 单资源赠送成功 |
| `ResourceGroupGranted` | 资源组展开赠送成功 |
| `UserResourceChanged` | 用户权益新增、延长、数量变化、过期或装备变更 |

消费者只能把事件作为缓存刷新、消息通知或分析输入。用户当前是否拥有某资源，必须以 `user_resource_entitlements` 和钱包余额事实为准。

## Important Edge Cases

- 修改资源组后，不影响已经赠送出去的资源组；历史赠送以 `group_snapshot_json` 为准。
- 禁用资源不删除用户已持有权益，除非后续单独设计“强制召回”命令。
- 礼物已经发送成功后，即使绑定的 `gift` 资源后来被禁用，历史房间事件仍按发送时快照展示。
- 资源组不能嵌套资源组，避免赠送展开循环。
- 金币资源赠送必须走钱包账本，不能只写资源赠送记录。
- 资源组里的金币和钻石资产项也必须走钱包账本；不能写成用户资源权益。
- 用户资源过期不能依赖客户端隐藏；服务端查询和装备校验必须过滤过期权益。
- 后台批量赠送必须拆成可追踪的命令，不能用一个不可恢复的大事务吞掉所有用户结果。

## Implementation Order

1. 在 `wallet-service` 新增资源 catalog、资源组、礼物配置、用户权益和赠送表。
2. 新增 wallet-service 管理 RPC：资源 CRUD、资源组 CRUD、礼物配置、赠送资源、赠送资源组。
3. admin-server 增加资源、资源组、礼物和赠送页面接口，只做鉴权和审计，调用 wallet-service。
4. gateway 增加 App 资源列表、礼物列表、我的资源接口。
5. room-service SendGift 读取礼物资源快照，房间礼物消息携带资源快照。
6. 增加用户装备接口和房间用户资料资源快照。
7. 最后补批量赠送、过期扫描、缓存刷新和资源变更 outbox 消费。
