// Package service 承载 im-service 的领域逻辑:连接注册、房间频道、消息时间线、ACK 和 room bridge。 package service import ( "context" "encoding/json" "errors" "sync" imv1 "hyapp/api/proto/im/v1" roomv1 "hyapp/api/proto/room/v1" "hyapp/pkg/clock" "hyapp/pkg/idgen" "hyapp/pkg/xerr" "hyapp/services/im-service/internal/auth" "hyapp/services/im-service/internal/session" "hyapp/services/im-service/internal/store" ) // GuardClient 抽象 im-service 对 room-service 守卫查询的依赖。 type GuardClient interface { // CheckSpeakPermission 校验当前用户能否向房间公屏发言,禁言、踢人和房间状态都由 room-service 判定。 CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) // VerifyRoomPresence 校验当前用户能否订阅房间频道,防止只连上 WS 但未进房的用户偷收消息。 VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) } // Config 收敛 im-service 领域层运行参数。 type Config struct { // NodeID 写入 session,未来多节点路由可以基于它定位真实连接所在实例。 NodeID string // RoomHistoryLimit 控制房间最近历史窗口,房间消息不做逐用户 ACK,靠窗口补拉恢复。 RoomHistoryLimit int // Clock 允许测试注入稳定时间,生产默认使用系统时间。 Clock clock.Clock } // OutgoingFrame 是 WS 和测试共用的标准下行结构。 type OutgoingFrame struct { // Type 是客户端帧类型,例如 message、history、force_resync。 Type string `json:"type"` // RoomID 标识房间相关下行帧所属房间。 RoomID string `json:"room_id,omitempty"` // Reason 承载 force_resync 等控制帧的人类可读原因。 Reason string `json:"reason,omitempty"` // Error 保留给 WS 错误帧;当前 handler 遇错直接断开,暂未下发错误帧。 Error string `json:"error,omitempty"` // Message 承载单条消息投递。 Message *store.Message `json:"message,omitempty"` // History 承载 pull_history 的房间最近窗口返回。 History []store.Message `json:"history,omitempty"` } // Service 承载 im-service 的消息、连接和投递逻辑。 type Service struct { // nodeID 标识本实例,写入 session 用于在线状态定位。 nodeID string // roomHistoryLimit 是房间消息最近窗口上限。 roomHistoryLimit int // clock 为消息创建时间和 session 时间提供统一来源。 clock clock.Clock // verifier 校验 WS auth 帧中的 JWT。 verifier *auth.HMACVerifier // guards 是 room-service 守卫查询接口,im-service 不复制房间业务规则。 guards GuardClient // sessions 保存真实 WS 连接和房间频道订阅。 sessions *session.Manager // store 保存当前 v1 的内存消息历史、room_seq 和 ACK 待确认集合。 store *store.MemoryStore // sinksMu 保护 conn_id -> 下行 channel 映射,防止并发断线和投递竞争。 sinksMu sync.Mutex // sinks 是真实 WS 写协程消费的下行队列。 sinks map[string]chan OutgoingFrame // dedupeMu 保护 roomEvents 和 notices 两类幂等表。 dedupeMu sync.Mutex // roomEvents 保存 room-service event_id 到 im message identity 的映射,确保补投不会重复入时间线。 roomEvents map[string]struct { roomSeq int64 messageID string } // notices 保存 notice_id 到 message_id 的映射,确保系统通知补偿调用可幂等返回。 notices map[string]string } // New 初始化 im-service 领域服务。 func New(cfg Config, verifier *auth.HMACVerifier, guards GuardClient) *Service { usedClock := cfg.Clock if usedClock == nil { // 未注入时使用系统时钟,测试可传入固定时钟避免时间相关断言抖动。 usedClock = clock.System{} } limit := cfg.RoomHistoryLimit if limit <= 0 { // 房间最近历史默认 200 条,匹配 v1 方案中的重进房补拉窗口。 limit = 200 } return &Service{ nodeID: cfg.NodeID, roomHistoryLimit: limit, clock: usedClock, verifier: verifier, guards: guards, sessions: session.NewManager(), store: store.NewMemoryStore(), sinks: make(map[string]chan OutgoingFrame), roomEvents: make(map[string]struct { roomSeq int64 messageID string }), notices: make(map[string]string), } } // HealthCheck 覆盖 im-service 内存态核心依赖;当前 session manager 没有后台 loop。 func (s *Service) HealthCheck() error { if s == nil { // nil service 说明 App 装配失败,live 必须失败而不是让空指针在请求路径上暴露。 return errors.New("im service is not initialized") } if s.sessions == nil { // session manager 缺失会导致所有连接和投递路径不可用。 return errors.New("session manager is not initialized") } if s.store == nil { // message store 缺失会导致 room_seq、历史和 ACK 状态不可用。 return errors.New("message store is not initialized") } return nil } // Connect 校验 JWT,并注册一条真实在线连接。 func (s *Service) Connect(token string) (string, <-chan OutgoingFrame, error) { claims, err := s.verifier.Verify(token) if err != nil { // 鉴权失败不创建 session,也不暴露部分连接态给后续帧复用。 return "", nil, err } now := s.clock.Now().UnixMilli() connID := idgen.New("conn") // 下行队列有限缓冲,避免业务 goroutine 被单个慢客户端长期阻塞。 out := make(chan OutgoingFrame, 32) // session 只保存连接态和订阅态,不保存用户完整资料。 s.sessions.AddSession(&session.Session{ ConnID: connID, UserID: claims.UserID, DeviceID: claims.DeviceID, NodeID: s.nodeID, ConnectedAtMS: now, LastSeenAtMS: now, SubscribedRooms: make(map[string]bool), }) s.sinksMu.Lock() // sink 在 session 之后注册,保证投递路径能先通过 session 索引找到连接归属。 s.sinks[connID] = out s.sinksMu.Unlock() return connID, out, nil } // Disconnect 移除在线连接及其消息下行通道。 func (s *Service) Disconnect(connID string) { // 先移除 session 索引,后续广播不会再把该连接列为候选接收者。 s.sessions.RemoveSession(connID) s.sinksMu.Lock() defer s.sinksMu.Unlock() if sink, exists := s.sinks[connID]; exists { // 关闭 channel 会结束 WS 写协程;重复断开由 exists 判断保持幂等。 close(sink) delete(s.sinks, connID) } } // ConnectionUserID 返回连接归属用户,供传输层在下线日志里记录真实连接身份。 func (s *Service) ConnectionUserID(connID string) (int64, bool) { current, exists := s.sessions.GetSession(connID) if !exists { // handler 可能在鉴权失败或并发断线后查询,此时连接身份不可用。 return 0, false } return current.UserID, true } // SubscribeRoom 在业务上确认用户仍在房间后,把连接加入房间频道。 func (s *Service) SubscribeRoom(ctx context.Context, connID string, roomID string) error { current, exists := s.sessions.GetSession(connID) if !exists { // 未鉴权或已断开的连接不能订阅任何房间频道。 return xerr.New(xerr.NotFound, "session not found") } // im-service 必须同步询问 room-service,避免被踢用户仅凭旧 WS 连接继续收房间消息。 guard, err := s.guards.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{ RoomId: roomID, UserId: current.UserID, SessionId: connID, }) if err != nil { // room-service 守卫查询失败时不本地放行,宁可让客户端重试。 return err } if !guard.GetPresent() { // 业务拒绝直接返回权限错误,原因由 room-service 统一给出。 return xerr.New(xerr.PermissionDenied, guard.GetReason()) } s.sessions.SubscribeRoom(connID, roomID) return nil } // UnsubscribeRoom 把连接从房间频道移出。 func (s *Service) UnsubscribeRoom(connID string, roomID string) { s.sessions.UnsubscribeRoom(connID, roomID) } // PullRoomHistory 返回房间最近 N 条历史消息。 func (s *Service) PullRoomHistory(connID string, roomID string, limit int) ([]store.Message, error) { if _, exists := s.sessions.GetSession(connID); !exists { // 历史拉取仍要求有效 WS session,避免匿名扫描房间最近消息。 return nil, xerr.New(xerr.NotFound, "session not found") } return s.store.RoomHistory(roomID, limit), nil } // SendRoomChat 先同步问 room-service,再分配 room_seq、入历史并广播。 func (s *Service) SendRoomChat(ctx context.Context, connID string, roomID string, content string) (store.Message, error) { current, exists := s.sessions.GetSession(connID) if !exists { // 未连接用户没有发言身份,不能绕过 WS auth 直接发公屏。 return store.Message{}, xerr.New(xerr.NotFound, "session not found") } // 发言权限始终由 room-service 决定,im-service 只做连接层和投递层闭环。 guard, err := s.guards.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{ RoomId: roomID, UserId: current.UserID, }) if err != nil { // 守卫 RPC 失败时不能降级为允许发言,否则会绕过禁言和房间关闭聊天状态。 return store.Message{}, err } if !guard.GetAllowed() { // 拒绝原因透传给上层,调用方可转换成 WS 错误或直接断开。 return store.Message{}, xerr.New(xerr.PermissionDenied, guard.GetReason()) } // room_seq 由 im-service 分配,确保房间系统消息和公屏消息进入同一时间线。 message := store.Message{ MessageID: idgen.New("msg"), MessageType: "room_chat_message", FromUserID: current.UserID, RoomID: roomID, RoomSeq: s.store.NextRoomSeq(roomID), Content: content, CreatedAtMS: s.clock.Now().UnixMilli(), } // 先入历史再广播,客户端收到消息后重拉历史一定能看到同一条记录。 s.store.AppendRoomMessage(roomID, message, s.roomHistoryLimit) s.broadcastRoom(roomID, messageFrame(message)) return message, nil } // SendPeerMessage 投递点对点消息,并给接收方建立 ACK 待确认项。 func (s *Service) SendPeerMessage(connID string, toUserID int64, content string) (store.Message, error) { current, exists := s.sessions.GetSession(connID) if !exists { // 单聊发送也必须基于已鉴权连接,FromUserID 不接受客户端自报。 return store.Message{}, xerr.New(xerr.NotFound, "session not found") } // v1 单聊只做基础在线投递和 ACK,不做复杂会话同步与已读未读模型。 message := store.Message{ MessageID: idgen.New("msg"), MessageType: "peer_message", FromUserID: current.UserID, ToUserID: toUserID, Content: content, RequiresACK: true, CreatedAtMS: s.clock.Now().UnixMilli(), } // 单聊需要持久化基础历史,并为接收方登记 ACK 待确认项。 s.store.SavePeerMessage(current.UserID, toUserID, message) s.store.AddPendingACK(toUserID, message) // 同时推给发送方和接收方在线连接,发送方可用同一消息 ID 更新本地状态。 s.pushToUser(current.UserID, messageFrame(message)) s.pushToUser(toUserID, messageFrame(message)) return message, nil } // Ack 标记单聊或系统通知已经被目标用户确认。 func (s *Service) Ack(connID string, messageID string) error { current, exists := s.sessions.GetSession(connID) if !exists { // ACK 必须绑定当前连接对应用户,不能由客户端指定 user_id。 return xerr.New(xerr.NotFound, "session not found") } if !s.store.Ack(current.UserID, messageID) { // 只允许确认属于当前用户的 pending ACK,防止跨用户清理待确认项。 return xerr.New(xerr.NotFound, "ack target not found") } return nil } // PublishRoomEvent 把 room-service 发来的房间系统事件写入统一房间时间线,并立即广播。 func (s *Service) PublishRoomEvent(_ context.Context, req *imv1.PublishRoomEventRequest) (*imv1.PublishRoomEventResponse, error) { event := req.GetEvent() if event == nil || event.GetRoomId() == "" || event.GetEventId() == "" { // room_id 和 event_id 是写入房间时间线及幂等表的硬依赖。 return nil, xerr.New(xerr.InvalidArgument, "room event is incomplete") } s.dedupeMu.Lock() cached, exists := s.roomEvents[event.GetEventId()] s.dedupeMu.Unlock() if exists { // Outbox 补投或同步重试命中相同 event_id 时,返回首次写入的 room_seq 和 message_id。 return &imv1.PublishRoomEventResponse{ Accepted: true, RoomSeq: cached.roomSeq, MessageId: cached.messageID, }, nil } // room-service 只发送业务事件,im-service 在这里转换成客户端可消费的房间系统消息。 message := store.Message{ MessageID: idgen.New("msg"), MessageType: "room_system_message", FromUserID: event.GetActorUserId(), ToUserID: event.GetTargetUserId(), RoomID: event.GetRoomId(), RoomSeq: s.store.NextRoomSeq(event.GetRoomId()), EventID: event.GetEventId(), Content: event.GetText(), Attributes: cloneAttributes(event.GetAttributes()), CreatedAtMS: s.clock.Now().UnixMilli(), } // 先写最近历史再广播,保证客户端收到事件后拉历史能补到同一 room_seq。 s.store.AppendRoomMessage(event.GetRoomId(), message, s.roomHistoryLimit) s.broadcastRoom(event.GetRoomId(), messageFrame(message)) s.dedupeMu.Lock() // 幂等表在广播后记录;当前内存实现重启会丢,后续持久化 store 应保持同一语义。 s.roomEvents[event.GetEventId()] = struct { roomSeq int64 messageID string }{ roomSeq: message.RoomSeq, messageID: message.MessageID, } s.dedupeMu.Unlock() return &imv1.PublishRoomEventResponse{ Accepted: true, RoomSeq: message.RoomSeq, MessageId: message.MessageID, }, nil } // PushSystemNotice 给指定用户投递系统通知,并要求接收方 ACK。 func (s *Service) PushSystemNotice(_ context.Context, req *imv1.PushSystemNoticeRequest) (*imv1.PushSystemNoticeResponse, error) { s.dedupeMu.Lock() if messageID, exists := s.notices[req.GetNoticeId()]; exists && req.GetNoticeId() != "" { s.dedupeMu.Unlock() // notice_id 重复说明调用方正在补偿或重试,返回原 message_id 避免重复通知用户。 return &imv1.PushSystemNoticeResponse{ Accepted: true, MessageId: messageID, }, nil } s.dedupeMu.Unlock() // 系统通知和单聊一样需要 ACK,区别是没有 FromUserID,且按目标用户保存。 message := store.Message{ MessageID: idgen.New("msg"), MessageType: "system_notice", ToUserID: req.GetUserId(), Content: req.GetBody(), Attributes: map[string]string{ "title": req.GetTitle(), }, RequiresACK: true, CreatedAtMS: s.clock.Now().UnixMilli(), } for key, value := range req.GetAttributes() { // 调用方属性覆盖 title 以外的扩展字段,客户端按约定解释。 message.Attributes[key] = value } // 先保存和登记 ACK,再进行在线推送;离线补投可以基于待确认集合扩展。 s.store.SaveSystemNotice(req.GetUserId(), message) s.store.AddPendingACK(req.GetUserId(), message) s.pushToUser(req.GetUserId(), messageFrame(message)) if req.GetNoticeId() != "" { s.dedupeMu.Lock() // 只有调用方提供 notice_id 时才建立幂等关系,普通通知允许多次发送。 s.notices[req.GetNoticeId()] = message.MessageID s.dedupeMu.Unlock() } return &imv1.PushSystemNoticeResponse{ Accepted: true, MessageId: message.MessageID, }, nil } // ForceRoomResync 主动通知某个用户重新拉房间状态。 func (s *Service) ForceRoomResync(_ context.Context, req *imv1.ForceRoomResyncRequest) (*imv1.ForceRoomResyncResponse, error) { // 该控制帧不修改 im-service 状态,只通知在线客户端重新走 room/gateway 查询或重新订阅。 s.pushToUser(req.GetUserId(), OutgoingFrame{ Type: "force_resync", RoomID: req.GetRoomId(), Reason: req.GetReason(), }) return &imv1.ForceRoomResyncResponse{Accepted: true}, nil } // MarshalFrame 统一把下行帧编码成 JSON,供 WS 写回客户端。 func MarshalFrame(frame OutgoingFrame) ([]byte, error) { // JSON 编码集中在 service 层,WS handler 不需要理解具体下行结构。 return json.Marshal(frame) } // PendingACK 返回指定用户当前待确认消息数量。 func (s *Service) PendingACK(userID int64) int { return s.store.PendingACK(userID) } func messageFrame(message store.Message) OutgoingFrame { // 所有消息类型都通过统一 message 帧下发,客户端根据 MessageType 分流展示。 return OutgoingFrame{ Type: "message", RoomID: message.RoomID, Message: &message, } } func (s *Service) broadcastRoom(roomID string, frame OutgoingFrame) { // ListByRoom 返回连接快照;投递阶段不持有 session manager 锁,避免慢客户端阻塞索引读写。 for _, connID := range s.sessions.ListByRoom(roomID) { s.sendFrame(connID, frame) } } func (s *Service) pushToUser(userID int64, frame OutgoingFrame) { // 同一用户可能多端在线,系统通知和单聊都推给当前所有连接。 for _, connID := range s.sessions.ListByUser(userID) { s.sendFrame(connID, frame) } } func (s *Service) sendFrame(connID string, frame OutgoingFrame) { s.sinksMu.Lock() sink, exists := s.sinks[connID] s.sinksMu.Unlock() if !exists { // session 索引和 sink 移除不是同一把锁,断线竞态下缺失 sink 按投递失败处理。 return } select { case sink <- frame: default: // 下行队列满说明客户端消费过慢;当前 v1 丢弃该帧,后续靠历史窗口或 ACK 补偿。 } } func cloneAttributes(values map[string]string) map[string]string { if len(values) == 0 { // nil attributes 会被 JSON omitempty 省略,减少房间广播帧体积。 return nil } // 拷贝 attributes,避免调用方复用 proto map 后影响已经写入历史的消息。 cloned := make(map[string]string, len(values)) for key, value := range values { cloned[key] = value } return cloned }