# 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` | | `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`、`coin_price`、`gift_point_amount`、`heat_value`、生效时间 | `SendGift` 仍然只接收 `gift_id/gift_count`。room-service 必须把房间 `visible_region_id` 传给 wallet-service,wallet-service 必须验证礼物 active 且在该区域可用,使用服务端价格扣 `COIN`,用服务端资源快照发房间消息。客户端不能提交资源 URL、礼物价格、热度、主播积分或礼物区域。 礼物绑定资源时的约束: - 一个 active 礼物必须绑定一个 active resource。 - 礼物绑定的 resource 必须 `resource_type=gift`。 - 下架 resource 不自动删除历史礼物交易,只阻止新礼物上架或继续发送。 - 修改礼物 resource 后,新发送使用新 resource;历史礼物事件使用发送时快照,不反查当前配置。 - 钱包落账 metadata 必须保存 `gift_id`、`price_version`、`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, 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) ); 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, 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) ); 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) ); ``` `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` 项。 权限建议: | 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 兜底礼物。 装备资源的边界: - `equip` 只适用于头像框、座驾、气泡、徽章等可装备资源。 - `floating_screen`、`gift` 和 `coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`。 - `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 消费。