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

362 lines
10 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 http 暴露 im-service 的 HTTP/WebSocket 传输层。
package http
import (
"context"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
imservice "hyapp/services/im-service/internal/service"
)
const (
serviceRestartReason = "service_restart"
closeWriteTimeout = 2 * time.Second
)
// clientFrame 是客户端到 im-service 的 WS JSON 帧契约。
type clientFrame struct {
// Type 决定帧路由,固定支持 auth、subscribe_room、unsubscribe_room、room_chat_send、peer_send、ack、pull_history。
Type string `json:"type"`
// Token 只在首帧 auth 中使用,鉴权通过后不再信任客户端自报身份。
Token string `json:"token,omitempty"`
// RoomID 用于房间订阅、公屏发送和历史拉取。
RoomID string `json:"room_id,omitempty"`
// ToUserID 用于单聊发送FromUserID 由当前 session 推导。
ToUserID int64 `json:"to_user_id,omitempty"`
// Content 是 v1 文本消息体。
Content string `json:"content,omitempty"`
// MessageID 用于 ACK 指定待确认消息。
MessageID string `json:"message_id,omitempty"`
// Limit 用于 pull_history 裁剪房间最近窗口。
Limit int `json:"limit,omitempty"`
}
// WSHandler 负责承接客户端到 im-service 的真实 WebSocket 长连接。
type WSHandler struct {
// upgrader 执行 HTTP 到 WebSocket 的协议升级。
upgrader websocket.Upgrader
// svc 是连接、消息和房间频道逻辑的领域入口。
svc *imservice.Service
// mu 保护 draining 和 conns保证进入 drain 后不会再登记新 WS 连接。
mu sync.Mutex
// draining 表示进程正在下线,新的 upgrade 必须被拒绝。
draining bool
// conns 保存当前本节点持有的真实 WS 连接,用于下线时发送 1012 close frame。
conns map[*trackedConn]struct{}
// wg 等待已登记连接的 handler 退出,避免下线时只关闭 listener 不等待 WS 清理。
wg sync.WaitGroup
}
// trackedConn 是传输层持有的真实 WS 连接及其日志身份。
type trackedConn struct {
// conn 是 gorilla websocket 的真实连接close frame 和强制关闭都落到它上面。
conn *websocket.Conn
// mu 保护 connID/userID身份会在 auth 成功后补齐。
mu sync.Mutex
// connID 是 im-service session idauth 前连接使用 pending。
connID string
// userID 是 auth 后从 session manager 读出的真实用户 ID。
userID int64
// closeOnce 保证强制关闭同一底层连接最多执行一次。
closeOnce sync.Once
}
// NewWSHandler 创建一个 WS 入口处理器。
func NewWSHandler(svc *imservice.Service) *WSHandler {
return &WSHandler{
upgrader: websocket.Upgrader{
// 当前 v1 不在 WS 层做 Origin 收敛,外层网关或 CDN 可先承担来源控制。
CheckOrigin: func(*http.Request) bool { return true },
},
svc: svc,
conns: make(map[*trackedConn]struct{}),
}
}
// ServeHTTP 先完成 auth再处理 subscribe/chat/peer/ack/pull_history 等帧。
func (h *WSHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if h.IsDraining() {
// drain 后新连接不能再进入 upgrade客户端会按服务重启语义重新连接其他实例。
http.Error(writer, serviceRestartReason, http.StatusServiceUnavailable)
return
}
conn, err := h.upgrader.Upgrade(writer, request, nil)
if err != nil {
// 升级失败说明连接尚未进入 im-service session不需要额外清理。
return
}
tracked, accepted := h.register(conn)
if !accepted {
// upgrade 和 drain 可能并发发生;已升级但未登记的连接也要收到稳定关闭语义。
writeServiceRestartClose(conn, "pending", 0)
_ = conn.Close()
return
}
defer h.unregister(tracked)
defer tracked.close()
if h.IsDraining() {
// 已登记连接在 auth 前遇到 drain直接通知重连不进入 session manager。
tracked.writeServiceRestartClose()
return
}
var first clientFrame
if err := conn.ReadJSON(&first); err != nil || first.Type != "auth" {
// 首帧必须是 auth避免未鉴权连接先发送业务帧占用服务资源。
return
}
if h.IsDraining() {
// auth 读取和 drain 可以并发drain 后不再创建新的 session。
tracked.writeServiceRestartClose()
return
}
connID, out, err := h.svc.Connect(first.Token)
if err != nil {
// token 无效时不下发错误细节,连接直接关闭,减少鉴权枚举面。
return
}
if userID, exists := h.svc.ConnectionUserID(connID); exists {
// close frame 日志需要真实连接身份,但身份必须来自已注册 session。
tracked.setIdentity(connID, userID)
}
// 连接退出时必须从 session manager 和 sink 表中同时移除。
defer h.svc.Disconnect(connID)
done := make(chan struct{})
go func() {
defer close(done)
// 写协程只消费 service 下行队列;业务 goroutine 不直接持有 websocket.Conn。
for frame := range out {
payload, err := imservice.MarshalFrame(frame)
if err != nil {
// 下行帧无法编码说明服务端数据异常,关闭连接让客户端重连恢复。
return
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
// 写失败通常代表客户端断线,读循环会随连接关闭退出。
return
}
}
}()
for {
var frame clientFrame
if err := conn.ReadJSON(&frame); err != nil {
// 读失败统一视为连接结束defer Disconnect 会清理订阅和下行通道。
return
}
if h.IsDraining() {
// 下线期间不继续接受新的 subscribe_room 或发送帧,避免旧节点继续扩展连接态。
tracked.writeServiceRestartClose()
return
}
switch frame.Type {
case "subscribe_room":
// 订阅前会同步调用 room-service VerifyRoomPresence防止越权收房间消息。
if err := h.svc.SubscribeRoom(request.Context(), connID, frame.RoomID); err != nil {
return
}
case "unsubscribe_room":
// 取消订阅是纯连接态操作,不改变 room-service presence。
h.svc.UnsubscribeRoom(connID, frame.RoomID)
case "room_chat_send":
// 公屏消息先经 room-service CheckSpeakPermission再由 im-service 分配 room_seq。
if _, err := h.svc.SendRoomChat(request.Context(), connID, frame.RoomID, frame.Content); err != nil {
return
}
case "peer_send":
// 单聊 FromUserID 来自当前 session客户端只提供目标用户和内容。
if _, err := h.svc.SendPeerMessage(connID, frame.ToUserID, frame.Content); err != nil {
return
}
case "ack":
// ACK 只清理当前用户的 peer_message/system_notice 待确认项。
if err := h.svc.Ack(connID, frame.MessageID); err != nil {
return
}
case "pull_history":
// 房间历史只返回 im-service 保存的最近窗口,用于重进房补拉。
history, err := h.svc.PullRoomHistory(connID, frame.RoomID, frame.Limit)
if err != nil {
return
}
payload, err := imservice.MarshalFrame(imservice.OutgoingFrame{
Type: "history",
RoomID: frame.RoomID,
History: history,
})
if err != nil {
// 历史帧编码失败说明存储数据异常,关闭连接比发送半包更安全。
return
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
// 同步历史响应写失败时连接已不可用,退出后交给客户端重连。
return
}
}
}
}
// Shutdown 进入 WS drain发送 1012/service_restart并等待客户端主动断开。
func (h *WSHandler) Shutdown(ctx context.Context) int {
conns := h.beginDraining()
for _, conn := range conns {
conn.writeServiceRestartClose()
}
done := make(chan struct{})
go func() {
h.wg.Wait()
close(done)
}()
select {
case <-done:
return 0
case <-ctx.Done():
remaining := h.snapshotConns()
for _, conn := range remaining {
conn.close()
}
return len(remaining)
}
}
// StartDraining 只切换 WS 入口状态,用于 HTTP listener 关闭前先拒绝并发 upgrade。
func (h *WSHandler) StartDraining() {
_ = h.beginDraining()
}
// ForceClose 立即关闭所有本机 WS 连接,用于第二次信号或 shutdown timeout 后加速退出。
func (h *WSHandler) ForceClose() int {
conns := h.beginDraining()
for _, conn := range conns {
conn.close()
}
return len(conns)
}
// ActiveConnections 返回当前仍由本节点持有的 WS 连接数量。
func (h *WSHandler) ActiveConnections() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.conns)
}
// IsDraining 返回当前 handler 是否已经停止接收新连接。
func (h *WSHandler) IsDraining() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.draining
}
func (h *WSHandler) register(conn *websocket.Conn) (*trackedConn, bool) {
h.mu.Lock()
defer h.mu.Unlock()
if h.draining {
return nil, false
}
tracked := &trackedConn{conn: conn}
h.wg.Add(1)
h.conns[tracked] = struct{}{}
return tracked, true
}
func (h *WSHandler) unregister(conn *trackedConn) {
h.mu.Lock()
delete(h.conns, conn)
h.mu.Unlock()
h.wg.Done()
}
func (h *WSHandler) beginDraining() []*trackedConn {
h.mu.Lock()
defer h.mu.Unlock()
h.draining = true
return copyConns(h.conns)
}
func (h *WSHandler) snapshotConns() []*trackedConn {
h.mu.Lock()
defer h.mu.Unlock()
return copyConns(h.conns)
}
func copyConns(values map[*trackedConn]struct{}) []*trackedConn {
conns := make([]*trackedConn, 0, len(values))
for conn := range values {
conns = append(conns, conn)
}
return conns
}
func (c *trackedConn) setIdentity(connID string, userID int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.connID = connID
c.userID = userID
}
func (c *trackedConn) identity() (string, int64) {
c.mu.Lock()
defer c.mu.Unlock()
connID := c.connID
if connID == "" {
connID = "pending"
}
return connID, c.userID
}
func (c *trackedConn) writeServiceRestartClose() {
connID, userID := c.identity()
writeServiceRestartClose(c.conn, connID, userID)
}
func (c *trackedConn) close() {
c.closeOnce.Do(func() {
_ = c.conn.Close()
})
}
func writeServiceRestartClose(conn *websocket.Conn, connID string, userID int64) {
err := conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseServiceRestart, serviceRestartReason),
time.Now().Add(closeWriteTimeout),
)
if err != nil {
log.Printf("service=im-service state=draining conn_id=%s user_id=%d close_code=%d close_reason=%s result=close_frame_failed error=%q", connID, userID, websocket.CloseServiceRestart, serviceRestartReason, err.Error())
return
}
log.Printf("service=im-service state=draining conn_id=%s user_id=%d close_code=%d close_reason=%s result=close_frame_sent", connID, userID, websocket.CloseServiceRestart, serviceRestartReason)
}