优化快捷回复联想与个人调用统计,数据统计支持自定义日期

/ 按输入码与个人频次建议(最多10条,唯一自动填入),正文关键字联想;统计页可日历选区间并统一中文日历。
This commit is contained in:
yml2213
2026-07-19 01:10:16 +08:00
parent c6532f2ee3
commit 1a3b32868c
9 changed files with 488 additions and 114 deletions
+7 -2
View File
@@ -242,13 +242,18 @@ func (h *SessionHandler) Export(c *gin.Context) {
}
// Export 导出统计报告 CSV(KPI + 坐席绩效 + 渠道分布)。
// GET /api/statistics/export
// 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)
sessions, messages, err := loadStatisticsData(tenantID, r)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"})
return
+91 -11
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -213,13 +214,22 @@ func (h *QuickReplyHandler) List(c *gin.Context) {
middleware.JSONList(c, list, total, page, pageSize)
}
// Suggest GET /api/quick-replies/suggest?prefix=
// 供输入框 / 触发:匹配 shortcut 前缀或标题
const suggestLimit = 10
// Suggest GET /api/quick-replies/suggest
// mode=slash&prefix= → / 调用:按输入码前缀过滤;空前缀返回个人调用频次最高的 10 条
// mode=keyword&q= → 普通输入:按标题/内容/输入码关键字匹配
// 排序一律按当前坐席个人调用次数(非全租户)
func (h *QuickReplyHandler) Suggest(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
mode := strings.TrimSpace(c.Query("mode"))
if mode == "" {
mode = "slash"
}
prefix := strings.TrimSpace(c.Query("prefix"))
prefix = strings.TrimPrefix(strings.ToLower(prefix), "/")
q := strings.TrimSpace(c.Query("q"))
db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID).Where(
"(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ? AND status = ?)",
@@ -227,19 +237,64 @@ func (h *QuickReplyHandler) Suggest(c *gin.Context) {
quickReplyScopePersonal, uid, quickReplyStatusPub,
)
if prefix != "" {
like := prefix + "%"
titleLike := "%" + prefix + "%"
db = db.Where("shortcut LIKE ? OR title LIKE ?", like, titleLike)
switch mode {
case "slash":
if prefix != "" {
// 仅匹配输入码前缀(/n → shortcut 以 n 开头)
db = db.Where("shortcut <> '' AND LOWER(shortcut) LIKE ?", prefix+"%")
}
case "keyword":
if utf8.RuneCountInString(q) < 1 {
middleware.JSON(c, []model.QuickReply{})
return
}
like := "%" + q + "%"
db = db.Where("title LIKE ? OR content LIKE ? OR shortcut LIKE ?", like, like, like)
default:
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "mode 无效"})
return
}
// 多取一些再按个人频次排序截断(避免漏掉个人高频但全局低的条目)
var list []model.QuickReply
// 无前缀时返回高频
order := "usage_count desc, sort_order asc, id desc"
if err := db.Order(order).Limit(20).Find(&list).Error; err != nil {
if err := db.Order("usage_count desc, sort_order asc, id desc").Limit(200).Find(&list).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "搜索失败"})
return
}
if len(list) == 0 {
middleware.JSON(c, []model.QuickReply{})
return
}
ids := make([]uint, len(list))
for i, item := range list {
ids[i] = item.ID
}
var usages []model.QuickReplyUserUsage
_ = model.DB.Where("user_id = ? AND tenant_id = ? AND quick_reply_id IN ?", uid, tenantID, ids).Find(&usages).Error
myMap := map[uint]int{}
for _, u := range usages {
myMap[u.QuickReplyID] = u.UsageCount
}
for i := range list {
list[i].MyUsageCount = myMap[list[i].ID]
}
// 个人调用次数优先
sort.SliceStable(list, func(i, j int) bool {
if list[i].MyUsageCount != list[j].MyUsageCount {
return list[i].MyUsageCount > list[j].MyUsageCount
}
if list[i].UsageCount != list[j].UsageCount {
return list[i].UsageCount > list[j].UsageCount
}
if list[i].SortOrder != list[j].SortOrder {
return list[i].SortOrder < list[j].SortOrder
}
return list[i].ID > list[j].ID
})
if len(list) > suggestLimit {
list = list[:suggestLimit]
}
middleware.JSON(c, list)
}
@@ -428,7 +483,7 @@ func (h *QuickReplyHandler) Unpublish(c *gin.Context) {
middleware.JSON(c, item)
}
// Use POST /api/quick-replies/:id/use 使用计数 +1
// Use POST /api/quick-replies/:id/use 个人调用 +1(排序用),并累计全局 usage_count
func (h *QuickReplyHandler) Use(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
item, ok := loadQuickReply(c, uint(id64))
@@ -437,6 +492,7 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
}
// 可见性:团队已发布,或本人个人
uid := middleware.GetUserID(c)
tenantID := middleware.GetTenantID(c)
if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub {
if !middleware.HasAnyRole(c, "admin", "supervisor") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"})
@@ -447,8 +503,32 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权使用"})
return
}
// 全局累计(管理页)
_ = model.DB.Model(item).UpdateColumn("usage_count", item.UsageCount+1).Error
middleware.JSON(c, gin.H{"ok": true, "usage_count": item.UsageCount + 1})
// 个人累计(建议排序)
var usage model.QuickReplyUserUsage
err := model.DB.Where("user_id = ? AND quick_reply_id = ?", uid, item.ID).First(&usage).Error
myCount := 1
if err != nil {
usage = model.QuickReplyUserUsage{
TenantID: tenantID,
UserID: uid,
QuickReplyID: item.ID,
UsageCount: 1,
}
_ = model.DB.Create(&usage).Error
} else {
myCount = usage.UsageCount + 1
_ = model.DB.Model(&usage).UpdateColumn("usage_count", myCount).Error
}
middleware.JSON(c, gin.H{
"ok": true,
"usage_count": item.UsageCount + 1,
"my_usage_count": myCount,
})
}
// 快捷回复 CSV 固定四列(与导入模板一致)
+112 -24
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -22,9 +23,60 @@ func requireStatisticsAccess(c *gin.Context) bool {
return false
}
func loadStatisticsData(tenantID uint) ([]model.Session, []model.Message, error) {
// 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
if err := model.DB.Where("tenant_id = ?", tenantID).Order("created_at asc").Find(&sessions).Error; err != nil {
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 {
@@ -101,7 +153,12 @@ func (h *StatisticsHandler) KPIs(c *gin.Context) {
if !requireStatisticsAccess(c) {
return
}
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c))
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
@@ -141,23 +198,30 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
if !requireStatisticsAccess(c) {
return
}
period := c.DefaultQuery("period", "day")
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
if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil {
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
}
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" {
if !hasCustom && period == "month" {
// 近 6 个自然月
now := time.Now()
counts := make(map[string]int)
for _, session := range sessions {
counts[session.CreatedAt.Format("2006-01")]++
@@ -168,14 +232,19 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
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("01-02")]++
counts[session.CreatedAt.Format("2006-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]})
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)
@@ -185,7 +254,12 @@ func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
if !requireStatisticsAccess(c) {
return
}
_, messages, err := loadStatisticsData(middleware.GetTenantID(c))
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
@@ -216,8 +290,13 @@ 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)
sessions, messages, err := loadStatisticsData(tenantID, r)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
return
@@ -280,10 +359,19 @@ 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)
var sessions []model.Session
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(&sessions).Error; err != nil {
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
return
}
+1
View File
@@ -43,6 +43,7 @@ func Migrate(db *gorm.DB) error {
&Category{},
&KnowledgeEntry{},
&QuickReply{},
&QuickReplyUserUsage{},
&Plan{},
&OperationLog{},
&Announcement{},
+16 -3
View File
@@ -201,9 +201,22 @@ type QuickReply struct {
GroupName string `gorm:"size:50" json:"group_name"`
Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published
SortOrder int `gorm:"default:0" json:"sort_order"`
UsageCount int `gorm:"default:0" json:"usage_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// UsageCount 全局累计(管理页展示);排序以个人用量为准见 QuickReplyUserUsage
UsageCount int `gorm:"default:0" json:"usage_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// MyUsageCount 当前登录用户个人调用次数(接口动态填充,非库字段)
MyUsageCount int `gorm:"-" json:"my_usage_count,omitempty"`
}
// QuickReplyUserUsage 坐席个人快捷回复调用统计(按账户独立,非全租户)。
type QuickReplyUserUsage struct {
ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"`
UserID uint `gorm:"uniqueIndex:idx_qr_user_usage;not null" json:"user_id"`
QuickReplyID uint `gorm:"uniqueIndex:idx_qr_user_usage;index;not null" json:"quick_reply_id"`
UsageCount int `gorm:"default:0" json:"usage_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type Plan struct {