feat(session): 优化工作台会话分页与筛选
CI / Backend (go vet + test) (push) Failing after 3m18s
CI / Frontend (typecheck + lint + build) (push) Successful in 10m42s

This commit is contained in:
yml2213
2026-11-06 19:05:41 +08:00
parent 93ef9f2040
commit 723989f039
6 changed files with 524 additions and 135 deletions
+128 -36
View File
@@ -41,15 +41,17 @@ type AgentItem struct {
// SessionFilter 会话列表筛选。
type SessionFilter struct {
Status string
Priority string
AgentID string
ChannelID string
Search string
From string
To string
Page int
PageSize int
Scope string
Status string
Priority string
AgentID string
ChannelID string
CustomerID string
Search string
From string
To string
Page int
PageSize int
}
// LoadTenantSession 加载本租户会话;跨租户访问返回 403。
@@ -120,21 +122,30 @@ func assignableRoleCodes(tenantID uint) []string {
return append(codes, customCodes...)
}
func unreadCount(session model.Session) int {
var count int64
model.DB.Model(&model.Message{}).
Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq).
Count(&count)
return int(count)
}
// ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息数。
// ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息摘要。
func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListItem, int64, error) {
var sessions []model.Session
var total int64
page := f.Page
pageSize := f.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
if !actor.SessionAll || !actor.HistoryAll {
if f.Scope == "workbench" {
query = query.Where("sessions.status IN ?", []string{"waiting", "active"})
if !actor.SessionAll {
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting")
}
} else if !actor.SessionAll || !actor.HistoryAll {
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting")
}
if f.Status != "" {
@@ -149,6 +160,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
if f.ChannelID != "" {
query = query.Where("sessions.channel_id = ?", f.ChannelID)
}
if f.CustomerID != "" {
query = query.Where("sessions.customer_id = ?", f.CustomerID)
}
if f.From != "" {
if t, err := time.ParseInLocation("2006-01-02", f.From, time.Local); err == nil {
query = query.Where("sessions.created_at >= ?", t)
@@ -161,16 +175,52 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
}
if f.Search != "" {
like := "%" + f.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)
query = query.Where(
`EXISTS (
SELECT 1 FROM customers AS search_customers
WHERE search_customers.id = sessions.customer_id
AND search_customers.name LIKE ?
) OR CAST(sessions.id AS TEXT) LIKE ? OR EXISTS (
SELECT 1 FROM messages AS search_messages
WHERE search_messages.session_id = sessions.id
AND search_messages.seq = (
SELECT MAX(latest_message.seq)
FROM messages AS latest_message
WHERE latest_message.session_id = sessions.id
)
AND search_messages.content LIKE ?
)`,
like, like, like,
)
}
query.Count(&total)
if err := query.Order("sessions.created_at desc").Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&sessions).Error; err != nil {
if err := query.Count(&total).Error; err != nil {
return nil, 0, Internal("统计会话失败")
}
if f.Scope == "workbench" {
query = query.
Order(`CASE
WHEN sessions.priority = 'urgent' THEN 0
WHEN sessions.status = 'waiting' THEN 1
ELSE 2
END ASC`).
Order(`COALESCE(
(SELECT order_messages.sent_at
FROM messages AS order_messages
WHERE order_messages.session_id = sessions.id
ORDER BY order_messages.seq DESC
LIMIT 1),
sessions.created_at
) DESC`).
Order("sessions.id DESC")
} else {
query = query.Order("sessions.created_at DESC").Order("sessions.id DESC")
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
return nil, 0, Internal("查询会话失败")
}
// 批量补全客户 / 客服 / 渠道 / 消息数
// 批量补全客户 / 客服 / 渠道 / 消息摘要,避免按会话逐条查询。
customerIDs := make([]uint, 0)
agentIDs := make([]uint, 0)
channelIDs := make([]uint, 0)
@@ -186,7 +236,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
customerMap := map[uint]model.Customer{}
if len(customerIDs) > 0 {
var customers []model.Customer
model.DB.Where("id IN ?", UniqueUint(customerIDs)).Find(&customers)
if err := model.DB.Where("id IN ?", UniqueUint(customerIDs)).Find(&customers).Error; err != nil {
return nil, 0, Internal("查询会话客户失败")
}
for _, cu := range customers {
customerMap[cu.ID] = cu
}
@@ -194,7 +246,9 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
agentMap := map[uint]model.User{}
if len(agentIDs) > 0 {
var users []model.User
model.DB.Where("id IN ?", UniqueUint(agentIDs)).Find(&users)
if err := model.DB.Where("id IN ?", UniqueUint(agentIDs)).Find(&users).Error; err != nil {
return nil, 0, Internal("查询会话坐席失败")
}
for _, u := range users {
agentMap[u.ID] = u
}
@@ -202,35 +256,74 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
channelMap := map[uint]model.Channel{}
if len(channelIDs) > 0 {
var channels []model.Channel
model.DB.Unscoped().Where("id IN ?", UniqueUint(channelIDs)).Find(&channels)
if err := model.DB.Unscoped().Where("id IN ?", UniqueUint(channelIDs)).Find(&channels).Error; err != nil {
return nil, 0, Internal("查询会话渠道失败")
}
for _, ch := range channels {
channelMap[ch.ID] = ch
}
}
msgCountMap := map[uint]int{}
lastMessageMap := map[uint]model.Message{}
unreadMap := map[uint]int{}
if len(sessionIDs) > 0 {
type row struct {
SessionID uint
Cnt int
Cnt int `gorm:"column:cnt"`
}
var rows []row
model.DB.Model(&model.Message{}).
if err := model.DB.Model(&model.Message{}).
Select("session_id, COUNT(*) as cnt").
Where("session_id IN ?", sessionIDs).
Group("session_id").
Scan(&rows)
Scan(&rows).Error; err != nil {
return nil, 0, Internal("统计会话消息失败")
}
for _, r := range rows {
msgCountMap[r.SessionID] = r.Cnt
}
var lastMessages []model.Message
if err := model.DB.Where(
`session_id IN ? AND seq = (
SELECT MAX(latest.seq)
FROM messages AS latest
WHERE latest.session_id = messages.session_id
)`,
sessionIDs,
).Find(&lastMessages).Error; err != nil {
return nil, 0, Internal("查询会话最后消息失败")
}
for _, msg := range lastMessages {
lastMessageMap[msg.SessionID] = msg
}
var unreadRows []row
if err := model.DB.Table("messages").
Select("messages.session_id, COUNT(*) AS cnt").
Joins("JOIN sessions AS unread_sessions ON unread_sessions.id = messages.session_id").
Where(
"messages.session_id IN ? AND messages.sender_type = ? AND messages.seq > unread_sessions.last_read_seq",
sessionIDs,
"visitor",
).
Group("messages.session_id").
Scan(&unreadRows).Error; err != nil {
return nil, 0, Internal("统计会话未读数失败")
}
for _, r := range unreadRows {
unreadMap[r.SessionID] = r.Cnt
}
}
items := make([]SessionListItem, 0, len(sessions))
for _, session := range sessions {
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]}
item.VisitorOnline = VisitorIsOnline(&session, time.Now())
if !actor.SessionAll && session.AgentID != nil && *session.AgentID == actor.UserID {
item.UnreadCount = unreadCount(session)
item := SessionListItem{
Session: session,
MessageCount: msgCountMap[session.ID],
UnreadCount: unreadMap[session.ID],
}
item.VisitorOnline = VisitorIsOnline(&session, time.Now())
if cu, ok := customerMap[session.CustomerID]; ok {
item.CustomerName = cu.Name
}
@@ -244,8 +337,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
item.ChannelType = ch.Type
item.ChannelDomain = ch.SiteDomain
}
var lastMsg model.Message
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
if lastMsg, ok := lastMessageMap[session.ID]; ok {
if lastMsg.Type == "image" {
item.LastMessage = "[图片]"
} else {
@@ -0,0 +1,179 @@
package service
import (
"fmt"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"kefu-cloud/server/internal/model"
)
func setupSessionListTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(
sqlite.Open(fmt.Sprintf("file:kefu_session_list_%d?mode=memory&cache=shared", time.Now().UnixNano())),
&gorm.Config{},
)
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
model.DB = db
if err := model.Migrate(db); err != nil {
t.Fatalf("迁移测试数据库失败: %v", err)
}
return db
}
func TestListSessionsWorkbenchPaginatesSortsAndExcludesEnded(t *testing.T) {
db := setupSessionListTestDB(t)
tenant := model.Tenant{Name: "分页测试租户", Status: "normal"}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("创建租户失败: %v", err)
}
customer := model.Customer{TenantID: tenant.ID, Name: "星河科技"}
if err := db.Create(&customer).Error; err != nil {
t.Fatalf("创建客户失败: %v", err)
}
now := time.Now().Truncate(time.Second)
sessions := []model.Session{
{
TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "urgent",
VisitorIP: "10.0.0.1", CreatedAt: now.Add(-4 * time.Hour),
},
{
TenantID: tenant.ID, CustomerID: customer.ID, Status: "waiting", Priority: "normal",
VisitorIP: "10.0.0.2", CreatedAt: now.Add(-3 * time.Hour),
},
{
TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "normal",
VisitorIP: "10.0.0.3", CreatedAt: now.Add(-2 * time.Hour),
},
{
TenantID: tenant.ID, CustomerID: customer.ID, Status: "ended", Priority: "urgent",
VisitorIP: "10.0.0.4", CreatedAt: now.Add(-time.Hour),
},
}
if err := db.Create(&sessions).Error; err != nil {
t.Fatalf("创建会话失败: %v", err)
}
messages := []model.Message{
{SessionID: sessions[0].ID, SenderType: "visitor", Content: "紧急报价单", Type: "text", Seq: 1, SentAt: now.Add(-10 * time.Minute)},
{SessionID: sessions[1].ID, SenderType: "visitor", Content: "等待接入", Type: "text", Seq: 1, SentAt: now.Add(-5 * time.Minute)},
{SessionID: sessions[2].ID, SenderType: "visitor", Content: "普通咨询", Type: "text", Seq: 1, SentAt: now.Add(-time.Minute)},
{SessionID: sessions[3].ID, SenderType: "visitor", Content: "已结束会话", Type: "text", Seq: 1, SentAt: now},
}
if err := db.Create(&messages).Error; err != nil {
t.Fatalf("创建消息失败: %v", err)
}
allData := Actor{SessionAll: true, HistoryAll: true}
firstPage, total, err := ListSessions(allData, tenant.ID, SessionFilter{
Scope: "workbench", Page: 1, PageSize: 2,
})
if err != nil {
t.Fatalf("查询第一页失败: %v", err)
}
if total != 3 {
t.Fatalf("工作台总数=%d,期望 3(不包含已结束会话)", total)
}
if len(firstPage) != 2 {
t.Fatalf("第一页数量=%d,期望 2", len(firstPage))
}
if firstPage[0].ID != sessions[0].ID || firstPage[1].ID != sessions[1].ID {
t.Fatalf("排序异常: got [%d, %d], want [%d, %d]",
firstPage[0].ID, firstPage[1].ID, sessions[0].ID, sessions[1].ID)
}
if firstPage[0].CustomerName != "星河科技" || firstPage[0].LastMessage != "紧急报价单" {
t.Fatalf("会话摘要补全异常: %+v", firstPage[0])
}
if firstPage[0].UnreadCount != 1 || firstPage[0].MessageCount != 1 {
t.Fatalf("未读/消息数异常: unread=%d messages=%d", firstPage[0].UnreadCount, firstPage[0].MessageCount)
}
secondPage, secondTotal, err := ListSessions(allData, tenant.ID, SessionFilter{
Scope: "workbench", Page: 2, PageSize: 2,
})
if err != nil {
t.Fatalf("查询第二页失败: %v", err)
}
if secondTotal != 3 || len(secondPage) != 1 || secondPage[0].ID != sessions[2].ID {
t.Fatalf("第二页异常: total=%d items=%+v", secondTotal, secondPage)
}
historyItems, historyTotal, err := ListSessions(allData, tenant.ID, SessionFilter{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("查询普通会话列表失败: %v", err)
}
if historyTotal != 4 || len(historyItems) != 4 {
t.Fatalf("非工作台查询不应排除已结束会话: total=%d items=%+v", historyTotal, historyItems)
}
agentItems, agentTotal, err := ListSessions(Actor{UserID: 999}, tenant.ID, SessionFilter{
Scope: "workbench", Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("查询普通坐席工作台失败: %v", err)
}
if agentTotal != 1 || len(agentItems) != 1 || agentItems[0].ID != sessions[1].ID {
t.Fatalf("普通坐席只能看到等待中或自己的进行中会话: total=%d items=%+v", agentTotal, agentItems)
}
}
func TestListSessionsWorkbenchFiltersSearchAndCustomer(t *testing.T) {
db := setupSessionListTestDB(t)
tenant := model.Tenant{Name: "筛选测试租户", Status: "normal"}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("创建租户失败: %v", err)
}
customers := []model.Customer{
{TenantID: tenant.ID, Name: "远山贸易"},
{TenantID: tenant.ID, Name: "青禾设计"},
}
if err := db.Create(&customers).Error; err != nil {
t.Fatalf("创建客户失败: %v", err)
}
sessions := []model.Session{
{TenantID: tenant.ID, CustomerID: customers[0].ID, Status: "waiting", Priority: "urgent"},
{TenantID: tenant.ID, CustomerID: customers[1].ID, Status: "active", Priority: "normal"},
}
if err := db.Create(&sessions).Error; err != nil {
t.Fatalf("创建会话失败: %v", err)
}
messages := []model.Message{
{SessionID: sessions[0].ID, SenderType: "visitor", Content: "需要产品报价", Type: "text", Seq: 1, SentAt: time.Now()},
{SessionID: sessions[0].ID, SenderType: "agent", Content: "稍等", Type: "text", Seq: 2, SentAt: time.Now()},
{SessionID: sessions[1].ID, SenderType: "visitor", Content: "售后服务", Type: "text", Seq: 1, SentAt: time.Now()},
}
if err := db.Create(&messages).Error; err != nil {
t.Fatalf("创建消息失败: %v", err)
}
allData := Actor{SessionAll: true, HistoryAll: true}
cases := []struct {
name string
filter SessionFilter
want uint
}{
{name: "按客户名搜索", filter: SessionFilter{Scope: "workbench", Search: "远山"}, want: sessions[0].ID},
{name: "按最后消息搜索", filter: SessionFilter{Scope: "workbench", Search: "稍等"}, want: sessions[0].ID},
{name: "按状态筛选", filter: SessionFilter{Scope: "workbench", Status: "active"}, want: sessions[1].ID},
{name: "按优先级筛选", filter: SessionFilter{Scope: "workbench", Priority: "urgent"}, want: sessions[0].ID},
{name: "按客户筛选", filter: SessionFilter{Scope: "workbench", CustomerID: fmt.Sprint(customers[1].ID)}, want: sessions[1].ID},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.filter.Page = 1
tc.filter.PageSize = 20
items, total, err := ListSessions(allData, tenant.ID, tc.filter)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if total != 1 || len(items) != 1 || items[0].ID != tc.want {
t.Fatalf("筛选结果异常: total=%d items=%+v want=%d", total, items, tc.want)
}
})
}
}