refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身
- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留 - 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应 - session 875→361 行、widget 745→321 行、settings 540→73 行 - 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包 - 相关单元测试随迁 service 包,go test ./... 全部通过
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user