优化客服会话筛选

This commit is contained in:
yml
2026-06-18 01:08:54 +08:00
parent 272b2cb71e
commit 7dc7368367
8 changed files with 567 additions and 98 deletions
+23 -14
View File
@@ -8,20 +8,28 @@ type Principal struct {
}
type ConversationDTO struct {
ID uint64 `json:"id"`
OrderID *uint64 `json:"order_id"`
ListingID *uint64 `json:"listing_id"`
Type string `json:"type"`
Title string `json:"title"`
Status string `json:"status"`
Role string `json:"role"`
Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"`
LastMessageAt *time.Time `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
OrderID *uint64 `json:"order_id"`
ListingID *uint64 `json:"listing_id"`
LatestOrderID *uint64 `json:"latest_order_id,omitempty"`
LatestOrderNo string `json:"latest_order_no,omitempty"`
LatestOrderStatus string `json:"latest_order_status,omitempty"`
LatestHandoffStatus string `json:"latest_handoff_status,omitempty"`
LatestRefundStatus string `json:"latest_refund_status,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Status string `json:"status"`
Role string `json:"role"`
Participants []ParticipantDTO `json:"participants,omitempty"`
LastMessageID *uint64 `json:"last_message_id"`
LastMessagePreview string `json:"last_message_preview"`
LastMessageAt *time.Time `json:"last_message_at"`
LastSenderType string `json:"last_sender_type,omitempty"`
LastSenderID uint64 `json:"last_sender_id,omitempty"`
LastSenderRole string `json:"last_sender_role,omitempty"`
UnreadCount int64 `json:"unread_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ParticipantDTO struct {
@@ -104,4 +112,5 @@ type PaginatedResult struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Counts interface{} `json:"counts,omitempty"`
}
@@ -13,9 +13,11 @@ func (h *Handler) AdminList(c *gin.Context) {
return
}
filter := c.DefaultQuery("filter", "all")
stage := c.DefaultQuery("stage", "all")
keyword := c.Query("keyword")
page, pageSize := parsePagination(c)
principal := Principal{Type: "admin", ID: adminID}
result, err := h.service.ListConversationsWithFilter(c.Request.Context(), principal, page, pageSize, filter)
result, err := h.service.ListConversationsWithFilter(c.Request.Context(), principal, page, pageSize, filter, stage, keyword)
if err != nil {
writeChatError(c, err)
return
+43 -27
View File
@@ -12,19 +12,27 @@ import (
)
type conversationRow struct {
ID uint64
OrderID *uint64
ListingID *uint64
Type string
Title string
Status string
Role string
LastMessageID *uint64
LastMessagePreview string
LastMessageAt *time.Time
UnreadCount int64
CreatedAt time.Time
UpdatedAt time.Time
ID uint64
OrderID *uint64
ListingID *uint64
LatestOrderID *uint64
LatestOrderNo string
LatestOrderStatus string
LatestHandoffStatus string
LatestRefundStatus string
Type string
Title string
Status string
Role string
LastMessageID *uint64
LastMessagePreview string
LastMessageAt *time.Time
LastSenderType string
LastSenderID uint64
LastSenderRole string
UnreadCount int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (r *Repository) conversationQuery(ctx context.Context, principal Principal) *gorm.DB {
@@ -281,20 +289,28 @@ func (r *Repository) adminNames(ctx context.Context, ids []uint64) (map[uint64]s
}
func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO {
return ConversationDTO{
ID: row.ID,
OrderID: row.OrderID,
ListingID: row.ListingID,
Type: row.Type,
Title: row.Title,
Status: row.Status,
Role: row.Role,
Participants: participants,
LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview,
LastMessageAt: row.LastMessageAt,
UnreadCount: row.UnreadCount,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
OrderID: row.OrderID,
ListingID: row.ListingID,
LatestOrderID: row.LatestOrderID,
LatestOrderNo: row.LatestOrderNo,
LatestOrderStatus: row.LatestOrderStatus,
LatestHandoffStatus: row.LatestHandoffStatus,
LatestRefundStatus: row.LatestRefundStatus,
Type: row.Type,
Title: row.Title,
Status: row.Status,
Role: row.Role,
Participants: participants,
LastMessageID: row.LastMessageID,
LastMessagePreview: row.LastMessagePreview,
LastMessageAt: row.LastMessageAt,
LastSenderType: row.LastSenderType,
LastSenderID: row.LastSenderID,
LastSenderRole: row.LastSenderRole,
UnreadCount: row.UnreadCount,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func defaultSupportAdminID(tx *gorm.DB) uint64 {
+2 -2
View File
@@ -148,11 +148,11 @@ func (s *Service) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAdmin
return s.repo.GetAvailableSupportAdmins(ctx)
}
func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter)
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
}
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
+197 -42
View File
@@ -5,9 +5,32 @@ import (
"fmt"
"gorm.io/gorm"
"hfb_sys/backend/internal/model"
"strings"
"time"
)
const (
adminChatFilterAll = "all"
adminChatFilterMine = "mine"
adminChatFilterUnassigned = "unassigned"
adminChatStageAll = "all"
adminChatStagePending = "pending"
adminChatStageUnjoined = "unjoined"
adminChatStageHandoff = "handoff"
adminChatStageRenting = "renting"
adminChatStageAfterSale = "after_sale"
adminChatStageEnded = "ended"
)
var adminChatFilters = []string{adminChatFilterMine, adminChatFilterAll, adminChatFilterUnassigned}
var adminChatStages = []string{adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded}
type AdminConversationCountsDTO struct {
Ownership map[string]int64 `json:"ownership"`
Stages map[string]int64 `json:"stages"`
}
func (r *Repository) TransferConversation(ctx context.Context, principal Principal, conversationID uint64, toAdminID uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 验证当前操作者是会话参与者
@@ -112,54 +135,19 @@ func (r *Repository) GetAvailableSupportAdmins(ctx context.Context) ([]SupportAd
}
return result, nil
}
func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) {
func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize)
db := r.db.WithContext(ctx)
filter = normalizeAdminChatFilter(filter)
stage = normalizeAdminChatStage(stage)
keyword = strings.TrimSpace(keyword)
// 管理员在"全部"模式下直接查询所有会话
if principal.Type == "admin" && filter == "all" {
var total int64
if err := db.Model(&model.ChatConversation{}).Count(&total).Error; err != nil {
return nil, err
}
var conversations []model.ChatConversation
offset := (page - 1) * pageSize
if err := db.Order("COALESCE(last_message_at, created_at) DESC, id DESC").
Offset(offset).
Limit(pageSize).
Find(&conversations).Error; err != nil {
return nil, err
}
items := make([]ConversationDTO, 0, len(conversations))
for _, conv := range conversations {
participants, err := r.participants(ctx, conv.ID)
if err != nil {
return nil, err
}
items = append(items, ConversationDTO{
ID: conv.ID,
OrderID: conv.OrderID,
ListingID: conv.ListingID,
Type: conv.Type,
Title: conv.Title,
Status: conv.Status,
Role: "admin", // 管理员角色
Participants: participants,
LastMessageID: conv.LastMessageID,
LastMessagePreview: conv.LastMessagePreview,
LastMessageAt: conv.LastMessageAt,
UnreadCount: 0, // 管理员不计未读
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
})
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
if principal.Type == "admin" {
return r.listAdminConversations(ctx, principal, page, pageSize, filter, stage, keyword)
}
// 其他情况使用原有逻辑
var total int64
db := r.db.WithContext(ctx)
countDB := db.Table("chat_conversations AS c").
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id")
@@ -214,3 +202,170 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
var total int64
countDB := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(countDB, filter, principal)
applyAdminChatStageFilter(countDB, stage, principal)
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
var rows []conversationRow
offset := (page - 1) * pageSize
queryDB := r.adminConversationBase(ctx, principal, keyword).
Select(`c.id, c.order_id, c.listing_id, c.type, c.title, c.status, c.last_message_id,
c.last_message_preview, c.last_message_at, c.created_at, c.updated_at,
COALESCE(cp_me.role, 'admin') AS role,
lo.id AS latest_order_id, lo.order_no AS latest_order_no, lo.status AS latest_order_status,
lo.handoff_status AS latest_handoff_status, lo.refund_status AS latest_refund_status,
lm.sender_type AS last_sender_type, lm.sender_id AS last_sender_id, lm.sender_role AS last_sender_role,
CASE WHEN cp_me.id IS NULL THEN 0 ELSE (
SELECT COUNT(1)
FROM chat_messages AS cm
WHERE cm.conversation_id = c.id
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm.created_at > cp_me.last_read_at)
) END AS unread_count`, principal.Type, principal.ID)
applyAdminChatOwnershipFilter(queryDB, filter, principal)
applyAdminChatStageFilter(queryDB, stage, principal)
if err := queryDB.
Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC").
Offset(offset).
Limit(pageSize).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]ConversationDTO, 0, len(rows))
for _, row := range rows {
participants, err := r.participants(ctx, row.ID)
if err != nil {
return nil, err
}
items = append(items, row.toDTO(participants))
}
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
if err != nil {
return nil, err
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize, Counts: counts}, nil
}
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
db := r.db.WithContext(ctx).Table("chat_conversations AS c").
Joins("LEFT JOIN chat_participants AS cp_me ON cp_me.conversation_id = c.id AND cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID).
Joins("LEFT JOIN chat_messages AS lm ON lm.id = c.last_message_id").
Joins("LEFT JOIN rental_orders AS lo ON lo.id = COALESCE(c.order_id, (SELECT ro.id FROM rental_orders AS ro WHERE ro.listing_id = c.listing_id ORDER BY ro.id DESC LIMIT 1))").
Joins("LEFT JOIN rental_listings AS l ON l.id = COALESCE(lo.listing_id, c.listing_id)").
Joins("LEFT JOIN users AS renter ON renter.id = lo.renter_id").
Joins("LEFT JOIN users AS owner ON owner.id = COALESCE(lo.owner_id, l.owner_id)")
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where(`c.title LIKE ? OR c.last_message_preview LIKE ? OR lo.order_no LIKE ? OR l.listing_no LIKE ?
OR renter.phone LIKE ? OR owner.phone LIKE ?
OR EXISTS (
SELECT 1 FROM chat_participants AS cp_kw
WHERE cp_kw.conversation_id = c.id AND cp_kw.remark LIKE ?
)`, like, like, like, like, like, like, like)
}
return db
}
func (r *Repository) adminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
counts := &AdminConversationCountsDTO{
Ownership: make(map[string]int64, len(adminChatFilters)),
Stages: make(map[string]int64, len(adminChatStages)),
}
for _, item := range adminChatFilters {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, item, principal)
applyAdminChatStageFilter(db, stage, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Ownership[item] = count
}
for _, item := range adminChatStages {
db := r.adminConversationBase(ctx, principal, keyword)
applyAdminChatOwnershipFilter(db, filter, principal)
applyAdminChatStageFilter(db, item, principal)
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, err
}
counts.Stages[item] = count
}
return counts, nil
}
func normalizeAdminChatFilter(filter string) string {
switch filter {
case adminChatFilterAll, adminChatFilterMine, adminChatFilterUnassigned:
return filter
default:
return adminChatFilterMine
}
}
func normalizeAdminChatStage(stage string) string {
switch stage {
case adminChatStageAll, adminChatStagePending, adminChatStageUnjoined, adminChatStageHandoff, adminChatStageRenting, adminChatStageAfterSale, adminChatStageEnded:
return stage
default:
return adminChatStageAll
}
}
func applyAdminChatOwnershipFilter(db *gorm.DB, filter string, principal Principal) {
switch filter {
case adminChatFilterMine:
db.Where("cp_me.id IS NOT NULL")
case adminChatFilterUnassigned:
db.Where(`NOT EXISTS (
SELECT 1 FROM chat_participants AS cp_support
WHERE cp_support.conversation_id = c.id
AND cp_support.participant_type = ?
AND cp_support.role = ?
)`, "admin", "support")
case adminChatFilterAll:
return
default:
db.Where("cp_me.participant_type = ? AND cp_me.participant_id = ?", principal.Type, principal.ID)
}
}
func applyAdminChatStageFilter(db *gorm.DB, stage string, principal Principal) {
switch stage {
case adminChatStagePending:
db.Where(`(
lm.sender_type = ?
OR (
cp_me.id IS NOT NULL
AND EXISTS (
SELECT 1
FROM chat_messages AS cm_pending
WHERE cm_pending.conversation_id = c.id
AND NOT (cm_pending.sender_type = ? AND cm_pending.sender_id = ?)
AND (cp_me.last_read_at IS NULL OR cm_pending.created_at > cp_me.last_read_at)
)
)
)`, "user", principal.Type, principal.ID)
case adminChatStageUnjoined:
db.Where("lo.id IS NULL")
case adminChatStageHandoff:
db.Where("lo.status = ?", "pending_handoff")
case adminChatStageRenting:
db.Where("lo.status IN ?", []string{"renting", "overdue"})
case adminChatStageAfterSale:
db.Where("(lo.status IN ? OR (lo.refund_status IS NOT NULL AND lo.refund_status <> ?))",
[]string{"pending_checkout_confirm", "pending_checkout_accept", "checkout_disputing", "abnormal"}, "none")
case adminChatStageEnded:
db.Where("(c.status IN ? OR lo.status IN ?)", []string{"archived", "closed"}, []string{"completed", "cancelled", "closed"})
case adminChatStageAll:
return
}
}