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