From 4060aee8afb0385446dfe2636d7d86793a74dab2 Mon Sep 17 00:00:00 2001 From: local Date: Sat, 27 Jun 2026 23:39:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/roomadmin/handler.go | 18 +- .../internal/modules/roomadmin/request.go | 16 +- .../internal/modules/roomadmin/service.go | 99 ++++++---- .../modules/roomadmin/service_test.go | 169 ++++++++++++++++++ .../room-service/internal/room/service/mic.go | 3 +- .../room/service/user_gift_value_test.go | 26 ++- 6 files changed, 275 insertions(+), 56 deletions(-) diff --git a/server/admin/internal/modules/roomadmin/handler.go b/server/admin/internal/modules/roomadmin/handler.go index e7cbabf5..0be1c814 100644 --- a/server/admin/internal/modules/roomadmin/handler.go +++ b/server/admin/internal/modules/roomadmin/handler.go @@ -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 diff --git a/server/admin/internal/modules/roomadmin/request.go b/server/admin/internal/modules/roomadmin/request.go index c34471bf..df2242ff 100644 --- a/server/admin/internal/modules/roomadmin/request.go +++ b/server/admin/internal/modules/roomadmin/request.go @@ -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 { diff --git a/server/admin/internal/modules/roomadmin/service.go b/server/admin/internal/modules/roomadmin/service.go index 42eaf5e7..2b9e7445 100644 --- a/server/admin/internal/modules/roomadmin/service.go +++ b/server/admin/internal/modules/roomadmin/service.go @@ -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 diff --git a/server/admin/internal/modules/roomadmin/service_test.go b/server/admin/internal/modules/roomadmin/service_test.go index 3ff34808..1b3e814d 100644 --- a/server/admin/internal/modules/roomadmin/service_test.go +++ b/server/admin/internal/modules/roomadmin/service_test.go @@ -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 diff --git a/services/room-service/internal/room/service/mic.go b/services/room-service/internal/room/service/mic.go index 10cef176..e820ebd6 100644 --- a/services/room-service/internal/room/service/mic.go +++ b/services/room-service/internal/room/service/mic.go @@ -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 diff --git a/services/room-service/internal/room/service/user_gift_value_test.go b/services/room-service/internal/room/service/user_gift_value_test.go index 047df9e8..82d51140 100644 --- a/services/room-service/internal/room/service/user_gift_value_test.go +++ b/services/room-service/internal/room/service/user_gift_value_test.go @@ -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 +}