座位数

This commit is contained in:
zhx 2026-05-11 20:01:55 +08:00
parent d1fbeb54a8
commit 82806c73a6
34 changed files with 2106 additions and 271 deletions

View File

@ -2459,7 +2459,7 @@
if (p.endsWith('/users/me/country/change')) return { country: c.country || 'US' };
if (p.endsWith('/users/me/display-id/change')) return { new_display_user_id: c.displayUserId };
if (p.endsWith('/users/me/display-id/pretty/apply')) return { pretty_display_user_id: c.displayUserId };
if (p.endsWith('/rooms/create')) return { seat_count: 8, mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
if (p.endsWith('/rooms/create')) return { mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
if (p.endsWith('/rooms/join')) return { command_id: commandId('cmd_join'), room_id: c.roomId, role: 'audience' };
if (p.endsWith('/rooms/heartbeat')) return { command_id: commandId('cmd_heartbeat'), room_id: c.roomId };
if (p.endsWith('/rooms/leave')) return { command_id: commandId('cmd_leave'), room_id: c.roomId };

View File

@ -39,6 +39,10 @@ tags:
description: App 钱包余额、币商转账和充值口径入口。
- name: resources
description: App 资源库、资源组、礼物配置和我的资源入口。
- name: manager-center
description: H5 经理中心入口;服务端校验业务身份,客户端不能自报经理权限。
- name: business
description: 带业务场景权限裁剪的通用能力入口。
- name: messages
description: App 消息 tab后端只负责 system/activity inbox用户私聊来自腾讯云 IM SDK。
securityDefinitions:
@ -1099,6 +1103,38 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/profile/update:
post:
tags:
- rooms
summary: 修改房间资料和座位数
operationId: updateRoomProfile
description: |
App 房主在房间内修改房间名、头像、简介和座位数。所有状态变更经过 room-service Room Cell缩小座位数时被删除的高编号麦位必须全部空闲且未锁。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/UpdateRoomProfileRequest"
responses:
"200":
description: 修改成功,`data` 返回最新房间信息和麦位列表。
schema:
$ref: "#/definitions/UpdateRoomProfileEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"409":
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/join:
post:
tags:
@ -1653,6 +1689,119 @@ paths:
$ref: "#/responses/BadRequest"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/manager-center/resource-grants/resources:
get:
tags:
- manager-center
summary: 经理中心可赠送资源列表
operationId: listManagerGrantResources
description: gateway 先校验当前用户拥有 `manager_resource_grant` capability再从 wallet-service 查询 `status=active AND grantable=TRUE AND manager_grant_enabled=TRUE` 的资源。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: resource_type
in: query
required: false
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
- name: page
in: query
required: false
type: integer
format: int32
- name: page_size
in: query
required: false
type: integer
format: int32
maximum: 20
responses:
"200":
description: 返回 H5 展示和提交所需的最小资源字段。
schema:
$ref: "#/definitions/ManagerGrantResourceListEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/manager-center/resource-grants:
post:
tags:
- manager-center
summary: 经理中心赠送单个资源
operationId: grantManagerResource
description: 客户端只提交命令 ID、目标用户和资源 ID数量固定为 1有效期固定为 0`grant_source` 固定为 `manager_center`。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/ManagerResourceGrantRequest"
responses:
"200":
description: 赠送成功,返回资源赠送事实。
schema:
$ref: "#/definitions/ManagerResourceGrantEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/business/users/lookup:
get:
tags:
- business
summary: 业务场景用户查询
operationId: lookupBusinessUsers
description: 首版只支持 `scene=manager_resource_grant`。user-service 会按 scene 校验 actor 能力,并只返回同 app_code 的 active 用户最小资料。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: scene
in: query
required: true
type: string
enum: [manager_resource_grant]
- name: keyword
in: query
required: true
type: string
description: 展示短号、用户 ID 或至少 2 个字符的昵称片段。
- name: page_size
in: query
required: false
type: integer
format: int32
maximum: 20
responses:
"200":
description: 查询成功。
schema:
$ref: "#/definitions/BusinessUserLookupEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/resource-groups/{group_id}:
get:
tags:
@ -3119,6 +3268,121 @@ definitions:
page_size:
type: integer
format: int32
ManagerGrantResourceData:
type: object
properties:
resource_id:
type: string
description: 资源 ID按字符串返回避免 64 位整数在客户端丢精度。
resource_type:
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
name:
type: string
asset_url:
type: string
preview_url:
type: string
wallet_asset_type:
type: string
wallet_asset_amount:
type: integer
format: int64
sort_order:
type: integer
format: int32
ManagerGrantResourceListData:
type: object
properties:
items:
type: array
items:
$ref: "#/definitions/ManagerGrantResourceData"
total:
type: integer
format: int64
page:
type: integer
format: int32
page_size:
type: integer
format: int32
BusinessUserLookupItem:
type: object
properties:
user_id:
type: string
description: 用户 ID按字符串返回避免 64 位整数在客户端丢精度。
display_user_id:
type: string
username:
type: string
avatar:
type: string
status:
type: string
enum: [active, disabled, banned, unspecified]
region_id:
type: integer
format: int64
region_code:
type: string
region_name:
type: string
BusinessUserLookupData:
type: object
properties:
items:
type: array
items:
$ref: "#/definitions/BusinessUserLookupItem"
total:
type: integer
format: int32
page_size:
type: integer
format: int32
ManagerResourceGrantRequest:
type: object
required:
- command_id
- target_user_id
- resource_id
properties:
command_id:
type: string
description: 客户端幂等命令 ID同一业务重试必须复用。
target_user_id:
type: string
description: 目标用户 IDgateway 会校验用户存在且 active。
resource_id:
type: string
description: 资源 IDwallet-service 会在事务内复验 `manager_grant_enabled`。
reason:
type: string
default: manager_center
description: 不接受 `quantity`、`duration_ms`、`wallet_asset_amount` 等客户端计算字段。
ManagerResourceGrantData:
type: object
properties:
grant_id:
type: string
command_id:
type: string
target_user_id:
type: string
resource_id:
type: string
status:
type: string
grant_source:
type: string
enum: [manager_center]
operator_user_id:
type: string
created_at_ms:
type: integer
format: int64
ResourceGroupItemData:
type: object
properties:
@ -3358,15 +3622,14 @@ definitions:
CreateRoomRequest:
type: object
required:
- seat_count
- mode
- room_name
properties:
seat_count:
type: integer
format: int32
minimum: 1
description: 麦位数量,必须大于 0
minimum: 0
description: 麦位数量,可省略;不传或传 0 时使用后台房间配置默认值,正数必须命中后台启用座位数
mode:
type: string
minLength: 1
@ -3384,6 +3647,39 @@ definitions:
type: string
maxLength: 512
description: 房间简介,可选;只写入 `room_ext.description`,不进入房间高频核心字段。
UpdateRoomProfileRequest:
type: object
required:
- room_id
- command_id
properties:
room_id:
type: string
command_id:
type: string
room_name:
type: string
minLength: 1
maxLength: 128
description: 可选;传入时更新房间标题。
room_avatar:
type: string
maxLength: 512
description: 可选;传空字符串时 room-service 恢复系统默认头像。
room_description:
type: string
maxLength: 512
description: 可选;允许传空字符串清空简介。
seat_count:
type: integer
format: int32
enum:
- 10
- 15
- 20
- 25
- 30
description: 可选;必须是后台已启用座位数。缩容时高编号麦位必须空闲且未锁。
JoinRoomRequest:
type: object
properties:
@ -3994,6 +4290,25 @@ definitions:
server_time_ms:
type: integer
format: int64
UpdateRoomProfileResponse:
type: object
required:
- result
- room
- seats
- server_time_ms
properties:
result:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomInitialData"
seats:
type: array
items:
$ref: "#/definitions/RoomSeatData"
server_time_ms:
type: integer
format: int64
RoomHeartbeatResponse:
type: object
properties:
@ -4278,6 +4593,27 @@ definitions:
properties:
data:
$ref: "#/definitions/ResourceListData"
ManagerGrantResourceListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/ManagerGrantResourceListData"
BusinessUserLookupEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/BusinessUserLookupData"
ManagerResourceGrantEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/ManagerResourceGrantData"
ResourceGroupEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -4313,6 +4649,13 @@ definitions:
properties:
data:
$ref: "#/definitions/CreateRoomResponse"
UpdateRoomProfileEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/UpdateRoomProfileResponse"
RoomListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -49,6 +49,26 @@ paths:
$ref: "#/definitions/CreateRoomResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomCommandService/UpdateRoomProfile:
post:
tags:
- room-command
summary: 修改房间资料和座位数
operationId: roomUpdateRoomProfile
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/UpdateRoomProfile
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/UpdateRoomProfileRequest"
responses:
"200":
description: 修改成功资料、座位数、snapshot、command log 和 outbox 已经通过 Room Cell 提交。
schema:
$ref: "#/definitions/UpdateRoomProfileResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomCommandService/JoinRoom:
post:
tags:
@ -613,7 +633,6 @@ definitions:
description: 同一个 `meta.actor_user_id` 只能作为 owner 创建一个房间owner/host 不接受外部字段自报。
required:
- meta
- seat_count
- mode
- room_name
properties:
@ -622,8 +641,8 @@ definitions:
seat_count:
type: integer
format: int32
minimum: 1
description: 麦位数量,必须大于 0
minimum: 0
description: 麦位数量0 或省略时使用后台默认值,正数必须命中后台启用座位数
mode:
type: string
minLength: 1
@ -648,6 +667,42 @@ definitions:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
UpdateRoomProfileRequest:
type: object
required:
- meta
properties:
meta:
$ref: "#/definitions/RequestMeta"
room_name:
type: string
maxLength: 128
description: proto3 optional传入时更新 RoomExt title。
room_avatar:
type: string
maxLength: 512
description: proto3 optional传空字符串时恢复系统默认头像。
room_description:
type: string
maxLength: 512
description: proto3 optional允许传空字符串清空简介。
seat_count:
type: integer
format: int32
enum:
- 10
- 15
- 20
- 25
- 30
description: proto3 optional必须是后台启用座位数缩容要求被删除麦位空闲且未锁。
UpdateRoomProfileResponse:
type: object
properties:
result:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
JoinRoomRequest:
type: object
properties:

View File

@ -0,0 +1,91 @@
# 房间座位数配置
本文定义房间创建和 App 修改房间资料时的座位数规则,以及后台“房间配置”的边界。
## 当前规则
- 固定候选座位数为 `10/15/20/25/30`
- 后台只配置启用项和默认值,不允许新增候选数字。
- 默认配置为:启用 `10/15/20/25/30`,默认 `15`
- 不使用 Redis 缓存。创建房间和修改房间资料是低频路径,`room-service` 每次直接读取 MySQL `room_seat_configs`,后台保存后立即生效。
## 数据表
`room_seat_configs` 是 App 级低频配置表:
| 字段 | 说明 |
| --- | --- |
| `app_code` | App 隔离键,当前默认 `lalu` |
| `allowed_seat_counts` | JSON 数组字符串,例如 `[10,15,20,25,30]` |
| `default_seat_count` | 创建房间不传 `seat_count` 或传 `0` 时使用 |
| `created_at_ms` / `updated_at_ms` | UTC epoch milliseconds |
`rooms.seat_count``room_list_entries.seat_count` 保存房间当前座位数Room Cell 快照里的 `mic_seats` 是房间内麦位权威状态。
## 创建房间
`POST /api/v1/rooms/create``seat_count` 可省略:
- 不传或传 `0`:使用后台默认座位数,当前为 `15`
- 传正数:必须命中后台启用座位数。
- 传负数或未启用数字:返回 `invalid_argument`
创建成功会初始化对应数量的空麦位,并写入 `rooms`、snapshot、command log、outbox 和 `room_list_entries`
## 修改房间资料
App 修改房间资料走:
```http
POST /api/v1/rooms/profile/update
```
请求:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_profile_xxx",
"room_name": "房间标题",
"room_avatar": "https://cdn.example.com/room.png",
"room_description": "简介",
"seat_count": 20
}
```
规则:
- 只有当前在房间内的房主可以执行。
- `room_name``room_avatar``room_description``seat_count` 都是可选字段;只提交需要修改的字段。
- `seat_count` 必须是已启用正数,不能用 `0` 表示默认。
- 扩大座位数时追加连续空麦位。
- 缩小座位数时,高编号将被删除的麦位必须全部空闲、未锁、无发流会话;否则返回 `conflict`
- 成功后返回 `result``room``seats``server_time_ms`,客户端可立即刷新房间信息和麦位列表。
- 成功命令写 `RoomProfileUpdated` outbox并向 IM 发布 `room_profile_updated` 房间系统事件。
## 后台配置
后台“房间管理 / 房间配置”使用:
```http
GET /api/v1/admin/rooms/config
PUT /api/v1/admin/rooms/config
```
响应 DTO
```json
{
"candidateSeatCounts": [10, 15, 20, 25, 30],
"allowedSeatCounts": [10, 15, 20, 25, 30],
"defaultSeatCount": 15,
"updatedAtMs": 1778000000000
}
```
权限:
| 权限码 | 说明 |
| --- | --- |
| `room-config:view` | 查看房间配置页面和接口 |
| `room-config:update` | 保存房间配置 |

View File

@ -53,6 +53,7 @@
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
| GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 |
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
| POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 |
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token |
| POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence |
| POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 |
@ -106,6 +107,8 @@
| GET | `/api/v1/admin/app-config/h5-links` | admin | `listH5Links` | listH5Links |
| PUT | `/api/v1/admin/app-config/h5-links` | admin | `updateH5Links` | updateH5Links |
| GET | `/api/v1/admin/apps` | admin | `listApps` | listApps |
| GET | `/api/v1/admin/rooms/config` | admin | `getRoomConfig` | 查询房间配置 |
| PUT | `/api/v1/admin/rooms/config` | admin | `updateRoomConfig` | 保存房间配置 |
| GET | `/api/v1/admin/bd-leaders` | admin | `listBDLeaders` | listBDLeaders |
| POST | `/api/v1/admin/bd-leaders` | admin | `createBDLeader` | createBDLeader |
| PATCH | `/api/v1/admin/bd-leaders/{user_id}/status` | admin | `setBDLeaderStatus` | setBDLeaderStatus |
@ -261,6 +264,7 @@
| POST | `/grpc.health.v1.Health/Check` | health | `userHealthCheck` | 标准 gRPC health check |
| POST | `/grpc.health.v1.Health/Watch` | health | `userHealthWatch` | 标准 gRPC health watch |
| POST | `/hyapp.room.v1.RoomCommandService/CreateRoom` | room-command | `roomCreateRoom` | 创建房间 |
| POST | `/hyapp.room.v1.RoomCommandService/UpdateRoomProfile` | room-command | `roomUpdateRoomProfile` | 修改房间资料和座位数 |
| POST | `/hyapp.room.v1.RoomCommandService/JoinRoom` | room-command | `roomJoinRoom` | 加入房间业务 presence |
| POST | `/hyapp.room.v1.RoomCommandService/LeaveRoom` | room-command | `roomLeaveRoom` | 离开房间业务 presence |
| POST | `/hyapp.room.v1.RoomCommandService/MicUp` | room-command | `roomMicUp` | 上麦 |

View File

@ -1,4 +1,4 @@
# Manager Center Resource Grant Architecture
# 经理中心资源赠送架构
本文定义后台资源列表的“赠送权限”开关、H5 经理中心资源赠送、以及可复用用户查询接口的服务边界。资源 catalog、用户资源权益和赠送事实仍按 [Resource And Resource Group Architecture](./资源组与礼物架构.md) 归属 `wallet-service`经理、Agency、BD 等业务身份仍按 [Host, Agency And BD App Architecture](./主播公会BD架构.md) 归属 `user-service`
@ -45,7 +45,7 @@
```sql
ALTER TABLE resources
ADD COLUMN manager_grant_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, sort_order);
ADD KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order);
```
字段语义:
@ -66,7 +66,7 @@ POST /api/v1/admin/resources/{resource_id}/enable
POST /api/v1/admin/resources/{resource_id}/disable
```
DTO/RPC 需要补充
DTO/RPC 字段
- `wallet.v1.Resource.manager_grant_enabled`
- `CreateResourceRequest.manager_grant_enabled`

