perf(chat): 分页客服会话并拆分统计查询
This commit is contained in:
@@ -60,15 +60,16 @@ func (ChatAdminConversationState) TableName() string {
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
|
||||
SenderType string `gorm:"size:16;not null" json:"sender_type"`
|
||||
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
|
||||
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
|
||||
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ConversationID uint64 `gorm:"not null;index" json:"conversation_id"`
|
||||
SenderType string `gorm:"size:16;not null" json:"sender_type"`
|
||||
SenderID uint64 `gorm:"not null;default:0" json:"sender_id"`
|
||||
SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"`
|
||||
ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"`
|
||||
AdminAttentionType string `gorm:"column:admin_attention_type;size:32;not null;default:'';index" json:"admin_attention_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ChatMessage) TableName() string {
|
||||
|
||||
@@ -99,7 +99,10 @@ func TestListAdminConversationsUsesPersonalStateAndBatchData(t *testing.T) {
|
||||
if item.UnreadCount != 1 {
|
||||
t.Fatalf("管理员未读数 = %d, want 1", item.UnreadCount)
|
||||
}
|
||||
counts := result.Counts.(*AdminConversationCountsDTO)
|
||||
counts, err := repo.AdminConversationCounts(t.Context(), Principal{Type: "admin", ID: admin.ID}, adminChatFilterMine, adminChatStageAll, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询客服统计失败: %v", err)
|
||||
}
|
||||
if counts.Ownership[adminChatFilterMine] != 1 || counts.Stages[adminChatStageAll] != 1 {
|
||||
t.Fatalf("聚合统计异常: ownership=%v stages=%v", counts.Ownership, counts.Stages)
|
||||
}
|
||||
|
||||
@@ -60,8 +60,7 @@ func (r *Repository) FindConversation(ctx context.Context, principal Principal,
|
||||
var unreadCount int64
|
||||
if err := db.Table("chat_messages AS cm").
|
||||
Where("cm.conversation_id = ?", conversation.ID).
|
||||
Where("cm.sender_type <> ?", "system").
|
||||
Where("NOT (cm.sender_type = ? AND cm.sender_id = ?)", "admin", principal.ID).
|
||||
Where("(cm.admin_attention_type <> ? OR cm.sender_type = ?)", "", "user").
|
||||
Where("cm.id > ?", state.LastReadMessageID).
|
||||
Count(&unreadCount).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -54,19 +54,20 @@ type ParticipantDTO struct {
|
||||
}
|
||||
|
||||
type MessageDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
ConversationID uint64 `json:"conversation_id"`
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID uint64 `json:"sender_id"`
|
||||
SenderRole string `json:"sender_role"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderAvatar string `json:"sender_avatar"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
IsRead bool `json:"is_read"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint64 `json:"id"`
|
||||
ConversationID uint64 `json:"conversation_id"`
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID uint64 `json:"sender_id"`
|
||||
SenderRole string `json:"sender_role"`
|
||||
SenderName string `json:"sender_name"`
|
||||
SenderAvatar string `json:"sender_avatar"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
IsRead bool `json:"is_read"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UnreadCountDTO struct {
|
||||
|
||||
@@ -25,6 +25,27 @@ func (h *Handler) AdminList(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminCounts(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
principal := Principal{Type: "admin", ID: adminID}
|
||||
result, err := h.service.AdminConversationCounts(
|
||||
c.Request.Context(),
|
||||
principal,
|
||||
c.DefaultQuery("filter", "all"),
|
||||
c.DefaultQuery("stage", "all"),
|
||||
c.Query("keyword"),
|
||||
)
|
||||
if err != nil {
|
||||
writeChatError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminDetail(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -195,7 +195,7 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
|
||||
if handoffSupportID > 0 {
|
||||
message += ",卖号组客服已接入"
|
||||
}
|
||||
return sendSystemMessage(tx, conv.ID, message)
|
||||
return sendSystemMessageWithAttention(tx, conv.ID, message, "order_paid")
|
||||
}
|
||||
|
||||
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
|
||||
@@ -248,13 +248,18 @@ func getListingGroupWelcomeMessage(tx *gorm.DB) string {
|
||||
}
|
||||
|
||||
func sendSystemMessage(tx *gorm.DB, conversationID uint64, content string) error {
|
||||
return sendSystemMessageWithAttention(tx, conversationID, content, "")
|
||||
}
|
||||
|
||||
func sendSystemMessageWithAttention(tx *gorm.DB, conversationID uint64, content, attentionType string) error {
|
||||
message := model.ChatMessage{
|
||||
ConversationID: conversationID,
|
||||
SenderType: "system",
|
||||
SenderRole: "system",
|
||||
ContentType: "system",
|
||||
Content: content,
|
||||
AttachmentURLS: emptyJSONList(),
|
||||
ConversationID: conversationID,
|
||||
SenderType: "system",
|
||||
SenderRole: "system",
|
||||
ContentType: "system",
|
||||
Content: content,
|
||||
AttachmentURLS: emptyJSONList(),
|
||||
AdminAttentionType: attentionType,
|
||||
}
|
||||
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
|
||||
@@ -104,6 +104,9 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
||||
Content: req.Content,
|
||||
AttachmentURLS: encodeStringList(req.AttachmentURLS),
|
||||
}
|
||||
if principal.Type == "user" {
|
||||
message.AdminAttentionType = "user_inquiry"
|
||||
}
|
||||
if err := tx.Create(&message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,16 +149,17 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
||||
Type: "new_message",
|
||||
ConversationID: conversationID,
|
||||
Message: &chathub.MessageData{
|
||||
ID: msg.ID,
|
||||
ConversationID: msg.ConversationID,
|
||||
SenderType: msg.SenderType,
|
||||
SenderID: msg.SenderID,
|
||||
SenderRole: msg.SenderRole,
|
||||
SenderName: msg.SenderName,
|
||||
ContentType: msg.ContentType,
|
||||
Content: msg.Content,
|
||||
AttachmentURLS: msg.AttachmentURLS,
|
||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||
ID: msg.ID,
|
||||
ConversationID: msg.ConversationID,
|
||||
SenderType: msg.SenderType,
|
||||
SenderID: msg.SenderID,
|
||||
SenderRole: msg.SenderRole,
|
||||
SenderName: msg.SenderName,
|
||||
ContentType: msg.ContentType,
|
||||
Content: msg.Content,
|
||||
AttachmentURLS: msg.AttachmentURLS,
|
||||
AdminAttentionType: msg.AdminAttentionType,
|
||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
r.hub.NotifyConversation(conversationID, event)
|
||||
|
||||
@@ -45,9 +45,9 @@ func (r *Repository) conversationQuery(ctx context.Context, principal Principal)
|
||||
SELECT COUNT(1)
|
||||
FROM chat_messages AS cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.sender_type <> 'system'
|
||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
|
||||
AND cm.sender_type <> 'system'
|
||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||
AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at)
|
||||
) AS unread_count`, principal.Type, principal.ID).
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||
Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID)
|
||||
@@ -184,11 +184,12 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
||||
SenderAvatar: avatar,
|
||||
IsSelf: isSelf,
|
||||
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
|
||||
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
|
||||
ContentType: row.ContentType,
|
||||
Content: row.Content,
|
||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||
CreatedAt: row.CreatedAt,
|
||||
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
|
||||
ContentType: row.ContentType,
|
||||
Content: row.Content,
|
||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||
AdminAttentionType: row.AdminAttentionType,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -155,6 +155,13 @@ func (s *Service) ListConversationsWithFilter(ctx context.Context, principal Pri
|
||||
return s.repo.ListConversationsWithFilter(ctx, principal, page, pageSize, filter, stage, keyword)
|
||||
}
|
||||
|
||||
func (s *Service) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.AdminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRemark(ctx context.Context, principal Principal, conversationID uint64, req UpdateRemarkRequest) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
|
||||
@@ -221,7 +221,7 @@ func (r *Repository) ListConversationsWithFilter(ctx context.Context, principal
|
||||
}
|
||||
|
||||
func (r *Repository) listAdminConversations(ctx context.Context, principal Principal, page, pageSize int, filter string, stage string, keyword string) (*PaginatedResult, error) {
|
||||
counts, err := r.adminConversationCounts(ctx, principal, filter, stage, keyword)
|
||||
total, err := r.adminConversationTotal(ctx, principal, filter, stage, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,10 +243,9 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
SELECT COUNT(1)
|
||||
FROM chat_messages AS cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.sender_type <> 'system'
|
||||
AND NOT (cm.sender_type = ? AND cm.sender_id = ?)
|
||||
AND cm.id > COALESCE(cas.last_read_message_id, 0)
|
||||
) AS unread_count`, principal.Type, principal.ID)
|
||||
AND (cm.admin_attention_type <> '' OR cm.sender_type = 'user')
|
||||
AND cm.id > COALESCE(cas.last_read_message_id, 0)
|
||||
) AS unread_count`)
|
||||
applyAdminChatOwnershipFilter(queryDB, filter, principal)
|
||||
applyAdminChatStageFilter(queryDB, stage, principal)
|
||||
if err := queryDB.
|
||||
@@ -269,7 +268,26 @@ func (r *Repository) listAdminConversations(ctx context.Context, principal Princ
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO(participantsByConversation[row.ID]))
|
||||
}
|
||||
return &PaginatedResult{Items: items, Total: counts.Total, Page: page, PageSize: pageSize, Counts: counts.DTO}, nil
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdminConversationCounts(ctx context.Context, principal Principal, filter string, stage string, keyword string) (*AdminConversationCountsDTO, error) {
|
||||
result, err := r.adminConversationCounts(ctx, principal, normalizeAdminChatFilter(filter), normalizeAdminChatStage(stage), strings.TrimSpace(keyword))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.DTO, nil
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationTotal(ctx context.Context, principal Principal, filter string, stage string, keyword string) (int64, error) {
|
||||
db := r.adminConversationBase(ctx, principal, strings.TrimSpace(keyword))
|
||||
applyAdminChatOwnershipFilter(db, normalizeAdminChatFilter(filter), principal)
|
||||
applyAdminChatStageFilter(db, normalizeAdminChatStage(stage), principal)
|
||||
var total int64
|
||||
if err := db.Select("COUNT(DISTINCT c.id)").Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) adminConversationBase(ctx context.Context, principal Principal, keyword string) *gorm.DB {
|
||||
@@ -396,17 +414,13 @@ func adminChatStageCondition(stage string, principal Principal) (string, []inter
|
||||
latestRefundStatus := "COALESCE(explicit_lo.refund_status, listing_lo.refund_status)"
|
||||
switch stage {
|
||||
case adminChatStagePending:
|
||||
return `(
|
||||
lm.sender_type = 'user'
|
||||
OR EXISTS (
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM chat_messages AS cm_pending
|
||||
WHERE cm_pending.conversation_id = c.id
|
||||
AND cm_pending.sender_type <> 'system'
|
||||
AND NOT (cm_pending.sender_type = 'admin' AND cm_pending.sender_id = ?)
|
||||
AND cm_pending.id > COALESCE(cas.last_read_message_id, 0)
|
||||
)
|
||||
)`, []interface{}{principal.ID}
|
||||
AND (cm_pending.admin_attention_type <> '' OR cm_pending.sender_type = 'user')
|
||||
AND cm_pending.id > COALESCE(cas.last_read_message_id, 0)
|
||||
)`, nil
|
||||
case adminChatStageUnjoined:
|
||||
return latestID + " IS NULL", nil
|
||||
case adminChatStageHandoff:
|
||||
|
||||
@@ -20,16 +20,17 @@ type ChatEvent struct {
|
||||
|
||||
// MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
|
||||
type MessageData struct {
|
||||
ID uint64 `json:"id"`
|
||||
ConversationID uint64 `json:"conversation_id"`
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID uint64 `json:"sender_id"`
|
||||
SenderRole string `json:"sender_role"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uint64 `json:"id"`
|
||||
ConversationID uint64 `json:"conversation_id"`
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID uint64 `json:"sender_id"`
|
||||
SenderRole string `json:"sender_role"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content string `json:"content"`
|
||||
AttachmentURLS []string `json:"attachment_urls"`
|
||||
AdminAttentionType string `json:"admin_attention_type,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// principal 标识一个连接方。
|
||||
|
||||
@@ -726,6 +726,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/chats/events", requirePerm("chat:view"), chatHubHandler.AdminEvents)
|
||||
}
|
||||
adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList)
|
||||
adminRoutes.GET("/chats/counts", requirePerm("chat:view"), chatHandler.AdminCounts)
|
||||
adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail)
|
||||
adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages)
|
||||
adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend)
|
||||
|
||||
Reference in New Issue
Block a user