同步创建房间

This commit is contained in:
zhx 2026-05-27 15:57:01 +08:00
parent aa43bf65a9
commit 30a25a9d4d
12 changed files with 316 additions and 22 deletions

View File

@ -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` outboxworker 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 群 |

View File

@ -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`
## 当前目录结构

View File

@ -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:

View File

@ -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 投递 |

View File

@ -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` | 公屏消息内容审核、频控未做 |

View File

@ -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 |

View File

@ -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 命令。

View File

@ -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)
}
}

View File

@ -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{}

View File

@ -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),
)

View File

@ -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
}

View File

@ -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)
}
}