View File

@ -52,7 +52,8 @@
| Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- |
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、seat_count、mode写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
| 修改房间资料和座位数 | `DONE` | `UpdateRoomProfile` 由房间内 owner 执行;资料和座位数变更经过 Room Cell缩容要求被删除麦位空闲且未锁 | App 编辑页和真实 IM `room_profile_updated` 展示联调 |
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host拒绝外部冒用 | 后续转主持需独立命令 |
| 进房 presence | `DONE` | `JoinRoom``OnlineUsers`gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 |
| 显式心跳刷新 | `DONE` | `/api/v1/rooms/heartbeat``RoomHeartbeat` 只刷新已有 `last_seen_at_ms` | 客户端按 stale timeout 配置周期调用 |

View File

@ -297,7 +297,7 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20
"status": "active",
"heat": 1000,
"online_count": 26,
"seat_count": 8,
"seat_count": 15,
"occupied_seat_count": 2
}
],

View File

@ -139,7 +139,7 @@ Response `data`:
"chat_enabled": true,
"heat": 1000,
"online_count": 26,
"seat_count": 8,
"seat_count": 15,
"occupied_seat_count": 2,
"version": 2
},
@ -299,6 +299,39 @@ Client SDK action:
Tencent RTC SDK enterRoom(strRoomId=resp.str_room_id, userId=resp.rtc_user_id, role=audience)
```
### Update Room Profile
房主在房间内修改房间资料和座位数时调用:
```http
POST /api/v1/rooms/profile/update
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_profile_<room_id>_<user_id>_<nonce>",
"room_name": "Live Room",
"room_avatar": "https://cdn.example/room.png",
"room_description": "welcome",
"seat_count": 20
}
```
Rules:
- 只有当前在房间内的 owner 可以执行。
- `room_name``room_avatar``room_description``seat_count` 都是可选字段;客户端只传本次要修改的字段。
- `seat_count` 只能传后台已启用的 `10/15/20/25/30` 子集,当前默认配置全部启用。
- 创建房间可以省略 `seat_count` 使用后台默认值;修改资料时不能传 `0`
- 缩小座位数时,被删除的高编号麦位必须全部空闲且未锁,否则返回 `conflict`,客户端需要先处理麦位再重试。
- 成功响应返回最新 `room``seats`,客户端直接刷新房间标题、头像、简介和麦位列表。
- 成功后房间系统消息事件名为 `room_profile_updated`
### Heartbeat
```http

View File

