168 lines
6.1 KiB
Go
168 lines
6.1 KiB
Go
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)
|
||
}
|