Files
kefu_cloud/server/internal/handler/export.go
T
yml2213 c43f420875 支持客户、对话记录与统计报告 CSV 导出
- 新增 /customers/export、/sessions/export、/statistics/export
- 沿用列表筛选与角色可见范围,UTF-8 BOM 便于 Excel 打开
- 前端三处导出按钮接入真实下载
2026-07-15 15:10:46 +08:00

376 lines
11 KiB
Go

package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"kefu-sys/server/internal/middleware"
"kefu-sys/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
func (h *StatisticsHandler) Export(c *gin.Context) {
if !requireStatisticsAccess(c) {
return
}
tenantID := middleware.GetTenantID(c)
sessions, messages, err := loadStatisticsData(tenantID)
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)
}