@ -167,6 +167,7 @@ resources(
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,
@ -181,7 +182,8 @@ resources(
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_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(
@ -330,6 +332,8 @@ user_resource_equipment(
);
```
`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

View File

@ -72,7 +72,6 @@ Authorization: Bearer <access_token>
```json
{
"seat_count": 8,
"mode": "voice",
"room_name": "周末语音房",
"room_avatar": "",
@ -80,7 +79,7 @@ Authorization: Bearer <access_token>
}
```
正式客户端不应该传 `room_id``command_id``owner_user_id``host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id``command_id`room-service 使用该 actor 确定 owner 和默认 host。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`
正式客户端不应该传 `room_id``command_id``owner_user_id``host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id``command_id`room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`
响应必须包含:
@ -121,7 +120,7 @@ Authorization: Bearer <access_token>
"room_id": "room_1001",
"im_group_id": "room_1001",
"online_count": 26,
"seat_count": 8,
"seat_count": 15,
"version": 2
},
"viewer": {

View File

@ -261,6 +261,8 @@
| `room:view` | `menu` | 房间列表、详情、房间状态 |
| `room:update` | `button` | 后台修改房间展示资料、模式、状态、区域 |
| `room:delete` | `button` | 后台删除房间 |
| `room-config:view` | `menu` | 房间配置页面、读取座位数配置 |
| `room-config:update` | `button` | 保存启用座位数和默认座位数 |
| `room:create` | `button` | 后台创建房间 |
| `room:mic` | `button` | 上下麦、换麦、锁麦 |
| `room:chat` | `button` | 公屏开关 |

View File

@ -3,27 +3,27 @@ package resource
import walletv1 "hyapp.local/api/proto/wallet/v1"
type resourceDTO struct {
AppCode string `json:"appCode"`
ResourceID int64 `json:"resourceId"`
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Grantable bool `json:"grantable"`
ManagerGrantEnabled bool `json:"managerGrantEnabled"`
GrantStrategy string `json:"grantStrategy"`
WalletAssetType string `json:"walletAssetType"`
WalletAssetAmount int64 `json:"walletAssetAmount"`
UsageScopes []string `json:"usageScopes"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
MetadataJSON string `json:"metadataJson"`
SortOrder int32 `json:"sortOrder"`
CreatedByUserID int64 `json:"createdByUserId"`
UpdatedByUserID int64 `json:"updatedByUserId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
AppCode string `json:"appCode"`
ResourceID int64 `json:"resourceId"`
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Grantable bool `json:"grantable"`
ManagerGrantEnabled bool `json:"managerGrantEnabled"`
GrantStrategy string `json:"grantStrategy"`
WalletAssetType string `json:"walletAssetType"`
WalletAssetAmount int64 `json:"walletAssetAmount"`
UsageScopes []string `json:"usageScopes"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
MetadataJSON string `json:"metadataJson"`
SortOrder int32 `json:"sortOrder"`
CreatedByUserID int64 `json:"createdByUserId"`
UpdatedByUserID int64 `json:"updatedByUserId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type resourceGroupItemDTO struct {
@ -114,27 +114,27 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
return resourceDTO{}
}
return resourceDTO{
AppCode: item.GetAppCode(),
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Status: item.GetStatus(),
Grantable: item.GetGrantable(),
AppCode: item.GetAppCode(),
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Status: item.GetStatus(),
Grantable: item.GetGrantable(),
ManagerGrantEnabled: item.GetManagerGrantEnabled(),
GrantStrategy: item.GetGrantStrategy(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
CreatedByUserID: item.GetCreatedByUserId(),
UpdatedByUserID: item.GetUpdatedByUserId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
GrantStrategy: item.GetGrantStrategy(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
MetadataJSON: item.GetMetadataJson(),
SortOrder: item.GetSortOrder(),
CreatedByUserID: item.GetCreatedByUserId(),
UpdatedByUserID: item.GetUpdatedByUserId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -13,15 +13,15 @@ import (
const dayMillis int64 = 24 * 60 * 60 * 1000
type resourceRequest struct {
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Amount int64 `json:"amount"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
SortOrder int32 `json:"sortOrder"`
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
Status string `json:"status"`
Amount int64 `json:"amount"`
ManagerGrantEnabled *bool `json:"managerGrantEnabled"`
AssetURL string `json:"assetUrl"`
PreviewURL string `json:"previewUrl"`
SortOrder int32 `json:"sortOrder"`
}
type resourceGroupRequest struct {
@ -82,24 +82,24 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: "",
MetadataJson: "{}",
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: "",
MetadataJson: "{}",
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
}
@ -107,25 +107,25 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: "",
MetadataJson: "{}",
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
GrantStrategy: resourceGrantStrategy(resourceType),
WalletAssetType: walletAssetType,
WalletAssetAmount: walletAssetAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: "",
MetadataJson: "{}",
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
}
}

View File

@ -0,0 +1,116 @@
package roomadmin
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sort"
"time"
"hyapp-admin-server/internal/appctx"
)
var roomSeatCountCandidates = []int32{10, 15, 20, 25, 30}
type RoomConfig struct {
CandidateSeatCounts []int32 `json:"candidateSeatCounts"`
AllowedSeatCounts []int32 `json:"allowedSeatCounts"`
DefaultSeatCount int32 `json:"defaultSeatCount"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
// GetRoomConfig 读取房间座位数配置;缺行时返回产品默认值,不阻塞首次打开配置页。
func (s *Service) GetRoomConfig(ctx context.Context) (RoomConfig, error) {
if s.db == nil {
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
}
row := s.db.QueryRowContext(ctx, `
SELECT allowed_seat_counts, default_seat_count, updated_at_ms
FROM room_seat_configs
WHERE app_code = ?
`, appctx.FromContext(ctx))
var rawAllowed string
config := defaultRoomConfig()
if err := row.Scan(&rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
if err == sql.ErrNoRows {
return config, nil
}
return RoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
return RoomConfig{}, err
}
return normalizeRoomConfig(config)
}
// UpdateRoomConfig 保存后台房间配置;候选集合固定,后台只控制启用项和默认值。
func (s *Service) UpdateRoomConfig(ctx context.Context, req updateRoomConfigRequest) (RoomConfig, error) {
if s.db == nil {
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
}
config, err := normalizeRoomConfig(RoomConfig{
AllowedSeatCounts: req.AllowedSeatCounts,
DefaultSeatCount: req.DefaultSeatCount,
UpdatedAtMS: time.Now().UTC().UnixMilli(),
})
if err != nil {
return RoomConfig{}, err
}
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
if err != nil {
return RoomConfig{}, err
}
now := config.UpdatedAtMS
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
allowed_seat_counts = VALUES(allowed_seat_counts),
default_seat_count = VALUES(default_seat_count),
updated_at_ms = VALUES(updated_at_ms)
`, appctx.FromContext(ctx), string(rawAllowed), config.DefaultSeatCount, now, now); err != nil {
return RoomConfig{}, err
}
return config, nil
}
func defaultRoomConfig() RoomConfig {
return RoomConfig{
CandidateSeatCounts: append([]int32(nil), roomSeatCountCandidates...),
AllowedSeatCounts: append([]int32(nil), roomSeatCountCandidates...),
DefaultSeatCount: 15,
}
}
func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) {
candidateSet := make(map[int32]bool, len(roomSeatCountCandidates))
for _, candidate := range roomSeatCountCandidates {
candidateSet[candidate] = true
}
seen := make(map[int32]bool, len(config.AllowedSeatCounts))
allowed := make([]int32, 0, len(config.AllowedSeatCounts))
for _, value := range config.AllowedSeatCounts {
if !candidateSet[value] {
return RoomConfig{}, fmt.Errorf("%w: 座位数只能选择 10、15、20、25、30", ErrInvalidArgument)
}
if seen[value] {
return RoomConfig{}, fmt.Errorf("%w: 座位数不能重复", ErrInvalidArgument)
}
seen[value] = true
allowed = append(allowed, value)
}
if len(allowed) == 0 {
return RoomConfig{}, fmt.Errorf("%w: 至少启用一个座位数", ErrInvalidArgument)
}
sort.Slice(allowed, func(i int, j int) bool {
return allowed[i] < allowed[j]
})
if !seen[config.DefaultSeatCount] {
return RoomConfig{}, fmt.Errorf("%w: 默认座位数必须在已启用座位数中", ErrInvalidArgument)
}
config.CandidateSeatCounts = append([]int32(nil), roomSeatCountCandidates...)
config.AllowedSeatCounts = allowed
return config, nil
}

View File

@ -0,0 +1,48 @@
package roomadmin
import (
"errors"
"reflect"
"testing"
)
func TestNormalizeRoomConfigSortsAndKeepsFixedCandidates(t *testing.T) {
config, err := normalizeRoomConfig(RoomConfig{
AllowedSeatCounts: []int32{30, 10, 20},
DefaultSeatCount: 20,
UpdatedAtMS: 123,
})
if err != nil {
t.Fatalf("normalizeRoomConfig failed: %v", err)
}
if !reflect.DeepEqual(config.CandidateSeatCounts, []int32{10, 15, 20, 25, 30}) {
t.Fatalf("candidate seat counts mismatch: %+v", config.CandidateSeatCounts)
}
if !reflect.DeepEqual(config.AllowedSeatCounts, []int32{10, 20, 30}) {
t.Fatalf("allowed seat counts should be sorted: %+v", config.AllowedSeatCounts)
}
if config.DefaultSeatCount != 20 || config.UpdatedAtMS != 123 {
t.Fatalf("config fields changed unexpectedly: %+v", config)
}
}
func TestNormalizeRoomConfigRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
config RoomConfig
}{
{name: "empty", config: RoomConfig{AllowedSeatCounts: nil, DefaultSeatCount: 15}},
{name: "duplicate", config: RoomConfig{AllowedSeatCounts: []int32{10, 10}, DefaultSeatCount: 10}},
{name: "outside_candidate", config: RoomConfig{AllowedSeatCounts: []int32{12}, DefaultSeatCount: 12}},
{name: "default_not_allowed", config: RoomConfig{AllowedSeatCounts: []int32{10, 20}, DefaultSeatCount: 15}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := normalizeRoomConfig(test.config)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("normalizeRoomConfig should reject %s with ErrInvalidArgument, got %v", test.name, err)
}
})
}
}

View File

@ -34,6 +34,30 @@ func (h *Handler) ListRooms(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) GetRoomConfig(c *gin.Context) {
config, err := h.service.GetRoomConfig(c.Request.Context())
if err != nil {
response.ServerError(c, "获取房间配置失败")
return
}
response.OK(c, config)
}
func (h *Handler) UpdateRoomConfig(c *gin.Context) {
var req updateRoomConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "房间配置参数不正确")
return
}
config, err := h.service.UpdateRoomConfig(c.Request.Context(), req)
if err != nil {
writeMutationError(c, err, "更新房间配置失败")
return
}
writeRoomAuditLog(c, h.audit, "update-room-config", "room_seat_configs", "seat-counts", "success", "update room seat config")
response.OK(c, config)
}
func (h *Handler) UpdateRoom(c *gin.Context) {
roomID, ok := parseRoomID(c)
if !ok {

View File

@ -16,3 +16,8 @@ type updateRoomRequest struct {
Title *string `json:"title"`
VisibleRegionID *int64 `json:"visibleRegionId"`
}
type updateRoomConfigRequest struct {
AllowedSeatCounts []int32 `json:"allowedSeatCounts"`
DefaultSeatCount int32 `json:"defaultSeatCount"`
}

View File

@ -12,6 +12,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/rooms", middleware.RequirePermission("room:view"), h.ListRooms)
protected.GET("/admin/rooms/config", middleware.RequirePermission("room-config:view"), h.GetRoomConfig)
protected.PUT("/admin/rooms/config", middleware.RequirePermission("room-config:update"), h.UpdateRoomConfig)
protected.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), h.UpdateRoom)
protected.DELETE("/admin/rooms/:room_id", middleware.RequirePermission("room:delete"), h.DeleteRoom)
}

View File

@ -23,6 +23,8 @@ var defaultPermissions = []model.Permission{
{Name: "房间查看", Code: "room:view", Kind: "menu"},
{Name: "房间更新", Code: "room:update", Kind: "button"},
{Name: "房间删除", Code: "room:delete", Kind: "button"},
{Name: "房间配置查看", Code: "room-config:view", Kind: "menu"},
{Name: "房间配置更新", Code: "room-config:update", Kind: "button"},
{Name: "APP 配置查看", Code: "app-config:view", Kind: "menu"},
{Name: "APP 配置更新", Code: "app-config:update", Kind: "button"},
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
@ -166,6 +168,7 @@ func (s *Store) seedMenus() error {
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
@ -406,7 +409,7 @@ func defaultRolePermissionCodes(code string) []string {
"overview:view",
"user:view", "user:status",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"resource:view", "resource:create", "resource:update",
"resource-group:view", "resource-group:create", "resource-group:update",
@ -426,13 +429,14 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"app-user:view",
"room:view",
"room-config:view",
"app-config:view",
"resource:view",
"resource-group:view",
@ -463,7 +467,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
case "ops-admin":
return []string{
"app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"resource:view", "resource:create", "resource:update",
"resource-group:view", "resource-group:create", "resource-group:update",
@ -477,7 +481,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
}
case "auditor", "readonly":
return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"}
return []string{"room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"}
default:
return nil
}

View File

@ -0,0 +1,318 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/auth"
)
const managerResourceGrantCapability = "manager_resource_grant"
type managerGrantResourceData struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
WalletAssetType string `json:"wallet_asset_type"`
WalletAssetAmount int64 `json:"wallet_asset_amount"`
SortOrder int32 `json:"sort_order"`
}
type businessUserLookupData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
RegionID int64 `json:"region_id,omitempty"`
RegionCode string `json:"region_code,omitempty"`
RegionName string `json:"region_name,omitempty"`
}
type managerResourceGrantData struct {
GrantID string `json:"grant_id"`
CommandID string `json:"command_id"`
TargetUserID string `json:"target_user_id"`
ResourceID string `json:"resource_id"`
Status string `json:"status"`
GrantSource string `json:"grant_source"`
OperatorUserID string `json:"operator_user_id"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
return
}
page, pageSize, ok := managerPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")),
Page: page,
PageSize: pageSize,
ActiveOnly: true,
ManagerGrantOnly: true,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
items := make([]managerGrantResourceData, 0, len(resp.GetResources()))
for _, resource := range resp.GetResources() {
items = append(items, managerGrantResourceFromProto(resource))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
if pageSize > 20 {
pageSize = 20
}
resp, err := h.userProfileClient.BusinessUserLookup(request.Context(), &userv1.BusinessUserLookupRequest{
Meta: authRequestMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
items := make([]businessUserLookupData, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
items = append(items, businessUserLookupFromProto(user))
}
writeOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
}
func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userHostClient == nil || h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
TargetUserID json.RawMessage `json:"target_user_id"`
TargetUserIDAlt json.RawMessage `json:"targetUserId"`
ResourceID json.RawMessage `json:"resource_id"`
ResourceIDAlt json.RawMessage `json:"resourceId"`
Reason string `json:"reason"`
Quantity *int64 `json:"quantity"`
DurationMS *int64 `json:"duration_ms"`
DurationMSAlt *int64 `json:"durationMs"`
}
if !decode(writer, request, &body) {
return
}
if body.Quantity != nil || body.DurationMS != nil || body.DurationMSAlt != nil {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
if !h.requireManagerResourceGrantCapability(writer, request) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt)
resourceID, resourceOK := rawInt64(body.ResourceID, body.ResourceIDAlt)
if commandID == "" || !targetOK || targetUserID <= 0 || !resourceOK || resourceID <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
if target := targetResp.GetUser(); target == nil || target.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "manager_center"
}
actorUserID := auth.UserIDFromContext(request.Context())
resp, err := h.walletClient.GrantResource(request.Context(), &walletv1.GrantResourceRequest{
CommandId: commandID,
AppCode: appcode.FromContext(request.Context()),
TargetUserId: targetUserID,
ResourceId: resourceID,
Quantity: 1,
DurationMs: 0,
Reason: reason,
OperatorUserId: actorUserID,
GrantSource: "manager_center",
})
if err != nil {
logx.Warn(request.Context(), "manager_resource_grant_failed",
slog.Int64("actor_user_id", actorUserID),
slog.Int64("target_user_id", targetUserID),
slog.Int64("resource_id", resourceID),
slog.String("command_id", commandID),
)
writeRPCError(writer, request, err)
return
}
grant := managerResourceGrantFromProto(resp.GetGrant())
logx.Info(request.Context(), "manager_resource_grant_succeeded",
slog.Int64("actor_user_id", actorUserID),
slog.Int64("target_user_id", targetUserID),
slog.Int64("resource_id", resourceID),
slog.String("grant_id", grant.GrantID),
)
writeOK(writer, request, grant)
}
func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool {
resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{
Meta: authRequestMeta(request, ""),
ActorUserId: auth.UserIDFromContext(request.Context()),
Capability: managerResourceGrantCapability,
})
if err != nil {
writeRPCError(writer, request, err)
return false
}
if !resp.GetAllowed() {
writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied")
return false
}
return true
}
func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantResourceData {
if resource == nil {
return managerGrantResourceData{}
}
return managerGrantResourceData{
ResourceID: userIDString(resource.GetResourceId()),
ResourceType: resource.GetResourceType(),
Name: resource.GetName(),
AssetURL: resource.GetAssetUrl(),
PreviewURL: resource.GetPreviewUrl(),
WalletAssetType: resource.GetWalletAssetType(),
WalletAssetAmount: resource.GetWalletAssetAmount(),
SortOrder: resource.GetSortOrder(),
}
}
func businessUserLookupFromProto(user *userv1.BusinessUserLookupItem) businessUserLookupData {
if user == nil {
return businessUserLookupData{}
}
return businessUserLookupData{
UserID: userIDString(user.GetUserId()),
DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
Status: userStatusString(user.GetStatus()),
RegionID: user.GetRegionId(),
RegionCode: user.GetRegionCode(),
RegionName: user.GetRegionName(),
}
}
func managerResourceGrantFromProto(grant *walletv1.ResourceGrant) managerResourceGrantData {
if grant == nil {
return managerResourceGrantData{}
}
resourceID := grant.GetGrantSubjectId()
if len(grant.GetItems()) > 0 && grant.GetItems()[0].GetResourceId() > 0 {
resourceID = userIDString(grant.GetItems()[0].GetResourceId())
}
return managerResourceGrantData{
GrantID: grant.GetGrantId(),
CommandID: grant.GetCommandId(),
TargetUserID: userIDString(grant.GetTargetUserId()),
ResourceID: resourceID,
Status: grant.GetStatus(),
GrantSource: grant.GetGrantSource(),
OperatorUserID: userIDString(grant.GetOperatorUserId()),
CreatedAtMS: grant.GetCreatedAtMs(),
}
}
func managerPage(request *http.Request) (int32, int32, bool) {
page, pageSize, ok := resourcePage(request)
if !ok {
return 0, 0, false
}
if pageSize > 20 {
pageSize = 20
}
return page, pageSize, true
}
func rawInt64(values ...json.RawMessage) (int64, bool) {
for _, raw := range values {
value, ok := parseRawInt64(raw)
if ok {
return value, true
}
}
return 0, false
}
func parseRawInt64(raw json.RawMessage) (int64, bool) {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return 0, false
}
if strings.HasPrefix(text, `"`) {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return 0, false
}
text = strings.TrimSpace(value)
}
value, err := strconv.ParseInt(text, 10, 64)
return value, err == nil
}
func userStatusString(status userv1.UserStatus) string {
switch status {
case userv1.UserStatus_USER_STATUS_ACTIVE:
return "active"
case userv1.UserStatus_USER_STATUS_DISABLED:
return "disabled"
case userv1.UserStatus_USER_STATUS_BANNED:
return "banned"
default:
return "unspecified"
}
}

View File

@ -0,0 +1,179 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/services/gateway-service/internal/auth"
)
func newManagerCenterTestRouter(walletClient *fakeWalletClient, hostClient *fakeUserHostClient, profileClient *fakeUserProfileClient) http.Handler {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
return handler.Routes(auth.NewVerifier("secret"))
}
func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *testing.T) {
walletClient := &fakeWalletClient{
listResourcesResp: &walletv1.ListResourcesResponse{
Total: 1,
Resources: []*walletv1.Resource{
{
ResourceId: 101,
ResourceType: "badge",
Name: "VIP Badge",
AssetUrl: "https://cdn.example/badge.png",
PreviewUrl: "https://cdn.example/badge-preview.png",
WalletAssetType: "",
WalletAssetAmount: 0,
SortOrder: 7,
},
},
},
}
hostClient := &fakeUserHostClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/resource-grants/resources?resource_type=badge&page=2&page_size=99", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-resources")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != managerResourceGrantCapability {
t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability)
}
if walletClient.lastListResources == nil {
t.Fatal("wallet ListResources was not called")
}
if req := walletClient.lastListResources; req.GetRequestId() != "req-manager-resources" || req.GetAppCode() != "lalu" || req.GetResourceType() != "badge" || req.GetPage() != 2 || req.GetPageSize() != 20 || !req.GetActiveOnly() || !req.GetManagerGrantOnly() {
t.Fatalf("wallet ListResources request mismatch: %+v", req)
}
var envelope struct {
Code string `json:"code"`
RequestID string `json:"request_id"`
Data struct {
Items []managerGrantResourceData `json:"items"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if envelope.Code != codeOK || envelope.RequestID != "req-manager-resources" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("response envelope mismatch: %+v", envelope)
}
if envelope.Data.Items[0].ResourceID != "101" || envelope.Data.Items[0].Name != "VIP Badge" {
t.Fatalf("resource data mismatch: %+v", envelope.Data.Items[0])
}
}
func TestListManagerGrantResourcesRejectsUnauthorizedManager(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{capabilityResp: &userv1.CheckBusinessCapabilityResponse{Allowed: false, Reason: "active_agency_owner_required"}}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/resource-grants/resources", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-denied")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, codePermissionDenied, "req-manager-denied")
if walletClient.lastListResources != nil {
t.Fatalf("unauthorized manager must not reach wallet-service: %+v", walletClient.lastListResources)
}
}
func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
profileClient := &fakeUserProfileClient{
businessLookupResp: &userv1.BusinessUserLookupResponse{
Users: []*userv1.BusinessUserLookupItem{
{UserId: 900001, DisplayUserId: "123456", Username: "Alice", Avatar: "https://cdn.example/a.png", Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001, RegionCode: "SA", RegionName: "Saudi Arabia"},
},
},
}
router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{}, profileClient)
request := httptest.NewRequest(http.MethodGet, "/api/v1/business/users/lookup?scene=manager_resource_grant&keyword=123456&page_size=99", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-business-lookup")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if req := profileClient.lastBusinessLookup; req == nil || req.GetMeta().GetRequestId() != "req-business-lookup" || req.GetActorUserId() != 42 || req.GetScene() != "manager_resource_grant" || req.GetKeyword() != "123456" || req.GetPageSize() != 20 {
t.Fatalf("business lookup request mismatch: %+v", req)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Items []businessUserLookupData `json:"items"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if envelope.Code != codeOK || envelope.Data.Total != 1 || envelope.Data.Items[0].UserID != "900001" || envelope.Data.Items[0].Status != "active" {
t.Fatalf("business lookup response mismatch: %+v", envelope)
}
}
func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{}
profileClient := &fakeUserProfileClient{}
router := newManagerCenterTestRouter(walletClient, hostClient, profileClient)
body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-grant")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 {
t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability)
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 900001 {
t.Fatalf("target user lookup mismatch: %+v", profileClient.lastGet)
}
if req := walletClient.lastGrantResource; req == nil || req.GetCommandId() != "mgr-grant-1" || req.GetTargetUserId() != 900001 || req.GetResourceId() != 101 || req.GetQuantity() != 1 || req.GetDurationMs() != 0 || req.GetOperatorUserId() != 42 || req.GetGrantSource() != "manager_center" || req.GetReason() != "manager_center" {
t.Fatalf("wallet GrantResource request mismatch: %+v", req)
}
}
func TestGrantManagerResourceRejectsClientComputedQuantityOrDuration(t *testing.T) {
walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101","quantity":9}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-grant-shape")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-manager-grant-shape")
if walletClient.lastGrantResource != nil {
t.Fatalf("client computed grant shape must not reach wallet-service: %+v", walletClient.lastGrantResource)
}
}

