在线状态

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

@ -248,13 +248,19 @@ func (h *Handler) setRobotRoomStatus(c *gin.Context, status string, action strin
func parseListQuery(c *gin.Context) (listQuery, bool) { func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c) options := shared.ListOptions(c)
query := listQuery{ query := listQuery{
Page: options.Page, Page: options.Page,
PageSize: options.PageSize, PageSize: options.PageSize,
Keyword: options.Keyword, Keyword: options.Keyword,
Status: options.Status, OwnerUserKeyword: firstQueryValue(c, "ownerUserKeyword", "owner_user_keyword"),
SortBy: firstQueryValue(c, "sortBy", "sort_by"), Status: options.Status,
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"), SortBy: firstQueryValue(c, "sortBy", "sort_by"),
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

@ -8,13 +8,15 @@ import (
) )
type listQuery struct { type listQuery struct {
Page int Page int
PageSize int PageSize int
Keyword string Keyword string
Status string OwnerUserID int64
RegionID int64 OwnerUserKeyword string
SortBy string Status string
SortDirection string RegionID int64
SortBy string
SortDirection string
} }
type roomPinListQuery struct { type roomPinListQuery struct {

View File

@ -73,42 +73,37 @@ 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, Status: query.Status,
RegionID: query.RegionID, RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy, SortBy: query.SortBy,
SortDirection: query.SortDirection, SortDirection: query.SortDirection,
}) })
if err != nil { if err != nil {
return nil, 0, err 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,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
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+`,
if errors.Is(err, sql.ErrNoRows) { u.user_id DESC
return 0, false, nil LIMIT 1`,
} args...,
if err != nil { )
var userID int64
if err := row.Scan(&userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, false, 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
}