From 39e91bbbe068f5b0aaecbf18b36b36b83937ad75 Mon Sep 17 00:00:00 2001 From: zhangsz Date: Wed, 11 Jun 2025 17:24:41 +0800 Subject: [PATCH] feat: callback ticket features --- database/install/mf_callback_ticket.sql | 37 ++ features/cdr/cdrevent.go | 123 ++++++ features/ue/api.go | 2 + features/ue/mf_callback_ticket/controller.go | 155 ++++++++ features/ue/mf_callback_ticket/model.go | 115 ++++++ features/ue/mf_callback_ticket/service.go | 353 ++++++++++++++++++ features/ue/mf_calling/controller.go | 2 +- .../psap_ticket_monitor.go | 104 ++++++ 8 files changed, 890 insertions(+), 1 deletion(-) create mode 100755 database/install/mf_callback_ticket.sql create mode 100644 features/ue/mf_callback_ticket/controller.go create mode 100644 features/ue/mf_callback_ticket/model.go create mode 100644 features/ue/mf_callback_ticket/service.go create mode 100644 src/modules/crontask/processor/psap_ticket_monitor/psap_ticket_monitor.go diff --git a/database/install/mf_callback_ticket.sql b/database/install/mf_callback_ticket.sql new file mode 100755 index 00000000..495d28a1 --- /dev/null +++ b/database/install/mf_callback_ticket.sql @@ -0,0 +1,37 @@ +/* + Navicat Premium Data Transfer + + Source Server : omc@192.168.2.223-psap + Source Server Type : MariaDB + Source Server Version : 100622 (10.6.22-MariaDB-0ubuntu0.22.04.1) + Source Host : 192.168.2.223:33066 + Source Schema : omc_db + + Target Server Type : MariaDB + Target Server Version : 100622 (10.6.22-MariaDB-0ubuntu0.22.04.1) + File Encoding : 65001 + + Date: 04/06/2025 10:50:08 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for mf_callback_ticket +-- ---------------------------- +DROP TABLE IF EXISTS `mf_callback_ticket`; +CREATE TABLE `mf_callback_ticket` ( + `ticket_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'Ticket ID', + `caller_number` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT 'caller number', + `callee_number` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'callee number', + `status` enum('NEW','PENDING','IN_PROGRESS','FAILED','COMPLETED','TIMEOUT','RECREATED') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT 'NEW', + `agent_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT 'agent name', + `comment` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT 'comment for callback', + `msd_data` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT 'MSD data', + `created_at` bigint(20) NULL DEFAULT NULL COMMENT 'created at time', + `updated_at` bigint(20) NULL DEFAULT NULL COMMENT 'updated at time', + PRIMARY KEY (`ticket_id`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 103 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户信息表' ROW_FORMAT = Dynamic; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/features/cdr/cdrevent.go b/features/cdr/cdrevent.go index 63642072..cf223bc0 100644 --- a/features/cdr/cdrevent.go +++ b/features/cdr/cdrevent.go @@ -1,10 +1,13 @@ package cdr import ( + "encoding/json" "fmt" "net/http" "strings" + "time" + ueCallBackTicket "be.ems/features/ue/mf_callback_ticket" "be.ems/lib/config" "be.ems/lib/core/ctx" "be.ems/lib/dborm" @@ -73,5 +76,125 @@ func PostCDREventFrom(w http.ResponseWriter, r *http.Request) { } } + // MF网元类型特殊处理, 未接电话的回拨工单流转处理 + if neTypeLower == "mf" && cdrEvent.CDR["agentName"] == "" { + // 发送到MF网元 + // 构造网元MF的API地址 + url := fmt.Sprintf("http://%s:%d/ne/config/data?neType=%s&neId=%s¶mName=agents", + neInfo.IP, neInfo.Port, neInfo.NeType, neInfo.NeId) + // 发送HTTP请求获取座席列表 + resp, err := http.Get(url) + if err != nil { + log.Error("Failed to get MF agents", err) + services.ResponseInternalServerError500ProcessError(w, err) + return + } + defer resp.Body.Close() + + // 解析座席列表响应 + var agentResp struct { + Code int `json:"code"` + Data []struct { + Domain string `json:"domain"` + Index int `json:"index"` + Name string `json:"name"` + Online bool `json:"online"` + Password string `json:"password"` + } `json:"data"` + Msg string `json:"msg"` + } + + if err := json.NewDecoder(resp.Body).Decode(&agentResp); err != nil { + log.Error("Failed to decode MF agents response", err) + services.ResponseInternalServerError500ProcessError(w, err) + return + } + + // 调用服务获取最新一个被分配工单的座席和下一个要分配的座席 + mfService := ueCallBackTicket.NewCallbackTicketService() + lastAgent, err := mfService.GetLastAssignedAgent() + if err != nil { + log.Error("Failed to get last assigned agent", err) + // 可以继续执行,不返回错误 + } + + // 选择下一个要分配的座席 + selectedAgent := mfService.SelectNextAgent(agentResp.Data, lastAgent) + + // 创建回调工单 + var updatedAt *int64 = nil + ticket := ueCallBackTicket.CallbackTicket{ + CallerNumber: cdrEvent.CDR["callerParty"].(string), + CalleeNumber: cdrEvent.CDR["calledParty"].(string), + Status: ueCallBackTicket.TicketStatusNew.Enum(), + AgentName: selectedAgent, + Comment: "", + MsdData: cdrEvent.CDR["msdData"].(string), + CreatedAt: time.Now().UnixMicro(), + UpdatedAt: updatedAt, + } + if err := mfService.InsertCallbackTicket(ticket); err != nil { + log.Error("Failed to insert MF callback ticket", err) + // services.ResponseInternalServerError500ProcessError(w, err) + // return + } + } + + // MF网元类型特殊处理, 处理座席回拨的工单流转 + if neTypeLower == "mf" && cdrEvent.CDR["recordType"] == "MTC" { + // 获取座席号码(主叫)和被叫号码 + agentNumber, ok1 := cdrEvent.CDR["callerParty"].(string) + callerNumber, ok2 := cdrEvent.CDR["calledParty"].(string) + + if !ok1 || !ok2 { + log.Error("Invalid CDR format: missing callerParty or calledParty") + services.ResponseInternalServerError500ProcessError(w, fmt.Errorf("invalid CDR format")) + return + } + + // 获取通话时长 + callDuration, ok := cdrEvent.CDR["callDuration"].(float64) + if !ok { + // 尝试其他可能的类型 + if durationInt, ok := cdrEvent.CDR["callDuration"].(int); ok { + callDuration = float64(durationInt) + } else { + log.Error("Invalid CDR format: callDuration is not a number") + services.ResponseInternalServerError500ProcessError(w, fmt.Errorf("invalid CDR format")) + return + } + } + + // 通过座席号码和主叫号码查找符合条件的工单 + mfService := ueCallBackTicket.NewCallbackTicketService() + ticket, err := mfService.FindCallbackTicketByAgentAndCaller(agentNumber, callerNumber) + if err != nil { + log.Error("Failed to find callback ticket", err) + services.ResponseInternalServerError500ProcessError(w, err) + return + } + + if ticket == nil { + // 没有找到对应的工单,可能是手动呼叫,不处理 + log.Warn(fmt.Sprintf("No callback ticket found for agent %s and caller %s", agentNumber, callerNumber)) + services.ResponseStatusOK204NoContent(w) + return + } + + // 获取通话信息 + answerTime, _ := cdrEvent.CDR["answerTime"].(string) + releaseTime, _ := cdrEvent.CDR["releaseTime"].(string) + cause, _ := cdrEvent.CDR["cause"].(string) + + // 处理回拨结果并更新工单 + if err := mfService.ProcessCallbackResult(ticket, callDuration, answerTime, releaseTime, cause); err != nil { + log.Error("Failed to process callback result", err) + services.ResponseInternalServerError500ProcessError(w, err) + return + } + + log.Info(fmt.Sprintf("Successfully processed callback for ticket %d", ticket.TicketId)) + } + services.ResponseStatusOK204NoContent(w) } diff --git a/features/ue/api.go b/features/ue/api.go index f81ced35..f2531021 100644 --- a/features/ue/api.go +++ b/features/ue/api.go @@ -3,6 +3,7 @@ package ue import ( + "be.ems/features/ue/mf_callback_ticket" "be.ems/features/ue/mf_calling" "be.ems/lib/log" "github.com/gin-gonic/gin" @@ -14,5 +15,6 @@ func InitSubServiceRoute(r *gin.Engine) { mfGroup := r.Group("/psap/v1/mf") mf_calling.Register(mfGroup) + mf_callback_ticket.Register(mfGroup) // register other sub modules routes } diff --git a/features/ue/mf_callback_ticket/controller.go b/features/ue/mf_callback_ticket/controller.go new file mode 100644 index 00000000..3a9b913a --- /dev/null +++ b/features/ue/mf_callback_ticket/controller.go @@ -0,0 +1,155 @@ +package mf_callback_ticket + +import ( + "strconv" + + "be.ems/src/framework/i18n" + "be.ems/src/framework/middleware" + "be.ems/src/framework/utils/ctx" + "be.ems/src/framework/vo/result" + "github.com/gin-gonic/gin" +) + +// @Description Register Routes for mf calling +func Register(r *gin.RouterGroup) { + + mfCallingGroup := r.Group("/ticket") + { + var m *CallbackTicket + mfCallingGroup.GET("/list", + middleware.PreAuthorize(nil), + m.List, + ) + mfCallingGroup.PUT("/:ticketId", + middleware.PreAuthorize(nil), + m.Update, + ) + mfCallingGroup.DELETE("/:ticketId", + middleware.PreAuthorize(nil), + m.Delete, + ) + mfCallingGroup.GET("/:ticketId", + middleware.PreAuthorize(nil), + m.GetById, + ) + } +} + +func (m *CallbackTicket) List(c *gin.Context) { + language := ctx.AcceptLanguage(c) + neId := c.Query("neId") + if neId == "" { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + var query CallbackTicketQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + service := NewCallbackTicketService() + data, total, err := service.SelectCallbackTicket(query) + if err != nil { + c.JSON(500, result.ErrMsg(err.Error())) + return + } + + c.JSON(200, result.Ok(gin.H{ + "total": total, + "data": data, + })) +} + +// Update 更新回调工单 +func (m *CallbackTicket) Update(c *gin.Context) { + language := ctx.AcceptLanguage(c) + + // 获取路径参数 + ticketId := c.Param("ticketId") + + // 绑定请求体 + var ticket CallbackTicket + if err := c.ShouldBindJSON(&ticket); err != nil { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + // 检查路径ID和请求体ID是否一致 + if ticketId != "" { + id, err := strconv.ParseInt(ticketId, 10, 64) + if err != nil { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + ticket.TicketId = id + } + + service := NewCallbackTicketService() + if err := service.UpdateCallbackTicket(ticket); err != nil { + c.JSON(500, result.ErrMsg(err.Error())) + return + } + + c.JSON(200, result.Ok(nil)) +} + +// Delete 删除回调工单 +func (m *CallbackTicket) Delete(c *gin.Context) { + language := ctx.AcceptLanguage(c) + + // 获取路径参数 + ticketId := c.Param("ticketId") + if ticketId == "" { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + id, err := strconv.ParseInt(ticketId, 10, 64) + if err != nil { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + service := NewCallbackTicketService() + if err := service.DeleteCallbackTicket(id); err != nil { + c.JSON(500, result.ErrMsg(err.Error())) + return + } + + c.JSON(200, result.Ok(nil)) +} + +// GetById 根据ID获取回调工单 +func (m *CallbackTicket) GetById(c *gin.Context) { + language := ctx.AcceptLanguage(c) + + // 获取路径参数 + ticketId := c.Param("ticketId") + if ticketId == "" { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + id, err := strconv.ParseInt(ticketId, 10, 64) + if err != nil { + c.JSON(400, result.CodeMsg(400, i18n.TKey(language, "app.common.err400"))) + return + } + + service := NewCallbackTicketService() + ticket, err := service.SelectCallbackTicketById(id) + if err != nil { + c.JSON(500, result.ErrMsg(err.Error())) + return + } + + if ticket == nil { + c.JSON(404, result.CodeMsg(404, i18n.TKey(language, "app.common.err404"))) + return + } + + c.JSON(200, result.Ok(gin.H{ + "ticket": ticket, + })) +} diff --git a/features/ue/mf_callback_ticket/model.go b/features/ue/mf_callback_ticket/model.go new file mode 100644 index 00000000..da4beb04 --- /dev/null +++ b/features/ue/mf_callback_ticket/model.go @@ -0,0 +1,115 @@ +package mf_callback_ticket + +import ( + "fmt" + "strconv" + "strings" +) + +type TicketStatus int + +// TicketStatus 工单状态 +const ( + TicketStatusNull TicketStatus = iota // 未知状态 + TicketStatusNew + TicketStatusInProgress + // TicketStatusFailed + TicketStatusNoAnswer1 + TicketStatusNoAnswer2 + // TicketStatusBusy + // TicketStatusCancelled + TicketStatusTimeout + TicketStatusPending + // TicketStatusAnswered + // TicketStatusCompleted + TicketStatusClosed +) + +func (ts TicketStatus) Enum() string { + switch ts { + case TicketStatusNull: + return "NULL" + case TicketStatusNew: + return "NEW" + case TicketStatusInProgress: + return "IN_PROGRESS" + case TicketStatusNoAnswer1: + return "NO_ANSWER_1" + case TicketStatusNoAnswer2: + return "NO_ANSWER_2" + case TicketStatusTimeout: + return "TIMEOUT" + case TicketStatusPending: + return "PENDING" + case TicketStatusClosed: + return "CLOSED" + // 如果没有匹配的状态,返回未知状态 + default: + return "UNKNOWN" + } +} + +func (ts TicketStatus) String() string { + return fmt.Sprintf("%d", ts) +} + +// ParseCallTag 将字符串转换为 CallTag 枚举类型 +func ParseCallTag(s string) TicketStatus { + if i, err := strconv.Atoi(s); err == nil { + return TicketStatus(i) + } + // 如果转换失败,则按名称匹配(忽略大小写) + switch strings.ToLower(s) { + case "NULL": + return TicketStatusNull + case "NEW": + return TicketStatusNew + case "IN_PROGRESS": + return TicketStatusInProgress + case "NO_ANSWER_1": + return TicketStatusNoAnswer1 + case "NO_ANSWER_2": + return TicketStatusNoAnswer2 + case "TIMEOUT": + return TicketStatusTimeout + case "PENDING": + return TicketStatusPending + case "CLOSED": + return TicketStatusClosed + case "": + // 如果字符串为空,则返回未知状态 + return TicketStatusNull + default: + // 默认返回未知状态 + return TicketStatusNull + } +} + +// CallbackTicketQuery 查询条件结构体 +type CallbackTicketQuery struct { + CallerNumber string `form:"callerNumber"` + AgentName string `form:"agentName"` + Status string `form:"status"` + StartTime string `form:"startTime"` // 创建时间范围-起始 + EndTime string `form:"endTime"` // 创建时间范围-结束 + PageNum int `form:"pageNum" binding:"required"` + PageSize int `form:"pageSize" binding:"required"` +} + +// @Description VoLTE用户信息 +type CallbackTicket struct { + TicketId int64 `json:"ticketId" gorm:"column:ticket_id"` // 工单ID + CallerNumber string `json:"callerNumber" gorm:"column:caller_number"` // 主叫号码 + CalleeNumber string `json:"calleeNumber" gorm:"column:callee_number"` // 被叫号码 + Status string `json:"status" gorm:"column:status"` // 工单状态 + AgentName string `json:"agentName" gorm:"column:agent_name"` // 坐席名称 + Comment string `json:"comment" gorm:"column:comment"` // 工单备注 + MsdData string `json:"msdData" gorm:"column:msd_data"` // MSD数据 + CreatedAt int64 `json:"createdAt" gorm:"column:created_at"` // 创建时间 + UpdatedAt *int64 `json:"updatedAt" gorm:"column:updated_at;autoUpdateTime:false"` // 更新时间 +} + +// TableName 表名称 +func (*CallbackTicket) TableName() string { + return "mf_callback_ticket" +} diff --git a/features/ue/mf_callback_ticket/service.go b/features/ue/mf_callback_ticket/service.go new file mode 100644 index 00000000..4efc7a09 --- /dev/null +++ b/features/ue/mf_callback_ticket/service.go @@ -0,0 +1,353 @@ +package mf_callback_ticket + +import ( + "fmt" + "time" + + "be.ems/lib/dborm" + "gorm.io/gorm" +) + +type CallbackTicketService struct { + db *gorm.DB +} + +// 构造函数示例 +func NewCallbackTicketService() *CallbackTicketService { + db := dborm.DefaultDB() + return &CallbackTicketService{db: db} +} + +// SelectCallbackTicket 根据条件分页查询回调工单 +func (s *CallbackTicketService) SelectCallbackTicket(query CallbackTicketQuery) ([]CallbackTicket, int, error) { + var tickets []CallbackTicket + var total int64 + + db := s.db.Table("mf_callback_ticket") + + if query.CallerNumber != "" { + db = db.Where("caller_number = ?", query.CallerNumber) + } + if query.AgentName != "" { + db = db.Where("agent_name = ?", query.AgentName) + } + if query.Status != "" { + db = db.Where("status = ?", query.Status) + } + if query.StartTime != "" && query.EndTime != "" { + db = db.Where("created_at BETWEEN ? AND ?", query.StartTime, query.EndTime) + } else if query.StartTime != "" { + db = db.Where("created_at >= ?", query.StartTime) + } else if query.EndTime != "" { + db = db.Where("created_at <= ?", query.EndTime) + } + + // 统计总数 + if err := db.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count callback tickets: %w", err) + } + + // 分页查询 + offset := (query.PageNum - 1) * query.PageSize + if err := db.Limit(query.PageSize).Offset(offset).Order("created_at desc").Find(&tickets).Error; err != nil { + return nil, 0, fmt.Errorf("failed to select callback tickets: %w", err) + } + + return tickets, int(total), nil +} + +// SelectCallbackTicketByPage 分页查询回调工单 +// @Description 分页查询回调工单 +// @param page 页码 +// @param pageSize 每页大小 +// @return []MfCallbackTicket 回调工单列表 +// @return int 总记录数 +// @return error 错误信息 +// @example +// mfCallbackTicketService.SelectCallbackTicketByPage(1, 10) +func (s *CallbackTicketService) SelectCallbackTicketByPage(pageNum int, pageSize int) ([]CallbackTicket, int, error) { + var tickets []CallbackTicket + var total int64 + + // 统计总数 + if err := s.db.Table("mf_callback_ticket").Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count callback tickets: %w", err) + } + + // 分页查询 + offset := (pageNum - 1) * pageSize + if err := s.db.Table("mf_callback_ticket"). + Limit(pageSize). + Offset(offset). + Find(&tickets).Error; err != nil { + return nil, 0, fmt.Errorf("failed to select callback tickets: %w", err) + } + + return tickets, int(total), nil +} + +func (s *CallbackTicketService) InsertCallbackTicket(ticket CallbackTicket) error { + // 判断主叫号码是否已存在未处理完的工单 + var existingCount int64 + if err := s.db.Table("mf_callback_ticket"). + Where("caller_number = ? AND status IN ('NEW', 'PENDING', 'IN_PROGRESS')", ticket.CallerNumber). + Count(&existingCount).Error; err != nil { + return fmt.Errorf("failed to check existing tickets: %w", err) + } + // 如果存在未处理完的工单,返回错误 + if existingCount > 0 { + return fmt.Errorf("caller %s already has a pending ticket", ticket.CallerNumber) + } + // 这里可以使用ORM或其他方式将ticket插入到数据库中 + if err := s.db.Table("mf_callback_ticket").Create(&ticket).Error; err != nil { + return fmt.Errorf("failed to insert callback ticket: %w", err) + } + return nil +} + +// SelectCallbackTicketById 根据工单ID查询回调工单 +// @Description 根据工单ID查询回调工单 +// @param ticketId 工单ID +// @return *CallbackTicket 回调工单对象 +// @return error 错误信息 +// @example +// mfCallbackTicketService.SelectCallbackTicketById(12345) +func (s *CallbackTicketService) SelectCallbackTicketById(ticketId int64) (*CallbackTicket, error) { + var ticket CallbackTicket + if err := s.db.Table("mf_callback_ticket"). + Where("ticket_id = ?", ticketId). + First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("callback ticket with ID %d not found", ticketId) + } + return nil, fmt.Errorf("failed to select callback ticket: %w", err) + } + return &ticket, nil +} + +// UpdateCallbackTicket 更新回调工单 +// @Description 更新回调工单 +// @param ticket 回调工单对象 +// @return error 错误信息 +// @example +// mfCallbackTicketService.UpdateCallbackTicket(ticket) +func (s *CallbackTicketService) UpdateCallbackTicket(ticket CallbackTicket) error { + if err := s.db.Table("mf_callback_ticket"). + Where("ticket_id = ?", ticket.TicketId). + Updates(ticket).Error; err != nil { + return fmt.Errorf("failed to update callback ticket: %w", err) + } + return nil +} + +// DeleteCallbackTicket 删除回调工单 +// @Description 删除回调工单 +// @param ticketId 工单ID +// @return error 错误信息 +// @example +// mfCallbackTicketService.DeleteCallbackTicket(12345) +func (s *CallbackTicketService) DeleteCallbackTicket(ticketId int64) error { + if err := s.db.Table("mf_callback_ticket"). + Where("ticket_id = ?", ticketId). + Delete(&CallbackTicket{}).Error; err != nil { + return fmt.Errorf("failed to delete callback ticket: %w", err) + } + return nil +} + +// GetLastAssignedAgent 获取最近一个分配的座席名称 +// @Description 获取最近分配工单的座席名称 +// @return string 座席名称 +// @return error 错误信息 +func (s *CallbackTicketService) GetLastAssignedAgent() (string, error) { + var lastTicket CallbackTicket + if err := s.db.Table("mf_callback_ticket"). + Where("agent_name <> ''"). + Order("created_at DESC"). + First(&lastTicket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // 没有记录属于正常情况,返回空字符串 + return "", nil + } + return "", fmt.Errorf("failed to query last ticket: %w", err) + } + + return lastTicket.AgentName, nil +} + +// SelectNextAgent 根据上一个座席和座席列表选择下一个座席 +// @Description 选择下一个要分配的座席 +// @param agents 座席列表 +// @param lastAgentName 上一个座席名称 +// @return string 下一个座席名称 +func (s *CallbackTicketService) SelectNextAgent(agents []struct { + Domain string `json:"domain"` + Index int `json:"index"` + Name string `json:"name"` + Online bool `json:"online"` + Password string `json:"password"` +}, lastAgentName string) string { + if len(agents) == 0 { + return "" + } + + // 默认选第一个座席 + selectedAgent := agents[0].Name + + // 如果没有上一个座席,直接返回第一个 + if lastAgentName == "" { + return selectedAgent + } + + // 找到上一个座席的下一个 + foundLastAgent := false + for i, agent := range agents { + if foundLastAgent { + // 找到上一个座席的下一个 + return agent.Name + } + if agent.Name == lastAgentName { + foundLastAgent = true + // 如果是最后一个座席,则循环回第一个 + if i == len(agents)-1 { + return agents[0].Name + } + } + } + + // 如果没找到上一个座席(可能被删除了),使用第一个座席 + return selectedAgent +} + +// FindCallbackTicketByAgentAndCaller 通过座席号码和主叫号码查找符合条件的工单 +// @Description 通过座席号码和主叫号码查找符合条件的工单 +// @param agentName 座席号码 +// @param callerNumber 主叫号码 +// @return *CallbackTicket 回调工单对象 +// @return error 错误信息 +func (s *CallbackTicketService) FindCallbackTicketByAgentAndCaller(agentName string, callerNumber string) (*CallbackTicket, error) { + var ticket CallbackTicket + + if err := s.db.Table("mf_callback_ticket"). + Where("agent_name = ? AND caller_number = ? AND status IN ('NEW', 'PENDING', 'IN_PROGRESS', 'NO_ANSWER_1', 'NO_ANSWER_2')", + agentName, callerNumber). + Order("created_at DESC"). + First(&ticket).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // 没有找到记录,返回nil, nil + } + return nil, fmt.Errorf("failed to find callback ticket: %w", err) + } + + return &ticket, nil +} + +// ProcessCallbackResult 处理回拨结果并更新工单 +// @Description 处理回拨结果并更新工单状态 +// @param ticket 要更新的工单 +// @param callDuration 通话时长 +// @param answerTime 应答时间 +// @param releaseTime 释放时间 +// @param cause 释放原因 +// @return error 错误信息 +func (s *CallbackTicketService) ProcessCallbackResult(ticket *CallbackTicket, callDuration float64, answerTime string, releaseTime string, cause string) error { + if ticket == nil { + return fmt.Errorf("ticket cannot be nil") + } + + // 构建更新信息 + now := time.Now().UnixMicro() + updatedTicket := CallbackTicket{ + TicketId: ticket.TicketId, + UpdatedAt: &now, + } + + // 构建评论内容 + comment := fmt.Sprintf("回拨时间: %s, 释放时间: %s, 原因: %s", answerTime, releaseTime, cause) + + // 根据通话时长判断处理结果 + if callDuration > 0 { + // 通话已接通,标记为已处理完毕 + updatedTicket.Status = TicketStatusClosed.Enum() + updatedTicket.Comment = fmt.Sprintf("%s, 通话时长: %.1f秒, 状态: 已接通并完成", comment, callDuration) + } else { + // 通话未接通,根据当前状态修改为相应状态 + switch ticket.Status { + case TicketStatusNew.Enum(), TicketStatusInProgress.Enum(): + // 第一次未接通 + updatedTicket.Status = TicketStatusNoAnswer1.Enum() + updatedTicket.Comment = fmt.Sprintf("%s, 通话时长: %.1f秒, 状态: 第一次未接通", comment, callDuration) + case TicketStatusNoAnswer1.Enum(): + // 第二次未接通 + updatedTicket.Status = TicketStatusNoAnswer2.Enum() + updatedTicket.Comment = fmt.Sprintf("%s, 通话时长: %.1f秒, 状态: 第二次未接通", comment, callDuration) + case TicketStatusNoAnswer2.Enum(): + // 第三次未接通,挂起 + updatedTicket.Status = TicketStatusPending.Enum() + updatedTicket.Comment = fmt.Sprintf("%s, 通话时长: %.1f秒, 状态: 多次未接通,工单挂起", comment, callDuration) + default: + // 其他状态保持不变 + updatedTicket.Status = ticket.Status + updatedTicket.Comment = fmt.Sprintf("%s, 通话时长: %.1f秒, 状态: 未知状态", comment, callDuration) + } + } + + // 更新工单 + if err := s.db.Table("mf_callback_ticket"). + Where("ticket_id = ?", ticket.TicketId). + Updates(updatedTicket).Error; err != nil { + return fmt.Errorf("failed to update callback ticket: %w", err) + } + + return nil +} + +// FindTimeoutTickets 查询指定状态的超时工单 +// @Description 查询指定状态且超过指定时间未处理的工单 +// @param status 工单状态 +// @param timeoutMicros 超时时间(微秒) +// @return []CallbackTicket 超时工单列表 +// @return error 错误信息 +func (s *CallbackTicketService) FindTimeoutTickets(status string, timeoutMicros int64) ([]CallbackTicket, error) { + // 计算超时时间点 + nowMicros := time.Now().UnixMicro() + timeoutBeforeMicros := nowMicros - timeoutMicros + + // 查询超时的工单 + var tickets []CallbackTicket + if err := s.db.Table("mf_callback_ticket"). + Where("status = ? AND created_at < ? AND (updated_at IS NULL OR updated_at < ?)", + status, timeoutBeforeMicros, timeoutBeforeMicros). + Find(&tickets).Error; err != nil { + return nil, fmt.Errorf("查询超时工单失败: %w", err) + } + + return tickets, nil +} + +// UpdateTicketToTimeout 将工单状态更新为超时 +// @Description 将指定工单更新为超时状态,并添加备注信息 +// @param ticket 要更新的工单 +// @param originalStatus 原工单状态 +// @return error 错误信息 +func (s *CallbackTicketService) UpdateTicketToTimeout(ticket *CallbackTicket, originalStatus string) error { + if ticket == nil { + return fmt.Errorf("ticket cannot be nil") + } + + now := time.Now().UnixMicro() + updatedTicket := CallbackTicket{ + TicketId: ticket.TicketId, + Status: TicketStatusTimeout.Enum(), + Comment: fmt.Sprintf("%s - 工单状态为 %s 处理超时,系统自动更新为超时状态", ticket.Comment, originalStatus), + UpdatedAt: &now, + } + + if err := s.db.Table("mf_callback_ticket"). + Where("ticket_id = ?", ticket.TicketId). + Updates(updatedTicket).Error; err != nil { + return fmt.Errorf("更新工单 %d 状态失败: %w", ticket.TicketId, err) + } + + return nil +} diff --git a/features/ue/mf_calling/controller.go b/features/ue/mf_calling/controller.go index c757cea9..ae8f0ef6 100644 --- a/features/ue/mf_calling/controller.go +++ b/features/ue/mf_calling/controller.go @@ -16,7 +16,7 @@ func Register(r *gin.RouterGroup) { mfCallingGroup := r.Group("/callings") { var m *MfCallingInfo - mfCallingGroup.GET("/:neId/list", + mfCallingGroup.GET("/list", middleware.PreAuthorize(nil), m.List, ) diff --git a/src/modules/crontask/processor/psap_ticket_monitor/psap_ticket_monitor.go b/src/modules/crontask/processor/psap_ticket_monitor/psap_ticket_monitor.go new file mode 100644 index 00000000..c48cfe71 --- /dev/null +++ b/src/modules/crontask/processor/psap_ticket_monitor/psap_ticket_monitor.go @@ -0,0 +1,104 @@ +package psap_ticket_monitor + +import ( + ueCallBackTicket "be.ems/features/ue/mf_callback_ticket" + "be.ems/lib/log" + "be.ems/src/framework/cron" + "be.ems/src/framework/logger" +) + +var NewProcessor = &PsapTicketMonitor{ + callbackTicketService: ueCallBackTicket.NewCallbackTicketService(), + count: 0, +} + +// PsapTicketMonitor 工单处理超时监控 +type PsapTicketMonitor struct { + callbackTicketService *ueCallBackTicket.CallbackTicketService // 回调工单服务 + count int // 执行次数 +} + +func (s *PsapTicketMonitor) Execute(data any) (any, error) { + s.count++ // 执行次数加一 + options := data.(cron.JobData) + sysJob := options.SysJob + logger.Infof("执行工单监控任务 %v 任务ID %s", options.Repeat, sysJob.JobID) + + // 返回结果,用于记录执行结果 + result := map[string]any{ + "count": s.count, + } + + // 处理超时的NEW状态工单 (30分钟) + newTicketsUpdated, err := s.handleTimeoutTickets( + ueCallBackTicket.TicketStatusNew.Enum(), + 30*60*1000000, // 30分钟(微秒) + ) + if err != nil { + logger.Errorf("处理NEW状态超时工单失败: %v", err) + } + result["newTicketsUpdated"] = newTicketsUpdated + + // 处理超时的IN_PROGRESS状态工单 (60分钟) + inProgressTicketsUpdated, err := s.handleTimeoutTickets( + ueCallBackTicket.TicketStatusInProgress.Enum(), + 60*60*1000000, // 60分钟(微秒) + ) + if err != nil { + logger.Errorf("处理IN_PROGRESS状态超时工单失败: %v", err) + } + result["inProgressTicketsUpdated"] = inProgressTicketsUpdated + + // 处理超时的NO_ANSWER_1状态工单 (4小时) + noAnswer1TicketsUpdated, err := s.handleTimeoutTickets( + ueCallBackTicket.TicketStatusNoAnswer1.Enum(), + 4*60*60*1000000, // 4小时(微秒) + ) + if err != nil { + logger.Errorf("处理NO_ANSWER_1状态超时工单失败: %v", err) + } + result["noAnswer1TicketsUpdated"] = noAnswer1TicketsUpdated + + // 处理超时的NO_ANSWER_2状态工单 (8小时) + noAnswer2TicketsUpdated, err := s.handleTimeoutTickets( + ueCallBackTicket.TicketStatusNoAnswer2.Enum(), + 8*60*60*1000000, // 8小时(微秒) + ) + if err != nil { + logger.Errorf("处理NO_ANSWER_2状态超时工单失败: %v", err) + } + result["noAnswer2TicketsUpdated"] = noAnswer2TicketsUpdated + + // 汇总结果 + totalUpdated := newTicketsUpdated + inProgressTicketsUpdated + noAnswer1TicketsUpdated + noAnswer2TicketsUpdated + result["totalUpdated"] = totalUpdated + + logger.Infof("工单监控任务完成,共处理 %d 个超时工单", totalUpdated) + return result, nil +} + +// handleTimeoutTickets 处理指定状态的超时工单 +func (s *PsapTicketMonitor) handleTimeoutTickets(status string, timeoutMicros int64) (int, error) { + // 查询超时的工单 + tickets, err := s.callbackTicketService.FindTimeoutTickets(status, timeoutMicros) + if err != nil { + return 0, err + } + + if len(tickets) == 0 { + return 0, nil // 没有超时工单 + } + + // 更新超时工单状态 + var updatedCount int + for _, ticket := range tickets { + if err := s.callbackTicketService.UpdateTicketToTimeout(&ticket, status); err != nil { + log.Errorf("更新工单 %d 状态失败: %v", ticket.TicketId, err) + continue + } + updatedCount++ + log.Infof("工单 %d 已更新为超时状态 (原状态: %s)", ticket.TicketId, status) + } + + return updatedCount, nil +}