package integration import ( "context" "encoding/json" "fmt" "strings" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/imgroup" "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 } groupID, err := imgroup.RoomGroupID(roomID) if err != nil { return err } return p.client.EnsureRoomGroup(ctx, groupID, ownerUserID) } // PublishRoomEvent 把房间系统事件发送到腾讯云 IM 群。 func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error { if p == nil || p.client == nil { // 未启用腾讯云 IM 时不阻断房间状态提交。 return nil } groupID, err := imgroup.RoomGroupID(event.RoomID) if err != nil { return err } event.GroupID = groupID if err := p.client.PublishRoomEvent(ctx, event); err != nil { if !tencentim.IsRESTErrorCode(err, 10010, 10015) { return err } // 历史房间可能先产生 outbox 事件、但腾讯云群没有成功创建;保持原 GroupID,补建后只重发一次。 if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil { return fmt.Errorf("ensure room im group after publish failure: %w", ensureErr) } return p.client.PublishRoomEvent(ctx, event) } return nil } // 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.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId()) } event, publish, err := roomEventFromEnvelope(envelope) if err != nil || !publish { return err } if envelope.GetEventType() == "RoomUserKicked" { // KickUser 的安全边界除了系统消息,还必须补偿移除腾讯云 IM 群成员。 groupID, err := imgroup.RoomGroupID(envelope.GetRoomId()) if err != nil { return err } if err := p.client.DeleteGroupMember(ctx, groupID, event.TargetUserID); err != nil { if !tencentim.IsRESTErrorCode(err, 10010, 10015) { return err } // 群缺失时没有成员可删,先补建群,再继续投递 kick 系统消息以推进 outbox。 if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil { return fmt.Errorf("ensure room im group after member removal failure: %w", ensureErr) } } } return p.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 = roomUserJoinedAttributesFromEvent(&body) 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(), "target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()), "target_nickname": body.GetTargetNickname(), "target_avatar": body.GetTargetAvatar(), "target_display_user_id": body.GetTargetDisplayUserId(), "target_pretty_display_user_id": body.GetTargetPrettyDisplayUserId(), } if body.GetActorUserId() == body.GetTargetUserId() { base.Attributes["nickname"] = body.GetTargetNickname() base.Attributes["avatar"] = body.GetTargetAvatar() base.Attributes["display_user_id"] = body.GetTargetDisplayUserId() base.Attributes["pretty_display_user_id"] = body.GetTargetPrettyDisplayUserId() } 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 } if strings.EqualFold(strings.TrimSpace(body.GetDisplayMode()), "batch") { // batch-send 仍写逐目标 RoomGiftSent 作为 durable 业务事实,但房间展示只发 RoomGiftBatchSent;否则新 Flutter 会同时收到 batch 和 N 条旧单目标 IM。 return tencentim.RoomEvent{}, false, nil } base.ActorUserID = body.GetSenderUserId() base.TargetUserID = body.GetTargetUserId() base.GiftValue = body.GetGiftValue() // 房间内 IM 也透传 effect_types,客户端可以在房间流和区域广播流中使用同一套礼物展示判断。 effectTypesJSON, err := json.Marshal(body.GetGiftEffectTypes()) if err != nil { return tencentim.RoomEvent{}, false, err } base.Attributes = map[string]string{ "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()), "billing_receipt_id": body.GetBillingReceiptId(), "target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()), "gift_type_code": body.GetGiftTypeCode(), "cp_relation_type": body.GetCpRelationType(), "gift_name": body.GetGiftName(), "gift_icon_url": body.GetGiftIconUrl(), "gift_animation_url": body.GetGiftAnimationUrl(), "gift_effect_types": string(effectTypesJSON), "sender_name": body.GetSenderName(), "sender_avatar": body.GetSenderAvatar(), "sender_display_user_id": body.GetSenderDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), "receiver_nickname": body.GetReceiverNickname(), "receiver_avatar": body.GetReceiverAvatar(), "receiver_display_user_id": body.GetReceiverDisplayUserId(), "receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(), } return base, true, nil case "RoomGiftBatchSent": // 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。 var body roomeventsv1.RoomGiftBatchSent if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } targetUserIDsJSON, err := json.Marshal(body.GetTargetUserIds()) if err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetSenderUserId() base.TargetUserID = firstInt64(body.GetTargetUserIds()) base.GiftValue = body.GetTotalGiftValue() base.RoomHeat = body.GetRoomHeat() // 房间 IM 只保留客户端房内展示需要的小字段:飞座位依赖 target_user_ids,礼物图标/名称用于公屏和飞行图标兜底。 // 逐目标结算、收礼累计和幸运明细已经在 durable RoomGiftSent / lucky_gift_drawn 事实中消费,不能塞进 direct IM。 base.Attributes = map[string]string{ "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "total_gift_value": fmt.Sprintf("%d", body.GetTotalGiftValue()), "billing_receipt_id": body.GetBillingReceiptId(), "pool_id": body.GetPoolId(), "gift_type_code": body.GetGiftTypeCode(), "cp_relation_type": body.GetCpRelationType(), "gift_name": body.GetGiftName(), "gift_icon_url": body.GetGiftIconUrl(), "target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())), "target_user_ids": string(targetUserIDsJSON), "command_id": body.GetCommandId(), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "sender_name": body.GetSenderName(), "sender_avatar": body.GetSenderAvatar(), "sender_display_user_id": body.GetSenderDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), } return base, true, nil case "RoomHeatChanged": // 机器人通道也会投递热度展示事件,客户端可独立刷新房间热度,不依赖主 outbox。 var body roomeventsv1.RoomHeatChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.RoomHeat = body.GetCurrentHeat() base.Attributes = map[string]string{ "delta": fmt.Sprintf("%d", body.GetDelta()), "room_heat": fmt.Sprintf("%d", body.GetCurrentHeat()), } return base, true, nil case "RoomRankChanged": // 麦位收礼热度属于房间内展示数据;机器人事件只在 robot outbox 内广播。 var body roomeventsv1.RoomRankChanged if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return tencentim.RoomEvent{}, false, err } base.ActorUserID = body.GetUserId() base.TargetUserID = body.GetUserId() base.GiftValue = body.GetGiftValue() base.Attributes = map[string]string{ "user_id": fmt.Sprintf("%d", body.GetUserId()), "score": fmt.Sprintf("%d", body.GetScore()), "gift_value": fmt.Sprintf("%d", body.GetGiftValue()), } return base, true, nil case "RoomRobotLuckyGiftDrawn": // 机器人幸运礼物只发房间展示协议,不进入 activity-service 真实抽奖和奖池链路。 var body roomeventsv1.RoomRobotLuckyGiftDrawn 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.GetEffectiveRewardCoins() base.Attributes = map[string]string{ "event_type": "lucky_gift_drawn", "draw_id": body.GetDrawId(), "command_id": body.GetCommandId(), "sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()), "target_user_id": fmt.Sprintf("%d", body.GetTargetUserId()), "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), "pool_id": body.GetPoolId(), "multiplier_ppm": fmt.Sprintf("%d", body.GetMultiplierPpm()), "base_reward_coins": fmt.Sprintf("%d", body.GetBaseRewardCoins()), "effective_reward_coins": fmt.Sprintf("%d", body.GetEffectiveRewardCoins()), "created_at_ms": fmt.Sprintf("%d", body.GetCreatedAtMs()), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "is_robot": fmt.Sprintf("%t", body.GetIsRobot()), "synthetic": fmt.Sprintf("%t", body.GetSynthetic()), "reward_status": "granted", } 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 roomUserJoinedAttributesFromEvent(body *roomeventsv1.RoomUserJoined) map[string]string { if body == nil { return nil } values := map[string]string{ "role": strings.TrimSpace(body.GetRole()), "nickname": strings.TrimSpace(body.GetNickname()), "avatar": strings.TrimSpace(body.GetAvatar()), "display_user_id": strings.TrimSpace(body.GetDisplayUserId()), "pretty_display_user_id": strings.TrimSpace(body.GetPrettyDisplayUserId()), } // RoomEvent.MarshalJSON 会把 attributes 平铺成客户端顶层字段;空字段不发送,避免客户端把空快照当成明确资料值。 for key, value := range values { if value == "" { delete(values, key) } } return values } func firstInt64(values []int64) int64 { for _, value := range values { if value > 0 { return value } } return 0 } 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 "RoomGiftBatchSent": return "room_gift_batch_sent" case "RoomHeatChanged": return "room_heat_changed" case "RoomRankChanged": return "room_rank_changed" case "RoomRobotLuckyGiftDrawn": return "lucky_gift_drawn" 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 } }