同步创建房间
This commit is contained in:
parent
aa43bf65a9
commit
30a25a9d4d
@ -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 群 |
|
||||
|
||||
@ -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`。
|
||||
|
||||
## 当前目录结构
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 投递 |
|
||||
|
||||
|
||||
@ -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` | 公屏消息内容审核、频控未做 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 命令。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user