diff --git a/backend/internal/model/contact.go b/backend/internal/model/contact.go index 598d0041..15f863d8 100644 --- a/backend/internal/model/contact.go +++ b/backend/internal/model/contact.go @@ -51,7 +51,9 @@ func (c *Contact) syncLocationAndCountryCode() { if city, ok := attrs["city"].(string); ok && city != "" { c.Location = city } - if country, ok := attrs["country"].(string); ok && country != "" { + if countryCode, ok := attrs["country_code"].(string); ok && countryCode != "" { + c.CountryCode = countryCode + } else if country, ok := attrs["country"].(string); ok && country != "" { c.CountryCode = country } } diff --git a/backend/internal/model/contact_test.go b/backend/internal/model/contact_test.go new file mode 100644 index 00000000..6187d7ec --- /dev/null +++ b/backend/internal/model/contact_test.go @@ -0,0 +1,19 @@ +package model + +import ( + "testing" + + "gorm.io/datatypes" +) + +func TestContactSyncLocationAndCountryCodePrefersISOCode(t *testing.T) { + contact := Contact{ + AdditionalAttributes: datatypes.JSON([]byte(`{"city":"贵州贵阳","country":"China","country_code":"CN"}`)), + } + + contact.syncLocationAndCountryCode() + + if contact.Location != "贵州贵阳" || contact.CountryCode != "CN" { + t.Fatalf("location=%q country_code=%q", contact.Location, contact.CountryCode) + } +} diff --git a/backend/migrations/000067_translate_default_custom_priority.down.sql b/backend/migrations/000067_translate_default_custom_priority.down.sql new file mode 100644 index 00000000..380bd1fe --- /dev/null +++ b/backend/migrations/000067_translate_default_custom_priority.down.sql @@ -0,0 +1,27 @@ +UPDATE custom_attribute_definitions +SET + attribute_display_name = 'Custom Priority', + description = 'Custom ticket priority', + attribute_values = '["Low", "Medium", "High"]'::jsonb, + updated_at = CURRENT_TIMESTAMP +WHERE attribute_name = 'custom_priority' + AND attribute_model = 'conversation_attribute' + AND attribute_display_name = '自定义优先级' + AND description = '自定义工单优先级' + AND attribute_values = '["低", "中", "高"]'::jsonb; + +UPDATE conversations +SET + custom_attributes = jsonb_set( + custom_attributes, + '{custom_priority}', + to_jsonb( + CASE custom_attributes->>'custom_priority' + WHEN '低' THEN 'Low' + WHEN '中' THEN 'Medium' + WHEN '高' THEN 'High' + END + ) + ), + updated_at = CURRENT_TIMESTAMP +WHERE custom_attributes->>'custom_priority' IN ('低', '中', '高'); diff --git a/backend/migrations/000067_translate_default_custom_priority.up.sql b/backend/migrations/000067_translate_default_custom_priority.up.sql new file mode 100644 index 00000000..2b6559f3 --- /dev/null +++ b/backend/migrations/000067_translate_default_custom_priority.up.sql @@ -0,0 +1,27 @@ +UPDATE custom_attribute_definitions +SET + attribute_display_name = '自定义优先级', + description = '自定义工单优先级', + attribute_values = '["低", "中", "高"]'::jsonb, + updated_at = CURRENT_TIMESTAMP +WHERE attribute_name = 'custom_priority' + AND attribute_model = 'conversation_attribute' + AND attribute_display_name = 'Custom Priority' + AND description = 'Custom ticket priority' + AND attribute_values = '["Low", "Medium", "High"]'::jsonb; + +UPDATE conversations +SET + custom_attributes = jsonb_set( + custom_attributes, + '{custom_priority}', + to_jsonb( + CASE custom_attributes->>'custom_priority' + WHEN 'Low' THEN '低' + WHEN 'Medium' THEN '中' + WHEN 'High' THEN '高' + END + ) + ), + updated_at = CURRENT_TIMESTAMP +WHERE custom_attributes->>'custom_priority' IN ('Low', 'Medium', 'High'); diff --git a/backend/migrations/000068_add_shangwutong_contact_attributes.down.sql b/backend/migrations/000068_add_shangwutong_contact_attributes.down.sql new file mode 100644 index 00000000..d455c434 --- /dev/null +++ b/backend/migrations/000068_add_shangwutong_contact_attributes.down.sql @@ -0,0 +1,12 @@ +DELETE FROM custom_attribute_definitions +WHERE attribute_model = 'contact_attribute' + AND attribute_name IN ( + 'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution', + 'swt_language', 'swt_timezone', 'swt_os', 'swt_browser', + 'swt_browser_version', 'swt_user_agent', 'swt_device', + 'swt_query_title', 'swt_query_word', 'swt_traffic_source', + 'swt_profile_channel', 'swt_site_id' + ); + +-- Contact values are intentionally retained in custom_attributes. Removing +-- them would discard visitor diagnostics that may have arrived after upgrade. diff --git a/backend/migrations/000068_add_shangwutong_contact_attributes.up.sql b/backend/migrations/000068_add_shangwutong_contact_attributes.up.sql new file mode 100644 index 00000000..931b7bf2 --- /dev/null +++ b/backend/migrations/000068_add_shangwutong_contact_attributes.up.sql @@ -0,0 +1,83 @@ +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, + 'text', + 'contact_attribute', + definitions.description, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM accounts +CROSS JOIN (VALUES + ('swt_ip', '访客 IP', '商务通访客 IP 地址'), + ('swt_ip_location', 'IP 归属地', '商务通解析的访客 IP 归属地'), + ('swt_isp', '网络运营商', '商务通访客网络运营商'), + ('swt_resolution', '屏幕分辨率', '商务通访客设备屏幕分辨率'), + ('swt_language', '浏览器语言', '商务通访客浏览器语言'), + ('swt_timezone', '访客时区', '商务通访客设备时区'), + ('swt_os', '操作系统', '商务通访客设备操作系统'), + ('swt_browser', '浏览器', '商务通访客浏览器名称'), + ('swt_browser_version', '浏览器版本', '商务通访客浏览器版本'), + ('swt_user_agent', 'User Agent', '商务通访客浏览器 User Agent'), + ('swt_device', '访客设备', '商务通访客设备信息'), + ('swt_query_title', '搜索主题', '商务通访客进入页面时的搜索主题'), + ('swt_query_word', '搜索关键词', '商务通访客进入页面时的搜索关键词'), + ('swt_traffic_source', '流量来源', '商务通访客流量来源'), + ('swt_profile_channel', '来源渠道', '商务通访客来源渠道'), + ('swt_site_id', '商务通站点 ID', '商务通站点标识') +) AS definitions(attribute_name, attribute_display_name, 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 = 'contact_attribute' + AND existing.deleted_at IS NULL +); + +UPDATE contacts +SET + custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_ip', additional_attributes->'swt_ip', + 'swt_ip_location', additional_attributes->'swt_ip_location', + 'swt_isp', additional_attributes->'swt_isp', + 'swt_resolution', additional_attributes->'swt_resolution', + 'swt_language', additional_attributes->'swt_language', + 'swt_timezone', additional_attributes->'swt_timezone', + 'swt_os', additional_attributes->'swt_os', + 'swt_browser', additional_attributes->'swt_browser', + 'swt_browser_version', additional_attributes->'swt_browser_version', + 'swt_user_agent', additional_attributes->'swt_user_agent', + 'swt_device', additional_attributes->'swt_device', + 'swt_query_title', additional_attributes->'swt_query_title', + 'swt_query_word', additional_attributes->'swt_query_word', + 'swt_traffic_source', additional_attributes->'swt_traffic_source', + 'swt_profile_channel', additional_attributes->'swt_profile_channel', + 'swt_site_id', additional_attributes->'swt_site_id' + )), + updated_at = CURRENT_TIMESTAMP +WHERE EXISTS ( + SELECT 1 FROM contact_inboxes + WHERE contact_inboxes.contact_id = contacts.id + AND contact_inboxes.inbox_id IN ( + SELECT id FROM inboxes WHERE channel_type = 'shangwutong' + ) +) +AND COALESCE(additional_attributes, '{}'::jsonb) ?| ARRAY[ + 'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution', + 'swt_language', 'swt_timezone', 'swt_os', 'swt_browser', + 'swt_browser_version', 'swt_user_agent', 'swt_device', + 'swt_query_title', 'swt_query_word', 'swt_traffic_source', + 'swt_profile_channel', 'swt_site_id' +]; diff --git a/backend/migrations/000069_backfill_shangwutong_message_names.down.sql b/backend/migrations/000069_backfill_shangwutong_message_names.down.sql new file mode 100644 index 00000000..d1a71ebd --- /dev/null +++ b/backend/migrations/000069_backfill_shangwutong_message_names.down.sql @@ -0,0 +1,3 @@ +-- Message sender names are denormalized presentation metadata. The original +-- hard-coded values cannot be restored reliably after contacts are renamed. +SELECT 1; diff --git a/backend/migrations/000069_backfill_shangwutong_message_names.up.sql b/backend/migrations/000069_backfill_shangwutong_message_names.up.sql new file mode 100644 index 00000000..914843c8 --- /dev/null +++ b/backend/migrations/000069_backfill_shangwutong_message_names.up.sql @@ -0,0 +1,37 @@ +WITH latest_names AS ( + SELECT DISTINCT ON (conversation_id) + conversation_id, + NULLIF(BTRIM(additional_attributes->>'senderName'), '') AS visitor_name + FROM messages + WHERE message_type = 'incoming' + AND NULLIF(BTRIM(additional_attributes->>'senderName'), '') IS NOT NULL + AND BTRIM(additional_attributes->>'senderName') <> '商务通访客' + ORDER BY conversation_id, created_at DESC, id DESC +) +UPDATE contacts +SET + name = latest_names.visitor_name, + updated_at = CURRENT_TIMESTAMP +FROM conversations +JOIN latest_names ON latest_names.conversation_id = conversations.id +JOIN inboxes ON inboxes.id = conversations.inbox_id +WHERE contacts.id = conversations.contact_id + AND inboxes.channel_type = 'shangwutong' + AND contacts.name <> latest_names.visitor_name; + +-- Existing imported messages used a hard-coded senderName. Contact identity is +-- the authoritative sender for incoming messages after this repair. +UPDATE messages +SET additional_attributes = jsonb_set( + COALESCE(messages.additional_attributes, '{}'::jsonb), + '{senderName}', + to_jsonb(contacts.name) + ) +FROM conversations +JOIN contacts ON contacts.id = conversations.contact_id +JOIN inboxes ON inboxes.id = conversations.inbox_id +WHERE messages.conversation_id = conversations.id + AND messages.message_type = 'incoming' + AND inboxes.channel_type = 'shangwutong' + AND BTRIM(contacts.name) <> '' + AND COALESCE(messages.additional_attributes->>'senderName', '') <> contacts.name; diff --git a/backend/migrations/000070_decode_shangwutong_contact_attributes.down.sql b/backend/migrations/000070_decode_shangwutong_contact_attributes.down.sql new file mode 100644 index 00000000..976dfd27 --- /dev/null +++ b/backend/migrations/000070_decode_shangwutong_contact_attributes.down.sql @@ -0,0 +1,3 @@ +-- Percent-decoded visitor metadata cannot be losslessly restored to the +-- original hex casing and plus-sign representation. +SELECT 1; diff --git a/backend/migrations/000070_decode_shangwutong_contact_attributes.up.sql b/backend/migrations/000070_decode_shangwutong_contact_attributes.up.sql new file mode 100644 index 00000000..b7bfda9f --- /dev/null +++ b/backend/migrations/000070_decode_shangwutong_contact_attributes.up.sql @@ -0,0 +1,74 @@ +CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT) +RETURNS TEXT +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $$ +DECLARE + position INTEGER := 1; + encoded_bytes TEXT := ''; + token TEXT; +BEGIN + WHILE position <= char_length(value) LOOP + token := substr(value, position, 1); + IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN + encoded_bytes := encoded_bytes || substr(value, position + 1, 2); + position := position + 3; + ELSIF token = '+' THEN + encoded_bytes := encoded_bytes || '20'; + position := position + 1; + ELSE + encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex'); + position := position + 1; + END IF; + END LOOP; + RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8'); +EXCEPTION WHEN OTHERS THEN + RETURN value; +END; +$$; + +UPDATE contacts +SET + custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_ip_location', gochat_percent_decode(custom_attributes->>'swt_ip_location'), + 'swt_color_depth', gochat_percent_decode(custom_attributes->>'swt_color_depth'), + 'swt_resolution', gochat_percent_decode(custom_attributes->>'swt_resolution'), + 'swt_os', gochat_percent_decode(custom_attributes->>'swt_os'), + 'city', CASE + WHEN COALESCE(custom_attributes->>'city', '') = '' + THEN gochat_percent_decode(custom_attributes->>'swt_ip_location') + END + )), + additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_ip_location', gochat_percent_decode(additional_attributes->>'swt_ip_location'), + 'swt_color_depth', gochat_percent_decode(additional_attributes->>'swt_color_depth'), + 'swt_resolution', gochat_percent_decode(additional_attributes->>'swt_resolution'), + 'swt_os', gochat_percent_decode(additional_attributes->>'swt_os'), + 'city', CASE + WHEN COALESCE(additional_attributes->>'city', '') = '' + THEN gochat_percent_decode(additional_attributes->>'swt_ip_location') + END + )), + updated_at = CURRENT_TIMESTAMP +WHERE EXISTS ( + SELECT 1 + FROM contact_inboxes + JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id + WHERE contact_inboxes.contact_id = contacts.id + AND inboxes.channel_type = 'shangwutong' +) +AND ( + COALESCE(custom_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(custom_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(custom_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(custom_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(additional_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(additional_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(additional_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}' + OR COALESCE(additional_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}' +); + +DROP FUNCTION gochat_percent_decode(TEXT); diff --git a/backend/migrations/000071_repair_shangwutong_environment_fields.down.sql b/backend/migrations/000071_repair_shangwutong_environment_fields.down.sql new file mode 100644 index 00000000..238dc5ec --- /dev/null +++ b/backend/migrations/000071_repair_shangwutong_environment_fields.down.sql @@ -0,0 +1,3 @@ +-- Repaired environment fields cannot be reconstructed to the previous +-- misaligned values without reintroducing corrupt contact metadata. +SELECT 1; diff --git a/backend/migrations/000071_repair_shangwutong_environment_fields.up.sql b/backend/migrations/000071_repair_shangwutong_environment_fields.up.sql new file mode 100644 index 00000000..cb7c1c9b --- /dev/null +++ b/backend/migrations/000071_repair_shangwutong_environment_fields.up.sql @@ -0,0 +1,91 @@ +CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT) +RETURNS TEXT +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $$ +DECLARE + position INTEGER := 1; + encoded_bytes TEXT := ''; + token TEXT; +BEGIN + WHILE position <= char_length(value) LOOP + token := substr(value, position, 1); + IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN + encoded_bytes := encoded_bytes || substr(value, position + 1, 2); + position := position + 3; + ELSIF token = '+' THEN + encoded_bytes := encoded_bytes || '20'; + position := position + 1; + ELSE + encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex'); + position := position + 1; + END IF; + END LOOP; + RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8'); +EXCEPTION WHEN OTHERS THEN + RETURN value; +END; +$$; + +WITH profiles AS ( + SELECT DISTINCT ON (contacts.id) + contacts.id, + split_part( + COALESCE( + NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''), + contacts.additional_attributes->>'swt_xst_profile' + ), + '|||||', + 1 + ) AS profile + FROM contacts + JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id + JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id + WHERE inboxes.channel_type = 'shangwutong' + AND COALESCE( + NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''), + contacts.additional_attributes->>'swt_xst_profile' + ) IS NOT NULL +), decoded AS ( + SELECT + id, + NULLIF(gochat_percent_decode(split_part(profile, chr(26), 5)), '') AS location, + NULLIF(gochat_percent_decode(split_part(profile, chr(26), 11)), '') AS resolution, + NULLIF(gochat_percent_decode(split_part(profile, chr(26), 12)), '') AS user_agent, + NULLIF(gochat_percent_decode(split_part(profile, chr(26), 13)), '') AS ip_address + FROM profiles +) +UPDATE contacts +SET + custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_ip_location', decoded.location, + 'city', decoded.location, + 'swt_resolution', decoded.resolution, + 'swt_user_agent', decoded.user_agent, + 'swt_device', decoded.user_agent, + 'swt_os', decoded.user_agent, + 'swt_ip', decoded.ip_address + )), + additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_ip_location', decoded.location, + 'city', decoded.location, + 'swt_resolution', decoded.resolution, + 'swt_user_agent', decoded.user_agent, + 'swt_device', decoded.user_agent, + 'swt_os', decoded.user_agent, + 'swt_ip', decoded.ip_address + )), + updated_at = CURRENT_TIMESTAMP +FROM decoded +WHERE contacts.id = decoded.id + AND ( + decoded.location IS NOT NULL + OR decoded.resolution IS NOT NULL + OR decoded.user_agent IS NOT NULL + OR decoded.ip_address IS NOT NULL + ); + +DROP FUNCTION gochat_percent_decode(TEXT); diff --git a/backend/migrations/000072_fix_shangwutong_environment_semantics.down.sql b/backend/migrations/000072_fix_shangwutong_environment_semantics.down.sql new file mode 100644 index 00000000..df4a0523 --- /dev/null +++ b/backend/migrations/000072_fix_shangwutong_environment_semantics.down.sql @@ -0,0 +1,6 @@ +DELETE FROM custom_attribute_definitions +WHERE attribute_name = 'swt_environment_version' + AND attribute_model = 'contact_attribute'; + +-- The previous swt_isp and duplicated UA-derived values were semantically +-- incorrect and are intentionally not restored. diff --git a/backend/migrations/000072_fix_shangwutong_environment_semantics.up.sql b/backend/migrations/000072_fix_shangwutong_environment_semantics.up.sql new file mode 100644 index 00000000..e7a76138 --- /dev/null +++ b/backend/migrations/000072_fix_shangwutong_environment_semantics.up.sql @@ -0,0 +1,116 @@ +INSERT INTO custom_attribute_definitions ( + account_id, + attribute_name, + attribute_display_name, + attribute_type, + attribute_model, + description, + created_at, + updated_at +) +SELECT + accounts.id, + 'swt_environment_version', + '环境协议版本', + 'text', + 'contact_attribute', + '商务通 kind=7 环境信息格式版本;不是网络运营商', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM accounts +WHERE NOT EXISTS ( + SELECT 1 + FROM custom_attribute_definitions existing + WHERE existing.account_id = accounts.id + AND existing.attribute_name = 'swt_environment_version' + AND existing.attribute_model = 'contact_attribute' + AND existing.deleted_at IS NULL +); + +DELETE FROM custom_attribute_definitions +WHERE attribute_name = 'swt_isp' + AND attribute_model = 'contact_attribute'; + +WITH swt_contacts AS ( + SELECT DISTINCT contacts.id + FROM contacts + JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id + JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id + WHERE inboxes.channel_type = 'shangwutong' +), source AS ( + SELECT + contacts.id, + COALESCE( + NULLIF(contacts.custom_attributes->>'swt_user_agent', ''), + NULLIF(contacts.additional_attributes->>'swt_user_agent', ''), + NULLIF(contacts.custom_attributes->>'swt_os', ''), + NULLIF(contacts.additional_attributes->>'swt_os', '') + ) AS user_agent, + COALESCE( + NULLIF(contacts.custom_attributes->>'swt_environment_version', ''), + NULLIF(contacts.custom_attributes->>'swt_isp', ''), + NULLIF(contacts.additional_attributes->>'swt_environment_version', ''), + NULLIF(contacts.additional_attributes->>'swt_isp', '') + ) AS environment_version + FROM contacts + JOIN swt_contacts ON swt_contacts.id = contacts.id +), parsed AS ( + SELECT + id, + user_agent, + environment_version, + CASE + WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+' + THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)') + WHEN user_agent ~ 'Android[[:space:]]+[^;]+' + THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)')) + WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+' + THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.') + WHEN user_agent LIKE '%Windows NT 10.0%' + THEN 'Windows 10/11' + WHEN user_agent LIKE '%Windows%' + THEN 'Windows' + WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+' + THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.') + WHEN user_agent LIKE '%Linux%' + THEN 'Linux' + END AS operating_system, + CASE + WHEN user_agent LIKE '%OpenHarmony%' + THEN BTRIM(split_part(user_agent, ';', 1)) + WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/' + THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/')) + WHEN user_agent LIKE '%Android%' + THEN 'Android 设备' + WHEN user_agent LIKE '%iPhone OS%' + THEN 'iPhone' + WHEN user_agent LIKE '%Windows%' + THEN 'Windows PC' + WHEN user_agent LIKE '%Mac OS X%' + THEN 'Mac' + WHEN user_agent LIKE '%Linux%' + THEN 'Linux 设备' + END AS device + FROM source +) +UPDATE contacts +SET + custom_attributes = ( + COALESCE(contacts.custom_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth' + ) || jsonb_strip_nulls(jsonb_build_object( + 'swt_environment_version', parsed.environment_version, + 'swt_user_agent', parsed.user_agent, + 'swt_os', parsed.operating_system, + 'swt_device', parsed.device + )), + additional_attributes = ( + COALESCE(contacts.additional_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth' + ) || jsonb_strip_nulls(jsonb_build_object( + 'swt_environment_version', parsed.environment_version, + 'swt_user_agent', parsed.user_agent, + 'swt_os', parsed.operating_system, + 'swt_device', parsed.device + )), + updated_at = CURRENT_TIMESTAMP +FROM parsed +WHERE contacts.id = parsed.id; diff --git a/backend/migrations/000073_repair_shangwutong_device_after_profile.down.sql b/backend/migrations/000073_repair_shangwutong_device_after_profile.down.sql new file mode 100644 index 00000000..bf9ef561 --- /dev/null +++ b/backend/migrations/000073_repair_shangwutong_device_after_profile.down.sql @@ -0,0 +1,2 @@ +-- The previous device values duplicated the full User Agent and were invalid. +SELECT 1; diff --git a/backend/migrations/000073_repair_shangwutong_device_after_profile.up.sql b/backend/migrations/000073_repair_shangwutong_device_after_profile.up.sql new file mode 100644 index 00000000..796b02a9 --- /dev/null +++ b/backend/migrations/000073_repair_shangwutong_device_after_profile.up.sql @@ -0,0 +1,75 @@ +WITH swt_contacts AS ( + SELECT DISTINCT contacts.id + FROM contacts + JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id + JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id + WHERE inboxes.channel_type = 'shangwutong' +), source AS ( + SELECT + contacts.id, + COALESCE( + NULLIF(contacts.custom_attributes->>'swt_user_agent', ''), + NULLIF(contacts.additional_attributes->>'swt_user_agent', ''), + NULLIF(contacts.custom_attributes->>'swt_device', ''), + NULLIF(contacts.additional_attributes->>'swt_device', '') + ) AS user_agent + FROM contacts + JOIN swt_contacts ON swt_contacts.id = contacts.id +), parsed AS ( + SELECT + id, + user_agent, + CASE + WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+' + THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)') + WHEN user_agent ~ 'Android[[:space:]]+[^;]+' + THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)')) + WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+' + THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.') + WHEN user_agent LIKE '%Windows NT 10.0%' + THEN 'Windows 10/11' + WHEN user_agent LIKE '%Windows%' + THEN 'Windows' + WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+' + THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.') + WHEN user_agent LIKE '%Linux%' + THEN 'Linux' + END AS operating_system, + CASE + WHEN user_agent LIKE '%OpenHarmony%' + THEN BTRIM(split_part(user_agent, ';', 1)) + WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/' + THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/')) + WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*[^;]+ Build/' + THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*([^;]+) Build/')) + WHEN user_agent LIKE '%Android%' + THEN 'Android 设备' + WHEN user_agent LIKE '%iPhone OS%' + THEN 'iPhone' + WHEN user_agent LIKE '%Windows%' + THEN 'Windows PC' + WHEN user_agent LIKE '%Mac OS X%' + THEN 'Mac' + WHEN user_agent LIKE '%Linux%' + THEN 'Linux 设备' + END AS device + FROM source + WHERE user_agent IS NOT NULL +) +UPDATE contacts +SET + custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_user_agent', parsed.user_agent, + 'swt_device', parsed.device, + 'swt_os', parsed.operating_system + )), + additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb) + || jsonb_strip_nulls(jsonb_build_object( + 'swt_user_agent', parsed.user_agent, + 'swt_device', parsed.device, + 'swt_os', parsed.operating_system + )), + updated_at = CURRENT_TIMESTAMP +FROM parsed +WHERE contacts.id = parsed.id; diff --git a/backend/migrations/000074_backfill_shangwutong_china_country.down.sql b/backend/migrations/000074_backfill_shangwutong_china_country.down.sql new file mode 100644 index 00000000..bdd5c1e0 --- /dev/null +++ b/backend/migrations/000074_backfill_shangwutong_china_country.down.sql @@ -0,0 +1,3 @@ +-- Country metadata inferred from IP location cannot be safely distinguished from +-- user-entered values, so this data repair is intentionally irreversible. +SELECT 1; diff --git a/backend/migrations/000074_backfill_shangwutong_china_country.up.sql b/backend/migrations/000074_backfill_shangwutong_china_country.up.sql new file mode 100644 index 00000000..2373495a --- /dev/null +++ b/backend/migrations/000074_backfill_shangwutong_china_country.up.sql @@ -0,0 +1,22 @@ +UPDATE contacts +SET + country_code = 'CN', + location = CASE + WHEN COALESCE(location, '') = '' THEN custom_attributes->>'swt_ip_location' + ELSE location + END, + additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) + || jsonb_build_object( + 'country_code', 'CN', + 'country', 'China', + 'city', custom_attributes->>'swt_ip_location' + ), + updated_at = CURRENT_TIMESTAMP +WHERE EXISTS ( + SELECT 1 + FROM contact_inboxes + JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id + WHERE contact_inboxes.contact_id = contacts.id + AND inboxes.channel_type = 'shangwutong' +) +AND COALESCE(custom_attributes->>'swt_ip_location', '') ~ '^(北京|天津|上海|重庆|河北|山西|辽宁|吉林|黑龙江|江苏|浙江|安徽|福建|江西|山东|河南|湖北|湖南|广东|海南|四川|贵州|云南|陕西|甘肃|青海|台湾|内蒙古|广西|西藏|宁夏|新疆|香港|澳门)'; diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index cb28bd5e..f220521b 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -152,9 +152,15 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e contactAttributes := cloneMap(mapped.ContactAttributes) contactAttributes["swt_inbox_id"] = account.GochatInboxID + contactAdditional := cloneMap(mapped.ContactAdditional) + if inferred := shangwutongLocationAttributes(mapped.ContactAttributes); len(inferred) > 0 { + for key, value := range inferred { + contactAdditional[key] = value + } + } contactRequest := gochat.ContactRequest{ SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone, - AdditionalAttributes: contactAttributes, + CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional, } needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0 if mapped.RequiresContact && needsContactWrite { diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 1be6d0ea..bd3c0aeb 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -21,6 +21,7 @@ type mappedEvent struct { ContactName string ContactPhone string ContactAttributes map[string]any + ContactAdditional map[string]any ConversationAttrs map[string]any ConversationStatus string Typing *bool @@ -64,6 +65,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour mapped.Media = media mapped.Subtype = subtype mapped.Message.AdditionalAttributes["senderName"] = "商务通访客" + baseAttributes["swt"].(map[string]any)["operator_name"] = cleanText(operator) case 3: content, subtype, fallback, media := normalizeMessageContent(text) message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message")) @@ -124,7 +126,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour case 7: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactAttributes = parseEnvironment(text) - mapped.ContactName = visitorDisplayName(mapped.ContactAttributes) case 8: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true mapped.ConversationAttrs = parseSource(text) @@ -132,8 +133,8 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour case 11: // Account presence is applied in the heartbeat transaction. case 12: - mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true - mapped.ConversationAttrs = map[string]any{"swt_operator_alias": cleanText(text)} + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true + mapped.ContactName = cleanText(text) case 15: content, subtype, fallback, media := normalizeMessageContent(text) message("incoming", "text", firstText(content, "访客发送了文件"), "fallback_text") @@ -143,6 +144,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour } case 26: mapped.ContactName, mapped.ContactPhone = callbackIdentity(text) + mapped.RequiresContact = true activity("访客请求电话回拨") mapped.Strategy = "activity" case 29: @@ -158,7 +160,10 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour case 35: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactAttributes = map[string]any{"swt_third_party_source": truncate(cleanText(text), 2048)} - case 41, 61: + case 41: + mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true + mapped.ConversationAttrs = map[string]any{"swt_outcome": cleanText(text)} + case 61: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactName = cleanText(text) case 52: @@ -172,7 +177,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour case 65: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true mapped.ContactAttributes = parseXSTProfile(text) - mapped.ContactName = visitorDisplayName(mapped.ContactAttributes) case 66: mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true mapped.ConversationAttrs = parseSearchSource(text) @@ -438,16 +442,16 @@ func cleanText(value string) string { } func parseEnvironment(text string) map[string]any { - parts := strings.Fields(text) - indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 2: "swt_isp", 7: "swt_resolution", 8: "swt_color_depth", 9: "swt_language", 10: "swt_timezone", 11: "swt_os", 13: "swt_browser", 14: "swt_browser_version"} + parts := strings.Split(text, " ") + indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 4: "swt_environment_version", 7: "swt_resolution", 9: "swt_language", 16: "swt_timezone", 17: "swt_user_agent"} result := map[string]any{} for index, key := range indexes { if index < len(parts) && parts[index] != "0" && parts[index] != "null" { result[key] = truncate(decodeSWTValue(parts[index]), 512) } } - if len(parts) > 19 { - result["swt_user_agent"] = truncate(decodeSWTValue(strings.Join(parts[19:], " ")), 1024) + if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" { + setVisitorUserAgentAttributes(result, userAgent) } if location, ok := result["swt_ip_location"].(string); ok && location != "" { result["city"] = location @@ -455,6 +459,86 @@ func parseEnvironment(text string) map[string]any { return result } +func setVisitorUserAgentAttributes(attributes map[string]any, userAgent string) { + operatingSystem, device := parseVisitorUserAgent(userAgent) + if operatingSystem != "" { + attributes["swt_os"] = operatingSystem + } + if device != "" { + attributes["swt_device"] = device + } +} + +func shangwutongLocationAttributes(attributes map[string]any) map[string]any { + location, _ := attributes["swt_ip_location"].(string) + location = strings.TrimSpace(location) + if location == "" { + return nil + } + return map[string]any{ + "city": location, + "country": "China", + "country_code": "CN", + } +} + +func parseVisitorUserAgent(userAgent string) (operatingSystem, device string) { + parts := strings.Split(userAgent, ";") + for index := range parts { + parts[index] = strings.TrimSpace(parts[index]) + } + switch { + case strings.Contains(userAgent, "OpenHarmony"): + match := regexp.MustCompile(`OpenHarmony\s+([\d.]+)`).FindStringSubmatch(userAgent) + operatingSystem = "OpenHarmony" + if len(match) > 1 { + operatingSystem += " " + match[1] + } + device = firstText(firstEnvironmentPart(parts), "HarmonyOS 设备") + case strings.Contains(userAgent, "Android"): + match := regexp.MustCompile(`Android\s+([^;]+)`).FindStringSubmatch(userAgent) + operatingSystem = "Android" + if len(match) > 1 { + operatingSystem += " " + strings.TrimSpace(match[1]) + } + for _, part := range parts { + if strings.Contains(part, " Build/") { + device = strings.TrimSpace(strings.SplitN(part, " Build/", 2)[0]) + break + } + } + if device == "" { + device = firstText(last(parts), "Android 设备") + } + case strings.Contains(userAgent, "iPhone OS"): + match := regexp.MustCompile(`iPhone OS\s+([\d_]+)`).FindStringSubmatch(userAgent) + operatingSystem, device = "iOS", "iPhone" + if len(match) > 1 { + operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".") + } + case strings.Contains(userAgent, "Windows NT 10.0"): + operatingSystem, device = "Windows 10/11", "Windows PC" + case strings.Contains(userAgent, "Windows"): + operatingSystem, device = "Windows", "Windows PC" + case strings.Contains(userAgent, "Mac OS X"): + match := regexp.MustCompile(`Mac OS X\s+([\d_]+)`).FindStringSubmatch(userAgent) + operatingSystem, device = "macOS", "Mac" + if len(match) > 1 { + operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".") + } + case strings.Contains(userAgent, "Linux"): + operatingSystem, device = "Linux", "Linux 设备" + } + return operatingSystem, device +} + +func firstEnvironmentPart(parts []string) string { + if len(parts) == 0 { + return "" + } + return parts[0] +} + func parseXSTProfile(text string) map[string]any { result := map[string]any{"swt_xst_profile": truncate(cleanText(text), 2048)} profiles := strings.Split(text, "|||||") @@ -465,7 +549,7 @@ func parseXSTProfile(text string) map[string]any { fields := map[int]string{ 0: "swt_profile_channel", 1: "swt_visitor_nickname", 2: "swt_query_title", 3: "swt_query_word", 4: "swt_profile_location", 5: "swt_site_id", - 8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_device", + 8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_user_agent", 12: "swt_ip", 13: "swt_traffic_source", } for index, key := range fields { @@ -480,34 +564,12 @@ func parseXSTProfile(text string) map[string]any { if location, ok := result["swt_profile_location"].(string); ok && location != "" { result["city"] = location } + if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" { + setVisitorUserAgentAttributes(result, userAgent) + } return result } -func visitorDisplayName(attributes map[string]any) string { - for _, key := range []string{"swt_visitor_nickname", "swt_ip_location", "swt_profile_location", "swt_ip"} { - if value, ok := attributes[key].(string); ok && usableVisitorLabel(key, value) { - return truncate(strings.TrimSpace(value), 64) - } - } - return "" -} - -func usableVisitorLabel(key, value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return false - } - if key != "swt_visitor_nickname" { - return true - } - // XST may put an opaque tracking token in the nickname slot. Never expose - // that token as the contact name; fall back to location or IP instead. - if len([]rune(value)) > 32 || regexp.MustCompile(`^[A-Za-z0-9_-]{24,}$`).MatchString(value) { - return false - } - return true -} - func decodeSWTValue(value string) string { value = strings.TrimSpace(value) if decoded, err := url.QueryUnescape(value); err == nil { @@ -554,7 +616,7 @@ func callbackIdentity(text string) (string, string) { if phone != "" && !strings.HasPrefix(phone, "+") { phone = "+86" + strings.TrimPrefix(phone, "86") } - name := strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)) + name := strings.Trim(strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)), "|,,::;-_") return truncate(name, 255), phone } diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index c86b70c6..f57f910f 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -39,19 +39,57 @@ func TestMapInboundEventCoreStrategies(t *testing.T) { } } -func TestKind2UsesSequenceAsExternalMessageID(t *testing.T) { - mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now()) +func TestKind2UsesSequenceAsExternalMessageIDWithoutOverwritingContactName(t *testing.T) { + mapped := mapInboundEvent(2, 98765, "hello", "商务通客服名称", "", "swt:10:sid:2:98765:0", 10, time.Now()) if mapped.Message == nil || mapped.Message.ExternalSourceIDs["shangwutong"] != "98765" { t.Fatalf("external source IDs = %#v", mapped.Message) } + if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" { + t.Fatalf("visitor identity = %#v", mapped) + } +} + +func TestKind2FallsBackToGenericNameWhenMessageHasNoVisitorName(t *testing.T) { + mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now()) + if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" { + t.Fatalf("visitor identity = %#v", mapped) + } +} + +func TestKind12UpdatesContactNickname(t *testing.T) { + mapped := mapInboundEvent(12, 42, "贵州贵阳客人1219", "", "", "source", 10, time.Now()) + if mapped.ContactName != "贵州贵阳客人1219" || !mapped.RequiresContact || mapped.RequiresConversation { + t.Fatalf("visitor nickname mapping = %#v", mapped) + } +} + +func TestKind26UpdatesCurrentContactPhone(t *testing.T) { + mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now()) + if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact { + t.Fatalf("callback identity mapping = %#v", mapped) + } +} + +func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing.T) { + mapped := mapInboundEvent(41, 42, "留联", "", "", "source", 10, time.Now()) + if mapped.ContactName != "" || !mapped.RequiresConversation || mapped.ConversationAttrs["swt_outcome"] != "留联" { + t.Fatalf("conversation outcome mapping = %#v", mapped) + } } 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 ISP 0 0 0 0 393x798 24 zh-CN +8 iPhone 0 Safari 18", "", "", "source", 10, time.Now()) - if environment.ContactName != "贵州贵阳" { + 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 != "" { t.Fatalf("environment contact name = %q", environment.ContactName) } - if environment.ContactAttributes["swt_ip"] != "220.197.4.178" || environment.ContactAttributes["city"] != "贵州贵阳" { + if environment.ContactAttributes["swt_ip"] != "220.197.4.178" || + environment.ContactAttributes["swt_ip_location"] != "贵州贵阳" || + environment.ContactAttributes["city"] != "贵州贵阳" || + environment.ContactAttributes["swt_resolution"] != "393x798" || + environment.ContactAttributes["swt_environment_version"] != "5.6" || + environment.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7 like Mac OS X" || + environment.ContactAttributes["swt_os"] != "iOS 18.7" || + environment.ContactAttributes["swt_device"] != "iPhone" { t.Fatalf("environment attributes = %#v", environment.ContactAttributes) } @@ -60,19 +98,42 @@ func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) { "xst%7csbox%7czhinengzx", "医院", "fc", "1", "393x798", "iPhone%3b+CPU+iPhone+OS+18_7", "220.197.4.178", "百度搜索推广", }, "\x1a") profile := mapInboundEvent(65, 2, profileText, "", "", "source", 10, time.Now()) - if profile.ContactName != "访客昵称" { + if profile.ContactName != "" { t.Fatalf("profile contact name = %q", profile.ContactName) } - if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" || profile.ContactAttributes["swt_device"] != "iPhone; CPU iPhone OS 18_7" { + if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" || + profile.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7" || + profile.ContactAttributes["swt_device"] != "iPhone" || + profile.ContactAttributes["swt_os"] != "iOS 18.7" { t.Fatalf("profile attributes = %#v", profile.ContactAttributes) } - profileText = strings.Join([]string{ - "xst|sbox|zhinengzx", "TLnqPW6zPjDdPH9BPAc1nh7WPhfsuy79uH9-nHwBmy7WmHRKnHTvPjmsrjD1ns", "胆固醇高", "甘油三酯高怎么办", "贵州贵阳", "48989266", - }, "\x1a") - profile = mapInboundEvent(65, 3, profileText, "", "", "source", 10, time.Now()) - if profile.ContactName != "贵州贵阳" { - t.Fatalf("opaque nickname fallback contact name = %q", profile.ContactName) +} + +func TestParseVisitorUserAgent(t *testing.T) { + tests := []struct { + userAgent string + os string + device string + }{ + {"Linux; Android 16; V2302A Build/BP2A.250605.031.A3; wv", "Android 16", "V2302A"}, + {"Phone; OpenHarmony 6.1", "OpenHarmony 6.1", "Phone"}, + {"iPhone; CPU iPhone OS 18_7 like Mac OS X", "iOS 18.7", "iPhone"}, + {"Windows NT 10.0; Win64; x64", "Windows 10/11", "Windows PC"}, + {"Macintosh; Intel Mac OS X 10_15_7", "macOS 10.15.7", "Mac"}, + } + for _, test := range tests { + operatingSystem, device := parseVisitorUserAgent(test.userAgent) + if operatingSystem != test.os || device != test.device { + t.Fatalf("parseVisitorUserAgent(%q) = %q, %q", test.userAgent, operatingSystem, device) + } + } +} + +func TestShangwutongLocationAttributesDefaultToChina(t *testing.T) { + attributes := shangwutongLocationAttributes(map[string]any{"swt_ip_location": "贵州贵阳"}) + if attributes["country_code"] != "CN" || attributes["country"] != "China" || attributes["city"] != "贵州贵阳" { + t.Fatalf("location attributes = %#v", attributes) } } @@ -96,12 +157,12 @@ func TestKnownKindMappingMatrix(t *testing.T) { {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}, - {11, "3", "raw_only", true}, {12, "alias", "conversation_attributes", 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}, {30, "system", "activity", false}, {31, "guest_open_chat", "activity", false}, {34, "active", "conversation_attributes", false}, {35, "source", "contact_attributes", false}, - {39, "unverified", "raw_only", true}, {41, "访客", "contact_attributes", false}, + {39, "unverified", "raw_only", true}, {41, "留联", "conversation_attributes", false}, {52, "unverified#history", "raw_only", true}, {56, "web", "conversation_attributes", false}, {58, "left", "activity", false}, {61, "访客", "contact_attributes", false}, {65, "profile", "contact_attributes", false}, {66, "keyword|referrer|engine", "conversation_attributes", false}, diff --git a/frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue b/frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue index d8479c4f..b9a37a66 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue @@ -1,8 +1,8 @@