package integration import ( "context" "encoding/json" "fmt" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/tencentim" ) // TencentIMPublisher 是 room-service 到腾讯云 IM 的生产路径发布器。 type TencentIMPublisher struct { // client 持有腾讯云 IM REST API、UserSig 和管理员账号配置。 client *tencentim.RESTClient } // NewTencentIMPublisher 创建腾讯云 IM 房间消息发布器。 func NewTencentIMPublisher(client *tencentim.RESTClient) *TencentIMPublisher { return &TencentIMPublisher{client: client} } // EnsureRoomGroup 确保每个 Room Cell 都有对应腾讯云 IM 群组。 func (p *TencentIMPublisher) EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error { if p == nil || p.client == nil { // 未启用腾讯云 IM 时保持无副作用,便于本地测试只跑 room-service。 return nil } return p.client.EnsureRoomGroup(ctx, roomID, ownerUserID) } // PublishRoomEvent 把房间系统事件发送到腾讯云 IM 群。 func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error { if p == nil || p.client == nil { // 未启用腾讯云 IM 时不阻断房间状态提交。 return nil } return p.client.PublishRoomEvent(ctx, event) } // PublishOutboxEvent 补偿投递 room_outbox 中可转成房间系统消息的事件。 func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error { if p == nil || p.client == nil || envelope == nil { return nil } if envelope.GetEventType() == "RoomCreated" { var body roomeventsv1.RoomCreated if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return err } // 建群是腾讯 IM 的外部副作用,必须留在 outbox worker 内,不能阻塞 CreateRoom 返回。 return p.client.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId()) } event, publish, err := roomEventFromEnvelope(envelope) if err != nil || !publish { return err } if envelope.GetEventType() == "RoomUserKicked" { // KickUser 的安全边界除了系统消息,还必须补偿移除腾讯云 IM 群成员。 if err := p.client.DeleteRoomGroupMember(ctx, envelope.GetRoomId(), event.TargetUserID); err != nil { return err } } return p.client.PublishRoomEvent(ctx, event) } // noopRoomEventPublisher 是本地和测试路径的房间消息发布占位实现。 type noopRoomEventPublisher struct{} // NewNoopRoomEventPublisher 返回无外部副作用的房间消息发布器。 func NewNoopRoomEventPublisher() RoomEventPublisher { return noopRoomEventPublisher{} } // EnsureRoomGroup 在 noop 实现中不做外部调用。 func (noopRoomEventPublisher) EnsureRoomGroup(context.Context, string, int64) error { return nil } // PublishRoomEvent 在 noop 实现中不做外部调用。 func (noopRoomEventPublisher) PublishRoomEvent(context.Context, tencentim.RoomEvent) error { return nil } // noopOutboxPublisher 是首版异步 outbox 消费占位实现。 type noopOutboxPublisher struct{} // NewNoopOutboxPublisher 返回一个占位 outbox 发布器。 // 当前版本保留 outbox 表和 worker 语义,activity/audit 投递后续替换该实现。 func NewNoopOutboxPublisher() OutboxPublisher { return noopOutboxPublisher{} } // PublishOutboxEvent 直接吞掉 outbox 事件。 func (noopOutboxPublisher) PublishOutboxEvent(context.Context, *roomeventsv1.EventEnvelope) error { // room_outbox worker 会把同一事件源异步投给腾讯云 IM、activity 或后续审计消费者。 return nil } func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.RoomEvent, bool, error) { // EventEnvelope 是 room_outbox 的稳定信封,补偿投递时再按 event_type 解出客户端字段。 base := tencentim.RoomEvent{ EventID: envelope.GetEventId(), RoomID: envelope.GetRoomId(), EventType: eventTypeForClient(envelope.GetEventType()), RoomVersion: envelope.GetRoomVersion(), } switch envelope.GetEventType() { case "RoomUserJoined": // 进房事件需要带 role,客户端可用于展示进房身份,但权限仍以后续 snapshot 为准。 var body roomeventsv1.RoomUserJoined if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetUserId() base.TargetUserID = body.GetUserId() // RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。 base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle()) base.Attributes = map[string]string{"role": body.GetRole()} return base, true, nil case "RoomUserLeft": // 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。 var body roomeventsv1.RoomUserLeft if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetUserId() base.TargetUserID = body.GetUserId() return base, true, nil case "RoomClosed": // 关房事件提示仍在群内的客户端刷新房间终态;私有通知由 RoomUserKicked 事实另行投递。 var body roomeventsv1.RoomClosed if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.Attributes = map[string]string{"reason": body.GetReason()} return base, true, nil case "RoomMicChanged": // 麦位变更把 from/to 合并成一个客户端事件,避免换麦出现中间空位状态。 var body roomeventsv1.RoomMicChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetTargetUserId() base.SeatNo = body.GetToSeat() if base.SeatNo == 0 { base.SeatNo = body.GetFromSeat() } base.Attributes = map[string]string{ "action": body.GetAction(), "mic_session_id": body.GetMicSessionId(), "publish_state": body.GetPublishState(), "reason": body.GetReason(), "publish_deadline_ms": fmt.Sprintf("%d", body.GetPublishDeadlineMs()), "publish_event_time_ms": fmt.Sprintf("%d", body.GetPublishEventTimeMs()), "mic_muted": fmt.Sprintf("%t", body.GetMicMuted()), "seat_status": body.GetSeatStatus(), } return base, true, nil case "RoomMicSeatLocked": // 锁麦事件只广播锁状态;首版锁麦不会自动下掉已占用用户。 var body roomeventsv1.RoomMicSeatLocked if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.SeatNo = body.GetSeatNo() base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())} return base, true, nil case "RoomProfileUpdated": // 房间资料变更会影响标题、头像、简介和麦位布局,客户端收到后应以 room_version 更新本地 UI。 var body roomeventsv1.RoomProfileUpdated if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.Attributes = map[string]string{ "room_name": body.GetRoomName(), "room_avatar": body.GetRoomAvatar(), "room_description": body.GetRoomDescription(), "seat_count": fmt.Sprintf("%d", body.GetSeatCount()), "room_background_url": body.GetRoomBackgroundUrl(), } return base, true, nil case "RoomBackgroundChanged": // 背景切换是独立的房间 UI 状态变更,客户端无需从通用资料更新里反推。 var body roomeventsv1.RoomBackgroundChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.Attributes = map[string]string{ "background_id": fmt.Sprintf("%d", body.GetBackgroundId()), "room_background_url": body.GetRoomBackgroundUrl(), } return base, true, nil case "RoomChatEnabledChanged": // 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。 var body roomeventsv1.RoomChatEnabledChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())} return base, true, nil case "RoomPasswordChanged": // 锁房事件改由 activity-service 按 visible_region_id 生成区域通知;房间群不再重复投递。 var body roomeventsv1.RoomPasswordChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } return tencentim.RoomEvent{}, false, nil case "RoomAdminChanged": // 管理员变更进入系统消息,客户端收到后仍应以最新 snapshot 修正本地权限 UI。 var body roomeventsv1.RoomAdminChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetTargetUserId() base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())} return base, true, nil case "RoomUserMuted": // 禁言事件只影响公屏发言权限,不改变 presence、麦位或收消息资格。 var body roomeventsv1.RoomUserMuted if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetTargetUserId() base.Attributes = map[string]string{"muted": fmt.Sprintf("%t", body.GetMuted())} return base, true, nil case "RoomUserKicked": // KickUser 的即时系统消息和补偿踢群共用同一个 target_user_id。 var body roomeventsv1.RoomUserKicked if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetTargetUserId() return base, true, nil case "RoomUserUnbanned": // 解封只恢复再次 JoinRoom 的资格,不代表目标已经回到房间 presence。 var body roomeventsv1.RoomUserUnbanned if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetTargetUserId() return base, true, nil case "RoomGiftSent": // 送礼客户端消息只用 GiftSent 主事件承载,Heat/Rank 变化作为字段随同一条消息展示。 var body roomeventsv1.RoomGiftSent if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetSenderUserId() base.TargetUserID = body.GetTargetUserId() base.GiftValue = body.GetGiftValue() base.Attributes = map[string]string{ "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "billing_receipt_id": body.GetBillingReceiptId(), } return base, true, nil case "RoomRocketFuelChanged": var body roomeventsv1.RoomRocketFuelChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetSenderUserId() base.Attributes = map[string]string{ "rocket_id": body.GetRocketId(), "level": fmt.Sprintf("%d", body.GetLevel()), "added_fuel": fmt.Sprintf("%d", body.GetAddedFuel()), "effective_added_fuel": fmt.Sprintf("%d", body.GetEffectiveAddedFuel()), "overflow_fuel": fmt.Sprintf("%d", body.GetOverflowFuel()), "current_fuel": fmt.Sprintf("%d", body.GetCurrentFuel()), "fuel_threshold": fmt.Sprintf("%d", body.GetFuelThreshold()), "status": body.GetStatus(), "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "command_id": body.GetCommandId(), } return base, true, nil case "RoomRocketIgnited": var body roomeventsv1.RoomRocketIgnited if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetIgniterUserId() base.TargetUserID = body.GetTop1UserId() base.Attributes = map[string]string{ "rocket_id": body.GetRocketId(), "level": fmt.Sprintf("%d", body.GetLevel()), "rocket_level": fmt.Sprintf("%d", body.GetLevel()), "room_short_id": body.GetRoomShortId(), "rocket_cover_url": body.GetRocketCoverUrl(), "current_fuel": fmt.Sprintf("%d", body.GetCurrentFuel()), "fuel_threshold": fmt.Sprintf("%d", body.GetFuelThreshold()), "ignited_at_ms": fmt.Sprintf("%d", body.GetIgnitedAtMs()), "launch_at_ms": fmt.Sprintf("%d", body.GetLaunchAtMs()), "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), "broadcast_scope": body.GetBroadcastScope(), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), "igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()), "command_id": body.GetCommandId(), } return base, true, nil case "RoomRocketLaunched": var body roomeventsv1.RoomRocketLaunched if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } rewardsJSON, err := json.Marshal(body.GetRewards()) if err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetIgniterUserId() base.TargetUserID = body.GetTop1UserId() base.Attributes = map[string]string{ "rocket_id": body.GetRocketId(), "level": fmt.Sprintf("%d", body.GetLevel()), "next_level": fmt.Sprintf("%d", body.GetNextLevel()), "launched_at_ms": fmt.Sprintf("%d", body.GetLaunchedAtMs()), "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), "igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()), "rewards_json": string(rewardsJSON), } return base, true, nil case "RoomRocketRewardGranted": // 奖励 IM 需要头像、昵称和短 ID,room-service 不反查 user-service; // activity-service 消费同一事实后做资料 enrichment 并投递房间播报。 return tencentim.RoomEvent{}, false, nil default: // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 return tencentim.RoomEvent{}, false, nil } } func roomEntryVehicleFromEvent(item *roomeventsv1.RoomEntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot { if item == nil || item.GetResourceId() <= 0 { return nil } return &tencentim.RoomEntryVehicleSnapshot{ ResourceID: item.GetResourceId(), ResourceCode: item.GetResourceCode(), Name: item.GetName(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), AnimationURL: item.GetAnimationUrl(), MetadataJSON: item.GetMetadataJson(), EntitlementID: item.GetEntitlementId(), ExpiresAtMS: item.GetExpiresAtMs(), } } func eventTypeForClient(eventType string) string { // protobuf 事件名使用 PascalCase,客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。 switch eventType { case "RoomUserJoined": return "room_user_joined" case "RoomUserLeft": return "room_user_left" case "RoomClosed": return "room_closed" case "RoomMicChanged": return "room_mic_changed" case "RoomMicSeatLocked": return "room_mic_seat_locked" case "RoomProfileUpdated": return "room_profile_updated" case "RoomBackgroundChanged": return "room_background_changed" case "RoomChatEnabledChanged": return "room_chat_enabled_changed" case "RoomPasswordChanged": return "room_password_changed" case "RoomAdminChanged": return "room_admin_changed" case "RoomUserMuted": return "room_user_muted" case "RoomUserKicked": return "room_user_kicked" case "RoomUserUnbanned": return "room_user_unbanned" case "RoomGiftSent": return "room_gift_sent" case "RoomRocketFuelChanged": return "room_rocket_fuel_changed" case "RoomRocketIgnited": return "room_rocket_ignited" case "RoomRocketLaunched": return "room_rocket_launched" case "RoomRocketRewardGranted": return "room_rocket_reward_granted" default: return eventType } }