支持访客实时输入草稿展示与联系方式自动识别

坐席输入框上方显示「对方正在输入」草稿;从草稿/消息提取手机微信邮箱QQ并追加入库,不覆盖已有联系方式。
This commit is contained in:
yml2213
2026-07-19 00:19:24 +08:00
parent fea5f4acf5
commit 6070cf47dc
11 changed files with 465 additions and 17 deletions
+177
View File
@@ -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 QQ123456"
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")
}
}
+6 -1
View File
@@ -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 -2
View File
@@ -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)
+17
View File
@@ -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})
}
+1
View File
@@ -34,6 +34,7 @@ func Migrate(db *gorm.DB) error {
&Channel{},
&Customer{},
&CustomerTag{},
&CustomerContact{},
&Session{},
&VisitorPageView{},
&Message{},
+18 -4
View File
@@ -91,10 +91,13 @@ type Session struct {
CurrentURL string `gorm:"size:1000" json:"current_url"`
CurrentTitle string `gorm:"size:200" json:"current_title"`
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
LastSeenAt *time.Time `json:"last_seen_at"`
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
Status string `gorm:"size:20;default:waiting" json:"status"`
Priority string `gorm:"size:20;default:normal" json:"priority"`
LastSeenAt *time.Time `json:"last_seen_at"`
// DraftText 访客输入框未发送草稿(实时监控用,非聊天消息)
DraftText string `gorm:"type:text" json:"draft_text"`
DraftUpdatedAt *time.Time `json:"draft_updated_at"`
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
Status string `gorm:"size:20;default:waiting" json:"status"`
Priority string `gorm:"size:20;default:normal" json:"priority"`
SatisfactionScore *int `json:"satisfaction_score"`
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
EndReason string `gorm:"size:50" json:"end_reason"`
@@ -103,6 +106,17 @@ type Session struct {
UpdatedAt time.Time `json:"updated_at"`
}
// CustomerContact 客户联系方式(多条不覆盖,各自独立记录)。
type CustomerContact struct {
ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"`
CustomerID uint `gorm:"uniqueIndex:idx_cust_contact;not null" json:"customer_id"`
Kind string `gorm:"size:20;uniqueIndex:idx_cust_contact;not null" json:"kind"` // phone|wechat|email|qq
Value string `gorm:"size:100;uniqueIndex:idx_cust_contact;not null" json:"value"`
Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent
CreatedAt time.Time `json:"created_at"`
}
// VisitorPageView 访客在宿主站的页面浏览记录(按会话)。
type VisitorPageView struct {
ID uint `gorm:"primaryKey" json:"id"`
+53 -5
View File
@@ -4,13 +4,25 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gorilla/websocket"
"kefu-cloud/server/internal/model"
)
// ProcessDraftContacts 由 handler 包注入,避免 ws ↔ handler 循环依赖。
// tenantID, customerID, sessionID, draftText
var ProcessDraftContacts func(tenantID, customerID, sessionID uint, text string)
func processDraftContacts(tenantID, customerID, sessionID uint, text string) {
if ProcessDraftContacts != nil {
ProcessDraftContacts(tenantID, customerID, sessionID, text)
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"},
@@ -36,6 +48,8 @@ type Event struct {
type ClientEvent struct {
Type string `json:"type"`
SessionID uint `json:"session_id"`
// Text 访客输入框草稿(type=input_draft 或 typing 附带)
Text string `json:"text,omitempty"`
}
type Hub struct {
@@ -166,8 +180,14 @@ func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []by
}
}
const maxDraftTextRunes = 500
func handleClientEvent(client *Client, event ClientEvent) {
if event.Type != "typing" || event.SessionID == 0 {
if event.SessionID == 0 {
return
}
// typing:仅指示;input_draft:带正文草稿
if event.Type != "typing" && event.Type != "input_draft" {
return
}
var session model.Session
@@ -175,7 +195,7 @@ func handleClientEvent(client *Client, event ClientEvent) {
return
}
// 访客输入中 → 通知可接待的客服
// 访客输入中 / 草稿 → 通知坐席
if client.Kind == "visitor" {
if client.SessionID == nil || *client.SessionID != event.SessionID {
return
@@ -183,6 +203,34 @@ func handleClientEvent(client *Client, event ClientEvent) {
if session.Status == "ended" || session.Status == "archived" {
return
}
if event.Type == "input_draft" {
text := strings.TrimSpace(event.Text)
if utf8.RuneCountInString(text) > maxDraftTextRunes {
text = string([]rune(text)[:maxDraftTextRunes])
}
now := time.Now()
_ = model.DB.Model(&session).Updates(map[string]interface{}{
"draft_text": text,
"draft_updated_at": now,
}).Error
// 异步提取联系方式(不阻塞 WS
go processDraftContacts(session.TenantID, session.CustomerID, session.ID, text)
payload, err := NewEvent("input_draft", session.ID, map[string]interface{}{
"from": "visitor",
"text": text,
})
if err != nil {
return
}
// 排队中也让租户坐席能看到
DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
return
}
// 兼容旧 typing(无正文)
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"})
if err != nil {
return
@@ -195,8 +243,8 @@ func handleClientEvent(client *Client, event ClientEvent) {
return
}
// 客服输入中 → 通知访客
if client.Kind != "agent" {
// 客服输入中 → 通知访客(不传草稿内容)
if client.Kind != "agent" || event.Type != "typing" {
return
}
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) {
@@ -217,7 +265,7 @@ func HandleWebSocket(client *Client) {
client.Conn.Close()
}()
client.Conn.SetReadLimit(1024)
client.Conn.SetReadLimit(4096) // 允许访客草稿正文
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
client.Conn.SetPongHandler(func(string) error {
client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))