diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 045db875..5291ec72 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -8367,9 +8367,11 @@ type ListRoomsRequest struct { Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` Query string `protobuf:"bytes,7,opt,name=query,proto3" json:"query,omitempty"` // country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。 - CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` + // viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。 + ViewerCountryCode string `protobuf:"bytes,9,opt,name=viewer_country_code,json=viewerCountryCode,proto3" json:"viewer_country_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListRoomsRequest) Reset() { @@ -8458,6 +8460,13 @@ func (x *ListRoomsRequest) GetCountryCode() string { return "" } +func (x *ListRoomsRequest) GetViewerCountryCode() string { + if x != nil { + return x.ViewerCountryCode + } + return "" +} + // ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。 // 它和公共房间发现列表分离,避免把用户关系流误建模成房间全集过滤。 type ListRoomFeedsRequest struct { @@ -10948,7 +10957,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x1aVerifyRoomPresenceResponse\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12!\n" + - "\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\x8d\x02\n" + + "\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\xbd\x02\n" + "\x10ListRoomsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" + @@ -10957,7 +10966,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x06cursor\x18\x05 \x01(\tR\x06cursor\x12\x14\n" + "\x05limit\x18\x06 \x01(\x05R\x05limit\x12\x14\n" + "\x05query\x18\a \x01(\tR\x05query\x12!\n" + - "\fcountry_code\x18\b \x01(\tR\vcountryCode\"\xb7\x02\n" + + "\fcountry_code\x18\b \x01(\tR\vcountryCode\x12.\n" + + "\x13viewer_country_code\x18\t \x01(\tR\x11viewerCountryCode\"\xb7\x02\n" + "\x14ListRoomFeedsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index cbe71c4d..975dc6ff 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -1003,6 +1003,8 @@ message ListRoomsRequest { string query = 7; // country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。 string country_code = 8; + // viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。 + string viewer_country_code = 9; } // ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。 diff --git a/server/admin/internal/modules/roomadmin/pin.go b/server/admin/internal/modules/roomadmin/pin.go index c6701a08..28fb811c 100644 --- a/server/admin/internal/modules/roomadmin/pin.go +++ b/server/admin/internal/modules/roomadmin/pin.go @@ -17,6 +17,7 @@ const ( roomPinStatusAll = "all" roomPinTypeRegion = "region" roomPinTypeGlobal = "global" + roomPinTypeCountry = "country" defaultRoomPinDays = int64(30) maxRoomPinDays = int64(3650) roomPinDayMillis = int64(24 * 60 * 60 * 1000) @@ -142,6 +143,8 @@ func normalizeRoomPinType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeGlobal: return roomPinTypeGlobal + case roomPinTypeCountry: + return roomPinTypeCountry case roomPinTypeRegion: return roomPinTypeRegion default: diff --git a/server/admin/internal/modules/roomadmin/request.go b/server/admin/internal/modules/roomadmin/request.go index 1ac54615..b6fce047 100644 --- a/server/admin/internal/modules/roomadmin/request.go +++ b/server/admin/internal/modules/roomadmin/request.go @@ -1,5 +1,12 @@ package roomadmin +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + type listQuery struct { Page int PageSize int @@ -26,20 +33,74 @@ type robotRoomListQuery struct { } type createRobotRoomRequest struct { - OwnerRobotUserID int64 `json:"ownerRobotUserId"` - CandidateRobotUserIDs []int64 `json:"candidateRobotUserIds"` - MinRobotCount int32 `json:"minRobotCount"` - MaxRobotCount int32 `json:"maxRobotCount"` - RoomName string `json:"roomName"` - RoomAvatar string `json:"roomAvatar"` - VisibleRegionID int64 `json:"visibleRegionId"` - GiftIDs []string `json:"giftIds"` - LuckyGiftIDs []string `json:"luckyGiftIds"` - NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"` - LuckyComboMin int64 `json:"luckyComboMin"` - LuckyComboMax int64 `json:"luckyComboMax"` - LuckyPauseMinMS int64 `json:"luckyPauseMinMs"` - LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"` + OwnerRobotUserID flexibleJSONInt64 `json:"ownerRobotUserId"` + CandidateRobotUserIDs flexibleJSONInt64Slice `json:"candidateRobotUserIds"` + MinRobotCount int32 `json:"minRobotCount"` + MaxRobotCount int32 `json:"maxRobotCount"` + RoomName string `json:"roomName"` + RoomAvatar string `json:"roomAvatar"` + VisibleRegionID int64 `json:"visibleRegionId"` + GiftIDs []string `json:"giftIds"` + LuckyGiftIDs []string `json:"luckyGiftIds"` + NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"` + LuckyComboMin int64 `json:"luckyComboMin"` + LuckyComboMax int64 `json:"luckyComboMax"` + LuckyPauseMinMS int64 `json:"luckyPauseMinMs"` + LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"` +} + +type flexibleJSONInt64 int64 + +type flexibleJSONInt64Slice []int64 + +func (v *flexibleJSONInt64) UnmarshalJSON(data []byte) error { + // 机器人用户 ID 来自 int64,前端必须按字符串提交才能避开 JS 安全整数上限;这里同时兼容旧脚本传数字。 + value, err := parseFlexibleJSONInt64(data) + if err != nil { + return err + } + *v = flexibleJSONInt64(value) + return nil +} + +func (values *flexibleJSONInt64Slice) UnmarshalJSON(data []byte) error { + // JSON 数组逐项走同一套 int64 解析,避免候选机器人列表里混入浮点或科学计数法。 + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + out := make([]int64, 0, len(raw)) + for _, item := range raw { + value, err := parseFlexibleJSONInt64(item) + if err != nil { + return err + } + out = append(out, value) + } + *values = out + return nil +} + +func parseFlexibleJSONInt64(data []byte) (int64, error) { + raw := strings.TrimSpace(string(data)) + if raw == "" || raw == "null" { + return 0, nil + } + if strings.HasPrefix(raw, "\"") { + var text string + if err := json.Unmarshal(data, &text); err != nil { + return 0, err + } + raw = strings.TrimSpace(text) + } + if raw == "" { + return 0, nil + } + if strings.ContainsAny(raw, ".eE") { + // ID 没有小数语义,拒绝 float/scientific notation,避免 18 位 ID 被中间层改写精度。 + return 0, fmt.Errorf("integer id must not be decimal") + } + return strconv.ParseInt(raw, 10, 64) } type createRoomPinRequest struct { diff --git a/server/admin/internal/modules/roomadmin/robot_room.go b/server/admin/internal/modules/roomadmin/robot_room.go index a135b957..2e03b3db 100644 --- a/server/admin/internal/modules/roomadmin/robot_room.go +++ b/server/admin/internal/modules/roomadmin/robot_room.go @@ -148,8 +148,8 @@ func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomReques return RobotRoom{}, err } created, err := s.roomClient.CreateRobotRoom(ctx, roomclient.CreateRobotRoomRequest{ - OwnerRobotUserID: req.OwnerRobotUserID, - CandidateRobotUserIDs: req.CandidateRobotUserIDs, + OwnerRobotUserID: int64(req.OwnerRobotUserID), + CandidateRobotUserIDs: []int64(req.CandidateRobotUserIDs), MinRobotCount: req.MinRobotCount, MaxRobotCount: req.MaxRobotCount, RoomName: req.RoomName, @@ -180,11 +180,12 @@ func (s *Service) applyOwnerProfileToRobotRoomRequest(ctx context.Context, req * if s.userDB == nil { return fmt.Errorf("user database is not configured") } - owners, err := s.queryRoomOwners(ctx, []int64{req.OwnerRobotUserID}) + ownerID := int64(req.OwnerRobotUserID) + owners, err := s.queryRoomOwners(ctx, []int64{ownerID}) if err != nil { return err } - owner, ok := owners[req.OwnerRobotUserID] + owner, ok := owners[ownerID] if !ok { return fmt.Errorf("%w: 房主机器人资料不存在", ErrInvalidArgument) } @@ -235,13 +236,15 @@ func (s *Service) SetRobotRoomStatus(ctx context.Context, roomID string, status } func normalizeCreateRobotRoomRequest(req createRobotRoomRequest) (createRobotRoomRequest, error) { - if req.OwnerRobotUserID <= 0 { + ownerID := int64(req.OwnerRobotUserID) + if ownerID <= 0 { return req, fmt.Errorf("%w: 请选择房主机器人", ErrInvalidArgument) } if req.MinRobotCount <= 0 || req.MaxRobotCount < req.MinRobotCount { return req, fmt.Errorf("%w: 机器人数量范围不正确", ErrInvalidArgument) } - if len(req.CandidateRobotUserIDs) == 0 { + candidateIDs := normalizeRobotRoomCandidateIDs([]int64(req.CandidateRobotUserIDs), ownerID) + if len(candidateIDs) == 0 { return req, fmt.Errorf("%w: 请选择候选机器人", ErrInvalidArgument) } if len(req.GiftIDs) == 0 { @@ -259,9 +262,24 @@ func normalizeCreateRobotRoomRequest(req createRobotRoomRequest) (createRobotRoo if req.LuckyPauseMinMS <= 0 || req.LuckyPauseMaxMS < req.LuckyPauseMinMS { return req, fmt.Errorf("%w: 幸运礼物间隔范围不正确", ErrInvalidArgument) } + req.CandidateRobotUserIDs = flexibleJSONInt64Slice(candidateIDs) return req, nil } +func normalizeRobotRoomCandidateIDs(values []int64, ownerID int64) []int64 { + seen := make(map[int64]bool, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + // 房主由 room-service 单独并入机器人集合;候选列表只保留其他机器人,避免前端重复选择或旧脚本传入重复 ID。 + if value <= 0 || value == ownerID || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + func robotRoomsFromClient(items []roomclient.RobotRoom) []RobotRoom { out := make([]RobotRoom, 0, len(items)) for _, item := range items { diff --git a/server/admin/internal/modules/roomadmin/service_test.go b/server/admin/internal/modules/roomadmin/service_test.go index b5938713..80d010d5 100644 --- a/server/admin/internal/modules/roomadmin/service_test.go +++ b/server/admin/internal/modules/roomadmin/service_test.go @@ -1,6 +1,7 @@ package roomadmin import ( + "encoding/json" "errors" "testing" @@ -77,6 +78,36 @@ func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) { } } +func TestCreateRobotRoomRequestAcceptsStringInt64IDs(t *testing.T) { + var req createRobotRoomRequest + if err := json.Unmarshal([]byte(`{ + "ownerRobotUserId":"325379237278126080", + "candidateRobotUserIds":["325379237278126080","325455441691676672","325455441691676672"], + "minRobotCount":2, + "maxRobotCount":8, + "giftIds":["84"], + "luckyGiftIds":["28"], + "normalGiftIntervalMs":10000, + "luckyComboMin":100, + "luckyComboMax":10000, + "luckyPauseMinMs":5000, + "luckyPauseMaxMs":20000 + }`), &req); err != nil { + t.Fatalf("robot room request should accept string int64 ids: %v", err) + } + + normalized, err := normalizeCreateRobotRoomRequest(req) + if err != nil { + t.Fatalf("robot room request should normalize: %v", err) + } + if got := int64(normalized.OwnerRobotUserID); got != 325379237278126080 { + t.Fatalf("owner id mismatch: got %d", got) + } + if got := []int64(normalized.CandidateRobotUserIDs); len(got) != 1 || got[0] != 325455441691676672 { + t.Fatalf("candidate ids should drop owner and duplicates, got %+v", got) + } +} + func TestAvailableRoomRobotsFromGamePoolKeepsOnlyRoomAvailableUsers(t *testing.T) { robots := []robotclient.Robot{ {UserID: 101, Status: "active", LastUsedAtMS: 1000, UsedCount: 2}, diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a521a559..1a72f8d6 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2641,7 +2641,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { if queryClient.lastList == nil { t.Fatal("room query client was not called") } - if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 { + if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 || queryClient.lastList.GetViewerCountryCode() != "US" { t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList) } if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" || queryClient.lastList.GetCountryCode() != "US" { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 3806b7b0..fa8c09a6 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -108,14 +108,15 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) { } resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{ - Meta: httpkit.RoomMeta(request, "", ""), - ViewerUserId: viewerUserID, - VisibleRegionId: userResp.GetUser().GetRegionId(), - Tab: tab, - Cursor: request.URL.Query().Get("cursor"), - Limit: limit, - Query: roomListQuery(request), - CountryCode: countryCode, + Meta: httpkit.RoomMeta(request, "", ""), + ViewerUserId: viewerUserID, + VisibleRegionId: userResp.GetUser().GetRegionId(), + ViewerCountryCode: userResp.GetUser().GetCountry(), + Tab: tab, + Cursor: request.URL.Query().Get("cursor"), + Limit: limit, + Query: roomListQuery(request), + CountryCode: countryCode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index 807db00a..dcb421be 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -183,7 +183,7 @@ CREATE TABLE IF NOT EXISTS room_region_pins ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', visible_region_id BIGINT NOT NULL COMMENT '可见区域 ID', - pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,global 全区置顶', + pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,country 国家置顶,global 历史全区置顶', room_id VARCHAR(64) NOT NULL COMMENT '房间 ID', weight BIGINT NOT NULL DEFAULT 0 COMMENT '权重', status VARCHAR(32) NOT NULL COMMENT '业务状态', diff --git a/services/room-service/internal/room/service/admin_config_pin.go b/services/room-service/internal/room/service/admin_config_pin.go index 44a40151..4aa5bef5 100644 --- a/services/room-service/internal/room/service/admin_config_pin.go +++ b/services/room-service/internal/room/service/admin_config_pin.go @@ -13,9 +13,10 @@ import ( var adminSeatCandidates = []int32{10, 15, 20, 25, 30} const ( - roomPinTypeRegion = "region" - roomPinTypeGlobal = "global" - roomPinDayMillis = int64(24 * 60 * 60 * 1000) + roomPinTypeRegion = "region" + roomPinTypeGlobal = "global" + roomPinTypeCountry = "country" + roomPinDayMillis = int64(24 * 60 * 60 * 1000) ) func (s *Service) AdminGetRoomSeatConfig(ctx context.Context, _ *roomv1.AdminGetRoomSeatConfigRequest) (*roomv1.AdminGetRoomSeatConfigResponse, error) { @@ -156,6 +157,8 @@ func normalizeRoomPinType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeGlobal: return roomPinTypeGlobal + case roomPinTypeCountry: + return roomPinTypeCountry default: // 空值和未知值都回落到区域置顶,兼容旧后台只传 room_id/duration_days 的请求。 return roomPinTypeRegion diff --git a/services/room-service/internal/room/service/list.go b/services/room-service/internal/room/service/list.go index f9bf7841..d4e25759 100644 --- a/services/room-service/internal/room/service/list.go +++ b/services/room-service/internal/room/service/list.go @@ -37,6 +37,7 @@ type roomListCursor struct { Tab string `json:"tab"` Query string `json:"query,omitempty"` CountryCode string `json:"country_code,omitempty"` + ViewerCountry string `json:"viewer_country,omitempty"` SortScore int64 `json:"sort_score,omitempty"` CreatedAtMS int64 `json:"created_at_ms,omitempty"` UpdatedAtMS int64 `json:"updated_at_ms,omitempty"` @@ -68,7 +69,8 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( return nil, err } countryCode := normalizeRoomCountryCode(req.GetCountryCode()) - cursor, err := decodeRoomListCursor(tab, query, countryCode, req.GetCursor()) + viewerCountryCode := normalizeRoomCountryCode(req.GetViewerCountryCode()) + cursor, err := decodeRoomListCursor(tab, query, countryCode, viewerCountryCode, req.GetCursor()) if err != nil { return nil, err } @@ -79,6 +81,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( Tab: tab, Query: query, CountryCode: countryCode, + ViewerCountryCode: viewerCountryCode, Limit: limit + 1, CursorSortScore: cursor.SortScore, CursorCreatedAtMS: cursor.CreatedAtMS, @@ -93,7 +96,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( return nil, err } - return roomListResponseFromEntries(tab, query, countryCode, entries, limit), nil + return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, entries, limit), nil } // ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。 @@ -115,7 +118,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe if err != nil { return nil, err } - cursor, err := decodeRoomListCursor(tab, query, "", req.GetCursor()) + cursor, err := decodeRoomListCursor(tab, query, "", "", req.GetCursor()) if err != nil { return nil, err } @@ -163,7 +166,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe return nil, err } - return roomListResponseFromEntries(tab, query, "", entries, limit), nil + return roomListResponseFromEntries(tab, query, "", "", entries, limit), nil } // GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。 @@ -196,12 +199,12 @@ func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) ( }, nil } -func roomListResponseFromEntries(tab string, query string, countryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse { +func roomListResponseFromEntries(tab string, query string, countryCode string, viewerCountryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse { nextCursor := "" if len(entries) > limit { // 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。 last := entries[limit-1] - nextCursor = encodeRoomListCursor(tab, query, countryCode, last) + nextCursor = encodeRoomListCursor(tab, query, countryCode, viewerCountryCode, last) entries = entries[:limit] } @@ -406,10 +409,10 @@ func normalizeRoomCountryCode(countryCode string) string { } // decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。 -func decodeRoomListCursor(tab string, query string, countryCode string, encoded string) (roomListCursor, error) { +func decodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, encoded string) (roomListCursor, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { - return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode}, nil + return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode}, nil } payload, err := base64.RawURLEncoding.DecodeString(encoded) @@ -421,7 +424,7 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded if err := json.Unmarshal(payload, &cursor); err != nil { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } - if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.RoomID == "" { + if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.ViewerCountry != viewerCountryCode || cursor.RoomID == "" { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } @@ -430,11 +433,12 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded // encodeRoomListCursor 把最后一条列表记录编码成下一页边界。 // cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。 -func encodeRoomListCursor(tab string, query string, countryCode string, entry RoomListEntry) string { +func encodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, entry RoomListEntry) string { cursor := roomListCursor{ Tab: tab, Query: query, CountryCode: countryCode, + ViewerCountry: viewerCountryCode, SortScore: entry.SortScore, CreatedAtMS: entry.CreatedAtMS, UpdatedAtMS: entry.UpdatedAtMS, diff --git a/services/room-service/internal/room/service/pin_test.go b/services/room-service/internal/room/service/pin_test.go index 3bb8c64b..696830ca 100644 --- a/services/room-service/internal/room/service/pin_test.go +++ b/services/room-service/internal/room/service/pin_test.go @@ -64,7 +64,7 @@ func TestRegionalPinOrdersPublicRoomList(t *testing.T) { } } -func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) { +func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForHot(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ @@ -74,42 +74,46 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) { SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) - createPinnedListRoom(t, ctx, svc, "room-global-pin", "global-pin", 3101, 9002) - createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3102, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-online", "local-online", 3103, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-online", "other-online", 3104, 9002) - createPinnedListRoom(t, ctx, svc, "room-local-empty", "local-empty", 3105, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-empty", "other-empty", 3106, 9002) - repository.SetRoomSortScore("room-global-pin", 1) - repository.SetRoomSortScore("room-region-pin", 2) - repository.SetRoomSortScore("room-local-online", 100) - repository.SetRoomSortScore("room-other-online", 10000) - repository.SetRoomSortScore("room-local-empty", 100000) - repository.SetRoomSortScore("room-other-empty", 200000) - repository.SetRoomPresence("room-local-online", 1, 0) - repository.SetRoomPresence("room-other-online", 1, 0) - repository.SetRoomPresence("room-local-empty", 0, 0) - repository.SetRoomPresence("room-other-empty", 0, 0) + createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3101, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-country-pin", "country-pin", 3102, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-country-online", "viewer-online", 3103, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-country-online", "other-online", 3104, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-viewer-country-empty", "viewer-empty", 3105, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-country-empty", "other-empty", 3106, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-other-region-online", "other-region", 3107, 9002, "US") + repository.SetRoomSortScore("room-region-pin", 1) + repository.SetRoomSortScore("room-country-pin", 2) + repository.SetRoomSortScore("room-viewer-country-online", 100) + repository.SetRoomSortScore("room-other-country-online", 10000) + repository.SetRoomSortScore("room-viewer-country-empty", 100000) + repository.SetRoomSortScore("room-other-country-empty", 200000) + repository.SetRoomSortScore("room-other-region-online", 300000) + repository.SetRoomPresence("room-viewer-country-online", 1, 0) + repository.SetRoomPresence("room-other-country-online", 1, 0) + repository.SetRoomPresence("room-viewer-country-empty", 0, 0) + repository.SetRoomPresence("room-other-country-empty", 0, 0) + repository.SetRoomPresence("room-other-region-online", 1, 0) expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli() - repository.PinRoomWithType("room-global-pin", "global", 0, 1, expiresAtMS) repository.PinRoom("room-region-pin", 9001, 99, expiresAtMS) + repository.PinRoomWithType("room-country-pin", "country", 9001, 88, expiresAtMS) page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 4001, - VisibleRegionId: 9001, - Tab: "hot", - Limit: 6, + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, + ViewerUserId: 4001, + VisibleRegionId: 9001, + ViewerCountryCode: "US", + Tab: "hot", + Limit: 6, }) if err != nil { t.Fatalf("list hot rooms failed: %v", err) } - if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-global-pin" || got[1] != "room-region-pin" || got[2] != "room-local-online" || got[3] != "room-other-online" || got[4] != "room-local-empty" || got[5] != "room-other-empty" { + if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-region-pin" || got[1] != "room-country-pin" || got[2] != "room-viewer-country-online" || got[3] != "room-other-country-online" || got[4] != "room-viewer-country-empty" || got[5] != "room-other-country-empty" { t.Fatalf("hot order mismatch: %+v", got) } } -func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) { +func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForNew(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ @@ -119,31 +123,34 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) { SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) - createPinnedListRoom(t, ctx, svc, "room-global-new", "global-new", 3201, 9002) - createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3202, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-newer", "local-newer", 3203, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-older", "local-older", 3204, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9002) - repository.SetRoomCreatedAt("room-global-new", 10) + createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3201, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-country-new", "country-new", 3202, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-newer", "viewer-newer", 3203, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-older", "viewer-older", 3204, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-other-region-newest", "other-region-newest", 3206, 9002, "US") repository.SetRoomCreatedAt("room-region-new", 20) - repository.SetRoomCreatedAt("room-local-newer", 300) - repository.SetRoomCreatedAt("room-local-older", 100) + repository.SetRoomCreatedAt("room-country-new", 10) + repository.SetRoomCreatedAt("room-viewer-newer", 300) + repository.SetRoomCreatedAt("room-viewer-older", 100) repository.SetRoomCreatedAt("room-other-newest", 1000) + repository.SetRoomCreatedAt("room-other-region-newest", 2000) expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli() - repository.PinRoomWithType("room-global-new", "global", 0, 1, expiresAtMS) repository.PinRoom("room-region-new", 9001, 99, expiresAtMS) + repository.PinRoomWithType("room-country-new", "country", 9001, 88, expiresAtMS) page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 4001, - VisibleRegionId: 9001, - Tab: "new", - Limit: 5, + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, + ViewerUserId: 4001, + VisibleRegionId: 9001, + ViewerCountryCode: "US", + Tab: "new", + Limit: 5, }) if err != nil { t.Fatalf("list new rooms failed: %v", err) } - if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-global-new" || got[1] != "room-region-new" || got[2] != "room-local-newer" || got[3] != "room-local-older" || got[4] != "room-other-newest" { + if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-region-new" || got[1] != "room-country-new" || got[2] != "room-viewer-newer" || got[3] != "room-viewer-older" || got[4] != "room-other-newest" { t.Fatalf("new order mismatch: %+v", got) } } diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 8fbeb6a1..4b7386bf 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -423,6 +423,8 @@ type RoomListQuery struct { Query string // CountryCode 非空时只返回房主国家命中的房间;gateway 必须先校验它属于当前用户区域。 CountryCode string + // ViewerCountryCode 是当前用户国家;公共列表不按它过滤,只用它在区域内把本国家房间排到同状态房间前。 + ViewerCountryCode string // Limit 是实际查询数量,service 层会限制最大值。 Limit int // CursorSortScore 是 hot tab 的游标 score。 diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index 3dec2f4b..8484fe5f 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -1937,7 +1937,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice. return err } -// ListRoomListEntries 读取公共发现列表读模型,并按查询区域把全区置顶、区域置顶、本区房和外区房排成稳定分页序列。 +// ListRoomListEntries 读取公共发现列表读模型,并先按查询区域隔离,再在区域内部按置顶、用户国家和在线状态排成稳定分页序列。 func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 @@ -2354,16 +2354,22 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { if regionID < 0 { regionID = 0 } - regionLiteral := strconv.FormatInt(regionID, 10) - pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END" - newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END" - hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END" - pinRankExpr := hotPinRankExpr - if query.Tab == "new" { - pinRankExpr = newPinRankExpr + viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode) + filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode) + sortCountryCode := viewerCountryCode + if filterCountryCode != "" { + // 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。 + sortCountryCode = filterCountryCode } - pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight ELSE 0 END" - pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms ELSE 0 END" + normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END" + if sortCountryCode != "" { + countryLiteral := roomListSQLStringLiteral(sortCountryCode) + normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END" + } + pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END" + pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END" + pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.weight ELSE 0 END" + pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms WHEN cp.room_id IS NOT NULL THEN cp.expires_at_ms ELSE 0 END" selectSQL := ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms, @@ -2384,9 +2390,18 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { AND rp.room_id = r.room_id AND rp.status = ? AND rp.pinned_at_ms <= ? - AND rp.expires_at_ms > ?` - where := []string{"r.app_code = ?", "r.status = ?"} - args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"} + AND rp.expires_at_ms > ? + LEFT JOIN room_region_pins cp + ON cp.app_code = r.app_code + AND cp.pin_type = 'country' + AND cp.visible_region_id = ? + AND cp.room_id = r.room_id + AND r.owner_country_code = ? + AND cp.status = ? + AND cp.pinned_at_ms <= ? + AND cp.expires_at_ms > ?` + where := []string{"r.app_code = ?", "r.status = ?", "r.visible_region_id = ?"} + args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, regionID, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active", regionID} if query.OwnerUserID > 0 { where = append(where, "r.owner_user_id = ?") args = append(args, query.OwnerUserID) @@ -2417,6 +2432,23 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args } +func normalizeRoomListSQLCountryCode(countryCode string) string { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if len(countryCode) < 2 || len(countryCode) > 3 { + return "" + } + for _, ch := range countryCode { + if ch < 'A' || ch > 'Z' { + return "" + } + } + return countryCode +} + +func roomListSQLStringLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) { if query.Page <= 0 { query.Page = 1