View File

@ -32,6 +32,7 @@ import (
// 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。
type fakeRoomClient struct {
lastCreate *roomv1.CreateRoomRequest
lastUpdateProfile *roomv1.UpdateRoomProfileRequest
lastJoin *roomv1.JoinRoomRequest
lastHeartbeat *roomv1.RoomHeartbeatRequest
lastLeave *roomv1.LeaveRoomRequest
@ -49,6 +50,7 @@ type fakeRoomClient struct {
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
createErr error
updateProfileErr error
joinErr error
joinResp *roomv1.JoinRoomResponse
}
@ -64,6 +66,32 @@ func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomReq
}, nil
}
func (f *fakeRoomClient) UpdateRoomProfile(_ context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) {
f.lastUpdateProfile = req
if f.updateProfileErr != nil {
return nil, f.updateProfileErr
}
return &roomv1.UpdateRoomProfileResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3, ServerTimeMs: 1_700_000_000_002},
Room: &roomv1.RoomSnapshot{
RoomId: req.GetMeta().GetRoomId(),
OwnerUserId: req.GetMeta().GetActorUserId(),
HostUserId: req.GetMeta().GetActorUserId(),
Mode: "voice",
Status: "active",
ChatEnabled: true,
MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}, {SeatNo: 3}},
RoomExt: map[string]string{
"title": req.GetRoomName(),
"cover_url": req.GetRoomAvatar(),
"description": req.GetRoomDescription(),
},
Version: 3,
},
}, nil
}
func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
f.lastJoin = req
if f.joinErr != nil {
@ -189,20 +217,23 @@ type fakeUserAuthClient struct {
}
type fakeUserProfileClient struct {
lastGet *userv1.GetUserRequest
lastStats *userv1.GetMyProfileStatsRequest
lastBatch *userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastCountry *userv1.ChangeUserCountryRequest
getErr error
statsResp *userv1.GetMyProfileStatsResponse
statsErr error
completeErr error
countryErr error
regionID int64
regionByUserID map[int64]int64
lastGet *userv1.GetUserRequest
lastBusinessLookup *userv1.BusinessUserLookupRequest
lastStats *userv1.GetMyProfileStatsRequest
lastBatch *userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastCountry *userv1.ChangeUserCountryRequest
getErr error
statsResp *userv1.GetMyProfileStatsResponse
businessLookupResp *userv1.BusinessUserLookupResponse
businessLookupErr error
statsErr error
completeErr error
countryErr error
regionID int64
regionByUserID map[int64]int64
}
type fakeUserIdentityClient struct {
@ -255,6 +286,9 @@ type fakeUserHostClient struct {
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
}
type fakeWalletClient struct {
@ -285,6 +319,11 @@ type fakeWalletClient struct {
lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error
lastListResources *walletv1.ListResourcesRequest
listResourcesResp *walletv1.ListResourcesResponse
lastGrantResource *walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error
}
type fakeMessageInboxClient struct {
@ -405,6 +444,17 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
}}, nil
}
func (f *fakeUserProfileClient) BusinessUserLookup(_ context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) {
f.lastBusinessLookup = req
if f.businessLookupErr != nil {
return nil, f.businessLookupErr
}
if f.businessLookupResp != nil {
return f.businessLookupResp, nil
}
return &userv1.BusinessUserLookupResponse{}, nil
}
func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) {
f.lastStats = req
if f.statsErr != nil {
@ -755,6 +805,17 @@ func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.G
return &userv1.GetUserRoleSummaryResponse{Summary: &userv1.UserRoleSummary{UserId: req.GetUserId()}}, nil
}
func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
f.lastCapability = req
if f.capabilityErr != nil {
return nil, f.capabilityErr
}
if f.capabilityResp != nil {
return f.capabilityResp, nil
}
return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
}
func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
f.last = req
if f.err != nil {
@ -904,7 +965,14 @@ func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *wallet
return &walletv1.TransferCoinFromSellerResponse{}, nil
}
func (f *fakeWalletClient) ListResources(context.Context, *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
func (f *fakeWalletClient) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
f.lastListResources = req
if f.err != nil {
return nil, f.err
}
if f.listResourcesResp != nil {
return f.listResourcesResp, nil
}
return &walletv1.ListResourcesResponse{}, nil
}
@ -916,6 +984,17 @@ func (f *fakeWalletClient) ListGiftConfigs(context.Context, *walletv1.ListGiftCo
return &walletv1.ListGiftConfigsResponse{}, nil
}
func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
f.lastGrantResource = req
if f.grantResourceErr != nil {
return nil, f.grantResourceErr
}
if f.grantResourceResp != nil {
return f.grantResourceResp, nil
}
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil
}
func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
return &walletv1.ListUserResourcesResponse{}, nil
}
@ -993,7 +1072,7 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
client := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{regionID: 1001}
router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":8,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`)
body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":10,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-test")
@ -1046,6 +1125,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
commandID string
extractMeta func(*fakeRoomClient) *roomv1.RequestMeta
}{
{
name: "profile_update",
path: "/api/v1/rooms/profile/update",
body: `{"room_id":"room-1","command_id":"cmd-profile-1","room_name":"New Room","seat_count":20}`,
commandID: "cmd-profile-1",
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
return client.lastUpdateProfile.GetMeta()
},
},
{
name: "join",
path: "/api/v1/rooms/join",
@ -1606,6 +1694,11 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
OwnerUserId: 42,
Status: "active",
Version: 7,
MicSeats: []*roomv1.SeatState{
{SeatNo: 1, UserId: 42},
{SeatNo: 2},
},
RoomExt: map[string]string{"title": "Snapshot Room"},
},
ServerTimeMs: 123456,
}}
@ -1628,6 +1721,22 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != "req-room-snapshot" {
t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode snapshot response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if !ok {
t.Fatalf("snapshot response must be wrapped in app DTO: %+v", response.Data)
}
room, ok := data["room"].(map[string]any)
if !ok || room["seat_count"] != float64(2) || room["title"] != "Snapshot Room" {
t.Fatalf("snapshot room DTO must include derived room fields: %+v", data["room"])
}
seats, ok := data["seats"].([]any)
if !ok || len(seats) != 2 {
t.Fatalf("snapshot DTO must include seat list: %+v", data["seats"])
}
}
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
@ -2726,6 +2835,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
}{
{name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"},
{name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`},
{name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`},
{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
{name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
@ -2927,7 +3037,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":8,"mode":"voice","room_name":"Lobby"}`)))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":10,"mode":"voice","room_name":"Lobby"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-generated-room-id")
recorder := httptest.NewRecorder()
@ -2945,14 +3055,81 @@ func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
}
}
func TestCreateRoomAllowsMissingSeatCount(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","mode":"voice","room_name":"Lobby"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-create-default-seat")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastCreate == nil || client.lastCreate.GetSeatCount() != 0 {
t.Fatalf("missing seat_count should forward zero to room-service default config: %+v", client.lastCreate)
}
}
func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-1","room_name":" New Room ","room_avatar":" https://cdn.example.com/new.png ","room_description":" new desc ","seat_count":20}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-update")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
req := client.lastUpdateProfile
if req == nil {
t.Fatal("UpdateRoomProfile must be forwarded")
}
if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != "req-profile-update" || req.GetMeta().GetActorUserId() != 42 {
t.Fatalf("UpdateRoomProfile meta mismatch: %+v", req.GetMeta())
}
if req.RoomName == nil || req.GetRoomName() != "New Room" || req.RoomAvatar == nil || req.GetRoomAvatar() != "https://cdn.example.com/new.png" || req.RoomDescription == nil || req.GetRoomDescription() != "new desc" || req.SeatCount == nil || req.GetSeatCount() != 20 {
t.Fatalf("UpdateRoomProfile optional fields mismatch: %+v", req)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if !ok || data["server_time_ms"] == nil || data["room"] == nil || data["seats"] == nil {
t.Fatalf("profile update response should include result room seats and server_time_ms: %+v", response.Data)
}
}
func TestUpdateRoomProfileRPCErrorWritesEnvelope(t *testing.T) {
client := &fakeRoomClient{updateProfileErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "removed seats must be empty and unlocked"))}
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-conflict","seat_count":10}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-profile-conflict")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-profile-conflict")
}
func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "seat_count", body: `{"room_id":"room-1","mode":"voice","room_name":"Lobby"}`},
{name: "mode", body: `{"room_id":"room-1","seat_count":8,"room_name":"Lobby"}`},
{name: "room_name", body: `{"room_id":"room-1","seat_count":8,"mode":"voice"}`},
{name: "mode", body: `{"room_id":"room-1","seat_count":10,"room_name":"Lobby"}`},
{name: "room_name", body: `{"room_id":"room-1","seat_count":10,"mode":"voice"}`},
{name: "negative_seat_count", body: `{"room_id":"room-1","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
}
for _, test := range tests {
@ -2976,7 +3153,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-1","seat_count":8,"mode":"voice","room_name":"Lobby"}`)
body := []byte(`{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-upstream")
@ -2992,7 +3169,7 @@ func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) {
createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")),
}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-owner-2","seat_count":8,"mode":"voice","room_name":"Lobby"}`)
body := []byte(`{"room_id":"room-owner-2","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-owner-conflict")

View File

@ -365,7 +365,7 @@ func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Requ
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
})
write(writer, request, resp, err)
write(writer, request, roomSnapshotDataFromProto(resp), err)
}
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。

View File

@ -58,6 +58,13 @@ type updateRoomProfileData struct {
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomSnapshotData struct {
Room roomInitialData `json:"room"`
Seats []roomSeatData `json:"seats"`
ContributionRank []roomRankItemData `json:"contribution_rank"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomCommandResultData struct {
Applied bool `json:"applied"`
RoomVersion int64 `json:"room_version"`
@ -196,6 +203,19 @@ func updateRoomProfileDataFromProto(resp *roomv1.UpdateRoomProfileResponse) upda
}
}
func roomSnapshotDataFromProto(resp *roomv1.GetRoomSnapshotResponse) roomSnapshotData {
if resp == nil {
return roomSnapshotData{}
}
snapshot := resp.GetRoom()
return roomSnapshotData{
Room: roomInitialRoomDataFromSnapshot(snapshot, snapshot.GetRoomId()),
Seats: roomSeatDataFromSnapshot(snapshot),
ContributionRank: roomRankDataFromSnapshot(snapshot, 0),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID string) roomInitialData {
if snapshot == nil {
return roomInitialData{RoomID: roomID, IMGroupID: roomIMGroupID(roomID)}

View File

@ -26,6 +26,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources))
mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
mux.Handle(apiV1Prefix+"/manager-center/resource-grants/resources", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listManagerGrantResources)))
mux.Handle(apiV1Prefix+"/manager-center/resource-grants", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.grantManagerResource)))
mux.Handle(apiV1Prefix+"/business/users/lookup", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.lookupBusinessUsers)))
mux.Handle(apiV1Prefix+"/im/usersig", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.issueTencentIMUserSig)))
mux.Handle(apiV1Prefix+"/rtc/token", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.issueTencentRTCToken)))
mux.Handle(apiV1Prefix+"/tencent-im/callback", h.publicAPIHandler(h.handleTencentIMCallback))

