231 lines
7.6 KiB
Go
231 lines
7.6 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"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"
|
||
)
|
||
|
||
func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error {
|
||
closed, err := s.isRoomClosed(ctx, roomID)
|
||
if err != nil || !closed {
|
||
return err
|
||
}
|
||
// 进房入口使用专门错误码,gateway 可以稳定映射成客户端房间关闭态。
|
||
return xerr.New(xerr.RoomClosed, "room closed")
|
||
}
|
||
|
||
func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) {
|
||
// 关闭状态先读 rooms 元数据,避免为了拒绝进房恢复完整 Room Cell。
|
||
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
|
||
if err != nil || !exists {
|
||
return false, err
|
||
}
|
||
return isClosedRoomStatus(roomMeta.Status), nil
|
||
}
|
||
|
||
func isClosedRoomStatus(status string) bool {
|
||
value := strings.TrimSpace(status)
|
||
// 空状态兼容测试构造;非 active 一律按不可进入处理,closing 也不接收新用户。
|
||
return value != "" && !strings.EqualFold(value, state.RoomStatusActive)
|
||
}
|
||
|
||
// CloseRoom 关闭房间,统一清理 presence、麦位、读模型状态和房间外事件。
|
||
func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (*roomv1.CloseRoomResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
cmd := command.CloseRoom{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
Reason: strings.TrimSpace(req.GetReason()),
|
||
}
|
||
if cmd.Reason == "" {
|
||
cmd.Reason = "closed_by_owner"
|
||
}
|
||
if isAdminReopenReason(cmd.Reason) {
|
||
return s.reopenRoom(ctx, cmd)
|
||
}
|
||
|
||
rtcKickTargets := make([]int64, 0)
|
||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||
if current == nil {
|
||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||
}
|
||
if current.Status == state.RoomStatusClosed {
|
||
// 关房命令幂等,重复请求只返回当前关闭快照,不重复写 RoomClosed 事件。
|
||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||
}
|
||
if current.Status != state.RoomStatusActive && current.Status != state.RoomStatusClosing {
|
||
// creating 等非开放状态不允许用 CloseRoom 直接跳过创建收尾语义。
|
||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not active")
|
||
}
|
||
if !isAdminCloseReason(cmd.Reason) {
|
||
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
}
|
||
|
||
kickTargets := closeRoomKickTargets(current)
|
||
if len(kickTargets) > 0 {
|
||
rtcKickTargets = append(rtcKickTargets[:0], kickTargets...)
|
||
}
|
||
|
||
type occupiedMicSeat struct {
|
||
userID int64
|
||
seatNo int32
|
||
micSessionID string
|
||
}
|
||
occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats))
|
||
for _, seat := range current.MicSeats {
|
||
if seat.UserID != 0 && seat.MicSessionID != "" {
|
||
// 清场前先保存占麦会话,后续 ClearMicSession 会抹掉 mic_session_id。
|
||
occupiedSeats = append(occupiedSeats, occupiedMicSeat{
|
||
userID: seat.UserID,
|
||
seatNo: seat.SeatNo,
|
||
micSessionID: seat.MicSessionID,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 关房是 Room Cell 的终态变更,presence 和麦位都在同一版本下清空。
|
||
current.Status = state.RoomStatusClosed
|
||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||
for index := range current.MicSeats {
|
||
current.ClearMicSession(index)
|
||
}
|
||
current.Version++
|
||
|
||
records := make([]outbox.Record, 0, len(occupiedSeats)+len(kickTargets)+1)
|
||
closedEvent, err := outbox.Build(current.RoomID, "RoomClosed", current.Version, now, &roomeventsv1.RoomClosed{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
Reason: cmd.Reason,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, closedEvent)
|
||
|
||
for _, targetUserID := range kickTargets {
|
||
// 关房清场复用 RoomUserKicked 事实,notice-service 和腾讯 IM 群成员移除可沿用同一条消费链路。
|
||
kickEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{
|
||
ActorUserId: cmd.ActorUserID(),
|
||
TargetUserId: targetUserID,
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, kickEvent)
|
||
}
|
||
|
||
for _, seat := range occupiedSeats {
|
||
// 关房是 Room Cell 统一清场,给每个占麦 session 单独写 down 事件,保证用户时长能闭合。
|
||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_closed")
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, micEvent)
|
||
}
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
roomStatus: state.RoomStatusClosed,
|
||
syncEvent: &tencentim.RoomEvent{
|
||
EventID: closedEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_closed",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
RoomVersion: current.Version,
|
||
Attributes: map[string]string{
|
||
"reason": cmd.Reason,
|
||
},
|
||
},
|
||
}, records, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
response := &roomv1.CloseRoomResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}
|
||
if result.applied && s.rtcUserRemover != nil {
|
||
for _, targetUserID := range rtcKickTargets {
|
||
// RTC 是房间外连接态:关房事实已提交后再逐个移除,失败不回滚 Room Cell。
|
||
_ = s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), targetUserID)
|
||
}
|
||
}
|
||
|
||
return response, nil
|
||
}
|
||
|
||
func isAdminCloseReason(reason string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(reason), "admin_closed")
|
||
}
|
||
|
||
func isAdminReopenReason(reason string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(reason), "admin_reopen")
|
||
}
|
||
|
||
func (s *Service) reopenRoom(ctx context.Context, cmd command.CloseRoom) (*roomv1.CloseRoomResponse, error) {
|
||
result, err := s.mutateRoom(ctx, cmd, true, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||
if current == nil {
|
||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||
}
|
||
if current.Status == state.RoomStatusActive {
|
||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||
}
|
||
if current.Status != state.RoomStatusClosed {
|
||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not closed")
|
||
}
|
||
|
||
current.Status = state.RoomStatusActive
|
||
current.Version++
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
roomStatus: state.RoomStatusActive,
|
||
}, nil, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &roomv1.CloseRoomResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}, nil
|
||
}
|
||
|
||
func closeRoomKickTargets(current *state.RoomState) []int64 {
|
||
if current == nil {
|
||
return nil
|
||
}
|
||
seen := make(map[int64]struct{}, len(current.OnlineUsers)+len(current.MicSeats))
|
||
for userID := range current.OnlineUsers {
|
||
if userID > 0 {
|
||
seen[userID] = struct{}{}
|
||
}
|
||
}
|
||
for _, seat := range current.MicSeats {
|
||
if seat.UserID > 0 {
|
||
seen[seat.UserID] = struct{}{}
|
||
}
|
||
}
|
||
targets := make([]int64, 0, len(seen))
|
||
for userID := range seen {
|
||
targets = append(targets, userID)
|
||
}
|
||
sort.Slice(targets, func(left, right int) bool {
|
||
return targets[left] < targets[right]
|
||
})
|
||
return targets
|
||
}
|