534 lines
19 KiB
Go
534 lines
19 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/logx"
|
||
"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"
|
||
)
|
||
|
||
const joinRoomResponseModeLite = "lite"
|
||
|
||
// JoinRoom 把用户业务态接入房间。
|
||
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
password := strings.TrimSpace(req.GetPassword())
|
||
// JoinRoom 只维护 room-service presence,不创建任何本地长连接订阅。
|
||
cmd := command.JoinRoom{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
Role: req.GetRole(),
|
||
ActorCountryID: req.GetActorCountryId(),
|
||
ActorRegionID: req.GetActorRegionId(),
|
||
ActorIsRobot: req.GetActorIsRobot(),
|
||
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
|
||
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
|
||
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
|
||
}
|
||
|
||
if cmd.Role == "" {
|
||
// 客户端不传角色时按普通观众进入房间。
|
||
cmd.Role = "audience"
|
||
}
|
||
reactivateClosedRoom, err := s.roomEntryPolicy(ctx, cmd.RoomID(), cmd.ActorUserID())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||
result := mutationResult{}
|
||
if current.Status == state.RoomStatusClosed {
|
||
if !reactivateClosedRoom || current.OwnerUserID != cmd.ActorUserID() {
|
||
// 元数据和 Room Cell 任一层发现不是房主恢复 closed 房间,都必须继续拒绝进房。
|
||
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
|
||
}
|
||
current.Status = state.RoomStatusActive
|
||
result.roomStatus = state.RoomStatusActive
|
||
result.closeInfo = &RoomCloseInfo{ClearOnOpen: true}
|
||
}
|
||
if current.Status != state.RoomStatusActive {
|
||
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
|
||
}
|
||
if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
|
||
if ban.ActiveAt(now.UnixMilli()) {
|
||
// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
|
||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
|
||
}
|
||
// 限时 ban 过期后由下一次进房命令清理,避免定时任务介入 Room Cell owner 状态。
|
||
delete(current.BanUsers, cmd.ActorUserID())
|
||
}
|
||
|
||
if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) {
|
||
// 锁房校验必须覆盖新进房和已有 presence 的重连;否则房主改密后,悬浮保活或 stale 未清理用户会绕过最新密码。
|
||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
|
||
}
|
||
|
||
joinRole := cmd.Role
|
||
if current.OwnerUserID == cmd.ActorUserID() {
|
||
// 房主恢复 closed 房间时通常没有现存 presence,角色必须按房间 owner 事实收敛为 owner。
|
||
joinRole = "owner"
|
||
}
|
||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
||
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
||
// 避免统计服务把同一个在线用户重复计为进房活跃。
|
||
existing.LastSeenAtMS = now.UnixMilli()
|
||
current.Version++
|
||
|
||
joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID))
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
snapshot := current.ToProto()
|
||
result.snapshot = snapshot
|
||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||
result.directIMRecords = []outbox.Record{joinedDisplayEvent}
|
||
return result, nil, nil
|
||
}
|
||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||
UserID: cmd.ActorUserID(),
|
||
Role: joinRole,
|
||
JoinedAtMS: now.UnixMilli(),
|
||
LastSeenAtMS: now.UnixMilli(),
|
||
}
|
||
current.Version++
|
||
|
||
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID))
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
|
||
snapshot := current.ToProto()
|
||
|
||
result.snapshot = snapshot
|
||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||
result.syncEvent = &tencentim.RoomEvent{
|
||
EventID: joinedEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_user_joined",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
TargetUserID: cmd.ActorUserID(),
|
||
RoomVersion: current.Version,
|
||
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
||
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
||
Attributes: roomUserJoinedIMAttributes(joinRole, cmd.ActorDisplayProfile),
|
||
}
|
||
return result, []outbox.Record{joinedEvent}, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())
|
||
|
||
return &roomv1.JoinRoomResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
User: result.user,
|
||
Room: joinRoomResponseSnapshot(result.snapshot, req.GetResponseMode()),
|
||
}, nil
|
||
}
|
||
|
||
func joinRoomResponseSnapshot(snapshot *roomv1.RoomSnapshot, responseMode string) *roomv1.RoomSnapshot {
|
||
if strings.ToLower(strings.TrimSpace(responseMode)) != joinRoomResponseModeLite || snapshot == nil {
|
||
return snapshot
|
||
}
|
||
onlineCount := snapshot.GetOnlineCount()
|
||
if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
|
||
onlineCount = int32(len(snapshot.GetOnlineUsers()))
|
||
}
|
||
// lite 只裁剪返回给 gateway 的快照;Room Cell 提交、持久化、projection、outbox 都已经使用完整 snapshot。
|
||
return &roomv1.RoomSnapshot{
|
||
RoomId: snapshot.GetRoomId(),
|
||
OwnerUserId: snapshot.GetOwnerUserId(),
|
||
Mode: snapshot.GetMode(),
|
||
Status: snapshot.GetStatus(),
|
||
ChatEnabled: snapshot.GetChatEnabled(),
|
||
MicSeats: snapshot.GetMicSeats(),
|
||
RoomExt: snapshot.GetRoomExt(),
|
||
Heat: snapshot.GetHeat(),
|
||
Version: snapshot.GetVersion(),
|
||
AppCode: snapshot.GetAppCode(),
|
||
RoomShortId: snapshot.GetRoomShortId(),
|
||
VisibleRegionId: snapshot.GetVisibleRegionId(),
|
||
Locked: snapshot.GetLocked(),
|
||
Rocket: snapshot.GetRocket(),
|
||
OnlineCount: onlineCount,
|
||
}
|
||
}
|
||
|
||
func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
|
||
if item == nil || item.GetUserId() <= 0 {
|
||
return command.UserDisplayProfile{}
|
||
}
|
||
// 只信任 gateway 固化的展示快照;room-service 不在进房高频链路反查用户服务。
|
||
return command.UserDisplayProfile{
|
||
UserID: item.GetUserId(),
|
||
Nickname: strings.TrimSpace(item.GetNickname()),
|
||
Avatar: strings.TrimSpace(item.GetAvatar()),
|
||
DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()),
|
||
PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()),
|
||
}
|
||
}
|
||
|
||
func roomUserJoinedEventFromCommand(cmd command.JoinRoom, role string, visibleRegionID int64) *roomeventsv1.RoomUserJoined {
|
||
profile := cmd.ActorDisplayProfile
|
||
// 事件 body 是 direct IM 和 outbox 补偿的共同来源,展示字段必须和统计字段一起固定在命令提交时刻。
|
||
return &roomeventsv1.RoomUserJoined{
|
||
UserId: cmd.ActorUserID(),
|
||
Role: role,
|
||
VisibleRegionId: visibleRegionID,
|
||
CountryId: cmd.ActorCountryID,
|
||
RegionId: firstNonZeroInt64(cmd.ActorRegionID, visibleRegionID),
|
||
IsRobot: cmd.ActorIsRobot,
|
||
EntryVehicle: eventEntryVehicleFromCommand(cmd.EntryVehicle),
|
||
Nickname: strings.TrimSpace(profile.Nickname),
|
||
Avatar: strings.TrimSpace(profile.Avatar),
|
||
DisplayUserId: strings.TrimSpace(profile.DisplayUserID),
|
||
PrettyDisplayUserId: strings.TrimSpace(profile.PrettyDisplayUserID),
|
||
}
|
||
}
|
||
|
||
func roomUserJoinedIMAttributes(role string, profile command.UserDisplayProfile) map[string]string {
|
||
values := map[string]string{
|
||
"role": strings.TrimSpace(role),
|
||
"nickname": strings.TrimSpace(profile.Nickname),
|
||
"avatar": strings.TrimSpace(profile.Avatar),
|
||
"display_user_id": strings.TrimSpace(profile.DisplayUserID),
|
||
"pretty_display_user_id": strings.TrimSpace(profile.PrettyDisplayUserID),
|
||
}
|
||
// 空字段不进 IM payload,避免客户端把空字符串误判为后端明确清空资料。
|
||
for key, value := range values {
|
||
if value == "" {
|
||
delete(values, key)
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
|
||
if item == nil || item.GetResourceId() <= 0 {
|
||
return command.EntryVehicleSnapshot{}
|
||
}
|
||
// 只裁剪字符串,不做有效期二次判断;wallet batch RPC 已经负责过期佩戴过滤。
|
||
return command.EntryVehicleSnapshot{
|
||
ResourceID: item.GetResourceId(),
|
||
ResourceCode: strings.TrimSpace(item.GetResourceCode()),
|
||
Name: strings.TrimSpace(item.GetName()),
|
||
AssetURL: strings.TrimSpace(item.GetAssetUrl()),
|
||
PreviewURL: strings.TrimSpace(item.GetPreviewUrl()),
|
||
AnimationURL: strings.TrimSpace(item.GetAnimationUrl()),
|
||
MetadataJSON: strings.TrimSpace(item.GetMetadataJson()),
|
||
EntitlementID: strings.TrimSpace(item.GetEntitlementId()),
|
||
ExpiresAtMS: item.GetExpiresAtMs(),
|
||
}
|
||
}
|
||
|
||
func eventEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *roomeventsv1.RoomEntryVehicleSnapshot {
|
||
if item.ResourceID <= 0 {
|
||
return nil
|
||
}
|
||
// 事件 proto 是补偿投递、统计消费的稳定事实,字段不依赖后续资源表变更。
|
||
return &roomeventsv1.RoomEntryVehicleSnapshot{
|
||
ResourceId: item.ResourceID,
|
||
ResourceCode: item.ResourceCode,
|
||
Name: item.Name,
|
||
AssetUrl: item.AssetURL,
|
||
PreviewUrl: item.PreviewURL,
|
||
AnimationUrl: item.AnimationURL,
|
||
MetadataJson: item.MetadataJSON,
|
||
EntitlementId: item.EntitlementID,
|
||
ExpiresAtMs: item.ExpiresAtMS,
|
||
}
|
||
}
|
||
|
||
func imEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot {
|
||
if item.ResourceID <= 0 {
|
||
return nil
|
||
}
|
||
// Tencent IM payload 使用客户端展示字段,保持和 /appearance 的资源字段命名一致。
|
||
return &tencentim.RoomEntryVehicleSnapshot{
|
||
ResourceID: item.ResourceID,
|
||
ResourceCode: item.ResourceCode,
|
||
Name: item.Name,
|
||
AssetURL: item.AssetURL,
|
||
PreviewURL: item.PreviewURL,
|
||
AnimationURL: item.AnimationURL,
|
||
MetadataJSON: item.MetadataJSON,
|
||
EntitlementID: item.EntitlementID,
|
||
ExpiresAtMS: item.ExpiresAtMS,
|
||
}
|
||
}
|
||
|
||
// projectRoomVisitBestEffort 记录 Mine/Visited tab 的房间访问索引。
|
||
// 这是列表读模型,不参与 Room Cell 提交;失败只记录日志,不能影响已经成功的 JoinRoom。
|
||
func (s *Service) projectRoomVisitBestEffort(ctx context.Context, userID int64, roomID string) {
|
||
if userID <= 0 || roomID == "" {
|
||
return
|
||
}
|
||
nowMS := s.clock.Now().UnixMilli()
|
||
if err := s.repository.UpsertRoomUserFeedEntry(ctx, RoomUserFeedEntry{
|
||
AppCode: appcode.FromContext(ctx),
|
||
UserID: userID,
|
||
FeedType: roomListTabVisited,
|
||
RoomID: roomID,
|
||
CreatedAtMS: nowMS,
|
||
UpdatedAtMS: nowMS,
|
||
}); err != nil {
|
||
logx.Error(ctx, "room_visit_feed_upsert_failed", err,
|
||
slog.String("component", "room_user_feed_projector"),
|
||
slog.String("room_id", roomID),
|
||
slog.Int64("user_id", userID),
|
||
)
|
||
}
|
||
}
|
||
|
||
// RoomHeartbeat 显式刷新已在房间内用户的业务 presence。
|
||
func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
cmd := command.RoomHeartbeat{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
}
|
||
|
||
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 ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
|
||
if ban.ActiveAt(now.UnixMilli()) {
|
||
// ban 用户不能通过心跳续住业务 presence;KickUser 已经清理 presence,这里是防御脏状态。
|
||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
|
||
}
|
||
// 过期 ban 不再阻断心跳;保守清理后继续按 presence 是否存在判断。
|
||
delete(current.BanUsers, cmd.ActorUserID())
|
||
}
|
||
|
||
existing, exists := current.OnlineUsers[cmd.ActorUserID()]
|
||
if !exists {
|
||
// 心跳只刷新已存在 presence,不能把已离房用户重新加入房间。
|
||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
|
||
}
|
||
|
||
existing.LastSeenAtMS = now.UnixMilli()
|
||
current.Version++
|
||
|
||
snapshot := current.ToProto()
|
||
return mutationResult{
|
||
snapshot: snapshot,
|
||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||
}, nil, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &roomv1.RoomHeartbeatResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
User: result.user,
|
||
Room: result.snapshot,
|
||
}, nil
|
||
}
|
||
|
||
// LeaveRoom 把用户从房间业务态移出。
|
||
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
// LeaveRoom 只移除 room-service 业务 presence,真实 IM 连接态由腾讯云 IM SDK 处理。
|
||
cmd := command.LeaveRoom{
|
||
Base: baseFromMeta(req.GetMeta()),
|
||
}
|
||
|
||
result, err := s.leaveRoom(ctx, cmd, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &roomv1.LeaveRoomResponse{
|
||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||
Room: result.snapshot,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason string) (mutationResult, error) {
|
||
return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||
_, inRoom := current.OnlineUsers[cmd.ActorUserID()]
|
||
seat, onSeat := current.SeatByUser(cmd.ActorUserID())
|
||
if !inRoom && !onSeat {
|
||
// 离房幂等:没有业务 presence 且不占麦时不写命令日志和 outbox。
|
||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||
}
|
||
|
||
delete(current.OnlineUsers, cmd.ActorUserID())
|
||
var micSessionID string
|
||
if onSeat {
|
||
// 离房必须同步释放麦位;同时写 RoomMicChanged/down,给时长聚合一个稳定闭合事件。
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
if index >= 0 {
|
||
micSessionID = current.MicSeats[index].MicSessionID
|
||
current.ClearMicSession(index)
|
||
}
|
||
}
|
||
current.Version++
|
||
|
||
records := make([]outbox.Record, 0, 2)
|
||
if onSeat && micSessionID != "" {
|
||
micReason := reason
|
||
if micReason == "" {
|
||
micReason = "leave"
|
||
}
|
||
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason)
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, micEvent)
|
||
}
|
||
|
||
// 离房事件进入 outbox;stale 清理会通过 attributes 标记原因,腾讯云 IM 由 worker 异步投递。
|
||
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
|
||
UserId: cmd.ActorUserID(),
|
||
})
|
||
if err != nil {
|
||
return mutationResult{}, nil, err
|
||
}
|
||
records = append(records, leftEvent)
|
||
|
||
attributes := map[string]string{}
|
||
if reason != "" {
|
||
attributes["reason"] = reason
|
||
}
|
||
|
||
return mutationResult{
|
||
snapshot: current.ToProto(),
|
||
syncEvent: &tencentim.RoomEvent{
|
||
EventID: leftEvent.EventID,
|
||
RoomID: current.RoomID,
|
||
EventType: "room_user_left",
|
||
ActorUserID: cmd.ActorUserID(),
|
||
TargetUserID: cmd.ActorUserID(),
|
||
RoomVersion: current.Version,
|
||
Attributes: attributes,
|
||
},
|
||
}, records, nil
|
||
})
|
||
}
|
||
|
||
// RunPresenceStaleWorker 周期性清理本节点已装载房间中的过期业务 presence。
|
||
// worker 只调用 room-service 命令链路,不触碰腾讯云 IM 连接态,客户端重连后仍需重新 JoinRoom。
|
||
func (s *Service) RunPresenceStaleWorker(ctx context.Context, interval time.Duration) {
|
||
if interval <= 0 || s.presenceStaleAfter <= 0 {
|
||
// 非正数表示显式关闭 worker,测试或特殊环境可用。
|
||
return
|
||
}
|
||
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
_ = s.SweepStalePresence(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// SweepStalePresence 扫描并清理已经超过 last_seen 阈值的业务 presence。
|
||
// 该方法暴露给测试和后台 worker 复用,所有清理都复用 LeaveRoom 的持久化、快照和 outbox 语义。
|
||
func (s *Service) SweepStalePresence(ctx context.Context) error {
|
||
if s.presenceStaleAfter <= 0 {
|
||
return nil
|
||
}
|
||
|
||
now := s.clock.Now()
|
||
cutoffMs := now.Add(-s.presenceStaleAfter).UnixMilli()
|
||
for _, roomRef := range s.loadedRoomRefs() {
|
||
roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
|
||
lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
continue
|
||
}
|
||
|
||
roomCell := s.loadCell(roomCtx, roomRef.RoomID)
|
||
if roomCell == nil {
|
||
continue
|
||
}
|
||
|
||
currentState, _, err := roomCell.Snapshot(roomCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
continue
|
||
}
|
||
|
||
for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) {
|
||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
break
|
||
}
|
||
|
||
cmd := command.LeaveRoom{
|
||
Base: command.Base{
|
||
RequestID: idgen.New("req_presence_stale"),
|
||
CommandID: idgen.New("cmd_presence_stale"),
|
||
ActorID: userID,
|
||
Room: roomRef.RoomID,
|
||
GatewayNodeID: s.nodeID,
|
||
SessionID: "presence-stale",
|
||
SentAtMS: now.UnixMilli(),
|
||
},
|
||
}
|
||
if _, err := s.leaveRoom(roomCtx, cmd, "stale_timeout"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func stalePresenceUserIDs(current *state.RoomState, cutoffMs int64) []int64 {
|
||
if current == nil {
|
||
return nil
|
||
}
|
||
|
||
robotUserIDs := map[int64]bool(nil)
|
||
if current.RoomExt[roomExtRobotRoomKey] == "true" {
|
||
// 机器人房的内部机器人没有客户端心跳;它们的生命周期由机器人房配置和运行时管理,
|
||
// 不能被普通用户 stale worker 清掉,否则送礼循环会因为 sender/target 不在房间而失败。
|
||
robotUserIDs = parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey])
|
||
}
|
||
|
||
userIDs := make([]int64, 0)
|
||
for userID, userState := range current.OnlineUsers {
|
||
if robotUserIDs[userID] {
|
||
continue
|
||
}
|
||
if userState != nil && userState.LastSeenAtMS <= cutoffMs {
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
}
|
||
|
||
return userIDs
|
||
}
|