进房接口
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;
|
||||
// ban_states 保存 ban 的过期边界;ban_user_ids 仍只服务简单集合判断。
|
||||
repeated RoomBanState ban_states = 21;
|
||||
// online_count 允许 lite 响应不携带 online_users 明细仍保留准确在线人数。
|
||||
int32 online_count = 22;
|
||||
}
|
||||
|
||||
// RoomBackgroundImage 是房间维度保存过的背景图素材。
|
||||
@ -656,6 +658,8 @@ message JoinRoomRequest {
|
||||
bool actor_is_robot = 7;
|
||||
// actor_display_profile 是进房用户展示资料快照,room-service 只透传到进房 IM/outbox,不写入 RoomState。
|
||||
RoomUserDisplayProfile actor_display_profile = 8;
|
||||
// response_mode=lite 只裁剪响应快照,不改变 Room Cell 命令提交、outbox 或 IM 事件语义。
|
||||
string response_mode = 9;
|
||||
}
|
||||
|
||||
// JoinRoomResponse 返回加入后的房间快照。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -691,6 +691,25 @@ message BatchGetUsersResponse {
|
||||
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 游标读取目标用户。
|
||||
message ListUserIDsRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -1324,6 +1343,7 @@ service UserService {
|
||||
rpc BusinessUserLookup(BusinessUserLookupRequest) returns (BusinessUserLookupResponse);
|
||||
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
|
||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
|
||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
|
||||
|
||||
@ -24,6 +24,7 @@ const (
|
||||
UserService_BusinessUserLookup_FullMethodName = "/hyapp.user.v1.UserService/BusinessUserLookup"
|
||||
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
||||
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_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||
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)
|
||||
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, 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)
|
||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListUserIDsResponse)
|
||||
@ -288,6 +300,7 @@ type UserServiceServer interface {
|
||||
BusinessUserLookup(context.Context, *BusinessUserLookupRequest) (*BusinessUserLookupResponse, error)
|
||||
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
|
||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
|
||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, 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) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(ListUserIDsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -781,6 +815,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "BatchGetUsers",
|
||||
Handler: _UserService_BatchGetUsers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetRoomBasicUsers",
|
||||
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListUserIDs",
|
||||
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)
|
||||
GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, 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)
|
||||
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return c.client.CompleteOnboarding(ctx, req)
|
||||
}
|
||||
|
||||
@ -113,6 +113,10 @@ func (f *fakeInviteActivityUserProfileClient) BatchGetUsers(_ context.Context, r
|
||||
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) {
|
||||
return &userv1.GetUserResponse{}, nil
|
||||
}
|
||||
|
||||
@ -318,6 +318,8 @@ type fakeUserProfileClient struct {
|
||||
lastStats *userv1.GetMyProfileStatsRequest
|
||||
lastBatch *userv1.BatchGetUsersRequest
|
||||
batchRequests []*userv1.BatchGetUsersRequest
|
||||
lastRoomBasicBatch *userv1.BatchGetRoomBasicUsersRequest
|
||||
roomBasicRequests []*userv1.BatchGetRoomBasicUsersRequest
|
||||
getRequests []*userv1.GetUserRequest
|
||||
lastComplete *userv1.CompleteOnboardingRequest
|
||||
lastUpdate *userv1.UpdateUserProfileRequest
|
||||
@ -958,6 +960,32 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
|
||||
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) {
|
||||
f.lastComplete = req
|
||||
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) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
|
||||
|
||||
@ -37,6 +37,10 @@ const (
|
||||
// 机器人房标记来自 room-service RoomExt;gateway 只用它决定首屏资料是否补机器人头像昵称。
|
||||
roomExtRobotRoomKey = "robot_room"
|
||||
roomExtRobotUserIDsKey = "robot_user_ids"
|
||||
|
||||
// JoinRoom 默认保持历史完整包;lite 只保留进房首屏必要字段,把装扮/榜单等增强展示后移。
|
||||
joinRoomResponseModeFull = "full"
|
||||
joinRoomResponseModeLite = "lite"
|
||||
)
|
||||
|
||||
type flexibleInt64 int64
|
||||
@ -1092,15 +1096,21 @@ func trimOptionalString(value *string) {
|
||||
// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令,并组装房间首屏初始化包。
|
||||
func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
}
|
||||
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
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)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
@ -1115,16 +1125,42 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
ActorRegionId: actorSnapshot.regionID,
|
||||
ActorDisplayProfile: actorSnapshot.displayProfile,
|
||||
// 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。
|
||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
|
||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request, responseMode),
|
||||
ResponseMode: responseMode,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
// 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。
|
||||
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 {
|
||||
rtc := roomRTCData{NeedToken: true}
|
||||
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)
|
||||
}
|
||||
|
||||
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 命令。
|
||||
func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
|
||||
@ -73,6 +73,17 @@ type joinRoomData struct {
|
||||
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 {
|
||||
Room roomInitialData `json:"room"`
|
||||
Viewer roomViewerData `json:"viewer"`
|
||||
@ -176,6 +187,13 @@ type roomDisplayProfileData struct {
|
||||
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 {
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
Recipients []roomGiftRecipientData `json:"recipients"`
|
||||
@ -797,6 +815,11 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
|
||||
roomShortID = ext["room_short_id"]
|
||||
}
|
||||
seatCount, occupiedSeatCount := roomSeatCounts(snapshot)
|
||||
onlineCount := snapshot.GetOnlineCount()
|
||||
if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
|
||||
// 老快照或非 lite 上游可能还没有 online_count 字段;此时用明细长度保持兼容。
|
||||
onlineCount = int32(len(snapshot.GetOnlineUsers()))
|
||||
}
|
||||
return roomInitialData{
|
||||
RoomID: roomID,
|
||||
IMGroupID: roomIMGroupID(roomID),
|
||||
@ -811,7 +834,7 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
|
||||
ChatEnabled: snapshot.GetChatEnabled(),
|
||||
Locked: snapshot.GetLocked(),
|
||||
Heat: snapshot.GetHeat(),
|
||||
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
|
||||
OnlineCount: onlineCount,
|
||||
SeatCount: seatCount,
|
||||
OccupiedSeatCount: occupiedSeatCount,
|
||||
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
||||
@ -967,6 +990,25 @@ func roomInitialProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64
|
||||
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 {
|
||||
if resp == nil {
|
||||
return roomOnlineUsersData{}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"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 快照压缩成用户当前房间读模型。
|
||||
// 读模型只保存恢复入口需要的字段,不复制完整 RoomState,也不作为权限权威来源。
|
||||
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 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。
|
||||
func containsUserID(values []int64, target int64) bool {
|
||||
return slices.Contains(values, target)
|
||||
|
||||
@ -94,6 +94,95 @@ func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) {
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -178,7 +178,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||||
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
|
||||
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
|
||||
s.projectRoomPresenceForCommandBestEffort(ctx, cmd, result.snapshot, result.user)
|
||||
// 跨房间贡献榜是 Redis 轻量读模型,不能影响已提交的房间状态和账务事实。
|
||||
s.recordRoomGiftLeaderboardBestEffort(ctx, result.roomGiftLeaderboard)
|
||||
projectionMS = elapsedMS(projectionStartedAt)
|
||||
|
||||
@ -19,6 +19,8 @@ import (
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
const joinRoomResponseModeLite = "lite"
|
||||
|
||||
// JoinRoom 把用户业务态接入房间。
|
||||
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
@ -133,10 +135,38 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
return &roomv1.JoinRoomResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
User: result.user,
|
||||
Room: result.snapshot,
|
||||
Room: joinRoomResponseSnapshot(result.snapshot, req.GetResponseMode()),
|
||||
}, 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 {
|
||||
if item == nil || item.GetUserId() <= 0 {
|
||||
return command.UserDisplayProfile{}
|
||||
|
||||
@ -784,6 +784,10 @@ type RoomListStore interface {
|
||||
type PresenceStore interface {
|
||||
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
|
||||
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(ctx context.Context, userID int64) (RoomPresence, bool, error)
|
||||
// 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) {
|
||||
w.lastDebit = req
|
||||
w.debitRequests = append(w.debitRequests, req)
|
||||
|
||||
@ -465,6 +465,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
||||
Locked: s.RoomPasswordHash != "",
|
||||
Rocket: rocketStateToProto(s.Rocket),
|
||||
BanStates: sortedModerationStates(s.BanUsers),
|
||||
OnlineCount: int32(len(users)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -77,6 +77,62 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
|
||||
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 房间,不扫描发现页列表。
|
||||
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
|
||||
@ -294,6 +294,18 @@ type User struct {
|
||||
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,只保存预聚合计数。
|
||||
type ProfileStats struct {
|
||||
// AppCode 是统计所属 App,关系链和礼物统计都必须在 App 内隔离。
|
||||
|
||||
@ -216,6 +216,10 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[
|
||||
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) {
|
||||
r.lastFindAppCode = appCode
|
||||
r.lastFindInviteCode = inviteCode
|
||||
|
||||
@ -45,6 +45,8 @@ type UserRepository interface {
|
||||
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
|
||||
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
|
||||
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(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
||||
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同区域邀请人。
|
||||
|
||||
@ -94,6 +94,25 @@ func (s *Service) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64
|
||||
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.
|
||||
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
|
||||
if s.userRepository == nil {
|
||||
|
||||
@ -92,6 +92,49 @@ func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[in
|
||||
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.
|
||||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 目标用户查询接口。
|
||||
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 游标页。
|
||||
func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest) (*userv1.ListUserIDsResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user