2026-04-25 01:18:30 +08:00

507 lines
18 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 承载 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
}