diff --git a/pkg/imgroup/imgroup.go b/pkg/imgroup/imgroup.go index 350f042e..6405bcbe 100644 --- a/pkg/imgroup/imgroup.go +++ b/pkg/imgroup/imgroup.go @@ -101,8 +101,8 @@ func RoomGroupIDWithPrefix(prefix string, value string) (string, error) { if !roomid.ValidStringID(room) { return "", fmt.Errorf("room_id is invalid for group_id") } - // 腾讯云自定义 GroupID 不接受 UUID 连字符;内部 room_id 保持不变,只在 IM 边界转义。 - groupID := prefix + strings.ReplaceAll(room, "-", "_") + // 房间群 GroupID 必须和内部 room_id 保持一致;线上已有腾讯 IM 群也是这个 ID,不能在投递边界改写。 + groupID := prefix + room if len(groupID) > roomid.MaxStringIDLength { return "", fmt.Errorf("room group_id is too long") } @@ -164,52 +164,9 @@ func RoomIDFromRoomGroupID(groupID string) (string, bool) { 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 3d41572f..c8ce7911 100644 --- a/pkg/imgroup/imgroup_test.go +++ b/pkg/imgroup/imgroup_test.go @@ -43,13 +43,13 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) { } } -func TestRoomGroupIDEscapesUUIDHyphen(t *testing.T) { +func TestRoomGroupIDPreservesRoomID(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" { + if groupID != roomID { t.Fatalf("room group mismatch: %s", groupID) } parsed := Parse(groupID) @@ -92,6 +92,9 @@ func TestBroadcastGroupIDWithPrefix(t *testing.T) { if err != nil { t.Fatalf("RoomGroupIDWithPrefix failed: %v", err) } + if prefixedRoom != "test_lalu_2963d975-316e-444d-ad5c-f82fc36c28ca" { + t.Fatalf("prefixed room group mismatch: %s", prefixedRoom) + } 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 b3daf775..d9d102e3 100644 --- a/pkg/tencentim/rest_client.go +++ b/pkg/tencentim/rest_client.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -478,6 +479,31 @@ type restResponse struct { ErrorCode int `json:"ErrorCode"` } +// RESTError 保留腾讯云 IM 业务错误码,让上层能区分缺群、参数错误和限流。 +type RESTError struct { + Code int + Info string +} + +// Error 实现 error,沿用既有日志格式,避免线上检索规则失效。 +func (e RESTError) Error() string { + return fmt.Sprintf("tencent im rest failed: code=%d info=%s", e.Code, e.Info) +} + +// IsRESTErrorCode 判断错误链中是否包含指定腾讯云 IM 业务错误码。 +func IsRESTErrorCode(err error, codes ...int) bool { + var restErr RESTError + if !errors.As(err, &restErr) { + return false + } + for _, code := range codes { + if restErr.Code == code { + return true + } + } + return false +} + func (r restResponse) err() error { if r.ErrorCode == 0 && strings.EqualFold(r.ActionStatus, "OK") { return nil @@ -487,7 +513,7 @@ func (r restResponse) err() error { return nil } - return fmt.Errorf("tencent im rest failed: code=%d info=%s", r.ErrorCode, r.ErrorInfo) + return RESTError{Code: r.ErrorCode, Info: r.ErrorInfo} } func isAccountAlreadyImported(response restResponse) bool { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 1456e7c3..19b52a2b 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 7555e6a1..2fd0840f 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 118ecd96..f936f29b 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -1088,7 +1088,7 @@ func roomSeatCounts(snapshot *roomv1.RoomSnapshot) (int32, int32) { } func roomIMGroupID(roomID string) string { - // 房间业务 ID 保持原始值;腾讯 IM GroupID 在协议边界转义,避免 UUID 连字符被腾讯 REST 拒绝。 + // 房间 IM 群 ID 必须等于内部 room_id;线上腾讯云已按这个 ID 建群,客户端也按这个字段入群。 groupID, err := imgroup.RoomGroupID(roomID) if err != nil { return roomID diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index 1417fb19..501239c7 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -48,7 +48,17 @@ func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencent return err } event.GroupID = groupID - return p.client.PublishRoomEvent(ctx, event) + if err := p.client.PublishRoomEvent(ctx, event); err != nil { + if !tencentim.IsRESTErrorCode(err, 10010, 10015) { + return err + } + // 历史房间可能先产生 outbox 事件、但腾讯云群没有成功创建;保持原 GroupID,补建后只重发一次。 + if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil { + return fmt.Errorf("ensure room im group after publish failure: %w", ensureErr) + } + return p.client.PublishRoomEvent(ctx, event) + } + return nil } // PublishOutboxEvent 补偿投递 room_outbox 中可转成房间系统消息的事件。 @@ -76,7 +86,13 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r return err } if err := p.client.DeleteGroupMember(ctx, groupID, event.TargetUserID); err != nil { - return err + if !tencentim.IsRESTErrorCode(err, 10010, 10015) { + return err + } + // 群缺失时没有成员可删,先补建群,再继续投递 kick 系统消息以推进 outbox。 + if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil { + return fmt.Errorf("ensure room im group after member removal failure: %w", ensureErr) + } } } diff --git a/services/room-service/internal/integration/tencent_im_test.go b/services/room-service/internal/integration/tencent_im_test.go index 42c6161b..179877ec 100644 --- a/services/room-service/internal/integration/tencent_im_test.go +++ b/services/room-service/internal/integration/tencent_im_test.go @@ -198,8 +198,71 @@ func TestTencentIMPublisherRemovesGroupMemberBeforeKickMessage(t *testing.T) { if len(paths) != 2 || paths[0] != "/v4/group_open_http_svc/delete_group_member" || paths[1] != "/v4/group_open_http_svc/send_group_msg" { t.Fatalf("kick outbox should delete member before system message, paths=%+v", paths) } - if bodies[0]["GroupId"] != "room_kick" || bodies[1]["GroupId"] != "room_kick" { - t.Fatalf("kick outbox must use Tencent-safe room group id, bodies=%+v", bodies) + if bodies[0]["GroupId"] != "room-kick" || bodies[1]["GroupId"] != "room-kick" { + t.Fatalf("kick outbox must use original room group id, bodies=%+v", bodies) + } +} + +func TestTencentIMPublisherEnsuresRoomGroupAndRetriesWhenGroupMissing(t *testing.T) { + paths := make([]string, 0, 3) + bodies := make([]map[string]any, 0, 3) + client, err := tencentim.NewRESTClient(tencentim.RESTConfig{ + SDKAppID: 1400000000, + SecretKey: "secret", + AdminIdentifier: "administrator", + AdminUserSigTTL: time.Hour, + HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatalf("decode request body failed: %v", err) + } + bodies = append(bodies, body) + if len(paths) == 1 { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"FAIL","ErrorCode":10015,"ErrorInfo":"bytes_group_id invalid"}`))), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}`))), + Header: make(http.Header), + }, nil + })}, + }) + if err != nil { + t.Fatalf("NewRESTClient failed: %v", err) + } + + publisher := NewTencentIMPublisher(client) + err = publisher.PublishRoomEvent(context.Background(), tencentim.RoomEvent{ + EventID: "evt-retry", + RoomID: "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca", + EventType: "room_mic_changed", + }) + if err != nil { + t.Fatalf("PublishRoomEvent should retry after ensuring group: %v", err) + } + + wantPaths := []string{ + "/v4/group_open_http_svc/send_group_msg", + "/v4/group_open_http_svc/create_group", + "/v4/group_open_http_svc/send_group_msg", + } + if len(paths) != len(wantPaths) { + t.Fatalf("path count mismatch: paths=%+v bodies=%+v", paths, bodies) + } + for index, want := range wantPaths { + if paths[index] != want { + t.Fatalf("path[%d] mismatch: got %s want %s", index, paths[index], want) + } + } + for index, body := range bodies { + if body["GroupId"] != "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca" { + t.Fatalf("request %d must preserve original room group id: %+v", index, body) + } } }