fix(shangwutong): 展示会话来源信息
This commit is contained in:
@@ -836,7 +836,8 @@ func (h *ConversationHandler) UpdateCustomAttributes(c *gin.Context) {
|
|||||||
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
if !requireConnectorShangwutongConversation(c, h.conversationSvc.DB(), conversation) {
|
||||||
return
|
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 {
|
if svcErr != nil {
|
||||||
handleServiceError(c, svcErr)
|
handleServiceError(c, svcErr)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -1646,6 +1647,28 @@ func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accoun
|
|||||||
return nil, err
|
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 {
|
if err := s.repo.UpdateCustomAttributes(ctx, id, attrs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -579,20 +579,70 @@ func decodeSWTValue(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseSource(text string) map[string]any {
|
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{}
|
result := map[string]any{}
|
||||||
if len(parts) > 0 {
|
if fields := strings.Fields(decoded); len(fields) > 0 {
|
||||||
result["swt_source_url"] = safeHTTPURL(parts[0])
|
if sourceURL := safeHTTPURL(fields[0]); sourceURL != "" {
|
||||||
|
result["swt_source_url"] = sourceURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(parts) > 2 {
|
if match := regexp.MustCompile(`(?is)<div\b[^>]*>(.*?)</div>`).FindStringSubmatch(decoded); len(match) > 1 {
|
||||||
result["swt_source_description"] = truncate(cleanText(parts[2]), 512)
|
descriptionHTML := regexp.MustCompile(`(?i)<br\s*/?>`).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 {
|
if value := hiddenSourceValue(decoded, "xsthiddenawakeflag"); value != "" {
|
||||||
result["swt_source_type"] = truncate(parts[6], 128)
|
if display := map[string]string{"0": "否", "1": "是"}[value]; display != "" {
|
||||||
|
result["swt_source_wakeable"] = display
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
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)<p\b[^>]*\bid=["']?` + regexp.QuoteMeta(id) + `["']?[^>]*>([^<]*)</p>`)
|
||||||
|
match := pattern.FindStringSubmatch(decoded)
|
||||||
|
if len(match) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
func parseSearchSource(text string) map[string]any {
|
func parseSearchSource(text string) map[string]any {
|
||||||
parts := strings.Split(text, "|")
|
parts := strings.Split(text, "|")
|
||||||
if len(parts) == 1 {
|
if len(parts) == 1 {
|
||||||
@@ -646,6 +696,7 @@ func safeHTTPURL(value string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
parsed.Fragment = ""
|
parsed.Fragment = ""
|
||||||
|
parsed.RawQuery = parsed.Query().Encode()
|
||||||
return parsed.String()
|
return parsed.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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())
|
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 != "" {
|
if environment.ContactName != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user