From 1224f3fbc634e75555e089d6c2237012959388ac Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 8 Jun 2026 20:51:42 +0800 Subject: [PATCH] fix room rocket test im delivery --- pkg/imgroup/imgroup.go | 91 ++++++++++++++++++- pkg/imgroup/imgroup_test.go | 22 +++++ pkg/tencentim/rest_client.go | 7 +- pkg/tencentim/rest_client_test.go | 6 +- .../configs/config.docker.yaml | 2 +- .../internal/transport/http/response_test.go | 4 +- .../http/roomapi/room_handler_test.go | 4 +- .../transport/http/roomapi/room_view.go | 9 +- .../room-service/configs/config.docker.yaml | 2 +- .../internal/integration/tencent_im.go | 20 +++- 10 files changed, 149 insertions(+), 18 deletions(-) diff --git a/pkg/imgroup/imgroup.go b/pkg/imgroup/imgroup.go index 5594184c..350f042e 100644 --- a/pkg/imgroup/imgroup.go +++ b/pkg/imgroup/imgroup.go @@ -86,6 +86,29 @@ func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int6 return groupID, nil } +// RoomGroupID 生成腾讯云 IM 房间群 ID。 +func RoomGroupID(value string) (string, error) { + return RoomGroupIDWithPrefix("", value) +} + +// RoomGroupIDWithPrefix 生成带环境前缀的腾讯云 IM 房间群 ID。 +func RoomGroupIDWithPrefix(prefix string, value string) (string, error) { + prefix = strings.TrimSpace(prefix) + if err := validateGroupIDPrefix(prefix); err != nil { + return "", err + } + room := strings.TrimSpace(value) + if !roomid.ValidStringID(room) { + return "", fmt.Errorf("room_id is invalid for group_id") + } + // 腾讯云自定义 GroupID 不接受 UUID 连字符;内部 room_id 保持不变,只在 IM 边界转义。 + groupID := prefix + strings.ReplaceAll(room, "-", "_") + if len(groupID) > roomid.MaxStringIDLength { + return "", fmt.Errorf("room group_id is too long") + } + return groupID, nil +} + // Parse 在调用任何 room guard 前完成 GroupID 分类。 // 解析顺序必须先播报群、后房间群;否则 hy__bc_* 这类合法短字符串可能被 roomid 规则吞掉。 func Parse(groupID string) ParsedGroup { @@ -101,8 +124,8 @@ func Parse(groupID string) ParsedGroup { return ParsedGroup{Kind: KindUnknown} } - if roomid.ValidStringID(groupID) { - return ParsedGroup{Kind: KindRoom, RoomID: groupID} + if roomID, ok := RoomIDFromRoomGroupID(groupID); ok { + return ParsedGroup{Kind: KindRoom, RoomID: roomID} } return ParsedGroup{Kind: KindUnknown} } @@ -118,17 +141,75 @@ func ParseWithPrefix(groupID string, prefix string) ParsedGroup { return Parse(groupID) } if after, ok := strings.CutPrefix(groupID, prefix); ok { - return parseBroadcastGroupID(after) + if parsed := parseBroadcastGroupID(after); parsed.Kind != KindUnknown { + return parsed + } + if roomID, ok := RoomIDFromRoomGroupID(after); ok { + return ParsedGroup{Kind: KindRoom, RoomID: roomID} + } + return ParsedGroup{Kind: KindUnknown} } if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") { return ParsedGroup{Kind: KindUnknown} } - if roomid.ValidStringID(groupID) { - return ParsedGroup{Kind: KindRoom, RoomID: groupID} + if roomID, ok := RoomIDFromRoomGroupID(groupID); ok { + return ParsedGroup{Kind: KindRoom, RoomID: roomID} } return ParsedGroup{Kind: KindUnknown} } +// RoomIDFromRoomGroupID 把腾讯云 IM 房间群 ID 还原成内部 room_id。 +func RoomIDFromRoomGroupID(groupID string) (string, bool) { + groupID = strings.TrimSpace(groupID) + if !roomid.ValidStringID(groupID) { + return "", false + } + if roomID, ok := uuidRoomGroupIDToRoomID(groupID); ok { + return roomID, true + } + return groupID, true +} + +func uuidRoomGroupIDToRoomID(groupID string) (string, bool) { + parts := strings.Split(groupID, "_") + if len(parts) < 6 { + return "", false + } + tail := parts[len(parts)-5:] + if !isHexLength(tail[0], 8) || !isHexLength(tail[1], 4) || !isHexLength(tail[2], 4) || !isHexLength(tail[3], 4) || !isHexLength(tail[4], 12) { + return "", false + } + prefix := strings.Join(parts[:len(parts)-5], "_") + if prefix == "" { + return "", false + } + roomID := prefix + "_" + tail[0] + "-" + tail[1] + "-" + tail[2] + "-" + tail[3] + "-" + tail[4] + if !roomid.ValidStringID(roomID) { + return "", false + } + return roomID, true +} + +func isHexLength(value string, length int) bool { + if len(value) != length { + return false + } + for i := 0; i < len(value); i++ { + char := value[i] + if char >= '0' && char <= '9' { + continue + } + if char >= 'a' && char <= 'f' { + continue + } + if char >= 'A' && char <= 'F' { + continue + } + return false + } + return true +} + func parseBroadcastGroupID(groupID string) ParsedGroup { if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) { app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix) diff --git a/pkg/imgroup/imgroup_test.go b/pkg/imgroup/imgroup_test.go index 11920f5c..3d41572f 100644 --- a/pkg/imgroup/imgroup_test.go +++ b/pkg/imgroup/imgroup_test.go @@ -43,6 +43,21 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) { } } +func TestRoomGroupIDEscapesUUIDHyphen(t *testing.T) { + roomID := "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca" + groupID, err := RoomGroupID(roomID) + if err != nil { + t.Fatalf("RoomGroupID failed: %v", err) + } + if groupID != "lalu_2963d975_316e_444d_ad5c_f82fc36c28ca" { + t.Fatalf("room group mismatch: %s", groupID) + } + parsed := Parse(groupID) + if parsed.Kind != KindRoom || parsed.RoomID != roomID { + t.Fatalf("room group parse mismatch: %+v", parsed) + } +} + func TestBroadcastGroupIDWithPrefix(t *testing.T) { global, err := GlobalBroadcastGroupIDWithPrefix("test_", "lalu") if err != nil { @@ -73,4 +88,11 @@ func TestBroadcastGroupIDWithPrefix(t *testing.T) { if parsed := ParseWithPrefix("room-1001", "test_"); parsed.Kind != KindRoom || parsed.RoomID != "room-1001" { t.Fatalf("room parse with prefix mismatch: %+v", parsed) } + prefixedRoom, err := RoomGroupIDWithPrefix("test_", "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca") + if err != nil { + t.Fatalf("RoomGroupIDWithPrefix failed: %v", err) + } + if parsed := ParseWithPrefix(prefixedRoom, "test_"); parsed.Kind != KindRoom || parsed.RoomID != "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca" { + t.Fatalf("prefixed room parse mismatch: %+v", parsed) + } } diff --git a/pkg/tencentim/rest_client.go b/pkg/tencentim/rest_client.go index 1e6c9202..b3daf775 100644 --- a/pkg/tencentim/rest_client.go +++ b/pkg/tencentim/rest_client.go @@ -94,6 +94,7 @@ type AccountProfile struct { // RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。 type RoomEvent struct { + GroupID string `json:"-"` EventID string `json:"event_id"` RoomID string `json:"room_id"` EventType string `json:"event_type"` @@ -220,6 +221,10 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro // event_id 是客户端去重、日志追踪和补偿投递判断的共同锚点。 return fmt.Errorf("room event is incomplete") } + groupID := strings.TrimSpace(event.GroupID) + if groupID == "" { + groupID = event.RoomID + } eventPayload, err := json.Marshal(event) if err != nil { @@ -227,7 +232,7 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro } return c.PublishGroupCustomMessage(ctx, CustomGroupMessage{ - GroupID: event.RoomID, + GroupID: groupID, EventID: event.EventID, Desc: event.EventType, Ext: "room_system_message", diff --git a/pkg/tencentim/rest_client_test.go b/pkg/tencentim/rest_client_test.go index 730d00a5..7c56fa85 100644 --- a/pkg/tencentim/rest_client_test.go +++ b/pkg/tencentim/rest_client_test.go @@ -58,6 +58,7 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) { }) err := client.PublishRoomEvent(context.Background(), RoomEvent{ + GroupID: "room_1001", EventID: "evt-1", RoomID: "room-1001", EventType: "room_gift_sent", @@ -73,7 +74,7 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) { if capturedPath != "/"+sendGroupMsgCommand { t.Fatalf("path mismatch: got %q", capturedPath) } - if capturedBody["GroupId"] != "room-1001" { + if capturedBody["GroupId"] != "room_1001" { t.Fatalf("group id mismatch: %+v", capturedBody) } body, ok := capturedBody["MsgBody"].([]any) @@ -91,6 +92,9 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) { if !strings.Contains(content["Data"].(string), `"event_id":"evt-1"`) { t.Fatalf("event payload missing event_id: %s", content["Data"]) } + if !strings.Contains(content["Data"].(string), `"room_id":"room-1001"`) { + t.Fatalf("event payload must keep internal room_id: %s", content["Data"]) + } } func TestRESTClientPublishGroupCustomMessageBuildsBroadcastPayload(t *testing.T) { diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index 032186fe..926876ef 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -58,7 +58,7 @@ rocketmq: secret_key: "" namespace: "" room_outbox: - enabled: false + enabled: true topic: "hyapp_room_outbox" consumer_group: "hyapp-activity-room-outbox" consumer_max_reconsume_times: 16 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 19b52a2b..1456e7c3 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2471,7 +2471,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { } rooms := data["rooms"].([]any) first := rooms[0].(map[string]any) - if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" { + if first["im_group_id"] != "room_1" || first["locked"] != true || first["country_code"] != "US" { t.Fatalf("room list must expose room IM group id: %+v", first) } } @@ -2836,7 +2836,7 @@ func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) { t.Fatalf("my room must preserve has_room=true: %+v", data) } room := data["room"].(map[string]any) - if room["room_id"] != "room-owner" || room["im_group_id"] != "room-owner" { + if room["room_id"] != "room-owner" || room["im_group_id"] != "room_owner" { t.Fatalf("my room must expose room id and im group id: %+v", room) } } diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go index ac3f4e86..c64667c4 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go @@ -72,10 +72,10 @@ func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) { }, }) - if data.Room.RoomID != "room-create-im" || data.Room.IMGroupID != "room-create-im" { + if data.Room.RoomID != "room-create-im" || data.Room.IMGroupID != "room_create_im" { t.Fatalf("create response room must expose room_id and im_group_id: %+v", data.Room) } - if data.IM.GroupID != "room-create-im" || !data.IM.NeedJoinGroup { + if data.IM.GroupID != "room_create_im" || !data.IM.NeedJoinGroup { t.Fatalf("create response must expose joinable im group: %+v", data.IM) } if !data.Result.Applied || data.ServerTimeMS != 1700000000000 { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index c7b9423f..96affcdd 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -3,6 +3,7 @@ package roomapi import ( "strconv" + "hyapp/pkg/imgroup" "hyapp/pkg/tencentrtc" roomv1 "hyapp.local/api/proto/room/v1" @@ -1085,8 +1086,12 @@ func roomSeatCounts(snapshot *roomv1.RoomSnapshot) (int32, int32) { } func roomIMGroupID(roomID string) string { - // 当前房间群规则固定为腾讯云 IM GroupID 等于内部 room_id;显式下发给客户端,避免端侧硬编码规则。 - return roomID + // 房间业务 ID 保持原始值;腾讯 IM GroupID 在协议边界转义,避免 UUID 连字符被腾讯 REST 拒绝。 + groupID, err := imgroup.RoomGroupID(roomID) + if err != nil { + return roomID + } + return groupID } func formatOptionalUserID(userID int64) string { diff --git a/services/room-service/configs/config.docker.yaml b/services/room-service/configs/config.docker.yaml index 31832ec2..33019776 100644 --- a/services/room-service/configs/config.docker.yaml +++ b/services/room-service/configs/config.docker.yaml @@ -68,7 +68,7 @@ rocketmq: tencent_im_consumer_group: "hyapp-room-im-bridge" consumer_max_reconsume_times: 16 rocket_launch: - enabled: false + enabled: true topic: "hyapp_room_rocket_launch" producer_group: "hyapp-room-rocket-launch-producer" consumer_group: "hyapp-room-rocket-launch" diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index 51b6a36f..4128d3b3 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -7,6 +7,7 @@ import ( "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/pkg/imgroup" "hyapp/pkg/tencentim" ) @@ -28,7 +29,11 @@ func (p *TencentIMPublisher) EnsureRoomGroup(ctx context.Context, roomID string, return nil } - return p.client.EnsureRoomGroup(ctx, roomID, ownerUserID) + groupID, err := imgroup.RoomGroupID(roomID) + if err != nil { + return err + } + return p.client.EnsureRoomGroup(ctx, groupID, ownerUserID) } // PublishRoomEvent 把房间系统事件发送到腾讯云 IM 群。 @@ -38,6 +43,11 @@ func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencent return nil } + groupID, err := imgroup.RoomGroupID(event.RoomID) + if err != nil { + return err + } + event.GroupID = groupID return p.client.PublishRoomEvent(ctx, event) } @@ -52,7 +62,7 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r return err } // 建群是腾讯 IM 的外部副作用,必须留在 outbox worker 内,不能阻塞 CreateRoom 返回。 - return p.client.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId()) + return p.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId()) } event, publish, err := roomEventFromEnvelope(envelope) @@ -61,7 +71,11 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r } if envelope.GetEventType() == "RoomUserKicked" { // KickUser 的安全边界除了系统消息,还必须补偿移除腾讯云 IM 群成员。 - if err := p.client.DeleteRoomGroupMember(ctx, envelope.GetRoomId(), event.TargetUserID); err != nil { + groupID, err := imgroup.RoomGroupID(envelope.GetRoomId()) + if err != nil { + return err + } + if err := p.client.DeleteGroupMember(ctx, groupID, event.TargetUserID); err != nil { return err } }