2026-05-12 19:34:47 +08:00

539 lines
19 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"
"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(),
}
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 _, exists := current.SeatByUser(cmd.ActorUserID()); exists {
// 一个用户同一时间只能占一个麦位。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "user already on seat")
}
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
// RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源。
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.ActorUserID(),
FromSeat: 0,
ToSeat: cmd.SeatNo,
Action: "up",
MicSessionId: cmd.MicSessionID,
PublishState: state.MicPublishPending,
PublishDeadlineMs: cmd.PublishDeadlineMS,
})
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: map[string]string{
"action": "up",
"publish_state": state.MicPublishPending,
"mic_session_id": cmd.MicSessionID,
"publish_deadline_ms": int64String(cmd.PublishDeadlineMS),
},
},
}, []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(),
}
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++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: 0,
Action: "down",
MicSessionId: micSessionID,
PublishState: state.MicPublishIdle,
Reason: cmd.Reason,
})
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: map[string]string{
"action": "down",
"publish_state": state.MicPublishIdle,
"mic_session_id": micSessionID,
"reason": cmd.Reason,
},
},
}, []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(),
}
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[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].MicMuted = movedSeat.MicMuted
current.Version++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &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,
})
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: map[string]string{
"action": "change",
"publish_state": movedSeat.PublishState,
"mic_session_id": movedSeat.MicSessionID,
"publish_deadline_ms": int64String(movedSeat.PublishDeadlineMS),
},
},
}, []outbox.Record{micEvent}, 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(),
}
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
}
if seat.MicSessionID != cmd.MicSessionID ||
cmd.RoomVersion < seat.MicSessionRoomVersion ||
cmd.EventTimeMS <= seat.LastPublishEventTimeMS ||
cmd.EventTimeMS > seat.PublishDeadlineMS {
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")
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
current.Version++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &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: cmd.EventTimeMS,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
micSessionID: cmd.MicSessionID,
publishDeadlineMS: seat.PublishDeadlineMS,
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: map[string]string{
"action": "publish_confirmed",
"publish_state": state.MicPublishPublishing,
"mic_session_id": cmd.MicSessionID,
"publish_event_time_ms": int64String(cmd.EventTimeMS),
"source": cmd.Source,
},
},
}, []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
}
// 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(),
}
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++
action := "mic_unmute"
if cmd.Muted {
action = "mic_mute"
}
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &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,
})
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: map[string]string{
"action": action,
"mic_session_id": seat.MicSessionID,
"publish_state": seat.PublishState,
"mic_muted": boolString(cmd.Muted),
},
},
}, []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 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
}