fix(ws): 发送 WebSocket 协议级 Ping 控制帧防止 60s 后连接断开

writePump 只发 ActionCable 文本 JSON ping,从不发 websocket.PingMessage,
导致 readPump 的 PongHandler 永远不触发,ReadDeadline(60s) 到期后连接
被强制关闭。前端每 60 秒弹出"离线""重连"通知。

修复:在 ticker 中先发 PingMessage 控制帧(浏览器自动回 Pong 重置
ReadDeadline),再发 ActionCable 文本 ping(保活 ConnectionMonitor)。

同时包含此前会话中的其他变更:
- 移除安全设置(SAML)页面及路由
- 新增超级管理员控制台前端页面
- platform agent_bots 路由使用 Platform* handler
- 超级管理员入口改为 SPA 内路由跳转
This commit is contained in:
Rogee
2026-07-30 16:55:44 +08:00
parent 1fb95e4151
commit cd82d079e3
26 changed files with 2421 additions and 199 deletions
+14 -2
View File
@@ -194,7 +194,19 @@ func (h *Handler) writePump(client *Client) {
}
case <-ticker.C:
// Send ActionCable-level ping message (JSON text frame).
// Send WebSocket protocol-level ping control frame.
// The browser automatically responds with a Pong, which triggers
// the PongHandler in readPump and resets the ReadDeadline.
// Without this, the ReadDeadline (PongWait=60s) expires and the
// connection is forcibly closed, causing the client to show
// "offline" / "reconnecting" notifications every ~60 seconds.
client.Conn.SetWriteDeadline(time.Now().Add(WriteWait))
if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
logger.L().Errorf("ws: ws-ping control frame failed for user=%d: %v", client.UserID, err)
return
}
// Also send ActionCable-level ping message (JSON text frame).
// The JS ConnectionMonitor expects periodic ping messages to
// keep the connection alive (staleThreshold = 6s by default).
pingMsg, _ := json.Marshal(PingFrame{
@@ -203,7 +215,7 @@ func (h *Handler) writePump(client *Client) {
})
client.Conn.SetWriteDeadline(time.Now().Add(WriteWait))
if err := client.Conn.WriteMessage(websocket.TextMessage, pingMsg); err != nil {
logger.L().Errorf("ws: ping write failed for user=%d: %v", client.UserID, err)
logger.L().Errorf("ws: actioncable ping write failed for user=%d: %v", client.UserID, err)
return
}
}
+6 -5
View File
@@ -2040,11 +2040,12 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
// Agent Bot CRUD (super-admin, global bots without account_id)
// Reference: Chatwoot namespace :agent_bots under :platform_app
g.GET("/agent_bots", h.AgentBot.List)
g.POST("/agent_bots", h.AgentBot.Create)
g.GET("/agent_bots/:id", h.AgentBot.Get)
g.PUT("/agent_bots/:id", h.AgentBot.Update)
g.DELETE("/agent_bots/:id", h.AgentBot.Delete)
// Use Platform* handlers — these don't require account_id from URL params.
g.GET("/agent_bots", h.AgentBot.PlatformList)
g.POST("/agent_bots", h.AgentBot.PlatformCreate)
g.GET("/agent_bots/:id", h.AgentBot.PlatformGet)
g.PUT("/agent_bots/:id", h.AgentBot.PlatformUpdate)
g.DELETE("/agent_bots/:id", h.AgentBot.PlatformDelete)
g.POST("/agent_bots/:id/reset_token", h.AgentBot.ResetToken)
g.POST("/agent_bots/:id/reset_secret", h.AgentBot.ResetSecret)
g.POST("/agent_bots/:id/delete_avatar", h.AgentBot.DeleteAvatar)