在线状态

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) {
options := shared.ListOptions(c)
query := listQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
Status: options.Status,
SortBy: firstQueryValue(c, "sortBy", "sort_by"),
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"),
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
OwnerUserKeyword: firstQueryValue(c, "ownerUserKeyword", "owner_user_keyword"),
Status: options.Status,
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")
if !ok {
return listQuery{}, false

View File

@ -8,13 +8,15 @@ import (
)
type listQuery struct {
Page int
PageSize int
Keyword string
Status string
RegionID int64
SortBy string
SortDirection string
Page int
PageSize int
Keyword string
OwnerUserID int64
OwnerUserKeyword string
Status string
RegionID int64
SortBy string
SortDirection string
}
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")
}
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{
Page: query.Page,
PageSize: query.PageSize,
Keyword: query.Keyword,
Keyword: roomKeyword,
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
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,
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)
if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err
@ -116,24 +111,50 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return items, result.Total, nil
}
func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID string) (int64, bool, error) {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" || s.userDB == nil {
func (s *Service) resolveListOwnerUserID(ctx context.Context, query listQuery) (int64, bool, error) {
if query.OwnerUserID > 0 {
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
}
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli())
args := append([]any{appctx.FromContext(ctx)}, matchArgs...)
var userID int64
err := s.userDB.QueryRowContext(ctx, `
if s.userDB == nil {
return shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
// 房主筛选必须先把短号、当前展示号、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
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
LIMIT 1
`, args...).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
WHERE u.app_code = ? AND (CAST(u.user_id AS CHAR) = ? OR `+displaySQL+`)
ORDER BY
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
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 userID, true, nil

View File

@ -2,6 +2,7 @@ package roomadmin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
@ -9,9 +10,12 @@ import (
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
)
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) {
name, avatar, err := robotRoomProfileFromOwner(RoomOwner{
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) {
tests := []struct {
name string

View File

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

View File

@ -27,7 +27,8 @@ func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) {
HeatValue: 117,
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"
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 {
t.Fatalf("gift value must follow user to new seat: %+v", newSeat)
}
if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed == nil || changed.GetTargetGiftValue() != 117 {
t.Fatalf("RoomMicChanged/change must carry target gift value: %+v", changed)
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomMicChanged": 2})
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 {
@ -462,3 +467,18 @@ func firstMicChangedEventByAction(t *testing.T, ctx context.Context, repository
}
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
}