2026-07-04 22:02:28 +08:00

712 lines
27 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 service
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
// MicUp 把目标用户放到指定麦位。
func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
// MicUp 以 actor 作为上麦目标,管理员代操作走 MicDown/ChangeMicSeat 等显式 target 命令。
cmd := command.MicUp{
Base: baseFromMeta(req.GetMeta()),
SeatNo: req.GetSeatNo(),
TargetDisplayProfile: userDisplayProfileFromProto(req.GetTargetDisplayProfile()),
}
cmd.MicSessionID = micSessionIDForCommand(cmd.RoomID(), cmd.ID())
cmd.PublishDeadlineMS = s.clock.Now().Add(s.micPublishTimeout).UnixMilli()
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if _, exists := current.OnlineUsers[cmd.ActorUserID()]; !exists {
// 不在 room-service presence 的用户不能上麦。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
}
if seat, exists := current.SeatByUser(cmd.ActorUserID()); exists {
// MicUp 是客户端“确保自己在麦上”的入口;重复点击或网络重试如果换了 command_id
// 当前业务目标已经满足,直接返回现有麦位会话,避免把首次成功覆盖成用户可见失败。
// 真正换麦必须继续走 ChangeMicSeat不能让重复 MicUp 隐式迁移麦位。
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
micSessionID: seat.MicSessionID,
publishDeadlineMS: seat.PublishDeadlineMS,
}, nil, nil
}
index := current.SeatIndex(cmd.SeatNo)
if index < 0 {
// 麦位编号必须来自房间初始化的 MicSeats。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist")
}
if current.MicSeats[index].Locked {
// 锁定麦位不能被普通上麦命令占用。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "seat is locked")
}
if current.MicSeats[index].UserID != 0 {
// 非空麦位不能被覆盖,避免两个用户同时占同一麦位。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied")
}
// MicUp 只代表业务占麦成功RTC 发流必须再通过 ConfirmMicPublishing 确认。
current.Version++
current.MicSeats[index].UserID = cmd.ActorUserID()
current.MicSeats[index].PublishState = state.MicPublishPending
current.MicSeats[index].MicSessionID = cmd.MicSessionID
current.MicSeats[index].PublishDeadlineMS = cmd.PublishDeadlineMS
current.MicSeats[index].MicSessionRoomVersion = current.Version
// 新 session 尚未接受任何 RTC/客户端事件;不能用服务端 MicUp 时间初始化,
// 否则客户端设备时间略慢时,合法的首次确认会被误判为旧事件。
current.MicSeats[index].LastPublishEventTimeMS = 0
current.MicSeats[index].MicHeartbeatAtMS = 0
current.MicSeats[index].MicMuted = false
seatStatus := current.MicSeats[index].SeatStatus()
targetGiftValue := roomUserGiftValue(current, cmd.ActorUserID())
// RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源展示快照固化在事件体direct IM 和 outbox 补偿共用。
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.ActorUserID(),
FromSeat: 0,
ToSeat: cmd.SeatNo,
Action: "up",
MicSessionId: cmd.MicSessionID,
PublishState: state.MicPublishPending,
PublishDeadlineMs: cmd.PublishDeadlineMS,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
}, cmd.TargetDisplayProfile)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: cmd.SeatNo,
micSessionID: cmd.MicSessionID,
publishDeadlineMS: cmd.PublishDeadlineMS,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_up",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
},
}, []outbox.Record{micEvent}, nil
})
if err != nil {
return nil, err
}
if result.micSessionID == "" {
// 幂等重试不会重新执行 mutate需要从当前快照补回首次 MicUp 生成的会话信息。
if seat := protoSeatByUser(result.snapshot, cmd.ActorUserID()); seat != nil {
result.seatNo = seat.GetSeatNo()
result.micSessionID = seat.GetMicSessionId()
result.publishDeadlineMS = seat.GetPublishDeadlineMs()
}
}
return &roomv1.MicUpResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
MicSessionId: result.micSessionID,
PublishDeadlineMs: result.publishDeadlineMS,
}, nil
}
// MicDown 把目标用户从当前麦位移下。
func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
// MicDown 支持操作者和目标用户不同,权限策略后续可在命令入口扩展。
cmd := command.MicDown{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
Reason: req.GetReason(),
TargetDisplayProfile: userDisplayProfileFromProto(req.GetTargetDisplayProfile()),
}
if cmd.TargetUserID == 0 {
// target 缺省时按主动下麦处理;管理别人下麦必须显式传 target_user_id。
cmd.TargetUserID = cmd.ActorUserID()
}
result, err := s.micDown(ctx, cmd)
if err != nil {
return nil, err
}
return &roomv1.MicDownResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
}, nil
}
func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationResult, error) {
return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
// 目标用户不在麦上时不能生成下麦事件。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
}
if cmd.MicSessionID != "" && seat.MicSessionID != cmd.MicSessionID {
// 超时任务携带旧 mic_session_id 时只能成为 no-op不能清掉用户新的上麦会话。
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
// SeatByUser 返回的是值拷贝,真正修改需要再定位切片下标。
index := current.SeatIndex(seat.SeatNo)
micSessionID := current.MicSeats[index].MicSessionID
current.ClearMicSession(index)
current.Version++
seatStatus := current.MicSeats[index].SeatStatus()
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: 0,
Action: "down",
MicSessionId: micSessionID,
PublishState: state.MicPublishIdle,
Reason: cmd.Reason,
SeatStatus: seatStatus,
}, cmd.TargetDisplayProfile)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_down",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
},
}, []outbox.Record{micEvent}, nil
})
}
// ChangeMicSeat 调整指定用户所在麦位。
func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
// ChangeMicSeat 是原子换位:从原麦位清空和写入新麦位必须同版本完成。
cmd := command.ChangeMicSeat{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
SeatNo: req.GetSeatNo(),
TargetDisplayProfile: userDisplayProfileFromProto(req.GetTargetDisplayProfile()),
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
fromSeat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
// 未上麦用户没有可换出的源麦位。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
}
toIndex := current.SeatIndex(cmd.SeatNo)
if toIndex < 0 {
// 目标麦位必须存在。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist")
}
if current.MicSeats[toIndex].Locked {
// 锁定麦位不能换入。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "seat is locked")
}
if current.MicSeats[toIndex].UserID != 0 {
// 目标麦位被占用时拒绝换位,避免覆盖另一个用户。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied")
}
// 清空源麦位和占用目标麦位在同一 Cell 任务内完成,客户端不会看到中间态。
fromIndex := current.SeatIndex(fromSeat.SeatNo)
movedSeat := current.MicSeats[fromIndex]
current.MicSeats[fromIndex].UserID = 0
current.MicSeats[fromIndex].PublishState = ""
current.MicSeats[fromIndex].MicSessionID = ""
current.MicSeats[fromIndex].PublishDeadlineMS = 0
current.MicSeats[fromIndex].MicSessionRoomVersion = 0
current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
current.MicSeats[fromIndex].MicHeartbeatAtMS = 0
current.MicSeats[fromIndex].MicMuted = false
current.MicSeats[toIndex].UserID = cmd.TargetUserID
current.MicSeats[toIndex].PublishState = movedSeat.PublishState
current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
current.MicSeats[toIndex].MicHeartbeatAtMS = movedSeat.MicHeartbeatAtMS
current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
current.Version++
seatStatus := current.MicSeats[toIndex].SeatStatus()
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: fromSeat.SeatNo,
ToSeat: cmd.SeatNo,
Action: "change",
MicSessionId: movedSeat.MicSessionID,
PublishState: movedSeat.PublishState,
PublishDeadlineMs: movedSeat.PublishDeadlineMS,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
}, cmd.TargetDisplayProfile)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_change",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
},
directIMRecords: []outbox.Record{micEvent},
}, nil, nil
})
if err != nil {
return nil, err
}
return &roomv1.ChangeMicSeatResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
}, nil
}
// ConfirmMicPublishing 把 pending_publish 麦位推进到 publishing。
func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.ConfirmMicPublishing{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
MicSessionID: req.GetMicSessionId(),
RoomVersion: req.GetRoomVersion(),
EventTimeMS: req.GetEventTimeMs(),
Source: req.GetSource(),
TargetDisplayProfile: userDisplayProfileFromProto(req.GetTargetDisplayProfile()),
}
if cmd.TargetUserID == 0 {
cmd.TargetUserID = cmd.ActorUserID()
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 || cmd.MicSessionID == "" || cmd.RoomVersion <= 0 || cmd.EventTimeMS <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "mic publish confirmation is incomplete")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if cmd.ActorUserID() != cmd.TargetUserID {
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
// RTC webhook 可能晚于用户下麦到达;旧事件只忽略,不返回错误清掉新状态。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
receivedAtMS := micPublishConfirmReceivedAtMS(seat, cmd, s.micPublishTimeout, now)
if !micPublishConfirmationMatchesCurrentSession(seat, cmd, receivedAtMS) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if seat.PublishState == state.MicPublishPublishing {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if seat.PublishState != state.MicPublishPending {
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic is not waiting for publish confirmation")
}
acceptedCmd := cmd
acceptedCmd.AcceptedEventTimeMS = receivedAtMS
commandPayload, err := command.Serialize(acceptedCmd)
if err != nil {
return mutationResult{}, nil, err
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = receivedAtMS
// 确认发流本身代表当前会话刚被服务端接受,心跳起点使用同一个 accepted time保证 command log 回放一致。
current.MicSeats[index].MicHeartbeatAtMS = receivedAtMS
if micPublishConfirmSourceIsMuted(cmd.Source) {
current.MicSeats[index].MicMuted = true
}
current.Version++
seatStatus := current.MicSeats[index].SeatStatus()
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: seat.SeatNo,
Action: "publish_confirmed",
MicSessionId: cmd.MicSessionID,
PublishState: state.MicPublishPublishing,
PublishDeadlineMs: seat.PublishDeadlineMS,
PublishEventTimeMs: receivedAtMS,
MicMuted: current.MicSeats[index].MicMuted,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
}, cmd.TargetDisplayProfile)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
if err != nil {
return mutationResult{}, nil, err
}
attributes := roomMicChangedIMAttributes(micChanged)
if strings.TrimSpace(cmd.Source) != "" {
attributes["source"] = strings.TrimSpace(cmd.Source)
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
micSessionID: cmd.MicSessionID,
publishDeadlineMS: seat.PublishDeadlineMS,
micHeartbeatAtMS: current.MicSeats[index].MicHeartbeatAtMS,
commandPayload: commandPayload,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: attributes,
},
}, []outbox.Record{micEvent}, nil
})
if err != nil {
return nil, err
}
return &roomv1.ConfirmMicPublishingResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
}, nil
}
func micPublishConfirmationMatchesCurrentSession(seat state.MicSeat, cmd command.ConfirmMicPublishing, arrivedAtMS int64) bool {
if seat.MicSessionID != cmd.MicSessionID || cmd.RoomVersion < seat.MicSessionRoomVersion {
// session_id 和创建 session 的房间版本共同防止快速下麦/重上麦后旧确认误推进新会话。
return false
}
if cmd.EventTimeMS <= seat.LastPublishEventTimeMS {
// event_time_ms 只负责同一会话内的事件顺序,不能拿它判断客户端请求是否真的超时。
return false
}
if seat.PublishDeadlineMS <= 0 || arrivedAtMS >= seat.PublishDeadlineMS {
// deadline 是服务端创建的等待窗口:客户端时钟可能快于服务端,只有服务端到达时间越界才算真实超时。
return false
}
return true
}
func micPublishConfirmReceivedAtMS(seat state.MicSeat, cmd command.ConfirmMicPublishing, timeout time.Duration, now time.Time) int64 {
receivedAtMS := cmd.SentAtMS
if receivedAtMS <= 0 {
return now.UnixMilli()
}
if createdAtMS := rtcMicSessionCreatedAtMS(seat, timeout); createdAtMS > 0 && receivedAtMS < createdAtMS {
// sent_at_ms 正常来自 gateway 服务端时间;若测试夹具或异常入口给了早于会话创建的时间,
// 不能把它当成本次确认时间,否则恢复后 LastPublishEventTimeMS 会落到会话创建前。
return now.UnixMilli()
}
return receivedAtMS
}
func confirmMicPublishingAcceptedEventTimeMS(cmd *command.ConfirmMicPublishing) int64 {
if cmd == nil {
return 0
}
if cmd.AcceptedEventTimeMS > 0 {
return cmd.AcceptedEventTimeMS
}
return cmd.EventTimeMS
}
// MicHeartbeat 刷新当前 publishing 麦位会话的服务端心跳时间。
func (s *Service) MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.MicHeartbeat{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
MicSessionID: req.GetMicSessionId(),
}
if cmd.TargetUserID == 0 {
cmd.TargetUserID = cmd.ActorUserID()
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 || cmd.MicSessionID == "" {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "mic heartbeat is incomplete")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if cmd.ActorUserID() != cmd.TargetUserID {
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
}
if seat.MicSessionID != cmd.MicSessionID {
// 旧客户端或旧 RTC 会话的心跳不能续住新麦位会话。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic session mismatch")
}
if seat.PublishState != state.MicPublishPublishing {
// pending_publish 还没有有效发流,只能先走 ConfirmMicPublishing不能靠心跳开始计时。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic is not publishing")
}
heartbeatAtMS := now.UnixMilli()
if existing, ok := current.OnlineUsers[cmd.TargetUserID]; ok {
// 麦上心跳也证明用户仍处于房间页,顺手刷新通用 presence避免客户端同时打两个心跳。
existing.LastSeenAtMS = heartbeatAtMS
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].MicHeartbeatAtMS = heartbeatAtMS
current.Version++
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
micHeartbeatAtMS: heartbeatAtMS,
}, nil, nil
})
if err != nil {
return nil, err
}
if result.micHeartbeatAtMS == 0 {
result.seatNo, result.micHeartbeatAtMS = micHeartbeatFromSnapshot(result.snapshot, cmd.TargetUserID, cmd.MicSessionID)
}
return &roomv1.MicHeartbeatResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
MicHeartbeatAtMs: result.micHeartbeatAtMS,
}, nil
}
// SetMicMute 修改麦位上的服务端可见静音态。
func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.SetMicMute{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
Muted: req.GetMuted(),
TargetDisplayProfile: userDisplayProfileFromProto(req.GetTargetDisplayProfile()),
}
if cmd.TargetUserID == 0 {
cmd.TargetUserID = cmd.ActorUserID()
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
}
if seat.MicMuted == cmd.Muted {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].MicMuted = cmd.Muted
current.Version++
seatStatus := current.MicSeats[index].SeatStatus()
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
action := "mic_unmute"
if cmd.Muted {
action = "mic_mute"
}
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: seat.SeatNo,
Action: action,
MicSessionId: seat.MicSessionID,
PublishState: seat.PublishState,
PublishDeadlineMs: seat.PublishDeadlineMS,
MicMuted: cmd.Muted,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
}, cmd.TargetDisplayProfile)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_mute_changed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
},
}, []outbox.Record{micEvent}, nil
})
if err != nil {
return nil, err
}
return &roomv1.SetMicMuteResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
}, nil
}
func micSessionIDForCommand(roomID string, commandID string) string {
sum := sha256.Sum256([]byte(roomID + "\x00" + commandID))
return "mic_" + hex.EncodeToString(sum[:16])
}
func micPublishConfirmSourceIsMuted(source string) bool {
switch strings.ToLower(strings.TrimSpace(source)) {
case "client_muted", "client_start_muted":
return true
default:
return false
}
}
func micHeartbeatFromSnapshot(snapshot *roomv1.RoomSnapshot, userID int64, micSessionID string) (int32, int64) {
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() == userID && seat.GetMicSessionId() == micSessionID {
return seat.GetSeatNo(), seat.GetMicHeartbeatAtMs()
}
}
return 0, 0
}
func protoSeatByUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatState {
if snapshot == nil {
return nil
}
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() == userID {
return seat
}
}
return nil
}
func roomUserGiftValue(current *state.RoomState, userID int64) int64 {
if current == nil || userID <= 0 {
return 0
}
if userState := current.OnlineUsers[userID]; userState != nil {
// 麦位事件只透出在线用户态中的收礼热度,避免把麦位号当成热度归属。
return userState.GiftValue
}
return 0
}