diff --git a/docs/语音房房间用户列表前端接入文档.md b/docs/语音房房间用户列表前端接入文档.md index dc1ec2d8..215d16ca 100644 --- a/docs/语音房房间用户列表前端接入文档.md +++ b/docs/语音房房间用户列表前端接入文档.md @@ -107,8 +107,16 @@ curl -H "Authorization: Bearer $TOKEN" \ "joined_at_ms": 1760000000000, "last_seen_at_ms": 1760000005000, "profile": { - "nickname": "Alice", - "avatar_url": "https://example.com/a.png" + "user_id": "10001", + "username": "Alice", + "avatar": "https://example.com/a.png", + "display_user_id": "163001", + "gender": "female", + "age": 26, + "country": "US", + "country_name": "United States", + "country_display_name": "United States", + "country_flag": "🇺🇸" } }, { @@ -120,8 +128,16 @@ curl -H "Authorization: Bearer $TOKEN" \ "joined_at_ms": 1760000001000, "last_seen_at_ms": 1760000004000, "profile": { - "nickname": "Bob", - "avatar_url": "https://example.com/b.png" + "user_id": "10002", + "username": "Bob", + "avatar": "https://example.com/b.png", + "display_user_id": "163002", + "gender": "male", + "age": 24, + "country": "SG", + "country_name": "Singapore", + "country_display_name": "Singapore", + "country_flag": "🇸🇬" } } ], @@ -144,8 +160,16 @@ curl -H "Authorization: Bearer $TOKEN" \ | `items[].gift_value` | int64 | 当前房间内累计送礼价值,未送礼返回 `0` | | `items[].joined_at_ms` | int64 | 进房时间 | | `items[].last_seen_at_ms` | int64 | 最近房间业务心跳时间 | -| `items[].profile.nickname` | string | 用户昵称 | -| `items[].profile.avatar_url` | string | 用户头像 | +| `items[].profile.user_id` | string | 用户 ID | +| `items[].profile.username` | string | 用户昵称 | +| `items[].profile.avatar` | string | 用户头像 | +| `items[].profile.display_user_id` | string | 展示 ID | +| `items[].profile.gender` | string | 用户性别,来自 user-service | +| `items[].profile.age` | int | 用户年龄,由后端按生日计算;生日为空或非法时返回 `0` | +| `items[].profile.country` | string | 用户国家码,ISO Alpha-2,例如 `US` | +| `items[].profile.country_name` | string | 国家名称,来自国家主数据 | +| `items[].profile.country_display_name` | string | 国家展示名称,来自国家主数据 | +| `items[].profile.country_flag` | string | 国家旗帜 emoji,由国家码生成;国家码为空或非法时返回空字符串 | | `total` | int64 | 在线用户总数 | | `page` | int | 当前页 | | `page_size` | int | 当前每页数量 | @@ -158,6 +182,10 @@ curl -H "Authorization: Bearer $TOKEN" \ - 房主标识看 `is_owner = true`。 - 管理员标识看 `room_role = admin`。 - 房主不是管理员,不要因为 `is_owner = true` 就把管理员标识点亮。 +- 在线列表只返回 `gender` 和 `age`,不会返回用户生日 `birth`。 +- `age = 0` 表示用户未填写生日或生日格式异常,前端可隐藏年龄。 +- 国家展示优先使用 `country_display_name`,没有时可降级使用 `country_name` 或 `country`。 +- `country_flag` 是 emoji 字符串,前端无需自行按国家码换算。 - 用户列表弹窗打开时,可以先拉第一页;滚动到底再拉下一页。 - 收到相关 IM 后,如果列表弹窗正在展示,可以局部更新;状态不确定时重新拉接口。 diff --git a/docs/语音房房间用户列表后端技术架构设计.md b/docs/语音房房间用户列表后端技术架构设计.md index d5267f1c..0b5c2ca1 100644 --- a/docs/语音房房间用户列表后端技术架构设计.md +++ b/docs/语音房房间用户列表后端技术架构设计.md @@ -24,6 +24,7 @@ 4. 每个用户返回当前房间管理员身份 `room_role`,只允许 `normal` 或 `admin`。 5. 复用同一个接口完成设置管理员和取消管理员。 6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。 +7. 在线用户列表聚合用户性别 `gender` 和年龄 `age`,但不暴露生日 `birth`。 ## 3. 非目标 @@ -230,7 +231,25 @@ HTTP 返回字段需要新增: | `gift_value` | room-service 查询统计表 | | `joined_at_ms` | room_user_presence | | `last_seen_at_ms` | room_user_presence | -| `profile` | gateway 现有聚合逻辑 | +| `profile.user_id` | user-service `BatchGetUsers` | +| `profile.username` | user-service `BatchGetUsers` | +| `profile.avatar` | user-service `BatchGetUsers` | +| `profile.display_user_id` | user-service `BatchGetUsers` | +| `profile.gender` | user-service `BatchGetUsers` 的 `gender` | +| `profile.age` | gateway 根据 user-service `birth` 按 `yyyy-mm-dd` 计算 | +| `profile.country` | user-service `BatchGetUsers` 的 `country` | +| `profile.country_name` | user-service `BatchGetUsers` 的 `country_name` | +| `profile.country_display_name` | user-service `BatchGetUsers` 的 `country_display_name` | +| `profile.country_flag` | gateway 根据 ISO Alpha-2 国家码生成 | + +用户资料边界: + +- `gender`、`birth` 属于 user-service,room-service 不存储、不缓存。 +- `country`、`country_name`、`country_display_name` 属于 user-service 国家主数据聚合结果,room-service 不存储、不缓存。 +- HTTP 只返回 `age`,不返回 `birth`。 +- `birth` 为空、格式非法、未来日期或超过合理年龄上限时,`age` 返回 `0`。 +- 年龄计算以 gateway 当前 UTC 日期为准,避免客户端时区差异导致展示不一致。 +- `country_flag` 不依赖 user proto 新字段,由 gateway 使用两位国家码生成;国家码为空或非法时返回空字符串。 ### 7.2 room-service 查询流程 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a00e5ea4..cf51d255 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -580,14 +580,17 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat users := make(map[int64]*userv1.User, len(req.GetUserIds())) for _, userID := range req.GetUserIds() { users[userID] = &userv1.User{ - UserId: userID, - DisplayUserId: strconv.FormatInt(100000+userID, 10), - Username: "user-" + strconv.FormatInt(userID, 10), - Gender: "female", - Country: "US", - Avatar: "https://cdn.example/avatar.png", - RegionId: 1001, - AppCode: req.GetMeta().GetAppCode(), + UserId: userID, + DisplayUserId: strconv.FormatInt(100000+userID, 10), + Username: "user-" + strconv.FormatInt(userID, 10), + Gender: "female", + Birth: "2000-01-02", + Country: "US", + CountryName: "United States", + CountryDisplayName: "United States", + Avatar: "https://cdn.example/avatar.png", + RegionId: 1001, + AppCode: req.GetMeta().GetAppCode(), } } return &userv1.BatchGetUsersResponse{Users: users}, nil @@ -2228,6 +2231,65 @@ func TestGetRoomDetailIncludesFollowState(t *testing.T) { } } +func TestListRoomOnlineUsersIncludesProfileGenderAndAge(t *testing.T) { + queryClient := &fakeRoomQueryClient{onlineResp: &roomv1.ListRoomOnlineUsersResponse{ + Items: []*roomv1.RoomOnlineUser{ + { + UserId: 42, + Role: "audience", + RoomRole: "admin", + GiftValue: 188800, + JoinedAtMs: 1000, + LastSeenAtMs: 2000, + }, + }, + Total: 1, + Page: 1, + PageSize: 20, + ServerTimeMs: 3000, + }} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetRoomQueryClient(queryClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-online/online-users?page=1&page_size=20&sort=gift_value", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-room-online-users") + 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 queryClient.lastOnline == nil || queryClient.lastOnline.GetRoomId() != "room-online" || queryClient.lastOnline.GetViewerUserId() != 42 || queryClient.lastOnline.GetSort() != "gift_value" { + t.Fatalf("online list request mismatch: %+v", queryClient.lastOnline) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode online users response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + items, itemsOK := data["items"].([]any) + if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 { + t.Fatalf("online users response mismatch: %+v", response) + } + first, ok := items[0].(map[string]any) + if !ok || first["user_id"] != "42" || first["room_role"] != "admin" || first["gift_value"] != float64(188800) { + t.Fatalf("online user item mismatch: %+v", first) + } + profile, ok := first["profile"].(map[string]any) + age, ageOK := profile["age"].(float64) + if !ok || profile["gender"] != "female" || !ageOK || age <= 0 { + t.Fatalf("online user profile must expose gender and positive age: %+v", first["profile"]) + } + if profile["country"] != "US" || profile["country_name"] != "United States" || profile["country_display_name"] != "United States" || profile["country_flag"] != "🇺🇸" { + t.Fatalf("online user profile must expose country code, name, display name, and flag: %+v", profile) + } + if _, exists := profile["birth"]; exists { + t.Fatalf("online user profile must not expose birth: %+v", profile) + } +} + func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) { queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 05c3b28c..e4cc5497 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" "hyapp/pkg/appcode" "hyapp/pkg/roomid" @@ -1232,21 +1233,67 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) continue } profiles[userID] = roomDisplayProfileData{ - UserID: formatOptionalUserID(user.GetUserId()), - Username: user.GetUsername(), - Avatar: user.GetAvatar(), - DisplayUserID: user.GetDisplayUserId(), - VIP: map[string]any{}, - Level: map[string]any{}, - Badges: []map[string]any{}, - AvatarFrame: map[string]any{}, - Vehicle: map[string]any{}, - Charm: 0, + UserID: formatOptionalUserID(user.GetUserId()), + Username: user.GetUsername(), + Avatar: user.GetAvatar(), + DisplayUserID: user.GetDisplayUserId(), + Gender: strings.TrimSpace(user.GetGender()), + Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()), + Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())), + CountryName: strings.TrimSpace(user.GetCountryName()), + CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()), + CountryFlag: roomProfileCountryFlag(user.GetCountry()), + VIP: map[string]any{}, + Level: map[string]any{}, + Badges: []map[string]any{}, + AvatarFrame: map[string]any{}, + Vehicle: map[string]any{}, + Charm: 0, } } return profiles } +func roomProfileAgeFromBirth(birth string, now time.Time) int32 { + birth = strings.TrimSpace(birth) + if birth == "" { + return 0 + } + born, err := time.Parse("2006-01-02", birth) + if err != nil { + return 0 + } + now = now.UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + birthdayThisYear := time.Date(now.Year(), born.Month(), born.Day(), 0, 0, 0, 0, time.UTC) + age := now.Year() - born.Year() + if today.Before(birthdayThisYear) { + age-- + } + if age < 0 || age > 150 { + return 0 + } + return int32(age) +} + +func roomProfileCountryFlag(country string) string { + country = strings.ToUpper(strings.TrimSpace(country)) + if len(country) != 2 { + return "" + } + runes := []rune(country) + for _, value := range runes { + if value < 'A' || value > 'Z' { + return "" + } + } + const regionalIndicatorA = 0x1F1E6 + return string([]rune{ + regionalIndicatorA + (runes[0] - 'A'), + regionalIndicatorA + (runes[1] - 'A'), + }) +} + func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 { userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1) for _, seat := range snapshot.GetMicSeats() { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go index d511d61f..61fadd94 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go @@ -2,6 +2,7 @@ package roomapi import ( "testing" + "time" roomv1 "hyapp.local/api/proto/room/v1" ) @@ -58,3 +59,47 @@ func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) { t.Fatalf("create response result mismatch: %+v", data) } } + +func TestRoomProfileAgeFromBirth(t *testing.T) { + now := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + birth string + want int32 + }{ + {name: "birthday passed", birth: "2000-01-02", want: 26}, + {name: "birthday pending", birth: "2000-12-31", want: 25}, + {name: "today birthday", birth: "2000-05-27", want: 26}, + {name: "invalid", birth: "2000/01/02", want: 0}, + {name: "future", birth: "2030-01-01", want: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := roomProfileAgeFromBirth(test.birth, now); got != test.want { + t.Fatalf("age mismatch: got %d want %d", got, test.want) + } + }) + } +} + +func TestRoomProfileCountryFlag(t *testing.T) { + tests := []struct { + name string + country string + want string + }{ + {name: "upper code", country: "US", want: "🇺🇸"}, + {name: "lower code", country: "sg", want: "🇸🇬"}, + {name: "invalid length", country: "USA", want: ""}, + {name: "invalid character", country: "U1", want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := roomProfileCountryFlag(test.country); got != test.want { + t.Fatalf("flag mismatch: got %q want %q", got, test.want) + } + }) + } +} diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index 44ca1402..ed3eaf16 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -114,16 +114,22 @@ type roomPermissionsData struct { } type roomDisplayProfileData struct { - UserID string `json:"user_id"` - Username string `json:"username"` - Avatar string `json:"avatar"` - DisplayUserID string `json:"display_user_id"` - VIP map[string]any `json:"vip"` - Level map[string]any `json:"level"` - Badges []map[string]any `json:"badges"` - AvatarFrame map[string]any `json:"avatar_frame"` - Vehicle map[string]any `json:"vehicle"` - Charm int64 `json:"charm"` + UserID string `json:"user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + DisplayUserID string `json:"display_user_id"` + Gender string `json:"gender"` + Age int32 `json:"age"` + Country string `json:"country"` + CountryName string `json:"country_name"` + CountryDisplayName string `json:"country_display_name"` + CountryFlag string `json:"country_flag"` + VIP map[string]any `json:"vip"` + Level map[string]any `json:"level"` + Badges []map[string]any `json:"badges"` + AvatarFrame map[string]any `json:"avatar_frame"` + Vehicle map[string]any `json:"vehicle"` + Charm int64 `json:"charm"` } type roomGiftPanelData struct {