座位数
This commit is contained in:
parent
d1fbeb54a8
commit
82806c73a6
@ -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 };
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
91
docs/房间座位数配置.md
Normal file
91
docs/房间座位数配置.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 房间座位数配置
|
||||
|
||||
本文定义房间创建和 App 修改房间资料时的座位数规则,以及后台“房间配置”的边界。
|
||||
|
||||
## 当前规则
|
||||
|
||||
- 固定候选座位数为 `10/15/20/25/30`。
|
||||
- 后台只配置启用项和默认值,不允许新增候选数字。
|
||||
- 默认配置为:启用 `10/15/20/25/30`,默认 `15`。
|
||||
- 不使用 Redis 缓存。创建房间和修改房间资料是低频路径,`room-service` 每次直接读取 MySQL `room_seat_configs`,后台保存后立即生效。
|
||||
|
||||
## 数据表
|
||||
|
||||
`room_seat_configs` 是 App 级低频配置表:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `app_code` | App 隔离键,当前默认 `lalu` |
|
||||
| `allowed_seat_counts` | JSON 数组字符串,例如 `[10,15,20,25,30]` |
|
||||
| `default_seat_count` | 创建房间不传 `seat_count` 或传 `0` 时使用 |
|
||||
| `created_at_ms` / `updated_at_ms` | UTC epoch milliseconds |
|
||||
|
||||
`rooms.seat_count` 和 `room_list_entries.seat_count` 保存房间当前座位数;Room Cell 快照里的 `mic_seats` 是房间内麦位权威状态。
|
||||
|
||||
## 创建房间
|
||||
|
||||
`POST /api/v1/rooms/create` 的 `seat_count` 可省略:
|
||||
|
||||
- 不传或传 `0`:使用后台默认座位数,当前为 `15`。
|
||||
- 传正数:必须命中后台启用座位数。
|
||||
- 传负数或未启用数字:返回 `invalid_argument`。
|
||||
|
||||
创建成功会初始化对应数量的空麦位,并写入 `rooms`、snapshot、command log、outbox 和 `room_list_entries`。
|
||||
|
||||
## 修改房间资料
|
||||
|
||||
App 修改房间资料走:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/profile/update
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "cmd_profile_xxx",
|
||||
"room_name": "房间标题",
|
||||
"room_avatar": "https://cdn.example.com/room.png",
|
||||
"room_description": "简介",
|
||||
"seat_count": 20
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 只有当前在房间内的房主可以执行。
|
||||
- `room_name`、`room_avatar`、`room_description`、`seat_count` 都是可选字段;只提交需要修改的字段。
|
||||
- `seat_count` 必须是已启用正数,不能用 `0` 表示默认。
|
||||
- 扩大座位数时追加连续空麦位。
|
||||
- 缩小座位数时,高编号将被删除的麦位必须全部空闲、未锁、无发流会话;否则返回 `conflict`。
|
||||
- 成功后返回 `result`、`room`、`seats`、`server_time_ms`,客户端可立即刷新房间信息和麦位列表。
|
||||
- 成功命令写 `RoomProfileUpdated` outbox,并向 IM 发布 `room_profile_updated` 房间系统事件。
|
||||
|
||||
## 后台配置
|
||||
|
||||
后台“房间管理 / 房间配置”使用:
|
||||
|
||||
```http
|
||||
GET /api/v1/admin/rooms/config
|
||||
PUT /api/v1/admin/rooms/config
|
||||
```
|
||||
|
||||
响应 DTO:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidateSeatCounts": [10, 15, 20, 25, 30],
|
||||
"allowedSeatCounts": [10, 15, 20, 25, 30],
|
||||
"defaultSeatCount": 15,
|
||||
"updatedAtMs": 1778000000000
|
||||
}
|
||||
```
|
||||
|
||||
权限:
|
||||
|
||||
| 权限码 | 说明 |
|
||||
| --- | --- |
|
||||
| `room-config:view` | 查看房间配置页面和接口 |
|
||||
| `room-config:update` | 保存房间配置 |
|
||||
@ -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` | 上麦 |
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 配置周期调用 |
|
||||
|
||||
@ -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
|
||||
}
|
||||
],
|
||||
|
||||
@ -139,7 +139,7 @@ Response `data`:
|
||||
"chat_enabled": true,
|
||||
"heat": 1000,
|
||||
"online_count": 26,
|
||||
"seat_count": 8,
|
||||
"seat_count": 15,
|
||||
"occupied_seat_count": 2,
|
||||
"version": 2
|
||||
},
|
||||
@ -299,6 +299,39 @@ Client SDK action:
|
||||
Tencent RTC SDK enterRoom(strRoomId=resp.str_room_id, userId=resp.rtc_user_id, role=audience)
|
||||
```
|
||||
|
||||
### Update Room Profile
|
||||
|
||||
房主在房间内修改房间资料和座位数时调用:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/profile/update
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_xxx",
|
||||
"command_id": "cmd_profile_<room_id>_<user_id>_<nonce>",
|
||||
"room_name": "Live Room",
|
||||
"room_avatar": "https://cdn.example/room.png",
|
||||
"room_description": "welcome",
|
||||
"seat_count": 20
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- 只有当前在房间内的 owner 可以执行。
|
||||
- `room_name`、`room_avatar`、`room_description`、`seat_count` 都是可选字段;客户端只传本次要修改的字段。
|
||||
- `seat_count` 只能传后台已启用的 `10/15/20/25/30` 子集,当前默认配置全部启用。
|
||||
- 创建房间可以省略 `seat_count` 使用后台默认值;修改资料时不能传 `0`。
|
||||
- 缩小座位数时,被删除的高编号麦位必须全部空闲且未锁,否则返回 `conflict`,客户端需要先处理麦位再重试。
|
||||
- 成功响应返回最新 `room` 和 `seats`,客户端直接刷新房间标题、头像、简介和麦位列表。
|
||||
- 成功后房间系统消息事件名为 `room_profile_updated`。
|
||||
|
||||
### Heartbeat
|
||||
|
||||
```http
|
||||
|
||||
@ -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
|
||||
|
||||
@ -72,7 +72,6 @@ Authorization: Bearer <access_token>
|
||||
|
||||
```json
|
||||
{
|
||||
"seat_count": 8,
|
||||
"mode": "voice",
|
||||
"room_name": "周末语音房",
|
||||
"room_avatar": "",
|
||||
@ -80,7 +79,7 @@ Authorization: Bearer <access_token>
|
||||
}
|
||||
```
|
||||
|
||||
正式客户端不应该传 `room_id`、`command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`;room-service 使用该 actor 确定 owner 和默认 host。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。
|
||||
正式客户端不应该传 `room_id`、`command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`;room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。
|
||||
|
||||
响应必须包含:
|
||||
|
||||
@ -121,7 +120,7 @@ Authorization: Bearer <access_token>
|
||||
"room_id": "room_1001",
|
||||
"im_group_id": "room_1001",
|
||||
"online_count": 26,
|
||||
"seat_count": 8,
|
||||
"seat_count": 15,
|
||||
"version": 2
|
||||
},
|
||||
"viewer": {
|
||||
|
||||
@ -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` | 公屏开关 |
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
116
server/admin/internal/modules/roomadmin/config.go
Normal file
116
server/admin/internal/modules/roomadmin/config.go
Normal file
@ -0,0 +1,116 @@
|
||||
package roomadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
)
|
||||
|
||||
var roomSeatCountCandidates = []int32{10, 15, 20, 25, 30}
|
||||
|
||||
type RoomConfig struct {
|
||||
CandidateSeatCounts []int32 `json:"candidateSeatCounts"`
|
||||
AllowedSeatCounts []int32 `json:"allowedSeatCounts"`
|
||||
DefaultSeatCount int32 `json:"defaultSeatCount"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
// GetRoomConfig 读取房间座位数配置;缺行时返回产品默认值,不阻塞首次打开配置页。
|
||||
func (s *Service) GetRoomConfig(ctx context.Context) (RoomConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT allowed_seat_counts, default_seat_count, updated_at_ms
|
||||
FROM room_seat_configs
|
||||
WHERE app_code = ?
|
||||
`, appctx.FromContext(ctx))
|
||||
|
||||
var rawAllowed string
|
||||
config := defaultRoomConfig()
|
||||
if err := row.Scan(&rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return config, nil
|
||||
}
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
return normalizeRoomConfig(config)
|
||||
}
|
||||
|
||||
// UpdateRoomConfig 保存后台房间配置;候选集合固定,后台只控制启用项和默认值。
|
||||
func (s *Service) UpdateRoomConfig(ctx context.Context, req updateRoomConfigRequest) (RoomConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
config, err := normalizeRoomConfig(RoomConfig{
|
||||
AllowedSeatCounts: req.AllowedSeatCounts,
|
||||
DefaultSeatCount: req.DefaultSeatCount,
|
||||
UpdatedAtMS: time.Now().UTC().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
|
||||
if err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
now := config.UpdatedAtMS
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
allowed_seat_counts = VALUES(allowed_seat_counts),
|
||||
default_seat_count = VALUES(default_seat_count),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, appctx.FromContext(ctx), string(rawAllowed), config.DefaultSeatCount, now, now); err != nil {
|
||||
return RoomConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func defaultRoomConfig() RoomConfig {
|
||||
return RoomConfig{
|
||||
CandidateSeatCounts: append([]int32(nil), roomSeatCountCandidates...),
|
||||
AllowedSeatCounts: append([]int32(nil), roomSeatCountCandidates...),
|
||||
DefaultSeatCount: 15,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) {
|
||||
candidateSet := make(map[int32]bool, len(roomSeatCountCandidates))
|
||||
for _, candidate := range roomSeatCountCandidates {
|
||||
candidateSet[candidate] = true
|
||||
}
|
||||
seen := make(map[int32]bool, len(config.AllowedSeatCounts))
|
||||
allowed := make([]int32, 0, len(config.AllowedSeatCounts))
|
||||
for _, value := range config.AllowedSeatCounts {
|
||||
if !candidateSet[value] {
|
||||
return RoomConfig{}, fmt.Errorf("%w: 座位数只能选择 10、15、20、25、30", ErrInvalidArgument)
|
||||
}
|
||||
if seen[value] {
|
||||
return RoomConfig{}, fmt.Errorf("%w: 座位数不能重复", ErrInvalidArgument)
|
||||
}
|
||||
seen[value] = true
|
||||
allowed = append(allowed, value)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return RoomConfig{}, fmt.Errorf("%w: 至少启用一个座位数", ErrInvalidArgument)
|
||||
}
|
||||
sort.Slice(allowed, func(i int, j int) bool {
|
||||
return allowed[i] < allowed[j]
|
||||
})
|
||||
if !seen[config.DefaultSeatCount] {
|
||||
return RoomConfig{}, fmt.Errorf("%w: 默认座位数必须在已启用座位数中", ErrInvalidArgument)
|
||||
}
|
||||
config.CandidateSeatCounts = append([]int32(nil), roomSeatCountCandidates...)
|
||||
config.AllowedSeatCounts = allowed
|
||||
return config, nil
|
||||
}
|
||||
48
server/admin/internal/modules/roomadmin/config_test.go
Normal file
48
server/admin/internal/modules/roomadmin/config_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package roomadmin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRoomConfigSortsAndKeepsFixedCandidates(t *testing.T) {
|
||||
config, err := normalizeRoomConfig(RoomConfig{
|
||||
AllowedSeatCounts: []int32{30, 10, 20},
|
||||
DefaultSeatCount: 20,
|
||||
UpdatedAtMS: 123,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeRoomConfig failed: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(config.CandidateSeatCounts, []int32{10, 15, 20, 25, 30}) {
|
||||
t.Fatalf("candidate seat counts mismatch: %+v", config.CandidateSeatCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(config.AllowedSeatCounts, []int32{10, 20, 30}) {
|
||||
t.Fatalf("allowed seat counts should be sorted: %+v", config.AllowedSeatCounts)
|
||||
}
|
||||
if config.DefaultSeatCount != 20 || config.UpdatedAtMS != 123 {
|
||||
t.Fatalf("config fields changed unexpectedly: %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRoomConfigRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config RoomConfig
|
||||
}{
|
||||
{name: "empty", config: RoomConfig{AllowedSeatCounts: nil, DefaultSeatCount: 15}},
|
||||
{name: "duplicate", config: RoomConfig{AllowedSeatCounts: []int32{10, 10}, DefaultSeatCount: 10}},
|
||||
{name: "outside_candidate", config: RoomConfig{AllowedSeatCounts: []int32{12}, DefaultSeatCount: 12}},
|
||||
{name: "default_not_allowed", config: RoomConfig{AllowedSeatCounts: []int32{10, 20}, DefaultSeatCount: 15}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := normalizeRoomConfig(test.config)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("normalizeRoomConfig should reject %s with ErrInvalidArgument, got %v", test.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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 命令。
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user