进房接口
This commit is contained in:
parent
fedd4ea416
commit
b12c774bb3
File diff suppressed because it is too large
Load Diff
@ -526,6 +526,8 @@ message RoomSnapshot {
|
|||||||
RoomRocketState rocket = 20;
|
RoomRocketState rocket = 20;
|
||||||
// ban_states 保存 ban 的过期边界;ban_user_ids 仍只服务简单集合判断。
|
// ban_states 保存 ban 的过期边界;ban_user_ids 仍只服务简单集合判断。
|
||||||
repeated RoomBanState ban_states = 21;
|
repeated RoomBanState ban_states = 21;
|
||||||
|
// online_count 允许 lite 响应不携带 online_users 明细仍保留准确在线人数。
|
||||||
|
int32 online_count = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoomBackgroundImage 是房间维度保存过的背景图素材。
|
// RoomBackgroundImage 是房间维度保存过的背景图素材。
|
||||||
@ -656,6 +658,8 @@ message JoinRoomRequest {
|
|||||||
bool actor_is_robot = 7;
|
bool actor_is_robot = 7;
|
||||||
// actor_display_profile 是进房用户展示资料快照,room-service 只透传到进房 IM/outbox,不写入 RoomState。
|
// actor_display_profile 是进房用户展示资料快照,room-service 只透传到进房 IM/outbox,不写入 RoomState。
|
||||||
RoomUserDisplayProfile actor_display_profile = 8;
|
RoomUserDisplayProfile actor_display_profile = 8;
|
||||||
|
// response_mode=lite 只裁剪响应快照,不改变 Room Cell 命令提交、outbox 或 IM 事件语义。
|
||||||
|
string response_mode = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinRoomResponse 返回加入后的房间快照。
|
// JoinRoomResponse 返回加入后的房间快照。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -691,6 +691,25 @@ message BatchGetUsersResponse {
|
|||||||
map<int64, User> users = 1;
|
map<int64, User> users = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomBasicUser 是房间首屏只需要的用户展示基础资料。
|
||||||
|
message RoomBasicUser {
|
||||||
|
int64 user_id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string avatar = 3;
|
||||||
|
string display_user_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsersRequest 为房间进房 lite 首屏批量读取最小用户资料。
|
||||||
|
message BatchGetRoomBasicUsersRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
repeated int64 user_ids = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsersResponse 返回按 user_id 索引的房间首屏基础资料。
|
||||||
|
message BatchGetRoomBasicUsersResponse {
|
||||||
|
map<int64, RoomBasicUser> users = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserIDsRequest 给低频后台 fanout/重算任务按 user_id 游标读取目标用户。
|
// ListUserIDsRequest 给低频后台 fanout/重算任务按 user_id 游标读取目标用户。
|
||||||
message ListUserIDsRequest {
|
message ListUserIDsRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
@ -1324,6 +1343,7 @@ service UserService {
|
|||||||
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
|
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
|
||||||
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
|
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
|
||||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||||
|
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
|
||||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||||
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
|
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const (
|
|||||||
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
||||||
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
||||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||||
|
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
|
||||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||||
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||||
@ -52,6 +53,7 @@ type UserServiceClient interface {
|
|||||||
BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error)
|
BusinessUserLookup(ctx context.Context, in *BusinessUserLookupRequest, opts ...grpc.CallOption) (*BusinessUserLookupResponse, error)
|
||||||
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
|
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
|
||||||
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||||
|
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
|
||||||
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
||||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
|
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
|
||||||
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
|
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
|
||||||
@ -127,6 +129,16 @@ func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsers
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(BatchGetRoomBasicUsersResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserService_BatchGetRoomBasicUsers_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *userServiceClient) ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error) {
|
func (c *userServiceClient) ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListUserIDsResponse)
|
out := new(ListUserIDsResponse)
|
||||||
@ -288,6 +300,7 @@ type UserServiceServer interface {
|
|||||||
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
|
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
|
||||||
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
|
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
|
||||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||||
|
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
|
||||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
||||||
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
|
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
|
||||||
@ -328,6 +341,9 @@ func (UnimplementedUserServiceServer) GetMyProfileStats(context.Context, *GetMyP
|
|||||||
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
|
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) {
|
func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListUserIDs not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListUserIDs not implemented")
|
||||||
}
|
}
|
||||||
@ -484,6 +500,24 @@ func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, de
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(BatchGetRoomBasicUsersRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserServiceServer).BatchGetRoomBasicUsers(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserService_BatchGetRoomBasicUsers_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserServiceServer).BatchGetRoomBasicUsers(ctx, req.(*BatchGetRoomBasicUsersRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _UserService_ListUserIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _UserService_ListUserIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListUserIDsRequest)
|
in := new(ListUserIDsRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -781,6 +815,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "BatchGetUsers",
|
MethodName: "BatchGetUsers",
|
||||||
Handler: _UserService_BatchGetUsers_Handler,
|
Handler: _UserService_BatchGetUsers_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "BatchGetRoomBasicUsers",
|
||||||
|
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListUserIDs",
|
MethodName: "ListUserIDs",
|
||||||
Handler: _UserService_ListUserIDs_Handler,
|
Handler: _UserService_ListUserIDs_Handler,
|
||||||
|
|||||||
282
docs/flutter对接/进房接口Flutter对接.md
Normal file
282
docs/flutter对接/进房接口Flutter对接.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# 进房接口 Flutter 对接
|
||||||
|
|
||||||
|
## 接口地址
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/rooms/join
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
新版本进房默认使用 `response_mode=lite`。`full` 仍保留给旧客户端兼容,不建议新 Flutter 首屏使用。
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `room_id` | string | 是 | 房间 ID。 |
|
||||||
|
| `command_id` | string | 是 | 本次进房命令 ID。同一次点击超时重试复用同一个值,下一次重新进房换新值。 |
|
||||||
|
| `role` | string | 否 | 不传默认 `audience`。房主身份由服务端按房间 owner 判断,客户端不要靠传 `owner` 获取权限。 |
|
||||||
|
| `password` | string | 否 | 密码房传房间密码,非密码房不传或传空。 |
|
||||||
|
| `response_mode` | string | 是 | 新版固定传 `lite`。可选值:`lite` / `full`。 |
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "lalu_abc123",
|
||||||
|
"command_id": "cmd_join_room_lalu_abc123_10001_1778000001000",
|
||||||
|
"role": "audience",
|
||||||
|
"password": "",
|
||||||
|
"response_mode": "lite"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也支持 query 参数:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/rooms/join?response_mode=lite
|
||||||
|
```
|
||||||
|
|
||||||
|
Flutter 建议放在 body 里,避免调用层漏拼 query。
|
||||||
|
|
||||||
|
## 返回值
|
||||||
|
|
||||||
|
统一外壳:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "OK",
|
||||||
|
"message": "ok",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "OK",
|
||||||
|
"message": "ok",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"data": {
|
||||||
|
"result": {
|
||||||
|
"applied": true,
|
||||||
|
"room_version": 8,
|
||||||
|
"server_time_ms": 1778000001123
|
||||||
|
},
|
||||||
|
"room": {
|
||||||
|
"room_id": "lalu_abc123",
|
||||||
|
"im_group_id": "lalu_abc123",
|
||||||
|
"room_short_id": "10001",
|
||||||
|
"title": "Live Room",
|
||||||
|
"cover_url": "https://cdn.example/room.png",
|
||||||
|
"background_url": "https://cdn.example/bg.png",
|
||||||
|
"description": "welcome",
|
||||||
|
"owner_user_id": "10001",
|
||||||
|
"mode": "voice",
|
||||||
|
"status": "active",
|
||||||
|
"chat_enabled": true,
|
||||||
|
"locked": false,
|
||||||
|
"heat": 88,
|
||||||
|
"online_count": 200,
|
||||||
|
"seat_count": 10,
|
||||||
|
"occupied_seat_count": 2,
|
||||||
|
"visible_region_id": 1001,
|
||||||
|
"version": 8
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"user_id": "10001",
|
||||||
|
"role": "audience",
|
||||||
|
"joined_at_ms": 1778000001000,
|
||||||
|
"last_seen_at_ms": 1778000001100
|
||||||
|
},
|
||||||
|
"seats": [
|
||||||
|
{
|
||||||
|
"seat_no": 1,
|
||||||
|
"user_id": "10002",
|
||||||
|
"locked": false,
|
||||||
|
"publish_state": "publishing",
|
||||||
|
"mic_session_id": "mic_10002",
|
||||||
|
"publish_deadline_ms": 1778000030000,
|
||||||
|
"mic_muted": false,
|
||||||
|
"seat_status": "publishing",
|
||||||
|
"mic_heartbeat_at_ms": 1778000000000,
|
||||||
|
"gift_value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seat_no": 2,
|
||||||
|
"locked": false,
|
||||||
|
"mic_muted": false,
|
||||||
|
"gift_value": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"user_id": "10001",
|
||||||
|
"username": "host",
|
||||||
|
"avatar": "https://cdn.example/avatar.png",
|
||||||
|
"display_user_id": "10001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_id": "10002",
|
||||||
|
"username": "speaker",
|
||||||
|
"avatar": "https://cdn.example/avatar2.png",
|
||||||
|
"display_user_id": "10002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"im": {
|
||||||
|
"group_id": "lalu_abc123",
|
||||||
|
"need_join_group": true
|
||||||
|
},
|
||||||
|
"rtc": {
|
||||||
|
"need_token": true,
|
||||||
|
"available": true,
|
||||||
|
"token": {
|
||||||
|
"sdk_app_id": 1400000000,
|
||||||
|
"user_id": "10001",
|
||||||
|
"rtc_user_id": "10001",
|
||||||
|
"user_sig": "xxx",
|
||||||
|
"expire_at_ms": 1778007200000,
|
||||||
|
"room_id": "lalu_abc123",
|
||||||
|
"rtc_room_id": "lalu_abc123",
|
||||||
|
"rtc_room_id_type": "string",
|
||||||
|
"str_room_id": "lalu_abc123",
|
||||||
|
"app_scene": "voice_chat_room",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server_time_ms": 1778000001123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `data.result.applied` | 本次进房是否真正写入。重复 `command_id` 重试可能为 `false`。 |
|
||||||
|
| `data.result.room_version` | 房间版本,用于客户端状态更新判断。 |
|
||||||
|
| `data.room.room_id` | 房间 ID。 |
|
||||||
|
| `data.room.im_group_id` | 腾讯 IM 房间群 ID。当前通常等于 `room_id`,客户端仍以返回值为准。 |
|
||||||
|
| `data.room.online_count` | 房间在线人数。`lite` 不返回在线用户明细,只返回人数。 |
|
||||||
|
| `data.room.seat_count` | 麦位总数。 |
|
||||||
|
| `data.room.occupied_seat_count` | 已占用麦位数。 |
|
||||||
|
| `data.viewer` | 当前登录用户在房间内的 presence。 |
|
||||||
|
| `data.viewer.role` | 当前用户房间角色,房主会由服务端返回 `owner`。 |
|
||||||
|
| `data.seats` | 首屏麦位状态。 |
|
||||||
|
| `data.seats[].user_id` | 麦上用户 ID;空麦不返回该字段。 |
|
||||||
|
| `data.seats[].publish_state` | 麦上发流状态,常见值:`pending_publish` / `publishing`。 |
|
||||||
|
| `data.seats[].mic_session_id` | 麦位发流会话 ID。麦上心跳、发流确认需要使用它。 |
|
||||||
|
| `data.seats[].seat_status` | 麦位展示状态,常见值:`empty` / `locked` / `occupied` / `publishing` / `muted`。 |
|
||||||
|
| `data.profiles` | 首屏基础资料,只包含房主、当前用户、麦上用户。 |
|
||||||
|
| `data.profiles[].username` | 昵称。 |
|
||||||
|
| `data.profiles[].avatar` | 头像。 |
|
||||||
|
| `data.profiles[].display_user_id` | 展示 ID。 |
|
||||||
|
| `data.im.group_id` | Flutter 调腾讯 IM `joinGroup` 使用的群 ID。 |
|
||||||
|
| `data.im.need_join_group` | 为 `true` 时需要加入腾讯 IM 群。 |
|
||||||
|
| `data.rtc.available` | 是否拿到 RTC 票据。 |
|
||||||
|
| `data.rtc.token` | RTC 进房票据。`available=false` 时可能为空。 |
|
||||||
|
| `data.rtc.reason` | RTC 不可用原因。业务进房成功时,RTC 不可用不代表进房失败。 |
|
||||||
|
| `data.server_time_ms` | 服务端时间,UTC epoch milliseconds。 |
|
||||||
|
|
||||||
|
`lite` 不返回这些内容:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `contribution_rank` | 贡献榜不在进房首屏主包返回。 |
|
||||||
|
| `profiles[].avatar_frame` | 头像框后置拉。 |
|
||||||
|
| `profiles[].profile_card` | 资料卡后置拉。 |
|
||||||
|
| `profiles[].vehicle` | 座驾后置拉。 |
|
||||||
|
| `profiles[].mic_seat_animation` | 麦位动画后置拉。 |
|
||||||
|
| `profiles[].badges` | 等级/成就徽章后置拉。 |
|
||||||
|
| `profiles[].vip` | VIP 展示后置拉。 |
|
||||||
|
|
||||||
|
## 相关 IM
|
||||||
|
|
||||||
|
进房成功后 Flutter 执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. POST /api/v1/rooms/join,body 传 response_mode=lite
|
||||||
|
2. 如果 data.im.need_join_group=true,调用腾讯 IM joinGroup(data.im.group_id)
|
||||||
|
3. joinGroup 成功或已在群内,都按进房 IM 成功处理
|
||||||
|
4. 页面开始房间 heartbeat
|
||||||
|
5. 如果 data.rtc.available=true,用 data.rtc.token 进入 RTC
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| IM 登录 | 进房接口不返回 IM UserSig。Flutter 仍然通过 `GET /api/v1/im/usersig` 获取并登录腾讯 IM。 |
|
||||||
|
| IM 入群 | 必须先 `JoinRoom` 成功,再调腾讯 IM `joinGroup`。不要先入 IM 群。 |
|
||||||
|
| 入房广播 | 服务端会发 `RoomUserJoined` 房间事件。Flutter 按现有房间 IM 事件逻辑展示入房飘窗。 |
|
||||||
|
| 重复进房 | 用户重新打开房间页或重连时,服务端仍会给展示用入房事件,Flutter 正常展示即可。 |
|
||||||
|
| 入场座驾 | `lite` 进房不携带座驾快照。座驾、头像框、资料卡、麦位动画等展示资料在进房 UI 出来后异步补齐。 |
|
||||||
|
|
||||||
|
## 后置资料接口
|
||||||
|
|
||||||
|
进房首屏出来后,再按需要异步拉展示资料:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/users/room-display-profiles:batch?user_ids=10001,10002
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `user_ids` | string | 是 | 逗号分隔的用户 ID。也支持多个 `user_id=10001&user_id=10002`。 |
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "OK",
|
||||||
|
"message": "ok",
|
||||||
|
"request_id": "req_xxx",
|
||||||
|
"data": {
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"user_id": "10001",
|
||||||
|
"username": "host",
|
||||||
|
"avatar": "https://cdn.example/avatar.png",
|
||||||
|
"display_user_id": "10001",
|
||||||
|
"avatar_frame": {},
|
||||||
|
"profile_card": {},
|
||||||
|
"vehicle": {},
|
||||||
|
"mic_seat_animation": {},
|
||||||
|
"badges": [],
|
||||||
|
"vip": {},
|
||||||
|
"level": {},
|
||||||
|
"level_badges": {},
|
||||||
|
"charm": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前用户自己的装扮也可以后置拉:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/appearance
|
||||||
|
GET /api/v1/users/me/appearance
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
| code | HTTP | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `INVALID_JSON` | 400 | 请求体不是合法 JSON。 |
|
||||||
|
| `INVALID_ARGUMENT` | 400 | 参数错误,例如 `response_mode` 不是 `lite` 或 `full`。 |
|
||||||
|
| `UNAUTHORIZED` | 401 | 未登录或 token 无效。 |
|
||||||
|
| `PROFILE_REQUIRED` | 403 | 用户资料未补全。 |
|
||||||
|
| `PERMISSION_DENIED` | 403 | 密码错误、被踢/被禁等权限问题。 |
|
||||||
|
| `ROOM_CLOSED` | 409 | 房间已关闭;非房主不能进入。 |
|
||||||
|
| `NOT_FOUND` | 404 | 房间不存在。 |
|
||||||
|
| `UPSTREAM_ERROR` | 502 | 上游服务异常。 |
|
||||||
@ -39,6 +39,7 @@ type UserProfileClient interface {
|
|||||||
BusinessUserLookup(ctx context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error)
|
BusinessUserLookup(ctx context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error)
|
||||||
GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error)
|
GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error)
|
||||||
BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
|
BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
|
||||||
|
BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error)
|
||||||
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
|
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
|
||||||
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
|
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
|
||||||
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
|
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
|
||||||
@ -355,6 +356,10 @@ func (c *grpcUserProfileClient) BatchGetUsers(ctx context.Context, req *userv1.B
|
|||||||
return c.client.BatchGetUsers(ctx, req)
|
return c.client.BatchGetUsers(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *grpcUserProfileClient) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
return c.client.BatchGetRoomBasicUsers(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *grpcUserProfileClient) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
func (c *grpcUserProfileClient) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
||||||
return c.client.CompleteOnboarding(ctx, req)
|
return c.client.CompleteOnboarding(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,10 @@ func (f *fakeInviteActivityUserProfileClient) BatchGetUsers(_ context.Context, r
|
|||||||
return &userv1.BatchGetUsersResponse{Users: f.users}, nil
|
return &userv1.BatchGetUsersResponse{Users: f.users}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeInviteActivityUserProfileClient) BatchGetRoomBasicUsers(context.Context, *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
return &userv1.BatchGetRoomBasicUsersResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeInviteActivityUserProfileClient) GetUser(context.Context, *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
func (f *fakeInviteActivityUserProfileClient) GetUser(context.Context, *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||||
return &userv1.GetUserResponse{}, nil
|
return &userv1.GetUserResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -318,6 +318,8 @@ type fakeUserProfileClient struct {
|
|||||||
lastStats *userv1.GetMyProfileStatsRequest
|
lastStats *userv1.GetMyProfileStatsRequest
|
||||||
lastBatch *userv1.BatchGetUsersRequest
|
lastBatch *userv1.BatchGetUsersRequest
|
||||||
batchRequests []*userv1.BatchGetUsersRequest
|
batchRequests []*userv1.BatchGetUsersRequest
|
||||||
|
lastRoomBasicBatch *userv1.BatchGetRoomBasicUsersRequest
|
||||||
|
roomBasicRequests []*userv1.BatchGetRoomBasicUsersRequest
|
||||||
getRequests []*userv1.GetUserRequest
|
getRequests []*userv1.GetUserRequest
|
||||||
lastComplete *userv1.CompleteOnboardingRequest
|
lastComplete *userv1.CompleteOnboardingRequest
|
||||||
lastUpdate *userv1.UpdateUserProfileRequest
|
lastUpdate *userv1.UpdateUserProfileRequest
|
||||||
@ -958,6 +960,32 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
|
|||||||
return &userv1.BatchGetUsersResponse{Users: users}, nil
|
return &userv1.BatchGetUsersResponse{Users: users}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserProfileClient) BatchGetRoomBasicUsers(_ context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
f.lastRoomBasicBatch = req
|
||||||
|
f.roomBasicRequests = append(f.roomBasicRequests, req)
|
||||||
|
users := make(map[int64]*userv1.RoomBasicUser, len(req.GetUserIds()))
|
||||||
|
for _, userID := range req.GetUserIds() {
|
||||||
|
if f.usersByID != nil {
|
||||||
|
if user, ok := f.usersByID[userID]; ok {
|
||||||
|
users[userID] = &userv1.RoomBasicUser{
|
||||||
|
UserId: user.GetUserId(),
|
||||||
|
Username: user.GetUsername(),
|
||||||
|
Avatar: user.GetAvatar(),
|
||||||
|
DisplayUserId: user.GetDisplayUserId(),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users[userID] = &userv1.RoomBasicUser{
|
||||||
|
UserId: userID,
|
||||||
|
Username: "user-" + strconv.FormatInt(userID, 10),
|
||||||
|
Avatar: "https://cdn.example/avatar.png",
|
||||||
|
DisplayUserId: strconv.FormatInt(100000+userID, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &userv1.BatchGetRoomBasicUsersResponse{Users: users}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
||||||
f.lastComplete = req
|
f.lastComplete = req
|
||||||
if f.completeErr != nil {
|
if f.completeErr != nil {
|
||||||
@ -3290,6 +3318,96 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.T) {
|
||||||
|
roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
|
||||||
|
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8, ServerTimeMs: 1_700_000_010_123},
|
||||||
|
User: &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_010_000, LastSeenAtMs: 1_700_000_010_100},
|
||||||
|
Room: &roomv1.RoomSnapshot{
|
||||||
|
RoomId: "room_lite",
|
||||||
|
OwnerUserId: 101,
|
||||||
|
Mode: "voice",
|
||||||
|
Status: "active",
|
||||||
|
ChatEnabled: true,
|
||||||
|
MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing"}, {SeatNo: 2, Locked: true}},
|
||||||
|
GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
|
||||||
|
RoomExt: map[string]string{"title": "Lite Room", "cover_url": "https://cdn.example/lite.png"},
|
||||||
|
Heat: 88,
|
||||||
|
Version: 8,
|
||||||
|
RoomShortId: "100102",
|
||||||
|
OnlineCount: 200,
|
||||||
|
Locked: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
walletClient := &fakeWalletClient{batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{}}
|
||||||
|
profileClient := &fakeUserProfileClient{}
|
||||||
|
handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
|
||||||
|
handler.SetWalletClient(walletClient)
|
||||||
|
handler.SetTencentRTC(TencentRTCConfig{
|
||||||
|
Enabled: true,
|
||||||
|
SDKAppID: 1400000000,
|
||||||
|
SecretKey: "secret",
|
||||||
|
UserSigTTL: 2 * time.Hour,
|
||||||
|
RoomIDType: "string",
|
||||||
|
AppScene: "voice_chat_room",
|
||||||
|
})
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite","command_id":"cmd-join-lite","role":"audience","response_mode":"lite"}`)))
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-join-lite")
|
||||||
|
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 roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || roomClient.lastJoin.GetEntryVehicle() != nil {
|
||||||
|
t.Fatalf("lite join must pass response_mode and skip entry vehicle: %+v", roomClient.lastJoin)
|
||||||
|
}
|
||||||
|
if len(walletClient.batchEquippedRequests) != 0 {
|
||||||
|
t.Fatalf("lite join must not query wallet appearance or vehicle resources: %+v", walletClient.batchEquippedRequests)
|
||||||
|
}
|
||||||
|
if len(profileClient.batchRequests) != 0 {
|
||||||
|
t.Fatalf("lite join must not call full BatchGetUsers: %+v", profileClient.batchRequests)
|
||||||
|
}
|
||||||
|
if profileClient.lastRoomBasicBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastRoomBasicBatch.GetUserIds()), ",") != "101,42,102" {
|
||||||
|
t.Fatalf("lite join must batch only owner/viewer/mic basic users: %+v", profileClient.lastRoomBasicBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response httpkit.ResponseEnvelope
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("decode lite join response failed: %v", err)
|
||||||
|
}
|
||||||
|
data := response.Data.(map[string]any)
|
||||||
|
if _, exists := data["contribution_rank"]; exists {
|
||||||
|
t.Fatalf("lite join must not include contribution rank: %+v", data["contribution_rank"])
|
||||||
|
}
|
||||||
|
room := data["room"].(map[string]any)
|
||||||
|
if room["room_id"] != "room_lite" || room["online_count"].(float64) != 200 {
|
||||||
|
t.Fatalf("lite join must keep core room fields and online_count: %+v", room)
|
||||||
|
}
|
||||||
|
profiles := data["profiles"].([]any)
|
||||||
|
if len(profiles) != 3 {
|
||||||
|
t.Fatalf("lite join must include basic profiles for owner/viewer/mic only: %+v", profiles)
|
||||||
|
}
|
||||||
|
viewerProfile := map[string]any{}
|
||||||
|
for _, item := range profiles {
|
||||||
|
profile := item.(map[string]any)
|
||||||
|
if profile["user_id"] == "42" {
|
||||||
|
viewerProfile = profile
|
||||||
|
}
|
||||||
|
if profile["user_id"] == "301" {
|
||||||
|
t.Fatalf("lite join must not hydrate contribution-rank users: %+v", profiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewerProfile["username"] != "user-42" || viewerProfile["avatar"] == "" || viewerProfile["display_user_id"] == "" {
|
||||||
|
t.Fatalf("lite profile must carry only basic display fields: %+v", viewerProfile)
|
||||||
|
}
|
||||||
|
if _, exists := viewerProfile["avatar_frame"]; exists {
|
||||||
|
t.Fatalf("lite profile must not carry appearance fields: %+v", viewerProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
||||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||||
regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
|
regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
|
||||||
|
|||||||
@ -37,6 +37,10 @@ const (
|
|||||||
// 机器人房标记来自 room-service RoomExt;gateway 只用它决定首屏资料是否补机器人头像昵称。
|
// 机器人房标记来自 room-service RoomExt;gateway 只用它决定首屏资料是否补机器人头像昵称。
|
||||||
roomExtRobotRoomKey = "robot_room"
|
roomExtRobotRoomKey = "robot_room"
|
||||||
roomExtRobotUserIDsKey = "robot_user_ids"
|
roomExtRobotUserIDsKey = "robot_user_ids"
|
||||||
|
|
||||||
|
// JoinRoom 默认保持历史完整包;lite 只保留进房首屏必要字段,把装扮/榜单等增强展示后移。
|
||||||
|
joinRoomResponseModeFull = "full"
|
||||||
|
joinRoomResponseModeLite = "lite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type flexibleInt64 int64
|
type flexibleInt64 int64
|
||||||
@ -1092,15 +1096,21 @@ func trimOptionalString(value *string) {
|
|||||||
// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令,并组装房间首屏初始化包。
|
// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令,并组装房间首屏初始化包。
|
||||||
func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
ResponseMode string `json:"response_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if !httpkit.Decode(writer, request, &body) {
|
if !httpkit.Decode(writer, request, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
responseMode, ok := joinRoomResponseMode(request, body.ResponseMode)
|
||||||
|
if !ok {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid response_mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
|
actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
@ -1115,16 +1125,42 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
|||||||
ActorRegionId: actorSnapshot.regionID,
|
ActorRegionId: actorSnapshot.regionID,
|
||||||
ActorDisplayProfile: actorSnapshot.displayProfile,
|
ActorDisplayProfile: actorSnapshot.displayProfile,
|
||||||
// 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。
|
// 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。
|
||||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
|
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request, responseMode),
|
||||||
|
ResponseMode: responseMode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if responseMode == joinRoomResponseModeLite {
|
||||||
|
httpkit.WriteOK(writer, request, h.joinRoomInitialLiteData(request, body.RoomID, resp))
|
||||||
|
return
|
||||||
|
}
|
||||||
httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
|
httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request) *roomv1.RoomEntryVehicleSnapshot {
|
func joinRoomResponseMode(request *http.Request, bodyMode string) (string, bool) {
|
||||||
|
mode := strings.TrimSpace(bodyMode)
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.TrimSpace(request.URL.Query().Get("response_mode"))
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
return joinRoomResponseModeFull, true
|
||||||
|
}
|
||||||
|
mode = strings.ToLower(mode)
|
||||||
|
switch mode {
|
||||||
|
case joinRoomResponseModeFull, joinRoomResponseModeLite:
|
||||||
|
return mode, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request, responseMode string) *roomv1.RoomEntryVehicleSnapshot {
|
||||||
|
if responseMode == joinRoomResponseModeLite {
|
||||||
|
// lite 进房只提交 presence 必需事实;座驾属于入场展示增强,后续由资料/装扮接口补齐。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if h.walletClient == nil {
|
if h.walletClient == nil {
|
||||||
// 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。
|
// 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。
|
||||||
return nil
|
return nil
|
||||||
@ -1198,6 +1234,30 @@ func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) joinRoomInitialLiteData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomLiteData {
|
||||||
|
if resp == nil {
|
||||||
|
return joinRoomLiteData{}
|
||||||
|
}
|
||||||
|
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||||
|
snapshot := resp.GetRoom()
|
||||||
|
roomID := snapshot.GetRoomId()
|
||||||
|
if roomID == "" {
|
||||||
|
// lite 和完整包共用同一兜底,避免异常上游只回 result 时客户端拿不到 IM/RTC 房间 ID。
|
||||||
|
roomID = strings.TrimSpace(requestedRoomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinRoomLiteData{
|
||||||
|
Result: commandResultDataFromProto(resp.GetResult()),
|
||||||
|
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
|
||||||
|
Viewer: roomViewerDataFromProto(resp.GetUser(), viewerUserID),
|
||||||
|
Seats: roomSeatDataFromSnapshot(snapshot),
|
||||||
|
Profiles: h.roomInitialBasicProfiles(request, snapshot, viewerUserID),
|
||||||
|
IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
|
||||||
|
RTC: h.joinRoomRTCData(roomID, viewerUserID),
|
||||||
|
ServerTimeMS: resp.GetResult().GetServerTimeMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData {
|
func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData {
|
||||||
rtc := roomRTCData{NeedToken: true}
|
rtc := roomRTCData{NeedToken: true}
|
||||||
token, err := h.generateTencentRTCToken(viewerUserID, roomID)
|
token, err := h.generateTencentRTCToken(viewerUserID, roomID)
|
||||||
@ -1219,6 +1279,50 @@ func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.Ro
|
|||||||
return h.roomDisplayProfiles(request, userIDs)
|
return h.roomDisplayProfiles(request, userIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) roomInitialBasicProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomBasicProfileData {
|
||||||
|
userIDs := roomInitialBasicProfileUserIDs(snapshot, viewerUserID)
|
||||||
|
profileMap := h.roomBasicProfileMap(request, userIDs)
|
||||||
|
profiles := make([]roomBasicProfileData, 0, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if profile, ok := profileMap[userID]; ok {
|
||||||
|
profiles = append(profiles, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) roomBasicProfileMap(request *http.Request, userIDs []int64) map[int64]roomBasicProfileData {
|
||||||
|
if h.userProfileClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
userIDs = uniquePositiveUserIDs(userIDs)
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp, err := h.userProfileClient.BatchGetRoomBasicUsers(request.Context(), &userv1.BatchGetRoomBasicUsersRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserIds: userIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// lite 首屏资料是展示兜底,失败不能回滚已经成功的进房 presence。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
profiles := make(map[int64]roomBasicProfileData, len(resp.GetUsers()))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
user := resp.GetUsers()[userID]
|
||||||
|
if user == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
profiles[userID] = roomBasicProfileData{
|
||||||
|
UserID: formatOptionalUserID(user.GetUserId()),
|
||||||
|
Username: strings.TrimSpace(user.GetUsername()),
|
||||||
|
Avatar: strings.TrimSpace(user.GetAvatar()),
|
||||||
|
DisplayUserID: strings.TrimSpace(user.GetDisplayUserId()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
|
// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
|
||||||
func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
|
|||||||
@ -73,6 +73,17 @@ type joinRoomData struct {
|
|||||||
ServerTimeMS int64 `json:"server_time_ms"`
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type joinRoomLiteData struct {
|
||||||
|
Result roomCommandResultData `json:"result"`
|
||||||
|
Room roomInitialData `json:"room"`
|
||||||
|
Viewer roomViewerData `json:"viewer"`
|
||||||
|
Seats []roomSeatData `json:"seats"`
|
||||||
|
Profiles []roomBasicProfileData `json:"profiles"`
|
||||||
|
IM roomIMData `json:"im"`
|
||||||
|
RTC roomRTCData `json:"rtc"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
type roomDetailData struct {
|
type roomDetailData struct {
|
||||||
Room roomInitialData `json:"room"`
|
Room roomInitialData `json:"room"`
|
||||||
Viewer roomViewerData `json:"viewer"`
|
Viewer roomViewerData `json:"viewer"`
|
||||||
@ -176,6 +187,13 @@ type roomDisplayProfileData struct {
|
|||||||
Charm int64 `json:"charm"`
|
Charm int64 `json:"charm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type roomBasicProfileData struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
DisplayUserID string `json:"display_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type roomGiftPanelData struct {
|
type roomGiftPanelData struct {
|
||||||
CoinBalance int64 `json:"coin_balance"`
|
CoinBalance int64 `json:"coin_balance"`
|
||||||
Recipients []roomGiftRecipientData `json:"recipients"`
|
Recipients []roomGiftRecipientData `json:"recipients"`
|
||||||
@ -797,6 +815,11 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
|
|||||||
roomShortID = ext["room_short_id"]
|
roomShortID = ext["room_short_id"]
|
||||||
}
|
}
|
||||||
seatCount, occupiedSeatCount := roomSeatCounts(snapshot)
|
seatCount, occupiedSeatCount := roomSeatCounts(snapshot)
|
||||||
|
onlineCount := snapshot.GetOnlineCount()
|
||||||
|
if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
|
||||||
|
// 老快照或非 lite 上游可能还没有 online_count 字段;此时用明细长度保持兼容。
|
||||||
|
onlineCount = int32(len(snapshot.GetOnlineUsers()))
|
||||||
|
}
|
||||||
return roomInitialData{
|
return roomInitialData{
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
IMGroupID: roomIMGroupID(roomID),
|
IMGroupID: roomIMGroupID(roomID),
|
||||||
@ -811,7 +834,7 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
|
|||||||
ChatEnabled: snapshot.GetChatEnabled(),
|
ChatEnabled: snapshot.GetChatEnabled(),
|
||||||
Locked: snapshot.GetLocked(),
|
Locked: snapshot.GetLocked(),
|
||||||
Heat: snapshot.GetHeat(),
|
Heat: snapshot.GetHeat(),
|
||||||
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
|
OnlineCount: onlineCount,
|
||||||
SeatCount: seatCount,
|
SeatCount: seatCount,
|
||||||
OccupiedSeatCount: occupiedSeatCount,
|
OccupiedSeatCount: occupiedSeatCount,
|
||||||
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
||||||
@ -967,6 +990,25 @@ func roomInitialProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64
|
|||||||
return userIDs
|
return userIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func roomInitialBasicProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64) []int64 {
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+2)
|
||||||
|
add := func(userID int64) {
|
||||||
|
if userID <= 0 || seen[userID] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[userID] = true
|
||||||
|
userIDs = append(userIDs, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(snapshot.GetOwnerUserId())
|
||||||
|
add(viewerUserID)
|
||||||
|
for _, seat := range snapshot.GetMicSeats() {
|
||||||
|
add(seat.GetUserId())
|
||||||
|
}
|
||||||
|
return userIDs
|
||||||
|
}
|
||||||
|
|
||||||
func roomOnlineUserDataFromProto(resp *roomv1.ListRoomOnlineUsersResponse, profiles map[int64]roomDisplayProfileData) roomOnlineUsersData {
|
func roomOnlineUserDataFromProto(resp *roomv1.ListRoomOnlineUsersResponse, profiles map[int64]roomDisplayProfileData) roomOnlineUsersData {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return roomOnlineUsersData{}
|
return roomOnlineUsersData{}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/room/command"
|
||||||
"hyapp/services/room-service/internal/room/state"
|
"hyapp/services/room-service/internal/room/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -109,6 +110,49 @@ func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectRoomPresenceForCommandBestEffort 按命令类型选择最小投影;Join/Leave 热路径不能再重写整房间在线集合。
|
||||||
|
func (s *Service) projectRoomPresenceForCommandBestEffort(ctx context.Context, cmd command.Command, snapshot *roomv1.RoomSnapshot, joinedUser *roomv1.RoomUser) {
|
||||||
|
switch cmd.Type() {
|
||||||
|
case (command.JoinRoom{}).Type():
|
||||||
|
s.upsertJoinedRoomPresenceBestEffort(ctx, snapshot, cmd.ActorUserID(), joinedUser)
|
||||||
|
case (command.LeaveRoom{}).Type():
|
||||||
|
s.markLeftRoomPresenceBestEffort(ctx, snapshot, cmd.ActorUserID())
|
||||||
|
default:
|
||||||
|
// Kick/Close/SystemEvict/Mic 变更仍先保留全量投影,确保复杂状态收敛不因本次 Join/Leave 优化改变语义。
|
||||||
|
s.projectRoomPresenceBestEffort(ctx, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) upsertJoinedRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot, userID int64, joinedUser *roomv1.RoomUser) {
|
||||||
|
projection := roomPresenceProjectionForUser(snapshot, userID, joinedUser, s.clock.Now().UnixMilli())
|
||||||
|
if projection.RoomID == "" || projection.UserID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.repository.UpsertRoomUserPresence(ctx, projection); err != nil {
|
||||||
|
logx.Error(ctx, "room_presence_join_project_failed", err,
|
||||||
|
slog.String("component", "room_presence_projector"),
|
||||||
|
slog.String("room_id", projection.RoomID),
|
||||||
|
slog.Int64("user_id", projection.UserID),
|
||||||
|
slog.Int64("room_version", projection.RoomVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markLeftRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot, userID int64) {
|
||||||
|
projection := roomPresenceLeaveProjection(snapshot, userID, s.clock.Now().UnixMilli())
|
||||||
|
if projection.RoomID == "" || projection.UserID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.repository.MarkRoomUserPresenceLeft(ctx, projection); err != nil {
|
||||||
|
logx.Error(ctx, "room_presence_leave_project_failed", err,
|
||||||
|
slog.String("component", "room_presence_projector"),
|
||||||
|
slog.String("room_id", projection.RoomID),
|
||||||
|
slog.Int64("user_id", projection.UserID),
|
||||||
|
slog.Int64("room_version", projection.RoomVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// roomPresenceProjectionFromSnapshot 把 Room Cell 快照压缩成用户当前房间读模型。
|
// roomPresenceProjectionFromSnapshot 把 Room Cell 快照压缩成用户当前房间读模型。
|
||||||
// 读模型只保存恢复入口需要的字段,不复制完整 RoomState,也不作为权限权威来源。
|
// 读模型只保存恢复入口需要的字段,不复制完整 RoomState,也不作为权限权威来源。
|
||||||
func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot {
|
func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot {
|
||||||
@ -161,6 +205,53 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func roomPresenceProjectionForUser(snapshot *roomv1.RoomSnapshot, userID int64, user *roomv1.RoomUser, updatedAtMS int64) RoomPresence {
|
||||||
|
if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive || userID <= 0 {
|
||||||
|
return RoomPresence{}
|
||||||
|
}
|
||||||
|
if user == nil || user.GetUserId() != userID {
|
||||||
|
user = findProtoUser(snapshot, userID)
|
||||||
|
}
|
||||||
|
if user == nil || user.GetUserId() <= 0 {
|
||||||
|
return RoomPresence{}
|
||||||
|
}
|
||||||
|
entry := RoomPresence{
|
||||||
|
AppCode: snapshot.GetAppCode(),
|
||||||
|
UserID: user.GetUserId(),
|
||||||
|
RoomID: snapshot.GetRoomId(),
|
||||||
|
Role: user.GetRole(),
|
||||||
|
RoomVersion: snapshot.GetVersion(),
|
||||||
|
Status: roomPresenceStatusActive,
|
||||||
|
JoinedAtMS: user.GetJoinedAtMs(),
|
||||||
|
LastSeenAtMS: user.GetLastSeenAtMs(),
|
||||||
|
UpdatedAtMS: updatedAtMS,
|
||||||
|
}
|
||||||
|
for _, seat := range snapshot.GetMicSeats() {
|
||||||
|
if seat.GetUserId() != user.GetUserId() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// JoinRoom 可能是重连到已在麦用户;单用户投影仍要保留麦上发流状态。
|
||||||
|
entry.PublishState = seat.GetPublishState()
|
||||||
|
entry.MicSessionID = seat.GetMicSessionId()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomPresenceLeaveProjection(snapshot *roomv1.RoomSnapshot, userID int64, updatedAtMS int64) RoomPresence {
|
||||||
|
if snapshot == nil || snapshot.GetRoomId() == "" || userID <= 0 {
|
||||||
|
return RoomPresence{}
|
||||||
|
}
|
||||||
|
return RoomPresence{
|
||||||
|
AppCode: snapshot.GetAppCode(),
|
||||||
|
UserID: userID,
|
||||||
|
RoomID: snapshot.GetRoomId(),
|
||||||
|
RoomVersion: snapshot.GetVersion(),
|
||||||
|
Status: roomPresenceStatusLeft,
|
||||||
|
UpdatedAtMS: updatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// containsUserID 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。
|
// containsUserID 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。
|
||||||
func containsUserID(values []int64, target int64) bool {
|
func containsUserID(values []int64, target int64) bool {
|
||||||
return slices.Contains(values, target)
|
return slices.Contains(values, target)
|
||||||
|
|||||||
@ -94,6 +94,95 @@ func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) {
|
|||||||
t.Logf("online users response items: %s", payload)
|
t.Logf("online users response items: %s", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJoinLeaveRoomUpdatesPresenceIncrementally(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)}
|
||||||
|
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
|
||||||
|
|
||||||
|
roomID := "room-presence-incremental"
|
||||||
|
ownerID := int64(1101)
|
||||||
|
viewerID := int64(1102)
|
||||||
|
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, viewerID)
|
||||||
|
|
||||||
|
onlineResp, err := svc.ListRoomOnlineUsers(ctx, &roomv1.ListRoomOnlineUsersRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
||||||
|
RoomId: roomID,
|
||||||
|
ViewerUserId: ownerID,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list online users after join failed: %v", err)
|
||||||
|
}
|
||||||
|
if item := onlineUserItemByID(onlineResp.GetItems(), viewerID); item == nil || item.GetRole() != "audience" {
|
||||||
|
t.Fatalf("join must upsert viewer active presence: %+v", onlineResp.GetItems())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, viewerID, "presence-incremental-leave")}); err != nil {
|
||||||
|
t.Fatalf("leave room failed: %v", err)
|
||||||
|
}
|
||||||
|
onlineResp, err = svc.ListRoomOnlineUsers(ctx, &roomv1.ListRoomOnlineUsersRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
||||||
|
RoomId: roomID,
|
||||||
|
ViewerUserId: ownerID,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list online users after leave failed: %v", err)
|
||||||
|
}
|
||||||
|
if item := onlineUserItemByID(onlineResp.GetItems(), viewerID); item != nil {
|
||||||
|
t.Fatalf("leave must mark only viewer presence left: %+v", onlineResp.GetItems())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkRoomUserPresenceLeftDoesNotClearAnotherRoom(t *testing.T) {
|
||||||
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
userID := int64(1201)
|
||||||
|
|
||||||
|
if err := repository.UpsertRoomUserPresence(ctx, roomservice.RoomPresence{
|
||||||
|
AppCode: appcode.Default,
|
||||||
|
UserID: userID,
|
||||||
|
RoomID: "room-current-active",
|
||||||
|
Role: "audience",
|
||||||
|
RoomVersion: 2,
|
||||||
|
JoinedAtMS: 1000,
|
||||||
|
LastSeenAtMS: 1000,
|
||||||
|
UpdatedAtMS: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert current room presence failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.MarkRoomUserPresenceLeft(ctx, roomservice.RoomPresence{
|
||||||
|
AppCode: appcode.Default,
|
||||||
|
UserID: userID,
|
||||||
|
RoomID: "room-old-left",
|
||||||
|
RoomVersion: 3,
|
||||||
|
UpdatedAtMS: 2000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("mark old room presence left failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
presence, exists, err := repository.GetCurrentRoomPresence(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get current room presence failed: %v", err)
|
||||||
|
}
|
||||||
|
if !exists || presence.RoomID != "room-current-active" || presence.Status != "active" {
|
||||||
|
t.Fatalf("old room leave projection must not clear newer room presence: exists=%v presence=%+v", exists, presence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlineUserItemByID(items []*roomv1.RoomOnlineUser, userID int64) *roomv1.RoomOnlineUser {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.GetUserId() == userID {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
|
func TestSendGiftPassesHostPeriodScopeToWallet(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -178,7 +178,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||||||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||||||
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
|
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
|
||||||
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
|
s.projectRoomPresenceForCommandBestEffort(ctx, cmd, result.snapshot, result.user)
|
||||||
// 跨房间贡献榜是 Redis 轻量读模型,不能影响已提交的房间状态和账务事实。
|
// 跨房间贡献榜是 Redis 轻量读模型,不能影响已提交的房间状态和账务事实。
|
||||||
s.recordRoomGiftLeaderboardBestEffort(ctx, result.roomGiftLeaderboard)
|
s.recordRoomGiftLeaderboardBestEffort(ctx, result.roomGiftLeaderboard)
|
||||||
projectionMS = elapsedMS(projectionStartedAt)
|
projectionMS = elapsedMS(projectionStartedAt)
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import (
|
|||||||
"hyapp/services/room-service/internal/room/state"
|
"hyapp/services/room-service/internal/room/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const joinRoomResponseModeLite = "lite"
|
||||||
|
|
||||||
// JoinRoom 把用户业务态接入房间。
|
// JoinRoom 把用户业务态接入房间。
|
||||||
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
||||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
@ -133,10 +135,38 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
return &roomv1.JoinRoomResponse{
|
return &roomv1.JoinRoomResponse{
|
||||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||||
User: result.user,
|
User: result.user,
|
||||||
Room: result.snapshot,
|
Room: joinRoomResponseSnapshot(result.snapshot, req.GetResponseMode()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func joinRoomResponseSnapshot(snapshot *roomv1.RoomSnapshot, responseMode string) *roomv1.RoomSnapshot {
|
||||||
|
if strings.ToLower(strings.TrimSpace(responseMode)) != joinRoomResponseModeLite || snapshot == nil {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
onlineCount := snapshot.GetOnlineCount()
|
||||||
|
if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
|
||||||
|
onlineCount = int32(len(snapshot.GetOnlineUsers()))
|
||||||
|
}
|
||||||
|
// lite 只裁剪返回给 gateway 的快照;Room Cell 提交、持久化、projection、outbox 都已经使用完整 snapshot。
|
||||||
|
return &roomv1.RoomSnapshot{
|
||||||
|
RoomId: snapshot.GetRoomId(),
|
||||||
|
OwnerUserId: snapshot.GetOwnerUserId(),
|
||||||
|
Mode: snapshot.GetMode(),
|
||||||
|
Status: snapshot.GetStatus(),
|
||||||
|
ChatEnabled: snapshot.GetChatEnabled(),
|
||||||
|
MicSeats: snapshot.GetMicSeats(),
|
||||||
|
RoomExt: snapshot.GetRoomExt(),
|
||||||
|
Heat: snapshot.GetHeat(),
|
||||||
|
Version: snapshot.GetVersion(),
|
||||||
|
AppCode: snapshot.GetAppCode(),
|
||||||
|
RoomShortId: snapshot.GetRoomShortId(),
|
||||||
|
VisibleRegionId: snapshot.GetVisibleRegionId(),
|
||||||
|
Locked: snapshot.GetLocked(),
|
||||||
|
Rocket: snapshot.GetRocket(),
|
||||||
|
OnlineCount: onlineCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
|
func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
|
||||||
if item == nil || item.GetUserId() <= 0 {
|
if item == nil || item.GetUserId() <= 0 {
|
||||||
return command.UserDisplayProfile{}
|
return command.UserDisplayProfile{}
|
||||||
|
|||||||
@ -784,6 +784,10 @@ type RoomListStore interface {
|
|||||||
type PresenceStore interface {
|
type PresenceStore interface {
|
||||||
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
|
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
|
||||||
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
|
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
|
||||||
|
// UpsertRoomUserPresence 只刷新单个用户 active presence,服务 JoinRoom/Heartbeat 等单用户热路径。
|
||||||
|
UpsertRoomUserPresence(ctx context.Context, presence RoomPresence) error
|
||||||
|
// MarkRoomUserPresenceLeft 只把指定用户在指定房间标记为 left,避免 LeaveRoom 重新写整房间在线用户。
|
||||||
|
MarkRoomUserPresenceLeft(ctx context.Context, presence RoomPresence) error
|
||||||
// GetCurrentRoomPresence 查询用户当前 active 房间 presence,不扫描房间列表或快照。
|
// GetCurrentRoomPresence 查询用户当前 active 房间 presence,不扫描房间列表或快照。
|
||||||
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
|
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
|
||||||
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
|
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
|
||||||
|
|||||||
@ -151,6 +151,42 @@ func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJoinRoomLiteTrimsResponseSnapshotOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)}
|
||||||
|
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, newRecordingRoomDirectIMPublisher())
|
||||||
|
|
||||||
|
roomID := "room-lite-response-snapshot"
|
||||||
|
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, 1002)
|
||||||
|
joinRocketRoom(t, ctx, svc, roomID, 1003)
|
||||||
|
|
||||||
|
resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, 1004),
|
||||||
|
Role: "audience",
|
||||||
|
ResponseMode: "lite",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lite join room failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetRoom().GetOnlineUsers()) != 0 || len(resp.GetRoom().GetGiftRank()) != 0 || len(resp.GetRoom().GetAdminUserIds()) != 0 {
|
||||||
|
t.Fatalf("lite response must trim heavy room arrays: %+v", resp.GetRoom())
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
||||||
|
RoomId: roomID,
|
||||||
|
ViewerUserId: 1004,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshot after lite join failed: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := resp.GetRoom().GetOnlineCount(), int32(len(snapshot.GetRoom().GetOnlineUsers())); got != want || want == 0 {
|
||||||
|
t.Fatalf("lite response must keep accurate online_count without mutating stored snapshot: got=%d want=%d snapshot=%+v", got, want, snapshot.GetRoom())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||||
w.lastDebit = req
|
w.lastDebit = req
|
||||||
w.debitRequests = append(w.debitRequests, req)
|
w.debitRequests = append(w.debitRequests, req)
|
||||||
|
|||||||
@ -465,6 +465,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
|||||||
Locked: s.RoomPasswordHash != "",
|
Locked: s.RoomPasswordHash != "",
|
||||||
Rocket: rocketStateToProto(s.Rocket),
|
Rocket: rocketStateToProto(s.Rocket),
|
||||||
BanStates: sortedModerationStates(s.BanUsers),
|
BanStates: sortedModerationStates(s.BanUsers),
|
||||||
|
OnlineCount: int32(len(users)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,62 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertRoomUserPresence 只写入单个 active 用户,避免 JoinRoom 为整房间在线用户产生 O(N) 写放大。
|
||||||
|
func (r *Repository) UpsertRoomUserPresence(ctx context.Context, presence roomservice.RoomPresence) error {
|
||||||
|
appCode := normalizedRecordAppCode(ctx, presence.AppCode)
|
||||||
|
if presence.UserID <= 0 || strings.TrimSpace(presence.RoomID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO room_user_presence (
|
||||||
|
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
room_id = VALUES(room_id),
|
||||||
|
role = VALUES(role),
|
||||||
|
publish_state = VALUES(publish_state),
|
||||||
|
mic_session_id = VALUES(mic_session_id),
|
||||||
|
room_version = VALUES(room_version),
|
||||||
|
status = VALUES(status),
|
||||||
|
joined_at_ms = VALUES(joined_at_ms),
|
||||||
|
last_seen_at_ms = VALUES(last_seen_at_ms),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appCode,
|
||||||
|
presence.UserID,
|
||||||
|
presence.RoomID,
|
||||||
|
presence.Role,
|
||||||
|
presence.PublishState,
|
||||||
|
presence.MicSessionID,
|
||||||
|
presence.RoomVersion,
|
||||||
|
roomPresenceStatusActiveSQL,
|
||||||
|
presence.JoinedAtMS,
|
||||||
|
presence.LastSeenAtMS,
|
||||||
|
presence.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRoomUserPresenceLeft 只关闭指定用户在指定房间的 active presence,避免旧离房投影误伤新房间。
|
||||||
|
func (r *Repository) MarkRoomUserPresenceLeft(ctx context.Context, presence roomservice.RoomPresence) error {
|
||||||
|
appCode := normalizedRecordAppCode(ctx, presence.AppCode)
|
||||||
|
if presence.UserID <= 0 || strings.TrimSpace(presence.RoomID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE room_user_presence
|
||||||
|
SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ? AND room_id = ? AND status = ?`,
|
||||||
|
roomPresenceStatusLeftSQL,
|
||||||
|
presence.RoomVersion,
|
||||||
|
presence.UpdatedAtMS,
|
||||||
|
appCode,
|
||||||
|
presence.UserID,
|
||||||
|
presence.RoomID,
|
||||||
|
roomPresenceStatusActiveSQL,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
|
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
|
||||||
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
|
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
|||||||
@ -294,6 +294,18 @@ type User struct {
|
|||||||
UpdatedAtMs int64
|
UpdatedAtMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomBasicUser 是房间首屏热路径使用的最小用户展示资料,不能混入国家、靓号详情或装扮字段。
|
||||||
|
type RoomBasicUser struct {
|
||||||
|
// UserID 是系统内部不可变用户 ID,调用方用它和麦位/房主/当前用户状态关联。
|
||||||
|
UserID int64
|
||||||
|
// Username 是用户当前基础昵称;为空时客户端按 display_user_id 或 user_id 兜底。
|
||||||
|
Username string
|
||||||
|
// Avatar 是用户当前基础头像 URL;装扮头像框不属于这个结构。
|
||||||
|
Avatar string
|
||||||
|
// DisplayUserID 是房间首屏展示短号;过期靓号在 SQL 内兜底为默认短号。
|
||||||
|
DisplayUserID string
|
||||||
|
}
|
||||||
|
|
||||||
// ProfileStats 是我的页顶部统计 read model,只保存预聚合计数。
|
// ProfileStats 是我的页顶部统计 read model,只保存预聚合计数。
|
||||||
type ProfileStats struct {
|
type ProfileStats struct {
|
||||||
// AppCode 是统计所属 App,关系链和礼物统计都必须在 App 内隔离。
|
// AppCode 是统计所属 App,关系链和礼物统计都必须在 App 内隔离。
|
||||||
|
|||||||
@ -216,6 +216,10 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeModerationRepository) BatchGetRoomBasicUsers(context.Context, []int64) (map[int64]userdomain.RoomBasicUser, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) FindInviteReferrerByCode(_ context.Context, appCode string, inviteCode string) (userdomain.User, error) {
|
func (r *fakeModerationRepository) FindInviteReferrerByCode(_ context.Context, appCode string, inviteCode string) (userdomain.User, error) {
|
||||||
r.lastFindAppCode = appCode
|
r.lastFindAppCode = appCode
|
||||||
r.lastFindInviteCode = inviteCode
|
r.lastFindInviteCode = inviteCode
|
||||||
|
|||||||
@ -45,6 +45,8 @@ type UserRepository interface {
|
|||||||
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
|
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
|
||||||
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
|
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
|
||||||
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
||||||
|
// BatchGetRoomBasicUsers 批量查询房间首屏最小用户资料,不触发完整用户主状态投影。
|
||||||
|
BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.RoomBasicUser, error)
|
||||||
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
||||||
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
||||||
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同区域邀请人。
|
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同区域邀请人。
|
||||||
|
|||||||
@ -94,6 +94,25 @@ func (s *Service) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsers 批量查询房间首屏最小用户资料。
|
||||||
|
func (s *Service) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.RoomBasicUser, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
// 空批量请求返回空 map,gateway 可以直接输出空 profiles。
|
||||||
|
return map[int64]userdomain.RoomBasicUser{}, nil
|
||||||
|
}
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if userID <= 0 {
|
||||||
|
// room-service 只接受正 user_id;这里继续对调用方输入做边界收敛。
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.userRepository == nil {
|
||||||
|
// 房间热路径也不能绕过 owner repository;无存储时明确返回不可用。
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||||
|
}
|
||||||
|
return s.userRepository.BatchGetRoomBasicUsers(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserIDs returns active user IDs for low-frequency backend fanout tasks.
|
// ListUserIDs returns active user IDs for low-frequency backend fanout tasks.
|
||||||
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
|
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
|
||||||
if s.userRepository == nil {
|
if s.userRepository == nil {
|
||||||
|
|||||||
@ -92,6 +92,49 @@ func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[in
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsers 只读取房间首屏需要的基础展示字段,避免 JoinRoom lite 触发完整用户投影。
|
||||||
|
func (r *Repository) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.RoomBasicUser, error) {
|
||||||
|
placeholders := make([]string, 0, len(userIDs))
|
||||||
|
args := make([]any, 0, len(userIDs)+2)
|
||||||
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
|
args = append(args, nowMS)
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
args = append(args, appcode.FromContext(ctx))
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT user_id,
|
||||||
|
COALESCE(username, ''),
|
||||||
|
COALESCE(avatar, ''),
|
||||||
|
CASE
|
||||||
|
WHEN current_display_user_id_kind <> 'default'
|
||||||
|
AND COALESCE(current_display_user_id_expires_at_ms, 0) > 0
|
||||||
|
AND current_display_user_id_expires_at_ms <= ?
|
||||||
|
THEN default_display_user_id
|
||||||
|
ELSE current_display_user_id
|
||||||
|
END
|
||||||
|
FROM users FORCE INDEX (PRIMARY)
|
||||||
|
WHERE user_id IN (%s)
|
||||||
|
AND app_code = ?
|
||||||
|
`, strings.Join(placeholders, ",")), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := make(map[int64]userdomain.RoomBasicUser, len(userIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var user userdomain.RoomBasicUser
|
||||||
|
if err := rows.Scan(&user.UserID, &user.Username, &user.Avatar, &user.DisplayUserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[user.UserID] = user
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserIDs returns active user IDs in ascending order for cursor-based backend fanout.
|
// ListUserIDs returns active user IDs in ascending order for cursor-based backend fanout.
|
||||||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||||||
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
|
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
|
||||||
|
|||||||
@ -129,6 +129,11 @@ func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[in
|
|||||||
return r.Repository.UserRepository().BatchGetUsers(ctx, userIDs)
|
return r.Repository.UserRepository().BatchGetUsers(ctx, userIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsers 让测试 wrapper 继续满足房间首屏基础资料查询接口。
|
||||||
|
func (r *Repository) BatchGetRoomBasicUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.RoomBasicUser, error) {
|
||||||
|
return r.Repository.UserRepository().BatchGetRoomBasicUsers(ctx, userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserIDs 让测试 wrapper 继续满足后台 fanout 目标用户查询接口。
|
// ListUserIDs 让测试 wrapper 继续满足后台 fanout 目标用户查询接口。
|
||||||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||||||
return r.Repository.UserRepository().ListUserIDs(ctx, filter)
|
return r.Repository.UserRepository().ListUserIDs(ctx, filter)
|
||||||
|
|||||||
@ -660,6 +660,26 @@ func (s *Server) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersReq
|
|||||||
return &userv1.BatchGetUsersResponse{Users: result}, nil
|
return &userv1.BatchGetUsersResponse{Users: result}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetRoomBasicUsers 批量返回房间首屏最小用户展示资料。
|
||||||
|
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||||
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
users, err := s.userSvc.BatchGetRoomBasicUsers(ctx, req.GetUserIds())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
result := make(map[int64]*userv1.RoomBasicUser, len(users))
|
||||||
|
for userID, user := range users {
|
||||||
|
// 只透出首屏绘制必需字段,避免 gateway lite 路径拿到完整用户主状态。
|
||||||
|
result[userID] = &userv1.RoomBasicUser{
|
||||||
|
UserId: user.UserID,
|
||||||
|
Username: user.Username,
|
||||||
|
Avatar: user.Avatar,
|
||||||
|
DisplayUserId: user.DisplayUserID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &userv1.BatchGetRoomBasicUsersResponse{Users: result}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListUserIDs 为后台 fanout 任务返回稳定递增的用户 ID 游标页。
|
// ListUserIDs 为后台 fanout 任务返回稳定递增的用户 ID 游标页。
|
||||||
func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest) (*userv1.ListUserIDsResponse, error) {
|
func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest) (*userv1.ListUserIDsResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user