房间用户列表

This commit is contained in:
zhx 2026-05-27 18:50:40 +08:00
parent 9fa6991ffd
commit 31f686bd68
6 changed files with 242 additions and 35 deletions

View File

@ -107,8 +107,16 @@ curl -H "Authorization: Bearer $TOKEN" \
"joined_at_ms": 1760000000000, "joined_at_ms": 1760000000000,
"last_seen_at_ms": 1760000005000, "last_seen_at_ms": 1760000005000,
"profile": { "profile": {
"nickname": "Alice", "user_id": "10001",
"avatar_url": "https://example.com/a.png" "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, "joined_at_ms": 1760000001000,
"last_seen_at_ms": 1760000004000, "last_seen_at_ms": 1760000004000,
"profile": { "profile": {
"nickname": "Bob", "user_id": "10002",
"avatar_url": "https://example.com/b.png" "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[].gift_value` | int64 | 当前房间内累计送礼价值,未送礼返回 `0` |
| `items[].joined_at_ms` | int64 | 进房时间 | | `items[].joined_at_ms` | int64 | 进房时间 |
| `items[].last_seen_at_ms` | int64 | 最近房间业务心跳时间 | | `items[].last_seen_at_ms` | int64 | 最近房间业务心跳时间 |
| `items[].profile.nickname` | string | 用户昵称 | | `items[].profile.user_id` | string | 用户 ID |
| `items[].profile.avatar_url` | string | 用户头像 | | `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 | 在线用户总数 | | `total` | int64 | 在线用户总数 |
| `page` | int | 当前页 | | `page` | int | 当前页 |
| `page_size` | int | 当前每页数量 | | `page_size` | int | 当前每页数量 |
@ -158,6 +182,10 @@ curl -H "Authorization: Bearer $TOKEN" \
- 房主标识看 `is_owner = true` - 房主标识看 `is_owner = true`
- 管理员标识看 `room_role = admin` - 管理员标识看 `room_role = admin`
- 房主不是管理员,不要因为 `is_owner = true` 就把管理员标识点亮。 - 房主不是管理员,不要因为 `is_owner = true` 就把管理员标识点亮。
- 在线列表只返回 `gender``age`,不会返回用户生日 `birth`
- `age = 0` 表示用户未填写生日或生日格式异常,前端可隐藏年龄。
- 国家展示优先使用 `country_display_name`,没有时可降级使用 `country_name``country`
- `country_flag` 是 emoji 字符串,前端无需自行按国家码换算。
- 用户列表弹窗打开时,可以先拉第一页;滚动到底再拉下一页。 - 用户列表弹窗打开时,可以先拉第一页;滚动到底再拉下一页。
- 收到相关 IM 后,如果列表弹窗正在展示,可以局部更新;状态不确定时重新拉接口。 - 收到相关 IM 后,如果列表弹窗正在展示,可以局部更新;状态不确定时重新拉接口。

View File

@ -24,6 +24,7 @@
4. 每个用户返回当前房间管理员身份 `room_role`,只允许 `normal``admin` 4. 每个用户返回当前房间管理员身份 `room_role`,只允许 `normal``admin`
5. 复用同一个接口完成设置管理员和取消管理员。 5. 复用同一个接口完成设置管理员和取消管理员。
6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。 6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。
7. 在线用户列表聚合用户性别 `gender` 和年龄 `age`,但不暴露生日 `birth`
## 3. 非目标 ## 3. 非目标
@ -230,7 +231,25 @@ HTTP 返回字段需要新增:
| `gift_value` | room-service 查询统计表 | | `gift_value` | room-service 查询统计表 |
| `joined_at_ms` | room_user_presence | | `joined_at_ms` | room_user_presence |
| `last_seen_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-serviceroom-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 查询流程 ### 7.2 room-service 查询流程

View File

@ -580,14 +580,17 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
users := make(map[int64]*userv1.User, len(req.GetUserIds())) users := make(map[int64]*userv1.User, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() { for _, userID := range req.GetUserIds() {
users[userID] = &userv1.User{ users[userID] = &userv1.User{
UserId: userID, UserId: userID,
DisplayUserId: strconv.FormatInt(100000+userID, 10), DisplayUserId: strconv.FormatInt(100000+userID, 10),
Username: "user-" + strconv.FormatInt(userID, 10), Username: "user-" + strconv.FormatInt(userID, 10),
Gender: "female", Gender: "female",
Country: "US", Birth: "2000-01-02",
Avatar: "https://cdn.example/avatar.png", Country: "US",
RegionId: 1001, CountryName: "United States",
AppCode: req.GetMeta().GetAppCode(), CountryDisplayName: "United States",
Avatar: "https://cdn.example/avatar.png",
RegionId: 1001,
AppCode: req.GetMeta().GetAppCode(),
} }
} }
return &userv1.BatchGetUsersResponse{Users: users}, nil 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) { func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
queryClient := &fakeRoomQueryClient{} queryClient := &fakeRoomQueryClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/roomid" "hyapp/pkg/roomid"
@ -1232,21 +1233,67 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
continue continue
} }
profiles[userID] = roomDisplayProfileData{ profiles[userID] = roomDisplayProfileData{
UserID: formatOptionalUserID(user.GetUserId()), UserID: formatOptionalUserID(user.GetUserId()),
Username: user.GetUsername(), Username: user.GetUsername(),
Avatar: user.GetAvatar(), Avatar: user.GetAvatar(),
DisplayUserID: user.GetDisplayUserId(), DisplayUserID: user.GetDisplayUserId(),
VIP: map[string]any{}, Gender: strings.TrimSpace(user.GetGender()),
Level: map[string]any{}, Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()),
Badges: []map[string]any{}, Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())),
AvatarFrame: map[string]any{}, CountryName: strings.TrimSpace(user.GetCountryName()),
Vehicle: map[string]any{}, CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
Charm: 0, 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 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 { func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1) userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1)
for _, seat := range snapshot.GetMicSeats() { for _, seat := range snapshot.GetMicSeats() {

View File

@ -2,6 +2,7 @@ package roomapi
import ( import (
"testing" "testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
) )
@ -58,3 +59,47 @@ func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) {
t.Fatalf("create response result mismatch: %+v", data) 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)
}
})
}
}

View File

@ -114,16 +114,22 @@ type roomPermissionsData struct {
} }
type roomDisplayProfileData struct { type roomDisplayProfileData struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
DisplayUserID string `json:"display_user_id"` DisplayUserID string `json:"display_user_id"`
VIP map[string]any `json:"vip"` Gender string `json:"gender"`
Level map[string]any `json:"level"` Age int32 `json:"age"`
Badges []map[string]any `json:"badges"` Country string `json:"country"`
AvatarFrame map[string]any `json:"avatar_frame"` CountryName string `json:"country_name"`
Vehicle map[string]any `json:"vehicle"` CountryDisplayName string `json:"country_display_name"`
Charm int64 `json:"charm"` 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 { type roomGiftPanelData struct {