修复会话安全与实时消息

This commit is contained in:
yml2213
2026-07-14 15:06:01 +08:00
parent 064af29d3b
commit 8cff2a5824
29 changed files with 1961 additions and 546 deletions
+282 -39
View File
@@ -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)
}