diff --git a/docs/api-test-harness.html b/docs/api-test-harness.html index fe793116..34cd9916 100644 --- a/docs/api-test-harness.html +++ b/docs/api-test-harness.html @@ -2459,7 +2459,7 @@ if (p.endsWith('/users/me/country/change')) return { country: c.country || 'US' }; if (p.endsWith('/users/me/display-id/change')) return { new_display_user_id: c.displayUserId }; if (p.endsWith('/users/me/display-id/pretty/apply')) return { pretty_display_user_id: c.displayUserId }; - if (p.endsWith('/rooms/create')) return { seat_count: 8, mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' }; + if (p.endsWith('/rooms/create')) return { mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' }; if (p.endsWith('/rooms/join')) return { command_id: commandId('cmd_join'), room_id: c.roomId, role: 'audience' }; if (p.endsWith('/rooms/heartbeat')) return { command_id: commandId('cmd_heartbeat'), room_id: c.roomId }; if (p.endsWith('/rooms/leave')) return { command_id: commandId('cmd_leave'), room_id: c.roomId }; diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 8c717094..d05b2841 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -39,6 +39,10 @@ tags: description: App 钱包余额、币商转账和充值口径入口。 - name: resources description: App 资源库、资源组、礼物配置和我的资源入口。 + - name: manager-center + description: H5 经理中心入口;服务端校验业务身份,客户端不能自报经理权限。 + - name: business + description: 带业务场景权限裁剪的通用能力入口。 - name: messages description: App 消息 tab,后端只负责 system/activity inbox,用户私聊来自腾讯云 IM SDK。 securityDefinitions: @@ -1099,6 +1103,38 @@ paths: $ref: "#/responses/Internal" "502": $ref: "#/responses/UpstreamError" + /api/v1/rooms/profile/update: + post: + tags: + - rooms + summary: 修改房间资料和座位数 + operationId: updateRoomProfile + description: | + App 房主在房间内修改房间名、头像、简介和座位数。所有状态变更经过 room-service Room Cell;缩小座位数时,被删除的高编号麦位必须全部空闲且未锁。 + security: + - BearerAuth: [] + parameters: + - $ref: "#/parameters/RequestIDHeader" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/UpdateRoomProfileRequest" + responses: + "200": + description: 修改成功,`data` 返回最新房间信息和麦位列表。 + schema: + $ref: "#/definitions/UpdateRoomProfileEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/Forbidden" + "409": + $ref: "#/responses/Conflict" + "502": + $ref: "#/responses/UpstreamError" /api/v1/rooms/join: post: tags: @@ -1653,6 +1689,119 @@ paths: $ref: "#/responses/BadRequest" "502": $ref: "#/responses/UpstreamError" + /api/v1/manager-center/resource-grants/resources: + get: + tags: + - manager-center + summary: 经理中心可赠送资源列表 + operationId: listManagerGrantResources + description: gateway 先校验当前用户拥有 `manager_resource_grant` capability,再从 wallet-service 查询 `status=active AND grantable=TRUE AND manager_grant_enabled=TRUE` 的资源。 + security: + - BearerAuth: [] + parameters: + - $ref: "#/parameters/RequestIDHeader" + - name: resource_type + in: query + required: false + type: string + enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift] + - name: page + in: query + required: false + type: integer + format: int32 + - name: page_size + in: query + required: false + type: integer + format: int32 + maximum: 20 + responses: + "200": + description: 返回 H5 展示和提交所需的最小资源字段。 + schema: + $ref: "#/definitions/ManagerGrantResourceListEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/Forbidden" + "502": + $ref: "#/responses/UpstreamError" + /api/v1/manager-center/resource-grants: + post: + tags: + - manager-center + summary: 经理中心赠送单个资源 + operationId: grantManagerResource + description: 客户端只提交命令 ID、目标用户和资源 ID;数量固定为 1,有效期固定为 0,`grant_source` 固定为 `manager_center`。 + security: + - BearerAuth: [] + parameters: + - $ref: "#/parameters/RequestIDHeader" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ManagerResourceGrantRequest" + responses: + "200": + description: 赠送成功,返回资源赠送事实。 + schema: + $ref: "#/definitions/ManagerResourceGrantEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/Forbidden" + "404": + $ref: "#/responses/NotFound" + "409": + $ref: "#/responses/Conflict" + "502": + $ref: "#/responses/UpstreamError" + /api/v1/business/users/lookup: + get: + tags: + - business + summary: 业务场景用户查询 + operationId: lookupBusinessUsers + description: 首版只支持 `scene=manager_resource_grant`。user-service 会按 scene 校验 actor 能力,并只返回同 app_code 的 active 用户最小资料。 + security: + - BearerAuth: [] + parameters: + - $ref: "#/parameters/RequestIDHeader" + - name: scene + in: query + required: true + type: string + enum: [manager_resource_grant] + - name: keyword + in: query + required: true + type: string + description: 展示短号、用户 ID 或至少 2 个字符的昵称片段。 + - name: page_size + in: query + required: false + type: integer + format: int32 + maximum: 20 + responses: + "200": + description: 查询成功。 + schema: + $ref: "#/definitions/BusinessUserLookupEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + $ref: "#/responses/Forbidden" + "502": + $ref: "#/responses/UpstreamError" /api/v1/resource-groups/{group_id}: get: tags: @@ -3119,6 +3268,121 @@ definitions: page_size: type: integer format: int32 + ManagerGrantResourceData: + type: object + properties: + resource_id: + type: string + description: 资源 ID;按字符串返回避免 64 位整数在客户端丢精度。 + resource_type: + type: string + enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift] + name: + type: string + asset_url: + type: string + preview_url: + type: string + wallet_asset_type: + type: string + wallet_asset_amount: + type: integer + format: int64 + sort_order: + type: integer + format: int32 + ManagerGrantResourceListData: + type: object + properties: + items: + type: array + items: + $ref: "#/definitions/ManagerGrantResourceData" + total: + type: integer + format: int64 + page: + type: integer + format: int32 + page_size: + type: integer + format: int32 + BusinessUserLookupItem: + type: object + properties: + user_id: + type: string + description: 用户 ID;按字符串返回避免 64 位整数在客户端丢精度。 + display_user_id: + type: string + username: + type: string + avatar: + type: string + status: + type: string + enum: [active, disabled, banned, unspecified] + region_id: + type: integer + format: int64 + region_code: + type: string + region_name: + type: string + BusinessUserLookupData: + type: object + properties: + items: + type: array + items: + $ref: "#/definitions/BusinessUserLookupItem" + total: + type: integer + format: int32 + page_size: + type: integer + format: int32 + ManagerResourceGrantRequest: + type: object + required: + - command_id + - target_user_id + - resource_id + properties: + command_id: + type: string + description: 客户端幂等命令 ID;同一业务重试必须复用。 + target_user_id: + type: string + description: 目标用户 ID;gateway 会校验用户存在且 active。 + resource_id: + type: string + description: 资源 ID;wallet-service 会在事务内复验 `manager_grant_enabled`。 + reason: + type: string + default: manager_center + description: 不接受 `quantity`、`duration_ms`、`wallet_asset_amount` 等客户端计算字段。 + ManagerResourceGrantData: + type: object + properties: + grant_id: + type: string + command_id: + type: string + target_user_id: + type: string + resource_id: + type: string + status: + type: string + grant_source: + type: string + enum: [manager_center] + operator_user_id: + type: string + created_at_ms: + type: integer + format: int64 ResourceGroupItemData: type: object properties: @@ -3358,15 +3622,14 @@ definitions: CreateRoomRequest: type: object required: - - seat_count - mode - room_name properties: seat_count: type: integer format: int32 - minimum: 1 - description: 麦位数量,必须大于 0。 + minimum: 0 + description: 麦位数量,可省略;不传或传 0 时使用后台房间配置默认值,正数必须命中后台启用座位数。 mode: type: string minLength: 1 @@ -3384,6 +3647,39 @@ definitions: type: string maxLength: 512 description: 房间简介,可选;只写入 `room_ext.description`,不进入房间高频核心字段。 + UpdateRoomProfileRequest: + type: object + required: + - room_id + - command_id + properties: + room_id: + type: string + command_id: + type: string + room_name: + type: string + minLength: 1 + maxLength: 128 + description: 可选;传入时更新房间标题。 + room_avatar: + type: string + maxLength: 512 + description: 可选;传空字符串时 room-service 恢复系统默认头像。 + room_description: + type: string + maxLength: 512 + description: 可选;允许传空字符串清空简介。 + seat_count: + type: integer + format: int32 + enum: + - 10 + - 15 + - 20 + - 25 + - 30 + description: 可选;必须是后台已启用座位数。缩容时高编号麦位必须空闲且未锁。 JoinRoomRequest: type: object properties: @@ -3994,6 +4290,25 @@ definitions: server_time_ms: type: integer format: int64 + UpdateRoomProfileResponse: + type: object + required: + - result + - room + - seats + - server_time_ms + properties: + result: + $ref: "#/definitions/CommandResult" + room: + $ref: "#/definitions/RoomInitialData" + seats: + type: array + items: + $ref: "#/definitions/RoomSeatData" + server_time_ms: + type: integer + format: int64 RoomHeartbeatResponse: type: object properties: @@ -4278,6 +4593,27 @@ definitions: properties: data: $ref: "#/definitions/ResourceListData" + ManagerGrantResourceListEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/ManagerGrantResourceListData" + BusinessUserLookupEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/BusinessUserLookupData" + ManagerResourceGrantEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/ManagerResourceGrantData" ResourceGroupEnvelope: allOf: - $ref: "#/definitions/GatewayOKEnvelopeBase" @@ -4313,6 +4649,13 @@ definitions: properties: data: $ref: "#/definitions/CreateRoomResponse" + UpdateRoomProfileEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/UpdateRoomProfileResponse" RoomListEnvelope: allOf: - $ref: "#/definitions/GatewayOKEnvelopeBase" diff --git a/docs/openapi/room.swagger.yaml b/docs/openapi/room.swagger.yaml index d23f0790..89df1cb9 100644 --- a/docs/openapi/room.swagger.yaml +++ b/docs/openapi/room.swagger.yaml @@ -49,6 +49,26 @@ paths: $ref: "#/definitions/CreateRoomResponse" default: $ref: "#/responses/GRPCError" + /hyapp.room.v1.RoomCommandService/UpdateRoomProfile: + post: + tags: + - room-command + summary: 修改房间资料和座位数 + operationId: roomUpdateRoomProfile + x-grpc-full-method: /hyapp.room.v1.RoomCommandService/UpdateRoomProfile + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/UpdateRoomProfileRequest" + responses: + "200": + description: 修改成功;资料、座位数、snapshot、command log 和 outbox 已经通过 Room Cell 提交。 + schema: + $ref: "#/definitions/UpdateRoomProfileResponse" + default: + $ref: "#/responses/GRPCError" /hyapp.room.v1.RoomCommandService/JoinRoom: post: tags: @@ -613,7 +633,6 @@ definitions: description: 同一个 `meta.actor_user_id` 只能作为 owner 创建一个房间;owner/host 不接受外部字段自报。 required: - meta - - seat_count - mode - room_name properties: @@ -622,8 +641,8 @@ definitions: seat_count: type: integer format: int32 - minimum: 1 - description: 麦位数量,必须大于 0。 + minimum: 0 + description: 麦位数量;0 或省略时使用后台默认值,正数必须命中后台启用座位数。 mode: type: string minLength: 1 @@ -648,6 +667,42 @@ definitions: $ref: "#/definitions/CommandResult" room: $ref: "#/definitions/RoomSnapshot" + UpdateRoomProfileRequest: + type: object + required: + - meta + properties: + meta: + $ref: "#/definitions/RequestMeta" + room_name: + type: string + maxLength: 128 + description: proto3 optional;传入时更新 RoomExt title。 + room_avatar: + type: string + maxLength: 512 + description: proto3 optional;传空字符串时恢复系统默认头像。 + room_description: + type: string + maxLength: 512 + description: proto3 optional;允许传空字符串清空简介。 + seat_count: + type: integer + format: int32 + enum: + - 10 + - 15 + - 20 + - 25 + - 30 + description: proto3 optional;必须是后台启用座位数,缩容要求被删除麦位空闲且未锁。 + UpdateRoomProfileResponse: + type: object + properties: + result: + $ref: "#/definitions/CommandResult" + room: + $ref: "#/definitions/RoomSnapshot" JoinRoomRequest: type: object properties: diff --git a/docs/房间座位数配置.md b/docs/房间座位数配置.md new file mode 100644 index 00000000..847c93e4 --- /dev/null +++ b/docs/房间座位数配置.md @@ -0,0 +1,91 @@ +# 房间座位数配置 + +本文定义房间创建和 App 修改房间资料时的座位数规则,以及后台“房间配置”的边界。 + +## 当前规则 + +- 固定候选座位数为 `10/15/20/25/30`。 +- 后台只配置启用项和默认值,不允许新增候选数字。 +- 默认配置为:启用 `10/15/20/25/30`,默认 `15`。 +- 不使用 Redis 缓存。创建房间和修改房间资料是低频路径,`room-service` 每次直接读取 MySQL `room_seat_configs`,后台保存后立即生效。 + +## 数据表 + +`room_seat_configs` 是 App 级低频配置表: + +| 字段 | 说明 | +| --- | --- | +| `app_code` | App 隔离键,当前默认 `lalu` | +| `allowed_seat_counts` | JSON 数组字符串,例如 `[10,15,20,25,30]` | +| `default_seat_count` | 创建房间不传 `seat_count` 或传 `0` 时使用 | +| `created_at_ms` / `updated_at_ms` | UTC epoch milliseconds | + +`rooms.seat_count` 和 `room_list_entries.seat_count` 保存房间当前座位数;Room Cell 快照里的 `mic_seats` 是房间内麦位权威状态。 + +## 创建房间 + +`POST /api/v1/rooms/create` 的 `seat_count` 可省略: + +- 不传或传 `0`:使用后台默认座位数,当前为 `15`。 +- 传正数:必须命中后台启用座位数。 +- 传负数或未启用数字:返回 `invalid_argument`。 + +创建成功会初始化对应数量的空麦位,并写入 `rooms`、snapshot、command log、outbox 和 `room_list_entries`。 + +## 修改房间资料 + +App 修改房间资料走: + +```http +POST /api/v1/rooms/profile/update +``` + +请求: + +```json +{ + "room_id": "lalu_xxx", + "command_id": "cmd_profile_xxx", + "room_name": "房间标题", + "room_avatar": "https://cdn.example.com/room.png", + "room_description": "简介", + "seat_count": 20 +} +``` + +规则: + +- 只有当前在房间内的房主可以执行。 +- `room_name`、`room_avatar`、`room_description`、`seat_count` 都是可选字段;只提交需要修改的字段。 +- `seat_count` 必须是已启用正数,不能用 `0` 表示默认。 +- 扩大座位数时追加连续空麦位。 +- 缩小座位数时,高编号将被删除的麦位必须全部空闲、未锁、无发流会话;否则返回 `conflict`。 +- 成功后返回 `result`、`room`、`seats`、`server_time_ms`,客户端可立即刷新房间信息和麦位列表。 +- 成功命令写 `RoomProfileUpdated` outbox,并向 IM 发布 `room_profile_updated` 房间系统事件。 + +## 后台配置 + +后台“房间管理 / 房间配置”使用: + +```http +GET /api/v1/admin/rooms/config +PUT /api/v1/admin/rooms/config +``` + +响应 DTO: + +```json +{ + "candidateSeatCounts": [10, 15, 20, 25, 30], + "allowedSeatCounts": [10, 15, 20, 25, 30], + "defaultSeatCount": 15, + "updatedAtMs": 1778000000000 +} +``` + +权限: + +| 权限码 | 说明 | +| --- | --- | +| `room-config:view` | 查看房间配置页面和接口 | +| `room-config:update` | 保存房间配置 | diff --git a/docs/接口清单.md b/docs/接口清单.md index 6be54cf6..a160bb58 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -53,6 +53,7 @@ | GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 | | GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 | | POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 | +| POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 | | POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token | | POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence | | POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 | @@ -106,6 +107,8 @@ | GET | `/api/v1/admin/app-config/h5-links` | admin | `listH5Links` | listH5Links | | PUT | `/api/v1/admin/app-config/h5-links` | admin | `updateH5Links` | updateH5Links | | GET | `/api/v1/admin/apps` | admin | `listApps` | listApps | +| GET | `/api/v1/admin/rooms/config` | admin | `getRoomConfig` | 查询房间配置 | +| PUT | `/api/v1/admin/rooms/config` | admin | `updateRoomConfig` | 保存房间配置 | | GET | `/api/v1/admin/bd-leaders` | admin | `listBDLeaders` | listBDLeaders | | POST | `/api/v1/admin/bd-leaders` | admin | `createBDLeader` | createBDLeader | | PATCH | `/api/v1/admin/bd-leaders/{user_id}/status` | admin | `setBDLeaderStatus` | setBDLeaderStatus | @@ -261,6 +264,7 @@ | POST | `/grpc.health.v1.Health/Check` | health | `userHealthCheck` | 标准 gRPC health check | | POST | `/grpc.health.v1.Health/Watch` | health | `userHealthWatch` | 标准 gRPC health watch | | POST | `/hyapp.room.v1.RoomCommandService/CreateRoom` | room-command | `roomCreateRoom` | 创建房间 | +| POST | `/hyapp.room.v1.RoomCommandService/UpdateRoomProfile` | room-command | `roomUpdateRoomProfile` | 修改房间资料和座位数 | | POST | `/hyapp.room.v1.RoomCommandService/JoinRoom` | room-command | `roomJoinRoom` | 加入房间业务 presence | | POST | `/hyapp.room.v1.RoomCommandService/LeaveRoom` | room-command | `roomLeaveRoom` | 离开房间业务 presence | | POST | `/hyapp.room.v1.RoomCommandService/MicUp` | room-command | `roomMicUp` | 上麦 | diff --git a/docs/经理中心资源赠送架构.md b/docs/经理中心资源赠送架构.md index a7d68cd0..40f0fab5 100644 --- a/docs/经理中心资源赠送架构.md +++ b/docs/经理中心资源赠送架构.md @@ -1,4 +1,4 @@ -# Manager Center Resource Grant Architecture +# 经理中心资源赠送架构 本文定义后台资源列表的“赠送权限”开关、H5 经理中心资源赠送、以及可复用用户查询接口的服务边界。资源 catalog、用户资源权益和赠送事实仍按 [Resource And Resource Group Architecture](./资源组与礼物架构.md) 归属 `wallet-service`;经理、Agency、BD 等业务身份仍按 [Host, Agency And BD App Architecture](./主播公会BD架构.md) 归属 `user-service`。 @@ -45,7 +45,7 @@ ```sql ALTER TABLE resources ADD COLUMN manager_grant_enabled BOOLEAN NOT NULL DEFAULT TRUE, - ADD KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, sort_order); + ADD KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order); ``` 字段语义: @@ -66,7 +66,7 @@ POST /api/v1/admin/resources/{resource_id}/enable POST /api/v1/admin/resources/{resource_id}/disable ``` -DTO/RPC 需要补充: +DTO/RPC 字段: - `wallet.v1.Resource.manager_grant_enabled` - `CreateResourceRequest.manager_grant_enabled` diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index 4b745508..c6e514f7 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -52,7 +52,8 @@ | Feature | Status | Current Evidence | Remaining Work | | --- | --- | --- | --- | -| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、seat_count、mode;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 | +| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode;`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 | +| 修改房间资料和座位数 | `DONE` | `UpdateRoomProfile` 由房间内 owner 执行;资料和座位数变更经过 Room Cell,缩容要求被删除麦位空闲且未锁 | App 编辑页和真实 IM `room_profile_updated` 展示联调 | | owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 | | 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 | | 显式心跳刷新 | `DONE` | `/api/v1/rooms/heartbeat` 和 `RoomHeartbeat` 只刷新已有 `last_seen_at_ms` | 客户端按 stale timeout 配置周期调用 | diff --git a/docs/语音房区域房间列表架构.md b/docs/语音房区域房间列表架构.md index 9a9f5661..9b5688c1 100644 --- a/docs/语音房区域房间列表架构.md +++ b/docs/语音房区域房间列表架构.md @@ -297,7 +297,7 @@ GET /api/v1/rooms?tab=new&cursor=&limit=20 "status": "active", "heat": 1000, "online_count": 26, - "seat_count": 8, + "seat_count": 15, "occupied_seat_count": 2 } ], diff --git a/docs/语音房客户端接口流程.md b/docs/语音房客户端接口流程.md index ca391fdf..438f9045 100644 --- a/docs/语音房客户端接口流程.md +++ b/docs/语音房客户端接口流程.md @@ -139,7 +139,7 @@ Response `data`: "chat_enabled": true, "heat": 1000, "online_count": 26, - "seat_count": 8, + "seat_count": 15, "occupied_seat_count": 2, "version": 2 }, @@ -299,6 +299,39 @@ Client SDK action: Tencent RTC SDK enterRoom(strRoomId=resp.str_room_id, userId=resp.rtc_user_id, role=audience) ``` +### Update Room Profile + +房主在房间内修改房间资料和座位数时调用: + +```http +POST /api/v1/rooms/profile/update +Authorization: Bearer +Content-Type: application/json +``` + +Request: + +```json +{ + "room_id": "lalu_xxx", + "command_id": "cmd_profile___", + "room_name": "Live Room", + "room_avatar": "https://cdn.example/room.png", + "room_description": "welcome", + "seat_count": 20 +} +``` + +Rules: + +- 只有当前在房间内的 owner 可以执行。 +- `room_name`、`room_avatar`、`room_description`、`seat_count` 都是可选字段;客户端只传本次要修改的字段。 +- `seat_count` 只能传后台已启用的 `10/15/20/25/30` 子集,当前默认配置全部启用。 +- 创建房间可以省略 `seat_count` 使用后台默认值;修改资料时不能传 `0`。 +- 缩小座位数时,被删除的高编号麦位必须全部空闲且未锁,否则返回 `conflict`,客户端需要先处理麦位再重试。 +- 成功响应返回最新 `room` 和 `seats`,客户端直接刷新房间标题、头像、简介和麦位列表。 +- 成功后房间系统消息事件名为 `room_profile_updated`。 + ### Heartbeat ```http diff --git a/docs/资源组与礼物架构.md b/docs/资源组与礼物架构.md index 607d29b8..3b1cbfbd 100644 --- a/docs/资源组与礼物架构.md +++ b/docs/资源组与礼物架构.md @@ -167,6 +167,7 @@ resources( name VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL, grantable BOOLEAN NOT NULL DEFAULT TRUE, + manager_grant_enabled BOOLEAN NOT NULL DEFAULT TRUE, grant_strategy VARCHAR(32) NOT NULL, wallet_asset_type VARCHAR(32) NULL, wallet_asset_amount BIGINT NOT NULL DEFAULT 0, @@ -181,7 +182,8 @@ resources( created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, UNIQUE KEY uk_resources_code (app_code, resource_code), - KEY idx_resources_type_status (app_code, resource_type, status, sort_order) + KEY idx_resources_type_status (app_code, resource_type, status, sort_order), + KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order) ); resource_groups( @@ -330,6 +332,8 @@ user_resource_equipment( ); ``` +`manager_grant_enabled` 只控制经理中心 H5 是否可赠送该资源。后台仍可按后台权限管理资源;App 普通资源列表不暴露这个字段。`grant_source='manager_center'` 的 `GrantResource` 必须在 wallet-service 事务内重新校验 `status=active`、`grantable=TRUE` 和 `manager_grant_enabled=TRUE`,不能只信 gateway 的列表过滤。 + `wallet_gift_prices` 保持账务字段,但必须和 `gift_configs.gift_id` 对齐。后续实现可以在 `wallet_gift_prices` 增加 `gift_config_version` 或在账务 metadata 中保存 `gift_configs` 快照;不要把礼物资源字段塞进账务价格表。 ## Admin API Boundary diff --git a/docs/进房与重连开发需求.md b/docs/进房与重连开发需求.md index c3db52a6..29430e9b 100644 --- a/docs/进房与重连开发需求.md +++ b/docs/进房与重连开发需求.md @@ -72,7 +72,6 @@ Authorization: Bearer ```json { - "seat_count": 8, "mode": "voice", "room_name": "周末语音房", "room_avatar": "", @@ -80,7 +79,7 @@ Authorization: Bearer } ``` -正式客户端不应该传 `room_id`、`command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`;room-service 使用该 actor 确定 owner 和默认 host。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。 +正式客户端不应该传 `room_id`、`command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`;room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`。 响应必须包含: @@ -121,7 +120,7 @@ Authorization: Bearer "room_id": "room_1001", "im_group_id": "room_1001", "online_count": 26, - "seat_count": 8, + "seat_count": 15, "version": 2 }, "viewer": { diff --git a/server/admin/docs/权限管理.md b/server/admin/docs/权限管理.md index edab2baf..968933a4 100644 --- a/server/admin/docs/权限管理.md +++ b/server/admin/docs/权限管理.md @@ -261,6 +261,8 @@ | `room:view` | `menu` | 房间列表、详情、房间状态 | | `room:update` | `button` | 后台修改房间展示资料、模式、状态、区域 | | `room:delete` | `button` | 后台删除房间 | +| `room-config:view` | `menu` | 房间配置页面、读取座位数配置 | +| `room-config:update` | `button` | 保存启用座位数和默认座位数 | | `room:create` | `button` | 后台创建房间 | | `room:mic` | `button` | 上下麦、换麦、锁麦 | | `room:chat` | `button` | 公屏开关 | diff --git a/server/admin/internal/modules/resource/dto.go b/server/admin/internal/modules/resource/dto.go index 8b3f9517..9930b545 100644 --- a/server/admin/internal/modules/resource/dto.go +++ b/server/admin/internal/modules/resource/dto.go @@ -3,27 +3,27 @@ package resource import walletv1 "hyapp.local/api/proto/wallet/v1" type resourceDTO struct { - AppCode string `json:"appCode"` - ResourceID int64 `json:"resourceId"` - ResourceCode string `json:"resourceCode"` - ResourceType string `json:"resourceType"` - Name string `json:"name"` - Status string `json:"status"` - Grantable bool `json:"grantable"` - ManagerGrantEnabled bool `json:"managerGrantEnabled"` - GrantStrategy string `json:"grantStrategy"` - WalletAssetType string `json:"walletAssetType"` - WalletAssetAmount int64 `json:"walletAssetAmount"` - UsageScopes []string `json:"usageScopes"` - AssetURL string `json:"assetUrl"` - PreviewURL string `json:"previewUrl"` - AnimationURL string `json:"animationUrl"` - MetadataJSON string `json:"metadataJson"` - SortOrder int32 `json:"sortOrder"` - CreatedByUserID int64 `json:"createdByUserId"` - UpdatedByUserID int64 `json:"updatedByUserId"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` + AppCode string `json:"appCode"` + ResourceID int64 `json:"resourceId"` + ResourceCode string `json:"resourceCode"` + ResourceType string `json:"resourceType"` + Name string `json:"name"` + Status string `json:"status"` + Grantable bool `json:"grantable"` + ManagerGrantEnabled bool `json:"managerGrantEnabled"` + GrantStrategy string `json:"grantStrategy"` + WalletAssetType string `json:"walletAssetType"` + WalletAssetAmount int64 `json:"walletAssetAmount"` + UsageScopes []string `json:"usageScopes"` + AssetURL string `json:"assetUrl"` + PreviewURL string `json:"previewUrl"` + AnimationURL string `json:"animationUrl"` + MetadataJSON string `json:"metadataJson"` + SortOrder int32 `json:"sortOrder"` + CreatedByUserID int64 `json:"createdByUserId"` + UpdatedByUserID int64 `json:"updatedByUserId"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` } type resourceGroupItemDTO struct { @@ -114,27 +114,27 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO { return resourceDTO{} } return resourceDTO{ - AppCode: item.GetAppCode(), - ResourceID: item.GetResourceId(), - ResourceCode: item.GetResourceCode(), - ResourceType: item.GetResourceType(), - Name: item.GetName(), - Status: item.GetStatus(), - Grantable: item.GetGrantable(), + AppCode: item.GetAppCode(), + ResourceID: item.GetResourceId(), + ResourceCode: item.GetResourceCode(), + ResourceType: item.GetResourceType(), + Name: item.GetName(), + Status: item.GetStatus(), + Grantable: item.GetGrantable(), ManagerGrantEnabled: item.GetManagerGrantEnabled(), - GrantStrategy: item.GetGrantStrategy(), - WalletAssetType: item.GetWalletAssetType(), - WalletAssetAmount: item.GetWalletAssetAmount(), - UsageScopes: item.GetUsageScopes(), - AssetURL: item.GetAssetUrl(), - PreviewURL: item.GetPreviewUrl(), - AnimationURL: item.GetAnimationUrl(), - MetadataJSON: item.GetMetadataJson(), - SortOrder: item.GetSortOrder(), - CreatedByUserID: item.GetCreatedByUserId(), - UpdatedByUserID: item.GetUpdatedByUserId(), - CreatedAtMS: item.GetCreatedAtMs(), - UpdatedAtMS: item.GetUpdatedAtMs(), + GrantStrategy: item.GetGrantStrategy(), + WalletAssetType: item.GetWalletAssetType(), + WalletAssetAmount: item.GetWalletAssetAmount(), + UsageScopes: item.GetUsageScopes(), + AssetURL: item.GetAssetUrl(), + PreviewURL: item.GetPreviewUrl(), + AnimationURL: item.GetAnimationUrl(), + MetadataJSON: item.GetMetadataJson(), + SortOrder: item.GetSortOrder(), + CreatedByUserID: item.GetCreatedByUserId(), + UpdatedByUserID: item.GetUpdatedByUserId(), + CreatedAtMS: item.GetCreatedAtMs(), + UpdatedAtMS: item.GetUpdatedAtMs(), } } diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index 33e6c2fc..b01138b4 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -13,15 +13,15 @@ import ( const dayMillis int64 = 24 * 60 * 60 * 1000 type resourceRequest struct { - ResourceCode string `json:"resourceCode"` - ResourceType string `json:"resourceType"` - Name string `json:"name"` - Status string `json:"status"` - Amount int64 `json:"amount"` - ManagerGrantEnabled *bool `json:"managerGrantEnabled"` - AssetURL string `json:"assetUrl"` - PreviewURL string `json:"previewUrl"` - SortOrder int32 `json:"sortOrder"` + ResourceCode string `json:"resourceCode"` + ResourceType string `json:"resourceType"` + Name string `json:"name"` + Status string `json:"status"` + Amount int64 `json:"amount"` + ManagerGrantEnabled *bool `json:"managerGrantEnabled"` + AssetURL string `json:"assetUrl"` + PreviewURL string `json:"previewUrl"` + SortOrder int32 `json:"sortOrder"` } type resourceGroupRequest struct { @@ -82,24 +82,24 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) return &walletv1.CreateResourceRequest{ - RequestId: middleware.CurrentRequestID(c), - AppCode: appctx.FromContext(c.Request.Context()), - ResourceCode: strings.TrimSpace(r.ResourceCode), - ResourceType: resourceType, - Name: strings.TrimSpace(r.Name), - Status: strings.TrimSpace(r.Status), - Grantable: true, + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ResourceCode: strings.TrimSpace(r.ResourceCode), + ResourceType: resourceType, + Name: strings.TrimSpace(r.Name), + Status: strings.TrimSpace(r.Status), + Grantable: true, ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled), - GrantStrategy: resourceGrantStrategy(resourceType), - WalletAssetType: walletAssetType, - WalletAssetAmount: walletAssetAmount, - UsageScopes: []string{resourceType}, - AssetUrl: strings.TrimSpace(r.AssetURL), - PreviewUrl: strings.TrimSpace(r.PreviewURL), - AnimationUrl: "", - MetadataJson: "{}", - SortOrder: r.SortOrder, - OperatorUserId: actorID(c), + GrantStrategy: resourceGrantStrategy(resourceType), + WalletAssetType: walletAssetType, + WalletAssetAmount: walletAssetAmount, + UsageScopes: []string{resourceType}, + AssetUrl: strings.TrimSpace(r.AssetURL), + PreviewUrl: strings.TrimSpace(r.PreviewURL), + AnimationUrl: "", + MetadataJson: "{}", + SortOrder: r.SortOrder, + OperatorUserId: actorID(c), } } @@ -107,25 +107,25 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1 resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) return &walletv1.UpdateResourceRequest{ - RequestId: middleware.CurrentRequestID(c), - AppCode: appctx.FromContext(c.Request.Context()), - ResourceId: resourceID, - ResourceCode: strings.TrimSpace(r.ResourceCode), - ResourceType: resourceType, - Name: strings.TrimSpace(r.Name), - Status: strings.TrimSpace(r.Status), - Grantable: true, + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ResourceId: resourceID, + ResourceCode: strings.TrimSpace(r.ResourceCode), + ResourceType: resourceType, + Name: strings.TrimSpace(r.Name), + Status: strings.TrimSpace(r.Status), + Grantable: true, ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled), - GrantStrategy: resourceGrantStrategy(resourceType), - WalletAssetType: walletAssetType, - WalletAssetAmount: walletAssetAmount, - UsageScopes: []string{resourceType}, - AssetUrl: strings.TrimSpace(r.AssetURL), - PreviewUrl: strings.TrimSpace(r.PreviewURL), - AnimationUrl: "", - MetadataJson: "{}", - SortOrder: r.SortOrder, - OperatorUserId: actorID(c), + GrantStrategy: resourceGrantStrategy(resourceType), + WalletAssetType: walletAssetType, + WalletAssetAmount: walletAssetAmount, + UsageScopes: []string{resourceType}, + AssetUrl: strings.TrimSpace(r.AssetURL), + PreviewUrl: strings.TrimSpace(r.PreviewURL), + AnimationUrl: "", + MetadataJson: "{}", + SortOrder: r.SortOrder, + OperatorUserId: actorID(c), } } diff --git a/server/admin/internal/modules/roomadmin/config.go b/server/admin/internal/modules/roomadmin/config.go new file mode 100644 index 00000000..30214131 --- /dev/null +++ b/server/admin/internal/modules/roomadmin/config.go @@ -0,0 +1,116 @@ +package roomadmin + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sort" + "time" + + "hyapp-admin-server/internal/appctx" +) + +var roomSeatCountCandidates = []int32{10, 15, 20, 25, 30} + +type RoomConfig struct { + CandidateSeatCounts []int32 `json:"candidateSeatCounts"` + AllowedSeatCounts []int32 `json:"allowedSeatCounts"` + DefaultSeatCount int32 `json:"defaultSeatCount"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +// GetRoomConfig 读取房间座位数配置;缺行时返回产品默认值,不阻塞首次打开配置页。 +func (s *Service) GetRoomConfig(ctx context.Context) (RoomConfig, error) { + if s.db == nil { + return RoomConfig{}, fmt.Errorf("room mysql is not configured") + } + row := s.db.QueryRowContext(ctx, ` + SELECT allowed_seat_counts, default_seat_count, updated_at_ms + FROM room_seat_configs + WHERE app_code = ? + `, appctx.FromContext(ctx)) + + var rawAllowed string + config := defaultRoomConfig() + if err := row.Scan(&rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil { + if err == sql.ErrNoRows { + return config, nil + } + return RoomConfig{}, err + } + if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil { + return RoomConfig{}, err + } + return normalizeRoomConfig(config) +} + +// UpdateRoomConfig 保存后台房间配置;候选集合固定,后台只控制启用项和默认值。 +func (s *Service) UpdateRoomConfig(ctx context.Context, req updateRoomConfigRequest) (RoomConfig, error) { + if s.db == nil { + return RoomConfig{}, fmt.Errorf("room mysql is not configured") + } + config, err := normalizeRoomConfig(RoomConfig{ + AllowedSeatCounts: req.AllowedSeatCounts, + DefaultSeatCount: req.DefaultSeatCount, + UpdatedAtMS: time.Now().UTC().UnixMilli(), + }) + if err != nil { + return RoomConfig{}, err + } + rawAllowed, err := json.Marshal(config.AllowedSeatCounts) + if err != nil { + return RoomConfig{}, err + } + now := config.UpdatedAtMS + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + allowed_seat_counts = VALUES(allowed_seat_counts), + default_seat_count = VALUES(default_seat_count), + updated_at_ms = VALUES(updated_at_ms) + `, appctx.FromContext(ctx), string(rawAllowed), config.DefaultSeatCount, now, now); err != nil { + return RoomConfig{}, err + } + return config, nil +} + +func defaultRoomConfig() RoomConfig { + return RoomConfig{ + CandidateSeatCounts: append([]int32(nil), roomSeatCountCandidates...), + AllowedSeatCounts: append([]int32(nil), roomSeatCountCandidates...), + DefaultSeatCount: 15, + } +} + +func normalizeRoomConfig(config RoomConfig) (RoomConfig, error) { + candidateSet := make(map[int32]bool, len(roomSeatCountCandidates)) + for _, candidate := range roomSeatCountCandidates { + candidateSet[candidate] = true + } + seen := make(map[int32]bool, len(config.AllowedSeatCounts)) + allowed := make([]int32, 0, len(config.AllowedSeatCounts)) + for _, value := range config.AllowedSeatCounts { + if !candidateSet[value] { + return RoomConfig{}, fmt.Errorf("%w: 座位数只能选择 10、15、20、25、30", ErrInvalidArgument) + } + if seen[value] { + return RoomConfig{}, fmt.Errorf("%w: 座位数不能重复", ErrInvalidArgument) + } + seen[value] = true + allowed = append(allowed, value) + } + if len(allowed) == 0 { + return RoomConfig{}, fmt.Errorf("%w: 至少启用一个座位数", ErrInvalidArgument) + } + sort.Slice(allowed, func(i int, j int) bool { + return allowed[i] < allowed[j] + }) + if !seen[config.DefaultSeatCount] { + return RoomConfig{}, fmt.Errorf("%w: 默认座位数必须在已启用座位数中", ErrInvalidArgument) + } + config.CandidateSeatCounts = append([]int32(nil), roomSeatCountCandidates...) + config.AllowedSeatCounts = allowed + return config, nil +} diff --git a/server/admin/internal/modules/roomadmin/config_test.go b/server/admin/internal/modules/roomadmin/config_test.go new file mode 100644 index 00000000..2b21e929 --- /dev/null +++ b/server/admin/internal/modules/roomadmin/config_test.go @@ -0,0 +1,48 @@ +package roomadmin + +import ( + "errors" + "reflect" + "testing" +) + +func TestNormalizeRoomConfigSortsAndKeepsFixedCandidates(t *testing.T) { + config, err := normalizeRoomConfig(RoomConfig{ + AllowedSeatCounts: []int32{30, 10, 20}, + DefaultSeatCount: 20, + UpdatedAtMS: 123, + }) + if err != nil { + t.Fatalf("normalizeRoomConfig failed: %v", err) + } + if !reflect.DeepEqual(config.CandidateSeatCounts, []int32{10, 15, 20, 25, 30}) { + t.Fatalf("candidate seat counts mismatch: %+v", config.CandidateSeatCounts) + } + if !reflect.DeepEqual(config.AllowedSeatCounts, []int32{10, 20, 30}) { + t.Fatalf("allowed seat counts should be sorted: %+v", config.AllowedSeatCounts) + } + if config.DefaultSeatCount != 20 || config.UpdatedAtMS != 123 { + t.Fatalf("config fields changed unexpectedly: %+v", config) + } +} + +func TestNormalizeRoomConfigRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + config RoomConfig + }{ + {name: "empty", config: RoomConfig{AllowedSeatCounts: nil, DefaultSeatCount: 15}}, + {name: "duplicate", config: RoomConfig{AllowedSeatCounts: []int32{10, 10}, DefaultSeatCount: 10}}, + {name: "outside_candidate", config: RoomConfig{AllowedSeatCounts: []int32{12}, DefaultSeatCount: 12}}, + {name: "default_not_allowed", config: RoomConfig{AllowedSeatCounts: []int32{10, 20}, DefaultSeatCount: 15}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := normalizeRoomConfig(test.config) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("normalizeRoomConfig should reject %s with ErrInvalidArgument, got %v", test.name, err) + } + }) + } +} diff --git a/server/admin/internal/modules/roomadmin/handler.go b/server/admin/internal/modules/roomadmin/handler.go index 9a7d79e6..b1b76ba0 100644 --- a/server/admin/internal/modules/roomadmin/handler.go +++ b/server/admin/internal/modules/roomadmin/handler.go @@ -34,6 +34,30 @@ func (h *Handler) ListRooms(c *gin.Context) { response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total}) } +func (h *Handler) GetRoomConfig(c *gin.Context) { + config, err := h.service.GetRoomConfig(c.Request.Context()) + if err != nil { + response.ServerError(c, "获取房间配置失败") + return + } + response.OK(c, config) +} + +func (h *Handler) UpdateRoomConfig(c *gin.Context) { + var req updateRoomConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "房间配置参数不正确") + return + } + config, err := h.service.UpdateRoomConfig(c.Request.Context(), req) + if err != nil { + writeMutationError(c, err, "更新房间配置失败") + return + } + writeRoomAuditLog(c, h.audit, "update-room-config", "room_seat_configs", "seat-counts", "success", "update room seat config") + response.OK(c, config) +} + func (h *Handler) UpdateRoom(c *gin.Context) { roomID, ok := parseRoomID(c) if !ok { diff --git a/server/admin/internal/modules/roomadmin/request.go b/server/admin/internal/modules/roomadmin/request.go index 2d696585..e714ef80 100644 --- a/server/admin/internal/modules/roomadmin/request.go +++ b/server/admin/internal/modules/roomadmin/request.go @@ -16,3 +16,8 @@ type updateRoomRequest struct { Title *string `json:"title"` VisibleRegionID *int64 `json:"visibleRegionId"` } + +type updateRoomConfigRequest struct { + AllowedSeatCounts []int32 `json:"allowedSeatCounts"` + DefaultSeatCount int32 `json:"defaultSeatCount"` +} diff --git a/server/admin/internal/modules/roomadmin/routes.go b/server/admin/internal/modules/roomadmin/routes.go index e9268613..6e04366a 100644 --- a/server/admin/internal/modules/roomadmin/routes.go +++ b/server/admin/internal/modules/roomadmin/routes.go @@ -12,6 +12,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { } protected.GET("/admin/rooms", middleware.RequirePermission("room:view"), h.ListRooms) + protected.GET("/admin/rooms/config", middleware.RequirePermission("room-config:view"), h.GetRoomConfig) + protected.PUT("/admin/rooms/config", middleware.RequirePermission("room-config:update"), h.UpdateRoomConfig) protected.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), h.UpdateRoom) protected.DELETE("/admin/rooms/:room_id", middleware.RequirePermission("room:delete"), h.DeleteRoom) } diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 0ec3a77f..a6c48a4e 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -23,6 +23,8 @@ var defaultPermissions = []model.Permission{ {Name: "房间查看", Code: "room:view", Kind: "menu"}, {Name: "房间更新", Code: "room:update", Kind: "button"}, {Name: "房间删除", Code: "room:delete", Kind: "button"}, + {Name: "房间配置查看", Code: "room-config:view", Kind: "menu"}, + {Name: "房间配置更新", Code: "room-config:update", Kind: "button"}, {Name: "APP 配置查看", Code: "app-config:view", Kind: "menu"}, {Name: "APP 配置更新", Code: "app-config:update", Kind: "button"}, {Name: "资源查看", Code: "resource:view", Kind: "menu"}, @@ -166,6 +168,7 @@ func (s *Store) seedMenus() error { {ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true}, {ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 61, Visible: true}, {ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true}, + {ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 66, Visible: true}, {ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true}, {ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true}, {ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true}, @@ -406,7 +409,7 @@ func defaultRolePermissionCodes(code string) []string { "overview:view", "user:view", "user:status", "app-user:view", "app-user:update", "app-user:status", "app-user:password", - "room:view", "room:update", "room:delete", + "room:view", "room:update", "room:delete", "room-config:view", "room-config:update", "app-config:view", "app-config:update", "resource:view", "resource:create", "resource:update", "resource-group:view", "resource-group:create", "resource-group:update", @@ -426,13 +429,14 @@ func defaultRolePermissionCodes(code string) []string { "upload:create", } case "auditor": - return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"} + return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"} case "readonly": return []string{ "overview:view", "user:view", "app-user:view", "room:view", + "room-config:view", "app-config:view", "resource:view", "resource-group:view", @@ -463,7 +467,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { case "ops-admin": return []string{ "app-user:update", "app-user:status", "app-user:password", - "room:view", "room:update", "room:delete", + "room:view", "room:update", "room:delete", "room-config:view", "room-config:update", "app-config:view", "app-config:update", "resource:view", "resource:create", "resource:update", "resource-group:view", "resource-group:create", "resource-group:update", @@ -477,7 +481,7 @@ func defaultRolePermissionMigrationCodes(code string) []string { "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", } case "auditor", "readonly": - return []string{"room:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"} + return []string{"room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"} default: return nil } diff --git a/services/gateway-service/internal/transport/http/manager_center_handler.go b/services/gateway-service/internal/transport/http/manager_center_handler.go new file mode 100644 index 00000000..05c004c5 --- /dev/null +++ b/services/gateway-service/internal/transport/http/manager_center_handler.go @@ -0,0 +1,318 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + "strings" + + userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/logx" + "hyapp/services/gateway-service/internal/auth" +) + +const managerResourceGrantCapability = "manager_resource_grant" + +type managerGrantResourceData struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + Name string `json:"name"` + AssetURL string `json:"asset_url"` + PreviewURL string `json:"preview_url"` + WalletAssetType string `json:"wallet_asset_type"` + WalletAssetAmount int64 `json:"wallet_asset_amount"` + SortOrder int32 `json:"sort_order"` +} + +type businessUserLookupData struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Status string `json:"status"` + RegionID int64 `json:"region_id,omitempty"` + RegionCode string `json:"region_code,omitempty"` + RegionName string `json:"region_name,omitempty"` +} + +type managerResourceGrantData struct { + GrantID string `json:"grant_id"` + CommandID string `json:"command_id"` + TargetUserID string `json:"target_user_id"` + ResourceID string `json:"resource_id"` + Status string `json:"status"` + GrantSource string `json:"grant_source"` + OperatorUserID string `json:"operator_user_id"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +func (h *Handler) listManagerGrantResources(writer http.ResponseWriter, request *http.Request) { + if h.walletClient == nil || h.userHostClient == nil { + writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error") + return + } + if !h.requireManagerResourceGrantCapability(writer, request) { + return + } + page, pageSize, ok := managerPage(request) + if !ok { + writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument") + return + } + resp, err := h.walletClient.ListResources(request.Context(), &walletv1.ListResourcesRequest{ + RequestId: requestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + ResourceType: strings.TrimSpace(request.URL.Query().Get("resource_type")), + Page: page, + PageSize: pageSize, + ActiveOnly: true, + ManagerGrantOnly: true, + }) + if err != nil { + writeRPCError(writer, request, err) + return + } + items := make([]managerGrantResourceData, 0, len(resp.GetResources())) + for _, resource := range resp.GetResources() { + items = append(items, managerGrantResourceFromProto(resource)) + } + writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize}) +} + +func (h *Handler) lookupBusinessUsers(writer http.ResponseWriter, request *http.Request) { + if h.userProfileClient == nil { + writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error") + return + } + pageSize, ok := parsePositiveInt32Query(request, "page_size", 20) + if !ok { + writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument") + return + } + if pageSize > 20 { + pageSize = 20 + } + resp, err := h.userProfileClient.BusinessUserLookup(request.Context(), &userv1.BusinessUserLookupRequest{ + Meta: authRequestMeta(request, ""), + ActorUserId: auth.UserIDFromContext(request.Context()), + Scene: strings.TrimSpace(request.URL.Query().Get("scene")), + Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")), + PageSize: pageSize, + }) + if err != nil { + writeRPCError(writer, request, err) + return + } + items := make([]businessUserLookupData, 0, len(resp.GetUsers())) + for _, user := range resp.GetUsers() { + items = append(items, businessUserLookupFromProto(user)) + } + writeOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize}) +} + +func (h *Handler) grantManagerResource(writer http.ResponseWriter, request *http.Request) { + if h.walletClient == nil || h.userHostClient == nil || h.userProfileClient == nil { + writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error") + return + } + var body struct { + CommandID string `json:"command_id"` + CommandIDAlt string `json:"commandId"` + TargetUserID json.RawMessage `json:"target_user_id"` + TargetUserIDAlt json.RawMessage `json:"targetUserId"` + ResourceID json.RawMessage `json:"resource_id"` + ResourceIDAlt json.RawMessage `json:"resourceId"` + Reason string `json:"reason"` + Quantity *int64 `json:"quantity"` + DurationMS *int64 `json:"duration_ms"` + DurationMSAlt *int64 `json:"durationMs"` + } + if !decode(writer, request, &body) { + return + } + if body.Quantity != nil || body.DurationMS != nil || body.DurationMSAlt != nil { + writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument") + return + } + if !h.requireManagerResourceGrantCapability(writer, request) { + return + } + commandID := strings.TrimSpace(body.CommandID) + if commandID == "" { + commandID = strings.TrimSpace(body.CommandIDAlt) + } + targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt) + resourceID, resourceOK := rawInt64(body.ResourceID, body.ResourceIDAlt) + if commandID == "" || !targetOK || targetUserID <= 0 || !resourceOK || resourceID <= 0 { + writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument") + return + } + targetResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ + Meta: authRequestMeta(request, ""), + UserId: targetUserID, + }) + if err != nil { + writeRPCError(writer, request, err) + return + } + if target := targetResp.GetUser(); target == nil || target.GetStatus() != userv1.UserStatus_USER_STATUS_ACTIVE { + writeError(writer, request, http.StatusNotFound, codeNotFound, "not found") + return + } + reason := strings.TrimSpace(body.Reason) + if reason == "" { + reason = "manager_center" + } + actorUserID := auth.UserIDFromContext(request.Context()) + resp, err := h.walletClient.GrantResource(request.Context(), &walletv1.GrantResourceRequest{ + CommandId: commandID, + AppCode: appcode.FromContext(request.Context()), + TargetUserId: targetUserID, + ResourceId: resourceID, + Quantity: 1, + DurationMs: 0, + Reason: reason, + OperatorUserId: actorUserID, + GrantSource: "manager_center", + }) + if err != nil { + logx.Warn(request.Context(), "manager_resource_grant_failed", + slog.Int64("actor_user_id", actorUserID), + slog.Int64("target_user_id", targetUserID), + slog.Int64("resource_id", resourceID), + slog.String("command_id", commandID), + ) + writeRPCError(writer, request, err) + return + } + grant := managerResourceGrantFromProto(resp.GetGrant()) + logx.Info(request.Context(), "manager_resource_grant_succeeded", + slog.Int64("actor_user_id", actorUserID), + slog.Int64("target_user_id", targetUserID), + slog.Int64("resource_id", resourceID), + slog.String("grant_id", grant.GrantID), + ) + writeOK(writer, request, grant) +} + +func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWriter, request *http.Request) bool { + resp, err := h.userHostClient.CheckBusinessCapability(request.Context(), &userv1.CheckBusinessCapabilityRequest{ + Meta: authRequestMeta(request, ""), + ActorUserId: auth.UserIDFromContext(request.Context()), + Capability: managerResourceGrantCapability, + }) + if err != nil { + writeRPCError(writer, request, err) + return false + } + if !resp.GetAllowed() { + writeError(writer, request, http.StatusForbidden, codePermissionDenied, "permission denied") + return false + } + return true +} + +func managerGrantResourceFromProto(resource *walletv1.Resource) managerGrantResourceData { + if resource == nil { + return managerGrantResourceData{} + } + return managerGrantResourceData{ + ResourceID: userIDString(resource.GetResourceId()), + ResourceType: resource.GetResourceType(), + Name: resource.GetName(), + AssetURL: resource.GetAssetUrl(), + PreviewURL: resource.GetPreviewUrl(), + WalletAssetType: resource.GetWalletAssetType(), + WalletAssetAmount: resource.GetWalletAssetAmount(), + SortOrder: resource.GetSortOrder(), + } +} + +func businessUserLookupFromProto(user *userv1.BusinessUserLookupItem) businessUserLookupData { + if user == nil { + return businessUserLookupData{} + } + return businessUserLookupData{ + UserID: userIDString(user.GetUserId()), + DisplayUserID: user.GetDisplayUserId(), + Username: user.GetUsername(), + Avatar: user.GetAvatar(), + Status: userStatusString(user.GetStatus()), + RegionID: user.GetRegionId(), + RegionCode: user.GetRegionCode(), + RegionName: user.GetRegionName(), + } +} + +func managerResourceGrantFromProto(grant *walletv1.ResourceGrant) managerResourceGrantData { + if grant == nil { + return managerResourceGrantData{} + } + resourceID := grant.GetGrantSubjectId() + if len(grant.GetItems()) > 0 && grant.GetItems()[0].GetResourceId() > 0 { + resourceID = userIDString(grant.GetItems()[0].GetResourceId()) + } + return managerResourceGrantData{ + GrantID: grant.GetGrantId(), + CommandID: grant.GetCommandId(), + TargetUserID: userIDString(grant.GetTargetUserId()), + ResourceID: resourceID, + Status: grant.GetStatus(), + GrantSource: grant.GetGrantSource(), + OperatorUserID: userIDString(grant.GetOperatorUserId()), + CreatedAtMS: grant.GetCreatedAtMs(), + } +} + +func managerPage(request *http.Request) (int32, int32, bool) { + page, pageSize, ok := resourcePage(request) + if !ok { + return 0, 0, false + } + if pageSize > 20 { + pageSize = 20 + } + return page, pageSize, true +} + +func rawInt64(values ...json.RawMessage) (int64, bool) { + for _, raw := range values { + value, ok := parseRawInt64(raw) + if ok { + return value, true + } + } + return 0, false +} + +func parseRawInt64(raw json.RawMessage) (int64, bool) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return 0, false + } + if strings.HasPrefix(text, `"`) { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return 0, false + } + text = strings.TrimSpace(value) + } + value, err := strconv.ParseInt(text, 10, 64) + return value, err == nil +} + +func userStatusString(status userv1.UserStatus) string { + switch status { + case userv1.UserStatus_USER_STATUS_ACTIVE: + return "active" + case userv1.UserStatus_USER_STATUS_DISABLED: + return "disabled" + case userv1.UserStatus_USER_STATUS_BANNED: + return "banned" + default: + return "unspecified" + } +} diff --git a/services/gateway-service/internal/transport/http/manager_center_handler_test.go b/services/gateway-service/internal/transport/http/manager_center_handler_test.go new file mode 100644 index 00000000..03a3611d --- /dev/null +++ b/services/gateway-service/internal/transport/http/manager_center_handler_test.go @@ -0,0 +1,179 @@ +package http + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/services/gateway-service/internal/auth" +) + +func newManagerCenterTestRouter(walletClient *fakeWalletClient, hostClient *fakeUserHostClient, profileClient *fakeUserProfileClient) http.Handler { + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) + handler.SetWalletClient(walletClient) + handler.SetUserHostClient(hostClient) + return handler.Routes(auth.NewVerifier("secret")) +} + +func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *testing.T) { + walletClient := &fakeWalletClient{ + listResourcesResp: &walletv1.ListResourcesResponse{ + Total: 1, + Resources: []*walletv1.Resource{ + { + ResourceId: 101, + ResourceType: "badge", + Name: "VIP Badge", + AssetUrl: "https://cdn.example/badge.png", + PreviewUrl: "https://cdn.example/badge-preview.png", + WalletAssetType: "", + WalletAssetAmount: 0, + SortOrder: 7, + }, + }, + }, + } + hostClient := &fakeUserHostClient{} + router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{}) + request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/resource-grants/resources?resource_type=badge&page=2&page_size=99", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-manager-resources") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 || hostClient.lastCapability.GetCapability() != managerResourceGrantCapability { + t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability) + } + if walletClient.lastListResources == nil { + t.Fatal("wallet ListResources was not called") + } + if req := walletClient.lastListResources; req.GetRequestId() != "req-manager-resources" || req.GetAppCode() != "lalu" || req.GetResourceType() != "badge" || req.GetPage() != 2 || req.GetPageSize() != 20 || !req.GetActiveOnly() || !req.GetManagerGrantOnly() { + t.Fatalf("wallet ListResources request mismatch: %+v", req) + } + + var envelope struct { + Code string `json:"code"` + RequestID string `json:"request_id"` + Data struct { + Items []managerGrantResourceData `json:"items"` + Total int64 `json:"total"` + Page int32 `json:"page"` + PageSize int32 `json:"page_size"` + } `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if envelope.Code != codeOK || envelope.RequestID != "req-manager-resources" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 { + t.Fatalf("response envelope mismatch: %+v", envelope) + } + if envelope.Data.Items[0].ResourceID != "101" || envelope.Data.Items[0].Name != "VIP Badge" { + t.Fatalf("resource data mismatch: %+v", envelope.Data.Items[0]) + } +} + +func TestListManagerGrantResourcesRejectsUnauthorizedManager(t *testing.T) { + walletClient := &fakeWalletClient{} + hostClient := &fakeUserHostClient{capabilityResp: &userv1.CheckBusinessCapabilityResponse{Allowed: false, Reason: "active_agency_owner_required"}} + router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{}) + request := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/resource-grants/resources", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-manager-denied") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + assertEnvelope(t, recorder, http.StatusForbidden, codePermissionDenied, "req-manager-denied") + if walletClient.lastListResources != nil { + t.Fatalf("unauthorized manager must not reach wallet-service: %+v", walletClient.lastListResources) + } +} + +func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) { + profileClient := &fakeUserProfileClient{ + businessLookupResp: &userv1.BusinessUserLookupResponse{ + Users: []*userv1.BusinessUserLookupItem{ + {UserId: 900001, DisplayUserId: "123456", Username: "Alice", Avatar: "https://cdn.example/a.png", Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001, RegionCode: "SA", RegionName: "Saudi Arabia"}, + }, + }, + } + router := newManagerCenterTestRouter(&fakeWalletClient{}, &fakeUserHostClient{}, profileClient) + request := httptest.NewRequest(http.MethodGet, "/api/v1/business/users/lookup?scene=manager_resource_grant&keyword=123456&page_size=99", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-business-lookup") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if req := profileClient.lastBusinessLookup; req == nil || req.GetMeta().GetRequestId() != "req-business-lookup" || req.GetActorUserId() != 42 || req.GetScene() != "manager_resource_grant" || req.GetKeyword() != "123456" || req.GetPageSize() != 20 { + t.Fatalf("business lookup request mismatch: %+v", req) + } + var envelope struct { + Code string `json:"code"` + Data struct { + Items []businessUserLookupData `json:"items"` + Total int `json:"total"` + } `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if envelope.Code != codeOK || envelope.Data.Total != 1 || envelope.Data.Items[0].UserID != "900001" || envelope.Data.Items[0].Status != "active" { + t.Fatalf("business lookup response mismatch: %+v", envelope) + } +} + +func TestGrantManagerResourceUsesServerControlledGrantShape(t *testing.T) { + walletClient := &fakeWalletClient{} + hostClient := &fakeUserHostClient{} + profileClient := &fakeUserProfileClient{} + router := newManagerCenterTestRouter(walletClient, hostClient, profileClient) + body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101"}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-manager-grant") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if hostClient.lastCapability == nil || hostClient.lastCapability.GetActorUserId() != 42 { + t.Fatalf("manager capability request mismatch: %+v", hostClient.lastCapability) + } + if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 900001 { + t.Fatalf("target user lookup mismatch: %+v", profileClient.lastGet) + } + if req := walletClient.lastGrantResource; req == nil || req.GetCommandId() != "mgr-grant-1" || req.GetTargetUserId() != 900001 || req.GetResourceId() != 101 || req.GetQuantity() != 1 || req.GetDurationMs() != 0 || req.GetOperatorUserId() != 42 || req.GetGrantSource() != "manager_center" || req.GetReason() != "manager_center" { + t.Fatalf("wallet GrantResource request mismatch: %+v", req) + } +} + +func TestGrantManagerResourceRejectsClientComputedQuantityOrDuration(t *testing.T) { + walletClient := &fakeWalletClient{} + router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{}) + body := []byte(`{"command_id":"mgr-grant-1","target_user_id":"900001","resource_id":"101","quantity":9}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/resource-grants", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-manager-grant-shape") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-manager-grant-shape") + if walletClient.lastGrantResource != nil { + t.Fatalf("client computed grant shape must not reach wallet-service: %+v", walletClient.lastGrantResource) + } +} diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 5601e4e0..2f9230e8 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -32,6 +32,7 @@ import ( // 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。 type fakeRoomClient struct { lastCreate *roomv1.CreateRoomRequest + lastUpdateProfile *roomv1.UpdateRoomProfileRequest lastJoin *roomv1.JoinRoomRequest lastHeartbeat *roomv1.RoomHeartbeatRequest lastLeave *roomv1.LeaveRoomRequest @@ -49,6 +50,7 @@ type fakeRoomClient struct { lastUnban *roomv1.UnbanUserRequest lastGift *roomv1.SendGiftRequest createErr error + updateProfileErr error joinErr error joinResp *roomv1.JoinRoomResponse } @@ -64,6 +66,32 @@ func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomReq }, nil } +func (f *fakeRoomClient) UpdateRoomProfile(_ context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) { + f.lastUpdateProfile = req + if f.updateProfileErr != nil { + return nil, f.updateProfileErr + } + + return &roomv1.UpdateRoomProfileResponse{ + Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3, ServerTimeMs: 1_700_000_000_002}, + Room: &roomv1.RoomSnapshot{ + RoomId: req.GetMeta().GetRoomId(), + OwnerUserId: req.GetMeta().GetActorUserId(), + HostUserId: req.GetMeta().GetActorUserId(), + Mode: "voice", + Status: "active", + ChatEnabled: true, + MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}, {SeatNo: 3}}, + RoomExt: map[string]string{ + "title": req.GetRoomName(), + "cover_url": req.GetRoomAvatar(), + "description": req.GetRoomDescription(), + }, + Version: 3, + }, + }, nil +} + func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { f.lastJoin = req if f.joinErr != nil { @@ -189,20 +217,23 @@ type fakeUserAuthClient struct { } type fakeUserProfileClient struct { - lastGet *userv1.GetUserRequest - lastStats *userv1.GetMyProfileStatsRequest - lastBatch *userv1.BatchGetUsersRequest - getRequests []*userv1.GetUserRequest - lastComplete *userv1.CompleteOnboardingRequest - lastUpdate *userv1.UpdateUserProfileRequest - lastCountry *userv1.ChangeUserCountryRequest - getErr error - statsResp *userv1.GetMyProfileStatsResponse - statsErr error - completeErr error - countryErr error - regionID int64 - regionByUserID map[int64]int64 + lastGet *userv1.GetUserRequest + lastBusinessLookup *userv1.BusinessUserLookupRequest + lastStats *userv1.GetMyProfileStatsRequest + lastBatch *userv1.BatchGetUsersRequest + getRequests []*userv1.GetUserRequest + lastComplete *userv1.CompleteOnboardingRequest + lastUpdate *userv1.UpdateUserProfileRequest + lastCountry *userv1.ChangeUserCountryRequest + getErr error + statsResp *userv1.GetMyProfileStatsResponse + businessLookupResp *userv1.BusinessUserLookupResponse + businessLookupErr error + statsErr error + completeErr error + countryErr error + regionID int64 + regionByUserID map[int64]int64 } type fakeUserIdentityClient struct { @@ -255,6 +286,9 @@ type fakeUserHostClient struct { lastRoleSummary *userv1.GetUserRoleSummaryRequest roleSummary *userv1.UserRoleSummary roleSummaryErr error + lastCapability *userv1.CheckBusinessCapabilityRequest + capabilityResp *userv1.CheckBusinessCapabilityResponse + capabilityErr error } type fakeWalletClient struct { @@ -285,6 +319,11 @@ type fakeWalletClient struct { lastTransfer *walletv1.TransferCoinFromSellerRequest transferResp *walletv1.TransferCoinFromSellerResponse transferErr error + lastListResources *walletv1.ListResourcesRequest + listResourcesResp *walletv1.ListResourcesResponse + lastGrantResource *walletv1.GrantResourceRequest + grantResourceResp *walletv1.ResourceGrantResponse + grantResourceErr error } type fakeMessageInboxClient struct { @@ -405,6 +444,17 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe }}, nil } +func (f *fakeUserProfileClient) BusinessUserLookup(_ context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) { + f.lastBusinessLookup = req + if f.businessLookupErr != nil { + return nil, f.businessLookupErr + } + if f.businessLookupResp != nil { + return f.businessLookupResp, nil + } + return &userv1.BusinessUserLookupResponse{}, nil +} + func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) { f.lastStats = req if f.statsErr != nil { @@ -755,6 +805,17 @@ func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.G return &userv1.GetUserRoleSummaryResponse{Summary: &userv1.UserRoleSummary{UserId: req.GetUserId()}}, nil } +func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) { + f.lastCapability = req + if f.capabilityErr != nil { + return nil, f.capabilityErr + } + if f.capabilityResp != nil { + return f.capabilityResp, nil + } + return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil +} + func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { f.last = req if f.err != nil { @@ -904,7 +965,14 @@ func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *wallet return &walletv1.TransferCoinFromSellerResponse{}, nil } -func (f *fakeWalletClient) ListResources(context.Context, *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) { +func (f *fakeWalletClient) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) { + f.lastListResources = req + if f.err != nil { + return nil, f.err + } + if f.listResourcesResp != nil { + return f.listResourcesResp, nil + } return &walletv1.ListResourcesResponse{}, nil } @@ -916,6 +984,17 @@ func (f *fakeWalletClient) ListGiftConfigs(context.Context, *walletv1.ListGiftCo return &walletv1.ListGiftConfigsResponse{}, nil } +func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) { + f.lastGrantResource = req + if f.grantResourceErr != nil { + return nil, f.grantResourceErr + } + if f.grantResourceResp != nil { + return f.grantResourceResp, nil + } + return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil +} + func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) { return &walletv1.ListUserResourcesResponse{}, nil } @@ -993,7 +1072,7 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) { client := &fakeRoomClient{} profileClient := &fakeUserProfileClient{regionID: 1001} router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) - body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":8,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`) + body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":10,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-test") @@ -1046,6 +1125,15 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) { commandID string extractMeta func(*fakeRoomClient) *roomv1.RequestMeta }{ + { + name: "profile_update", + path: "/api/v1/rooms/profile/update", + body: `{"room_id":"room-1","command_id":"cmd-profile-1","room_name":"New Room","seat_count":20}`, + commandID: "cmd-profile-1", + extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { + return client.lastUpdateProfile.GetMeta() + }, + }, { name: "join", path: "/api/v1/rooms/join", @@ -1606,6 +1694,11 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) { OwnerUserId: 42, Status: "active", Version: 7, + MicSeats: []*roomv1.SeatState{ + {SeatNo: 1, UserId: 42}, + {SeatNo: 2}, + }, + RoomExt: map[string]string{"title": "Snapshot Room"}, }, ServerTimeMs: 123456, }} @@ -1628,6 +1721,22 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) { if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != "req-room-snapshot" { t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot) } + var response responseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode snapshot response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("snapshot response must be wrapped in app DTO: %+v", response.Data) + } + room, ok := data["room"].(map[string]any) + if !ok || room["seat_count"] != float64(2) || room["title"] != "Snapshot Room" { + t.Fatalf("snapshot room DTO must include derived room fields: %+v", data["room"]) + } + seats, ok := data["seats"].([]any) + if !ok || len(seats) != 2 { + t.Fatalf("snapshot DTO must include seat list: %+v", data["seats"]) + } } func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) { @@ -2726,6 +2835,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin }{ {name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"}, {name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`}, + {name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`}, {name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`}, {name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`}, {name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"}, @@ -2927,7 +3037,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) { func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":8,"mode":"voice","room_name":"Lobby"}`))) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":10,"mode":"voice","room_name":"Lobby"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-generated-room-id") recorder := httptest.NewRecorder() @@ -2945,14 +3055,81 @@ func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { } } +func TestCreateRoomAllowsMissingSeatCount(t *testing.T) { + client := &fakeRoomClient{} + router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","mode":"voice","room_name":"Lobby"}`))) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-create-default-seat") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if client.lastCreate == nil || client.lastCreate.GetSeatCount() != 0 { + t.Fatalf("missing seat_count should forward zero to room-service default config: %+v", client.lastCreate) + } +} + +func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) { + client := &fakeRoomClient{} + router := NewHandler(client).Routes(auth.NewVerifier("secret")) + body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-1","room_name":" New Room ","room_avatar":" https://cdn.example.com/new.png ","room_description":" new desc ","seat_count":20}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-profile-update") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + req := client.lastUpdateProfile + if req == nil { + t.Fatal("UpdateRoomProfile must be forwarded") + } + if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != "req-profile-update" || req.GetMeta().GetActorUserId() != 42 { + t.Fatalf("UpdateRoomProfile meta mismatch: %+v", req.GetMeta()) + } + if req.RoomName == nil || req.GetRoomName() != "New Room" || req.RoomAvatar == nil || req.GetRoomAvatar() != "https://cdn.example.com/new.png" || req.RoomDescription == nil || req.GetRoomDescription() != "new desc" || req.SeatCount == nil || req.GetSeatCount() != 20 { + t.Fatalf("UpdateRoomProfile optional fields mismatch: %+v", req) + } + + var response responseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + if !ok || data["server_time_ms"] == nil || data["room"] == nil || data["seats"] == nil { + t.Fatalf("profile update response should include result room seats and server_time_ms: %+v", response.Data) + } +} + +func TestUpdateRoomProfileRPCErrorWritesEnvelope(t *testing.T) { + client := &fakeRoomClient{updateProfileErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "removed seats must be empty and unlocked"))} + router := NewHandler(client).Routes(auth.NewVerifier("secret")) + body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-conflict","seat_count":10}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-profile-conflict") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-profile-conflict") +} + func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { tests := []struct { name string body string }{ - {name: "seat_count", body: `{"room_id":"room-1","mode":"voice","room_name":"Lobby"}`}, - {name: "mode", body: `{"room_id":"room-1","seat_count":8,"room_name":"Lobby"}`}, - {name: "room_name", body: `{"room_id":"room-1","seat_count":8,"mode":"voice"}`}, + {name: "mode", body: `{"room_id":"room-1","seat_count":10,"room_name":"Lobby"}`}, + {name: "room_name", body: `{"room_id":"room-1","seat_count":10,"mode":"voice"}`}, + {name: "negative_seat_count", body: `{"room_id":"room-1","seat_count":-1,"mode":"voice","room_name":"Lobby"}`}, } for _, test := range tests { @@ -2976,7 +3153,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - body := []byte(`{"room_id":"room-1","seat_count":8,"mode":"voice","room_name":"Lobby"}`) + body := []byte(`{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upstream") @@ -2992,7 +3169,7 @@ func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) { createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")), } router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) - body := []byte(`{"room_id":"room-owner-2","seat_count":8,"mode":"voice","room_name":"Lobby"}`) + body := []byte(`{"room_id":"room-owner-2","seat_count":10,"mode":"voice","room_name":"Lobby"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-owner-conflict") diff --git a/services/gateway-service/internal/transport/http/room_handler.go b/services/gateway-service/internal/transport/http/room_handler.go index 3abc6126..e876dd40 100644 --- a/services/gateway-service/internal/transport/http/room_handler.go +++ b/services/gateway-service/internal/transport/http/room_handler.go @@ -365,7 +365,7 @@ func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Requ RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), }) - write(writer, request, resp, err) + write(writer, request, roomSnapshotDataFromProto(resp), err) } // createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。 diff --git a/services/gateway-service/internal/transport/http/room_view.go b/services/gateway-service/internal/transport/http/room_view.go index d14f726a..de1aadce 100644 --- a/services/gateway-service/internal/transport/http/room_view.go +++ b/services/gateway-service/internal/transport/http/room_view.go @@ -58,6 +58,13 @@ type updateRoomProfileData struct { ServerTimeMS int64 `json:"server_time_ms"` } +type roomSnapshotData struct { + Room roomInitialData `json:"room"` + Seats []roomSeatData `json:"seats"` + ContributionRank []roomRankItemData `json:"contribution_rank"` + ServerTimeMS int64 `json:"server_time_ms"` +} + type roomCommandResultData struct { Applied bool `json:"applied"` RoomVersion int64 `json:"room_version"` @@ -196,6 +203,19 @@ func updateRoomProfileDataFromProto(resp *roomv1.UpdateRoomProfileResponse) upda } } +func roomSnapshotDataFromProto(resp *roomv1.GetRoomSnapshotResponse) roomSnapshotData { + if resp == nil { + return roomSnapshotData{} + } + snapshot := resp.GetRoom() + return roomSnapshotData{ + Room: roomInitialRoomDataFromSnapshot(snapshot, snapshot.GetRoomId()), + Seats: roomSeatDataFromSnapshot(snapshot), + ContributionRank: roomRankDataFromSnapshot(snapshot, 0), + ServerTimeMS: resp.GetServerTimeMs(), + } +} + func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID string) roomInitialData { if snapshot == nil { return roomInitialData{RoomID: roomID, IMGroupID: roomIMGroupID(roomID)} diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 9b81dddb..8649eca5 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -26,6 +26,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources)) mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup)) mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts)) + mux.Handle(apiV1Prefix+"/manager-center/resource-grants/resources", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listManagerGrantResources))) + mux.Handle(apiV1Prefix+"/manager-center/resource-grants", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.grantManagerResource))) + mux.Handle(apiV1Prefix+"/business/users/lookup", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.lookupBusinessUsers))) mux.Handle(apiV1Prefix+"/im/usersig", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.issueTencentIMUserSig))) mux.Handle(apiV1Prefix+"/rtc/token", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.issueTencentRTCToken))) mux.Handle(apiV1Prefix+"/tencent-im/callback", h.publicAPIHandler(h.handleTencentIMCallback)) diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go index 0c37cc32..8aa37f35 100644 --- a/services/room-service/internal/room/service/service_test.go +++ b/services/room-service/internal/room/service/service_test.go @@ -4,6 +4,7 @@ package service_test import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -145,7 +146,7 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) { roomID := "room-owner-default" createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }) @@ -181,7 +182,7 @@ func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Route Room", }); err != nil { @@ -207,7 +208,7 @@ func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-owner-single-a", 42), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Owner Room A", }); err != nil { @@ -216,7 +217,7 @@ func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) { _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-owner-single-b", 42), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Owner Room B", }) @@ -237,7 +238,7 @@ func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) { RoomID: "room-owner-unique-a", OwnerUserID: 77, HostUserID: 77, - SeatCount: 4, + SeatCount: 10, Mode: "social", Status: "active", } @@ -261,7 +262,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) { roomID := "room-list-region-a" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Region A Room", RoomAvatar: "https://cdn.example.com/room-a.png", @@ -273,7 +274,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) { usedClock.now = usedClock.now.Add(time.Second) if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-list-region-b", 3), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Region B Room", VisibleRegionId: 2002, @@ -312,7 +313,7 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) { if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 { t.Fatalf("room list item identity mismatch: %+v", item) } - if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 { + if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 10 { t.Fatalf("room list projection did not track Room Cell state: %+v", item) } if item.GetTitle() != "Region A Room" || item.GetCoverUrl() != "https://cdn.example.com/room-a.png" { @@ -341,7 +342,7 @@ func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 6, + SeatCount: 10, Mode: "voice", RoomName: "Owner Top Card", RoomAvatar: "https://cdn.example.com/owner-room.png", @@ -361,7 +362,7 @@ func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) { if !resp.GetHasRoom() || resp.GetRoom().GetRoomId() != roomID { t.Fatalf("my room should return owner room from rooms metadata: %+v", resp) } - if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 6 || resp.GetRoom().GetVisibleRegionId() != 1001 { + if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 10 || resp.GetRoom().GetVisibleRegionId() != 1001 { t.Fatalf("my room card should use snapshot/meta fields: %+v", resp.GetRoom()) } } @@ -374,7 +375,7 @@ func TestListRoomFeedsVisitedUsesJoinRoomFeed(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Visited Room", VisibleRegionId: 1001, @@ -431,7 +432,7 @@ func TestListRoomFeedsFriendFollowingUsesGatewayRelations(t *testing.T) { } { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(room.roomID, room.ownerID), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: room.title, VisibleRegionId: room.regionID, @@ -524,7 +525,7 @@ func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Current Room", }); err != nil { @@ -561,7 +562,7 @@ func TestGetCurrentRoomReturnsOwnerAfterCreate(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Current Room", }); err != nil { @@ -591,7 +592,7 @@ func TestGetCurrentRoomReturnsEmptyAfterLeave(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Current Room", }); err != nil { @@ -624,7 +625,7 @@ func TestGetRoomSnapshotRequiresViewerPresence(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Snapshot Room", }); err != nil { @@ -661,7 +662,7 @@ func TestCreateRoomRejectsInvalidRoomID(t *testing.T) { _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room:1001", 42), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }) @@ -678,9 +679,8 @@ func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) { name string req *roomv1.CreateRoomRequest }{ - {name: "seat_count", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-seat", 42), Mode: "social", RoomName: "Test Room"}}, - {name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4, RoomName: "Test Room"}}, - {name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 4, Mode: "social"}}, + {name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 10, RoomName: "Test Room"}}, + {name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 10, Mode: "social"}}, } for _, test := range tests { @@ -696,6 +696,212 @@ func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) { } } +func TestCreateRoomUsesDefaultSeatConfig(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + + resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta("room-default-seat-config", 42), + Mode: "social", + RoomName: "Default Seat Room", + }) + if err != nil { + t.Fatalf("CreateRoom without seat_count should use default config: %v", err) + } + if got := len(resp.GetRoom().GetMicSeats()); got != 15 { + t.Fatalf("default room seat count mismatch: got %d", got) + } +} + +func TestCreateRoomRejectsDisallowedSeatCount(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + + for _, seatCount := range []int32{8, 12, 31, -1} { + _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(fmt.Sprintf("room-disallowed-seat-%d", seatCount), 42), + SeatCount: seatCount, + Mode: "social", + RoomName: "Bad Seat Room", + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("CreateRoom should reject seat_count=%d, got %v", seatCount, err) + } + } +} + +func TestUpdateRoomProfileUpdatesProjectionAndExpandsSeats(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + syncPublisher := &fakeSyncPublisher{} + outboxPublisher := &fakeOutboxPublisher{} + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, outboxPublisher) + roomID := "room-profile-update" + + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, 42), + SeatCount: 10, + Mode: "voice", + RoomName: "Old Room", + RoomAvatar: "https://cdn.example.com/old.png", + RoomDescription: "old desc", + VisibleRegionId: 1001, + }); err != nil { + t.Fatalf("CreateRoom failed: %v", err) + } + + resp, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ + Meta: roomservice.NewRequestMeta(roomID, 42), + RoomName: stringPtr(" New Room "), + RoomAvatar: stringPtr(" https://cdn.example.com/new.png "), + RoomDescription: stringPtr(" new desc "), + SeatCount: int32Ptr(20), + }) + if err != nil { + t.Fatalf("UpdateRoomProfile failed: %v", err) + } + + snapshot := resp.GetRoom() + if !resp.GetResult().GetApplied() || snapshot.GetVersion() != 2 { + t.Fatalf("profile update should advance room version: %+v", resp.GetResult()) + } + if got := len(snapshot.GetMicSeats()); got != 20 { + t.Fatalf("expanded seat count mismatch: got %d", got) + } + ext := snapshot.GetRoomExt() + if ext["title"] != "New Room" || ext["cover_url"] != "https://cdn.example.com/new.png" || ext["description"] != "new desc" { + t.Fatalf("room ext mismatch after profile update: %+v", ext) + } + meta, exists, err := repository.GetRoomMeta(ctx, roomID) + if err != nil || !exists || meta.SeatCount != 20 { + t.Fatalf("room meta seat_count should update: exists=%v meta=%+v err=%v", exists, meta, err) + } + listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ + ViewerUserId: 99, + VisibleRegionId: 1001, + Tab: "hot", + Limit: 20, + }) + if err != nil { + t.Fatalf("ListRooms failed: %v", err) + } + if len(listResp.GetRooms()) != 1 || listResp.GetRooms()[0].GetTitle() != "New Room" || listResp.GetRooms()[0].GetSeatCount() != 20 { + t.Fatalf("room list projection should reflect profile update: %+v", listResp.GetRooms()) + } + if len(outboxPublisher.envelopes) != 1 || outboxPublisher.envelopes[0].GetEventType() != "RoomProfileUpdated" { + t.Fatalf("profile update should publish one RoomProfileUpdated outbox envelope: %+v", outboxPublisher.envelopes) + } + if len(syncPublisher.events) != 1 || syncPublisher.events[0].EventType != "room_profile_updated" || syncPublisher.events[0].Attributes["seat_count"] != "20" { + t.Fatalf("profile update should publish room_profile_updated: %+v", syncPublisher.events) + } +} + +func TestUpdateRoomProfileRejectsInvalidSeatCount(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + roomID := "room-profile-invalid-seat" + + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, 42), + SeatCount: 10, + Mode: "voice", + RoomName: "Seat Room", + }); err != nil { + t.Fatalf("CreateRoom failed: %v", err) + } + + _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ + Meta: roomservice.NewRequestMeta(roomID, 42), + SeatCount: int32Ptr(12), + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("UpdateRoomProfile should reject disallowed seat count, got %v", err) + } +} + +func TestUpdateRoomProfileRejectsShrinkWhenRemovedSeatsBusy(t *testing.T) { + t.Run("occupied", func(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + roomID := "room-shrink-occupied" + + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil { + t.Fatalf("CreateRoom failed: %v", err) + } + if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { + t.Fatalf("JoinRoom failed: %v", err) + } + if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 20}); err != nil { + t.Fatalf("MicUp failed: %v", err) + } + + _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ + Meta: roomservice.NewRequestMeta(roomID, 1), + SeatCount: int32Ptr(10), + }) + if !xerr.IsCode(err, xerr.Conflict) { + t.Fatalf("shrinking occupied high seat should conflict, got %v", err) + } + }) + + t.Run("locked", func(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + roomID := "room-shrink-locked" + + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil { + t.Fatalf("CreateRoom failed: %v", err) + } + if _, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 20, Locked: true}); err != nil { + t.Fatalf("SetMicSeatLock failed: %v", err) + } + + _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ + Meta: roomservice.NewRequestMeta(roomID, 1), + SeatCount: int32Ptr(10), + }) + if !xerr.IsCode(err, xerr.Conflict) { + t.Fatalf("shrinking locked high seat should conflict, got %v", err) + } + }) +} + +func TestUpdateRoomProfileRecoveryReplay(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + directory := router.NewMemoryDirectory() + serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + roomID := "room-profile-replay" + + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Old Room"}); err != nil { + t.Fatalf("CreateRoom failed: %v", err) + } + if _, err := serviceA.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ + Meta: roomservice.NewRequestMeta(roomID, 1), + RoomName: stringPtr("Replay Room"), + RoomDescription: stringPtr("replayed desc"), + SeatCount: int32Ptr(20), + }); err != nil { + t.Fatalf("UpdateRoomProfile failed: %v", err) + } + + directory.ForceExpire(roomRouteKeyForTest(roomID)) + serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) + joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}) + if err != nil { + t.Fatalf("JoinRoom on recovered service failed: %v", err) + } + snapshot := joinResp.GetRoom() + if len(snapshot.GetMicSeats()) != 20 || snapshot.GetRoomExt()["title"] != "Replay Room" || snapshot.GetRoomExt()["description"] != "replayed desc" { + t.Fatalf("recovered profile state mismatch: seats=%d ext=%+v", len(snapshot.GetMicSeats()), snapshot.GetRoomExt()) + } +} + // TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。 func TestRoomLifecycleGiftAndGuards(t *testing.T) { ctx := context.Background() @@ -710,7 +916,7 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) { // 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。 createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }) @@ -817,7 +1023,7 @@ func TestRoomManagementPermissionsAndHostFlow(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-management-flow" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -888,7 +1094,7 @@ func TestMicPublishingConfirmAndStaleEvents(t *testing.T) { svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-publish-confirm" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -947,7 +1153,7 @@ func TestAudienceCanChangeOwnMicSeat(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-self-change-mic" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -981,7 +1187,7 @@ func TestApplyRTCEventConfirmsAudioAndClearsMicOnStopOrExit(t *testing.T) { svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-event" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1059,7 +1265,7 @@ func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) { svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-exit-replay" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1119,7 +1325,7 @@ func TestRTCRoomExitedRecoveryKeepsBusinessPresence(t *testing.T) { serviceA := newRoomServiceWithClock("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-exit-recovery-presence" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1172,7 +1378,7 @@ func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) { svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-publish-timeout" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1212,7 +1418,7 @@ func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) { svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-old-session" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1262,7 +1468,7 @@ func TestMicSeatLockAndNoopIdempotency(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) roomID := "room-mic-lock" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1318,7 +1524,7 @@ func TestKickUnbanAndAdminRemoval(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-ban-unban" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1374,7 +1580,7 @@ func TestJoinRoomRejectsClosedRoom(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-closed" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } repository.SetRoomStatus(roomID, "closed") @@ -1399,7 +1605,7 @@ func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) roomID := "room-close-command" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Close Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Close Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1440,7 +1646,7 @@ func TestDrainingRejectsNewRoomCommands(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-draining" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Drain Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Drain Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } svc.MarkDraining() @@ -1448,7 +1654,7 @@ func TestDrainingRejectsNewRoomCommands(t *testing.T) { if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("draining service should reject new JoinRoom, got %v", err) } - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 4, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 10, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("draining service should reject new CreateRoom, got %v", err) } } @@ -1459,7 +1665,7 @@ func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-command-conflict" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } @@ -1496,7 +1702,7 @@ func TestRoomManagementRecoveryReplay(t *testing.T) { serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-management-replay" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1556,7 +1762,7 @@ func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -1598,7 +1804,7 @@ func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "voice", RoomName: "Test Room", }); err != nil { @@ -1637,7 +1843,7 @@ func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -1687,7 +1893,7 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) { // node-a 先创建并推进房间状态,snapshotEveryN=2 会让恢复需要结合快照和命令日志。 if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -1761,7 +1967,7 @@ func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) { }, syncPublisherA, outboxPublisher) roomID := "room-lease-fencing" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Fence Room"}); err != nil { + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Fence Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1797,7 +2003,7 @@ func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) { if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -1837,7 +2043,7 @@ func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -1892,7 +2098,7 @@ func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) { serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-stale-lease-takeover" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1926,7 +2132,7 @@ func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) { serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-timeout-lease-takeover" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil { + if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { @@ -1969,7 +2175,7 @@ func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) { // 创建和进房先建立可送礼的基本房间态。 if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -2115,7 +2321,7 @@ func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 4, + SeatCount: 10, Mode: "social", RoomName: "Test Room", }); err != nil { @@ -2235,6 +2441,14 @@ func containsInt64(values []int64, target int64) bool { return false } +func stringPtr(value string) *string { + return &value +} + +func int32Ptr(value int32) *int32 { + return &value +} + func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool { for _, event := range events { if event.EventType == "room_user_left" && event.Attributes["reason"] == reason { diff --git a/services/room-service/internal/transport/grpc/server_test.go b/services/room-service/internal/transport/grpc/server_test.go index 2266bc1d..ee596d07 100644 --- a/services/room-service/internal/transport/grpc/server_test.go +++ b/services/room-service/internal/transport/grpc/server_test.go @@ -44,7 +44,7 @@ func TestCreateRoomMapsDomainErrorToGRPCReason(t *testing.T) { _, err := server.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{ // room_id 缺失属于客户端参数错误,不能在 gRPC 边界丢失 reason 后变成 INTERNAL_ERROR。 - SeatCount: 8, + SeatCount: 10, Mode: "voice", }) if err == nil { diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index bba4250e..c59315c4 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -49,6 +49,42 @@ func displayID(userID int64) string { return fmt.Sprintf("%06d", 900000+userID%100000) } +func TestCheckBusinessCapabilityRequiresActiveAgencyOwner(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedActiveUser(t, repository, 101, 10) + seedActiveUser(t, repository, 202, 10) + repository.PutAgency(hostdomain.Agency{ + AgencyID: 301, + OwnerUserID: 101, + RegionID: 10, + Name: "Manager Agency", + Status: hostdomain.AgencyStatusActive, + }) + svc := newHostService(repository, 1000, 2000) + + allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant) + if err != nil { + t.Fatalf("CheckBusinessCapability for owner failed: %v", err) + } + if !allowed || reason != "" { + t.Fatalf("active agency owner should be allowed: allowed=%v reason=%q", allowed, reason) + } + + allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant) + if err != nil { + t.Fatalf("CheckBusinessCapability for non-owner failed: %v", err) + } + if allowed || reason != "active_agency_owner_required" { + t.Fatalf("non-owner should be denied by stable reason: allowed=%v reason=%q", allowed, reason) + } + + allowed, _, err = svc.CheckBusinessCapability(ctx, 101, "unknown_capability") + if !xerr.IsCode(err, xerr.InvalidArgument) || allowed { + t.Fatalf("unknown capability must fail closed: allowed=%v err=%v", allowed, err) + } +} + func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/user-service/internal/service/user/service_test.go b/services/user-service/internal/service/user/service_test.go index 6dd2e491..83f4572d 100644 --- a/services/user-service/internal/service/user/service_test.go +++ b/services/user-service/internal/service/user/service_test.go @@ -137,6 +137,49 @@ func TestGetUserUsesRepository(t *testing.T) { } } +func TestBusinessUserLookupRestrictsSceneKeywordAndInactiveUsers(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.PutUser(completedUser(userdomain.User{ + UserID: 700001, + CurrentDisplayUserID: "910001", + Username: "Alice Star", + Avatar: "https://cdn.example/alice.png", + RegionID: 1001, + Status: userdomain.StatusActive, + })) + repository.PutUser(completedUser(userdomain.User{ + UserID: 700002, + CurrentDisplayUserID: "910002", + Username: "Alice Disabled", + RegionID: 1001, + Status: userdomain.StatusDisabled, + })) + svc := newUserService(repository) + + byDisplayID, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "910001", 99) + if err != nil { + t.Fatalf("BusinessUserLookup by display id failed: %v", err) + } + if len(byDisplayID) != 1 || byDisplayID[0].UserID != 700001 || byDisplayID[0].DisplayUserID != "910001" || byDisplayID[0].Status != userdomain.StatusActive { + t.Fatalf("display id lookup mismatch: %+v", byDisplayID) + } + + byNickname, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "Alice", 20) + if err != nil { + t.Fatalf("BusinessUserLookup by nickname failed: %v", err) + } + if len(byNickname) != 1 || byNickname[0].UserID != 700001 { + t.Fatalf("nickname lookup must exclude inactive users: %+v", byNickname) + } + + if _, err := svc.BusinessUserLookup(context.Background(), "unknown_scene", "Alice", 20); !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("expected INVALID_ARGUMENT for unknown scene, got %v", err) + } + if _, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "A", 20); !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("expected INVALID_ARGUMENT for short keyword, got %v", err) + } +} + func TestGetMyProfileStatsReadsCounterTable(t *testing.T) { repository := mysqltest.NewRepository(t) svc := newUserService(repository) diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index a141d4bf..5c6f91f6 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -111,6 +111,11 @@ func (r *Repository) ListFriendApplications(ctx context.Context, userID int64, d return r.Repository.UserRepository().ListFriendApplications(ctx, userID, direction, page, pageSize) } +// BusinessUserLookup 让测试 wrapper 继续满足业务场景用户查询接口。 +func (r *Repository) BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) { + return r.Repository.UserRepository().BusinessUserLookup(ctx, keyword, numericKeyword, pageSize) +} + // BatchGetUsers 让测试 wrapper 继续满足业务层用户主数据接口。 func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) { return r.Repository.UserRepository().BatchGetUsers(ctx, userIDs) diff --git a/services/wallet-service/internal/domain/resource/resource.go b/services/wallet-service/internal/domain/resource/resource.go index ad132cd6..183725be 100644 --- a/services/wallet-service/internal/domain/resource/resource.go +++ b/services/wallet-service/internal/domain/resource/resource.go @@ -53,26 +53,26 @@ const ( ) type Resource struct { - AppCode string - ResourceID int64 - ResourceCode string - ResourceType string - Name string - Status string - Grantable bool - GrantStrategy string - WalletAssetType string - WalletAssetAmount int64 - UsageScopes []string - AssetURL string - PreviewURL string - AnimationURL string - MetadataJSON string - SortOrder int32 - CreatedByUserID int64 - UpdatedByUserID int64 - CreatedAtMS int64 - UpdatedAtMS int64 + AppCode string + ResourceID int64 + ResourceCode string + ResourceType string + Name string + Status string + Grantable bool + GrantStrategy string + WalletAssetType string + WalletAssetAmount int64 + UsageScopes []string + AssetURL string + PreviewURL string + AnimationURL string + MetadataJSON string + SortOrder int32 + CreatedByUserID int64 + UpdatedByUserID int64 + CreatedAtMS int64 + UpdatedAtMS int64 ManagerGrantEnabled bool } @@ -177,34 +177,34 @@ type ResourceGrant struct { } type ListResourcesQuery struct { - AppCode string - ResourceType string - Status string - Keyword string - Page int32 - PageSize int32 - ActiveOnly bool + AppCode string + ResourceType string + Status string + Keyword string + Page int32 + PageSize int32 + ActiveOnly bool ManagerGrantOnly bool } type ResourceCommand struct { - AppCode string - ResourceID int64 - ResourceCode string - ResourceType string - Name string - Status string - Grantable bool - GrantStrategy string - WalletAssetType string - WalletAssetAmount int64 - UsageScopes []string - AssetURL string - PreviewURL string - AnimationURL string - MetadataJSON string - SortOrder int32 - OperatorUserID int64 + AppCode string + ResourceID int64 + ResourceCode string + ResourceType string + Name string + Status string + Grantable bool + GrantStrategy string + WalletAssetType string + WalletAssetAmount int64 + UsageScopes []string + AssetURL string + PreviewURL string + AnimationURL string + MetadataJSON string + SortOrder int32 + OperatorUserID int64 ManagerGrantEnabled bool } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index d6380d2a..4ae12ee8 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -818,6 +818,115 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) { } } +// TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。 +func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + ctx := context.Background() + + enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "badge_manager_enabled", + ResourceType: resourcedomain.TypeBadge, + Name: "Manager Enabled Badge", + Status: resourcedomain.StatusActive, + Grantable: true, + ManagerGrantEnabled: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create manager enabled resource failed: %v", err) + } + disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "badge_manager_disabled", + ResourceType: resourcedomain.TypeBadge, + Name: "Manager Disabled Badge", + Status: resourcedomain.StatusActive, + Grantable: true, + ManagerGrantEnabled: false, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create manager disabled resource failed: %v", err) + } + if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "badge_disabled_status", + ResourceType: resourcedomain.TypeBadge, + Name: "Disabled Badge", + Status: resourcedomain.StatusDisabled, + Grantable: true, + ManagerGrantEnabled: true, + GrantStrategy: resourcedomain.GrantStrategySetActiveFlag, + UsageScopes: []string{"profile"}, + OperatorUserID: 90001, + }); err != nil { + t.Fatalf("create disabled status resource failed: %v", err) + } + + items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{ + ResourceType: resourcedomain.TypeBadge, + ManagerGrantOnly: true, + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("ListResources manager grant only failed: %v", err) + } + if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID { + t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items) + } + + grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-manager-grant-enabled", + TargetUserID: 43001, + ResourceID: enabled.ResourceID, + Quantity: 1, + Reason: "manager center", + OperatorUserID: 91001, + GrantSource: resourcedomain.GrantSourceManagerCenter, + }) + if err != nil { + t.Fatalf("manager center grant enabled resource failed: %v", err) + } + if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 { + t.Fatalf("manager grant response mismatch: %+v", grant) + } + + _, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{ + CommandID: "cmd-manager-grant-disabled", + TargetUserID: 43001, + ResourceID: disabledForManager.ResourceID, + Quantity: 1, + Reason: "manager center", + OperatorUserID: 91001, + GrantSource: resourcedomain.GrantSourceManagerCenter, + }) + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err) + } +} + +// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。 +func TestManagerCenterCannotGrantResourceGroup(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + + _, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{ + CommandID: "cmd-manager-grant-group", + TargetUserID: 43002, + GroupID: 101, + Reason: "manager center", + OperatorUserID: 91001, + GrantSource: resourcedomain.GrantSourceManagerCenter, + }) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err) + } +} + // TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。 func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/transport/grpc/resource.go b/services/wallet-service/internal/transport/grpc/resource.go index cb6918b6..87089b36 100644 --- a/services/wallet-service/internal/transport/grpc/resource.go +++ b/services/wallet-service/internal/transport/grpc/resource.go @@ -257,20 +257,20 @@ func (s *Server) ListResourceGrants(ctx context.Context, req *walletv1.ListResou func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand { return resourcedomain.ResourceCommand{ - AppCode: req.GetAppCode(), - ResourceCode: req.GetResourceCode(), - ResourceType: req.GetResourceType(), - Name: req.GetName(), - Status: req.GetStatus(), - Grantable: req.GetGrantable(), - GrantStrategy: req.GetGrantStrategy(), - WalletAssetType: req.GetWalletAssetType(), - WalletAssetAmount: req.GetWalletAssetAmount(), - UsageScopes: req.GetUsageScopes(), - AssetURL: req.GetAssetUrl(), - PreviewURL: req.GetPreviewUrl(), - AnimationURL: req.GetAnimationUrl(), - MetadataJSON: req.GetMetadataJson(), + AppCode: req.GetAppCode(), + ResourceCode: req.GetResourceCode(), + ResourceType: req.GetResourceType(), + Name: req.GetName(), + Status: req.GetStatus(), + Grantable: req.GetGrantable(), + GrantStrategy: req.GetGrantStrategy(), + WalletAssetType: req.GetWalletAssetType(), + WalletAssetAmount: req.GetWalletAssetAmount(), + UsageScopes: req.GetUsageScopes(), + AssetURL: req.GetAssetUrl(), + PreviewURL: req.GetPreviewUrl(), + AnimationURL: req.GetAnimationUrl(), + MetadataJSON: req.GetMetadataJson(), SortOrder: req.GetSortOrder(), OperatorUserID: req.GetOperatorUserId(), ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled), @@ -279,23 +279,23 @@ func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedoma func resourceCommandFromUpdate(req *walletv1.UpdateResourceRequest) resourcedomain.ResourceCommand { command := resourceCommandFromCreate(&walletv1.CreateResourceRequest{ - AppCode: req.GetAppCode(), - ResourceCode: req.GetResourceCode(), - ResourceType: req.GetResourceType(), - Name: req.GetName(), - Status: req.GetStatus(), - Grantable: req.GetGrantable(), - GrantStrategy: req.GetGrantStrategy(), - WalletAssetType: req.GetWalletAssetType(), - WalletAssetAmount: req.GetWalletAssetAmount(), - UsageScopes: req.GetUsageScopes(), - AssetUrl: req.GetAssetUrl(), - PreviewUrl: req.GetPreviewUrl(), - AnimationUrl: req.GetAnimationUrl(), - MetadataJson: req.GetMetadataJson(), + AppCode: req.GetAppCode(), + ResourceCode: req.GetResourceCode(), + ResourceType: req.GetResourceType(), + Name: req.GetName(), + Status: req.GetStatus(), + Grantable: req.GetGrantable(), + GrantStrategy: req.GetGrantStrategy(), + WalletAssetType: req.GetWalletAssetType(), + WalletAssetAmount: req.GetWalletAssetAmount(), + UsageScopes: req.GetUsageScopes(), + AssetUrl: req.GetAssetUrl(), + PreviewUrl: req.GetPreviewUrl(), + AnimationUrl: req.GetAnimationUrl(), + MetadataJson: req.GetMetadataJson(), SortOrder: req.GetSortOrder(), OperatorUserId: req.GetOperatorUserId(), - ManagerGrantEnabled: managerGrantEnabledOrDefault(req.ManagerGrantEnabled), + ManagerGrantEnabled: req.ManagerGrantEnabled, }) command.ResourceID = req.GetResourceId() return command @@ -397,26 +397,26 @@ func groupItemInputs(items []*walletv1.ResourceGroupItemInput) []resourcedomain. func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource { return &walletv1.Resource{ - AppCode: appcode.Normalize(resource.AppCode), - ResourceId: resource.ResourceID, - ResourceCode: resource.ResourceCode, - ResourceType: resource.ResourceType, - Name: resource.Name, - Status: resource.Status, - Grantable: resource.Grantable, - GrantStrategy: resource.GrantStrategy, - WalletAssetType: resource.WalletAssetType, - WalletAssetAmount: resource.WalletAssetAmount, - UsageScopes: resource.UsageScopes, - AssetUrl: resource.AssetURL, - PreviewUrl: resource.PreviewURL, - AnimationUrl: resource.AnimationURL, - MetadataJson: resource.MetadataJSON, - SortOrder: resource.SortOrder, - CreatedByUserId: resource.CreatedByUserID, - UpdatedByUserId: resource.UpdatedByUserID, - CreatedAtMs: resource.CreatedAtMS, - UpdatedAtMs: resource.UpdatedAtMS, + AppCode: appcode.Normalize(resource.AppCode), + ResourceId: resource.ResourceID, + ResourceCode: resource.ResourceCode, + ResourceType: resource.ResourceType, + Name: resource.Name, + Status: resource.Status, + Grantable: resource.Grantable, + GrantStrategy: resource.GrantStrategy, + WalletAssetType: resource.WalletAssetType, + WalletAssetAmount: resource.WalletAssetAmount, + UsageScopes: resource.UsageScopes, + AssetUrl: resource.AssetURL, + PreviewUrl: resource.PreviewURL, + AnimationUrl: resource.AnimationURL, + MetadataJson: resource.MetadataJSON, + SortOrder: resource.SortOrder, + CreatedByUserId: resource.CreatedByUserID, + UpdatedByUserId: resource.UpdatedByUserID, + CreatedAtMs: resource.CreatedAtMS, + UpdatedAtMs: resource.UpdatedAtMS, ManagerGrantEnabled: resource.ManagerGrantEnabled, } }