feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -28,9 +31,10 @@ import (
var WsService = newWsService()
type wsService struct {
upgrader websocket.Upgrader
seq atomic.Uint64
manager *WsConnectionManager
upgrader websocket.Upgrader
seq atomic.Uint64
manager *WsConnectionManager
authCheckPeriod time.Duration
}
func newWsService() *wsService {
@@ -40,7 +44,8 @@ func newWsService() *wsService {
return true
},
},
manager: newWsConnectionManager(),
manager: newWsConnectionManager(),
authCheckPeriod: realtimeAuthCheckPeriod,
}
}
@@ -50,6 +55,10 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return
}
if !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonErrorCode(errorsx.CodeAuthForbidden, i18nx.T(ctx, "error.e0225")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort()
@@ -77,28 +86,34 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
return
}
var (
principal = AuthService.GetAuthPrincipal(ctx)
external *openidentity.ExternalUser
customerSessionInfo *CustomerSessionVerifyResult
)
if principal == nil {
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
external = result.ExternalUser
customerSessionInfo = result
// The customer endpoint always authenticates the customer credential. A staff
// cookie/bearer must not turn this into a second dashboard subscription path.
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID)
if err := s.upgradeConnection(ctx, nil, result.ExternalUser, realtimeRoleUser, result); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channel_id", channel.ID)
ctx.Abort()
return
}
}
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error {
if principal != nil {
fresh, err := AuthService.RevalidatePrincipal(principal)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return err
}
principal = fresh
if role == realtimeRoleAdmin && !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
err := errorsx.ForbiddenI18n("error.e0225")
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonError(err))
return err
}
}
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return err
@@ -114,12 +129,22 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
Topics: make(map[string]struct{}),
Send: make(chan []byte, realtimeSendBufferSize),
}
if len(customerSessionInfo) > 0 {
session.CustomerSession = customerSessionInfo[0]
}
session.touch()
if principal != nil && (role == realtimeRoleAdmin || role == realtimeRoleNotification) {
session.PresenceLeaseID, _ = randomToken("presence_")
}
conn.SetReadLimit(realtimeMaxMessageSize)
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
conn.SetPongHandler(func(string) error {
if !s.revalidateSession(session) {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
session.touch()
s.renewAgentPresence(session)
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
})
@@ -139,13 +164,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
"userId", logUserID,
"externalId", logExternalID,
"terminalType", session.TerminalType,
"topicCount", len(session.Topics),
"topicCount", len(s.manager.TopicList(session)),
"sessionCount", sessionCount,
)
go s.writePump(session)
go s.readPump(session)
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
Payload: RealtimeConnectedPayload{
ConnID: session.ID,
@@ -153,7 +175,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
GuestID: logExternalID,
Role: role,
TerminalType: session.TerminalType,
Topics: session.topicList(),
Topics: s.manager.TopicList(session),
},
}))
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed {
@@ -164,6 +186,9 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
},
}))
}
s.renewAgentPresence(session)
go s.writePump(session)
go s.readPump(session)
return nil
}
@@ -181,6 +206,9 @@ func (s *wsService) readPump(session *ClientSession) {
}
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
if !s.revalidateSession(session) {
return
}
input := realtimeClientMessage{}
if err := json.Unmarshal(payload, &input); err != nil {
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
@@ -193,6 +221,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
switch strings.TrimSpace(input.Type) {
case enums.IMRealtimeClientTypePing:
s.renewAgentPresence(session)
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
case enums.IMRealtimeClientTypeSubscribe:
topics := s.subscribeTopics(session, input.Topics)
@@ -214,7 +243,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
"eventId", strings.TrimSpace(input.EventID),
)
case enums.IMRealtimeClientTypePresence:
if input.ConversationID > 0 && session.Principal != nil {
if input.ConversationID > 0 && session.Role == realtimeRoleAdmin && session.Principal != nil && s.canSubscribeConversation(session, input.ConversationID) {
action := strings.TrimSpace(input.Action)
if action == "" {
action = "viewing"
@@ -232,8 +261,10 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
func (s *wsService) writePump(session *ClientSession) {
ticker := time.NewTicker(realtimePingPeriod)
authTicker := time.NewTicker(s.authCheckPeriod)
defer func() {
ticker.Stop()
authTicker.Stop()
s.closeSession(session)
}()
@@ -245,9 +276,24 @@ func (s *wsService) writePump(session *ClientSession) {
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// Recheck after queueing as well: logout or permission revocation can
// happen while a slow client still has messages buffered.
if !s.revalidateSession(session) {
return
}
var envelope struct {
Topic string `json:"topic"`
}
if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) {
continue
}
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
case <-authTicker.C:
if !s.revalidateSession(session) {
return
}
case <-ticker.C:
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -262,10 +308,15 @@ func (s *wsService) closeSession(session *ClientSession) {
return
}
session.closeOnce.Do(func() {
s.endAgentPresence(session)
session.sendMu.Lock()
session.Closed.Store(true)
if session.Send != nil {
close(session.Send)
}
session.sendMu.Unlock()
remaining := s.manager.Unregister(session)
close(session.Send)
if session.Conn != nil {
_ = session.Conn.Close()
}
@@ -587,6 +638,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
}
for _, session := range targets {
if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) {
continue
}
if filter != nil && !filter(session) {
continue
}
@@ -603,12 +657,11 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
}
}
func (s *wsService) IsGuestOnline(guestID string) bool {
guestID = strings.TrimSpace(guestID)
if guestID == "" {
func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool {
if customerID <= 0 || channelID <= 0 {
return false
}
return s.manager.HasTopic(s.guestTopic(guestID))
return s.manager.HasTopic(s.customerTopic(customerID, channelID))
}
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
@@ -617,8 +670,8 @@ func (s *wsService) routeConversationTopics(conversation *models.Conversation) [
}
topics := []string{s.conversationTopic(conversation.ID)}
if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" {
topics = append(topics, s.guestTopic(identity.ExternalID))
if conversation.CustomerID > 0 && conversation.ChannelID > 0 {
topics = append(topics, s.customerTopic(conversation.CustomerID, conversation.ChannelID))
}
if conversation.CurrentAssigneeID > 0 {
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
@@ -640,17 +693,13 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
}
return []string{s.notificationTopic(session.Principal.UserID)}
case realtimeRoleAdmin:
if session.Principal == nil || session.Principal.UserID <= 0 {
return []string{realtimeTopicAdminAll}
if session.Principal == nil || session.Principal.UserID <= 0 || !slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) {
return nil
}
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
default:
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
return []string{s.guestTopic(session.External.ExternalID)}
}
if session.Principal != nil && session.Principal.UserID > 0 {
return []string{s.userTopic(session.Principal.UserID)}
if session.CustomerSession != nil && session.External != nil && session.CustomerSession.CustomerID > 0 && session.CustomerSession.ChannelID > 0 {
return []string{s.customerTopic(session.CustomerSession.CustomerID, session.CustomerSession.ChannelID)}
}
return nil
}
@@ -693,7 +742,7 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
}
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
if session == nil || conversationID <= 0 {
if session == nil || conversationID <= 0 || session.Role == realtimeRoleNotification {
return false
}
if session.Role == realtimeRoleAdmin {
@@ -751,12 +800,8 @@ func (s *wsService) nextID(prefix string) string {
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
}
func (s *wsService) userTopic(userID int64) string {
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10)
}
func (s *wsService) guestTopic(guestID string) string {
return realtimeTopicGuestPrefix + strings.TrimSpace(guestID)
func (s *wsService) customerTopic(customerID, channelID int64) string {
return realtimeTopicCustomerPrefix + strconv.FormatInt(channelID, 10) + ":" + strconv.FormatInt(customerID, 10)
}
func (s *wsService) adminTopic(userID int64) string {
@@ -817,3 +862,30 @@ func formatWsTime(t *time.Time) string {
}
return t.Format(time.DateTime)
}
// Topic membership is an optimization, never an authorization grant.
func (s *wsService) canReceiveEvent(session *ClientSession, routingTopics []string, eventTopic string) bool {
if eventTopic != "" {
return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0
}
return len(s.filterAllowedTopics(session, routingTopics)) > 0
}
// Principal snapshots are immutable. Closing when their scope changes avoids
// races between fanout/read/write goroutines and forces a fresh subscription set.
func (s *wsService) revalidateSession(session *ClientSession) bool {
if session == nil || session.Closed.Load() {
return false
}
valid := false
if session.Principal != nil {
fresh, err := AuthService.RevalidatePrincipal(session.Principal)
valid = err == nil && fresh.UserType == session.Principal.UserType && slices.Equal(fresh.Roles, session.Principal.Roles) && slices.Equal(fresh.Permissions, session.Principal.Permissions)
} else if session.Role == realtimeRoleUser {
valid = CustomerSessionService.VerifyConnection(session.CustomerSession)
}
if !valid {
s.closeSession(session)
}
return valid
}