支持访客实时输入草稿展示与联系方式自动识别
坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"kefu-cloud/server/internal/model"
|
||||
"kefu-cloud/server/internal/ws"
|
||||
)
|
||||
|
||||
var (
|
||||
reMobileCN = regexp.MustCompile(`(?i)(?:手机|电话|联系|号|tel|phone|mobile)?[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})`)
|
||||
reMobileStrict = regexp.MustCompile(`(?:^|[^\d])(1[3-9]\d{9})(?:[^\d]|$)`)
|
||||
reEmail = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||
// 允许「微信是 xxx / 微信:xxx / 微信号 xxx」等口语写法
|
||||
reWechat = regexp.MustCompile(`(?i)(?:微信|微信号|vx|v信|wx)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([a-zA-Z][-_a-zA-Z0-9]{5,19})`)
|
||||
reWechatID = regexp.MustCompile(`(?i)\b(wxid_[a-zA-Z0-9]{5,20})\b`)
|
||||
reQQ = regexp.MustCompile(`(?i)(?:QQ|扣扣)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([1-9]\d{4,11})`)
|
||||
)
|
||||
|
||||
type extractedContact struct {
|
||||
Kind string
|
||||
Value string
|
||||
}
|
||||
|
||||
// extractContactsFromText 从文本中提取联系方式(手机/微信/邮箱/QQ)。
|
||||
func extractContactsFromText(text string) []extractedContact {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
// 限制扫描长度
|
||||
if utf8.RuneCountInString(text) > 2000 {
|
||||
text = string([]rune(text)[:2000])
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []extractedContact
|
||||
add := func(kind, val string) {
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
// 规范化手机号
|
||||
if kind == "phone" {
|
||||
val = regexp.MustCompile(`[^\d]`).ReplaceAllString(val, "")
|
||||
if strings.HasPrefix(val, "86") && len(val) == 13 {
|
||||
val = val[2:]
|
||||
}
|
||||
if len(val) != 11 || val[0] != '1' {
|
||||
return
|
||||
}
|
||||
}
|
||||
key := kind + ":" + strings.ToLower(val)
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, extractedContact{Kind: kind, Value: val})
|
||||
}
|
||||
|
||||
for _, m := range reMobileCN.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) > 1 {
|
||||
add("phone", m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range reMobileStrict.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) > 1 {
|
||||
add("phone", m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range reEmail.FindAllString(text, -1) {
|
||||
add("email", strings.ToLower(m))
|
||||
}
|
||||
for _, m := range reWechat.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) > 1 {
|
||||
add("wechat", m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range reWechatID.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) > 1 {
|
||||
add("wechat", m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range reQQ.FindAllStringSubmatch(text, -1) {
|
||||
if len(m) > 1 {
|
||||
add("qq", m[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeCustomerContacts 将提取结果写入客户联系方式表(已存在则跳过,不覆盖)。
|
||||
// 同时:若 customers.phone/email 为空,用第一条补全(兼容旧列表搜索)。
|
||||
// 返回新插入的条数。
|
||||
func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContact, source string) (int, []model.CustomerContact) {
|
||||
if customerID == 0 || len(contacts) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var customer model.Customer
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", customerID, tenantID).First(&customer).Error; err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
added := 0
|
||||
var inserted []model.CustomerContact
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
for _, c := range contacts {
|
||||
var exists int64
|
||||
model.DB.Model(&model.CustomerContact{}).
|
||||
Where("customer_id = ? AND kind = ? AND value = ?", customerID, c.Kind, c.Value).
|
||||
Count(&exists)
|
||||
if exists > 0 {
|
||||
continue
|
||||
}
|
||||
row := model.CustomerContact{
|
||||
TenantID: tenantID,
|
||||
CustomerID: customerID,
|
||||
Kind: c.Kind,
|
||||
Value: c.Value,
|
||||
Source: source,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
added++
|
||||
inserted = append(inserted, row)
|
||||
|
||||
// 主字段仅在为空时填充,绝不覆盖已有值
|
||||
if c.Kind == "phone" && strings.TrimSpace(customer.Phone) == "" && updates["phone"] == nil {
|
||||
updates["phone"] = c.Value
|
||||
customer.Phone = c.Value
|
||||
}
|
||||
if c.Kind == "email" && strings.TrimSpace(customer.Email) == "" && updates["email"] == nil {
|
||||
updates["email"] = c.Value
|
||||
customer.Email = c.Value
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
model.DB.Model(&model.Customer{}).Where("id = ?", customerID).Updates(updates)
|
||||
}
|
||||
return added, inserted
|
||||
}
|
||||
|
||||
func listCustomerContacts(customerID uint) []model.CustomerContact {
|
||||
var list []model.CustomerContact
|
||||
model.DB.Where("customer_id = ?", customerID).Order("id asc").Find(&list)
|
||||
return list
|
||||
}
|
||||
|
||||
// onVisitorDraftContacts 草稿文本提取联系方式并通知坐席刷新客户资料。
|
||||
func onVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) {
|
||||
contacts := extractContactsFromText(text)
|
||||
if len(contacts) == 0 {
|
||||
return
|
||||
}
|
||||
n, inserted := mergeCustomerContacts(tenantID, customerID, contacts, "draft")
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
// 推送客户资料更新(含全部联系方式)
|
||||
var customer model.Customer
|
||||
if err := model.DB.First(&customer, customerID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
all := listCustomerContacts(customerID)
|
||||
payload, err := ws.NewEvent("customer_updated", sessionID, map[string]interface{}{
|
||||
"customer": customer,
|
||||
"contacts": all,
|
||||
"added_contacts": inserted,
|
||||
})
|
||||
if err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(tenantID, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractContactsFromText(t *testing.T) {
|
||||
text := "你好,我微信是 abc_wx_01,手机 13800138000,邮箱 test@example.com QQ:123456"
|
||||
got := extractContactsFromText(text)
|
||||
kinds := map[string]string{}
|
||||
for _, c := range got {
|
||||
kinds[c.Kind] = c.Value
|
||||
}
|
||||
if kinds["phone"] != "13800138000" {
|
||||
t.Fatalf("phone: %+v", got)
|
||||
}
|
||||
if kinds["wechat"] != "abc_wx_01" {
|
||||
t.Fatalf("wechat: %+v", got)
|
||||
}
|
||||
if kinds["email"] != "test@example.com" {
|
||||
t.Fatalf("email: %+v", got)
|
||||
}
|
||||
if kinds["qq"] != "123456" {
|
||||
t.Fatalf("qq: %+v", got)
|
||||
}
|
||||
// 不完整不应误提
|
||||
if len(extractContactsFromText("微信 ab")) != 0 {
|
||||
t.Fatal("partial wechat should not extract")
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,13 @@ func (h *CustomerHandler) Get(c *gin.Context) {
|
||||
sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
|
||||
contacts := listCustomerContacts(customer.ID)
|
||||
|
||||
middleware.JSON(c, gin.H{"customer": customer, "sessions": sessions})
|
||||
middleware.JSON(c, gin.H{
|
||||
"customer": customer,
|
||||
"sessions": sessions,
|
||||
"contacts": contacts,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Create(c *gin.Context) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"kefu-cloud/server/internal/config"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
"kefu-cloud/server/internal/storage"
|
||||
"kefu-cloud/server/internal/ws"
|
||||
)
|
||||
|
||||
func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) {
|
||||
@@ -19,11 +20,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
channel := NewChannelHandler()
|
||||
settings := NewSettingsHandler()
|
||||
staff := NewStaffHandler()
|
||||
ws := NewWsHandler()
|
||||
wsHandler := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
upload := NewUploadHandler(store, storageCfg)
|
||||
|
||||
SetImagePublicBase(storageCfg.PublicBase)
|
||||
// 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖)
|
||||
ws.ProcessDraftContacts = onVisitorDraftContacts
|
||||
|
||||
api := r.Group("/api")
|
||||
|
||||
@@ -48,7 +51,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
authRequired.Use(middleware.AuthRequired())
|
||||
{
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", ws.Connect)
|
||||
authRequired.GET("/ws", wsHandler.Connect)
|
||||
|
||||
// 上传
|
||||
authRequired.POST("/uploads", upload.UploadImage)
|
||||
|
||||
@@ -432,8 +432,18 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||
return
|
||||
}
|
||||
// 发送后清空草稿
|
||||
_ = model.DB.Model(session).Updates(map[string]interface{}{
|
||||
"draft_text": "",
|
||||
"draft_updated_at": time.Now(),
|
||||
}).Error
|
||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
||||
|
||||
// 从已发送消息再提取一次联系方式
|
||||
if req.Type == "text" {
|
||||
go onVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content)
|
||||
}
|
||||
|
||||
// 仍在排队时尝试自动分配(客服刚上线的场景)
|
||||
if session.Status == "waiting" && session.AgentID == nil {
|
||||
if _, err := tryAutoAssign(session); err == nil {
|
||||
@@ -449,6 +459,13 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||
}
|
||||
}
|
||||
// 通知坐席清空草稿预览
|
||||
if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{
|
||||
"from": "visitor",
|
||||
"text": "",
|
||||
}); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user