View File

@ -4,6 +4,7 @@ package service_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
@ -145,7 +146,7 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
roomID := "room-owner-default"
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
})
@ -181,7 +182,7 @@ func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Route Room",
}); err != nil {
@ -207,7 +208,7 @@ func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-owner-single-a", 42),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Owner Room A",
}); err != nil {
@ -216,7 +217,7 @@ func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) {
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-owner-single-b", 42),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Owner Room B",
})
@ -237,7 +238,7 @@ func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) {
RoomID: "room-owner-unique-a",
OwnerUserID: 77,
HostUserID: 77,
SeatCount: 4,
SeatCount: 10,
Mode: "social",
Status: "active",
}
@ -261,7 +262,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
roomID := "room-list-region-a"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Region A Room",
RoomAvatar: "https://cdn.example.com/room-a.png",
@ -273,7 +274,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
usedClock.now = usedClock.now.Add(time.Second)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-list-region-b", 3),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Region B Room",
VisibleRegionId: 2002,
@ -312,7 +313,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 {
t.Fatalf("room list item identity mismatch: %+v", item)
}
if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 {
if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 10 {
t.Fatalf("room list projection did not track Room Cell state: %+v", item)
}
if item.GetTitle() != "Region A Room" || item.GetCoverUrl() != "https://cdn.example.com/room-a.png" {
@ -341,7 +342,7 @@ func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 6,
SeatCount: 10,
Mode: "voice",
RoomName: "Owner Top Card",
RoomAvatar: "https://cdn.example.com/owner-room.png",
@ -361,7 +362,7 @@ func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) {
if !resp.GetHasRoom() || resp.GetRoom().GetRoomId() != roomID {
t.Fatalf("my room should return owner room from rooms metadata: %+v", resp)
}
if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 6 || resp.GetRoom().GetVisibleRegionId() != 1001 {
if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 10 || resp.GetRoom().GetVisibleRegionId() != 1001 {
t.Fatalf("my room card should use snapshot/meta fields: %+v", resp.GetRoom())
}
}
@ -374,7 +375,7 @@ func TestListRoomFeedsVisitedUsesJoinRoomFeed(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Visited Room",
VisibleRegionId: 1001,
@ -431,7 +432,7 @@ func TestListRoomFeedsFriendFollowingUsesGatewayRelations(t *testing.T) {
} {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(room.roomID, room.ownerID),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: room.title,
VisibleRegionId: room.regionID,
@ -524,7 +525,7 @@ func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Current Room",
}); err != nil {
@ -561,7 +562,7 @@ func TestGetCurrentRoomReturnsOwnerAfterCreate(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Current Room",
}); err != nil {
@ -591,7 +592,7 @@ func TestGetCurrentRoomReturnsEmptyAfterLeave(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Current Room",
}); err != nil {
@ -624,7 +625,7 @@ func TestGetRoomSnapshotRequiresViewerPresence(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Snapshot Room",
}); err != nil {
@ -661,7 +662,7 @@ func TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room:1001", 42),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
})
@ -678,9 +679,8 @@ func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) {
name string
req *roomv1.CreateRoomRequest
}{
{name: "seat_count", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-seat", 42), Mode: "social", RoomName: "Test Room"}},
{name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4, RoomName: "Test Room"}},
{name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 4, Mode: "social"}},
{name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 10, RoomName: "Test Room"}},
{name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 10, Mode: "social"}},
}
for _, test := range tests {
@ -696,6 +696,212 @@ func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) {
}
}
func TestCreateRoomUsesDefaultSeatConfig(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-default-seat-config", 42),
Mode: "social",
RoomName: "Default Seat Room",
})
if err != nil {
t.Fatalf("CreateRoom without seat_count should use default config: %v", err)
}
if got := len(resp.GetRoom().GetMicSeats()); got != 15 {
t.Fatalf("default room seat count mismatch: got %d", got)
}
}
func TestCreateRoomRejectsDisallowedSeatCount(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
for _, seatCount := range []int32{8, 12, 31, -1} {
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(fmt.Sprintf("room-disallowed-seat-%d", seatCount), 42),
SeatCount: seatCount,
Mode: "social",
RoomName: "Bad Seat Room",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("CreateRoom should reject seat_count=%d, got %v", seatCount, err)
}
}
}
func TestUpdateRoomProfileUpdatesProjectionAndExpandsSeats(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, outboxPublisher)
roomID := "room-profile-update"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 10,
Mode: "voice",
RoomName: "Old Room",
RoomAvatar: "https://cdn.example.com/old.png",
RoomDescription: "old desc",
VisibleRegionId: 1001,
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
resp, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
RoomName: stringPtr(" New Room "),
RoomAvatar: stringPtr(" https://cdn.example.com/new.png "),
RoomDescription: stringPtr(" new desc "),
SeatCount: int32Ptr(20),
})
if err != nil {
t.Fatalf("UpdateRoomProfile failed: %v", err)
}
snapshot := resp.GetRoom()
if !resp.GetResult().GetApplied() || snapshot.GetVersion() != 2 {
t.Fatalf("profile update should advance room version: %+v", resp.GetResult())
}
if got := len(snapshot.GetMicSeats()); got != 20 {
t.Fatalf("expanded seat count mismatch: got %d", got)
}
ext := snapshot.GetRoomExt()
if ext["title"] != "New Room" || ext["cover_url"] != "https://cdn.example.com/new.png" || ext["description"] != "new desc" {
t.Fatalf("room ext mismatch after profile update: %+v", ext)
}
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
if err != nil || !exists || meta.SeatCount != 20 {
t.Fatalf("room meta seat_count should update: exists=%v meta=%+v err=%v", exists, meta, err)
}
listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
ViewerUserId: 99,
VisibleRegionId: 1001,
Tab: "hot",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRooms failed: %v", err)
}
if len(listResp.GetRooms()) != 1 || listResp.GetRooms()[0].GetTitle() != "New Room" || listResp.GetRooms()[0].GetSeatCount() != 20 {
t.Fatalf("room list projection should reflect profile update: %+v", listResp.GetRooms())
}
if len(outboxPublisher.envelopes) != 1 || outboxPublisher.envelopes[0].GetEventType() != "RoomProfileUpdated" {
t.Fatalf("profile update should publish one RoomProfileUpdated outbox envelope: %+v", outboxPublisher.envelopes)
}
if len(syncPublisher.events) != 1 || syncPublisher.events[0].EventType != "room_profile_updated" || syncPublisher.events[0].Attributes["seat_count"] != "20" {
t.Fatalf("profile update should publish room_profile_updated: %+v", syncPublisher.events)
}
}
func TestUpdateRoomProfileRejectsInvalidSeatCount(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-profile-invalid-seat"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 10,
Mode: "voice",
RoomName: "Seat Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: int32Ptr(12),
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("UpdateRoomProfile should reject disallowed seat count, got %v", err)
}
}
func TestUpdateRoomProfileRejectsShrinkWhenRemovedSeatsBusy(t *testing.T) {
t.Run("occupied", func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-shrink-occupied"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 20}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: int32Ptr(10),
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("shrinking occupied high seat should conflict, got %v", err)
}
})
t.Run("locked", func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-shrink-locked"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 20, Locked: true}); err != nil {
t.Fatalf("SetMicSeatLock failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: int32Ptr(10),
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("shrinking locked high seat should conflict, got %v", err)
}
})
}
func TestUpdateRoomProfileRecoveryReplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-profile-replay"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Old Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
RoomName: stringPtr("Replay Room"),
RoomDescription: stringPtr("replayed desc"),
SeatCount: int32Ptr(20),
}); err != nil {
t.Fatalf("UpdateRoomProfile failed: %v", err)
}
directory.ForceExpire(roomRouteKeyForTest(roomID))
serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
if err != nil {
t.Fatalf("JoinRoom on recovered service failed: %v", err)
}
snapshot := joinResp.GetRoom()
if len(snapshot.GetMicSeats()) != 20 || snapshot.GetRoomExt()["title"] != "Replay Room" || snapshot.GetRoomExt()["description"] != "replayed desc" {
t.Fatalf("recovered profile state mismatch: seats=%d ext=%+v", len(snapshot.GetMicSeats()), snapshot.GetRoomExt())
}
}
// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。
func TestRoomLifecycleGiftAndGuards(t *testing.T) {
ctx := context.Background()
@ -710,7 +916,7 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) {
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
})
@ -817,7 +1023,7 @@ func TestRoomManagementPermissionsAndHostFlow(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-management-flow"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -888,7 +1094,7 @@ func TestMicPublishingConfirmAndStaleEvents(t *testing.T) {
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-publish-confirm"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -947,7 +1153,7 @@ func TestAudienceCanChangeOwnMicSeat(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-self-change-mic"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -981,7 +1187,7 @@ func TestApplyRTCEventConfirmsAudioAndClearsMicOnStopOrExit(t *testing.T) {
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-event"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1059,7 +1265,7 @@ func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) {
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-exit-replay"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1119,7 +1325,7 @@ func TestRTCRoomExitedRecoveryKeepsBusinessPresence(t *testing.T) {
serviceA := newRoomServiceWithClock("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-exit-recovery-presence"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1172,7 +1378,7 @@ func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-publish-timeout"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1212,7 +1418,7 @@ func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) {
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-old-session"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1262,7 +1468,7 @@ func TestMicSeatLockAndNoopIdempotency(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
roomID := "room-mic-lock"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1318,7 +1524,7 @@ func TestKickUnbanAndAdminRemoval(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-ban-unban"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1374,7 +1580,7 @@ func TestJoinRoomRejectsClosedRoom(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-closed"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
repository.SetRoomStatus(roomID, "closed")
@ -1399,7 +1605,7 @@ func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
roomID := "room-close-command"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Close Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Close Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1440,7 +1646,7 @@ func TestDrainingRejectsNewRoomCommands(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-draining"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Drain Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Drain Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
svc.MarkDraining()
@ -1448,7 +1654,7 @@ func TestDrainingRejectsNewRoomCommands(t *testing.T) {
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("draining service should reject new JoinRoom, got %v", err)
}
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 4, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 10, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("draining service should reject new CreateRoom, got %v", err)
}
}
@ -1459,7 +1665,7 @@ func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-command-conflict"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
@ -1496,7 +1702,7 @@ func TestRoomManagementRecoveryReplay(t *testing.T) {
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-management-replay"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1556,7 +1762,7 @@ func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -1598,7 +1804,7 @@ func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "voice",
RoomName: "Test Room",
}); err != nil {
@ -1637,7 +1843,7 @@ func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -1687,7 +1893,7 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
// node-a 先创建并推进房间状态snapshotEveryN=2 会让恢复需要结合快照和命令日志。
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -1761,7 +1967,7 @@ func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) {
}, syncPublisherA, outboxPublisher)
roomID := "room-lease-fencing"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Fence Room"}); err != nil {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Fence Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1797,7 +2003,7 @@ func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -1837,7 +2043,7 @@ func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -1892,7 +2098,7 @@ func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) {
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-stale-lease-takeover"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1926,7 +2132,7 @@ func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) {
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-timeout-lease-takeover"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
@ -1969,7 +2175,7 @@ func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
// 创建和进房先建立可送礼的基本房间态。
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -2115,7 +2321,7 @@ func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
SeatCount: 10,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
@ -2235,6 +2441,14 @@ func containsInt64(values []int64, target int64) bool {
return false
}
func stringPtr(value string) *string {
return &value
}
func int32Ptr(value int32) *int32 {
return &value
}
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
for _, event := range events {
if event.EventType == "room_user_left" && event.Attributes["reason"] == reason {

View File

@ -44,7 +44,7 @@ func TestCreateRoomMapsDomainErrorToGRPCReason(t *testing.T) {
_, err := server.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{
// room_id 缺失属于客户端参数错误,不能在 gRPC 边界丢失 reason 后变成 INTERNAL_ERROR。
SeatCount: 8,
SeatCount: 10,
Mode: "voice",
})
if err == nil {

View File

@ -49,6 +49,42 @@ func displayID(userID int64) string {
return fmt.Sprintf("%06d", 900000+userID%100000)
}
func TestCheckBusinessCapabilityRequiresActiveAgencyOwner(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 101, 10)
seedActiveUser(t, repository, 202, 10)
repository.PutAgency(hostdomain.Agency{
AgencyID: 301,
OwnerUserID: 101,
RegionID: 10,
Name: "Manager Agency",
Status: hostdomain.AgencyStatusActive,
})
svc := newHostService(repository, 1000, 2000)
allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant)
if err != nil {
t.Fatalf("CheckBusinessCapability for owner failed: %v", err)
}
if !allowed || reason != "" {
t.Fatalf("active agency owner should be allowed: allowed=%v reason=%q", allowed, reason)
}
allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant)
if err != nil {
t.Fatalf("CheckBusinessCapability for non-owner failed: %v", err)
}
if allowed || reason != "active_agency_owner_required" {
t.Fatalf("non-owner should be denied by stable reason: allowed=%v reason=%q", allowed, reason)
}
allowed, _, err = svc.CheckBusinessCapability(ctx, 101, "unknown_capability")
if !xerr.IsCode(err, xerr.InvalidArgument) || allowed {
t.Fatalf("unknown capability must fail closed: allowed=%v err=%v", allowed, err)
}
}
func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -137,6 +137,49 @@ func TestGetUserUsesRepository(t *testing.T) {
}
}
func TestBusinessUserLookupRestrictsSceneKeywordAndInactiveUsers(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.PutUser(completedUser(userdomain.User{
UserID: 700001,
CurrentDisplayUserID: "910001",
Username: "Alice Star",
Avatar: "https://cdn.example/alice.png",
RegionID: 1001,
Status: userdomain.StatusActive,
}))
repository.PutUser(completedUser(userdomain.User{
UserID: 700002,
CurrentDisplayUserID: "910002",
Username: "Alice Disabled",
RegionID: 1001,
Status: userdomain.StatusDisabled,
}))
svc := newUserService(repository)
byDisplayID, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "910001", 99)
if err != nil {
t.Fatalf("BusinessUserLookup by display id failed: %v", err)
}
if len(byDisplayID) != 1 || byDisplayID[0].UserID != 700001 || byDisplayID[0].DisplayUserID != "910001" || byDisplayID[0].Status != userdomain.StatusActive {
t.Fatalf("display id lookup mismatch: %+v", byDisplayID)
}
byNickname, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "Alice", 20)
if err != nil {
t.Fatalf("BusinessUserLookup by nickname failed: %v", err)
}
if len(byNickname) != 1 || byNickname[0].UserID != 700001 {
t.Fatalf("nickname lookup must exclude inactive users: %+v", byNickname)
}
if _, err := svc.BusinessUserLookup(context.Background(), "unknown_scene", "Alice", 20); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for unknown scene, got %v", err)
}
if _, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "A", 20); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for short keyword, got %v", err)
}
}
func TestGetMyProfileStatsReadsCounterTable(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)

