298 lines
12 KiB
Go
298 lines
12 KiB
Go
package integration
|
||
|
||
import (
|
||
"context"
|
||
"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()
|
||
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()),
|
||
}
|
||
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()),
|
||
}
|
||
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":
|
||
// 锁房事件只广播布尔状态,密码明文和哈希都不会进入 IM 自定义消息。
|
||
var body roomeventsv1.RoomPasswordChanged
|
||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||
return tencentim.RoomEvent{}, false, err
|
||
}
|
||
base.ActorUserID = body.GetActorUserId()
|
||
base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
|
||
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 "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 "RoomClosed":
|
||
return "room_closed"
|
||
case "RoomMicChanged":
|
||
return "room_mic_changed"
|
||
case "RoomMicSeatLocked":
|
||
return "room_mic_seat_locked"
|
||
case "RoomProfileUpdated":
|
||
return "room_profile_updated"
|
||
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"
|
||
default:
|
||
return eventType
|
||
}
|
||
}
|