package handler import ( "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" ) // Export 导出客户列表 CSV(沿用列表筛选与坐席可见范围)。 // GET /api/customers/export func (h *CustomerHandler) Export(c *gin.Context) { tenantID := middleware.GetTenantID(c) search := c.Query("search") status := c.Query("status") source := c.Query("source") query := model.DB.Where("tenant_id = ?", tenantID) if middleware.GetRole(c) == "agent" { assignedCustomers := model.DB.Model(&model.Session{}). Select("customer_id"). Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) query = query.Where("id IN (?)", assignedCustomers) } if search != "" { query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%") } if status != "" { query = query.Where("status = ?", status) } if source != "" { query = query.Where("source = ?", source) } var customers []model.Customer if err := query.Order("updated_at desc").Limit(exportMaxRows).Find(&customers).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出客户失败"}) return } header := []string{"ID", "姓名", "手机", "邮箱", "来源", "状态", "标签", "对话次数", "最近联系", "创建时间"} rows := make([][]string, 0, len(customers)) for _, cu := range customers { rows = append(rows, []string{ strconv.FormatUint(uint64(cu.ID), 10), cu.Name, cu.Phone, cu.Email, cu.Source, cu.Status, cu.Tags, strconv.Itoa(cu.ConversationCount), formatCSVTimePtr(cu.LastContactAt), formatCSVTime(cu.CreatedAt), }) } name := fmt.Sprintf("customers_%s.csv", time.Now().Format("20060102_150405")) writeCSVResponse(c, name, header, rows) } // Export 导出会话列表 CSV(沿用列表筛选与权限)。 // GET /api/sessions/export func (h *SessionHandler) Export(c *gin.Context) { tenantID := middleware.GetTenantID(c) status := c.Query("status") priority := c.Query("priority") agentID := c.Query("agent_id") channelID := c.Query("channel_id") search := strings.TrimSpace(c.Query("search")) from := c.Query("from") to := c.Query("to") query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) if middleware.GetRole(c) == "agent" { query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting") } if status != "" { query = query.Where("sessions.status = ?", status) } if priority != "" { query = query.Where("sessions.priority = ?", priority) } if agentID != "" { query = query.Where("sessions.agent_id = ?", agentID) } if channelID != "" { query = query.Where("sessions.channel_id = ?", channelID) } if from != "" { if t, err := time.ParseInLocation("2006-01-02", from, time.Local); err == nil { query = query.Where("sessions.created_at >= ?", t) } } if to != "" { if t, err := time.ParseInLocation("2006-01-02", to, time.Local); err == nil { query = query.Where("sessions.created_at < ?", t.Add(24*time.Hour)) } } if search != "" { like := "%" + search + "%" query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id"). Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like) } var sessions []model.Session if err := query.Order("sessions.created_at desc").Limit(exportMaxRows).Find(&sessions).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出会话失败"}) return } customerIDs := make([]uint, 0) agentIDs := make([]uint, 0) channelIDs := make([]uint, 0) sessionIDs := make([]uint, 0, len(sessions)) for _, s := range sessions { sessionIDs = append(sessionIDs, s.ID) customerIDs = append(customerIDs, s.CustomerID) channelIDs = append(channelIDs, s.ChannelID) if s.AgentID != nil { agentIDs = append(agentIDs, *s.AgentID) } } customerMap := map[uint]model.Customer{} if len(customerIDs) > 0 { var customers []model.Customer model.DB.Where("id IN ?", uniqueUint(customerIDs)).Find(&customers) for _, cu := range customers { customerMap[cu.ID] = cu } } agentMap := map[uint]model.User{} if len(agentIDs) > 0 { var users []model.User model.DB.Where("id IN ?", uniqueUint(agentIDs)).Find(&users) for _, u := range users { agentMap[u.ID] = u } } channelMap := map[uint]model.Channel{} if len(channelIDs) > 0 { var channels []model.Channel model.DB.Where("id IN ?", uniqueUint(channelIDs)).Find(&channels) for _, ch := range channels { channelMap[ch.ID] = ch } } msgCountMap := map[uint]int{} if len(sessionIDs) > 0 { type row struct { SessionID uint Cnt int } var rows []row model.DB.Model(&model.Message{}). Select("session_id, count(*) as cnt"). Where("session_id IN ?", sessionIDs). Group("session_id"). Scan(&rows) for _, r := range rows { msgCountMap[r.SessionID] = r.Cnt } } // 最后一条消息摘要(按会话 max(seq) 批量取) lastMsgMap := map[uint]string{} if len(sessionIDs) > 0 { type lastRow struct { SessionID uint Content string Type string } var lastRows []lastRow _ = model.DB.Raw(` SELECT m.session_id AS session_id, m.content AS content, m.type AS type FROM messages m INNER JOIN ( SELECT session_id, MAX(seq) AS max_seq FROM messages WHERE session_id IN ? GROUP BY session_id ) t ON m.session_id = t.session_id AND m.seq = t.max_seq `, sessionIDs).Scan(&lastRows).Error for _, r := range lastRows { if r.Type == "image" { lastMsgMap[r.SessionID] = "[图片]" } else { lastMsgMap[r.SessionID] = r.Content } } } header := []string{ "会话ID", "客户", "客户手机", "坐席", "渠道", "状态", "优先级", "消息数", "满意度", "评价内容", "结束原因", "最后消息", "创建时间", "结束时间", } rows := make([][]string, 0, len(sessions)) for _, s := range sessions { custName, custPhone := "", "" if cu, ok := customerMap[s.CustomerID]; ok { custName, custPhone = cu.Name, cu.Phone } agentName := "" if s.AgentID != nil { if u, ok := agentMap[*s.AgentID]; ok { agentName = u.Nickname if agentName == "" { agentName = u.Username } } } chName := "" if ch, ok := channelMap[s.ChannelID]; ok { chName = ch.Name if chName == "" { chName = ch.Type } } rows = append(rows, []string{ strconv.FormatUint(uint64(s.ID), 10), custName, custPhone, agentName, chName, s.Status, s.Priority, strconv.Itoa(msgCountMap[s.ID]), formatIntPtr(s.SatisfactionScore), s.SatisfactionText, s.EndReason, lastMsgMap[s.ID], formatCSVTime(s.CreatedAt), formatCSVTimePtr(s.EndedAt), }) } name := fmt.Sprintf("sessions_%s.csv", time.Now().Format("20060102_150405")) writeCSVResponse(c, name, header, rows) } // Export 导出统计报告 CSV(KPI + 坐席绩效 + 渠道分布)。 // GET /api/statistics/export?from=&to= 或 period= func (h *StatisticsHandler) Export(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 } 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) } // 坐席绩效 var agents []model.User model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents) type perf struct { Name string Conversations int ResponseTotal float64 ResponseCount int Satisfaction float64 RatingCount int } items := make(map[uint]*perf) for _, agent := range agents { name := agent.Nickname if name == "" { name = agent.Username } items[agent.ID] = &perf{Name: name} } 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++ } } // 渠道 var channels []model.Channel model.DB.Where("tenant_id = ?", tenantID).Find(&channels) channelTypes := make(map[uint]string) for _, channel := range channels { channelTypes[channel.ID] = channel.Type } labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"} chCounts := make(map[string]int) for _, session := range sessions { channelType := channelTypes[session.ChannelID] if channelType == "" { channelType = "unknown" } chCounts[channelType]++ } // 扁平 CSV:分节用 section 列 header := []string{"区块", "指标/坐席/渠道", "数值1", "数值2", "数值3", "备注"} rows := make([][]string, 0, 32+len(items)+len(chCounts)) rows = append(rows, []string{"概览", "总会话数", strconv.Itoa(len(sessions)), "", "", ""}, []string{"概览", "总消息数", strconv.Itoa(len(messages)), "", "", ""}, []string{"概览", "平均首次响应(秒)", fmt.Sprintf("%.1f", avgResponse), "", "", ""}, []string{"概览", "平均满意度", fmt.Sprintf("%.2f", satisfactionAvg), "", "", fmt.Sprintf("样本 %d", satisfactionCount)}, []string{"概览", "一次解决率(%)", fmt.Sprintf("%.1f", firstResolveRate(sessions)), "", "", ""}, []string{"概览", "导出时间", time.Now().Format("2006-01-02 15:04:05"), "", "", ""}, ) rows = append(rows, []string{"坐席绩效", "坐席", "会话数", "平均响应(秒)", "满意度", ""}) for _, agent := range agents { item := items[agent.ID] if item == nil { continue } avgR := float64(0) if item.ResponseCount > 0 { avgR = item.ResponseTotal / float64(item.ResponseCount) } sat := float64(0) if item.RatingCount > 0 { sat = item.Satisfaction / float64(item.RatingCount) } rows = append(rows, []string{ "坐席绩效", item.Name, strconv.Itoa(item.Conversations), fmt.Sprintf("%.1f", avgR), fmt.Sprintf("%.2f", sat), "", }) } rows = append(rows, []string{"渠道分布", "渠道", "会话数", "", "", ""}) for typ, cnt := range chCounts { label := labels[typ] if label == "" { label = typ } rows = append(rows, []string{"渠道分布", label, strconv.Itoa(cnt), "", "", ""}) } name := fmt.Sprintf("statistics_%s.csv", time.Now().Format("20060102_150405")) writeCSVResponse(c, name, header, rows) }