diff --git a/docs/api-test-harness.html b/docs/api-test-harness.html
index fe793116..34cd9916 100644
--- a/docs/api-test-harness.html
+++ b/docs/api-test-harness.html
@@ -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 };
diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml
index 8c717094..d05b2841 100644
--- a/docs/openapi/gateway.swagger.yaml
+++ b/docs/openapi/gateway.swagger.yaml
@@ -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: 目标用户 ID;gateway 会校验用户存在且 active。
+ resource_id:
+ type: string
+ description: 资源 ID;wallet-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"
diff --git a/docs/openapi/room.swagger.yaml b/docs/openapi/room.swagger.yaml
index d23f0790..89df1cb9 100644
--- a/docs/openapi/room.swagger.yaml
+++ b/docs/openapi/room.swagger.yaml
@@ -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:
diff --git a/docs/房间座位数配置.md b/docs/房间座位数配置.md
new file mode 100644
index 00000000..847c93e4
--- /dev/null
+++ b/docs/房间座位数配置.md
@@ -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` | 保存房间配置 |
diff --git a/docs/接口清单.md b/docs/接口清单.md
index 6be54cf6..a160bb58 100644
--- a/docs/接口清单.md
+++ b/docs/接口清单.md
@@ -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` | 上麦 |
diff --git a/docs/经理中心资源赠送架构.md b/docs/经理中心资源赠送架构.md
index a7d68cd0..40f0fab5 100644
--- a/docs/经理中心资源赠送架构.md
+++ b/docs/经理中心资源赠送架构.md
@@ -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`
diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md
index 4b745508..c6e514f7 100644
--- a/docs/语音房功能清单.md
+++ b/docs/语音房功能清单.md
@@ -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 配置周期调用 |
diff --git a/docs/语音房区域房间列表架构.md b/docs/语音房区域房间列表架构.md
index 9a9f5661..9b5688c1 100644
--- a/docs/语音房区域房间列表架构.md
+++ b/docs/语音房区域房间列表架构.md
@@ -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
}
],
diff --git a/docs/语音房客户端接口流程.md b/docs/语音房客户端接口流程.md
index ca391fdf..438f9045 100644
--- a/docs/语音房客户端接口流程.md
+++ b/docs/语音房客户端接口流程.md
@@ -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
+Content-Type: application/json
+```
+
+Request:
+
+```json
+{
+ "room_id": "lalu_xxx",
+ "command_id": "cmd_profile___",
+ "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
diff --git a/docs/资源组与礼物架构.md b/docs/资源组与礼物架构.md
index 607d29b8..3b1cbfbd 100644
--- a/docs/资源组与礼物架构.md
+++ b/docs/资源组与礼物架构.md
@@ -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
diff --git a/docs/进房与重连开发需求.md b/docs/进房与重连开发需求.md
index c3db52a6..29430e9b 100644
--- a/docs/进房与重连开发需求.md
+++ b/docs/进房与重连开发需求.md
@@ -72,7 +72,6 @@ Authorization: Bearer
```json
{
- "seat_count": 8,
"mode": "voice",
"room_name": "周末语音房",
"room_avatar": "",
@@ -80,7 +79,7 @@ Authorization: Bearer
}
```
-正式客户端不应该传 `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
"room_id": "room_1001",
"im_group_id": "room_1001",
"online_count": 26,
- "seat_count": 8,
+ "seat_count": 15,
"version": 2
},
"viewer": {
diff --git a/server/admin/docs/权限管理.md b/server/admin/docs/权限管理.md
index edab2baf..968933a4 100644
--- a/server/admin/docs/权限管理.md
+++ b/server/admin/docs/权限管理.md
@@ -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` | 公屏开关 |
diff --git a/server/admin/internal/modules/resource/dto.go b/server/admin/internal/modules/resource/dto.go
index 8b3f9517..9930b545 100644
--- a/server/admin/internal/modules/resource/dto.go
+++ b/server/admin/internal/modules/resource/dto.go
@@ -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(),
}
}
diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go
index 33e6c2fc..b01138b4 100644
--- a/server/admin/internal/modules/resource/request.go
+++ b/server/admin/internal/modules/resource/request.go
@@ -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),
}
}
diff --git a/server/admin/internal/modules/roomadmin/config.go b/server/admin/internal/modules/roomadmin/config.go
new file mode 100644
index 00000000..30214131
--- /dev/null
+++ b/server/admin/internal/modules/roomadmin/config.go
@@ -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
+}
diff --git a/server/admin/internal/modules/roomadmin/config_test.go b/server/admin/internal/modules/roomadmin/config_test.go
new file mode 100644
index 00000000..2b21e929
--- /dev/null
+++ b/server/admin/internal/modules/roomadmin/config_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/server/admin/internal/modules/roomadmin/handler.go b/server/admin/internal/modules/roomadmin/handler.go
index 9a7d79e6..b1b76ba0 100644
--- a/server/admin/internal/modules/roomadmin/handler.go
+++ b/server/admin/internal/modules/roomadmin/handler.go
@@ -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 {
diff --git a/server/admin/internal/modules/roomadmin/request.go b/server/admin/internal/modules/roomadmin/request.go
index 2d696585..e714ef80 100644
--- a/server/admin/internal/modules/roomadmin/request.go
+++ b/server/admin/internal/modules/roomadmin/request.go
@@ -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"`
+}
diff --git a/server/admin/internal/modules/roomadmin/routes.go b/server/admin/internal/modules/roomadmin/routes.go
index e9268613..6e04366a 100644
--- a/server/admin/internal/modules/roomadmin/routes.go
+++ b/server/admin/internal/modules/roomadmin/routes.go
@@ -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)
}
diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go
index 0ec3a77f..a6c48a4e 100644
--- a/server/admin/internal/repository/seed.go
+++ b/server/admin/internal/repository/seed.go
@@ -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
}
diff --git a/services/gateway-service/internal/transport/http/manager_center_handler.go b/services/gateway-service/internal/transport/http/manager_center_handler.go
new file mode 100644
index 00000000..05c004c5
--- /dev/null
+++ b/services/gateway-service/internal/transport/http/manager_center_handler.go
@@ -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"
+ }
+}
diff --git a/services/gateway-service/internal/transport/http/manager_center_handler_test.go b/services/gateway-service/internal/transport/http/manager_center_handler_test.go
new file mode 100644
index 00000000..03a3611d
--- /dev/null
+++ b/services/gateway-service/internal/transport/http/manager_center_handler_test.go
@@ -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)
+ }
+}
diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go
index 5601e4e0..2f9230e8 100644
--- a/services/gateway-service/internal/transport/http/response_test.go
+++ b/services/gateway-service/internal/transport/http/response_test.go
@@ -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")
diff --git a/services/gateway-service/internal/transport/http/room_handler.go b/services/gateway-service/internal/transport/http/room_handler.go
index 3abc6126..e876dd40 100644
--- a/services/gateway-service/internal/transport/http/room_handler.go
+++ b/services/gateway-service/internal/transport/http/room_handler.go
@@ -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 命令。
diff --git a/services/gateway-service/internal/transport/http/room_view.go b/services/gateway-service/internal/transport/http/room_view.go
index d14f726a..de1aadce 100644
--- a/services/gateway-service/internal/transport/http/room_view.go
+++ b/services/gateway-service/internal/transport/http/room_view.go
@@ -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)}
diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go
index 9b81dddb..8649eca5 100644
--- a/services/gateway-service/internal/transport/http/router.go
+++ b/services/gateway-service/internal/transport/http/router.go
@@ -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))
diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go
index 0c37cc32..8aa37f35 100644
--- a/services/room-service/internal/room/service/service_test.go
+++ b/services/room-service/internal/room/service/service_test.go
@@ -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 {
diff --git a/services/room-service/internal/transport/grpc/server_test.go b/services/room-service/internal/transport/grpc/server_test.go
index 2266bc1d..ee596d07 100644
--- a/services/room-service/internal/transport/grpc/server_test.go
+++ b/services/room-service/internal/transport/grpc/server_test.go
@@ -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 {
diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go
index bba4250e..c59315c4 100644
--- a/services/user-service/internal/service/host/service_test.go
+++ b/services/user-service/internal/service/host/service_test.go
@@ -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)
diff --git a/services/user-service/internal/service/user/service_test.go b/services/user-service/internal/service/user/service_test.go
index 6dd2e491..83f4572d 100644
--- a/services/user-service/internal/service/user/service_test.go
+++ b/services/user-service/internal/service/user/service_test.go
@@ -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)
diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go
index a141d4bf..5c6f91f6 100644
--- a/services/user-service/internal/testutil/mysqltest/mysqltest.go
+++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go
@@ -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)
diff --git a/services/wallet-service/internal/domain/resource/resource.go b/services/wallet-service/internal/domain/resource/resource.go
index ad132cd6..183725be 100644
--- a/services/wallet-service/internal/domain/resource/resource.go
+++ b/services/wallet-service/internal/domain/resource/resource.go
@@ -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
}
diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go
index d6380d2a..4ae12ee8 100644
--- a/services/wallet-service/internal/service/wallet/service_test.go
+++ b/services/wallet-service/internal/service/wallet/service_test.go
@@ -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)
diff --git a/services/wallet-service/internal/transport/grpc/resource.go b/services/wallet-service/internal/transport/grpc/resource.go
index cb6918b6..87089b36 100644
--- a/services/wallet-service/internal/transport/grpc/resource.go
+++ b/services/wallet-service/internal/transport/grpc/resource.go
@@ -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,
}
}