616 lines
24 KiB
Go
616 lines
24 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())
|
||
meta, err := s.canonicalRoomCommandMeta(ctx, req.GetMeta())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
password := strings.TrimSpace(req.GetPassword())
|
||
// JoinRoom 只维护 room-service presence,不创建任何本地长连接订阅。
|
||
cmd := command.JoinRoom{
|
||
Base: baseFromMeta(meta),
|
||
Role: req.GetRole(),
|
||
ActorCountryID: req.GetActorCountryId(),
|
||
ActorRegionID: req.GetActorRegionId(),
|
||
ActorIsRobot: req.GetActorIsRobot(),
|
||
PresenceRecovery: req.GetPresenceRecovery(),
|
||
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
|
||
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
|
||
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
|
||
}
|
||
|
||
if cmd.Role == "" {
|
||
// 客户端不传角色时按普通观众进入房间。
|
||
cmd.Role = "audience"
|
||
}
|
||
// 进房展示只查询一次 wallet effective state,并在响应、首次 outbox 与重复进房 direct IM 之间复用。
|
||
// 查询失败不阻断核心 presence:VIP 入场表现可以降级,但房间可用性不能被纯展示依赖拖垮。
|
||
joinVIP := effectiveVIPAccess{}
|
||
if !cmd.ActorIsRobot && cmd.ActorUserID() > 0 && cmd.RoomID() != "" && cmd.ID() != "" {
|
||
if loadedVIP, lookupErr := s.loadEffectiveVIPAccess(ctx, cmd.RequestID, cmd.ActorUserID()); lookupErr != nil {
|
||
logx.Warn(ctx, "room_join_vip_lookup_failed",
|
||
slog.String("room_id", cmd.RoomID()),
|
||
slog.Int64("user_id", cmd.ActorUserID()),
|
||
slog.String("error", lookupErr.Error()),
|
||
)
|
||
} else {
|
||
joinVIP = loadedVIP
|
||
}
|
||
}
|
||
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 {
|
||
existing.LastSeenAtMS = now.UnixMilli()
|
||
current.Version++
|
||
if cmd.PresenceRecovery {
|
||
// 恢复请求与正常 heartbeat 可能并发;presence 已经存在时仍只刷新
|
||
// last_seen,不能因竞态补发入场飘窗。
|
||
snapshot := current.ToProto()
|
||
result.snapshot = snapshot
|
||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||
return result, nil, nil
|
||
}
|
||
|
||
// 普通重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新
|
||
// last_seen,但房间 UI 仍需要一条入房飘窗。该事件只进 direct IM,不进入 durable
|
||
// outbox,避免统计服务把同一在线用户重复计为进房活跃。
|
||
joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID, joinVIP))
|
||
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++
|
||
if cmd.PresenceRecovery {
|
||
// stale worker 只清理了 room-service 的业务 presence,客户端 IM/TRTC 会话仍在。
|
||
// 这里只补回 Room Cell 状态并返回快照;不创建 sync IM、direct IM 或 durable
|
||
// outbox,否则全房会重复看到进房横幅/座驾,统计也会把恢复误当新的 RoomUserJoined。
|
||
snapshot := current.ToProto()
|
||
result.snapshot = snapshot
|
||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||
return result, nil, nil
|
||
}
|
||
|
||
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID, joinVIP))
|
||
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, joinVIP),
|
||
}
|
||
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()),
|
||
EffectiveVip: joinVIP.roomSnapshot(),
|
||
}, 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, vip effectiveVIPAccess) *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),
|
||
VipProgramType: vip.programType,
|
||
EffectiveVipLevel: vip.level,
|
||
EffectiveVipName: vip.name,
|
||
RoomEntryNoticeEnabled: vip.roomEntryNoticeEnabled(),
|
||
}
|
||
}
|
||
|
||
func roomUserJoinedIMAttributes(role string, profile command.UserDisplayProfile, vip effectiveVIPAccess) 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),
|
||
"vip_program_type": vip.programType,
|
||
"effective_vip_level": int32String(vip.level),
|
||
"effective_vip_name": vip.name,
|
||
"room_entry_notice_enabled": boolString(vip.roomEntryNoticeEnabled()),
|
||
}
|
||
// 资料空字段不进 IM payload;VIP level/notice 即使为零值也必须保留,客户端据此明确关闭 VIP 入场表现。
|
||
for key, value := range values {
|
||
if value == "" && key != "effective_vip_name" {
|
||
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 已丢失但客户端仍在按房间节奏心跳,说明发生过 stale 清理或 owner
|
||
// 迁移这类服务端单方面清理;不恢复的话该用户会陷入 MicUp 404、RTC token 403
|
||
// 的粘性失败,只能退房重进。这里按"心跳即在房"原地恢复。
|
||
// 安全边界与 JoinRoom 对齐:room active 已在上方校验,活跃 ban(踢人/封禁)
|
||
// 已在上方拦截;锁房进入需要密码而心跳不携带密码,非房主不得借心跳绕过锁房,
|
||
// 维持原 NotFound 契约让客户端走完整 JoinRoom 补交密码。
|
||
if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() {
|
||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
|
||
}
|
||
// 与 JoinRoom presence_recovery 相同的静默语义:只补 Room Cell 状态,不发
|
||
// sync IM/direct IM/durable outbox,避免重复入房横幅与统计重复计数。
|
||
// LeaveRoom 后仍在途的迟到心跳会短暂恢复 presence,由 stale worker 在下一个
|
||
// 清理窗口自然移除,期间同样不产生任何对外事件。
|
||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||
UserID: cmd.ActorUserID(),
|
||
Role: recoveredPresenceRole(current, cmd.ActorUserID()),
|
||
JoinedAtMS: now.UnixMilli(),
|
||
LastSeenAtMS: now.UnixMilli(),
|
||
}
|
||
current.Version++
|
||
snapshot := current.ToProto()
|
||
return mutationResult{
|
||
snapshot: snapshot,
|
||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||
}, nil, nil
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// recoveredPresenceRole 推导心跳恢复 presence 时的展示角色。房间权限判定始终以
|
||
// OwnerUserID/AdminUsers 权威字段为准(见 actorRole),这里只保证在线列表的房主/
|
||
// 管理员徽标不因恢复而丢失;无法从权威字段证明的身份一律回落 audience,不存在提权。
|
||
func recoveredPresenceRole(current *state.RoomState, userID int64) string {
|
||
switch {
|
||
case current.OwnerUserID == userID:
|
||
return "owner"
|
||
case current.AdminUsers[userID]:
|
||
return "admin"
|
||
default:
|
||
return "audience"
|
||
}
|
||
}
|
||
|
||
// 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.loadCellForLease(roomCtx, roomRef.RoomID, lease)
|
||
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
|
||
}
|