hyapp-server/docs/资源组与礼物架构.md

492 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-servicewallet-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 消费。