From 30a25a9d4ddceb897ba4cd0c6017a1d56111be34 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 27 May 2026 15:57:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=88=9B=E5=BB=BA=E6=88=BF?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/IM全局与区域广播架构.md | 2 +- docs/notice-service架构.md | 2 +- docs/openapi/gateway.swagger.yaml | 20 +++- docs/房间Outbox补偿开发.md | 4 +- docs/语音房功能清单.md | 6 +- docs/语音房基础闭环实现.md | 22 ++-- .../transport/http/roomapi/room_handler.go | 6 +- .../http/roomapi/room_handler_test.go | 34 +++++- .../transport/http/roomapi/room_view.go | 23 ++++ .../internal/room/service/create_room.go | 14 ++- .../room/service/create_room_real_im_test.go | 109 ++++++++++++++++++ .../internal/room/service/create_room_test.go | 96 +++++++++++++++ 12 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 services/room-service/internal/room/service/create_room_real_im_test.go create mode 100644 services/room-service/internal/room/service/create_room_test.go diff --git a/docs/IM全局与区域广播架构.md b/docs/IM全局与区域广播架构.md index 59c1ec87..5c59a099 100644 --- a/docs/IM全局与区域广播架构.md +++ b/docs/IM全局与区域广播架构.md @@ -18,7 +18,7 @@ docs/房间Outbox补偿开发.md | Capability | Current Owner | Current Rule | | --- | --- | --- | | IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` | -| 房间群建群 | `room-service` | `CreateRoom` 写 `RoomCreated` outbox,worker direct 投递或 IM bridge consumer 从 MQ 消费后调用腾讯 IM REST `create_group`,`GroupID = room_id` | +| 房间群建群 | `room-service` | `CreateRoom` 返回前同步调用腾讯 IM REST `create_group`,`GroupID = room_id`;`RoomCreated` outbox/IM bridge 后续只做幂等补偿 | | 房间系统消息 | `room-service` | Room Cell 命令成功后写 `room_outbox`,worker direct 投递或 IM bridge consumer 从 MQ 消费后向 `GroupID = room_id` 发 `room_*` 自定义消息 | | IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard | | 系统/活动 inbox | `activity-service` | backend inbox/fanout,非实时 IM 群 | diff --git a/docs/notice-service架构.md b/docs/notice-service架构.md index 690afa38..506acedf 100644 --- a/docs/notice-service架构.md +++ b/docs/notice-service架构.md @@ -27,7 +27,7 @@ 房间 IM 被拆成两种通道: -- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。 +- 房间群通道:`CreateRoom` 主链路同步建群,`RoomCreated` outbox 只做幂等补偿;`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。 - 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice` 给 `target_user_id`。 ## 当前目录结构 diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 7de41ee6..add3b104 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -1135,6 +1135,7 @@ paths: description: | gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner。 请求体不接收 `room_id` 或 `owner_user_id`;`command_id` 由客户端按创建动作生成,gateway 生成内部 room_id。 + room-service 会在返回前同步确保腾讯 IM 房间群存在;创建成功后 `data.room.im_group_id` 可直接用于 JoinRoom 成功后的腾讯 IM SDK joinGroup。 同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。 security: - BearerAuth: [] @@ -1146,7 +1147,7 @@ paths: $ref: "#/definitions/CreateRoomRequest" responses: "200": - description: 创建成功,`data` 返回房间快照。 + description: 创建成功,`data` 返回房间首屏基础数据和已存在的腾讯 IM 群 ID。 schema: $ref: "#/definitions/CreateRoomEnvelope" "400": @@ -4628,11 +4629,26 @@ definitions: format: int64 CreateRoomResponse: type: object + required: + - result + - room + - seats + - im + - server_time_ms properties: result: $ref: "#/definitions/CommandResult" room: - $ref: "#/definitions/RoomSnapshot" + $ref: "#/definitions/RoomInitialData" + seats: + type: array + items: + $ref: "#/definitions/RoomSeatData" + im: + $ref: "#/definitions/RoomIMData" + server_time_ms: + type: integer + format: int64 JoinRoomResponse: type: object required: diff --git a/docs/房间Outbox补偿开发.md b/docs/房间Outbox补偿开发.md index 9c76db54..938073f4 100644 --- a/docs/房间Outbox补偿开发.md +++ b/docs/房间Outbox补偿开发.md @@ -141,7 +141,7 @@ outbox_worker: | Publisher / Consumer | 行为 | | --- | --- | | RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` | -| Tencent IM direct publisher | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 | +| Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup;房间系统事件发送腾讯云 IM 群自定义消息 | | Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 | | Treasure open scheduler | 在 `RoomTreasureCountdownStarted` 后发布到点延迟消息,唤醒 room-service 开箱 | | Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 | @@ -178,7 +178,7 @@ RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ | `snapshot_ms` | 快照持久化耗时;未触发为 0 | | `projection_ms` | 列表和 presence 投影耗时 | | `activity_ms` | 主链路 activity 同步耗时;当前固定为 0 | -| `tencent_im_ms` | 主链路腾讯 IM 同步耗时;当前固定为 0 | +| `tencent_im_ms` | 主链路腾讯 IM 同步耗时;CreateRoom 记录同步建群耗时,其余命令当前为 0 | | `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 | | `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 | diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index 840c8033..14fffacb 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -55,7 +55,7 @@ | Feature | Status | Current Evidence | Remaining Work | | --- | --- | --- | --- | -| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode;`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot 后返回 | 真实 IM 建群由 outbox worker 异步完成,客户端 IM joinGroup 需要容忍短暂 group-not-ready 并重试 | +| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode;`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;同步确保腾讯 IM 房间群存在后再写 meta/command/outbox/snapshot 并返回 `im_group_id` | 腾讯云真实错误码覆盖和已存在群语义验证 | | 修改房间资料和座位数 | `DONE` | `UpdateRoomProfile` 由房间内 owner 执行;资料和座位数变更经过 Room Cell,缩容要求被删除麦位空闲且未锁 | App 编辑页和真实 IM `room_profile_updated` 展示联调 | | owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 | | 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 | @@ -85,9 +85,9 @@ | Feature | Status | Current Evidence | Remaining Work | | --- | --- | --- | --- | | 客户端 IM UserSig | `DONE` | gateway 签发,identifier 使用内部 `user_id` 十进制字符串 | 线上密钥管理和过期刷新体验 | -| room-service 建群 | `DONE` | `RoomCreated` outbox 由 worker 调腾讯 REST create_group | 腾讯云真实错误码覆盖和已存在群语义验证 | +| room-service 建群 | `DONE` | `CreateRoom` 主链路同步调用腾讯 REST `create_group`;`RoomCreated` outbox 仍可幂等补偿 EnsureGroup | 腾讯云真实错误码覆盖和已存在群语义验证 | | 房间系统消息 | `DONE` | `PublishRoomEvent` 发送 TIMCustomElem,事件包含 room_version | 客户端消息解析和版本去重 | -| 主链路不阻塞 IM | `DONE` | 命令提交 command log/outbox 后返回;腾讯 IM 只由 outbox worker 异步投递 | 需要监控 pending/retryable/failed 积压 | +| 房间系统消息不阻塞命令 | `DONE` | 除 CreateRoom 同步建群外,房间系统消息在命令提交 command log/outbox 后由 outbox worker 异步投递 | 需要监控 pending/retryable/failed 积压 | | outbox 投递 IM | `DONE` | outbox worker publish 成功后标记 `delivered`,失败指数退避,超限进 `failed` | 多消费者场景需要更细粒度 sink 状态 | | 入群前守卫 | `DONE` | `Group.CallbackBeforeApplyJoinGroup` -> `VerifyRoomPresence` | 腾讯云控制台配置和公网回调验收 | | 发言前守卫 | `DONE` | `Group.CallbackBeforeSendMsg` -> `CheckSpeakPermission` | 公屏消息内容审核、频控未做 | diff --git a/docs/语音房基础闭环实现.md b/docs/语音房基础闭环实现.md index f5736f57..73b640fc 100644 --- a/docs/语音房基础闭环实现.md +++ b/docs/语音房基础闭环实现.md @@ -85,10 +85,11 @@ sequenceDiagram C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?) G->>R: CreateRoom(actor_user_id, room profile) + R->>T: create_group(room_id) + T-->>R: group ready R->>R: command log + snapshot + outbox - R-->>G: RoomSnapshot - G-->>C: room envelope - R-->>T: outbox worker create_group(room_id) + R-->>G: room DTO with im_group_id + G-->>C: room envelope with im_group_id C->>G: GET /api/v1/im/usersig G-->>C: SDKAppID + user_id + UserSig @@ -177,7 +178,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room ### 1. CreateRoom -`CreateRoom` 不等待腾讯云 IM 建群。它只提交房间权威状态和 `RoomCreated` outbox;建群由 outbox worker 异步完成。 +`CreateRoom` 会在返回前同步确保腾讯云 IM 房间群存在。创建房间是低频动作,主链路宁可因为腾讯 IM 建群失败返回错误,也不能让 App 在 `JoinRoom` 成功后遇到 group-not-ready。 同一个 `actor_user_id` 只能作为 owner 创建一个房间;加入、主持或管理别人的房间不受该 owner 限制影响。 主链路顺序: @@ -187,17 +188,18 @@ validate request acquire Redis lease check room meta check owner has no room +ensure Tencent IM room group initialize RoomState write room meta + command log + snapshot + outbox install Room Cell -return snapshot +return room DTO with im_group_id ``` -如果后续 `EnsureRoomGroup` 失败: +如果同步 `EnsureRoomGroup` 失败: -- `CreateRoom` 已经成功,不回滚房间状态。 -- outbox worker 按指数退避重试,达到上限进入 `failed`。 -- 客户端 IM SDK 加群可能短暂遇到 group-not-ready,需要按产品可接受窗口重试。 +- `CreateRoom` 返回失败,不写房间 meta、command log、snapshot 或 presence 投影。 +- App 不进入房间页,不调用 `JoinRoom` 或腾讯 IM SDK `joinGroup`。 +- 如果腾讯 IM 已建群但后续本地持久化失败,后续同 GroupID 建群按幂等成功处理。 - 失败原因不应该泄露腾讯云密钥、UserSig 或完整公网响应。 ### 2. JoinRoom @@ -331,7 +333,7 @@ room-service 发给腾讯云 IM 群的房间系统消息使用 `TIMCustomElem` | --- | --- | | UserSig 签发 | 已登录用户拿到 `sdk_app_id/user_id/user_sig`,匿名用户失败 | | UserSig 配置缺失 | 返回统一错误,不返回假票据 | -| CreateRoom 后建群失败 | 房间状态已提交,outbox 重试;超限后进入 `failed` | +| CreateRoom 建群失败 | 返回失败,MySQL 不写 room meta/snapshot/command/outbox | | CreateRoom 成功 | MySQL meta/snapshot/command/outbox 都存在 | | JoinRoom 成功 | presence 增加,返回 RoomSnapshot | | 重复 JoinRoom | 刷新 `last_seen_at_ms`,不重复创建 presence | 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 3261f925..05c3b28c 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -663,7 +663,11 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) RoomDescription: body.RoomDescription, RoomShortId: roomShortID, }) - httpkit.Write(writer, request, resp, err) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + httpkit.WriteOK(writer, request, createRoomDataFromProto(resp)) } // updateRoomProfile 把 App 房间资料编辑请求转换为 room-service UpdateRoomProfile 命令。 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 91d7ac3b..d511d61f 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 @@ -1,6 +1,10 @@ package roomapi -import "testing" +import ( + "testing" + + roomv1 "hyapp.local/api/proto/room/v1" +) func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) { tabs := roomGiftTabs( @@ -26,3 +30,31 @@ func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) { t.Fatalf("lucky tab mismatch: %+v", tabs[2]) } } + +func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) { + data := createRoomDataFromProto(&roomv1.CreateRoomResponse{ + Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1, ServerTimeMs: 1700000000000}, + Room: &roomv1.RoomSnapshot{ + RoomId: "room-create-im", + OwnerUserId: 1001, + Mode: "voice", + Status: "active", + ChatEnabled: true, + Version: 1, + RoomShortId: "1001", + OnlineUsers: []*roomv1.RoomUser{{UserId: 1001, Role: "owner"}}, + RoomExt: map[string]string{"title": "Create IM", "cover_url": "cover"}, + VisibleRegionId: 10, + }, + }) + + 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 { + t.Fatalf("create response must expose joinable im group: %+v", data.IM) + } + if !data.Result.Applied || data.ServerTimeMS != 1700000000000 { + t.Fatalf("create response result mismatch: %+v", data) + } +} 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 5d136d4f..44ca1402 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -39,6 +39,14 @@ type roomListItemData struct { RoomShortID string `json:"room_short_id,omitempty"` } +type createRoomData struct { + Result roomCommandResultData `json:"result"` + Room roomInitialData `json:"room"` + Seats []roomSeatData `json:"seats"` + IM roomIMData `json:"im"` + ServerTimeMS int64 `json:"server_time_ms"` +} + type joinRoomData struct { Result roomCommandResultData `json:"result"` Room roomInitialData `json:"room"` @@ -314,6 +322,21 @@ func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData { return data } +func createRoomDataFromProto(resp *roomv1.CreateRoomResponse) createRoomData { + if resp == nil { + return createRoomData{} + } + snapshot := resp.GetRoom() + roomID := snapshot.GetRoomId() + return createRoomData{ + Result: commandResultDataFromProto(resp.GetResult()), + Room: roomInitialRoomDataFromSnapshot(snapshot, roomID), + Seats: roomSeatDataFromSnapshot(snapshot), + IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true}, + ServerTimeMS: resp.GetResult().GetServerTimeMs(), + } +} + func roomTreasureDataFromProto(resp *roomv1.GetRoomTreasureResponse) roomTreasureData { if resp == nil || resp.GetTreasure() == nil { return roomTreasureData{} diff --git a/services/room-service/internal/room/service/create_room.go b/services/room-service/internal/room/service/create_room.go index 26028077..ae13d4da 100644 --- a/services/room-service/internal/room/service/create_room.go +++ b/services/room-service/internal/room/service/create_room.go @@ -53,6 +53,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) var snapshotMS int64 var projectionMS int64 var saveRoomMetaMS int64 + var tencentIMMS int64 if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" { // 创建房间必须指定 room_id,后续 lease、meta、snapshot 都依赖它。 return nil, xerr.New(xerr.InvalidArgument, "room_id is required") @@ -150,6 +151,17 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID)) } + if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil { + return nil, err + } + if s.syncPublisher != nil { + tencentIMStartedAt := time.Now() + if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil { + // 创建房间是低频动作;房间群必须在返回前存在,避免 App JoinRoom 成功后遇到腾讯 IM 群未 ready。 + return nil, xerr.New(xerr.Unavailable, fmt.Sprintf("ensure room im group failed: %v", err)) + } + tencentIMMS = elapsedMS(tencentIMStartedAt) + } if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil { return nil, err } @@ -236,7 +248,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) // 持久化成功后再安装内存 Cell,避免内存有房间但恢复来源不完整。 s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit))) - logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, 0, true, true, + logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true, slog.Int64("save_room_meta_ms", saveRoomMetaMS), ) diff --git a/services/room-service/internal/room/service/create_room_real_im_test.go b/services/room-service/internal/room/service/create_room_real_im_test.go new file mode 100644 index 00000000..e926fb45 --- /dev/null +++ b/services/room-service/internal/room/service/create_room_real_im_test.go @@ -0,0 +1,109 @@ +package service_test + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" + + roomv1 "hyapp.local/api/proto/room/v1" + "hyapp/pkg/tencentim" + "hyapp/services/room-service/internal/integration" + roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" + "hyapp/services/room-service/internal/testutil/mysqltest" +) + +func TestRealCreateRoomEnsuresTencentIMGroup(t *testing.T) { + if os.Getenv("ROOM_CREATE_REAL_IM_TEST") != "1" { + t.Skip("set ROOM_CREATE_REAL_IM_TEST=1, ROOM_SERVICE_MYSQL_TEST_DSN, and Tencent IM env vars to run the real create-room smoke test") + } + + imClient, err := tencentim.NewRESTClient(realRoomCreateIMConfig(t)) + if err != nil { + t.Fatalf("new real Tencent IM client failed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + repository := mysqltest.NewRepository(t) + roomID := "room-real-im-" + strconv.FormatInt(time.Now().UTC().UnixMilli(), 10) + ownerID := time.Now().UTC().UnixMilli() % 9_000_000_000 + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cleanupCancel() + if err := imClient.DestroyGroup(cleanupCtx, roomID); err != nil { + t.Errorf("destroy real Tencent IM room group failed: %v", err) + } + }) + + svc := roomservice.New(roomservice.Config{ + NodeID: "node-real-create-room-im", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewTencentIMPublisher(imClient), integration.NewNoopOutboxPublisher()) + + resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, ownerID), + SeatCount: 10, + Mode: "voice", + VisibleRegionId: 1001, + RoomName: "Real Tencent IM Room", + RoomShortId: strconv.FormatInt(ownerID, 10), + }) + if err != nil { + t.Fatalf("CreateRoom with real Tencent IM failed: %v", err) + } + if resp.GetRoom().GetRoomId() != roomID || !resp.GetResult().GetApplied() { + t.Fatalf("unexpected CreateRoom response: %+v", resp) + } + if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || !exists { + t.Fatalf("room meta must be persisted after real IM group succeeds: exists=%t err=%v", exists, err) + } + + t.Logf("real create room IM smoke passed: room_id=%s owner_user_id=%d", roomID, ownerID) +} + +func realRoomCreateIMConfig(t *testing.T) tencentim.RESTConfig { + t.Helper() + + sdkAppID, err := strconv.ParseInt(requiredRoomCreateEnv(t, "TENCENT_IM_SDK_APP_ID"), 10, 64) + if err != nil || sdkAppID <= 0 { + t.Fatalf("TENCENT_IM_SDK_APP_ID is invalid") + } + endpoint := strings.TrimSpace(os.Getenv("TENCENT_IM_ENDPOINT")) + if endpoint == "" { + endpoint = tencentim.DefaultEndpoint + } + ttl := time.Hour + if rawTTL := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); rawTTL != "" { + parsed, err := time.ParseDuration(rawTTL) + if err != nil || parsed <= 0 { + t.Fatalf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid") + } + ttl = parsed + } + + return tencentim.RESTConfig{ + SDKAppID: sdkAppID, + SecretKey: requiredRoomCreateEnv(t, "TENCENT_IM_SECRET_KEY"), + AdminIdentifier: requiredRoomCreateEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"), + AdminUserSigTTL: ttl, + Endpoint: endpoint, + GroupType: tencentim.DefaultGroupType, + } +} + +func requiredRoomCreateEnv(t *testing.T, key string) string { + t.Helper() + + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + t.Fatalf("%s is required for real create-room smoke test", key) + } + return value +} diff --git a/services/room-service/internal/room/service/create_room_test.go b/services/room-service/internal/room/service/create_room_test.go new file mode 100644 index 00000000..559f694d --- /dev/null +++ b/services/room-service/internal/room/service/create_room_test.go @@ -0,0 +1,96 @@ +package service_test + +import ( + "context" + "errors" + "testing" + "time" + + roomv1 "hyapp.local/api/proto/room/v1" + "hyapp/pkg/tencentim" + "hyapp/services/room-service/internal/integration" + roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" + "hyapp/services/room-service/internal/testutil/mysqltest" +) + +type createRoomIMPublisher struct { + err error + roomID string + ownerUserID int64 + callCount int +} + +func (p *createRoomIMPublisher) EnsureRoomGroup(_ context.Context, roomID string, ownerUserID int64) error { + p.callCount++ + p.roomID = roomID + p.ownerUserID = ownerUserID + return p.err +} + +func (p *createRoomIMPublisher) PublishRoomEvent(context.Context, tencentim.RoomEvent) error { + return nil +} + +func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + publisher := &createRoomIMPublisher{} + svc := roomservice.New(roomservice.Config{ + NodeID: "node-create-room-im-test", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, publisher, integration.NewNoopOutboxPublisher()) + + roomID := "room-sync-im-ok" + ownerID := int64(7001) + resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, ownerID), + SeatCount: 10, + Mode: "voice", + VisibleRegionId: 1001, + RoomName: "Sync IM Room", + RoomShortId: "sync-im-ok", + }) + if err != nil { + t.Fatalf("create room failed: %v", err) + } + if publisher.callCount != 1 || publisher.roomID != roomID || publisher.ownerUserID != ownerID { + t.Fatalf("IM group must be ensured once before create returns: %+v", publisher) + } + if resp.GetRoom().GetRoomId() != roomID || !resp.GetResult().GetApplied() { + t.Fatalf("create response mismatch: %+v", resp) + } +} + +func TestCreateRoomDoesNotPersistWhenIMGroupEnsureFails(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + publisher := &createRoomIMPublisher{err: errors.New("tencent im unavailable")} + svc := roomservice.New(roomservice.Config{ + NodeID: "node-create-room-im-fail-test", + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, router.NewMemoryDirectory(), repository, followTestWallet{}, publisher, integration.NewNoopOutboxPublisher()) + + roomID := "room-sync-im-fail" + ownerID := int64(7002) + if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: roomservice.NewRequestMeta(roomID, ownerID), + SeatCount: 10, + Mode: "voice", + VisibleRegionId: 1001, + RoomName: "Sync IM Failure", + RoomShortId: "sync-im-fail", + }); err == nil { + t.Fatalf("create room must fail when IM group cannot be created") + } + if publisher.callCount != 1 || publisher.roomID != roomID || publisher.ownerUserID != ownerID { + t.Fatalf("IM group ensure call mismatch: %+v", publisher) + } + if meta, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists { + t.Fatalf("room meta must not persist after IM failure: meta=%+v exists=%t err=%v", meta, exists, err) + } +}