Files
yml2213 8b9f3d710f refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身
- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留
- 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应
- session 875→361 行、widget 745→321 行、settings 540→73 行
- 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包
- 相关单元测试随迁 service 包,go test ./... 全部通过
2026-09-05 14:39:34 +08:00

513 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}