From d2aeea5f4404990b803b2abd9b26dcd4c1373734 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 6 Aug 2026 16:54:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(shangwutong):=20=E5=B1=95=E7=A4=BA=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=9D=A5=E6=BA=90=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/api/v1/conversation_handler.go | 3 +- .../conversation_custom_attributes_test.go | 38 +++++++++++ .../internal/service/conversation_service.go | 23 +++++++ ...add_shangwutong_source_attributes.down.sql | 18 +++++ ...5_add_shangwutong_source_attributes.up.sql | 46 +++++++++++++ .../shangwutong/internal/delivery/mapping.go | 65 +++++++++++++++++-- .../internal/delivery/mapping_test.go | 46 +++++++++++++ 7 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 backend/internal/service/conversation_custom_attributes_test.go create mode 100644 backend/migrations/000075_add_shangwutong_source_attributes.down.sql create mode 100644 backend/migrations/000075_add_shangwutong_source_attributes.up.sql diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 341d6d42..960f46a6 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -836,7 +836,8 @@ func (h *ConversationHandler) UpdateCustomAttributes(c *gin.Context) { if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) { return } - conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(c.Request.Context(), accountID, conversation.ID, req.CustomAttributes) + requestContext := service.WithShangwutongRequestMetadata(c.Request.Context(), middleware.IsConnectorService(c), currentUserID(c)) + conversation, svcErr := h.conversationSvc.UpdateCustomAttributes(requestContext, 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 new file mode 100644 index 00000000..89219266 --- /dev/null +++ b/backend/internal/service/conversation_custom_attributes_test.go @@ -0,0 +1,38 @@ +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 7cd2d493..ae719186 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "strconv" @@ -1646,6 +1647,28 @@ 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/000075_add_shangwutong_source_attributes.down.sql b/backend/migrations/000075_add_shangwutong_source_attributes.down.sql new file mode 100644 index 00000000..56daaf75 --- /dev/null +++ b/backend/migrations/000075_add_shangwutong_source_attributes.down.sql @@ -0,0 +1,18 @@ +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' + ); + +-- Conversation values are retained to avoid losing source-attribution data. diff --git a/backend/migrations/000075_add_shangwutong_source_attributes.up.sql b/backend/migrations/000075_add_shangwutong_source_attributes.up.sql new file mode 100644 index 00000000..81cd00a0 --- /dev/null +++ b/backend/migrations/000075_add_shangwutong_source_attributes.up.sql @@ -0,0 +1,46 @@ +INSERT INTO custom_attribute_definitions ( + account_id, + attribute_name, + attribute_display_name, + attribute_type, + attribute_model, + description, + created_at, + updated_at +) +SELECT + accounts.id, + definitions.attribute_name, + definitions.attribute_display_name, + definitions.attribute_type, + 'conversation_attribute', + definitions.description, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM accounts +CROSS JOIN (VALUES + ('swt_source_url', '来源页面', 'link', '商务通访客进入咨询时的来源页面'), + ('swt_source_search_term', '搜索词', 'text', '商务通 kind=8 来源信息中的搜索词'), + ('swt_source_purchase_term', '购买词', 'text', '商务通 kind=8 来源信息中的购买词'), + ('swt_source_keyword_id', '关键词 ID', 'text', '商务通 kind=8 来源信息中的推广关键词 ID'), + ('swt_source_channel', '流量渠道', 'text', '商务通 kind=8 来源信息中的流量渠道'), + ('swt_source_realtime_location', '实时位置', 'text', '商务通 kind=8 来源信息中的访客实时位置'), + ('swt_source_region', '地域', 'text', '商务通 kind=8 来源信息中的地域'), + ('swt_source_ad_account_id', '推广账户 ID', 'text', '商务通 kind=8 来源信息中的推广账户 ID'), + ('swt_source_wakeable', '是否可唤醒', 'text', '商务通 kind=8 来源信息中的访客可唤醒状态'), + ('swt_baidu_conversation_type', '百度会话类型', 'text', '商务通 kind=8 来源信息中的百度会话类型'), + ('swt_baidu_agent_name', '百度智能体名称', 'text', '商务通 kind=8 来源信息中的百度智能体名称'), + ('swt_baidu_ssid', '百度 SSID', 'text', '商务通 kind=8 来源信息中的百度会话 SSID') +) AS definitions(attribute_name, attribute_display_name, attribute_type, description) +WHERE NOT EXISTS ( + SELECT 1 + FROM custom_attribute_definitions existing + WHERE existing.account_id = accounts.id + AND existing.attribute_name = definitions.attribute_name + AND existing.attribute_model = 'conversation_attribute' + AND existing.deleted_at IS NULL +); + +-- Historical values are backfilled by replaying the raw kind=8 events from the +-- Shangwutong Connector store. PostgreSQL does not contain the original encoded +-- payload, so this migration intentionally limits itself to schema definitions. diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index bd3c0aeb..4cd69c6a 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -579,20 +579,70 @@ func decodeSWTValue(value string) string { } func parseSource(text string) map[string]any { - parts := strings.Fields(text) + decoded := strings.TrimSpace(text) + if value, err := url.QueryUnescape(decoded); err == nil { + decoded = html.UnescapeString(value) + } result := map[string]any{} - if len(parts) > 0 { - result["swt_source_url"] = safeHTTPURL(parts[0]) + if fields := strings.Fields(decoded); len(fields) > 0 { + if sourceURL := safeHTTPURL(fields[0]); sourceURL != "" { + result["swt_source_url"] = sourceURL + } } - if len(parts) > 2 { - result["swt_source_description"] = truncate(cleanText(parts[2]), 512) + if match := regexp.MustCompile(`(?is)]*>(.*?)`).FindStringSubmatch(decoded); len(match) > 1 { + descriptionHTML := regexp.MustCompile(`(?i)`).ReplaceAllString(match[1], "\n") + description := cleanText(descriptionHTML) + if description != "" { + result["swt_source_description"] = truncate(description, 2048) + } + for label, key := range map[string]string{ + "搜索词": "swt_source_search_term", + "购买词": "swt_source_purchase_term", + "关键词ID": "swt_source_keyword_id", + "流量渠道": "swt_source_channel", + "实时位置": "swt_source_realtime_location", + "地域": "swt_source_region", + "推广账户ID": "swt_source_ad_account_id", + "是否可唤醒": "swt_source_wakeable", + "百度会话类型": "swt_baidu_conversation_type", + "百度智能体名称": "swt_baidu_agent_name", + "百度ssid": "swt_baidu_ssid", + } { + if value := sourceDescriptionValue(description, label); value != "" { + result[key] = truncate(value, 1024) + } + } } - if len(parts) > 6 { - result["swt_source_type"] = truncate(parts[6], 128) + if value := hiddenSourceValue(decoded, "xsthiddenawakeflag"); value != "" { + if display := map[string]string{"0": "否", "1": "是"}[value]; display != "" { + result["swt_source_wakeable"] = display + } } return result } +func sourceDescriptionValue(description, label string) string { + for _, line := range strings.Split(description, "\n") { + line = strings.TrimSpace(line) + for _, separator := range []string{":", ":"} { + prefix := label + separator + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + } + return "" +} + +func hiddenSourceValue(decoded, id string) string { + pattern := regexp.MustCompile(`(?is)]*\bid=["']?` + regexp.QuoteMeta(id) + `["']?[^>]*>([^<]*)

`) + match := pattern.FindStringSubmatch(decoded) + if len(match) < 2 { + return "" + } + return strings.TrimSpace(match[1]) +} + func parseSearchSource(text string) map[string]any { parts := strings.Split(text, "|") if len(parts) == 1 { @@ -646,6 +696,7 @@ func safeHTTPURL(value string) string { return "" } parsed.Fragment = "" + parsed.RawQuery = parsed.Query().Encode() return parsed.String() } diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index f57f910f..b11ee51e 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -77,6 +77,52 @@ func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing } } +func TestKind8ParsesBaiduSourceDetails(t *testing.T) { + text := "https%3a%2f%2fada.baidu.com%2fsite%2ftest%2fagent%3fword%3d%e4%b8%89%e9%98%b3 " + + "%3cdiv%3e%3cP+id%3d%22xsthiddenawakeflag%22%3e1%3c%2fP%3e" + + "%e6%90%9c%e7%b4%a2%e8%af%8d%ef%bc%9a%e4%b8%89%e9%98%b3%e7%97%85%e6%af%92%e6%90%ba%e5%b8%a6%3cbr+%2f%3e" + + "%e8%b4%ad%e4%b9%b0%e8%af%8d%ef%bc%9a%e4%b8%89%e9%98%b3%e7%97%85%e6%af%92%e6%90%ba%e5%b8%a6%3cbr+%2f%3e" + + "%e5%85%b3%e9%94%ae%e8%af%8dID%ef%bc%9a1263742519801%3cbr+%2f%3e" + + "%e6%b5%81%e9%87%8f%e6%b8%a0%e9%81%93%ef%bc%9a%e7%99%be%e5%ba%a6%e6%90%9c%e7%b4%a2%e6%8e%a8%e5%b9%bf%3cbr+%2f%3e" + + "%e5%ae%9e%e6%97%b6%e4%bd%8d%e7%bd%ae%ef%bc%9a%e8%b4%b5%e5%b7%9e%e9%bb%94%e5%8d%97%3cbr+%2f%3e" + + "%e5%9c%b0%e5%9f%9f%ef%bc%9a%e8%b4%b5%e5%b7%9e%e9%bb%94%e5%8d%97%3cbr+%2f%3e" + + "%e6%8e%a8%e5%b9%bf%e8%b4%a6%e6%88%b7ID%ef%bc%9a48989266%3cbr+%2f%3e" + + "%e6%98%af%e5%90%a6%e5%8f%af%e5%94%a4%e9%86%92%ef%bc%9a%e6%98%af%3cbr+%2f%3e" + + "%e7%99%be%e5%ba%a6%e4%bc%9a%e8%af%9d%e7%b1%bb%e5%9e%8b%ef%bc%9a%e7%99%be%e5%ba%a6%e6%99%ba%e8%83%bd%e5%ae%a2%e6%9c%8d--%e7%99%be%e5%ba%a6%e5%95%86%e5%ae%b6%e6%99%ba%e8%83%bd%e4%bd%93%3cbr+%2f%3e" + + "%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", + } + 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) + } + } +} + +func TestKind8ParsesLatestWakeableState(t *testing.T) { + text := "https%3a%2f%2fada.baidu.com%2fsite%2ftest+%3cdiv%3e" + + "%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) + } +} + func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) { environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 5.6 1 1 393x798 zh-CN iPhone iPhone%3b+CPU+iPhone+OS+18_7+like+Mac+OS+X ", "", "", "source", 10, time.Now()) if environment.ContactName != "" {