房间用户列表
This commit is contained in:
parent
9fa6991ffd
commit
31f686bd68
@ -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 后,如果列表弹窗正在展示,可以局部更新;状态不确定时重新拉接口。
|
||||
|
||||
|
||||
@ -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 查询流程
|
||||
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user