Files
kefu_cloud/server/internal/handler/statistics.go
T

423 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"sort"
"strings"
"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.HasPermission(c, "statistics.view") {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"})
return false
}
// statsRange 统计时间窗:[From, To) 本地日界
type statsRange struct {
From time.Time
To time.Time // exclusive
}
// parseStatsRange 支持 from&to=YYYY-MM-DD,或 period=today|week|month(默认 week
func parseStatsRange(c *gin.Context) (statsRange, error) {
loc := time.Now().Location()
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
fromStr := strings.TrimSpace(c.Query("from"))
toStr := strings.TrimSpace(c.Query("to"))
if fromStr != "" && toStr != "" {
fromDay, err1 := time.ParseInLocation("2006-01-02", fromStr, loc)
toDay, err2 := time.ParseInLocation("2006-01-02", toStr, loc)
if err1 != nil || err2 != nil {
return statsRange{}, errStr("日期格式应为 YYYY-MM-DD")
}
if toDay.Before(fromDay) {
return statsRange{}, errStr("结束日期不能早于开始日期")
}
// 最多 366 天
if toDay.Sub(fromDay) > 366*24*time.Hour {
return statsRange{}, errStr("日期区间最长 366 天")
}
return statsRange{From: fromDay, To: toDay.AddDate(0, 0, 1)}, nil
}
period := strings.TrimSpace(c.DefaultQuery("period", "week"))
switch period {
case "today":
return statsRange{From: today, To: today.AddDate(0, 0, 1)}, nil
case "month":
// 本自然月
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
nextMonth := monthStart.AddDate(0, 1, 0)
return statsRange{From: monthStart, To: nextMonth}, nil
case "week", "day":
// 近 7 天(含今天)
return statsRange{From: today.AddDate(0, 0, -6), To: today.AddDate(0, 0, 1)}, nil
default:
return statsRange{From: today.AddDate(0, 0, -6), To: today.AddDate(0, 0, 1)}, nil
}
}
func loadStatisticsData(tenantID, userID uint, allData bool, r statsRange) ([]model.Session, []model.Message, error) {
var sessions []model.Session
q := model.DB.Where("tenant_id = ?", tenantID)
if !allData {
q = q.Where("agent_id = ?", userID)
}
if !r.From.IsZero() {
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
}
if err := q.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
}
r, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
sessions, messages, err := loadStatisticsData(
middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
)
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
}
r, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
// 是否有自定义区间:有 from/to 时按日粒度;本月预置可按月展示近 6 月
hasCustom := strings.TrimSpace(c.Query("from")) != "" && strings.TrimSpace(c.Query("to")) != ""
period := strings.TrimSpace(c.DefaultQuery("period", "week"))
var sessions []model.Session
q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c))
if !middleware.CanAccessAllData(c, "statistics") {
q = q.Where("agent_id = ?", middleware.GetUserID(c))
}
// 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势
if hasCustom || period == "today" || period == "week" || period == "day" {
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
}
if err := q.Find(&sessions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"})
return
}
data := make([]gin.H, 0)
if !hasCustom && period == "month" {
// 近 6 个自然月
now := time.Now()
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 {
// 按日:从 From 到 To(不含)
counts := make(map[string]int)
for _, session := range sessions {
counts[session.CreatedAt.Format("2006-01-02")]++
}
for d := r.From; d.Before(r.To); d = d.AddDate(0, 0, 1) {
key := d.Format("2006-01-02")
label := d.Format("01-02")
// 跨年区间显示完整日期
if r.To.Sub(r.From) > 180*24*time.Hour || d.Year() != time.Now().Year() {
label = d.Format("2006-01-02")
}
data = append(data, gin.H{"date": label, "count": counts[key]})
}
}
middleware.JSON(c, data)
}
func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
if !requireStatisticsAccess(c) {
return
}
r, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
_, messages, err := loadStatisticsData(
middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
)
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
}
r, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
tenantID := middleware.GetTenantID(c)
allData := middleware.CanAccessAllData(c, "statistics")
sessions, messages, err := loadStatisticsData(tenantID, middleware.GetUserID(c), allData, r)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
return
}
var agents []model.User
agentQuery := model.DB.Where("tenant_id = ?", tenantID)
if !allData {
agentQuery = agentQuery.Where("id = ?", middleware.GetUserID(c))
}
if err := agentQuery.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
}
r, err := parseStatsRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
tenantID := middleware.GetTenantID(c)
sessions, _, err := loadStatisticsData(
tenantID, middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
return
}
var channels []model.Channel
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)
}