diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 960f46a6..341d6d42 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -836,8 +836,7 @@ func (h *ConversationHandler) UpdateCustomAttributes(c *gin.Context) { if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) { return } - requestContext := service.WithShangwutongRequestMetadata(c.Request.Context(), middleware.IsConnectorService(c), currentUserID(c)) - conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(requestContext, accountID, conversation.ID, req.CustomAttributes) + conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(c.Request.Context(), accountID, conversation.ID, req.CustomAttributes) if svcErr != nil { handleServiceError(c, svcErr) return diff --git a/backend/internal/service/conversation_custom_attributes_test.go b/backend/internal/service/conversation_custom_attributes_test.go deleted file mode 100644 index 89219266..00000000 --- a/backend/internal/service/conversation_custom_attributes_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "testing" - - "github.com/gochat/gochat/internal/channel" - "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/internal/repository" - "github.com/stretchr/testify/require" - "gorm.io/datatypes" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func TestUpdateCustomAttributesMergesConnectorPayload(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&model.Conversation{})) - - conversation := model.Conversation{ - AccountID: 1, - CustomAttributes: datatypes.JSON([]byte(`{"swt_sid":"sid","swt_state":"chatting"}`)), - } - require.NoError(t, db.Create(&conversation).Error) - - svc := NewConversationService(repository.NewConversationRepo(db), repository.NewMessageRepo(db), channel.NewDispatcher(), nil, nil, nil, nil) - ctx := WithShangwutongRequestMetadata(context.Background(), true, 0) - updated, err := svc.UpdateCustomAttributes(ctx, 1, conversation.ID, datatypes.JSON([]byte(`{"swt_source_channel":"百度搜索推广"}`))) - require.NoError(t, err) - - attributes := map[string]any{} - require.NoError(t, json.Unmarshal(updated.CustomAttributes, &attributes)) - require.Equal(t, "sid", attributes["swt_sid"]) - require.Equal(t, "chatting", attributes["swt_state"]) - require.Equal(t, "百度搜索推广", attributes["swt_source_channel"]) -} diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index ae719186..7cd2d493 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "strconv" @@ -1647,28 +1646,6 @@ func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accoun return nil, err } - metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) - if metadata.ConnectorOrigin { - merged := map[string]any{} - if len(conversation.CustomAttributes) > 0 { - if err := json.Unmarshal(conversation.CustomAttributes, &merged); err != nil { - return nil, fmt.Errorf("decode existing conversation custom attributes: %w", err) - } - } - incoming := map[string]any{} - if err := json.Unmarshal(attrs, &incoming); err != nil { - return nil, fmt.Errorf("decode incoming conversation custom attributes: %w", err) - } - for key, value := range incoming { - merged[key] = value - } - encoded, err := json.Marshal(merged) - if err != nil { - return nil, fmt.Errorf("encode merged conversation custom attributes: %w", err) - } - attrs = datatypes.JSON(encoded) - } - if err := s.repo.UpdateCustomAttributes(ctx, id, attrs); err != nil { return nil, err } diff --git a/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.down.sql b/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.down.sql new file mode 100644 index 00000000..5ec10b5c --- /dev/null +++ b/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.down.sql @@ -0,0 +1,3 @@ +-- Restoring the old custom-attribute presentation is intentionally unsupported. +-- Migration 000075 remains the historical definition of that superseded UI. +SELECT 1; \ No newline at end of file diff --git a/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.up.sql b/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.up.sql new file mode 100644 index 00000000..ecdd3720 --- /dev/null +++ b/backend/migrations/000076_replace_shangwutong_source_attributes_with_messages.up.sql @@ -0,0 +1,52 @@ +DELETE FROM custom_attribute_definitions +WHERE attribute_model = 'conversation_attribute' + AND attribute_name IN ( + 'swt_source_url', + 'swt_source_search_term', + 'swt_source_purchase_term', + 'swt_source_keyword_id', + 'swt_source_channel', + 'swt_source_realtime_location', + 'swt_source_region', + 'swt_source_ad_account_id', + 'swt_source_wakeable', + 'swt_baidu_conversation_type', + 'swt_baidu_agent_name', + 'swt_baidu_ssid' + ); + +UPDATE conversations +SET + custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) + - 'swt_source_url' + - 'swt_source_description' + - 'swt_source_search_term' + - 'swt_source_purchase_term' + - 'swt_source_keyword_id' + - 'swt_source_channel' + - 'swt_source_realtime_location' + - 'swt_source_region' + - 'swt_source_ad_account_id' + - 'swt_source_wakeable' + - 'swt_baidu_conversation_type' + - 'swt_baidu_agent_name' + - 'swt_baidu_ssid', + updated_at = CURRENT_TIMESTAMP +WHERE COALESCE(custom_attributes, '{}'::jsonb) ?| ARRAY[ + 'swt_source_url', + 'swt_source_description', + 'swt_source_search_term', + 'swt_source_purchase_term', + 'swt_source_keyword_id', + 'swt_source_channel', + 'swt_source_realtime_location', + 'swt_source_region', + 'swt_source_ad_account_id', + 'swt_source_wakeable', + 'swt_baidu_conversation_type', + 'swt_baidu_agent_name', + 'swt_baidu_ssid' +]; + +-- Historical kind=8 system messages are backfilled from the Connector SQLite +-- store because PostgreSQL intentionally does not retain the raw encoded payload. \ No newline at end of file diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 4cd69c6a..99757885 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -127,9 +127,9 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactAttributes = parseEnvironment(text) case 8: - mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true - mapped.ConversationAttrs = parseSource(text) - mapped.RequiresConversation = len(mapped.ConversationAttrs) > 0 + if content := sourceActivityContent(parseSource(text)); content != "" { + activity(content) + } case 11: // Account presence is applied in the heartbeat transaction. case 12: @@ -621,6 +621,39 @@ func parseSource(text string) map[string]any { return result } +func sourceActivityContent(attributes map[string]any) string { + if len(attributes) == 0 { + return "" + } + lines := []string{"访客来源信息"} + fields := []struct { + key string + label string + }{ + {"swt_source_search_term", "搜索词"}, + {"swt_source_purchase_term", "购买词"}, + {"swt_source_keyword_id", "关键词 ID"}, + {"swt_source_channel", "流量渠道"}, + {"swt_source_realtime_location", "实时位置"}, + {"swt_source_region", "地域"}, + {"swt_source_ad_account_id", "推广账户 ID"}, + {"swt_source_wakeable", "是否可唤醒"}, + {"swt_baidu_conversation_type", "百度会话类型"}, + {"swt_baidu_agent_name", "百度智能体名称"}, + {"swt_baidu_ssid", "百度 SSID"}, + {"swt_source_url", "来源页面"}, + } + for _, field := range fields { + if value, ok := attributes[field.key].(string); ok && strings.TrimSpace(value) != "" { + lines = append(lines, field.label+":"+strings.TrimSpace(value)) + } + } + if len(lines) == 1 { + return "" + } + return strings.Join(lines, "
") +} + func sourceDescriptionValue(description, label string) string { for _, line := range strings.Split(description, "\n") { line = strings.TrimSpace(line) diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index b11ee51e..5c35471b 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -92,23 +92,26 @@ func TestKind8ParsesBaiduSourceDetails(t *testing.T) { "%e7%99%be%e5%ba%a6%e6%99%ba%e8%83%bd%e4%bd%93%e5%90%8d%e7%a7%b0%ef%bc%9a%e8%b4%b5%e5%b7%9e%e7%9b%9b%e4%ba%ac%e4%b8%ad%e5%8c%bb%e8%82%9d%e7%97%85%e5%8c%bb%e9%99%a2%3cbr+%2f%3e" + "%e7%99%be%e5%ba%a6ssid%ef%bc%9a71cbbfedf1e09620d7823b614ecd4de3%3cbr+%2f%3e%3c%2fdiv%3e" mapped := mapInboundEvent(8, 42, text, "", "", "source", 10, time.Now()) - want := map[string]string{ - "swt_source_url": "https://ada.baidu.com/site/test/agent?word=%E4%B8%89%E9%98%B3", - "swt_source_search_term": "三阳病毒携带", - "swt_source_purchase_term": "三阳病毒携带", - "swt_source_keyword_id": "1263742519801", - "swt_source_channel": "百度搜索推广", - "swt_source_realtime_location": "贵州黔南", - "swt_source_region": "贵州黔南", - "swt_source_ad_account_id": "48989266", - "swt_source_wakeable": "是", - "swt_baidu_conversation_type": "百度智能客服--百度商家智能体", - "swt_baidu_agent_name": "贵州盛京中医肝病医院", - "swt_baidu_ssid": "71cbbfedf1e09620d7823b614ecd4de3", + want := []string{ + "来源页面:https://ada.baidu.com/site/test/agent?word=%E4%B8%89%E9%98%B3", + "搜索词:三阳病毒携带", + "购买词:三阳病毒携带", + "关键词 ID:1263742519801", + "流量渠道:百度搜索推广", + "实时位置:贵州黔南", + "地域:贵州黔南", + "推广账户 ID:48989266", + "是否可唤醒:是", + "百度会话类型:百度智能客服--百度商家智能体", + "百度智能体名称:贵州盛京中医肝病医院", + "百度 SSID:71cbbfedf1e09620d7823b614ecd4de3", } - for key, value := range want { - if mapped.ConversationAttrs[key] != value { - t.Fatalf("%s = %#v, want %q; attributes=%#v", key, mapped.ConversationAttrs[key], value, mapped.ConversationAttrs) + if mapped.Message == nil || mapped.Message.MessageType != "activity" || mapped.Strategy != "activity" || len(mapped.ConversationAttrs) != 0 { + t.Fatalf("kind=8 mapping = %#v", mapped) + } + for _, expected := range want { + if !strings.Contains(mapped.Message.Content, expected) { + t.Fatalf("activity content missing %q: %q", expected, mapped.Message.Content) } } } @@ -118,8 +121,8 @@ func TestKind8ParsesLatestWakeableState(t *testing.T) { "%e6%90%9c%e7%b4%a2%e8%af%8d%ef%bc%9a%e4%b8%89%e9%98%b3%3cbr%2f%3e" + "%e6%98%af%e5%90%a6%e5%8f%af%e5%94%a4%e9%86%92%ef%bc%9a%e6%98%af%3c%2fdiv%3e" mapped := mapInboundEvent(8, 43, text, "", "", "source", 10, time.Now()) - if mapped.ConversationAttrs["swt_source_search_term"] != "三阳" || mapped.ConversationAttrs["swt_source_wakeable"] != "是" { - t.Fatalf("source attributes = %#v", mapped.ConversationAttrs) + if mapped.Message == nil || !strings.Contains(mapped.Message.Content, "搜索词:三阳") || !strings.Contains(mapped.Message.Content, "是否可唤醒:是") { + t.Fatalf("source activity = %#v", mapped.Message) } } @@ -202,7 +205,7 @@ func TestKnownKindMappingMatrix(t *testing.T) { {0, "5", "conversation_attributes", false}, {2, "hello", "native_message", false}, {3, "hello", "native_message", false}, {5, "unverified", "raw_only", true}, {7, "127.0.0.1 Beijing ISP 0 0 0 0 1920x1080 24 zh-CN +8 Windows 0 Chrome 149", "contact_attributes", false}, - {8, "https://example.test 0 Landing 0 0 0 friendlink", "conversation_attributes", false}, + {8, "https%3A%2F%2Fexample.test+%3Cdiv%3E%E6%90%9C%E7%B4%A2%E8%AF%8D%EF%BC%9Atest%3C%2Fdiv%3E", "activity", false}, {11, "3", "raw_only", true}, {12, "alias", "contact_attributes", false}, {15, "filemsg|name|https://media.example/file", "native_message", false}, {26, "张三|13800138000", "activity", false}, {29, "red", "conversation_attributes", false},