diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 062754ca..46b147d3 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -966,7 +966,7 @@ func Bootstrap(env string) (*App, error) { EmailChannelMigration: v1.NewEmailChannelMigrationHandler(emailChannelMigrationService), // SummaryReport handler (read-only reporting resource — agent/team/inbox/label summaries) SummaryReport: v1.NewSummaryReportHandler(summaryReportService), - ShangwutongConnector: v1.NewShangwutongConnectorHandler(db, messageService), + ShangwutongConnector: v1.NewShangwutongConnectorHandler(db, messageService, workerPool), } // Step 10: Setup Gin router + middleware chain // (ref: Chatwoot Rails middleware stack in config/application.rb) diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler.go b/backend/internal/handler/api/v1/shangwutong_connector_handler.go index c298bb78..2bb1e22e 100644 --- a/backend/internal/handler/api/v1/shangwutong_connector_handler.go +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler.go @@ -15,12 +15,15 @@ import ( "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/internal/worker" + "gorm.io/datatypes" "gorm.io/gorm" ) type ShangwutongConnectorHandler struct { db *gorm.DB messageSvc *service.MessageService + worker *worker.WorkerPool } type shangwutongContactMetadataRequest struct { @@ -75,8 +78,12 @@ func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"updated": true}) } -func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService) *ShangwutongConnectorHandler { - return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc} +func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService, pools ...*worker.WorkerPool) *ShangwutongConnectorHandler { + var pool *worker.WorkerPool + if len(pools) > 0 { + pool = pools[0] + } + return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc, worker: pool} } type shangwutongConnectorInbox struct { @@ -270,6 +277,454 @@ func (h *ShangwutongConnectorHandler) UpdateMessageStatus(c *gin.Context) { }) } +type shangwutongClassificationCacheResponse struct { + InboxID uint `json:"inbox_id"` + ConversationKinds json.RawMessage `json:"conversation_kinds"` + CustomerColorKinds json.RawMessage `json:"customer_color_kinds"` + SyncStatus string `json:"sync_status"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + LastErrorCode *string `json:"last_error_code,omitempty"` + LastErrorMessage *string `json:"last_error_message,omitempty"` +} + +type shangwutongClassificationCallbackRequest struct { + EventID string `json:"event_id"` + ConversationKinds []shangwutongConversationKind `json:"conversation_kinds"` + CustomerColorKinds []shangwutongCustomerColorKind `json:"customer_colors"` +} + +type shangwutongConversationKind struct { + ID string `json:"id"` + Name string `json:"name"` + IconIndex int `json:"icon_index"` +} + +type shangwutongCustomerColorKind struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (h *ShangwutongConnectorHandler) ListClassifications(c *gin.Context) { + inbox, ok := h.userInbox(c) + if !ok { + return + } + var cache model.ShangwutongClassificationCache + err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, shangwutongClassificationCacheResponse{ + InboxID: inbox.ID, ConversationKinds: json.RawMessage(`[]`), CustomerColorKinds: json.RawMessage(`[]`), SyncStatus: "never", + }) + return + } + if err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_lookup_failed", "failed to load classifications", true) + return + } + c.JSON(http.StatusOK, classificationCacheResponse(&cache)) +} + +func (h *ShangwutongConnectorHandler) SyncClassifications(c *gin.Context) { + inbox, ok := h.userInbox(c) + if !ok { + return + } + if h.worker == nil { + h.connectorError(c, http.StatusServiceUnavailable, "classification_sync_unavailable", "classification sync is unavailable", true) + return + } + pending := map[string]any{"sync_status": "pending", "last_error_code": nil, "last_error_message": nil} + var cache model.ShangwutongClassificationCache + err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`)), SyncStatus: "pending"} + if err := h.db.WithContext(c.Request.Context()).Create(&cache).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to create sync state", true) + return + } + case err != nil: + h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to load sync state", true) + return + default: + if err := h.db.WithContext(c.Request.Context()).Model(&cache).Updates(pending).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to update sync state", true) + return + } + } + eventID, err := service.EnqueueShangwutongClassificationSync(c.Request.Context(), h.worker, inbox) + if err != nil { + code, message := "classification_sync_queue_failed", err.Error() + _ = h.db.WithContext(c.Request.Context()).Model(&cache).Updates(map[string]any{"sync_status": "failed", "last_error_code": code, "last_error_message": message}) + h.connectorError(c, http.StatusServiceUnavailable, code, "failed to queue classification sync", true) + return + } + c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "sync_status": "pending"}) +} + +type shangwutongConversationClassificationRequest struct { + ChatKindID string `json:"chat_kind_id"` + CustomerColorID string `json:"customer_color_id"` +} + +func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Context) { + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) + if err != nil || accountID == 0 { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + return + } + conversationID, err := strconv.ParseUint(c.Param("conversation_id"), 10, 64) + if err != nil || conversationID == 0 { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + return + } + var request shangwutongConversationClassificationRequest + if err := c.ShouldBindJSON(&request); err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "classification change payload is invalid", false) + return + } + request.ChatKindID, request.CustomerColorID = strings.TrimSpace(request.ChatKindID), strings.TrimSpace(request.CustomerColorID) + if (request.ChatKindID == "") == (request.CustomerColorID == "") { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "exactly one classification is required", false) + return + } + var conversation model.Conversation + if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", uint(conversationID), uint(accountID)).First(&conversation).Error; err != nil { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + return + } + var inbox model.Inbox + if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ? AND channel_type = ?", conversation.InboxID, uint(accountID), "shangwutong").First(&inbox).Error; err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "not_shangwutong_conversation", "conversation is not a Shangwutong conversation", false) + return + } + var contactInbox model.ContactInbox + query := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND contact_id = ?", inbox.ID, conversation.ContactID) + if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 { + query = h.db.WithContext(c.Request.Context()).Where("id = ? AND inbox_id = ?", *conversation.ContactInboxID, inbox.ID) + } + if err := query.First(&contactInbox).Error; err != nil || strings.TrimSpace(contactInbox.SourceID) == "" { + h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no Shangwutong session", false) + return + } + var cache model.ShangwutongClassificationCache + if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error; err != nil { + h.connectorError(c, http.StatusConflict, "classification_not_synced", "sync Shangwutong classifications first", false) + return + } + if cache.SyncStatus != "succeeded" { + h.connectorError(c, http.StatusConflict, "classification_not_synced", "sync Shangwutong classifications first", false) + return + } + var conversationKinds []shangwutongConversationKind + var customerColors []shangwutongCustomerColorKind + if err := json.Unmarshal(cache.ConversationKinds, &conversationKinds); err != nil || json.Unmarshal(cache.CustomerColorKinds, &customerColors) != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_invalid", "stored classifications are invalid", true) + return + } + colorName := "" + if request.ChatKindID != "" { + if !containsConversationKind(conversationKinds, request.ChatKindID) { + h.connectorError(c, http.StatusUnprocessableEntity, "classification_not_found", "conversation classification is not in the synced catalog", false) + return + } + } else { + for _, color := range customerColors { + if color.ID == request.CustomerColorID { + colorName = color.Name + break + } + } + if colorName == "" { + h.connectorError(c, http.StatusUnprocessableEntity, "classification_not_found", "customer classification is not in the synced catalog", false) + return + } + var metadata struct { + CID string `json:"cid"` + } + if err := json.Unmarshal(contactInbox.ChannelMetadata, &metadata); err != nil || strings.TrimSpace(metadata.CID) == "" { + h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no customer cid", false) + return + } + request.CustomerColorID = strings.TrimSpace(request.CustomerColorID) + if h.worker == nil { + h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true) + return + } + eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, metadata.CID, "", request.CustomerColorID, colorName) + if err != nil { + h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true) + return + } + c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "customer_color_id": request.CustomerColorID}) + return + } + if h.worker == nil { + h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true) + return + } + eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, "", request.ChatKindID, "", "") + if err != nil { + h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true) + return + } + c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "chat_kind_id": request.ChatKindID}) +} + +func containsConversationKind(kinds []shangwutongConversationKind, id string) bool { + for _, kind := range kinds { + if kind.ID == id { + return true + } + } + return false +} + +func (h *ShangwutongConnectorHandler) UpdateClassificationCatalog(c *gin.Context) { + inbox, ok := h.authorizedInbox(c) + if !ok { + return + } + var request shangwutongClassificationCallbackRequest + if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || len(request.EventID) > 128 { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) + return + } + expectedKey := fmt.Sprintf("swt-classification-sync:%d:%s", inbox.ID, request.EventID) + if c.GetHeader("Idempotency-Key") != expectedKey { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) + return + } + if !validClassificationCatalog(request) { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) + return + } + conversationKinds, err := json.Marshal(request.ConversationKinds) + if err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) + return + } + customerColors, err := json.Marshal(request.CustomerColorKinds) + if err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) + return + } + var cache model.ShangwutongClassificationCache + err = h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + cache = model.ShangwutongClassificationCache{InboxID: inbox.ID} + } else if err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true) + return + } else if cache.LastSyncEventID == request.EventID { + c.JSON(http.StatusOK, gin.H{"updated": false, "sync_status": cache.SyncStatus}) + return + } + now := time.Now().UTC() + cache.ConversationKinds = conversationKinds + cache.CustomerColorKinds = customerColors + cache.SyncStatus = "succeeded" + cache.SyncedAt = &now + cache.LastErrorCode = nil + cache.LastErrorMessage = nil + cache.LastSyncEventID = request.EventID + if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classifications", true) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus, "synced_at": cache.SyncedAt}) +} + +type shangwutongClassificationSyncStatusRequest struct { + EventID string `json:"event_id"` + Status string `json:"status"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` +} + +func (h *ShangwutongConnectorHandler) UpdateClassificationSyncStatus(c *gin.Context) { + inbox, ok := h.authorizedInbox(c) + if !ok { + return + } + var request shangwutongClassificationSyncStatusRequest + if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Status != "failed" { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_sync_status", "classification sync status is invalid", false) + return + } + expectedKey := fmt.Sprintf("swt-classification-sync:%d:%s", inbox.ID, request.EventID) + if c.GetHeader("Idempotency-Key") != expectedKey { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) + return + } + var cache model.ShangwutongClassificationCache + if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`))} + } else { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true) + return + } + } + cache.SyncStatus = request.Status + cache.LastErrorCode = stringPointer(request.ErrorCode) + cache.LastErrorMessage = stringPointer(request.ErrorMessage) + if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classification sync status", true) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus}) +} + +func stringPointer(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +type shangwutongClassificationStatusRequest struct { + EventID string `json:"event_id"` + Operation string `json:"operation"` + Status string `json:"status"` + ChatKindID string `json:"chat_kind_id"` + CustomerColorID string `json:"customer_color_id"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` +} + +func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) { + inbox, ok := h.authorizedInbox(c) + if !ok { + return + } + conversationID, err := strconv.ParseUint(c.Params.ByName("conversation_id"), 10, 64) + if err != nil || conversationID == 0 { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + return + } + var request shangwutongClassificationStatusRequest + if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Operation == "" || (request.Status != "succeeded" && request.Status != "failed" && request.Status != "uncertain") { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification status is invalid", false) + return + } + expectedKey := fmt.Sprintf("swt-classification-operation:%d:%s", inbox.ID, request.EventID) + if c.GetHeader("Idempotency-Key") != expectedKey { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) + return + } + request.Operation = strings.TrimSpace(request.Operation) + request.ChatKindID, request.CustomerColorID = strings.TrimSpace(request.ChatKindID), strings.TrimSpace(request.CustomerColorID) + if request.Operation != "set_chat_kind" && request.Operation != "set_customer_color" || (request.ChatKindID == "") == (request.CustomerColorID == "") { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification operation and target are invalid", false) + return + } + var conversation model.Conversation + if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND inbox_id = ?", uint(conversationID), inbox.ID).First(&conversation).Error; err != nil { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + return + } + attributes := map[string]any{} + if len(conversation.AdditionalAttributes) > 0 { + if err := json.Unmarshal(conversation.AdditionalAttributes, &attributes); err != nil { + h.connectorError(c, http.StatusInternalServerError, "conversation_attributes_invalid", "conversation attributes are invalid", true) + return + } + } + if request.Status == "succeeded" { + if request.ChatKindID != "" { + attributes["swt_chat_kind"] = request.ChatKindID + } else { + attributes["swt_label_color"] = request.CustomerColorID + } + } + attributes["swt_classification_status"] = request.Status + attributes["swt_classification_event_id"] = request.EventID + if request.ErrorCode != "" { + attributes["swt_classification_error_code"] = request.ErrorCode + } else { + delete(attributes, "swt_classification_error_code") + } + if request.ErrorMessage != "" { + attributes["swt_classification_error"] = request.ErrorMessage + } else { + delete(attributes, "swt_classification_error") + } + updatedAttributes, err := json.Marshal(attributes) + if err != nil { + h.connectorError(c, http.StatusInternalServerError, "conversation_attributes_invalid", "conversation attributes are invalid", true) + return + } + if err := h.db.WithContext(c.Request.Context()).Model(&conversation).Update("additional_attributes", updatedAttributes).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "conversation_update_failed", "failed to update conversation classification", true) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "status": request.Status}) +} + +func (h *ShangwutongConnectorHandler) userInbox(c *gin.Context) (*model.Inbox, bool) { + accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) + if err != nil || accountID == 0 { + h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) + return nil, false + } + inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 64) + if err != nil || inboxID == 0 { + h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) + return nil, false + } + var inbox model.Inbox + if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ? AND channel_type = ?", uint(inboxID), uint(accountID), "shangwutong").First(&inbox).Error; err != nil { + h.connectorError(c, http.StatusNotFound, "not_found", "inbox not found", false) + return nil, false + } + return &inbox, true +} + +func classificationCacheResponse(cache *model.ShangwutongClassificationCache) shangwutongClassificationCacheResponse { + conversationKinds := json.RawMessage(cache.ConversationKinds) + if len(conversationKinds) == 0 { + conversationKinds = json.RawMessage(`[]`) + } + customerColors := json.RawMessage(cache.CustomerColorKinds) + if len(customerColors) == 0 { + customerColors = json.RawMessage(`[]`) + } + return shangwutongClassificationCacheResponse{ + InboxID: cache.InboxID, ConversationKinds: conversationKinds, CustomerColorKinds: customerColors, + SyncStatus: cache.SyncStatus, SyncedAt: cache.SyncedAt, LastErrorCode: cache.LastErrorCode, LastErrorMessage: cache.LastErrorMessage, + } +} + +func validClassificationCatalog(request shangwutongClassificationCallbackRequest) bool { + if len(request.ConversationKinds) > 256 || len(request.CustomerColorKinds) > 256 { + return false + } + seen := make(map[string]struct{}, len(request.ConversationKinds)+len(request.CustomerColorKinds)) + for _, kind := range request.ConversationKinds { + if strings.TrimSpace(kind.ID) == "" || strings.TrimSpace(kind.Name) == "" || len(kind.ID) > 128 || len(kind.Name) > 255 { + return false + } + key := "conversation:" + kind.ID + if _, ok := seen[key]; ok { + return false + } + seen[key] = struct{}{} + } + for _, color := range request.CustomerColorKinds { + if strings.TrimSpace(color.ID) == "" || strings.TrimSpace(color.Name) == "" || len(color.ID) > 128 || len(color.Name) > 255 { + return false + } + key := "color:" + color.ID + if _, ok := seen[key]; ok { + return false + } + seen[key] = struct{}{} + } + return true +} + func (h *ShangwutongConnectorHandler) inboxItem(c *gin.Context, inbox *model.Inbox) (shangwutongConnectorInbox, error) { var config model.ChannelShangwutongConfig if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&config).Error; err != nil { diff --git a/backend/internal/model/channel_shangwutong_classification_cache.go b/backend/internal/model/channel_shangwutong_classification_cache.go new file mode 100644 index 00000000..6326191d --- /dev/null +++ b/backend/internal/model/channel_shangwutong_classification_cache.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// ShangwutongClassificationCache stores the last successful remote catalog per inbox. +// It is deliberately separate from native GoChat tags and CRM categories. +type ShangwutongClassificationCache struct { + InboxID uint `gorm:"primaryKey" json:"inbox_id"` + ConversationKinds datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'" json:"conversation_kinds"` + CustomerColorKinds datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'" json:"customer_color_kinds"` + SyncStatus string `gorm:"size:20;not null;default:never" json:"sync_status"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + LastErrorCode *string `gorm:"size:100" json:"last_error_code,omitempty"` + LastErrorMessage *string `gorm:"type:text" json:"last_error_message,omitempty"` + LastSyncEventID string `gorm:"size:128" json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ShangwutongClassificationCache) TableName() string { + return "shangwutong_classification_caches" +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 75e8601e..b703bf8b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -275,6 +275,27 @@ func RegisterRoutes( } handlers.ShangwutongConnector.UpdateContactMetadata(c) }) + connector.PUT("/inboxes/:inbox_id/classifications", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateClassificationCatalog(c) + }) + connector.PUT("/inboxes/:inbox_id/classifications/status", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateClassificationSyncStatus(c) + }) + connector.PUT("/inboxes/:inbox_id/conversations/:conversation_id/classifications/status", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateClassificationStatus(c) + }) // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") @@ -791,6 +812,9 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { inboxes.POST("/:inbox_id/set_agent_bot", h.Inbox.SetAgentBot) // GET /api/v1/accounts/:id/inboxes/:inbox_id/health — check channel health inboxes.GET("/:inbox_id/health", h.Inbox.Health) + // Shangwutong remote classification cache and sync + inboxes.GET("/:inbox_id/shangwutong/classifications", h.ShangwutongConnector.ListClassifications) + inboxes.POST("/:inbox_id/shangwutong/classifications/sync", middleware.SuperAdminOrAdministrator(), h.ShangwutongConnector.SyncClassifications) // POST /api/v1/accounts/:id/inboxes/:inbox_id/sync_templates — sync WhatsApp templates inboxes.POST("/:inbox_id/sync_templates", h.Inbox.SyncTemplates) // POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook — register channel webhook @@ -1055,6 +1079,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { conversations.GET("/search", h.Conversation.Search) conversations.POST("/filter", h.Conversation.Filter) conversations.GET("/:conversation_id", h.Conversation.Get) + conversations.PATCH("/:conversation_id/shangwutong-classifications", h.ShangwutongConnector.UpdateConversationClassification) conversations.PATCH("/:conversation_id", h.Conversation.Update) conversations.PUT("/:conversation_id", h.Conversation.Update) conversations.DELETE("/:conversation_id", h.Conversation.Delete) diff --git a/backend/internal/service/shangwutong_webhook_delivery.go b/backend/internal/service/shangwutong_webhook_delivery.go index 0b715e09..2bf4296e 100644 --- a/backend/internal/service/shangwutong_webhook_delivery.go +++ b/backend/internal/service/shangwutong_webhook_delivery.go @@ -43,6 +43,10 @@ type shangwutongWebhookDeliveryJob struct { RetryVersion int64 `json:"retry_version,omitempty"` ConversationID uint `json:"conversation_id,omitempty"` ConversationVersion int64 `json:"conversation_version,omitempty"` + SWTSessionID string `json:"sid,omitempty"` + ChatKindID string `json:"chat_kind_id,omitempty"` + CustomerColorID string `json:"customer_color_id,omitempty"` + CustomerColorName string `json:"customer_color_name,omitempty"` PreviousStatus string `json:"previous_status,omitempty"` ActorID uint `json:"actor_id,omitempty"` Private bool `json:"private,omitempty"` @@ -210,6 +214,13 @@ func (r *shangwutongWebhookDeliveryRunner) payload(ctx context.Context, job shan data = map[string]any{"contact": map[string]any{ "id": job.ContactID, "source_id": job.SourceID, "name": job.ContactName, }, "cid": job.CID, "cnote": ""} + case "classification_sync_requested": + data = map[string]any{} + case "conversation_classification_changed": + data = map[string]any{ + "conversation_id": job.ConversationID, "sid": job.SWTSessionID, "cid": job.CID, + "chat_kind_id": job.ChatKindID, "customer_color_id": job.CustomerColorID, "customer_color_name": job.CustomerColorName, + } default: return nil, fmt.Errorf("unsupported shangwutong webhook event %q", job.Event) } @@ -361,6 +372,39 @@ func newShangwutongLifecycleJob(event string, inbox *model.Inbox, configVersion } } +// EnqueueShangwutongClassificationSync asks the Connector to fetch the remote +// catalog. The Connector reports the result back through its service API. +func EnqueueShangwutongClassificationSync(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox) (string, error) { + if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 { + return "", errors.New("worker and inbox are required") + } + job := shangwutongWebhookDeliveryJob{ + Event: "classification_sync_requested", EventID: uuid.NewString(), OccurredAt: time.Now().UTC(), + AccountID: inbox.AccountID, InboxID: inbox.ID, + } + _, err := pool.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)) + return job.EventID, err +} + +func EnqueueShangwutongClassificationChange(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox, conversationID uint, sid, cid, chatKindID, customerColorID, customerColorName string) (string, error) { + if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 || conversationID == 0 || strings.TrimSpace(sid) == "" { + return "", errors.New("worker, inbox, conversation and sid are required") + } + chatKindID, customerColorID, cid = strings.TrimSpace(chatKindID), strings.TrimSpace(customerColorID), strings.TrimSpace(cid) + if (chatKindID == "") == (customerColorID == "") || (customerColorID != "" && cid == "") { + return "", errors.New("exactly one classification is required") + } + job := shangwutongWebhookDeliveryJob{ + Event: "conversation_classification_changed", EventID: uuid.NewString(), OccurredAt: time.Now().UTC(), + AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversationID, SWTSessionID: strings.TrimSpace(sid), CID: cid, + ChatKindID: chatKindID, CustomerColorID: customerColorID, CustomerColorName: strings.TrimSpace(customerColorName), + } + _, err := pool.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)) + return job.EventID, err +} + func newShangwutongMessageJob(event string, message *model.Message, retryVersion int64) shangwutongWebhookDeliveryJob { eventID := fmt.Sprintf("message:%d:created", message.ID) if event == "message_retry_requested" { diff --git a/backend/migrations/000087_add_shangwutong_classification_caches.down.sql b/backend/migrations/000087_add_shangwutong_classification_caches.down.sql new file mode 100644 index 00000000..4cf8089a --- /dev/null +++ b/backend/migrations/000087_add_shangwutong_classification_caches.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_shangwutong_classification_caches_sync_status; +DROP TABLE IF EXISTS shangwutong_classification_caches; diff --git a/backend/migrations/000087_add_shangwutong_classification_caches.up.sql b/backend/migrations/000087_add_shangwutong_classification_caches.up.sql new file mode 100644 index 00000000..97179178 --- /dev/null +++ b/backend/migrations/000087_add_shangwutong_classification_caches.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS shangwutong_classification_caches ( + inbox_id BIGINT PRIMARY KEY REFERENCES inboxes (id) ON DELETE CASCADE, + conversation_kinds JSONB NOT NULL DEFAULT '[]'::JSONB, + customer_color_kinds JSONB NOT NULL DEFAULT '[]'::JSONB, + sync_status VARCHAR(20) NOT NULL DEFAULT 'never', + synced_at TIMESTAMPTZ, + last_error_code VARCHAR(100), + last_error_message TEXT, + last_sync_event_id VARCHAR(128), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_shangwutong_classification_caches_sync_status +ON shangwutong_classification_caches (sync_status); diff --git a/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.down.sql b/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.down.sql new file mode 100644 index 00000000..4cf8089a --- /dev/null +++ b/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_shangwutong_classification_caches_sync_status; +DROP TABLE IF EXISTS shangwutong_classification_caches; diff --git a/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.up.sql b/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.up.sql new file mode 100644 index 00000000..2a04ad5c --- /dev/null +++ b/backend/migrations/sqlite/000087_add_shangwutong_classification_caches.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS shangwutong_classification_caches ( + inbox_id INTEGER PRIMARY KEY REFERENCES inboxes (id) ON DELETE CASCADE, + conversation_kinds TEXT NOT NULL DEFAULT '[]', + customer_color_kinds TEXT NOT NULL DEFAULT '[]', + sync_status VARCHAR(20) NOT NULL DEFAULT 'never', + synced_at DATETIME, + last_error_code VARCHAR(100), + last_error_message TEXT, + last_sync_event_id VARCHAR(128), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_shangwutong_classification_caches_sync_status +ON shangwutong_classification_caches (sync_status); diff --git a/channels/shangwutong/internal/account/manager.go b/channels/shangwutong/internal/account/manager.go index e4be2a0d..339d5724 100644 --- a/channels/shangwutong/internal/account/manager.go +++ b/channels/shangwutong/internal/account/manager.go @@ -21,10 +21,22 @@ type ProtocolClient interface { SetPresence(context.Context, swt.Session, swt.Presence) error } +type ClassificationProtocol interface { + FetchClassificationCatalog(context.Context, swt.Session) (swt.ClassificationCatalog, error) +} + type StatusReporter interface { UpdateInboxStatus(context.Context, int64, gochat.InboxStatus) error } +type ClassificationReporter interface { + UpdateClassificationCatalog(context.Context, int64, string, gochat.ClassificationCatalog) error +} + +type ClassificationSyncStatusReporter interface { + UpdateClassificationSyncStatus(context.Context, int64, string, string, string, string) error +} + type Manager struct { store *store.Store protocol ProtocolClient @@ -146,6 +158,53 @@ func (m *Manager) WithSession(ctx context.Context, accountID int64, operation fu return supervisor.withSession(ctx, operation) } +func (m *Manager) SyncClassifications(ctx context.Context, accountID int64, eventID string) error { + protocol, ok := m.protocol.(ClassificationProtocol) + if !ok { + return errors.New("classification protocol is unavailable") + } + reporter, ok := m.reporter.(ClassificationReporter) + if !ok { + return errors.New("classification reporter is unavailable") + } + var catalog swt.ClassificationCatalog + if err := m.WithSession(ctx, accountID, func(session swt.Session) error { + var err error + catalog, err = protocol.FetchClassificationCatalog(ctx, session) + return err + }); err != nil { + if statusReporter, ok := m.reporter.(ClassificationSyncStatusReporter); ok { + code := "classification_sync_failed" + var protocolErr *swt.Error + if errors.As(err, &protocolErr) && protocolErr.Code != "" { + code = protocolErr.Code + } + _ = statusReporter.UpdateClassificationSyncStatus(ctx, accountID, eventID, "failed", code, err.Error()) + } + return err + } + return reporter.UpdateClassificationCatalog(ctx, accountID, eventID, gochat.ClassificationCatalog{ + ConversationKinds: convertConversationKinds(catalog.ConversationKinds), + CustomerColors: convertCustomerColors(catalog.CustomerColors), + }) +} + +func convertConversationKinds(kinds []swt.ConversationKind) []gochat.ClassificationKind { + result := make([]gochat.ClassificationKind, len(kinds)) + for i, kind := range kinds { + result[i] = gochat.ClassificationKind{ID: kind.ID, Name: kind.Name, IconIndex: kind.IconIndex} + } + return result +} + +func convertCustomerColors(colors []swt.CustomerColorKind) []gochat.CustomerColorKind { + result := make([]gochat.CustomerColorKind, len(colors)) + for i, color := range colors { + result[i] = gochat.CustomerColorKind{ID: color.ID, Name: color.Name} + } + return result +} + func (m *Manager) InvalidateSession(ctx context.Context, accountID int64) error { m.mu.Lock() supervisor := m.supervisors[accountID] diff --git a/channels/shangwutong/internal/delivery/outbound.go b/channels/shangwutong/internal/delivery/outbound.go index 676d8211..62cc7f81 100644 --- a/channels/shangwutong/internal/delivery/outbound.go +++ b/channels/shangwutong/internal/delivery/outbound.go @@ -43,6 +43,15 @@ type MessageSender interface { TransferConversation(context.Context, swt.Session, string, string) error } +type ClassificationSender interface { + SetConversationKind(context.Context, swt.Session, string, string) error + ChangeCustomerColor(context.Context, swt.Session, string, string, string, string) error +} + +type ClassificationResultClient interface { + UpdateClassificationStatus(context.Context, int64, uint, string, string, string, string, string, string, string) error +} + type ResultClient interface { UpdateMessageStatus(context.Context, int64, int64, gochat.MessageResult) error } @@ -589,8 +598,33 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { if err != nil { return false, err } + classificationSender, classificationSupported := o.sender.(ClassificationSender) err = o.sessions.WithSession(ctx, operation.AccountID, func(session swt.Session) error { switch operation.Operation { + case "set_chat_kind": + if !classificationSupported { + return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} + } + var payload struct { + ChatKindID string `json:"chat_kind_id"` + } + if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.ChatKindID) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("chat_kind_id is required")} + } + return classificationSender.SetConversationKind(ctx, session, operation.SwtSid, payload.ChatKindID) + case "set_customer_color": + if !classificationSupported { + return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} + } + var payload struct { + CustomerColorID string `json:"customer_color_id"` + CustomerColorName string `json:"customer_color_name"` + CID string `json:"cid"` + } + if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.CustomerColorID) == "" || strings.TrimSpace(payload.CID) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("customer_color_id and cid are required")} + } + return classificationSender.ChangeCustomerColor(ctx, session, operation.SwtSid, payload.CustomerColorID, payload.CustomerColorName, payload.CID) case "end_conversation": return o.sender.EndConversation(ctx, session, operation.SwtSid) case "accept_transfer": @@ -624,12 +658,16 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { } }) if err == nil { + if reportErr := o.reportClassificationStatus(ctx, operation, "succeeded", nil); reportErr != nil { + return true, o.retryOperation(operation, "classification result callback failed: "+reportErr.Error()) + } return true, o.store.Writer().CompleteOutboundOperation(ctx, operation.ID) } var protocolErr *swt.Error if errors.As(err, &protocolErr) { if protocolErr.Uncertain { detail := protocolErr.Error() + _ = o.reportClassificationStatus(ctx, operation, "uncertain", protocolErr) return true, o.store.Writer().MarkOutboundOperationUncertain(ctx, dbgen.MarkOutboundOperationUncertainParams{LastError: &detail, ID: operation.ID}) } if protocolErr.Code == "tickint_reset" || protocolErr.Code == "cache_null" { @@ -647,6 +685,32 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { return true, o.failOperation(operation, err.Error()) } +func (o *Outbound) reportClassificationStatus(ctx context.Context, operation *dbgen.OutboundOperation, status string, cause error) error { + reporter, ok := o.results.(ClassificationResultClient) + if !ok || (operation.Operation != "set_chat_kind" && operation.Operation != "set_customer_color") { + return nil + } + var envelope struct { + Data struct { + ConversationID uint `json:"conversation_id"` + ChatKindID string `json:"chat_kind_id"` + CustomerColorID string `json:"customer_color_id"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || envelope.Data.ConversationID == 0 { + return errors.New("classification result payload is invalid") + } + errorCode, errorMessage := "", "" + if cause != nil { + errorMessage = cause.Error() + var protocolErr *swt.Error + if errors.As(cause, &protocolErr) { + errorCode = protocolErr.Code + } + } + return reporter.UpdateClassificationStatus(ctx, operation.AccountID, envelope.Data.ConversationID, operation.EventID, operation.Operation, status, envelope.Data.ChatKindID, envelope.Data.CustomerColorID, errorCode, errorMessage) +} + func (o *Outbound) retry(message *dbgen.OutboundMessage, detail string) error { next := time.Now().Add(backoff(message.Attempts, 5*time.Minute)) err := o.store.Writer().RetryOutboundDelivery(context.Background(), dbgen.RetryOutboundDeliveryParams{ diff --git a/channels/shangwutong/internal/gochat/classifications.go b/channels/shangwutong/internal/gochat/classifications.go new file mode 100644 index 00000000..10698570 --- /dev/null +++ b/channels/shangwutong/internal/gochat/classifications.go @@ -0,0 +1,64 @@ +package gochat + +import ( + "context" + "fmt" + "net/http" + "strconv" +) + +type ClassificationCatalog struct { + ConversationKinds []ClassificationKind `json:"conversation_kinds"` + CustomerColors []CustomerColorKind `json:"customer_colors"` +} + +type ClassificationKind struct { + ID string `json:"id"` + Name string `json:"name"` + IconIndex int `json:"icon_index"` +} + +type CustomerColorKind struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (c *Client) UpdateClassificationCatalog(ctx context.Context, inboxID int64, eventID string, catalog ClassificationCatalog) error { + if inboxID <= 0 || eventID == "" { + return fmt.Errorf("inbox id and event id are required") + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/classifications" + return c.doJSON(ctx, http.MethodPut, path, struct { + EventID string `json:"event_id"` + ClassificationCatalog + }{eventID, catalog}, nil, fmt.Sprintf("swt-classification-sync:%d:%s", inboxID, eventID)) +} + +func (c *Client) UpdateClassificationSyncStatus(ctx context.Context, inboxID int64, eventID, status, errorCode, errorMessage string) error { + if inboxID <= 0 || eventID == "" || status == "" { + return fmt.Errorf("inbox id, event id and status are required") + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/classifications/status" + return c.doJSON(ctx, http.MethodPut, path, struct { + EventID string `json:"event_id"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + }{eventID, status, errorCode, errorMessage}, nil, fmt.Sprintf("swt-classification-sync:%d:%s", inboxID, eventID)) +} + +func (c *Client) UpdateClassificationStatus(ctx context.Context, inboxID int64, conversationID uint, eventID, operation, status, chatKindID, customerColorID, errorCode, errorMessage string) error { + if inboxID <= 0 || conversationID == 0 || eventID == "" || operation == "" || status == "" { + return fmt.Errorf("inbox id, conversation id, event id, operation and status are required") + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/conversations/" + strconv.FormatUint(uint64(conversationID), 10) + "/classifications/status" + return c.doJSON(ctx, http.MethodPut, path, struct { + EventID string `json:"event_id"` + Operation string `json:"operation"` + Status string `json:"status"` + ChatKindID string `json:"chat_kind_id,omitempty"` + CustomerColorID string `json:"customer_color_id,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + }{eventID, operation, status, chatKindID, customerColorID, errorCode, errorMessage}, nil, fmt.Sprintf("swt-classification-operation:%d:%s", inboxID, eventID)) +} diff --git a/channels/shangwutong/internal/httpapi/server.go b/channels/shangwutong/internal/httpapi/server.go index 1911f6d2..66647596 100644 --- a/channels/shangwutong/internal/httpapi/server.go +++ b/channels/shangwutong/internal/httpapi/server.go @@ -36,6 +36,10 @@ type SupervisorManager interface { SetTyping(int64, string, bool) error } +type ClassificationManager interface { + SyncClassifications(context.Context, int64, string) error +} + type Server struct { app *fiber.App store *store.Store @@ -384,8 +388,19 @@ func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnv return s.acceptConversationStatus(c, envelope, body, deliveryID, local) case "contact_updated": return s.acceptContactUpdate(c, envelope, body, deliveryID, local) + case "conversation_classification_changed": + return s.acceptClassificationChange(c, envelope, body, deliveryID, local) case "conversation_typing_on", "conversation_typing_off": return s.acceptTyping(c, envelope, deliveryID, local) + case "classification_sync_requested": + manager, ok := s.manager.(ClassificationManager) + if !ok { + return s.writeError(c, http.StatusServiceUnavailable, "classification_sync_unavailable", "classification sync is unavailable", true) + } + if err := manager.SyncClassifications(c.Context(), local.ID, envelope.EventID); err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "classification_sync_failed", "classification sync failed", true) + } + return s.writeAck(c, http.StatusAccepted, envelope.EventID, deliveryID, nil, false, 0) default: return s.writeError(c, http.StatusUnprocessableEntity, "unsupported_event", "webhook event is not supported", false) } @@ -412,6 +427,42 @@ func (s *Server) acceptContactUpdate(c fiber.Ctx, envelope gochat.WebhookEnvelop return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0) } +func (s *Server) acceptClassificationChange(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { + var data struct { + SID string `json:"sid"` + CID string `json:"cid"` + ChatKindID string `json:"chat_kind_id"` + CustomerColorID string `json:"customer_color_id"` + CustomerColorName string `json:"customer_color_name"` + } + if err := json.Unmarshal(envelope.Data, &data); err != nil || strings.TrimSpace(data.SID) == "" { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "classification change payload is invalid", false) + } + data.SID, data.CID = strings.TrimSpace(data.SID), strings.TrimSpace(data.CID) + data.ChatKindID, data.CustomerColorID = strings.TrimSpace(data.ChatKindID), strings.TrimSpace(data.CustomerColorID) + if (data.ChatKindID == "") == (data.CustomerColorID == "") || (data.CustomerColorID != "" && data.CID == "") { + return s.writeError(c, http.StatusUnprocessableEntity, "invalid_classification_change", "exactly one classification and its target are required", false) + } + operation := "set_chat_kind" + if data.CustomerColorID != "" { + operation = "set_customer_color" + } + queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{ + AccountID: local.ID, SWTSessionID: data.SID, EventID: envelope.EventID, Operation: operation, Payload: string(body), OccurredAt: envelope.OccurredAt, + }) + if errors.Is(err, store.ErrOutboundConflict) { + return s.writeError(c, http.StatusConflict, "idempotency_conflict", "classification operation conflicts with webhook", false) + } + if err != nil { + return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "classification operation queue persistence failed", true) + } + status := http.StatusAccepted + if duplicate { + status = http.StatusOK + } + return s.writeAck(c, status, envelope.EventID, deliveryID, &queued.ID, duplicate, 0) +} + func (s *Server) acceptConversationStatus(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { var data gochat.ConversationStatusWebhookData if err := json.Unmarshal(envelope.Data, &data); err != nil { diff --git a/channels/shangwutong/internal/swt/classifications.go b/channels/shangwutong/internal/swt/classifications.go new file mode 100644 index 00000000..32efbf89 --- /dev/null +++ b/channels/shangwutong/internal/swt/classifications.go @@ -0,0 +1,157 @@ +package swt + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +type ConversationKind struct { + ID string `json:"id"` + Name string `json:"name"` + IconIndex int `json:"icon_index"` +} + +type CustomerColorKind struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ClassificationCatalog struct { + ConversationKinds []ConversationKind `json:"conversation_kinds"` + CustomerColors []CustomerColorKind `json:"customer_colors"` +} + +func (c *Client) FetchClassificationCatalog(ctx context.Context, session Session) (ClassificationCatalog, error) { + if err := session.Validate(); err != nil { + return ClassificationCatalog{}, err + } + target, err := resolveEndpoint(session.BaseURL, "oc/SiteSetting.aspx") + if err != nil { + return ClassificationCatalog{}, err + } + target += "?" + url.Values{ + "sn": {session.MAToken}, + "siteid": {session.SiteID}, + "act": {"load"}, + }.Encode() + form := sessionAuthForm(session) + form.Set("sidkind_share", "") + form.Set("colorkind0_share", "") + form.Set("colorkind1_share", "") + body, response, requestWritten, err := c.postFormTargetTracked(ctx, target, form) + if err != nil { + return ClassificationCatalog{}, classificationRequestError("load_classifications", response, requestWritten, err) + } + status := strings.TrimSpace(response.Header.Get("r")) + if !strings.EqualFold(status, "load ok") && !strings.EqualFold(status, "ok") { + return ClassificationCatalog{}, &Error{Operation: "load_classifications", Code: normalizeCode(status), Retryable: strings.EqualFold(normalizeCode(status), "server_err"), Err: fmt.Errorf("unexpected protocol status %q", status)} + } + catalog, err := parseClassificationCatalog(response.Header) + if err != nil { + return ClassificationCatalog{}, &Error{Operation: "load_classifications", Code: "invalid_response", Err: err} + } + _ = body // SiteSetting returns the settings in response headers. + return catalog, nil +} + +func (c *Client) SetConversationKind(ctx context.Context, session Session, sid, kindID string) error { + if strings.TrimSpace(sid) == "" || strings.TrimSpace(kindID) == "" { + return errors.New("sid and kind are required") + } + return c.sessionOperation(ctx, session, "oc/SetSidKind.aspx", map[string]string{ + "sid": sid, "kind": kindID, + }, "set_conversation_kind") +} + +func (c *Client) ChangeCustomerColor(ctx context.Context, session Session, sid, colorID, colorName, cid string) error { + if strings.TrimSpace(sid) == "" || strings.TrimSpace(colorID) == "" || strings.TrimSpace(cid) == "" { + return errors.New("sid, color id and cid are required") + } + return c.sessionOperation(ctx, session, "oc/changecolor.aspx", map[string]string{ + "sid": sid, "c0": colorID, "c1": colorName, "cid": cid, + }, "change_customer_color") +} + +func parseClassificationCatalog(headers http.Header) (ClassificationCatalog, error) { + conversationKinds, err := parseConversationKinds(headers.Get("sidkind_share")) + if err != nil { + return ClassificationCatalog{}, err + } + customerColors, err := parseCustomerColors(headers.Get("colorkind0_share"), headers.Get("colorkind1_share")) + if err != nil { + return ClassificationCatalog{}, err + } + return ClassificationCatalog{ConversationKinds: conversationKinds, CustomerColors: customerColors}, nil +} + +func parseConversationKinds(raw string) ([]ConversationKind, error) { + if strings.TrimSpace(raw) == "" { + return []ConversationKind{}, nil + } + items := strings.Split(raw, "|") + result := make([]ConversationKind, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + parts := strings.SplitN(item, ",", 3) + if len(parts) < 2 { + return nil, fmt.Errorf("conversation kind %q has fewer than two fields", item) + } + id, name := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if id == "" || name == "" { + return nil, errors.New("conversation kind id and name are required") + } + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("duplicate conversation kind %q", id) + } + seen[id] = struct{}{} + iconIndex := -1 + if len(parts) == 3 && strings.TrimSpace(parts[2]) != "" { + parsed, err := strconv.Atoi(strings.TrimSpace(parts[2])) + if err != nil { + return nil, fmt.Errorf("conversation kind %q has invalid icon index", id) + } + iconIndex = parsed + } + result = append(result, ConversationKind{ID: id, Name: name, IconIndex: iconIndex}) + } + return result, nil +} + +func parseCustomerColors(rawIDs, rawNames string) ([]CustomerColorKind, error) { + if strings.TrimSpace(rawIDs) == "" && strings.TrimSpace(rawNames) == "" { + return []CustomerColorKind{}, nil + } + ids, names := strings.Split(rawIDs, "|"), strings.Split(rawNames, "|") + if len(ids) != len(names) { + return nil, fmt.Errorf("customer color ids and names have different lengths") + } + result := make([]CustomerColorKind, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for index := range ids { + id, name := strings.TrimSpace(ids[index]), strings.TrimSpace(names[index]) + if id == "" || name == "" { + return nil, fmt.Errorf("customer color at index %d has an empty id or name", index) + } + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("duplicate customer color %q", id) + } + seen[id] = struct{}{} + result = append(result, CustomerColorKind{ID: id, Name: name}) + } + return result, nil +} + +func classificationRequestError(operation string, response *http.Response, requestWritten bool, err error) error { + if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) { + return &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: err} + } + if requestWritten { + return &Error{Operation: operation, Code: "network_result_uncertain", Uncertain: true, Err: err} + } + return &Error{Operation: operation, Code: "network_error", Retryable: true, Err: err} +} diff --git a/channels/shangwutong/internal/swt/classifications_test.go b/channels/shangwutong/internal/swt/classifications_test.go new file mode 100644 index 00000000..0319f6eb --- /dev/null +++ b/channels/shangwutong/internal/swt/classifications_test.go @@ -0,0 +1,81 @@ +package swt + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchClassificationCatalogUsesSiteSettingHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/oc/SiteSetting.aspx" || request.URL.Query().Get("sn") != "token" || request.URL.Query().Get("siteid") != "99917999" || request.URL.Query().Get("act") != "load" { + t.Fatalf("request = %s", request.URL.String()) + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + for _, key := range []string{"sn", "sidkind_share", "colorkind0_share", "colorkind1_share"} { + if !request.Form.Has(key) { + t.Fatalf("missing form key %q: %#v", key, request.Form) + } + } + response.Header().Set("r", "load ok") + response.Header().Set("sidkind_share", "1,在线咨询,0|2,售后服务,1") + response.Header().Set("colorkind0_share", "0|2") + response.Header().Set("colorkind1_share", "普通客户|VIP") + })) + defer server.Close() + + catalog, err := NewClient(rewriteTransportClient(server.URL)).FetchClassificationCatalog(context.Background(), testSession()) + if err != nil { + t.Fatal(err) + } + if len(catalog.ConversationKinds) != 2 || catalog.ConversationKinds[1].ID != "2" || catalog.ConversationKinds[1].IconIndex != 1 { + t.Fatalf("conversation kinds = %#v", catalog.ConversationKinds) + } + if len(catalog.CustomerColors) != 2 || catalog.CustomerColors[1].Name != "VIP" { + t.Fatalf("customer colors = %#v", catalog.CustomerColors) + } +} + +func TestClassificationOperationsUseNativeEndpoints(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + paths = append(paths, request.URL.Path) + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + switch request.URL.Path { + case "/oc/SetSidKind.aspx": + if request.Form.Get("sid") != "sid-1" || request.Form.Get("kind") != "2" { + t.Fatalf("chat kind form = %#v", request.Form) + } + case "/oc/changecolor.aspx": + if request.Form.Get("sid") != "sid-1" || request.Form.Get("c0") != "2" || request.Form.Get("c1") != "VIP" || request.Form.Get("cid") != "cid-1" { + t.Fatalf("customer color form = %#v", request.Form) + } + default: + t.Fatalf("unexpected path %q", request.URL.Path) + } + response.Header().Set("r", "ok") + })) + defer server.Close() + + client := NewClient(rewriteTransportClient(server.URL)) + if err := client.SetConversationKind(context.Background(), testSession(), "sid-1", "2"); err != nil { + t.Fatal(err) + } + if err := client.ChangeCustomerColor(context.Background(), testSession(), "sid-1", "2", "VIP", "cid-1"); err != nil { + t.Fatal(err) + } + if len(paths) != 2 || paths[0] != "/oc/SetSidKind.aspx" || paths[1] != "/oc/changecolor.aspx" { + t.Fatalf("paths = %#v", paths) + } +} + +func TestParseCustomerColorsRejectsMismatchedParallelArrays(t *testing.T) { + if _, err := parseCustomerColors("0|1", "普通"); err == nil { + t.Fatal("expected mismatched arrays to fail") + } +} diff --git a/deploy/docker/preflight.sh b/deploy/docker/preflight.sh index 98166ac5..13f2d2c7 100755 --- a/deploy/docker/preflight.sh +++ b/deploy/docker/preflight.sh @@ -65,16 +65,16 @@ if ! offsite_info=$(findmnt -M "$offsite_dir" -n -o SOURCE,FSTYPE,MAJ:MIN); then echo "GOCHAT_BACKUP_OFFSITE_DIR must be an existing external mount point" >&2 exit 1 fi -read -r offsite_source offsite_type offsite_device <<< "$offsite_info" +read -r offsite_source offsite_type offsite_device <<<"$offsite_info" if [[ $offsite_source != "$GOCHAT_BACKUP_OFFSITE_SOURCE" || $offsite_type != "$GOCHAT_BACKUP_OFFSITE_FSTYPE" ]]; then echo "GOCHAT_BACKUP_OFFSITE_DIR mount source/type does not match the approved values" >&2 exit 1 fi case $offsite_type in - tmpfs | devtmpfs | ramfs) - echo "GOCHAT_BACKUP_OFFSITE_DIR must not use an in-memory filesystem" >&2 - exit 1 - ;; +tmpfs | devtmpfs | ramfs) + echo "GOCHAT_BACKUP_OFFSITE_DIR must not use an in-memory filesystem" >&2 + exit 1 + ;; esac local_probe=${GOCHAT_BACKUP_DIR:-./backups/local} @@ -152,5 +152,5 @@ while IFS= read -r image; do fi echo "production image must be pinned to a sha256 digest: $image" >&2 exit 1 -done <<< "$images" +done <<<"$images" echo "production preflight passed" diff --git a/deploy/docker/preflight_test.sh b/deploy/docker/preflight_test.sh index 58464482..d65db974 100755 --- a/deploy/docker/preflight_test.sh +++ b/deploy/docker/preflight_test.sh @@ -12,7 +12,7 @@ cp "$tmp/external-db-client.crt" "$tmp/external-db-ca.crt" chmod 0644 "$tmp/external-db-ca.crt" "$tmp/external-db-client.crt" chmod 0640 "$tmp/external-db-client.key" -cat > "$tmp/bin/findmnt" <<'EOF' +cat >"$tmp/bin/findmnt" <<'EOF' #!/usr/bin/env bash if [[ $1 == -M && $2 == "$GOCHAT_BACKUP_OFFSITE_DIR" ]]; then printf '%s %s %s\n' "$TEST_OFFSITE_SOURCE" "$TEST_OFFSITE_FSTYPE" "$TEST_OFFSITE_DEVICE" @@ -22,7 +22,7 @@ else exit 1 fi EOF -cat > "$tmp/bin/docker" <<'EOF' +cat >"$tmp/bin/docker" <<'EOF' #!/usr/bin/env bash if [[ -n ${TEST_MUTABLE_IMAGE:-} ]]; then echo 'pgvector/pgvector:pg16' @@ -55,7 +55,7 @@ export TEST_LOCAL_SOURCE=/dev/sda1 export TEST_LOCAL_DEVICE=8:1 run_preflight() { - "$root/deploy/docker/preflight.sh" > "$tmp/output" 2>&1 + "$root/deploy/docker/preflight.sh" >"$tmp/output" 2>&1 } expect_failure() { @@ -101,7 +101,7 @@ printf '%s\n' 'https://alerts.example.test/gochat' >"$ALERTMANAGER_WEBHOOK_URL_F TEST_MUTABLE_IMAGE=1 export TEST_MUTABLE_IMAGE expect_failure 'a mutable production image' 'must be pinned to a sha256 digest' -"$root/deploy/docker/preflight.sh" --allow-local-images > "$tmp/output" 2>&1 +"$root/deploy/docker/preflight.sh" --allow-local-images >"$tmp/output" 2>&1 grep -F 'production preflight passed' "$tmp/output" >/dev/null unset TEST_MUTABLE_IMAGE @@ -116,7 +116,7 @@ GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test: expect_failure 'duplicate allowed sslmode parameters' 'must contain exactly one sslmode' while IFS='|' read -r name GOCHAT_DATABASE_DSN; do expect_failure "$name" 'must use an external PostgreSQL host' -done < "$root/deploy/docker/database_host_rejection_cases.txt" +done <"$root/deploy/docker/database_host_rejection_cases.txt" GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=verify-full&sslrootcert=/tmp/ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key' expect_failure 'a non-fixed certificate path' 'sslrootcert must use /run/secrets/external-db-ca.crt' diff --git a/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md b/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md new file mode 100644 index 00000000..ca5110df --- /dev/null +++ b/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md @@ -0,0 +1,251 @@ +# 商务通分类同步与会话分类配置实施计划 + +> 日期:2026-09-11 +> 状态:首版实现完成,待真实商务通账号灰度验证 +> 关联调研:[`docs/research/2026-09-11-shangwutong-pc-classification-protocol.md`](../research/2026-09-11-shangwutong-pc-classification-protocol.md) + +## 1. 目标与边界 + +为每个对接的商务通账号提供: + +1. 从商务通读取对话分类和客户颜色分类定义; +2. 在对应收件箱提供“同步商务通分类”按钮; +3. 在商务通会话右侧客户信息中提供两个独立下拉框; +4. 修改后通过 Connector 下发到商务通; +5. GoChat 不预设、创建、编辑或删除商务通分类定义。 + +明确不做: + +- 不把商务通分类写入 GoChat 原生标签定义表; +- 不把商务通颜色分类映射为 CRM `CategoryInFo`、`ClientLabelConfig`; +- 不为未同步的收件箱生成默认分类; +- 不在浏览器直接持有或调用商务通凭据。 + +## 2. 已确认的商务通协议 + +### 2.1 分类定义 + +```text +POST {serverurl}/oc/SiteSetting.aspx?sn={sn}&siteid={siteid}&act=load +``` + +对话分类: + +```text +sidkind_share = id,txt,iconindex|id,txt,iconindex|... +``` + +客户颜色分类: + +```text +colorkind0_share = id|id|... +colorkind1_share = name|name|... +``` + +保存/同步请求返回 `r=ok` 或 `r=load ok`。GoChat 只读取定义,不调用保存配置接口。 + +### 2.2 会话分类修改 + +对话分类: + +```text +POST {serverurl}/oc/SetSidKind.aspx +sid, oname, siteid, kind +``` + +客户颜色分类: + +```text +POST {serverurl}/oc/changecolor.aspx +sid, oname, siteid, c0, c1, cid +``` + +清除颜色分类的 `RESET` 语义暂不作为首版 UI 能力暴露。 + +## 3. 总体架构决定 + +采用“GoChat 按收件箱持久化远程分类缓存”的方案: + +```text +前端收件箱同步按钮 + -> GoChat 管理 API + -> 已签名的 GoChat -> Connector webhook 控制事件 + -> Connector 使用账号会话读取商务通 + -> Connector 回调 GoChat + -> GoChat 按 inbox_id 更新缓存 + -> 前端刷新列表 +``` + +会话修改采用现有 Connector 出站操作队列: + +```text +前端下拉修改 + -> GoChat 校验 inbox/conversation/sid/cid + -> 持久化 outbound operation + -> Connector 读取 operation + -> 调用 SetSidKind.aspx 或 changecolor.aspx + -> 回传结果 + -> GoChat 更新会话分类确认状态 +``` + +分类缓存按 `inbox_id` 隔离。同步失败保留旧缓存,并返回可展示的错误状态。 + +## 4. 分步实施清单 + +### 阶段 1:协议值对象与 Connector 商务通客户端 + +- [x] 增加对话分类、客户颜色分类值对象; +- [x] 实现 `SiteSetting.aspx` 读取和严格解析; +- [x] 实现 `SetSidKind.aspx`; +- [x] 实现 `changecolor.aspx`; +- [x] 保留已有认证、超时、响应状态和不确定结果处理; +- [x] 为分隔符、空值、重复 ID、平行数组长度不一致增加测试。 + +阶段验证:`cd channels/shangwutong && go test ./internal/swt` + +### 阶段 2:GoChat 分类缓存与 Connector 回调 + +- [x] 新增按 `inbox_id` 唯一的分类缓存模型及迁移; +- [x] 保存对话分类、客户颜色分类、同步时间、同步状态和最后错误; +- [x] 增加管理员读取分类缓存 API; +- [x] 增加管理员触发同步 API; +- [x] 增加 Connector 结果回调 API,验证账号、收件箱和幂等键; +- [x] 扩展已签名 webhook 事件 `classification_sync_requested`; +- [x] Connector 完成远程读取后回调 GoChat; +- [x] 失败时不覆盖上一次成功缓存。 + +### 阶段 3:会话分类出站操作 + +- [x] 扩展 GoChat 会话分类 API; +- [x] 校验会话属于商务通收件箱; +- [x] 从会话/联系人渠道元数据取得 `sid` 和 `cid`; +- [x] 将 `set_chat_kind`、`set_customer_color` 写入现有出站队列; +- [x] Connector 出站 worker 调用对应商务通接口; +- [x] 增加操作结果回调和幂等处理; +- [x] 只有商务通确认成功后更新确认值,失败/不确定保留状态并提示。 + +### 阶段 4:前端收件箱设置 + +- [x] 在商务通收件箱设置中增加“同步商务通分类”按钮; +- [x] 展示对话分类和客户分类同步结果; +- [x] 同步中禁用按钮; +- [x] 同步失败保留旧列表; +- [x] 不提供分类新增、编辑、删除控件。 + +### 阶段 5:前端会话右侧客户信息 + +- [x] 仅商务通会话显示“商务通分类”区块; +- [x] 增加对话分类下拉; +- [x] 增加客户分类下拉; +- [x] 未同步、失效或缺少 `cid` 时安全禁用; +- [x] 提交中禁用对应控件; +- [x] 根据远程确认结果更新或恢复值(轮询会话确认属性,超时显示处理中); +- [x] 不复用 GoChat 原生标签组件。 + +### 阶段 6:验证与文档 + +- [x] Go 单元测试与全模块编译; +- [x] SQLite migration/build/test(迁移已通过最小 SQLite 外键/建表冒烟验证); +- [x] 前端 production build; +- [x] Connector 协议 mock 测试; +- [x] 更新调研文档中的实现状态; +- [x] 检查无凭据泄漏、跨 inbox 读取和跨账号分类串用。 + +## 5. API 草案 + +### 5.1 读取缓存 + +```http +GET /api/v1/accounts/:account_id/inboxes/:inbox_id/shangwutong/classifications +``` + +### 5.2 触发同步 + +```http +POST /api/v1/accounts/:account_id/inboxes/:inbox_id/shangwutong/classifications/sync +``` + +### 5.3 修改会话分类 + +```http +PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/shangwutong-classifications +``` + +请求体只允许一个修改目标: + +```json +{"chat_kind_id": 2} +``` + +或: + +```json +{"customer_color_id": 1} +``` + +### 5.4 Connector 回调 + +Connector 专用回调不暴露给普通前端用户,沿用 Connector 平台鉴权和收件箱授权边界。 + +## 6. 数据模型草案 + +分类定义不是 GoChat 原生标签,建议独立表: + +```text +shangwutong_classification_caches +- inbox_id unique +- conversation_kinds JSON +- customer_color_kinds JSON +- sync_status pending/succeeded/failed +- synced_at nullable +- last_error_code nullable +- last_error_message nullable +- created_at +- updated_at +``` + +分类值只保留商务通远程 ID、名称及图标索引;不生成 GoChat `Tag` 记录。 + +## 7. 验收标准 + +- 不同步的商务通收件箱不显示伪造分类; +- 同步成功后两个列表均可按收件箱读取; +- 同步失败不会清空上一次成功结果; +- 会话右侧只对商务通显示两个独立下拉; +- 选择对话分类最终请求 `SetSidKind.aspx` 的 `kind`; +- 选择客户分类最终请求 `changecolor.aspx` 的 `c0/c1/cid`; +- `CategoryID`、`LabelID` 不参与上述实时会话接口; +- 重复点击和重试不会产生冲突修改; +- 远程失败或不确定时 UI 不显示虚假的成功状态; +- 不同商务通收件箱的分类 ID 不互相串用。 + +## 8. 风险与暂缓项 + +1. 商务通 PC 端分类读取响应存在旧式字符串协议,必须拒绝坏格式,不应静默错配两个颜色数组; +2. `cid` 是客户级标识,客户颜色修改的跨会话作用范围需灰度账号验证; +3. Connector 到 GoChat 的同步回调必须使用幂等键,避免 webhook 重试覆盖更新状态; +4. 首版不暴露清除客户分类按钮,待 `RESET` 完整协议验证后再增加; +5. 分类定义同步和会话分类修改必须分开建模,不能以“同步成功”代替“会话修改成功”。 + +## 9. 首版实现记录 + +已落地的主要文件: + +- Connector 协议:`channels/shangwutong/internal/swt/classifications.go`; +- Connector 回调客户端:`channels/shangwutong/internal/gochat/classifications.go`; +- Connector 会话操作:`channels/shangwutong/internal/delivery/outbound.go`; +- Connector webhook 分发:`channels/shangwutong/internal/httpapi/server.go`; +- GoChat 缓存模型与迁移:`backend/internal/model/channel_shangwutong_classification_cache.go`、`backend/migrations/000087_*`; +- GoChat API 与任务:`backend/internal/handler/api/v1/shangwutong_connector_handler.go`、`backend/internal/service/shangwutong_webhook_delivery.go`; +- 前端同步按钮:`frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue`; +- 前端会话下拉:`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`。 + +验证结果: + +```text +cd channels/shangwutong && go test ./... && go vet ./... +cd backend && GOCHAT_TEST_DB=sqlite go test ./... +cd frontend && pnpm build +``` + +均已通过。前端修改文件的 ESLint 无错误;现有设置组件保留一条既有的动态 i18n key warning。真实商务通账号的灰度协议验证仍待安排。 diff --git a/docs/research/2026-09-11-shangwutong-pc-classification-protocol.md b/docs/research/2026-09-11-shangwutong-pc-classification-protocol.md new file mode 100644 index 00000000..7687873e --- /dev/null +++ b/docs/research/2026-09-11-shangwutong-pc-classification-protocol.md @@ -0,0 +1,611 @@ +# 商务通 PC 端分类协议调研结论 + +> 日期:2026-09-11 +> 状态:PC 端逆向调研阶段性结论;GoChat 兼容实现已按关联计划落地首版 +> 软件:商务通 PC 端(部署程序名 `LiveReception.exe`) + +## 1. 调研范围 + +本次确认商务通 PC 端以下三类能力: + +1. 获取对话分类列表; +2. 获取客户分类列表; +3. 为当前会话设置对话分类和客户分类。 + +同时核对 CRM 中的客户主分类、客户标签,避免将它们与实时会话中的“客户分类”混为一谈。 + +## 2. 最终结论 + +商务通 PC 端存在两套独立的实时会话分类: + +| 业务名称 | 商务通内部概念 | 会话字段 | 配置/操作接口 | +| --- | --- | --- | --- | +| 对话分类 | Chat Kind / Sid Kind | `visitors.chatkind` | `sidkind_share`、`oc/SetSidKind.aspx` | +| 客户分类 | 颜色/客户颜色分类 | `visitors.colors` | `colorkind0_share`、`colorkind1_share`、`oc/changecolor.aspx` | + +CRM 另有独立的数据模型: + +- `CategoryInFo`:客户主分类; +- `ClientLabelConfig`:客户标签定义; +- `ClientLabelInFo`:客户与标签的关联。 + +在反编译得到的实时会话配置链路中,没有发现 `CategoryID` 或 `LabelID` 被提交给 `SetSidKind.aspx` 或 `changecolor.aspx`。因此,商务通实时会话中的“客户分类”应实现为颜色分类,不能直接映射成 GoChat 的 CRM Contact Label。 + +## 3. 证据与逆向环境 + +### 3.1 目标程序 + +```text +远程主机:rogee@10.1.1.101 +程序目录:C:\Users\Rogee\AppData\Roaming\ZoosNet\LiveReception +程序:LiveReception.exe +``` + +目标程序为旧版 32 位 .NET 程序,字符串经过运行时混淆。64 位 PowerShell 和 Mono 无法稳定执行其解密逻辑,最终使用远程 32 位 PowerShell/.NET Framework 解码字符串。 + +### 3.2 反编译目录 + +```text +/tmp/live-source +``` + +主要证据文件: + +```text +/tmp/live-source/LiveWS.LR/PublicData.cs +/tmp/live-source/LiveWS.LR/MainFrm.cs +/tmp/live-source/LiveWS.LR.Dialog/ChatKindSettingFrm.cs +/tmp/live-source/LiveWS.LR.Dialog/SetChatKindFrm.cs +/tmp/live-source/LiveWS.LR.Dialog/CustomerClassificationFrm.cs +/tmp/live-source/LiveWS.LR.Dialog/CustomerClassificationFrm_Ext.cs +/tmp/live-source/CRM.Manage.DataManage/CategoryInFoManage.cs +/tmp/live-source/CRM.Manage.DataManage/ClientLabelConfigManage.cs +/tmp/live-source/CRM.Manage.DataManage/SQLiteCRMDbContext.cs +``` + +## 4. 对话分类列表 + +### 4.1 配置页面和内存结构 + +配置页面: + +```text +LiveWS.LR.Dialog.ChatKindSettingFrm +``` + +内存数据集: + +```text +LiveWS.LR.DS.ChatKindDS +``` + +`ChatKindDS` 的核心列: + +| 列 | 含义 | +| --- | --- | +| `id` | 对话分类 ID | +| `txt` | 分类显示名称 | +| `iconindex` | UI 图标索引 | + +`PublicData.ReloadChatKind(string kindStr)` 将服务端字符串解析为 `ChatKindDS` 行,并提供: + +- `FindChatKind(int kid)`:按 ID 查找; +- `GetChatKindText(...)`:获取显示名称; +- `CheckChatKind(...)`:校验分类; +- `CheckChatKindText(...)`:校验名称。 + +### 4.2 列表读取接口 + +基础请求: + +```http +POST {serverurl}/oc/SiteSetting.aspx?sn={sn}&siteid={siteid}&act=load +``` + +请求会附带站点配置所需的 `sn`、站点 ID 以及共享设置字段。 + +读取字段: + +```text +sidkind_share +``` + +返回标记: + +```text +r = load ok +``` + +`sidkind_share` 的格式: + +```text +id,txt,iconindex|id,txt,iconindex|... +``` + +例如: + +```text +1,在线咨询,0|2,售后服务,1 +``` + +实际解析规则是: + +1. 先按 `|` 拆分记录; +2. 每条记录再按 `,` 拆分; +3. 第一个字段写入 `id`; +4. 第二个字段写入 `txt`; +5. 第三个字段存在时写入 `iconindex`。 + +### 4.3 列表保存接口 + +仍使用 `SiteSetting.aspx`: + +```http +POST {serverurl}/oc/SiteSetting.aspx?sn={sn}&siteid={siteid}&act=save +``` + +将当前 `ChatKindDS` 行重新序列化为: + +```text +id,txt,iconindex|id,txt,iconindex|... +``` + +然后提交: + +```text +sidkind_share= +``` + +`ChatKindSettingFrm` 的新增、编辑、删除最终都归并为这一次共享站点配置保存。 + +## 5. 实时会话客户分类列表 + +### 5.1 业务含义 + +商务通 PC 端“客户分类”页面对应的是颜色/图标分类,不是 CRM 的 `CategoryInFo`。 + +相关页面: + +```text +LiveWS.LR.Dialog.CustomerClassificationFrm_Ext +LiveWS.LR.Dialog.CustomerClassificationFrm +``` + +`CustomerClassificationFrm_Ext` 用于维护分类定义;`CustomerClassificationFrm` 用于在当前会话上选择一个分类。 + +### 5.2 配置字段 + +颜色分类通过同一个站点设置接口维护: + +```http +POST {serverurl}/oc/SiteSetting.aspx?sn={sn}&siteid={siteid}&act=load +``` + +读取两个平行数组: + +```text +colorkind0_share +colorkind1_share +``` + +含义: + +| 字段 | 含义 | +| --- | --- | +| `colorkind0_share` | 颜色/图标索引,作为分类值 | +| `colorkind1_share` | 分类显示名称 | + +格式: + +```text +colorkind0_share = 0|1|2 +colorkind1_share = 普通客户|重要客户|VIP +``` + +两个数组按相同下标配对。PC 端将它们加载到: + +```text +PublicData.colorkinds0 +PublicData.colorkinds1 +``` + +选择页面用这些数组填充 ImageCombo,颜色/图标索引来自 `colorkinds0`,显示文本来自 `colorkinds1`。 + +### 5.3 保存颜色分类定义 + +保存请求: + +```http +POST {serverurl}/oc/SiteSetting.aspx?sn={sn}&siteid={siteid}&act=save +``` + +保存时分别把选中的索引和名称用 `|` 连接: + +```text +colorkind0_share=||... +colorkind1_share=||... +``` + +成功后更新内存中的 `PublicData.colorkinds0/1`。关闭页面时会清理这两个临时内存数组。 + +## 6. 为会话设置对话分类 + +### 6.1 PC 端调用链 + +```text +MainFrm.qpSJrYUIY8S + -> 读取当前 visitors DataRow + -> 打开 SetChatKindFrm + -> 取得 SetChatKindFrm.chatkindStr + -> 创建 HttpRequestPM + -> 加入 MainFrm.TaskList + -> 后台 worker 执行 changesidkind +``` + +当前会话原值来自: + +```text +visitors.chatkind +``` + +`SetChatKindFrm` 只负责展示和返回所选分类,不直接发送 HTTP 请求。 + +### 6.2 内部任务数据 + +`MainFrm` 创建的任务等价于: + +```text +cmd = changesidkind +sid = 当前会话 sid +otherdata = [ + newChatKind, + cookies, + weixinid, + ServiceURL, + "1" +] +``` + +其中: + +- `newChatKind`:所选对话分类 ID; +- `cookies`:访客/客户级标识; +- `weixinid`、`ServiceURL`:渠道侧同步所需的会话元数据; +- 最后的 `"1"`:PC 端内部的即时更新标记。 + +### 6.3 实际 HTTP 接口 + +后台命令分发器将 `changesidkind` 映射到: + +```http +POST {serverurl}/oc/SetSidKind.aspx +``` + +核心参数: + +```text +sid = 会话 ID +oname = 当前操作员名称 +siteid = 站点 ID +kind = newChatKind +``` + +成功响应使用: + +```text +r = ok +``` + +此接口只设置会话分类,不设置 CRM 客户主分类或客户标签。 + +### 6.4 本地数据 + +生成的 `LRLocalDS.visitors` 数据表中已确认: + +```text +chatkind : short +``` + +PC 端发送任务后,当前会话的最终显示由请求结果、渠道同步消息或会话数据刷新完成;在 `MainFrm` 的发送入口中没有发现直接写入数据库的简单赋值语句。 + +## 7. 为会话设置客户颜色分类 + +### 7.1 PC 端调用链 + +```text +MainFrm.bFgJzrTJoY3 + -> 读取当前 visitors DataRow + -> 打开 CustomerClassificationFrm + -> 取得 ImageCombo 当前索引和文本 + -> 创建 HttpRequestPM + -> 加入 MainFrm.TaskList + -> 后台 worker 执行 changecolor +``` + +当前分类值来自: + +```text +visitors.colors +``` + +`CustomerClassificationFrm` 会根据 `PublicData.colorkinds0/1` 生成可选项,并根据当前 `colors` 选中对应颜色。 + +### 7.2 内部任务数据 + +`MainFrm` 创建的任务等价于: + +```text +cmd = changecolor +sid = 当前会话 sid +otherdata = [ + colorId, + colorName, + cookies, + weixinid, + ServiceURL, + "1" +] +``` + +其中: + +- `colorId`:ImageCombo 的颜色/图标索引; +- `colorName`:当前分类名称; +- `cookies`:访客/客户级 ID; +- `weixinid`、`ServiceURL`:渠道侧同步所需元数据。 + +### 7.3 实际 HTTP 接口 + +后台命令分发器将 `changecolor` 映射到: + +```http +POST {serverurl}/oc/changecolor.aspx +``` + +核心参数: + +```text +sid = 会话 ID +oname = 当前操作员名称 +siteid = 站点 ID +c0 = colorId +c1 = colorName +cid = cookies +``` + +清除客户分类时还会走 `RESET` 分支。 + +成功响应使用: + +```text +r = ok +``` + +成功后 PC 端还可能通过渠道管理器发送颜色/标记同步消息;这与商务通服务端的 `changecolor.aspx` 请求是两个层次。 + +### 7.4 本地数据 + +生成的 `LRLocalDS.visitors` 数据表中已确认: + +```text +colors : int +``` + +颜色名称不是独立的会话字段,而是通过 `colors` 与 `PublicData.colorkinds0/1` 反查显示。 + +## 8. 对话分类与客户分类不是一个接口 + +商务通 PC 端没有发现一个同时设置两种分类的接口。实际需要分别调用: + +```text +SetSidKind.aspx -> 对话分类 +changecolor.aspx -> 客户颜色分类 +``` + +对应关系: + +| 需求 | 配置列表 | 会话操作 | 会话字段 | +| --- | --- | --- | --- | +| 对话分类 | `sidkind_share` | `SetSidKind.aspx` | `chatkind` | +| 客户分类 | `colorkind0_share` + `colorkind1_share` | `changecolor.aspx` | `colors` | + +GoChat 兼容实现不能把 `sidkind_share` 当成会话更新参数,也不能把 `CategoryID` 当成 `c0`。 + +## 9. CRM 客户主分类与客户标签 + +### 9.1 CRM 客户主分类 + +模型: + +```text +CRM.Models.CategoryInFo +``` + +核心字段: + +```text +ID +Title +Comment +ColorValue +``` + +读取管理器: + +```text +CRM.Manage.DataManage.CategoryInFoManage +``` + +全部分类: + +```text +CategoryInFoManage.Get() +``` + +过滤条件为: + +```sql +TokenTime >= 0 +ORDER BY CreateTime DESC +``` + +有效分类: + +```text +CategoryInFoManage.GetStatusIsTrue() +``` + +过滤条件为: + +```sql +Status = 1 +AND TokenTime >= 0 +ORDER BY CreateTime DESC +``` + +SQLite 上下文: + +```text +CRM.Manage.DataManage.SQLiteCRMDbContext.CategoryInFoModel +``` + +由于 `SQLiteDbSet` 使用类型名加复数后缀生成表名,逻辑表名为: + +```text +CategoryInFos +``` + +客户模型通过以下字段关联主分类: + +```text +ClientInFo.CategoryID +RecoveryClientInFo.CategoryID +``` + +### 9.2 CRM 客户标签 + +标签定义模型: + +```text +CRM.Models.ClientLabelConfig +``` + +客户标签关系模型: + +```text +CRM.Models.ClientLabelInFo +``` + +核心关系字段: + +```text +ClientLabelInFo.ClientID +ClientLabelInFo.LabelID +``` + +有效标签由: + +```text +ClientLabelConfigManage.GetStatusIsTrue() +``` + +读取,条件为: + +```sql +Status = 1 +AND TokenTime >= 0 +ORDER BY Status, NumberValue, CreateTime DESC +``` + +逻辑 SQLite 表名为: + +```text +ClientLabelConfigs +ClientLabelInFos +``` + +`ClientLabelInFo.LabelID` 再通过 `ClientLabelConfigManageModel.GetByID` 解析标签定义。 + +### 9.3 与实时会话的边界 + +CRM 的 `CategoryID`、`ClientID`、`LabelID` 不出现在已确认的实时会话分类任务中: + +```text +changesidkind +changecolor +``` + +因此: + +- `CategoryInFo` 是 CRM 客户主分类; +- `ClientLabelConfig` 是 CRM 标签定义; +- `colorkind*_share` 是商务通实时会话客户颜色分类; +- 三者不能仅凭名称互相替换。 + +`ContactsCategoryFrm` 使用的则是 `ContactsDS` 联系人类别数据,也不是实时会话颜色分类。 + +## 10. 批量/历史记录场景 + +历史记录页面和批量操作页面也复用了聊天分类和颜色字段: + +```text +LiveWS.LR.Dialog.RecordsSqliteFrm +LiveWS.LR.Dialog.MBE_RecordsSqlite +``` + +批量聊天分类逻辑使用 `ModelUpdateChatKindItem` 一类的数据对象,包含: + +```text +sid +cid +chatkind +``` + +以及渠道相关字段。 + +这说明批量场景仍然围绕商务通会话 `sid`、访客 `cid` 和 `chatkind` 组织,而不是围绕 CRM 的 `CategoryID`/`LabelID`。批量请求的完整聚合发送路径仍受反编译控制流混淆影响,兼容实现应先复用单会话的 `SetSidKind.aspx` 合同,再单独补充批量重试和幂等。 + +## 11. GoChat 对接建议 + +### 11.1 应实现的商务通协议 + +第一阶段应实现四个独立能力: + +1. 站点配置读取 `sidkind_share`; +2. 站点配置读取 `colorkind0_share`、`colorkind1_share`; +3. 会话对话分类更新 `oc/SetSidKind.aspx`; +4. 会话客户颜色分类更新 `oc/changecolor.aspx`。 + +### 11.2 GoChat 内部字段建议 + +```text +conversation.chat_kind / custom_attributes.swt_chat_kind +conversation.custom_attributes.swt_customer_color +``` + +其中: + +- `swt_chat_kind` 保存商务通 `chatkind`/`kind`; +- `swt_customer_color` 保存商务通 `colors`/`c0`; +- `c1` 可作为当前分类名称缓存,但名称应以站点分类配置为准; +- `CategoryID`、`LabelID` 继续留在 CRM Contact 数据模型,不写入实时会话分类字段。 + +### 11.3 必须保留的协议细节 + +- 对话分类列表使用 `|` 分隔记录、`,` 分隔字段; +- 客户颜色分类使用两个平行的 `|` 分隔数组; +- 会话分类参数名是 `kind`,不是 `chatkind`; +- 客户颜色参数名是 `c0`/`c1`,不是 `CategoryID`/`LabelID`; +- 服务端成功标记是 `r=ok`; +- 修改会话分类时必须保留 `sid`; +- 修改客户颜色时必须保留访客级 `cid/cookies`,不能用 `sid` 猜测替代; +- 两个操作都应使用持久化出站任务和幂等键,避免网络超时后重复修改。 + +## 12. 尚未完全确认的部分 + +1. PC 端任务完成后,`visitors.chatkind`/`visitors.colors` 在所有渠道上的本地刷新入口被混淆在通用事件和渠道管理器中,未确认存在统一的直接 SQLite UPDATE; +2. `RESET` 在清除颜色分类时的完整表单值尚未从所有分支中还原; +3. 批量历史记录操作的最终聚合请求与失败重试策略仍需结合运行时抓包或测试账号验证; +4. CRM `CategoryInFo`/`ClientLabelConfig` 与商务通服务端是否存在额外同步接口,在 PC 端当前反编译范围内没有证据。 + +这些未确认项不影响四个核心接口和字段映射的结论,但实现时不能自行把 CRM 分类、标签映射到实时会话颜色分类。 diff --git a/frontend/app/javascript/dashboard/api/shangwutongClassifications.js b/frontend/app/javascript/dashboard/api/shangwutongClassifications.js new file mode 100644 index 00000000..5db34aaa --- /dev/null +++ b/frontend/app/javascript/dashboard/api/shangwutongClassifications.js @@ -0,0 +1,34 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class ShangwutongClassificationsAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + this.conversations = new ApiClient('conversations', { + accountScoped: true, + }); + } + + get(inboxId) { + return axios.get(`${this.url}/${inboxId}/shangwutong/classifications`); + } + + sync(inboxId) { + return axios.post( + `${this.url}/${inboxId}/shangwutong/classifications/sync` + ); + } + + updateConversation(conversationId, payload) { + return axios.patch( + `${this.conversations.url}/${conversationId}/shangwutong-classifications`, + payload + ); + } + + getConversation(conversationId) { + return axios.get(`${this.conversations.url}/${conversationId}`); + } +} + +export default new ShangwutongClassificationsAPI(); diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/conversation.json b/frontend/app/javascript/dashboard/i18n/locale/en/conversation.json index 71b0b70b..662e346d 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -403,6 +403,20 @@ "ASSIGNEE_LABEL": "Assigned Agent", "SELF_ASSIGN": "Assign to me", "TEAM_LABEL": "Assigned Team", + "SHANGWUTONG_CLASSIFICATIONS": { + "TITLE": "Shangwutong classifications", + "CONVERSATION_KIND": "Conversation classification", + "CUSTOMER_COLOR": "Customer classification", + "SELECT": "Select", + "NOT_SYNCED": "Sync Shangwutong classifications in inbox settings first", + "SYNC_PENDING": "Shangwutong classification sync is processing", + "SYNC_FAILED": "Shangwutong classification sync failed", + "LOAD_ERROR": "Failed to load Shangwutong classifications", + "UPDATE_QUEUED": "Shangwutong classification update queued", + "UPDATE_CONFIRMED": "Shangwutong classification updated", + "UPDATE_PENDING": "Shangwutong classification is still processing", + "UPDATE_ERROR": "Failed to update Shangwutong classification" + }, "SELECT": { "PLACEHOLDER": "None" }, diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 3bf7cdc3..291e7585 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -1225,6 +1225,15 @@ }, "PASSWORD": "Replace password", "PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.", + "CLASSIFICATIONS": "Shangwutong classifications", + "CLASSIFICATIONS_HELP": "Sync classification definitions from Shangwutong. GoChat does not predefine or edit them.", + "SYNC_CLASSIFICATIONS": "Sync Shangwutong classifications", + "CLASSIFICATION_SYNCED": "Synced: {conversation} conversation kinds, {customer} customer kinds", + "CLASSIFICATION_SYNC_PENDING": "Classification sync is processing", + "CLASSIFICATION_NOT_SYNCED": "Not synced yet", + "CLASSIFICATION_LOAD_ERROR": "Failed to load Shangwutong classifications", + "CLASSIFICATION_SYNC_QUEUED": "Shangwutong classification sync queued", + "CLASSIFICATION_SYNC_ERROR": "Shangwutong classification sync failed", "WEBHOOK_URL": "Connector webhook URL", "WEBHOOK_URL_HELP": "All enabled Shangwutong inboxes must use the same Connector URL." }, diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/conversation.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/conversation.json index d4e80997..d49bd8be 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/conversation.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/conversation.json @@ -403,6 +403,20 @@ "ASSIGNEE_LABEL": "已分配的客服代表", "SELF_ASSIGN": "分配给我", "TEAM_LABEL": "已分配的团队", + "SHANGWUTONG_CLASSIFICATIONS": { + "TITLE": "商务通分类", + "CONVERSATION_KIND": "对话分类", + "CUSTOMER_COLOR": "客户分类", + "SELECT": "请选择", + "NOT_SYNCED": "请先在收件箱设置中同步商务通分类", + "SYNC_PENDING": "商务通分类同步中", + "SYNC_FAILED": "商务通分类同步失败", + "LOAD_ERROR": "商务通分类加载失败", + "UPDATE_QUEUED": "商务通分类修改已提交", + "UPDATE_CONFIRMED": "商务通分类修改成功", + "UPDATE_PENDING": "商务通分类仍在处理中", + "UPDATE_ERROR": "商务通分类修改失败" + }, "SELECT": { "PLACEHOLDER": "啥都没有" }, diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index 2e31e28d..def44e88 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -1225,6 +1225,15 @@ }, "PASSWORD": "修改密码", "PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。", + "CLASSIFICATIONS": "商务通分类", + "CLASSIFICATIONS_HELP": "从商务通账号同步分类定义。GoChat 不预设或编辑商务通分类。", + "SYNC_CLASSIFICATIONS": "同步商务通分类", + "CLASSIFICATION_SYNCED": "已同步:对话分类 {conversation} 个,客户分类 {customer} 个", + "CLASSIFICATION_SYNC_PENDING": "同步任务处理中", + "CLASSIFICATION_NOT_SYNCED": "尚未同步", + "CLASSIFICATION_LOAD_ERROR": "商务通分类加载失败", + "CLASSIFICATION_SYNC_QUEUED": "商务通分类同步任务已提交", + "CLASSIFICATION_SYNC_ERROR": "商务通分类同步失败", "WEBHOOK_URL": "连接器 Webhook 地址", "WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个连接器地址。" }, diff --git a/frontend/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/frontend/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 05bb9aca..aee54dd5 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -15,6 +15,7 @@ import ConversationAction from './ConversationAction.vue'; import ConversationParticipant from './ConversationParticipant.vue'; import ContactInfo from './contact/ContactInfo.vue'; import ContactNotes from './contact/ContactNotes.vue'; +import ShangwutongClassifications from './ShangwutongClassifications.vue'; import ConversationInfo from './ConversationInfo.vue'; import CustomAttributes from './customAttributes/CustomAttributes.vue'; import SharedFiles from './SharedFiles.vue'; @@ -138,6 +139,12 @@ onMounted(() => { @close="closeContactPanel" /> +
+import { computed, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import { useMapGetter } from 'dashboard/composables/store'; +import ShangwutongClassificationsAPI from 'dashboard/api/shangwutongClassifications'; + +const props = defineProps({ + conversationId: { + type: [Number, String], + required: true, + }, + inboxId: { + type: [Number, String], + default: undefined, + }, + channelType: { + type: String, + default: '', + }, + conversationAttributes: { + type: Object, + default: () => ({}), + }, +}); + +const { t } = useI18n(); +const currentChat = useMapGetter('getSelectedChat'); +const catalog = ref({ + conversation_kinds: [], + customer_color_kinds: [], + sync_status: 'never', +}); +const selectedChatKind = ref(''); +const selectedCustomerColor = ref(''); +const isLoading = ref(false); +const saving = ref(''); + +const isShangwutong = computed( + () => + props.channelType === 'Channel::Shangwutong' || + currentChat.value?.meta?.channel === 'Channel::Shangwutong' +); +const isReady = computed(() => catalog.value.sync_status === 'succeeded'); +const attributes = computed(() => { + if (Object.keys(props.conversationAttributes || {}).length > 0) { + return props.conversationAttributes; + } + return currentChat.value?.meta?.additional_attributes || {}; +}); + +const resetSelections = () => { + selectedChatKind.value = + attributes.value.swt_chat_kind || attributes.value.swt_chat_kind_id || ''; + selectedCustomerColor.value = ''; + const currentColor = attributes.value.swt_label_color; + if (currentColor !== undefined && currentColor !== null) { + const match = catalog.value.customer_color_kinds.find( + item => + item.id === String(currentColor) || item.name === String(currentColor) + ); + selectedCustomerColor.value = match?.id || String(currentColor); + } +}; + +const fetchClassifications = async () => { + if (!isShangwutong.value || !props.inboxId) return; + isLoading.value = true; + try { + const response = await ShangwutongClassificationsAPI.get(props.inboxId); + catalog.value = response.data; + resetSelections(); + } catch (error) { + useAlert( + error.message || + t('CONVERSATION_SIDEBAR.SHANGWUTONG_CLASSIFICATIONS.LOAD_ERROR') + ); + } finally { + isLoading.value = false; + } +}; + +const conversationAttributesFrom = response => + response.data?.conversation?.additional_attributes || + response.data?.additional_attributes || + response.data?.meta?.additional_attributes || + {}; + +const waitForConfirmation = async (type, value, eventID, attempt = 0) => { + if (attempt >= 10) return null; + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + try { + const response = await ShangwutongClassificationsAPI.getConversation( + props.conversationId + ); + const updated = conversationAttributesFrom(response); + if (updated.swt_classification_event_id === eventID) { + if (updated.swt_classification_status !== 'succeeded') return false; + return type === 'chat' + ? String(updated.swt_chat_kind) === String(value) + : String(updated.swt_label_color) === String(value); + } + } catch (error) { + return null; + } + return waitForConfirmation(type, value, eventID, attempt + 1); +}; + +const updateClassification = async (type, value) => { + if (!value || saving.value || !isReady.value) return; + saving.value = type; + const payload = + type === 'chat' ? { chat_kind_id: value } : { customer_color_id: value }; + try { + const response = await ShangwutongClassificationsAPI.updateConversation( + props.conversationId, + payload + ); + const confirmation = await waitForConfirmation( + type, + value, + response.data?.sync_id + ); + if (confirmation === true) { + useAlert( + t('CONVERSATION_SIDEBAR.SHANGWUTONG_CLASSIFICATIONS.UPDATE_CONFIRMED') + ); + } else if (confirmation === false) { + resetSelections(); + useAlert( + t('CONVERSATION_SIDEBAR.SHANGWUTONG_CLASSIFICATIONS.UPDATE_ERROR') + ); + } else { + useAlert( + t('CONVERSATION_SIDEBAR.SHANGWUTONG_CLASSIFICATIONS.UPDATE_PENDING') + ); + } + } catch (error) { + resetSelections(); + useAlert( + error.message || + t('CONVERSATION_SIDEBAR.SHANGWUTONG_CLASSIFICATIONS.UPDATE_ERROR') + ); + } finally { + saving.value = ''; + } +}; + +watch( + [() => props.inboxId, () => props.channelType, () => props.conversationId], + fetchClassifications, + { immediate: true } +); +watch(() => props.conversationAttributes, resetSelections, { deep: true }); + + +