2026-04-29 23:07:50 +08:00

233 lines
7.0 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"
"time"
roomeventsv1 "hyapp/api/proto/events/room/v1"
roomv1 "hyapp/api/proto/room/v1"
"hyapp/pkg/idgen"
"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"
)
// JoinRoom 把用户业务态接入房间。
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
// JoinRoom 只维护 room-service presence不创建任何本地长连接订阅。
cmd := command.JoinRoom{
Base: baseFromMeta(req.GetMeta()),
Role: req.GetRole(),
}
if cmd.Role == "" {
// 客户端不传角色时按普通观众进入房间。
cmd.Role = "audience"
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if current.BanUsers[cmd.ActorUserID()] {
// 被踢或封禁用户不能重新进入业务 presence。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
}
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
// 重复 JoinRoom 表示重连或刷新业务 presence只更新 last_seen不重复发进房系统消息。
existing.LastSeenAtMS = now.UnixMilli()
current.Version++
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
}, nil, nil
}
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
UserID: cmd.ActorUserID(),
Role: cmd.Role,
JoinedAtMS: now.UnixMilli(),
LastSeenAtMS: now.UnixMilli(),
}
current.Version++
// JoinRoom 成功需要 outbox 事件,同时同步推给腾讯云 IM 做房间系统消息。
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{
UserId: cmd.ActorUserID(),
Role: cmd.Role,
})
if err != nil {
return mutationResult{}, nil, err
}
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
syncEvent: &tencentim.RoomEvent{
EventID: joinedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_joined",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
RoomVersion: current.Version,
},
}, []outbox.Record{joinedEvent}, nil
})
if err != nil {
return nil, err
}
return &roomv1.JoinRoomResponse{
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) {
// 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())
if onSeat {
// 离房必须同步释放麦位,但对外只发 RoomUserLeft 主事件,避免客户端处理双事件竞态。
index := current.SeatIndex(seat.SeatNo)
if index >= 0 {
current.ClearMicSession(index)
}
}
current.Version++
// 离房事件进入 outbox并尝试同步通知腾讯云 IMstale 清理会通过 attributes 标记原因。
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
UserId: cmd.ActorUserID(),
})
if err != nil {
return mutationResult{}, nil, err
}
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,
},
}, []outbox.Record{leftEvent}, 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 _, roomID := range s.loadedRoomIDs() {
roomCell := s.loadCell(roomID)
if roomCell == nil {
continue
}
currentState, _, err := roomCell.Snapshot(ctx)
if err != nil {
return err
}
for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) {
cmd := command.LeaveRoom{
Base: command.Base{
RequestID: idgen.New("req_presence_stale"),
CommandID: idgen.New("cmd_presence_stale"),
ActorID: userID,
Room: roomID,
GatewayNodeID: s.nodeID,
SessionID: "presence-stale",
SentAtMS: now.UnixMilli(),
},
}
if _, err := s.leaveRoom(ctx, cmd, "stale_timeout"); err != nil {
return err
}
}
}
return nil
}
func stalePresenceUserIDs(current *state.RoomState, cutoffMs int64) []int64 {
if current == nil {
return nil
}
userIDs := make([]int64, 0)
for userID, userState := range current.OnlineUsers {
if userState != nil && userState.LastSeenAtMS <= cutoffMs {
userIDs = append(userIDs, userID)
}
}
return userIDs
}