进房接口

This commit is contained in:
zhx 2026-07-03 10:43:34 +08:00
parent fedd4ea416
commit b12c774bb3
26 changed files with 4749 additions and 3483 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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);

View File

@ -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,

View 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/joinbody 传 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 | 上游服务异常。 |

View File

@ -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)
}

View File

@ -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
}

View File

@ -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{

View File

@ -37,6 +37,10 @@ const (
// 机器人房标记来自 room-service RoomExtgateway 只用它决定首屏资料是否补机器人头像昵称。
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 {

View File

@ -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{}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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{}

View File

@ -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。

View File

@ -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)

View File

@ -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)),
}
}

View File

@ -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,

View File

@ -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 内隔离。

View File

@ -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

View File

@ -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 绑定页搜索同区域邀请人。

View File

@ -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 {
// 空批量请求返回空 mapgateway 可以直接输出空 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 {

View File

@ -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 > ?"}

View File

@ -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)

View File

@ -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())