修复会话安全与实时消息
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
@@ -10,69 +14,308 @@ 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) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
|
||||
var totalSessions, totalMessages int64
|
||||
var avgResponseTime float64
|
||||
var satisfactionAvg float64
|
||||
var firstResolveRate float64
|
||||
|
||||
model.DB.Model(&model.Session{}).Where("tenant_id = ?", tenantID).Count(&totalSessions)
|
||||
model.DB.Model(&model.Message{}).
|
||||
Joins("JOIN sessions ON messages.session_id = sessions.id").
|
||||
Where("sessions.tenant_id = ?", tenantID).
|
||||
Count(&totalMessages)
|
||||
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": totalSessions,
|
||||
"avg_response_time": avgResponseTime,
|
||||
"total_sessions": len(sessions),
|
||||
"avg_response_time": avgResponse,
|
||||
"satisfaction_avg": satisfactionAvg,
|
||||
"first_resolve_rate": firstResolveRate,
|
||||
"total_messages": totalMessages,
|
||||
"first_resolve_rate": firstResolveRate(sessions),
|
||||
"total_messages": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
period := c.DefaultQuery("period", "day")
|
||||
|
||||
_ = tenantID
|
||||
var data []gin.H
|
||||
if period == "day" {
|
||||
data = []gin.H{
|
||||
{"date": "07-08", "count": 420}, {"date": "07-09", "count": 380},
|
||||
{"date": "07-10", "count": 450}, {"date": "07-11", "count": 520},
|
||||
{"date": "07-12", "count": 490}, {"date": "07-13", "count": 550},
|
||||
{"date": "07-14", "count": 610},
|
||||
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 {
|
||||
data = []gin.H{
|
||||
{"date": "06", "count": 12500}, {"date": "07", "count": 13800},
|
||||
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)
|
||||
_ = tenantID
|
||||
|
||||
data := []gin.H{
|
||||
{"name": "客服小王", "conversations": 420, "avg_response": 28, "satisfaction": 4.9},
|
||||
{"name": "客服小李", "conversations": 380, "avg_response": 35, "satisfaction": 4.7},
|
||||
{"name": "客服小张", "conversations": 350, "avg_response": 42, "satisfaction": 4.5},
|
||||
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) {
|
||||
data := []gin.H{
|
||||
{"type": "网页", "value": 45}, {"type": "微信", "value": 28},
|
||||
{"type": "APP", "value": 18}, {"type": "电话", "value": 6}, {"type": "邮件", "value": 3},
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user