Init
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
var WsService = newWsService()
|
||||
|
||||
type wsService struct {
|
||||
upgrader websocket.Upgrader
|
||||
seq atomic.Uint64
|
||||
manager *WsConnectionManager
|
||||
}
|
||||
|
||||
func newWsService() *wsService {
|
||||
return &wsService{
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
manager: newWsConnectionManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeUserConnection 开放 IM 用户侧 WebSocket。
|
||||
// principal 非空时表示站内用户;否则应传入 external(IM 访客)。二者勿混用为同一业务身份。
|
||||
func (s *wsService) UpgradeUserConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalInfo) error {
|
||||
return s.upgradeConnection(ctx, principal, external, realtimeRoleUser)
|
||||
}
|
||||
|
||||
func (s *wsService) UpgradeAdminConnection(ctx iris.Context, principal *dto.AuthPrincipal) error {
|
||||
return s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin)
|
||||
}
|
||||
|
||||
func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalInfo, role string) error {
|
||||
conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session := &ClientSession{
|
||||
ID: s.nextID("conn"),
|
||||
Conn: conn,
|
||||
Principal: principal,
|
||||
External: external,
|
||||
Role: role,
|
||||
TerminalType: s.resolveTerminalType(ctx, role),
|
||||
Topics: make(map[string]struct{}),
|
||||
Send: make(chan []byte, realtimeSendBufferSize),
|
||||
}
|
||||
session.touch()
|
||||
|
||||
conn.SetReadLimit(realtimeMaxMessageSize)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
session.touch()
|
||||
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
})
|
||||
|
||||
var logUserID int64
|
||||
var logExternalID string
|
||||
if principal != nil {
|
||||
logUserID = principal.UserID
|
||||
}
|
||||
if external != nil {
|
||||
logExternalID = strings.TrimSpace(external.ExternalID)
|
||||
}
|
||||
|
||||
sessionCount := s.manager.Register(session, s.defaultTopics(session))
|
||||
slog.Info("realtime client connected",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", logUserID,
|
||||
"externalId", logExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"topicCount", len(session.Topics),
|
||||
"sessionCount", sessionCount,
|
||||
)
|
||||
|
||||
go s.writePump(session)
|
||||
go s.readPump(session)
|
||||
|
||||
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
|
||||
Payload: RealtimeConnectedPayload{
|
||||
ConnID: session.ID,
|
||||
UserID: logUserID,
|
||||
VisitorID: logExternalID,
|
||||
Role: role,
|
||||
TerminalType: session.TerminalType,
|
||||
Topics: session.topicList(),
|
||||
},
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wsService) readPump(session *ClientSession) {
|
||||
defer s.closeSession(session)
|
||||
|
||||
for {
|
||||
_, body, err := session.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
session.touch()
|
||||
|
||||
input := realtimeClientMessage{}
|
||||
if err := json.Unmarshal(body, &input); err != nil {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{
|
||||
Reason: enums.IMRealtimeResyncReasonInvalidPayload,
|
||||
},
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(input.Type) {
|
||||
case enums.IMRealtimeClientTypePing:
|
||||
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
|
||||
case enums.IMRealtimeClientTypeSubscribe:
|
||||
topics := s.subscribeTopics(session, input.Topics)
|
||||
if len(topics) > 0 {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeSubscribedEvent{
|
||||
Payload: RealtimeTopicsPayload{Topics: topics},
|
||||
}))
|
||||
}
|
||||
case enums.IMRealtimeClientTypeUnsubscribe:
|
||||
topics := s.unsubscribeTopics(session, input.Topics)
|
||||
if len(topics) > 0 {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeUnsubscribedEvent{
|
||||
Payload: RealtimeTopicsPayload{Topics: topics},
|
||||
}))
|
||||
}
|
||||
case enums.IMRealtimeClientTypeAck:
|
||||
slog.Debug("realtime event ack",
|
||||
"connId", session.ID,
|
||||
"eventId", strings.TrimSpace(input.EventID),
|
||||
)
|
||||
default:
|
||||
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{
|
||||
Reason: enums.IMRealtimeResyncReasonUnsupportedMessageType,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) writePump(session *ClientSession) {
|
||||
ticker := time.NewTicker(realtimePingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
s.closeSession(session)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case payload, ok := <-session.Send:
|
||||
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
||||
if !ok {
|
||||
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
||||
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) closeSession(session *ClientSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.closeOnce.Do(func() {
|
||||
session.Closed.Store(true)
|
||||
remaining := s.manager.Unregister(session)
|
||||
|
||||
close(session.Send)
|
||||
_ = session.Conn.Close()
|
||||
|
||||
var discUserID int64
|
||||
var discExternalID string
|
||||
if session.Principal != nil {
|
||||
discUserID = session.Principal.UserID
|
||||
}
|
||||
if session.External != nil {
|
||||
discExternalID = strings.TrimSpace(session.External.ExternalID)
|
||||
}
|
||||
slog.Info("realtime client disconnected",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", discUserID,
|
||||
"externalId", discExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"sessionCount", remaining,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wsService) subscribeTopics(session *ClientSession, topics []string) []string {
|
||||
allowed := s.filterAllowedTopics(session, topics)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.manager.Subscribe(session, allowed)
|
||||
}
|
||||
|
||||
func (s *wsService) unsubscribeTopics(session *ClientSession, topics []string) []string {
|
||||
allowed := s.filterAllowedTopics(session, topics)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.manager.Unsubscribe(session, allowed, sliceToSet(s.defaultTopics(session)))
|
||||
}
|
||||
|
||||
func (s *wsService) PublishMessageCreated(conversation *models.Conversation, message *models.Message) {
|
||||
if conversation == nil || message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageCreatedEvent{
|
||||
Payload: RealtimeMessageCreatedPayload{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
Status: conversation.Status,
|
||||
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
||||
SenderType: message.SenderType,
|
||||
SenderID: message.SenderID,
|
||||
MessageType: message.MessageType,
|
||||
Content: message.Content,
|
||||
Payload: message.Payload,
|
||||
SeqNo: message.SeqNo,
|
||||
SendStatus: message.SendStatus,
|
||||
SentAt: formatWsTime(message.SentAt),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishMessageRecalled(conversation *models.Conversation, message *models.Message) {
|
||||
if conversation == nil || message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageRecalledEvent{
|
||||
Payload: RealtimeMessageRecalledPayload{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
SenderType: message.SenderType,
|
||||
SenderID: message.SenderID,
|
||||
SendStatus: message.SendStatus,
|
||||
RecalledAt: formatWsTime(message.RecalledAt),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishConversationChanged(conversation *models.Conversation, eventType string) {
|
||||
if conversation == nil {
|
||||
return
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID)
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{
|
||||
Type: eventType,
|
||||
Payload: RealtimeConversationChangedPayload{
|
||||
ConversationID: conversation.ID,
|
||||
Status: conversation.Status,
|
||||
ServiceMode: conversation.ServiceMode,
|
||||
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
||||
LastMessageID: conversation.LastMessageID,
|
||||
LastMessageAt: formatWsTime(&conversation.LastMessageAt),
|
||||
LastActiveAt: formatWsTime(&conversation.LastActiveAt),
|
||||
LastMessageSummary: conversation.LastMessageSummary,
|
||||
CustomerUnreadCount: conversation.CustomerUnreadCount,
|
||||
AgentUnreadCount: conversation.AgentUnreadCount,
|
||||
CustomerLastReadMessageID: readStateMessageID(customerReadState),
|
||||
CustomerLastReadSeqNo: readStateSeqNo(customerReadState),
|
||||
CustomerLastReadAt: readStateAt(customerReadState),
|
||||
AgentLastReadMessageID: readStateMessageID(agentReadState),
|
||||
AgentLastReadSeqNo: readStateSeqNo(agentReadState),
|
||||
AgentLastReadAt: readStateAt(agentReadState),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = enums.IMRealtimeResyncReasonManual
|
||||
}
|
||||
s.PublishToTopics(topics, s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{Reason: reason},
|
||||
}))
|
||||
}
|
||||
|
||||
func readStateMessageID(state *models.ConversationReadState) int64 {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastReadMessageID
|
||||
}
|
||||
|
||||
func readStateSeqNo(state *models.ConversationReadState) int64 {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastReadSeqNo
|
||||
}
|
||||
|
||||
func readStateAt(state *models.ConversationReadState) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatTimePtr(state.LastReadAt)
|
||||
}
|
||||
|
||||
func (s *wsService) Publish(event RealtimeEvent) {
|
||||
if strings.TrimSpace(event.Topic) == "" {
|
||||
return
|
||||
}
|
||||
s.PublishToTopic(event.Topic, event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishToTopic(topic string, event RealtimeEvent) {
|
||||
topic = strings.TrimSpace(topic)
|
||||
if topic == "" {
|
||||
return
|
||||
}
|
||||
if event.Topic == "" {
|
||||
event.Topic = topic
|
||||
}
|
||||
s.PublishToTopics([]string{topic}, event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
|
||||
normalized := normalizeRealtimeTopics(topics)
|
||||
if len(normalized) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
targets := s.manager.FindByTopics(normalized)
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
slog.Error("marshal realtime event failed", "error", err, "type", event.Type)
|
||||
return
|
||||
}
|
||||
|
||||
for _, session := range targets {
|
||||
if session.enqueue(payload) {
|
||||
continue
|
||||
}
|
||||
slog.Warn("drop slow realtime client",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"type", event.Type,
|
||||
"topic", event.Topic,
|
||||
)
|
||||
go s.closeSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
topics := []string{s.conversationTopic(conversation.ID)}
|
||||
if strings.TrimSpace(conversation.ExternalID) != "" {
|
||||
topics = append(topics, s.visitorTopic(conversation.ExternalID))
|
||||
}
|
||||
if conversation.CurrentAssigneeID > 0 {
|
||||
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
|
||||
} else {
|
||||
topics = append(topics, realtimeTopicAdminAll)
|
||||
}
|
||||
return normalizeRealtimeTopics(topics)
|
||||
}
|
||||
|
||||
func (s *wsService) defaultTopics(session *ClientSession) []string {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch session.Role {
|
||||
case realtimeRoleAdmin:
|
||||
if session.Principal == nil || session.Principal.UserID <= 0 {
|
||||
return []string{realtimeTopicAdminAll}
|
||||
}
|
||||
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
|
||||
default:
|
||||
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 visitor:{externalId},否则收不到推送。
|
||||
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
|
||||
return []string{s.visitorTopic(session.External.ExternalID)}
|
||||
}
|
||||
if session.Principal != nil && session.Principal.UserID > 0 {
|
||||
return []string{s.userTopic(session.Principal.UserID)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string) []string {
|
||||
normalized := normalizeRealtimeTopics(topics)
|
||||
if len(normalized) == 0 || session == nil {
|
||||
return nil
|
||||
}
|
||||
switch session.Role {
|
||||
case realtimeRoleAdmin:
|
||||
if session.Principal == nil {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
hasUser := session.Principal != nil && session.Principal.UserID > 0
|
||||
hasExternal := session.External != nil && strings.TrimSpace(session.External.ExternalID) != ""
|
||||
if !hasUser && !hasExternal {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
defaultTopics := sliceToSet(s.defaultTopics(session))
|
||||
ret := make([]string, 0, len(normalized))
|
||||
for _, topic := range normalized {
|
||||
if _, ok := defaultTopics[topic]; ok {
|
||||
ret = append(ret, topic)
|
||||
continue
|
||||
}
|
||||
if conversationID, ok := parseConversationTopic(topic); ok && s.canSubscribeConversation(session, conversationID) {
|
||||
ret = append(ret, topic)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
|
||||
if session == nil || conversationID <= 0 {
|
||||
return false
|
||||
}
|
||||
if session.Role == realtimeRoleAdmin {
|
||||
return true
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return false
|
||||
}
|
||||
if session.External != nil {
|
||||
return ConversationService.IsCustomerConversationOwner(conversation, *session.External)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *wsService) resolveTerminalType(ctx iris.Context, role string) string {
|
||||
if ctx == nil {
|
||||
return "web"
|
||||
}
|
||||
terminalType := strings.TrimSpace(ctx.URLParam("terminalType"))
|
||||
if terminalType != "" {
|
||||
return terminalType
|
||||
}
|
||||
if role == realtimeRoleAdmin {
|
||||
return "console_web"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
|
||||
func (s *wsService) newEvent(topic string, event RealtimeDomainEvent) RealtimeEvent {
|
||||
if event == nil {
|
||||
return RealtimeEvent{
|
||||
EventID: s.nextID("evt"),
|
||||
Topic: topic,
|
||||
At: time.Now().Format(time.DateTime),
|
||||
}
|
||||
}
|
||||
return RealtimeEvent{
|
||||
EventID: s.nextID("evt"),
|
||||
Type: event.EventType(),
|
||||
Topic: topic,
|
||||
Data: event.EventPayload(),
|
||||
At: time.Now().Format(time.DateTime),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) nextID(prefix string) string {
|
||||
seq := s.seq.Add(1)
|
||||
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) visitorTopic(visitorID string) string {
|
||||
return realtimeTopicVisitorPrefix + strings.TrimSpace(visitorID)
|
||||
}
|
||||
|
||||
func (s *wsService) adminTopic(userID int64) string {
|
||||
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func (s *wsService) conversationTopic(conversationID int64) string {
|
||||
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
|
||||
}
|
||||
|
||||
func normalizeRealtimeTopics(topics []string) []string {
|
||||
if len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(topics))
|
||||
seen := make(map[string]struct{}, len(topics))
|
||||
for _, topic := range topics {
|
||||
item := strings.TrimSpace(topic)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseConversationTopic(topic string) (int64, bool) {
|
||||
if !strings.HasPrefix(topic, realtimeTopicConversationPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
value := strings.TrimPrefix(topic, realtimeTopicConversationPrefix)
|
||||
id, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func sliceToSet(items []string) map[string]struct{} {
|
||||
ret := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
ret[item] = struct{}{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func formatWsTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.DateTime)
|
||||
}
|
||||
Reference in New Issue
Block a user