322 lines
9.4 KiB
Go
322 lines
9.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"kefu-cloud/server/internal/middleware"
|
|
"kefu-cloud/server/internal/model"
|
|
)
|
|
|
|
type StatisticsHandler struct{}
|
|
|
|
func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} }
|
|
|
|
func requireStatisticsAccess(c *gin.Context) bool {
|
|
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
|
return true
|
|
}
|
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"})
|
|
return false
|
|
}
|
|
|
|
func loadStatisticsData(tenantID uint) ([]model.Session, []model.Message, error) {
|
|
var sessions []model.Session
|
|
if err := model.DB.Where("tenant_id = ?", tenantID).Order("created_at asc").Find(&sessions).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(sessions) == 0 {
|
|
return sessions, []model.Message{}, nil
|
|
}
|
|
ids := make([]uint, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
ids = append(ids, session.ID)
|
|
}
|
|
var messages []model.Message
|
|
if err := model.DB.Where("session_id IN ?", ids).Order("session_id asc, seq asc").Find(&messages).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return sessions, messages, nil
|
|
}
|
|
|
|
func firstResponseSeconds(messages []model.Message) map[uint]float64 {
|
|
visitorFirst := make(map[uint]time.Time)
|
|
responseSeconds := make(map[uint]float64)
|
|
for _, message := range messages {
|
|
if message.SenderType == "visitor" {
|
|
if _, exists := visitorFirst[message.SessionID]; !exists {
|
|
visitorFirst[message.SessionID] = message.SentAt
|
|
}
|
|
continue
|
|
}
|
|
if message.SenderType != "agent" {
|
|
continue
|
|
}
|
|
firstVisitorAt, hasVisitor := visitorFirst[message.SessionID]
|
|
if !hasVisitor || !message.SentAt.After(firstVisitorAt) {
|
|
continue
|
|
}
|
|
if _, exists := responseSeconds[message.SessionID]; !exists {
|
|
responseSeconds[message.SessionID] = message.SentAt.Sub(firstVisitorAt).Seconds()
|
|
}
|
|
}
|
|
return responseSeconds
|
|
}
|
|
|
|
func firstResolveRate(sessions []model.Session) float64 {
|
|
endedCount := 0
|
|
resolvedCount := 0
|
|
for index, session := range sessions {
|
|
if session.Status != "ended" {
|
|
continue
|
|
}
|
|
endedCount++
|
|
if session.EndReason != "resolved" && session.EndReason != "已解决" {
|
|
continue
|
|
}
|
|
endedAt := session.CreatedAt
|
|
if session.EndedAt != nil {
|
|
endedAt = *session.EndedAt
|
|
}
|
|
reopened := false
|
|
for _, later := range sessions[index+1:] {
|
|
if later.CustomerID == session.CustomerID && later.CreatedAt.After(endedAt) && later.CreatedAt.Sub(endedAt) <= 24*time.Hour {
|
|
reopened = true
|
|
break
|
|
}
|
|
}
|
|
if !reopened {
|
|
resolvedCount++
|
|
}
|
|
}
|
|
if endedCount == 0 {
|
|
return 0
|
|
}
|
|
return float64(resolvedCount) * 100 / float64(endedCount)
|
|
}
|
|
|
|
func (h *StatisticsHandler) KPIs(c *gin.Context) {
|
|
if !requireStatisticsAccess(c) {
|
|
return
|
|
}
|
|
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
|
|
return
|
|
}
|
|
responses := firstResponseSeconds(messages)
|
|
var responseTotal float64
|
|
for _, seconds := range responses {
|
|
responseTotal += seconds
|
|
}
|
|
var satisfactionTotal float64
|
|
satisfactionCount := 0
|
|
for _, session := range sessions {
|
|
if session.SatisfactionScore != nil {
|
|
satisfactionTotal += float64(*session.SatisfactionScore)
|
|
satisfactionCount++
|
|
}
|
|
}
|
|
avgResponse := float64(0)
|
|
if len(responses) > 0 {
|
|
avgResponse = responseTotal / float64(len(responses))
|
|
}
|
|
satisfactionAvg := float64(0)
|
|
if satisfactionCount > 0 {
|
|
satisfactionAvg = satisfactionTotal / float64(satisfactionCount)
|
|
}
|
|
|
|
middleware.JSON(c, gin.H{
|
|
"total_sessions": len(sessions),
|
|
"avg_response_time": avgResponse,
|
|
"satisfaction_avg": satisfactionAvg,
|
|
"first_resolve_rate": firstResolveRate(sessions),
|
|
"total_messages": len(messages),
|
|
})
|
|
}
|
|
|
|
func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
|
if !requireStatisticsAccess(c) {
|
|
return
|
|
}
|
|
period := c.DefaultQuery("period", "day")
|
|
var sessions []model.Session
|
|
if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"})
|
|
return
|
|
}
|
|
now := time.Now()
|
|
data := make([]gin.H, 0)
|
|
if period == "today" {
|
|
count := 0
|
|
for _, session := range sessions {
|
|
if session.CreatedAt.Format("2006-01-02") == now.Format("2006-01-02") {
|
|
count++
|
|
}
|
|
}
|
|
data = append(data, gin.H{"date": now.Format("01-02"), "count": count})
|
|
} else if period == "month" {
|
|
counts := make(map[string]int)
|
|
for _, session := range sessions {
|
|
counts[session.CreatedAt.Format("2006-01")]++
|
|
}
|
|
for offset := 5; offset >= 0; offset-- {
|
|
month := time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, now.Location())
|
|
key := month.Format("2006-01")
|
|
data = append(data, gin.H{"date": key, "count": counts[key]})
|
|
}
|
|
} else {
|
|
counts := make(map[string]int)
|
|
for _, session := range sessions {
|
|
counts[session.CreatedAt.Format("01-02")]++
|
|
}
|
|
for offset := 6; offset >= 0; offset-- {
|
|
day := now.AddDate(0, 0, -offset)
|
|
key := day.Format("01-02")
|
|
data = append(data, gin.H{"date": key, "count": counts[key]})
|
|
}
|
|
}
|
|
middleware.JSON(c, data)
|
|
}
|
|
|
|
func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
|
|
if !requireStatisticsAccess(c) {
|
|
return
|
|
}
|
|
_, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"})
|
|
return
|
|
}
|
|
counts := map[string]int{"0-10s": 0, "10-30s": 0, "30-60s": 0, "1-3min": 0, ">3min": 0}
|
|
for _, seconds := range firstResponseSeconds(messages) {
|
|
switch {
|
|
case seconds <= 10:
|
|
counts["0-10s"]++
|
|
case seconds <= 30:
|
|
counts["10-30s"]++
|
|
case seconds <= 60:
|
|
counts["30-60s"]++
|
|
case seconds <= 180:
|
|
counts["1-3min"]++
|
|
default:
|
|
counts[">3min"]++
|
|
}
|
|
}
|
|
data := []gin.H{}
|
|
for _, key := range []string{"0-10s", "10-30s", "30-60s", "1-3min", ">3min"} {
|
|
data = append(data, gin.H{"range": key, "count": counts[key]})
|
|
}
|
|
middleware.JSON(c, data)
|
|
}
|
|
|
|
func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
|
if !requireStatisticsAccess(c) {
|
|
return
|
|
}
|
|
tenantID := middleware.GetTenantID(c)
|
|
sessions, messages, err := loadStatisticsData(tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
|
|
return
|
|
}
|
|
var agents []model.User
|
|
if err := model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
|
return
|
|
}
|
|
responses := firstResponseSeconds(messages)
|
|
type performance struct {
|
|
ID uint
|
|
Name string
|
|
Conversations int
|
|
ResponseTotal float64
|
|
ResponseCount int
|
|
Satisfaction float64
|
|
RatingCount int
|
|
}
|
|
items := make(map[uint]*performance)
|
|
for _, agent := range agents {
|
|
items[agent.ID] = &performance{ID: agent.ID, Name: agent.Nickname}
|
|
}
|
|
for _, session := range sessions {
|
|
if session.AgentID == nil || items[*session.AgentID] == nil {
|
|
continue
|
|
}
|
|
item := items[*session.AgentID]
|
|
item.Conversations++
|
|
if seconds, exists := responses[session.ID]; exists {
|
|
item.ResponseTotal += seconds
|
|
item.ResponseCount++
|
|
}
|
|
if session.SatisfactionScore != nil {
|
|
item.Satisfaction += float64(*session.SatisfactionScore)
|
|
item.RatingCount++
|
|
}
|
|
}
|
|
values := make([]*performance, 0, len(items))
|
|
for _, item := range items {
|
|
values = append(values, item)
|
|
}
|
|
sort.Slice(values, func(i, j int) bool { return values[i].Conversations > values[j].Conversations })
|
|
data := make([]gin.H, 0, len(values))
|
|
for _, item := range values {
|
|
avgResponse := float64(0)
|
|
if item.ResponseCount > 0 {
|
|
avgResponse = item.ResponseTotal / float64(item.ResponseCount)
|
|
}
|
|
satisfaction := float64(0)
|
|
if item.RatingCount > 0 {
|
|
satisfaction = item.Satisfaction / float64(item.RatingCount)
|
|
}
|
|
data = append(data, gin.H{"name": item.Name, "conversations": item.Conversations, "avg_response": avgResponse, "satisfaction": satisfaction})
|
|
}
|
|
middleware.JSON(c, data)
|
|
}
|
|
|
|
func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
|
if !requireStatisticsAccess(c) {
|
|
return
|
|
}
|
|
tenantID := middleware.GetTenantID(c)
|
|
var sessions []model.Session
|
|
var channels []model.Channel
|
|
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&sessions).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
|
return
|
|
}
|
|
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
|
return
|
|
}
|
|
channelTypes := make(map[uint]string)
|
|
for _, channel := range channels {
|
|
channelTypes[channel.ID] = channel.Type
|
|
}
|
|
labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"}
|
|
counts := make(map[string]int)
|
|
for _, session := range sessions {
|
|
channelType := channelTypes[session.ChannelID]
|
|
if channelType == "" {
|
|
channelType = "unknown"
|
|
}
|
|
counts[channelType]++
|
|
}
|
|
keys := make([]string, 0, len(counts))
|
|
for key := range counts {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
data := make([]gin.H, 0, len(keys))
|
|
for _, key := range keys {
|
|
label := labels[key]
|
|
if label == "" {
|
|
label = key
|
|
}
|
|
data = append(data, gin.H{"type": label, "value": counts[key]})
|
|
}
|
|
middleware.JSON(c, data)
|
|
}
|