2026-05-16 08:12:42 +08:00

231 lines
7.6 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"
"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
}