168 lines
6.1 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"
"log/slog"
"slices"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state"
)
const (
roomPresenceStatusActive = "active"
roomPresenceStatusLeft = "left"
)
// GetCurrentRoom 返回当前用户可恢复房间的只读探测结果。
// 该方法先查用户级 presence 读模型,再用 Room Cell 快照二次校验,避免读模型滞后导致恢复已关闭或已踢出的房间。
func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
userID := req.GetUserId()
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
serverTimeMS := s.clock.Now().UnixMilli()
presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, userID)
if err != nil {
return nil, err
}
if !exists {
// 没有用户当前房间投影时直接返回空结果,不扫描房间列表或所有 Room Cell。
return emptyCurrentRoomResponse(serverTimeMS), nil
}
// presence 只是恢复入口的索引,必须用 Room Cell 快照确认房间仍 active 且用户仍在房间内。
snapshot, err := s.currentSnapshot(ctx, presence.RoomID)
if err != nil {
return nil, err
}
if !currentRoomSnapshotRestorable(snapshot, userID) {
// 查询语义必须保持只读;读模型滞后由后续命令投影或专项补偿修复。
return emptyCurrentRoomResponse(serverTimeMS), nil
}
roomUser := findProtoUser(snapshot, userID)
seat := protoSeatByUser(snapshot, userID)
role := presence.Role
if roomUser != nil {
role = roomUser.GetRole()
}
publishState := ""
micSessionID := ""
needRTCToken := false
if seat != nil {
// 上麦用户回前台需要 RTC token 恢复媒体链路;普通房间用户只需要重新加 IM 群。
publishState = seat.GetPublishState()
micSessionID = seat.GetMicSessionId()
needRTCToken = publishState == state.MicPublishPending || publishState == state.MicPublishPublishing
}
return &roomv1.GetCurrentRoomResponse{
HasCurrentRoom: true,
RoomId: snapshot.GetRoomId(),
RoomVersion: snapshot.GetVersion(),
Role: role,
MicSessionId: micSessionID,
PublishState: publishState,
NeedJoinImGroup: true,
NeedRtcToken: needRTCToken,
ServerTimeMs: serverTimeMS,
}, nil
}
// emptyCurrentRoomResponse 保留 server_time_ms方便客户端用同一套时钟校正逻辑处理空结果。
func emptyCurrentRoomResponse(serverTimeMS int64) *roomv1.GetCurrentRoomResponse {
return &roomv1.GetCurrentRoomResponse{ServerTimeMs: serverTimeMS}
}
// currentRoomSnapshotRestorable 判定读模型指向的房间是否仍可被客户端恢复。
func currentRoomSnapshotRestorable(snapshot *roomv1.RoomSnapshot, userID int64) bool {
if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive {
// 非 active 房间不能恢复,即使用户级 presence 投影还没被清理。
return false
}
if containsUserID(snapshot.GetBanUserIds(), userID) {
// ban 优先于旧 presence避免被踢用户通过本地恢复入口重新收消息。
return false
}
return findProtoUser(snapshot, userID) != nil
}
// projectRoomPresenceBestEffort 用最新快照刷新“用户当前房间”读模型。
// 读模型失败不能影响命令成功语义,但必须记录日志,避免恢复入口长期缺失无人感知。
func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
projection := roomPresenceProjectionFromSnapshot(snapshot, s.clock.Now().UnixMilli())
if projection.RoomID == "" {
return
}
if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil {
logx.Error(ctx, "room_presence_project_failed", err,
slog.String("component", "room_presence_projector"),
slog.String("room_id", projection.RoomID),
slog.Int64("room_version", projection.RoomVersion),
)
}
}
// roomPresenceProjectionFromSnapshot 把 Room Cell 快照压缩成用户当前房间读模型。
// 读模型只保存恢复入口需要的字段,不复制完整 RoomState也不作为权限权威来源。
func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot {
if snapshot == nil || snapshot.GetRoomId() == "" {
return RoomPresenceSnapshot{}
}
// 先按 user_id 建立麦位索引,投影用户时能补齐发流状态而不做双层循环。
seatsByUser := make(map[int64]*roomv1.SeatState, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
seatsByUser[seat.GetUserId()] = seat
}
}
users := make([]RoomPresence, 0, len(snapshot.GetOnlineUsers()))
if snapshot.GetStatus() == state.RoomStatusActive {
// 只有 active 房间才投影 active 用户;关闭房间通过空 users 让 repository 收敛旧 presence。
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() <= 0 {
continue
}
entry := RoomPresence{
AppCode: snapshot.GetAppCode(),
UserID: user.GetUserId(),
RoomID: snapshot.GetRoomId(),
Role: user.GetRole(),
RoomVersion: snapshot.GetVersion(),
Status: roomPresenceStatusActive,
JoinedAtMS: user.GetJoinedAtMs(),
LastSeenAtMS: user.GetLastSeenAtMs(),
UpdatedAtMS: updatedAtMS,
}
if seat := seatsByUser[user.GetUserId()]; seat != nil {
// 发流状态只对当前上麦用户有意义,非上麦用户保持空字段。
entry.PublishState = seat.GetPublishState()
entry.MicSessionID = seat.GetMicSessionId()
}
users = append(users, entry)
}
}
return RoomPresenceSnapshot{
AppCode: snapshot.GetAppCode(),
RoomID: snapshot.GetRoomId(),
RoomVersion: snapshot.GetVersion(),
RoomStatus: snapshot.GetStatus(),
Users: users,
UpdatedAtMS: updatedAtMS,
}
}
// containsUserID 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。
func containsUserID(values []int64, target int64) bool {
return slices.Contains(values, target)
}