410 lines
12 KiB
Go
410 lines
12 KiB
Go
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.HasAnyRole(c, "admin", "supervisor") {
|
||
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 uint, r statsRange) ([]model.Session, []model.Message, error) {
|
||
var sessions []model.Session
|
||
q := model.DB.Where("tenant_id = ?", tenantID)
|
||
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), 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))
|
||
// 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 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), 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)
|
||
sessions, messages, err := loadStatisticsData(tenantID, r)
|
||
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
|
||
}
|
||
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, 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
|
||
}
|
||
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)
|
||
}
|