在线状态

This commit is contained in:
local 2026-06-27 23:39:22 +08:00
parent 400363ec65
commit 4060aee8af
6 changed files with 275 additions and 56 deletions

View File

@ -251,10 +251,16 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
Page: options.Page, Page: options.Page,
PageSize: options.PageSize, PageSize: options.PageSize,
Keyword: options.Keyword, Keyword: options.Keyword,
OwnerUserKeyword: firstQueryValue(c, "ownerUserKeyword", "owner_user_keyword"),
Status: options.Status, Status: options.Status,
SortBy: firstQueryValue(c, "sortBy", "sort_by"), SortBy: firstQueryValue(c, "sortBy", "sort_by"),
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"), SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"),
} }
ownerUserID, ok := parseOptionalInt64Query(c, "ownerUserId", "owner_user_id")
if !ok {
return listQuery{}, false
}
query.OwnerUserID = ownerUserID
regionID, ok := parseOptionalInt64Query(c, "regionId", "region_id") regionID, ok := parseOptionalInt64Query(c, "regionId", "region_id")
if !ok { if !ok {
return listQuery{}, false return listQuery{}, false

View File

@ -11,6 +11,8 @@ type listQuery struct {
Page int Page int
PageSize int PageSize int
Keyword string Keyword string
OwnerUserID int64
OwnerUserKeyword string
Status string Status string
RegionID int64 RegionID int64
SortBy string SortBy string

View File

@ -73,28 +73,28 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return nil, 0, fmt.Errorf("room service client is not configured") return nil, 0, fmt.Errorf("room service client is not configured")
} }
query = normalizeListQuery(query) query = normalizeListQuery(query)
ownerUserID, ownerMatched, err := s.resolveListOwnerUserID(ctx, query)
if err != nil {
return nil, 0, err
}
if strings.TrimSpace(query.OwnerUserKeyword) != "" && !ownerMatched {
return []Room{}, 0, nil
}
roomKeyword := query.Keyword
if ownerUserID == 0 {
keywordOwnerUserID, keywordOwnerMatched, err := s.resolveOwnerNumberUserID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if keywordOwnerMatched {
ownerUserID = keywordOwnerUserID
roomKeyword = ""
}
}
result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{ result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page, Page: query.Page,
PageSize: query.PageSize, PageSize: query.PageSize,
Keyword: query.Keyword, Keyword: roomKeyword,
Status: query.Status,
RegionID: query.RegionID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
if result.Total == 0 && strings.TrimSpace(query.Keyword) != "" && s.userDB != nil {
ownerUserID, ok, err := s.queryOwnerUserIDByDisplayID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if ok {
// 新增置顶弹窗按用户短 ID 找房间;精确解析到 user_id 后交给 room-service owner 过滤,避免字符串 LIKE 误命中。
fallback, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Status: query.Status, Status: query.Status,
RegionID: query.RegionID, RegionID: query.RegionID,
OwnerUserID: ownerUserID, OwnerUserID: ownerUserID,
@ -104,11 +104,6 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if fallback.Total > 0 {
result = fallback
}
}
}
items := roomsFromClient(result.Rooms) items := roomsFromClient(result.Rooms)
if err := s.fillRoomDetails(ctx, items); err != nil { if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err return nil, 0, err
@ -116,24 +111,50 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return items, result.Total, nil return items, result.Total, nil
} }
func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID string) (int64, bool, error) { func (s *Service) resolveListOwnerUserID(ctx context.Context, query listQuery) (int64, bool, error) {
displayUserID = strings.TrimSpace(displayUserID) if query.OwnerUserID > 0 {
if displayUserID == "" || s.userDB == nil { return query.OwnerUserID, true, nil
}
return s.resolveOwnerUserID(ctx, query.OwnerUserKeyword)
}
func (s *Service) resolveOwnerUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return 0, false, nil return 0, false, nil
} }
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli()) if s.userDB == nil {
args := append([]any{appctx.FromContext(ctx)}, matchArgs...) return shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
var userID int64 }
err := s.userDB.QueryRowContext(ctx, ` // 房主筛选必须先把短号、当前展示号、active 靓号等运营可见号码解析成稳定 user_id再交给 room-service 按 owner_user_id 精确过滤。
return shared.ResolveExactUserID(ctx, s.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
func (s *Service) resolveOwnerNumberUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" || s.userDB == nil {
return 0, false, nil
}
nowMS := time.Now().UnixMilli()
displaySQL, displayArgs := shared.UserDisplayIDSQL("u", keyword, nowMS)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMS)
args := append([]any{appctx.FromContext(ctx), keyword}, displayArgs...)
args = append(args, orderArgs...)
row := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id SELECT u.user_id
FROM users u FROM users u
WHERE u.app_code = ? AND `+matchSQL+` WHERE u.app_code = ? AND (CAST(u.user_id AS CHAR) = ? OR `+displaySQL+`)
LIMIT 1 ORDER BY
`, args...).Scan(&userID) `+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
var userID int64
if err := row.Scan(&userID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil return 0, false, nil
} }
if err != nil {
return 0, false, err return 0, false, err
} }
return userID, true, nil return userID, true, nil

View File

@ -2,6 +2,7 @@ package roomadmin
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@ -9,9 +10,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/robotclient" "hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
) )
func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) { func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
@ -52,6 +56,107 @@ func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
} }
} }
func TestListRoomsResolvesOwnerUserKeywordBeforeRoomClient(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP2026"
const ownerUserID = int64(327702592329093120)
expectRoomOwnerIdentityLookup(sqlMock, keyword, ownerUserID)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
_, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
OwnerUserKeyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(roomClient.requests) != 1 {
t.Fatalf("expected one room-service request, got %+v", roomClient.requests)
}
if got := roomClient.requests[0].OwnerUserID; got != ownerUserID {
t.Fatalf("owner user id should be resolved before room-service call: got %d want %d", got, ownerUserID)
}
if roomClient.requests[0].Keyword != "" {
t.Fatalf("owner keyword must not be forwarded as room keyword: %+v", roomClient.requests[0])
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestListRoomsResolvesRoomKeywordOwnerPrettyIDBeforeRoomClient(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP2026"
const ownerUserID = int64(327702592329093120)
expectRoomOwnerNumberLookup(sqlMock, keyword, ownerUserID)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
_, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
Keyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(roomClient.requests) != 1 {
t.Fatalf("expected one room-service request, got %+v", roomClient.requests)
}
if got := roomClient.requests[0].OwnerUserID; got != ownerUserID {
t.Fatalf("room keyword should resolve owner user id before room-service call: got %d want %d", got, ownerUserID)
}
if roomClient.requests[0].Keyword != "" {
t.Fatalf("owner pretty id must not be forwarded as room keyword: %+v", roomClient.requests[0])
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestListRoomsReturnsEmptyWhenOwnerUserKeywordHasNoMatch(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
const keyword = "VIP404"
expectRoomOwnerIdentityMiss(sqlMock, keyword)
roomClient := &recordingRoomClient{}
service := NewService(db, nil, roomClient, nil)
items, total, err := service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{
OwnerUserKeyword: keyword,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms failed: %v", err)
}
if len(items) != 0 || total != 0 {
t.Fatalf("unmatched owner keyword should return empty page, got items=%+v total=%d", items, total)
}
if len(roomClient.requests) != 0 {
t.Fatalf("room-service must not be called when owner keyword is known unmatched: %+v", roomClient.requests)
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) { func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) {
name, avatar, err := robotRoomProfileFromOwner(RoomOwner{ name, avatar, err := robotRoomProfileFromOwner(RoomOwner{
Username: " Robot Host ", Username: " Robot Host ",
@ -65,6 +170,70 @@ func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) {
} }
} }
func expectRoomOwnerIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) {
expectRoomOwnerIdentityQuery(sqlMock, keyword).
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
func expectRoomOwnerIdentityMiss(sqlMock sqlmock.Sqlmock, keyword string) {
expectRoomOwnerIdentityQuery(sqlMock, keyword).
WillReturnError(sql.ErrNoRows)
}
func expectRoomOwnerNumberLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) {
expectRoomOwnerNumberQuery(sqlMock, keyword).
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
func expectRoomOwnerIdentityQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery {
return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`).
WithArgs(
"lalu",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
"%"+keyword+"%",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
)
}
func expectRoomOwnerNumberQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery {
return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*CAST\(u\.user_id AS CHAR\) = \?.*LIMIT 1`).
WithArgs(
"lalu",
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
keyword,
keyword,
keyword,
sqlmock.AnyArg(),
keyword,
)
}
type recordingRoomClient struct {
roomclient.Client
requests []roomclient.ListRoomsRequest
result *roomclient.ListRoomsResult
}
func (c *recordingRoomClient) ListRooms(_ context.Context, req roomclient.ListRoomsRequest) (*roomclient.ListRoomsResult, error) {
c.requests = append(c.requests, req)
if c.result != nil {
return c.result, nil
}
return &roomclient.ListRoomsResult{}, nil
}
func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) { func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -322,7 +322,8 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
RoomVersion: current.Version, RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged), Attributes: roomMicChangedIMAttributes(micChanged),
}, },
}, []outbox.Record{micEvent}, nil directIMRecords: []outbox.Record{micEvent},
}, nil, nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -27,7 +27,8 @@ func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) {
HeatValue: 117, HeatValue: 117,
GiftTypeCode: "normal", GiftTypeCode: "normal",
}}} }}}
svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value") directIM := newRecordingRoomDirectIMPublisher()
svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value", directIM)
roomID := "room-user-gift-value" roomID := "room-user-gift-value"
ownerID := int64(61001) ownerID := int64(61001)
@ -76,8 +77,12 @@ func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) {
if newSeat := seatByNo(changeResp.GetRoom(), 5); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 117 { if newSeat := seatByNo(changeResp.GetRoom(), 5); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 117 {
t.Fatalf("gift value must follow user to new seat: %+v", newSeat) t.Fatalf("gift value must follow user to new seat: %+v", newSeat)
} }
if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed == nil || changed.GetTargetGiftValue() != 117 { waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomMicChanged": 2})
t.Fatalf("RoomMicChanged/change must carry target gift value: %+v", changed) if changed := firstDirectMicChangedEventByAction(t, directIM, "change"); changed == nil || changed.GetTargetGiftValue() != 117 {
t.Fatalf("direct RoomMicChanged/change must carry target gift value: %+v", changed)
}
if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed != nil {
t.Fatalf("RoomMicChanged/change must not be written to durable outbox: %+v", changed)
} }
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, targetID, "target-heat-leave")}); err != nil { if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, targetID, "target-heat-leave")}); err != nil {
@ -462,3 +467,18 @@ func firstMicChangedEventByAction(t *testing.T, ctx context.Context, repository
} }
return nil return nil
} }
func firstDirectMicChangedEventByAction(t *testing.T, publisher *recordingRoomDirectIMPublisher, action string) *roomeventsv1.RoomMicChanged {
t.Helper()
for _, record := range publisher.recordsByType("RoomMicChanged") {
var event roomeventsv1.RoomMicChanged
if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
t.Fatalf("decode direct mic changed event failed: %v", err)
}
if event.GetAction() == action {
return &event
}
}
return nil
}