2026-07-03 10:43:34 +08:00

534 lines
19 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"
"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 用户不能通过心跳续住业务 presenceKickUser 已经清理 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)
}
// 离房事件进入 outboxstale 清理会通过 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
}