View File

@ -111,6 +111,11 @@ func (r *Repository) ListFriendApplications(ctx context.Context, userID int64, d
return r.Repository.UserRepository().ListFriendApplications(ctx, userID, direction, page, pageSize)
}
// BusinessUserLookup 让测试 wrapper 继续满足业务场景用户查询接口。
func (r *Repository) BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
return r.Repository.UserRepository().BusinessUserLookup(ctx, keyword, numericKeyword, pageSize)
}
// BatchGetUsers 让测试 wrapper 继续满足业务层用户主数据接口。
func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) {
return r.Repository.UserRepository().BatchGetUsers(ctx, userIDs)

View File

@ -53,26 +53,26 @@ const (
)
type Resource struct {
AppCode string
ResourceID int64
ResourceCode string
ResourceType string
Name string
Status string
Grantable bool
GrantStrategy string
WalletAssetType string
WalletAssetAmount int64
UsageScopes []string
AssetURL string
PreviewURL string
AnimationURL string
MetadataJSON string
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
AppCode string
ResourceID int64
ResourceCode string
ResourceType string
Name string
Status string
Grantable bool
GrantStrategy string
WalletAssetType string
WalletAssetAmount int64
UsageScopes []string
AssetURL string
PreviewURL string
AnimationURL string
MetadataJSON string
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
ManagerGrantEnabled bool
}
@ -177,34 +177,34 @@ type ResourceGrant struct {
}
type ListResourcesQuery struct {
AppCode string
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
AppCode string
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
ManagerGrantOnly bool
}
type ResourceCommand struct {
AppCode string
ResourceID int64
ResourceCode string
ResourceType string
Name string
Status string
Grantable bool
GrantStrategy string
WalletAssetType string
WalletAssetAmount int64
UsageScopes []string
AssetURL string
PreviewURL string
AnimationURL string
MetadataJSON string
SortOrder int32
OperatorUserID int64
AppCode string
ResourceID int64
ResourceCode string
ResourceType string
Name string
Status string
Grantable bool
GrantStrategy string
WalletAssetType string
WalletAssetAmount int64
UsageScopes []string
AssetURL string
PreviewURL string
AnimationURL string
MetadataJSON string
SortOrder int32
OperatorUserID int64
ManagerGrantEnabled bool
}

View File

@ -818,6 +818,115 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
}
}
// TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。
func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_manager_enabled",
ResourceType: resourcedomain.TypeBadge,
Name: "Manager Enabled Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create manager enabled resource failed: %v", err)
}
disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_manager_disabled",
ResourceType: resourcedomain.TypeBadge,
Name: "Manager Disabled Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: false,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create manager disabled resource failed: %v", err)
}
if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "badge_disabled_status",
ResourceType: resourcedomain.TypeBadge,
Name: "Disabled Badge",
Status: resourcedomain.StatusDisabled,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
}); err != nil {
t.Fatalf("create disabled status resource failed: %v", err)
}
items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
ResourceType: resourcedomain.TypeBadge,
ManagerGrantOnly: true,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListResources manager grant only failed: %v", err)
}
if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID {
t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items)
}
grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-manager-grant-enabled",
TargetUserID: 43001,
ResourceID: enabled.ResourceID,
Quantity: 1,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if err != nil {
t.Fatalf("manager center grant enabled resource failed: %v", err)
}
if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 {
t.Fatalf("manager grant response mismatch: %+v", grant)
}
_, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-manager-grant-disabled",
TargetUserID: 43001,
ResourceID: disabledForManager.ResourceID,
Quantity: 1,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err)
}
}
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
_, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{
CommandID: "cmd-manager-grant-group",
TargetUserID: 43002,
GroupID: 101,
Reason: "manager center",
OperatorUserID: 91001,
GrantSource: resourcedomain.GrantSourceManagerCenter,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err)
}
}
// TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。
func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -257,20 +257,20 @@ func (s *Server) ListResourceGrants(ctx context.Context, req *walletv1.ListResou
func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand {
return resourcedomain.ResourceCommand{
AppCode: req.GetAppCode(),
ResourceCode: req.GetResourceCode(),
ResourceType: req.GetResourceType(),
Name: req.GetName(),
Status: req.GetStatus(),
Grantable: req.GetGrantable(),
GrantStrategy: req.GetGrantStrategy(),
WalletAssetType: req.GetWalletAssetType(),
WalletAssetAmount: req.GetWalletAssetAmount(),
UsageScopes: req.GetUsageScopes(),
AssetURL: req.GetAssetUrl(),
PreviewURL: req.GetPreviewUrl(),
AnimationURL: req.GetAnimationUrl(),
MetadataJSON: req.GetMetadataJson(),
AppCode: req.GetAppCode(),
ResourceCode: req.GetResourceCode(),
ResourceType: req.GetResourceType(),
Name: req.GetName(),
Status: req.GetStatus(),
Grantable: req.GetGrantable(),
GrantStrategy: req.GetGrantStrategy(),
WalletAssetType: req.GetWalletAssetType(),
WalletAssetAmount: req.GetWalletAssetAmount(),
UsageScopes: req.GetUsageScopes(),
AssetURL: req.GetAssetUrl(),
PreviewURL: req.GetPreviewUrl(),
AnimationURL: req.GetAnimationUrl(),
MetadataJSON: req.GetMetadataJson(),
SortOrder: req.GetSortOrder(),
OperatorUserID: req.GetOperatorUserId(),
ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled),
@ -279,23 +279,23 @@ func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedoma
func resourceCommandFromUpdate(req *walletv1.UpdateResourceRequest) resourcedomain.ResourceCommand {
command := resourceCommandFromCreate(&walletv1.CreateResourceRequest{
AppCode: req.GetAppCode(),
ResourceCode: req.GetResourceCode(),
ResourceType: req.GetResourceType(),
Name: req.GetName(),
Status: req.GetStatus(),
Grantable: req.GetGrantable(),
GrantStrategy: req.GetGrantStrategy(),
WalletAssetType: req.GetWalletAssetType(),
WalletAssetAmount: req.GetWalletAssetAmount(),
UsageScopes: req.GetUsageScopes(),
AssetUrl: req.GetAssetUrl(),
PreviewUrl: req.GetPreviewUrl(),
AnimationUrl: req.GetAnimationUrl(),
MetadataJson: req.GetMetadataJson(),
AppCode: req.GetAppCode(),
ResourceCode: req.GetResourceCode(),
ResourceType: req.GetResourceType(),
Name: req.GetName(),
Status: req.GetStatus(),
Grantable: req.GetGrantable(),
GrantStrategy: req.GetGrantStrategy(),
WalletAssetType: req.GetWalletAssetType(),
WalletAssetAmount: req.GetWalletAssetAmount(),
UsageScopes: req.GetUsageScopes(),
AssetUrl: req.GetAssetUrl(),
PreviewUrl: req.GetPreviewUrl(),
AnimationUrl: req.GetAnimationUrl(),
MetadataJson: req.GetMetadataJson(),
SortOrder: req.GetSortOrder(),
OperatorUserId: req.GetOperatorUserId(),
ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled),
ManagerGrantEnabled: req.ManagerGrantEnabled,
})
command.ResourceID = req.GetResourceId()
return command
@ -397,26 +397,26 @@ func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain.
func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource {
return &walletv1.Resource{
AppCode: appcode.Normalize(resource.AppCode),
ResourceId: resource.ResourceID,
ResourceCode: resource.ResourceCode,
ResourceType: resource.ResourceType,
Name: resource.Name,
Status: resource.Status,
Grantable: resource.Grantable,
GrantStrategy: resource.GrantStrategy,
WalletAssetType: resource.WalletAssetType,
WalletAssetAmount: resource.WalletAssetAmount,
UsageScopes: resource.UsageScopes,
AssetUrl: resource.AssetURL,
PreviewUrl: resource.PreviewURL,
AnimationUrl: resource.AnimationURL,
MetadataJson: resource.MetadataJSON,
SortOrder: resource.SortOrder,
CreatedByUserId: resource.CreatedByUserID,
UpdatedByUserId: resource.UpdatedByUserID,
CreatedAtMs: resource.CreatedAtMS,
UpdatedAtMs: resource.UpdatedAtMS,
AppCode: appcode.Normalize(resource.AppCode),
ResourceId: resource.ResourceID,
ResourceCode: resource.ResourceCode,
ResourceType: resource.ResourceType,
Name: resource.Name,
Status: resource.Status,
Grantable: resource.Grantable,
GrantStrategy: resource.GrantStrategy,
WalletAssetType: resource.WalletAssetType,
WalletAssetAmount: resource.WalletAssetAmount,
UsageScopes: resource.UsageScopes,
AssetUrl: resource.AssetURL,
PreviewUrl: resource.PreviewURL,
AnimationUrl: resource.AnimationURL,
MetadataJson: resource.MetadataJSON,
SortOrder: resource.SortOrder,
CreatedByUserId: resource.CreatedByUserID,
UpdatedByUserId: resource.UpdatedByUserID,
CreatedAtMs: resource.CreatedAtMS,
UpdatedAtMs: resource.UpdatedAtMS,
ManagerGrantEnabled: resource.ManagerGrantEnabled,
}
}