package service import ( "errors" "regexp" "strings" "time" "gorm.io/gorm" "kefu-cloud/server/internal/model" "kefu-cloud/server/internal/ws" ) var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`) var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) // VisitorMeta 访客环境信息(由 handler 从请求解析,service 不感知 HTTP)。 type VisitorMeta struct { IP string Region string UserAgent string } // WidgetSession 校验访客凭证并加载会话。 func WidgetSession(sessionID uint, token string) (*model.Session, error) { var session model.Session if err := model.DB.First(&session, sessionID).Error; err != nil { return nil, NotFound("会话不存在") } if !model.VerifyVisitorToken(&session, token) { return nil, &Error{Status: 401, Message: "访客会话凭证无效"} } return &session, nil } // WidgetInitInput 访客初始化入参。 type WidgetInitInput struct { ChannelKey string VisitorName string DeviceID string PageURL string PageTitle string Referrer string Meta VisitorMeta } // WidgetInit 访客进线:渠道校验、客户/会话创建、黑名单拦截、自动分配与欢迎语组装。 func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) { if !channelKeyPattern.MatchString(in.ChannelKey) { return nil, BadRequest("渠道标识无效") } var channel model.Channel if err := model.DB. Where("type = ? AND status = ? AND script_code LIKE ?", "web", "enabled", "%data-id=\""+in.ChannelKey+"\"%"). First(&channel).Error; err != nil { return nil, NotFound("渠道不存在或已关闭") } name := strings.TrimSpace(in.VisitorName) if name == "" { name = "访客" } // 创建或查找客户 var customer model.Customer model.DB.Where("tenant_id = ? AND name = ? AND phone = ''", channel.TenantID, name).First(&customer) if customer.ID == 0 { customer = model.Customer{ TenantID: channel.TenantID, Name: name, Source: "网页", Status: "online", } model.DB.Create(&customer) } visitorToken, visitorTokenHash, err := model.NewVisitorToken() if err != nil { return nil, Internal("创建访客会话失败") } deviceKey := NormalizeDeviceKey(in.DeviceID, in.Meta.UserAgent) // 黑名单拦截(IP / 设备) if blocked, blKind, blReason := CheckVisitorBlacklist(channel.TenantID, in.Meta.IP, deviceKey); blocked { kindLabel := "IP" if blKind == "device" { kindLabel = "设备" } msg := "您暂时无法使用在线客服" if strings.TrimSpace(blReason) != "" { msg = msg + "(" + kindLabel + "限制)" } return nil, Forbidden(msg) } now := time.Now() // 创建会话 session := model.Session{ TenantID: channel.TenantID, ChannelID: channel.ID, CustomerID: customer.ID, VisitorTokenHash: visitorTokenHash, VisitorIP: in.Meta.IP, VisitorRegion: in.Meta.Region, UserAgent: in.Meta.UserAgent, DeviceKey: deviceKey, LandingURL: in.PageURL, LandingTitle: in.PageTitle, Referrer: in.Referrer, CurrentURL: in.PageURL, CurrentTitle: in.PageTitle, LastSeenAt: &now, Status: "waiting", Priority: "normal", } if err := model.DB.Create(&session).Error; err != nil { return nil, Internal("创建会话失败") } // 首条浏览轨迹 if in.PageURL != "" { _ = model.DB.Create(&model.VisitorPageView{ SessionID: session.ID, TenantID: channel.TenantID, URL: in.PageURL, Title: in.PageTitle, EnteredAt: now, }).Error } model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID). Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) onlineCount, err := CountOnlineAgents(channel.TenantID) if err != nil { return nil, Internal("查询在线客服失败") } setting, _ := GetOrCreateTenantSettings(channel.TenantID) withinWorkHours := IsWithinWorkHours(setting, time.Now()) // 非工作时间不自动分配,走留言/排队提示 canServeNow := onlineCount > 0 && withinWorkHours var assignedAgent *model.User if canServeNow { assignedAgent, err = TryAutoAssign(&session) if err != nil { return nil, Internal("自动分配失败") } } if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } welcomeSegs := ResolveWelcomeSegments(setting) welcome := FirstWelcomeText(welcomeSegs) offlinePrompt := DefaultOfflinePrompt agentNickname := DefaultAgentNickname displayName := "在线客服" worktimePrompt := DefaultWorktimePrompt if setting != nil { if setting.OfflinePrompt != "" { offlinePrompt = setting.OfflinePrompt } if setting.AgentNickname != "" { agentNickname = setting.AgentNickname } if setting.DisplayName != "" { displayName = setting.DisplayName } if setting.WorktimePrompt != "" { worktimePrompt = setting.WorktimePrompt } } // 非工作时间优先展示非工作时间提示 if !withinWorkHours { offlinePrompt = worktimePrompt } resp := map[string]interface{}{ "session_id": session.ID, "customer_id": customer.ID, "channel_id": channel.ID, "tenant_id": channel.TenantID, "visitor_token": visitorToken, "agents_online": canServeNow, "online_agent_count": onlineCount, "within_work_hours": withinWorkHours, "session_status": session.Status, "welcome_message": welcome, "welcome_messages": welcomeSegs, "display_name": displayName, "agent_nickname": agentNickname, } if !canServeNow { resp["offline_prompt"] = offlinePrompt } if assignedAgent != nil { resp["agent_id"] = assignedAgent.ID resp["agent_name"] = assignedAgent.Nickname } else if canServeNow { resp["agent_name"] = agentNickname } resp["landing_url"] = session.LandingURL resp["current_url"] = session.CurrentURL return resp, nil } // WidgetPageView 访客换页上报:与当前页相同只刷新活跃时间/标题,否则插入轨迹并裁剪。 func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interface{}, error) { if session.Status == "ended" || session.Status == "archived" { return nil, Conflict("会话已结束") } if pageURL == "" { return nil, BadRequest("页面地址无效") } now := time.Now() // 与当前页相同:只刷新活跃时间/标题,不插重复轨迹 if strings.TrimSpace(session.CurrentURL) == pageURL { updates := map[string]interface{}{ "last_seen_at": now, } titleFixed := false if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) { updates["current_title"] = pageTitle // 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title) var last model.VisitorPageView if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL). Order("entered_at desc").First(&last).Error; err == nil { _ = model.DB.Model(&last).Update("title", pageTitle).Error titleFixed = true if payload, err := ws.NewEvent("page_view", session.ID, map[string]interface{}{ "id": last.ID, "session_id": last.SessionID, "url": pageURL, "title": pageTitle, "entered_at": last.EnteredAt, "title_fix": true, }); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } } } _ = model.DB.Model(session).Updates(updates).Error return map[string]interface{}{"deduped": true, "title_updated": titleFixed}, nil } pv := model.VisitorPageView{ SessionID: session.ID, TenantID: session.TenantID, URL: pageURL, Title: pageTitle, EnteredAt: now, } if err := model.DB.Create(&pv).Error; err != nil { return nil, Internal("记录失败") } _ = model.DB.Model(session).Updates(map[string]interface{}{ "current_url": pageURL, "current_title": pageTitle, "last_seen_at": now, }).Error // 控制单会话条数 var count int64 model.DB.Model(&model.VisitorPageView{}).Where("session_id = ?", session.ID).Count(&count) if count > MaxPageViewsPerSession { var oldest []model.VisitorPageView model.DB.Where("session_id = ?", session.ID).Order("entered_at asc"). Limit(int(count - MaxPageViewsPerSession)).Find(&oldest) ids := make([]uint, 0, len(oldest)) for _, o := range oldest { ids = append(ids, o.ID) } if len(ids) > 0 { model.DB.Where("id IN ?", ids).Delete(&model.VisitorPageView{}) } } if payload, err := ws.NewEvent("page_view", session.ID, map[string]interface{}{ "id": pv.ID, "session_id": pv.SessionID, "url": pv.URL, "title": pv.Title, "entered_at": pv.EnteredAt, }); err == nil { // 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor, // 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。 ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } return pv, nil } // WidgetHeartbeat 刷新访客 last_seen;会话已结束时仅返回结束标记。 func WidgetHeartbeat(session *model.Session) (ended bool, lastSeen *time.Time, err error) { if session.Status == "ended" || session.Status == "archived" { return true, nil, nil } now := time.Now() _ = model.DB.Model(session).Update("last_seen_at", now).Error return false, &now, nil } // WidgetSendMessage 访客发送消息;排队中会尝试自动分配并广播。 func WidgetSendMessage(session *model.Session, msgType, content string) (*model.Message, error) { if session.Status == "ended" || session.Status == "archived" { return nil, Conflict("会话已结束,请重新发起咨询") } custID := session.CustomerID msg := model.Message{ SessionID: session.ID, SenderType: "visitor", SenderID: &custID, Content: content, Type: msgType, SentAt: time.Now(), } if err := model.CreateMessage(&msg); err != nil { return nil, Internal("发送失败") } // 发送后清空草稿 _ = 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 msgType == "text" { go OnVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content) } // 仍在排队时尝试自动分配(客服刚上线的场景) if session.Status == "waiting" && session.AgentID == nil { if _, err := TryAutoAssign(session); err == nil { _ = model.DB.First(session, session.ID) } } if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { if session.AgentID == nil { // 等待会话的消息要通知全部客服,便于任一在线客服及时领取。 ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } else { 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) } return &msg, nil } // WidgetLeaveMessageResult 离线留言结果。 type WidgetLeaveMessageResult struct { Message model.Message SessionStatus string AgentsOnline bool } // WidgetLeaveMessage 无客服在线时提交留言并沉淀联系方式。 func WidgetLeaveMessage(session *model.Session, content, name, phone, email string) (*WidgetLeaveMessageResult, error) { if session.Status == "ended" || session.Status == "archived" { return nil, Conflict("会话已结束,请重新发起咨询") } // 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库 onlineCount, _ := CountOnlineAgents(session.TenantID) if onlineCount > 0 && session.Status == "waiting" { if _, err := TryAutoAssign(session); err == nil { _ = model.DB.First(session, session.ID) } } updates := map[string]interface{}{"last_contact_at": time.Now()} if name != "" { updates["name"] = name } if phone != "" { updates["phone"] = phone } if email != "" { updates["email"] = email } if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). Updates(updates).Error; err != nil { return nil, Internal("更新联系方式失败") } custID := session.CustomerID msg := model.Message{ SessionID: session.ID, SenderType: "visitor", SenderID: &custID, Content: content, Type: "text", SentAt: time.Now(), } if err := model.CreateMessage(&msg); err != nil { return nil, Internal("留言失败") } contactBits := make([]string, 0, 3) if name != "" { contactBits = append(contactBits, "姓名:"+name) } if phone != "" { contactBits = append(contactBits, "手机:"+phone) } if email != "" { contactBits = append(contactBits, "邮箱:"+email) } model.DB.Create(&model.SessionEvent{ SessionID: session.ID, OperatorID: 0, Action: "offline_leave", Detail: "离线留言 · " + strings.Join(contactBits, " · "), }) if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { if session.AgentID == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } else { ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) } } if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } return &WidgetLeaveMessageResult{ Message: msg, SessionStatus: session.Status, AgentsOnline: onlineCount > 0, }, nil } // ValidateLeaveContact 校验留言联系方式(至少一项 + 格式)。 func ValidateLeaveContact(name, phone, email string) error { if phone == "" && email == "" { return BadRequest("请至少填写手机号或邮箱,便于我们回复") } if phone != "" && !phonePattern.MatchString(phone) { return BadRequest("手机号格式不正确") } if email != "" && !emailPattern.MatchString(email) { return BadRequest("邮箱格式不正确") } if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) { return BadRequest("姓名需为 2 至 50 个字符") } return nil } // WidgetSubmitRating 会话结束后提交星级评价(一次性)。 func WidgetSubmitRating(session *model.Session, score int, text string) error { if session.Status != "ended" { return Conflict("会话结束后才能评价") } if session.SatisfactionScore != nil { return Conflict("该会话已评价") } if err := model.DB.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Session{}).Where("id = ? AND satisfaction_score IS NULL", session.ID). Updates(map[string]interface{}{"satisfaction_score": score, "satisfaction_text": text}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return tx.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). Updates(map[string]interface{}{ "satisfaction_sum": gorm.Expr("satisfaction_sum + ?", score), "satisfaction_count": gorm.Expr("satisfaction_count + 1"), }).Error }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return Conflict("该会话已评价") } return Internal("提交评价失败") } return nil } // WidgetMessages 访客按 seq 增量拉取消息。 func WidgetMessages(session *model.Session, afterSeq, limit int) (map[string]interface{}, error) { page, err := SessionMessagePage(session.ID, afterSeq, limit) if err != nil { return nil, err } return map[string]interface{}{ "messages": page.Messages, "after_seq": page.AfterSeq, "max_seq": page.MaxSeq, "has_more": page.HasMore, "session_status": session.Status, "satisfaction_score": session.SatisfactionScore, }, nil }