package integration import ( "context" "fmt" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp/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 } 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 { // 腾讯云 IM 同步广播不依赖该 outbox;activity/audit 仍会复用同一事件源。 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() 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 "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()), } 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 "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 "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 "RoomHostTransferred": // 主持人转移同时携带新旧 host,便于客户端展示交接提示。 var body roomeventsv1.RoomHostTransferred if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetActorUserId() base.TargetUserID = body.GetNewHostUserId() base.Attributes = map[string]string{ "old_host_user_id": fmt.Sprintf("%d", body.GetOldHostUserId()), "new_host_user_id": fmt.Sprintf("%d", body.GetNewHostUserId()), } 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 default: // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 return tencentim.RoomEvent{}, false, nil } } 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 "RoomMicChanged": return "room_mic_changed" case "RoomMicSeatLocked": return "room_mic_seat_locked" case "RoomChatEnabledChanged": return "room_chat_enabled_changed" case "RoomAdminChanged": return "room_admin_changed" case "RoomHostTransferred": return "room_host_transferred" case "RoomUserMuted": return "room_user_muted" case "RoomUserKicked": return "room_user_kicked" case "RoomUserUnbanned": return "room_user_unbanned" case "RoomGiftSent": return "room_gift_sent" default: return eventType } }