412 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 需要头像、昵称和短 IDroom-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
}
}